summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGrzegorz Bizon <grzesiek.bizon@gmail.com>2018-03-07 09:59:51 +0100
committerGrzegorz Bizon <grzesiek.bizon@gmail.com>2018-03-07 09:59:51 +0100
commita2a8e36178853b5f8fa2eda306b33f8f97970745 (patch)
tree0f8cf46e5c2968bcaa5a42c61f46338f1b921b0b /app
parente85e1dbb57af835945b37dc03d1f850cbdaf4d82 (diff)
parent95016507d49c3099afde0ef3909377bf70061dc3 (diff)
downloadgitlab-ce-a2a8e36178853b5f8fa2eda306b33f8f97970745.tar.gz
Merge branch 'master' into backstage/gb/refactor-ci-cd-variables-collections
* master: (6164 commits)
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/icons.json2
-rw-r--r--app/assets/images/icons.svg2
-rw-r--r--app/assets/images/illustrations/cluster_popover.svg1
-rw-r--r--app/assets/images/illustrations/clusters_empty.svg1
-rw-r--r--app/assets/images/illustrations/convdev/convdev_no_data.svg1
-rw-r--r--app/assets/images/illustrations/convdev/convdev_no_index.svg1
-rw-r--r--app/assets/images/illustrations/convdev/convdev_overview.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_1.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_10.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_2.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_3.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_4.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_5.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_6.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_7.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_8.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_9.svg1
-rw-r--r--app/assets/images/illustrations/epics.svg1
-rw-r--r--app/assets/images/illustrations/gitlab_logo.svg1
-rw-r--r--app/assets/images/illustrations/image_comment_light_cursor.svg1
-rw-r--r--app/assets/images/illustrations/image_comment_light_cursor@2x.svg1
-rw-r--r--app/assets/images/illustrations/job_not_triggered.svg1
-rw-r--r--app/assets/images/illustrations/logos/go_logo.svg1
-rw-r--r--app/assets/images/illustrations/logos/mattermost_logo.svg1
-rw-r--r--app/assets/images/illustrations/manual_action.svg1
-rw-r--r--app/assets/images/illustrations/merge_request_changes_empty.svg1
-rw-r--r--app/assets/images/illustrations/monitoring/getting_started.svg2
-rw-r--r--app/assets/images/illustrations/monitoring/loading.svg2
-rw-r--r--app/assets/images/illustrations/monitoring/unable_to_connect.svg2
-rw-r--r--app/assets/images/illustrations/multi-editor_all_changes_committed_empty.svg1
-rw-r--r--app/assets/images/illustrations/multi-editor_no_changes_empty.svg1
-rw-r--r--app/assets/images/illustrations/multi-editor_no_staged_files_empty.svg1
-rw-r--r--app/assets/images/illustrations/multi_file_editor_empty.svg1
-rw-r--r--app/assets/images/illustrations/no_commits.svg1
-rw-r--r--app/assets/images/illustrations/pending_job_empty.svg1
-rw-r--r--app/assets/images/illustrations/pipelines_pending.svg1
-rw-r--r--app/assets/images/illustrations/service_desk_callout.svg1
-rw-r--r--app/assets/images/illustrations/service_desk_empty.svg1
-rw-r--r--app/assets/images/illustrations/slack_logo.svg1
-rw-r--r--app/assets/images/illustrations/welcome/add_new_group.svg1
-rw-r--r--app/assets/images/illustrations/welcome/add_new_project.svg1
-rw-r--r--app/assets/images/illustrations/welcome/add_new_user.svg1
-rw-r--r--app/assets/images/illustrations/welcome/configure_server.svg1
-rw-r--r--app/assets/images/illustrations/welcome/ee_trial.svg1
-rw-r--r--app/assets/images/illustrations/welcome/globe.svg1
-rw-r--r--app/assets/images/illustrations/welcome/lightbulb.svg1
-rw-r--r--app/assets/images/illustrations/wiki-fro-logged-out-users.svg1
-rw-r--r--app/assets/images/illustrations/wiki_login_empty.svg1
-rw-r--r--app/assets/images/illustrations/wiki_logout_empty.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.pngbin14322 -> 0 bytes
-rw-r--r--app/assets/images/old_nav.pngbin25617 -> 0 bytes
-rw-r--r--app/assets/images/sprite.symbol.html3297
-rw-r--r--app/assets/javascripts/activities.js10
-rw-r--r--app/assets/javascripts/admin.js62
-rw-r--r--app/assets/javascripts/ajax_loading_spinner.js5
-rw-r--r--app/assets/javascripts/api.js152
-rw-r--r--app/assets/javascripts/aside.js24
-rw-r--r--app/assets/javascripts/autosave.js61
-rw-r--r--app/assets/javascripts/awards_handler.js78
-rw-r--r--app/assets/javascripts/behaviors/autosize.js6
-rw-r--r--app/assets/javascripts/behaviors/copy_as_gfm.js (renamed from app/assets/javascripts/copy_as_gfm.js)98
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js73
-rw-r--r--app/assets/javascripts/behaviors/index.js5
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js2
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js2
-rw-r--r--app/assets/javascripts/behaviors/secret_values.js47
-rw-r--r--app/assets/javascripts/behaviors/toggler_behavior.js7
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js9
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js9
-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/notebook/index.js84
-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/viewer/index.js57
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js15
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js15
-rw-r--r--app/assets/javascripts/boards/components/board.js4
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.js2
-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_list.vue (renamed from app/assets/javascripts/boards/components/board_list.js)190
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue (renamed from app/assets/javascripts/boards/components/board_new_issue.js)83
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js27
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.js14
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.js10
-rw-r--r--app/assets/javascripts/boards/components/modal/index.js2
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js28
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue127
-rw-r--r--app/assets/javascripts/boards/components/sidebar/remove_issue.js15
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js17
-rw-r--r--app/assets/javascripts/boards/index.js (renamed from app/assets/javascripts/boards/boards_bundle.js)105
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js13
-rw-r--r--app/assets/javascripts/boards/models/issue.js30
-rw-r--r--app/assets/javascripts/boards/models/list.js6
-rw-r--r--app/assets/javascripts/boards/models/project.js6
-rw-r--r--app/assets/javascripts/boards/services/board_service.js105
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js10
-rw-r--r--app/assets/javascripts/boards/utils/query_data.js2
-rw-r--r--app/assets/javascripts/broadcast_message.js33
-rw-r--r--app/assets/javascripts/build_artifacts.js50
-rw-r--r--app/assets/javascripts/build_variables.js16
-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.js26
-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/commit.js12
-rw-r--r--app/assets/javascripts/commit/file.js14
-rw-r--r--app/assets/javascripts/commit/image_file.js400
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js4
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue55
-rw-r--r--app/assets/javascripts/commit_merge_requests.js73
-rw-r--r--app/assets/javascripts/commits.js114
-rw-r--r--app/assets/javascripts/commons/bootstrap.js4
-rw-r--r--app/assets/javascripts/commons/index.js1
-rw-r--r--app/assets/javascripts/commons/jquery.js2
-rw-r--r--app/assets/javascripts/commons/polyfills.js3
-rw-r--r--app/assets/javascripts/commons/polyfills/element.js19
-rw-r--r--app/assets/javascripts/commons/vue.js1
-rw-r--r--app/assets/javascripts/compare.js82
-rw-r--r--app/assets/javascripts/compare_autocomplete.js120
-rw-r--r--app/assets/javascripts/contextual_sidebar.js (renamed from app/assets/javascripts/new_sidebar.js)14
-rw-r--r--app/assets/javascripts/copy_to_clipboard.js74
-rw-r--r--app/assets/javascripts/create_item_dropdown.js119
-rw-r--r--app/assets/javascripts/create_label.js30
-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.js116
-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.vue41
-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.js53
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js4
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js11
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js3
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js22
-rw-r--r--app/assets/javascripts/diff_notes/models/discussion.js3
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js9
-rw-r--r--app/assets/javascripts/dispatcher.js739
-rw-r--r--app/assets/javascripts/docs/docs_bundle.js10
-rw-r--r--app/assets/javascripts/droplab/drop_down.js34
-rw-r--r--app/assets/javascripts/droplab/hook.js2
-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.js561
-rw-r--r--app/assets/javascripts/due_date_select.js98
-rw-r--r--app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js16
-rw-r--r--app/assets/javascripts/emoji/support/unicode_support_map.js21
-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.vue268
-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.vue923
-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.vue83
-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.vue85
-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.js167
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js7
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight.js65
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_helper.js59
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_options.js12
-rw-r--r--app/assets/javascripts/files_comment_button.js17
-rw-r--r--app/assets/javascripts/filterable_list.js38
-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.js19
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js16
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js21
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js21
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js30
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_bundle.js11
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js15
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js91
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js183
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js5
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_tokenizer.js5
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js67
-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.js23
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js84
-rw-r--r--app/assets/javascripts/gl_dropdown.js91
-rw-r--r--app/assets/javascripts/gl_field_error.js5
-rw-r--r--app/assets/javascripts/gl_field_errors.js36
-rw-r--r--app/assets/javascripts/gl_form.js173
-rw-r--r--app/assets/javascripts/gpg_badges.js17
-rw-r--r--app/assets/javascripts/graphs/graphs_bundle.js4
-rw-r--r--app/assets/javascripts/group_avatar.js31
-rw-r--r--app/assets/javascripts/group_label_subscription.js32
-rw-r--r--app/assets/javascripts/groups/components/app.vue220
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue43
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue263
-rw-r--r--app/assets/javascripts/groups/components/groups.vue63
-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.js200
-rw-r--r--app/assets/javascripts/groups/new_group_child.js63
-rw-r--r--app/assets/javascripts/groups/service/groups_service.js (renamed from app/assets/javascripts/groups/services/groups_service.js)12
-rw-r--r--app/assets/javascripts/groups/store/groups_store.js106
-rw-r--r--app/assets/javascripts/groups/stores/groups_store.js167
-rw-r--r--app/assets/javascripts/groups/transfer_dropdown.js34
-rw-r--r--app/assets/javascripts/groups_select.js193
-rw-r--r--app/assets/javascripts/header.js23
-rw-r--r--app/assets/javascripts/help/help.js12
-rw-r--r--app/assets/javascripts/helpers/user_feature_helper.js7
-rw-r--r--app/assets/javascripts/how_to_merge.js25
-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.js143
-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.js171
-rw-r--r--app/assets/javascripts/init_changes_dropdown.js4
-rw-r--r--app/assets/javascripts/init_issuable_sidebar.js16
-rw-r--r--app/assets/javascripts/init_labels.js18
-rw-r--r--app/assets/javascripts/init_legacy_filters.js13
-rw-r--r--app/assets/javascripts/init_notes.js6
-rw-r--r--app/assets/javascripts/integrations/index.js7
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js52
-rw-r--r--app/assets/javascripts/issuable/auto_width_dropdown_select.js12
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js16
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js14
-rw-r--r--app/assets/javascripts/issuable_context.js93
-rw-r--r--app/assets/javascripts/issuable_form.js237
-rw-r--r--app/assets/javascripts/issuable_index.js193
-rw-r--r--app/assets/javascripts/issue.js135
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue492
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue57
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue10
-rw-r--r--app/assets/javascripts/issue_show/components/edited.vue52
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue24
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue4
-rw-r--r--app/assets/javascripts/issue_show/components/fields/title.vue8
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue47
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue70
-rw-r--r--app/assets/javascripts/issue_show/index.js34
-rw-r--r--app/assets/javascripts/issue_show/services/index.js21
-rw-r--r--app/assets/javascripts/issue_status_select.js57
-rw-r--r--app/assets/javascripts/job.js (renamed from app/assets/javascripts/build.js)195
-rw-r--r--app/assets/javascripts/jobs/components/header.vue45
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_detail_row.vue7
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_details_block.vue60
-rw-r--r--app/assets/javascripts/jobs/job_details_bundle.js18
-rw-r--r--app/assets/javascripts/jobs/job_details_mediator.js14
-rw-r--r--app/assets/javascripts/jobs/services/job_service.js9
-rw-r--r--app/assets/javascripts/label_manager.js205
-rw-r--r--app/assets/javascripts/labels.js75
-rw-r--r--app/assets/javascripts/labels_select.js734
-rw-r--r--app/assets/javascripts/layout_nav.js72
-rw-r--r--app/assets/javascripts/lazy_loader.js15
-rw-r--r--app/assets/javascripts/lib/utils/ajax_cache.js32
-rw-r--r--app/assets/javascripts/lib/utils/axios_utils.js36
-rw-r--r--app/assets/javascripts/lib/utils/cache.js4
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js194
-rw-r--r--app/assets/javascripts/lib/utils/constants.js1
-rw-r--r--app/assets/javascripts/lib/utils/csrf.js4
-rw-r--r--app/assets/javascripts/lib/utils/datefix.js33
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js368
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js2
-rw-r--r--app/assets/javascripts/lib/utils/image_utility.js5
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js28
-rw-r--r--app/assets/javascripts/lib/utils/poll.js12
-rw-r--r--app/assets/javascripts/lib/utils/sticky.js36
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js153
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js263
-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.js285
-rw-r--r--app/assets/javascripts/locale/index.js38
-rw-r--r--app/assets/javascripts/locale/sprintf.js26
-rw-r--r--app/assets/javascripts/logo.js8
-rw-r--r--app/assets/javascripts/main.js356
-rw-r--r--app/assets/javascripts/member_expiration_date.js94
-rw-r--r--app/assets/javascripts/members.js125
-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_conflicts_bundle.js30
-rw-r--r--app/assets/javascripts/merge_request.js256
-rw-r--r--app/assets/javascripts/merge_request_tabs.js649
-rw-r--r--app/assets/javascripts/milestone.js92
-rw-r--r--app/assets/javascripts/milestone_select.js391
-rw-r--r--app/assets/javascripts/mini_pipeline_graph_dropdown.js26
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue158
-rw-r--r--app/assets/javascripts/monitoring/components/empty_state.vue89
-rw-r--r--app/assets/javascripts/monitoring/components/graph.vue257
-rw-r--r--app/assets/javascripts/monitoring/components/graph/deployment.vue86
-rw-r--r--app/assets/javascripts/monitoring/components/graph/flag.vue217
-rw-r--r--app/assets/javascripts/monitoring/components/graph/legend.vue144
-rw-r--r--app/assets/javascripts/monitoring/components/graph/path.vue (renamed from app/assets/javascripts/monitoring/components/graph_path.vue)21
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue34
-rw-r--r--app/assets/javascripts/monitoring/mixins/monitoring_mixins.js27
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js26
-rw-r--r--app/assets/javascripts/monitoring/services/monitoring_service.js16
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js2
-rw-r--r--app/assets/javascripts/monitoring/utils/date_time_formatters.js53
-rw-r--r--app/assets/javascripts/monitoring/utils/measurements.js8
-rw-r--r--app/assets/javascripts/monitoring/utils/multiple_time_series.js76
-rw-r--r--app/assets/javascripts/mr_notes/index.js41
-rw-r--r--app/assets/javascripts/namespace_select.js134
-rw-r--r--app/assets/javascripts/network/branch_graph.js16
-rw-r--r--app/assets/javascripts/network/network_bundle.js17
-rw-r--r--app/assets/javascripts/new_branch_form.js168
-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.js402
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue (renamed from app/assets/javascripts/notes/components/issue_comment_form.vue)245
-rw-r--r--app/assets/javascripts/notes/components/diff_file_header.vue92
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue96
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue119
-rw-r--r--app/assets/javascripts/notes/components/discussion_locked_widget.vue28
-rw-r--r--app/assets/javascripts/notes/components/issue_discussion.vue232
-rw-r--r--app/assets/javascripts/notes/components/issue_placeholder_system_note.vue21
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue (renamed from app/assets/javascripts/notes/components/issue_note_actions.vue)140
-rw-r--r--app/assets/javascripts/notes/components/note_attachment.vue (renamed from app/assets/javascripts/notes/components/issue_note_attachment.vue)10
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue (renamed from app/assets/javascripts/notes/components/issue_note_awards_list.vue)21
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue (renamed from app/assets/javascripts/notes/components/issue_note_body.vue)82
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue (renamed from app/assets/javascripts/notes/components/issue_note_edited_text.vue)15
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue (renamed from app/assets/javascripts/notes/components/issue_note_form.vue)113
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue (renamed from app/assets/javascripts/notes/components/issue_note_header.vue)44
-rw-r--r--app/assets/javascripts/notes/components/note_signed_out_widget.vue (renamed from app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue)1
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue346
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue (renamed from app/assets/javascripts/notes/components/issue_note.vue)78
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue (renamed from app/assets/javascripts/notes/components/issue_notes_app.vue)130
-rw-r--r--app/assets/javascripts/notes/constants.js6
-rw-r--r--app/assets/javascripts/notes/index.js30
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js8
-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.js50
-rw-r--r--app/assets/javascripts/notes/services/notes_service.js (renamed from app/assets/javascripts/notes/services/issue_notes_service.js)10
-rw-r--r--app/assets/javascripts/notes/stores/actions.js58
-rw-r--r--app/assets/javascripts/notes/stores/getters.js39
-rw-r--r--app/assets/javascripts/notes/stores/index.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js7
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js55
-rw-r--r--app/assets/javascripts/notes/stores/utils.js1
-rw-r--r--app/assets/javascripts/notifications_dropdown.js50
-rw-r--r--app/assets/javascripts/notifications_form.js95
-rw-r--r--app/assets/javascripts/pager.js131
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js (renamed from app/assets/javascripts/abuse_reports.js)21
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/admin.js59
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js35
-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.js37
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue174
-rw-r--r--app/assets/javascripts/pages/admin/users/index.js43
-rw-r--r--app/assets/javascripts/pages/ci/lints/ci_lint_editor.js (renamed from app/assets/javascripts/ci_lint_editor.js)14
-rw-r--r--app/assets/javascripts/pages/ci/lints/create/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.js (renamed from app/assets/javascripts/todos.js)45
-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.js7
-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.js7
-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/event_hub.js3
-rw-r--r--app/assets/javascripts/pages/milestones/shared/index.js88
-rw-r--r--app/assets/javascripts/pages/milestones/shared/init_milestones_show.js11
-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.js16
-rw-r--r--app/assets/javascripts/pages/profiles/index/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.js (renamed from app/assets/javascripts/two_factor_auth.js)3
-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.js7
-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.js5
-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.js10
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js24
-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.js14
-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.js12
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js (renamed from app/assets/javascripts/graphs/graphs_charts.js)2
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/index.js (renamed from app/assets/javascripts/graphs/graphs_show.js)16
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js (renamed from app/assets/javascripts/graphs/stat_graph_contributors.js)43
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js (renamed from app/assets/javascripts/graphs/stat_graph_contributors_graph.js)57
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js (renamed from app/assets/javascripts/graphs/stat_graph_contributors_util.js)0
-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.js16
-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/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/labels/index/index.js3
-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.js19
-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.js (renamed from app/assets/javascripts/network/network.js)2
-rw-r--r--app/assets/javascripts/pages/projects/network/show/index.js16
-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.js (renamed from app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js)2
-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.vue (renamed from app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue)50
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue (renamed from app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue)22
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js (renamed from app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js)0
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js (renamed from app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js)0
-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.js (renamed from app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js)16
-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.js56
-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.js5
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/project.js134
-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.js3
-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.js (renamed from app/assets/javascripts/projects/permissions/constants.js)0
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/external.js (renamed from app/assets/javascripts/projects/permissions/external.js)0
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/index.js (renamed from app/assets/javascripts/projects/permissions/index.js)0
-rw-r--r--app/assets/javascripts/pages/projects/shared/project_avatar.js13
-rw-r--r--app/assets/javascripts/pages/projects/shared/project_new.js151
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js27
-rw-r--r--app/assets/javascripts/pages/projects/snippets/edit/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/snippets/new/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/snippets/show/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/tags/new/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/tree/show/index.js38
-rw-r--r--app/assets/javascripts/pages/projects/wikis/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/wikis/wikis.js (renamed from app/assets/javascripts/wikis.js)5
-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.js115
-rw-r--r--app/assets/javascripts/pages/sessions/index.js3
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js11
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.js (renamed from app/assets/javascripts/oauth_remember_me.js)0
-rw-r--r--app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js (renamed from app/assets/javascripts/signin_tabs_memoizer.js)8
-rw-r--r--app/assets/javascripts/pages/sessions/new/username_validator.js (renamed from app/assets/javascripts/username_validator.js)12
-rw-r--r--app/assets/javascripts/pages/snippets/edit/index.js7
-rw-r--r--app/assets/javascripts/pages/snippets/form.js7
-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.js (renamed from app/assets/javascripts/users/activity_calendar.js)37
-rw-r--r--app/assets/javascripts/pages/users/index.js (renamed from app/assets/javascripts/users/index.js)10
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js (renamed from app/assets/javascripts/users/user_tabs.js)39
-rw-r--r--app/assets/javascripts/pdf/index.vue36
-rw-r--r--app/assets/javascripts/pdf/page/index.vue44
-rw-r--r--app/assets/javascripts/performance_bar.js3
-rw-r--r--app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js73
-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.vue70
-rw-r--r--app/assets/javascripts/pipelines/components/error_state.vue26
-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.vue38
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue18
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_component.vue77
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue12
-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.vue68
-rw-r--r--app/assets/javascripts/pipelines/components/navigation_tabs.vue90
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue39
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines.vue397
-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.vue93
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue445
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue243
-rw-r--r--app/assets/javascripts/pipelines/components/time_ago.vue33
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines.js28
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js27
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediator.js (renamed from app/assets/javascripts/pipelines/pipeline_details_mediatior.js)6
-rw-r--r--app/assets/javascripts/pipelines/pipelines_bundle.js24
-rw-r--r--app/assets/javascripts/pipelines/pipelines_charts.js38
-rw-r--r--app/assets/javascripts/pipelines/pipelines_times.js27
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js1
-rw-r--r--app/assets/javascripts/preview_markdown.js362
-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.js3
-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.js139
-rw-r--r--app/assets/javascripts/project_avatar.js20
-rw-r--r--app/assets/javascripts/project_find_file.js291
-rw-r--r--app/assets/javascripts/project_fork.js18
-rw-r--r--app/assets/javascripts/project_import.js17
-rw-r--r--app/assets/javascripts/project_label_subscription.js78
-rw-r--r--app/assets/javascripts/project_new.js159
-rw-r--r--app/assets/javascripts/project_select.js130
-rw-r--r--app/assets/javascripts/project_show.js11
-rw-r--r--app/assets/javascripts/project_variables.js43
-rw-r--r--app/assets/javascripts/projects/permissions/components/project_feature_setting.vue104
-rw-r--r--app/assets/javascripts/projects/permissions/components/project_feature_toggle.vue51
-rw-r--r--app/assets/javascripts/projects/permissions/components/project_setting_row.vue36
-rw-r--r--app/assets/javascripts/projects/permissions/components/settings_panel.vue312
-rw-r--r--app/assets/javascripts/projects/project_import_gitlab_project.js12
-rw-r--r--app/assets/javascripts/projects/project_new.js80
-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.vue32
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue50
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_item.vue116
-rw-r--r--app/assets/javascripts/projects_dropdown/components/search.vue81
-rw-r--r--app/assets/javascripts/projects_dropdown/index.js7
-rw-r--r--app/assets/javascripts/projects_dropdown/service/projects_service.js3
-rw-r--r--app/assets/javascripts/prometheus_metrics/index.js6
-rw-r--r--app/assets/javascripts/prometheus_metrics/prometheus_metrics.js15
-rw-r--r--app/assets/javascripts/protected_branches/index.js9
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js63
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_dropdown.js90
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js41
-rw-r--r--app/assets/javascripts/protected_tags/index.js9
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_create.js13
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js88
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.js32
-rw-r--r--app/assets/javascripts/ref_select_dropdown.js4
-rw-r--r--app/assets/javascripts/registry/components/app.vue62
-rw-r--r--app/assets/javascripts/registry/components/collapsible_container.vue134
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue144
-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.js21
-rw-r--r--app/assets/javascripts/render_math.js71
-rw-r--r--app/assets/javascripts/render_mermaid.js57
-rw-r--r--app/assets/javascripts/repo/components/repo.vue70
-rw-r--r--app/assets/javascripts/repo/components/repo_commit_section.vue132
-rw-r--r--app/assets/javascripts/repo/components/repo_edit_button.vue58
-rw-r--r--app/assets/javascripts/repo/components/repo_editor.vue117
-rw-r--r--app/assets/javascripts/repo/components/repo_file.vue107
-rw-r--r--app/assets/javascripts/repo/components/repo_file_buttons.vue69
-rw-r--r--app/assets/javascripts/repo/components/repo_file_options.vue25
-rw-r--r--app/assets/javascripts/repo/components/repo_loading_file.vue76
-rw-r--r--app/assets/javascripts/repo/components/repo_prev_directory.vue38
-rw-r--r--app/assets/javascripts/repo/components/repo_preview.vue52
-rw-r--r--app/assets/javascripts/repo/components/repo_sidebar.vue103
-rw-r--r--app/assets/javascripts/repo/components/repo_tab.vue63
-rw-r--r--app/assets/javascripts/repo/components/repo_tabs.vue36
-rw-r--r--app/assets/javascripts/repo/helpers/monaco_loader_helper.js25
-rw-r--r--app/assets/javascripts/repo/helpers/repo_helper.js271
-rw-r--r--app/assets/javascripts/repo/index.js78
-rw-r--r--app/assets/javascripts/repo/mixins/repo_mixin.js17
-rw-r--r--app/assets/javascripts/repo/monaco_loader.js11
-rw-r--r--app/assets/javascripts/repo/services/repo_service.js82
-rw-r--r--app/assets/javascripts/repo/stores/repo_store.js199
-rw-r--r--app/assets/javascripts/right_sidebar.js431
-rw-r--r--app/assets/javascripts/search.js118
-rw-r--r--app/assets/javascripts/search_autocomplete.js779
-rw-r--r--app/assets/javascripts/settings_panels.js45
-rw-r--r--app/assets/javascripts/shared/milestones/form.js9
-rw-r--r--app/assets/javascripts/shared/sessions/u2f.js16
-rw-r--r--app/assets/javascripts/shortcuts.js233
-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.js162
-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.js2
-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.js33
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue116
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form.vue59
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue8
-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/comparison_pane.js4
-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.js4
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue (renamed from app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js)149
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js8
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js27
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js145
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js5
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js49
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js53
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js66
-rw-r--r--app/assets/javascripts/single_file_diff.js36
-rw-r--r--app/assets/javascripts/smart_interval.js29
-rw-r--r--app/assets/javascripts/snippet/snippet_bundle.js13
-rw-r--r--app/assets/javascripts/sortable/sortable_config.js7
-rw-r--r--app/assets/javascripts/star.js45
-rw-r--r--app/assets/javascripts/subscription.js45
-rw-r--r--app/assets/javascripts/subscription_select.js49
-rw-r--r--app/assets/javascripts/syntax_highlight.js14
-rw-r--r--app/assets/javascripts/task_list.js21
-rw-r--r--app/assets/javascripts/templates/issuable_template_selector.js102
-rw-r--r--app/assets/javascripts/templates/issuable_template_selectors.js49
-rw-r--r--app/assets/javascripts/terminal/index.js (renamed from app/assets/javascripts/terminal/terminal_bundle.js)2
-rw-r--r--app/assets/javascripts/test.js1
-rw-r--r--app/assets/javascripts/test_utils/index.js2
-rw-r--r--app/assets/javascripts/test_utils/simulate_input.js23
-rw-r--r--app/assets/javascripts/toggle_buttons.js61
-rw-r--r--app/assets/javascripts/tree.js5
-rw-r--r--app/assets/javascripts/u2f/authenticate.js190
-rw-r--r--app/assets/javascripts/u2f/error.js43
-rw-r--r--app/assets/javascripts/u2f/register.js154
-rw-r--r--app/assets/javascripts/u2f/util.js53
-rw-r--r--app/assets/javascripts/ui_development_kit.js4
-rw-r--r--app/assets/javascripts/usage_ping.js12
-rw-r--r--app/assets/javascripts/user_callout.js2
-rw-r--r--app/assets/javascripts/users_select.js197
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js28
-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.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js113
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue145
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js8
-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.js90
-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.js37
-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.js36
-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/states/mr_widget_archived.js26
-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.js47
-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.js18
-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.js35
-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.js37
-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.js78
-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_merge_when_pipeline_succeeds.js117
-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.js140
-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.js29
-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.js43
-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.js19
-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_pipeline_blocked.js18
-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.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js97
-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.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/dependencies.js33
-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.js58
-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.js61
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js20
-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/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.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue88
-rw-r--r--app/assets/javascripts/vue_shared/components/expand_button.vue46
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue92
-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.vue183
-rw-r--r--app/assets/javascripts/vue_shared/components/icon.vue85
-rw-r--r--app/assets/javascripts/vue_shared/components/identicon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue16
-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.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue129
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue174
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue63
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/memory_graph.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/modal.vue173
-rw-r--r--app/assets/javascripts/vue_shared/components/navigation_tabs.vue75
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue (renamed from app/assets/javascripts/notes/components/issue_placeholder_note.vue)35
-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.vue (renamed from app/assets/javascripts/notes/components/issue_system_note.vue)35
-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/popup_dialog.vue85
-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.vue149
-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.vue84
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue34
-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/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.js (renamed from app/assets/javascripts/boards/models/label.js)4
-rw-r--r--app/assets/javascripts/vue_shared/translate.js2
-rw-r--r--app/assets/javascripts/zen_mode.js13
-rw-r--r--app/assets/stylesheets/framework.scss22
-rw-r--r--app/assets/stylesheets/framework/animations.scss12
-rw-r--r--app/assets/stylesheets/framework/avatar.scss11
-rw-r--r--app/assets/stylesheets/framework/awards.scss16
-rw-r--r--app/assets/stylesheets/framework/banner.scss25
-rw-r--r--app/assets/stylesheets/framework/blank.scss83
-rw-r--r--app/assets/stylesheets/framework/blocks.scss42
-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.scss140
-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.scss161
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss (renamed from app/assets/stylesheets/new_sidebar.scss)176
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss376
-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/feature_highlight.scss103
-rw-r--r--app/assets/stylesheets/framework/files.scss106
-rw-r--r--app/assets/stylesheets/framework/filters.scss88
-rw-r--r--app/assets/stylesheets/framework/flash.scss12
-rw-r--r--app/assets/stylesheets/framework/forms.scss3
-rw-r--r--app/assets/stylesheets/framework/gfm.scss38
-rw-r--r--app/assets/stylesheets/framework/gitlab_theme.scss (renamed from app/assets/stylesheets/framework/gitlab-theme.scss)41
-rw-r--r--app/assets/stylesheets/framework/header.scss606
-rw-r--r--app/assets/stylesheets/framework/highlight.scss4
-rw-r--r--app/assets/stylesheets/framework/icons.scss20
-rw-r--r--app/assets/stylesheets/framework/images.scss15
-rw-r--r--app/assets/stylesheets/framework/issue_box.scss6
-rw-r--r--app/assets/stylesheets/framework/layout.scss57
-rw-r--r--app/assets/stylesheets/framework/lists.scss170
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss102
-rw-r--r--app/assets/stylesheets/framework/mixins.scss74
-rw-r--r--app/assets/stylesheets/framework/mobile.scss21
-rw-r--r--app/assets/stylesheets/framework/modal.scss72
-rw-r--r--app/assets/stylesheets/framework/page_header.scss (renamed from app/assets/stylesheets/framework/page-header.scss)0
-rw-r--r--app/assets/stylesheets/framework/popup.scss15
-rw-r--r--app/assets/stylesheets/framework/responsive_tables.scss (renamed from app/assets/stylesheets/framework/responsive-tables.scss)96
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss (renamed from app/assets/stylesheets/framework/nav.scss)380
-rw-r--r--app/assets/stylesheets/framework/selects.scss210
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss44
-rw-r--r--app/assets/stylesheets/framework/stacked_progress_bar.scss54
-rw-r--r--app/assets/stylesheets/framework/tables.scss2
-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.scss27
-rw-r--r--app/assets/stylesheets/framework/tw_bootstrap_variables.scss35
-rw-r--r--app/assets/stylesheets/framework/typography.scss14
-rw-r--r--app/assets/stylesheets/framework/variables.scss166
-rw-r--r--app/assets/stylesheets/framework/vue_transitions.scss9
-rw-r--r--app/assets/stylesheets/framework/wells.scss40
-rw-r--r--app/assets/stylesheets/framework/zen.scss2
-rw-r--r--app/assets/stylesheets/highlight/white.scss26
-rw-r--r--app/assets/stylesheets/mailers/highlighted_diff_email.scss26
-rw-r--r--app/assets/stylesheets/new_nav.scss472
-rw-r--r--app/assets/stylesheets/pages/boards.scss42
-rw-r--r--app/assets/stylesheets/pages/builds.scss61
-rw-r--r--app/assets/stylesheets/pages/ci_projects.scss2
-rw-r--r--app/assets/stylesheets/pages/clusters.scss28
-rw-r--r--app/assets/stylesheets/pages/commits.scss27
-rw-r--r--app/assets/stylesheets/pages/container_registry.scss8
-rw-r--r--app/assets/stylesheets/pages/convdev_index.scss6
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss61
-rw-r--r--app/assets/stylesheets/pages/detail_page.scss34
-rw-r--r--app/assets/stylesheets/pages/diff.scss261
-rw-r--r--app/assets/stylesheets/pages/editor.scss10
-rw-r--r--app/assets/stylesheets/pages/environments.scss213
-rw-r--r--app/assets/stylesheets/pages/events.scss14
-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.scss208
-rw-r--r--app/assets/stylesheets/pages/issues.scss88
-rw-r--r--app/assets/stylesheets/pages/labels.scss52
-rw-r--r--app/assets/stylesheets/pages/login.scss106
-rw-r--r--app/assets/stylesheets/pages/members.scss47
-rw-r--r--app/assets/stylesheets/pages/merge_conflicts.scss4
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss68
-rw-r--r--app/assets/stylesheets/pages/milestone.scss40
-rw-r--r--app/assets/stylesheets/pages/note_form.scss89
-rw-r--r--app/assets/stylesheets/pages/notes.scss249
-rw-r--r--app/assets/stylesheets/pages/notifications.scss4
-rw-r--r--app/assets/stylesheets/pages/pipeline_schedules.scss85
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss252
-rw-r--r--app/assets/stylesheets/pages/profile.scss19
-rw-r--r--app/assets/stylesheets/pages/profiles/preferences.scss16
-rw-r--r--app/assets/stylesheets/pages/projects.scss436
-rw-r--r--app/assets/stylesheets/pages/repo.scss772
-rw-r--r--app/assets/stylesheets/pages/runners.scss7
-rw-r--r--app/assets/stylesheets/pages/search.scss110
-rw-r--r--app/assets/stylesheets/pages/settings.scss55
-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.scss10
-rw-r--r--app/assets/stylesheets/pages/todos.scss7
-rw-r--r--app/assets/stylesheets/pages/tree.scss32
-rw-r--r--app/assets/stylesheets/pages/wiki.scss20
-rw-r--r--app/assets/stylesheets/pages/xterm.scss29
-rw-r--r--app/assets/stylesheets/test.scss11
-rw-r--r--app/controllers/admin/appearances_controller.rb6
-rw-r--r--app/controllers/admin/application_controller.rb14
-rw-r--r--app/controllers/admin/applications_controller.rb5
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb5
-rw-r--r--app/controllers/admin/cohorts_controller.rb2
-rw-r--r--app/controllers/admin/deploy_keys_controller.rb4
-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.rb2
-rw-r--r--app/controllers/admin/hooks_controller.rb6
-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/projects_controller.rb11
-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.rb6
-rw-r--r--app/controllers/application_controller.rb99
-rw-r--r--app/controllers/autocomplete_controller.rb6
-rw-r--r--app/controllers/boards/issues_controller.rb26
-rw-r--r--app/controllers/ci/lints_controller.rb5
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb2
-rw-r--r--app/controllers/concerns/boards_responses.rb48
-rw-r--r--app/controllers/concerns/controller_with_cross_project_access_check.rb24
-rw-r--r--app/controllers/concerns/creates_commit.rb26
-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/issuable_actions.rb104
-rw-r--r--app/controllers/concerns/issuable_collections.rb132
-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.rb64
-rw-r--r--app/controllers/concerns/merge_requests_action.rb15
-rw-r--r--app/controllers/concerns/milestone_actions.rb8
-rw-r--r--app/controllers/concerns/notes_actions.rb68
-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_commits.rb2
-rw-r--r--app/controllers/concerns/renders_member_access.rb23
-rw-r--r--app/controllers/concerns/renders_notes.rb4
-rw-r--r--app/controllers/concerns/requires_whitelisted_monitoring_client.rb4
-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.rb24
-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.rb14
-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.rb33
-rw-r--r--app/controllers/dashboard/projects_controller.rb14
-rw-r--r--app/controllers/dashboard/snippets_controller.rb2
-rw-r--r--app/controllers/dashboard/todos_controller.rb30
-rw-r--r--app/controllers/dashboard_controller.rb2
-rw-r--r--app/controllers/explore/groups_controller.rb16
-rw-r--r--app/controllers/explore/projects_controller.rb13
-rw-r--r--app/controllers/google_api/authorizations_controller.rb29
-rw-r--r--app/controllers/groups/application_controller.rb6
-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.rb9
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb1
-rw-r--r--app/controllers/groups/uploads_controller.rb29
-rw-r--r--app/controllers/groups/variables_controller.rb57
-rw-r--r--app/controllers/groups_controller.rb70
-rw-r--r--app/controllers/health_controller.rb14
-rw-r--r--app/controllers/help_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.rb21
-rw-r--r--app/controllers/import/gitlab_controller.rb18
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb6
-rw-r--r--app/controllers/import/google_code_controller.rb16
-rw-r--r--app/controllers/invites_controller.rb4
-rw-r--r--app/controllers/jwt_controller.rb6
-rw-r--r--app/controllers/koding_controller.rb2
-rw-r--r--app/controllers/metrics_controller.rb17
-rw-r--r--app/controllers/oauth/applications_controller.rb19
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb38
-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.rb29
-rw-r--r--app/controllers/profiles/gpg_keys_controller.rb2
-rw-r--r--app/controllers/profiles/keys_controller.rb2
-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.rb2
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb6
-rw-r--r--app/controllers/profiles_controller.rb22
-rw-r--r--app/controllers/projects/application_controller.rb14
-rw-r--r--app/controllers/projects/artifacts_controller.rb18
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb20
-rw-r--r--app/controllers/projects/blob_controller.rb11
-rw-r--r--app/controllers/projects/boards_controller.rb3
-rw-r--r--app/controllers/projects/branches_controller.rb53
-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.rb52
-rw-r--r--app/controllers/projects/commits_controller.rb10
-rw-r--r--app/controllers/projects/compare_controller.rb6
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb5
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb10
-rw-r--r--app/controllers/projects/deployments_controller.rb3
-rw-r--r--app/controllers/projects/discussions_controller.rb38
-rw-r--r--app/controllers/projects/environments_controller.rb1
-rw-r--r--app/controllers/projects/forks_controller.rb5
-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/group_links_controller.rb14
-rw-r--r--app/controllers/projects/hooks_controller.rb11
-rw-r--r--app/controllers/projects/issues_controller.rb121
-rw-r--r--app/controllers/projects/jobs_controller.rb13
-rw-r--r--app/controllers/projects/labels_controller.rb1
-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/merge_requests/application_controller.rb9
-rw-r--r--app/controllers/projects/merge_requests/conflicts_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb36
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb47
-rw-r--r--app/controllers/projects/merge_requests_controller.rb93
-rw-r--r--app/controllers/projects/milestones_controller.rb28
-rw-r--r--app/controllers/projects/network_controller.rb30
-rw-r--r--app/controllers/projects/notes_controller.rb47
-rw-r--r--app/controllers/projects/pages_domains_controller.rb45
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb31
-rw-r--r--app/controllers/projects/pipelines_controller.rb8
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb18
-rw-r--r--app/controllers/projects/project_members_controller.rb33
-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.rb10
-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/runners_controller.rb8
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb10
-rw-r--r--app/controllers/projects/settings/repository_controller.rb10
-rw-r--r--app/controllers/projects/tree_controller.rb8
-rw-r--r--app/controllers/projects/uploads_controller.rb33
-rw-r--r--app/controllers/projects/variables_controller.rb57
-rw-r--r--app/controllers/projects/wikis_controller.rb42
-rw-r--r--app/controllers/projects_controller.rb68
-rw-r--r--app/controllers/registrations_controller.rb39
-rw-r--r--app/controllers/root_controller.rb6
-rw-r--r--app/controllers/search_controller.rb9
-rw-r--r--app/controllers/sessions_controller.rb60
-rw-r--r--app/controllers/snippets/notes_controller.rb1
-rw-r--r--app/controllers/snippets_controller.rb12
-rw-r--r--app/controllers/unicorn_test_controller.rb14
-rw-r--r--app/controllers/uploads_controller.rb75
-rw-r--r--app/controllers/user_callouts_controller.rb23
-rw-r--r--app/controllers/users_controller.rb29
-rw-r--r--app/finders/autocomplete_users_finder.rb24
-rw-r--r--app/finders/branches_finder.rb4
-rw-r--r--app/finders/clusters_finder.rb29
-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/group_descendants_finder.rb171
-rw-r--r--app/finders/group_projects_finder.rb11
-rw-r--r--app/finders/groups_finder.rb8
-rw-r--r--app/finders/issuable_finder.rb128
-rw-r--r--app/finders/issues_finder.rb62
-rw-r--r--app/finders/labels_finder.rb40
-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.rb32
-rw-r--r--app/finders/milestones_finder.rb10
-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.rb3
-rw-r--r--app/finders/runner_jobs_finder.rb22
-rw-r--r--app/finders/snippets_finder.rb101
-rw-r--r--app/finders/todos_finder.rb18
-rw-r--r--app/finders/user_recent_events_finder.rb33
-rw-r--r--app/finders/users_finder.rb4
-rw-r--r--app/helpers/appearances_helper.rb25
-rw-r--r--app/helpers/application_helper.rb38
-rw-r--r--app/helpers/application_settings_helper.rb84
-rw-r--r--app/helpers/auth_helper.rb10
-rw-r--r--app/helpers/auto_devops_helper.rb25
-rw-r--r--app/helpers/avatars_helper.rb39
-rw-r--r--app/helpers/blob_helper.rb124
-rw-r--r--app/helpers/boards_helper.rb43
-rw-r--r--app/helpers/branches_helper.rb25
-rw-r--r--app/helpers/breadcrumbs_helper.rb8
-rw-r--r--app/helpers/builds_helper.rb3
-rw-r--r--app/helpers/button_helper.rb56
-rw-r--r--app/helpers/ci_status_helper.rb29
-rw-r--r--app/helpers/clusters_helper.rb5
-rw-r--r--app/helpers/commits_helper.rb34
-rw-r--r--app/helpers/compare_helper.rb4
-rw-r--r--app/helpers/dashboard_helper.rb24
-rw-r--r--app/helpers/diff_helper.rb49
-rw-r--r--app/helpers/emails_helper.rb17
-rw-r--r--app/helpers/events_helper.rb30
-rw-r--r--app/helpers/explore_helper.rb16
-rw-r--r--app/helpers/form_helper.rb4
-rw-r--r--app/helpers/gitlab_routing_helper.rb11
-rw-r--r--app/helpers/graph_helper.rb10
-rw-r--r--app/helpers/groups_helper.rb68
-rw-r--r--app/helpers/icons_helper.rb18
-rw-r--r--app/helpers/import_helper.rb40
-rw-r--r--app/helpers/instance_configuration_helper.rb18
-rw-r--r--app/helpers/issuables_helper.rb90
-rw-r--r--app/helpers/issues_helper.rb25
-rw-r--r--app/helpers/labels_helper.rb8
-rw-r--r--app/helpers/lazy_image_tag_helper.rb1
-rw-r--r--app/helpers/markup_helper.rb36
-rw-r--r--app/helpers/members_helper.rb7
-rw-r--r--app/helpers/merge_requests_helper.rb27
-rw-r--r--app/helpers/namespaces_helper.rb9
-rw-r--r--app/helpers/nav_helper.rb41
-rw-r--r--app/helpers/notes_helper.rb37
-rw-r--r--app/helpers/notifications_helper.rb1
-rw-r--r--app/helpers/numbers_helper.rb11
-rw-r--r--app/helpers/page_layout_helper.rb2
-rw-r--r--app/helpers/preferences_helper.rb29
-rw-r--r--app/helpers/profiles_helper.rb2
-rw-r--r--app/helpers/projects_helper.rb188
-rw-r--r--app/helpers/search_helper.rb11
-rw-r--r--app/helpers/selects_helper.rb1
-rw-r--r--app/helpers/services_helper.rb11
-rw-r--r--app/helpers/sidekiq_helper.rb6
-rw-r--r--app/helpers/snippets_helper.rb1
-rw-r--r--app/helpers/sorting_helper.rb323
-rw-r--r--app/helpers/storage_health_helper.rb9
-rw-r--r--app/helpers/submodule_helper.rb11
-rw-r--r--app/helpers/system_note_helper.rb4
-rw-r--r--app/helpers/todos_helper.rb13
-rw-r--r--app/helpers/tree_helper.rb26
-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.rb6
-rw-r--r--app/helpers/webpack_helper.rb39
-rw-r--r--app/helpers/wiki_helper.rb18
-rw-r--r--app/mailers/abuse_report_mailer.rb6
-rw-r--r--app/mailers/base_mailer.rb4
-rw-r--r--app/mailers/emails/issues.rb33
-rw-r--r--app/mailers/emails/members.rb11
-rw-r--r--app/mailers/emails/merge_requests.rb37
-rw-r--r--app/mailers/emails/notes.rb10
-rw-r--r--app/mailers/emails/pages_domains.rb43
-rw-r--r--app/mailers/emails/profile.rb6
-rw-r--r--app/mailers/notify.rb19
-rw-r--r--app/models/ability.rb30
-rw-r--r--app/models/appearance.rb4
-rw-r--r--app/models/application_setting.rb73
-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/dependency_manager.rb13
-rw-r--r--app/models/blob_viewer/package_json.rb18
-rw-r--r--app/models/board.rb8
-rw-r--r--app/models/chat_name.rb21
-rw-r--r--app/models/ci/artifact_blob.rb31
-rw-r--r--app/models/ci/build.rb101
-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.rb5
-rw-r--r--app/models/ci/job_artifact.rb39
-rw-r--r--app/models/ci/pipeline.rb127
-rw-r--r--app/models/ci/pipeline_schedule.rb3
-rw-r--r--app/models/ci/runner.rb39
-rw-r--r--app/models/ci/trigger.rb3
-rw-r--r--app/models/ci/variable.rb5
-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.rb113
-rw-r--r--app/models/commit_collection.rb44
-rw-r--r--app/models/commit_range.rb8
-rw-r--r--app/models/commit_status.rb33
-rw-r--r--app/models/concerns/access_requestable.rb2
-rw-r--r--app/models/concerns/artifact_migratable.rb44
-rw-r--r--app/models/concerns/avatarable.rb49
-rw-r--r--app/models/concerns/awardable.rb1
-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.rb22
-rw-r--r--app/models/concerns/deployment_platform.rb48
-rw-r--r--app/models/concerns/discussion_on_diff.rb20
-rw-r--r--app/models/concerns/group_descendant.rb56
-rw-r--r--app/models/concerns/has_status.rb1
-rw-r--r--app/models/concerns/has_variable.rb4
-rw-r--r--app/models/concerns/ignorable_column.rb4
-rw-r--r--app/models/concerns/internal_id.rb1
-rw-r--r--app/models/concerns/issuable.rb97
-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.rb14
-rw-r--r--app/models/concerns/milestoneish.rb24
-rw-r--r--app/models/concerns/note_on_diff.rb4
-rw-r--r--app/models/concerns/noteable.rb6
-rw-r--r--app/models/concerns/participable.rb12
-rw-r--r--app/models/concerns/project_features_compatibility.rb1
-rw-r--r--app/models/concerns/prometheus_adapter.rb48
-rw-r--r--app/models/concerns/protected_branch_access.rb18
-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.rb8
-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.rb31
-rw-r--r--app/models/concerns/routable.rb24
-rw-r--r--app/models/concerns/sha_attribute.rb1
-rw-r--r--app/models/concerns/sortable.rb15
-rw-r--r--app/models/concerns/spammable.rb5
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb24
-rw-r--r--app/models/concerns/subscribable.rb2
-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.rb29
-rw-r--r--app/models/concerns/token_authenticatable.rb4
-rw-r--r--app/models/concerns/triggerable_hooks.rb40
-rw-r--r--app/models/concerns/updated_at_filterable.rb12
-rw-r--r--app/models/cycle_analytics.rb6
-rw-r--r--app/models/deploy_key.rb20
-rw-r--r--app/models/deploy_keys_project.rb10
-rw-r--r--app/models/deployment.rb30
-rw-r--r--app/models/diff_discussion.rb20
-rw-r--r--app/models/diff_note.rb30
-rw-r--r--app/models/discussion.rb5
-rw-r--r--app/models/email.rb15
-rw-r--r--app/models/environment.rb33
-rw-r--r--app/models/epic.rb11
-rw-r--r--app/models/event.rb19
-rw-r--r--app/models/external_issue.rb10
-rw-r--r--app/models/fork_network.rb19
-rw-r--r--app/models/fork_network_member.rb17
-rw-r--r--app/models/global_milestone.rb8
-rw-r--r--app/models/gpg_key.rb22
-rw-r--r--app/models/gpg_key_subkey.rb22
-rw-r--r--app/models/gpg_signature.rb33
-rw-r--r--app/models/group.rb63
-rw-r--r--app/models/group_custom_attribute.rb6
-rw-r--r--app/models/hooks/project_hook.rb26
-rw-r--r--app/models/hooks/system_hook.rb16
-rw-r--r--app/models/hooks/web_hook.rb1
-rw-r--r--app/models/identity.rb37
-rw-r--r--app/models/instance_configuration.rb71
-rw-r--r--app/models/issue.rb60
-rw-r--r--app/models/issue_assignee.rb2
-rw-r--r--app/models/key.rb22
-rw-r--r--app/models/label.rb8
-rw-r--r--app/models/legacy_diff_discussion.rb8
-rw-r--r--app/models/legacy_diff_note.rb6
-rw-r--r--app/models/lfs_file_lock.rb12
-rw-r--r--app/models/lfs_object.rb14
-rw-r--r--app/models/member.rb14
-rw-r--r--app/models/merge_request.rb289
-rw-r--r--app/models/merge_request/metrics.rb10
-rw-r--r--app/models/merge_request_diff.rb106
-rw-r--r--app/models/merge_request_diff_commit.rb4
-rw-r--r--app/models/milestone.rb21
-rw-r--r--app/models/namespace.rb84
-rw-r--r--app/models/network/commit.rb7
-rw-r--r--app/models/network/graph.rb8
-rw-r--r--app/models/note.rb120
-rw-r--r--app/models/notification_reason.rb19
-rw-r--r--app/models/notification_recipient.rb36
-rw-r--r--app/models/oauth_access_token.rb10
-rw-r--r--app/models/pages_domain.rb82
-rw-r--r--app/models/personal_access_token.rb27
-rw-r--r--app/models/project.rb539
-rw-r--r--app/models/project_auto_devops.rb8
-rw-r--r--app/models/project_custom_attribute.rb6
-rw-r--r--app/models/project_services/asana_service.rb4
-rw-r--r--app/models/project_services/campfire_service.rb2
-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.rb14
-rw-r--r--app/models/project_services/emails_on_push_service.rb2
-rw-r--r--app/models/project_services/hipchat_service.rb7
-rw-r--r--app/models/project_services/irker_service.rb2
-rw-r--r--app/models/project_services/issue_tracker_service.rb4
-rw-r--r--app/models/project_services/jira_service.rb17
-rw-r--r--app/models/project_services/kubernetes_service.rb47
-rw-r--r--app/models/project_services/mattermost_slash_commands_service.rb2
-rw-r--r--app/models/project_services/monitoring_service.rb4
-rw-r--r--app/models/project_services/packagist_service.rb65
-rw-r--r--app/models/project_services/pipelines_email_service.rb2
-rw-r--r--app/models/project_services/prometheus_service.rb84
-rw-r--r--app/models/project_services/pushover_service.rb4
-rw-r--r--app/models/project_services/slash_commands_service.rb6
-rw-r--r--app/models/project_statistics.rb4
-rw-r--r--app/models/project_team.rb65
-rw-r--r--app/models/project_wiki.rb77
-rw-r--r--app/models/protected_branch.rb10
-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.rb3
-rw-r--r--app/models/redirect_route.rb28
-rw-r--r--app/models/repository.rb532
-rw-r--r--app/models/route.rb38
-rw-r--r--app/models/sent_notification.rb6
-rw-r--r--app/models/service.rb28
-rw-r--r--app/models/snippet.rb40
-rw-r--r--app/models/system_note_metadata.rb12
-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.rb387
-rw-r--r--app/models/user_callout.rb13
-rw-r--r--app/models/user_custom_attribute.rb6
-rw-r--r--app/models/user_synced_attributes_metadata.rb10
-rw-r--r--app/models/wiki_page.rb98
-rw-r--r--app/policies/base_policy.rb3
-rw-r--r--app/policies/ci/build_policy.rb11
-rw-r--r--app/policies/ci/pipeline_policy.rb16
-rw-r--r--app/policies/ci/pipeline_schedule_policy.rb18
-rw-r--r--app/policies/clusters/cluster_policy.rb12
-rw-r--r--app/policies/global_policy.rb11
-rw-r--r--app/policies/group_policy.rb24
-rw-r--r--app/policies/issuable_policy.rb25
-rw-r--r--app/policies/issue_policy.rb3
-rw-r--r--app/policies/merge_request_policy.rb2
-rw-r--r--app/policies/namespace_policy.rb5
-rw-r--r--app/policies/note_policy.rb2
-rw-r--r--app/policies/project_policy.rb32
-rw-r--r--app/presenters/ci/group_variable_presenter.rb10
-rw-r--r--app/presenters/ci/pipeline_presenter.rb11
-rw-r--r--app/presenters/ci/variable_presenter.rb10
-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.rb33
-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_stage_entity.rb3
-rw-r--r--app/serializers/base_serializer.rb7
-rw-r--r--app/serializers/blob_entity.rb4
-rw-r--r--app/serializers/build_details_entity.rb2
-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.rb2
-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/diff_file_entity.rb41
-rw-r--r--app/serializers/discussion_entity.rb38
-rw-r--r--app/serializers/environment_serializer.rb12
-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.rb16
-rw-r--r--app/serializers/issuable_sidebar_entity.rb12
-rw-r--r--app/serializers/issue_entity.rb15
-rw-r--r--app/serializers/issue_serializer.rb15
-rw-r--r--app/serializers/issue_sidebar_entity.rb3
-rw-r--r--app/serializers/job_entity.rb2
-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_metrics_entity.rb6
-rw-r--r--app/serializers/merge_request_serializer.rb11
-rw-r--r--app/serializers/merge_request_widget_entity.rb (renamed from app/serializers/merge_request_entity.rb)81
-rw-r--r--app/serializers/note_entity.rb12
-rw-r--r--app/serializers/pipeline_entity.rb8
-rw-r--r--app/serializers/pipeline_serializer.rb10
-rw-r--r--app/serializers/project_serializer.rb3
-rw-r--r--app/serializers/submodule_entity.rb2
-rw-r--r--app/serializers/time_trackable_entity.rb11
-rw-r--r--app/serializers/tree_entity.rb4
-rw-r--r--app/serializers/tree_root_entity.rb4
-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.rb7
-rw-r--r--app/services/akismet_service.rb6
-rw-r--r--app/services/applications/create_service.rb13
-rw-r--r--app/services/auth/container_registry_authentication_service.rb21
-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/issues/list_service.rb15
-rw-r--r--app/services/boards/issues/move_service.rb4
-rw-r--r--app/services/boards/lists/create_service.rb6
-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.rb195
-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.rb8
-rw-r--r--app/services/ci/register_job_service.rb26
-rw-r--r--app/services/ci/retry_build_service.rb4
-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/compare_service.rb12
-rw-r--r--app/services/concerns/issues/resolve_discussions.rb16
-rw-r--r--app/services/create_deployment_service.rb1
-rw-r--r--app/services/delete_merged_branches_service.rb18
-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.rb2
-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.rb20
-rw-r--r--app/services/files/update_service.rb21
-rw-r--r--app/services/git_push_service.rb20
-rw-r--r--app/services/gravatar_service.rb4
-rw-r--r--app/services/groups/destroy_service.rb3
-rw-r--r--app/services/groups/nested_create_service.rb10
-rw-r--r--app/services/groups/transfer_service.rb96
-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.rb142
-rw-r--r--app/services/issues/base_service.rb18
-rw-r--r--app/services/issues/fetch_referenced_merge_requests_service.rb12
-rw-r--r--app/services/issues/move_service.rb42
-rw-r--r--app/services/issues/reopen_service.rb1
-rw-r--r--app/services/issues/update_service.rb13
-rw-r--r--app/services/keys/base_service.rb1
-rw-r--r--app/services/keys/last_used_service.rb2
-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/lfs/file_modification_handler.rb42
-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.rb80
-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.rb43
-rw-r--r--app/services/merge_requests/build_service.rb101
-rw-r--r--app/services/merge_requests/close_service.rb13
-rw-r--r--app/services/merge_requests/conflicts/list_service.rb4
-rw-r--r--app/services/merge_requests/conflicts/resolve_service.rb48
-rw-r--r--app/services/merge_requests/create_from_issue_service.rb25
-rw-r--r--app/services/merge_requests/create_service.rb45
-rw-r--r--app/services/merge_requests/ff_merge_service.rb24
-rw-r--r--app/services/merge_requests/merge_service.rb68
-rw-r--r--app/services/merge_requests/post_merge_service.rb12
-rw-r--r--app/services/merge_requests/rebase_service.rb32
-rw-r--r--app/services/merge_requests/refresh_service.rb30
-rw-r--r--app/services/merge_requests/reopen_service.rb14
-rw-r--r--app/services/merge_requests/update_service.rb22
-rw-r--r--app/services/merge_requests/working_copy_base_service.rb24
-rw-r--r--app/services/metrics_service.rb5
-rw-r--r--app/services/milestones/promote_service.rb85
-rw-r--r--app/services/notes/destroy_service.rb4
-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.rb59
-rw-r--r--app/services/notification_service.rb84
-rw-r--r--app/services/projects/autocomplete_service.rb32
-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.rb30
-rw-r--r--app/services/projects/create_from_template_service.rb10
-rw-r--r--app/services/projects/create_service.rb17
-rw-r--r--app/services/projects/destroy_service.rb9
-rw-r--r--app/services/projects/fork_service.rb62
-rw-r--r--app/services/projects/forks_count_service.rb13
-rw-r--r--app/services/projects/gitlab_projects_import_service.rb16
-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.rb10
-rw-r--r--app/services/projects/import_export/export_service.rb2
-rw-r--r--app/services/projects/import_service.rb54
-rw-r--r--app/services/projects/open_issues_count_service.rb14
-rw-r--r--app/services/projects/open_merge_requests_count_service.rb2
-rw-r--r--app/services/projects/transfer_service.rb45
-rw-r--r--app/services/projects/unlink_fork_service.rb17
-rw-r--r--app/services/projects/update_pages_configuration_service.rb10
-rw-r--r--app/services/projects/update_pages_service.rb37
-rw-r--r--app/services/projects/update_service.rb21
-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_service.rb24
-rw-r--r--app/services/protected_branches/create_service.rb4
-rw-r--r--app/services/protected_branches/legacy_api_create_service.rb (renamed from app/services/protected_branches/api_create_service.rb)4
-rw-r--r--app/services/protected_branches/legacy_api_update_service.rb (renamed from app/services/protected_branches/api_update_service.rb)4
-rw-r--r--app/services/quick_actions/interpret_service.rb71
-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/submit_usage_ping_service.rb4
-rw-r--r--app/services/system_hooks_service.rb67
-rw-r--r--app/services/system_note_service.rb91
-rw-r--r--app/services/tags/create_service.rb2
-rw-r--r--app/services/test_hooks/base_service.rb2
-rw-r--r--app/services/test_hooks/system_service.rb7
-rw-r--r--app/services/todo_service.rb38
-rw-r--r--app/services/upload_service.rb4
-rw-r--r--app/services/users/activity_service.rb2
-rw-r--r--app/services/users/build_service.rb6
-rw-r--r--app/services/users/destroy_service.rb7
-rw-r--r--app/services/users/keys_count_service.rb27
-rw-r--r--app/services/users/last_push_event_service.rb4
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb18
-rw-r--r--app/services/users/update_service.rb19
-rw-r--r--app/services/verify_pages_domain_service.rb107
-rw-r--r--app/services/web_hook_service.rb4
-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/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.rb23
-rw-r--r--app/views/admin/appearances/_form.html.haml38
-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.haml177
-rw-r--r--app/views/admin/background_jobs/show.html.haml3
-rw-r--r--app/views/admin/broadcast_messages/preview.js.haml1
-rw-r--r--app/views/admin/cohorts/index.html.haml1
-rw-r--r--app/views/admin/conversational_development_index/show.html.haml4
-rw-r--r--app/views/admin/dashboard/_head.html.haml37
-rw-r--r--app/views/admin/dashboard/index.html.haml75
-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.haml6
-rw-r--r--app/views/admin/health_check/show.html.haml9
-rw-r--r--app/views/admin/hook_logs/_index.html.haml2
-rw-r--r--app/views/admin/hooks/_form.html.haml7
-rw-r--r--app/views/admin/hooks/edit.html.haml2
-rw-r--r--app/views/admin/hooks/index.html.haml4
-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.haml13
-rw-r--r--app/views/admin/logs/show.html.haml1
-rw-r--r--app/views/admin/monitoring/_head.html.haml25
-rw-r--r--app/views/admin/projects/_projects.html.haml9
-rw-r--r--app/views/admin/projects/index.html.haml5
-rw-r--r--app/views/admin/projects/show.html.haml8
-rw-r--r--app/views/admin/requests_profiles/index.html.haml1
-rw-r--r--app/views/admin/runners/_runner.html.haml2
-rw-r--r--app/views/admin/runners/index.html.haml40
-rw-r--r--app/views/admin/runners/show.html.haml8
-rw-r--r--app/views/admin/system_info/show.html.haml7
-rw-r--r--app/views/admin/users/_user.html.haml25
-rw-r--r--app/views/admin/users/index.html.haml4
-rw-r--r--app/views/admin/users/projects.html.haml4
-rw-r--r--app/views/admin/users/show.html.haml25
-rw-r--r--app/views/ci/lints/show.html.haml2
-rw-r--r--app/views/ci/runner/_how_to_setup_runner.html.haml28
-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.haml8
-rw-r--r--app/views/ci/variables/_content.html.haml4
-rw-r--r--app/views/ci/variables/_form.html.haml19
-rw-r--r--app/views/ci/variables/_index.html.haml34
-rw-r--r--app/views/ci/variables/_show.html.haml9
-rw-r--r--app/views/ci/variables/_table.html.haml28
-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.haml8
-rw-r--r--app/views/dashboard/_projects_head.html.haml8
-rw-r--r--app/views/dashboard/_snippets_head.html.haml4
-rw-r--r--app/views/dashboard/activity.html.haml4
-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.haml7
-rw-r--r--app/views/dashboard/issues.html.haml2
-rw-r--r--app/views/dashboard/projects/_blank_state_admin_welcome.html.haml70
-rw-r--r--app/views/dashboard/projects/_blank_state_welcome.html.haml98
-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.haml21
-rw-r--r--app/views/dashboard/projects/index.html.haml6
-rw-r--r--app/views/dashboard/projects/starred.html.haml3
-rw-r--r--app/views/dashboard/todos/_todo.html.haml2
-rw-r--r--app/views/dashboard/todos/index.html.haml10
-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/_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.haml2
-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.haml31
-rw-r--r--app/views/discussions/_discussion.html.haml18
-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/show.html.haml2
-rw-r--r--app/views/doorkeeper/authorizations/new.html.haml22
-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/_event.atom.builder7
-rw-r--r--app/views/events/_event_note.atom.haml2
-rw-r--r--app/views/events/event/_note.html.haml2
-rw-r--r--app/views/events/event/_push.html.haml6
-rw-r--r--app/views/explore/groups/_groups.html.haml8
-rw-r--r--app/views/explore/groups/index.html.haml6
-rw-r--r--app/views/explore/projects/_projects.html.haml2
-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.haml19
-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.haml23
-rw-r--r--app/views/groups/group_members/update.js.haml4
-rw-r--r--app/views/groups/issues.html.haml19
-rw-r--r--app/views/groups/labels/index.html.haml7
-rw-r--r--app/views/groups/labels/new.html.haml1
-rw-r--r--app/views/groups/merge_requests.html.haml13
-rw-r--r--app/views/groups/milestones/_form.html.haml4
-rw-r--r--app/views/groups/milestones/_header_title.html.haml3
-rw-r--r--app/views/groups/milestones/index.html.haml3
-rw-r--r--app/views/groups/milestones/new.html.haml1
-rw-r--r--app/views/groups/projects.html.haml3
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml10
-rw-r--r--app/views/groups/show.html.haml43
-rw-r--r--app/views/groups/subgroups.html.haml22
-rw-r--r--app/views/groups/variables/show.html.haml1
-rw-r--r--app/views/help/_shortcuts.html.haml4
-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/show.html.haml2
-rw-r--r--app/views/help/ui.html.haml4
-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/gitlab_projects/new.html.haml2
-rw-r--r--app/views/invites/show.html.haml2
-rw-r--r--app/views/issues/_issue.atom.builder2
-rw-r--r--app/views/koding/index.html.haml2
-rw-r--r--app/views/layouts/_flash.html.haml16
-rw-r--r--app/views/layouts/_head.html.haml12
-rw-r--r--app/views/layouts/_init_auto_complete.html.haml2
-rw-r--r--app/views/layouts/_mailer.html.haml4
-rw-r--r--app/views/layouts/_page.html.haml5
-rw-r--r--app/views/layouts/_recaptcha_verification.html.haml15
-rw-r--r--app/views/layouts/_search.html.haml16
-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/group.html.haml6
-rw-r--r--app/views/layouts/header/_default.html.haml59
-rw-r--r--app/views/layouts/header/_new_dropdown.haml6
-rw-r--r--app/views/layouts/nav/_breadcrumbs.html.haml2
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml115
-rw-r--r--app/views/layouts/nav/_explore.html.haml21
-rw-r--r--app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml2
-rw-r--r--app/views/layouts/nav/projects_dropdown/_show.html.haml6
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml7
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml169
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml8
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml69
-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/project.html.haml2
-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/new_email_email.html.haml10
-rw-r--r--app/views/notify/new_email_email.text.erb7
-rw-r--r--app/views/notify/new_issue_email.html.haml2
-rw-r--r--app/views/notify/new_merge_request_email.html.haml2
-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.haml2
-rw-r--r--app/views/notify/project_was_moved_email.html.haml2
-rw-r--r--app/views/peek/views/_gitaly.html.haml7
-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.haml40
-rw-r--r--app/views/profiles/audit_log.html.haml3
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml2
-rw-r--r--app/views/profiles/chat_names/index.html.haml1
-rw-r--r--app/views/profiles/emails/index.html.haml17
-rw-r--r--app/views/profiles/gpg_keys/_key.html.haml9
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml6
-rw-r--r--app/views/profiles/keys/index.html.haml5
-rw-r--r--app/views/profiles/keys/show.html.haml3
-rw-r--r--app/views/profiles/notifications/show.html.haml7
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml45
-rw-r--r--app/views/profiles/preferences/show.html.haml1
-rw-r--r--app/views/profiles/show.html.haml22
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml16
-rw-r--r--app/views/projects/_export.html.haml8
-rw-r--r--app/views/projects/_files.html.haml2
-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.haml2
-rw-r--r--app/views/projects/_md_preview.html.haml33
-rw-r--r--app/views/projects/_merge_request_fast_forward_settings.html.haml13
-rw-r--r--app/views/projects/_merge_request_merge_settings.html.haml2
-rw-r--r--app/views/projects/_merge_request_rebase_settings.html.haml13
-rw-r--r--app/views/projects/_merge_request_settings.html.haml15
-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.haml30
-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/activity.html.haml5
-rw-r--r--app/views/projects/artifacts/_tree_directory.html.haml4
-rw-r--r--app/views/projects/artifacts/_tree_file.html.haml15
-rw-r--r--app/views/projects/artifacts/browse.html.haml5
-rw-r--r--app/views/projects/artifacts/file.html.haml1
-rw-r--r--app/views/projects/blame/show.html.haml1
-rw-r--r--app/views/projects/blob/_editor.html.haml4
-rw-r--r--app/views/projects/blob/_header.html.haml4
-rw-r--r--app/views/projects/blob/_header_content.html.haml3
-rw-r--r--app/views/projects/blob/_new_dir.html.haml2
-rw-r--r--app/views/projects/blob/_template_selectors.html.haml10
-rw-r--r--app/views/projects/blob/_upload.html.haml7
-rw-r--r--app/views/projects/blob/_viewer.html.haml3
-rw-r--r--app/views/projects/blob/diff.html.haml31
-rw-r--r--app/views/projects/blob/edit.html.haml2
-rw-r--r--app/views/projects/blob/new.html.haml1
-rw-r--r--app/views/projects/blob/show.html.haml23
-rw-r--r--app/views/projects/blob/viewers/_balsamiq.html.haml3
-rw-r--r--app/views/projects/blob/viewers/_dependency_manager.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_download.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_image.html.haml3
-rw-r--r--app/views/projects/blob/viewers/_notebook.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_pdf.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_sketch.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_stl.html.haml3
-rw-r--r--app/views/projects/branches/_branch.html.haml44
-rw-r--r--app/views/projects/branches/_delete_protected_modal.html.haml34
-rw-r--r--app/views/projects/branches/_panel.html.haml19
-rw-r--r--app/views/projects/branches/index.html.haml76
-rw-r--r--app/views/projects/branches/new.html.haml3
-rw-r--r--app/views/projects/buttons/_download.html.haml7
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml61
-rw-r--r--app/views/projects/buttons/_fork.html.haml9
-rw-r--r--app/views/projects/buttons/_koding.html.haml2
-rw-r--r--app/views/projects/buttons/_star.html.haml8
-rw-r--r--app/views/projects/ci/builds/_build.html.haml8
-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.haml32
-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.haml3
-rw-r--r--app/views/projects/commit/_commit_box.html.haml29
-rw-r--r--app/views/projects/commit/_limit_exceeded_message.html.haml8
-rw-r--r--app/views/projects/commit/_other_user_signature_badge.html.haml2
-rw-r--r--app/views/projects/commit/_pipelines_list.haml4
-rw-r--r--app/views/projects/commit/_same_user_different_email_signature_badge.html.haml2
-rw-r--r--app/views/projects/commit/_signature_badge.html.haml4
-rw-r--r--app/views/projects/commit/_unverified_signature_badge.html.haml2
-rw-r--r--app/views/projects/commit/_verified_signature_badge.html.haml2
-rw-r--r--app/views/projects/commit/branches.html.haml26
-rw-r--r--app/views/projects/commit/show.html.haml1
-rw-r--r--app/views/projects/commits/_commit.atom.builder6
-rw-r--r--app/views/projects/commits/_commit.html.haml41
-rw-r--r--app/views/projects/commits/_commits.html.haml7
-rw-r--r--app/views/projects/commits/_head.html.haml36
-rw-r--r--app/views/projects/commits/show.html.haml5
-rw-r--r--app/views/projects/compare/_form.html.haml16
-rw-r--r--app/views/projects/compare/index.html.haml19
-rw-r--r--app/views/projects/compare/show.html.haml16
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml20
-rw-r--r--app/views/projects/deploy_keys/_form.html.haml18
-rw-r--r--app/views/projects/deploy_keys/_index.html.haml6
-rw-r--r--app/views/projects/deployments/_commit.html.haml2
-rw-r--r--app/views/projects/diffs/_file.html.haml4
-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/_parallel_view.html.haml16
-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.haml13
-rw-r--r--app/views/projects/diffs/viewers/_image.html.haml70
-rw-r--r--app/views/projects/edit.html.haml55
-rw-r--r--app/views/projects/empty.html.haml57
-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.haml7
-rw-r--r--app/views/projects/environments/metrics.html.haml20
-rw-r--r--app/views/projects/environments/new.html.haml1
-rw-r--r--app/views/projects/environments/show.html.haml18
-rw-r--r--app/views/projects/environments/terminal.html.haml2
-rw-r--r--app/views/projects/find_file/show.html.haml1
-rw-r--r--app/views/projects/forks/_fork_button.html.haml26
-rw-r--r--app/views/projects/forks/error.html.haml3
-rw-r--r--app/views/projects/forks/index.html.haml4
-rw-r--r--app/views/projects/forks/new.html.haml59
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml4
-rw-r--r--app/views/projects/graphs/charts.html.haml37
-rw-r--r--app/views/projects/graphs/show.html.haml20
-rw-r--r--app/views/projects/hook_logs/_index.html.haml2
-rw-r--r--app/views/projects/hook_logs/show.html.haml2
-rw-r--r--app/views/projects/hooks/edit.html.haml4
-rw-r--r--app/views/projects/imports/show.html.haml13
-rw-r--r--app/views/projects/issues/_by_email_description.html.haml6
-rw-r--r--app/views/projects/issues/_discussion.html.haml12
-rw-r--r--app/views/projects/issues/_head.html.haml33
-rw-r--r--app/views/projects/issues/_issue_by_email.html.haml34
-rw-r--r--app/views/projects/issues/_merge_requests.html.haml6
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml4
-rw-r--r--app/views/projects/issues/_new_branch.html.haml66
-rw-r--r--app/views/projects/issues/index.html.haml10
-rw-r--r--app/views/projects/issues/show.html.haml43
-rw-r--r--app/views/projects/jobs/_empty_state.html.haml17
-rw-r--r--app/views/projects/jobs/_sidebar.html.haml20
-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.haml3
-rw-r--r--app/views/projects/jobs/show.html.haml88
-rw-r--r--app/views/projects/labels/edit.html.haml1
-rw-r--r--app/views/projects/labels/index.html.haml2
-rw-r--r--app/views/projects/labels/new.html.haml1
-rw-r--r--app/views/projects/merge_requests/_by_email_description.html.haml1
-rw-r--r--app/views/projects/merge_requests/_commits.html.haml2
-rw-r--r--app/views/projects/merge_requests/_head.html.haml21
-rw-r--r--app/views/projects/merge_requests/_how_to_merge.html.haml3
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml24
-rw-r--r--app/views/projects/merge_requests/conflicts.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_diffs.html.haml6
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml2
-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.haml26
-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.haml (renamed from app/views/projects/merge_requests/diffs/_versions.html.haml)26
-rw-r--r--app/views/projects/merge_requests/index.html.haml15
-rw-r--r--app/views/projects/merge_requests/show.html.haml64
-rw-r--r--app/views/projects/milestones/edit.html.haml1
-rw-r--r--app/views/projects/milestones/index.html.haml4
-rw-r--r--app/views/projects/milestones/new.html.haml1
-rw-r--r--app/views/projects/milestones/show.html.haml24
-rw-r--r--app/views/projects/network/_head.html.haml2
-rw-r--r--app/views/projects/network/show.html.haml7
-rw-r--r--app/views/projects/network/show.json.erb4
-rw-r--r--app/views/projects/new.html.haml197
-rw-r--r--app/views/projects/notes/_actions.html.haml7
-rw-r--r--app/views/projects/pages/_list.html.haml13
-rw-r--r--app/views/projects/pages/show.html.haml1
-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.haml20
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml9
-rw-r--r--app/views/projects/pipeline_schedules/_tabs.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/_variable_row.html.haml17
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml6
-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.haml4
-rw-r--r--app/views/projects/pipelines/charts/_pipeline_times.haml4
-rw-r--r--app/views/projects/pipelines/charts/_pipelines.haml8
-rw-r--r--app/views/projects/pipelines/index.html.haml20
-rw-r--r--app/views/projects/pipelines/new.html.haml1
-rw-r--r--app/views/projects/pipelines/show.html.haml5
-rw-r--r--app/views/projects/pipelines_settings/_show.html.haml18
-rw-r--r--app/views/projects/project_members/_team.html.haml10
-rw-r--r--app/views/projects/project_members/index.html.haml6
-rw-r--r--app/views/projects/project_members/update.js.haml4
-rw-r--r--app/views/projects/protected_branches/_index.html.haml3
-rw-r--r--app/views/projects/protected_branches/shared/_dropdown.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_index.html.haml6
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/_index.html.haml3
-rw-r--r--app/views/projects/protected_tags/shared/_dropdown.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml6
-rw-r--r--app/views/projects/registry/repositories/_image.html.haml32
-rw-r--r--app/views/projects/registry/repositories/index.html.haml92
-rw-r--r--app/views/projects/releases/edit.html.haml1
-rw-r--r--app/views/projects/repositories/_feed.html.haml2
-rw-r--r--app/views/projects/runners/_form.html.haml5
-rw-r--r--app/views/projects/runners/_runner.html.haml4
-rw-r--r--app/views/projects/runners/_shared_runners.html.haml11
-rw-r--r--app/views/projects/runners/_specific_runners.html.haml5
-rw-r--r--app/views/projects/runners/show.html.haml3
-rw-r--r--app/views/projects/services/_deprecated_message.html.haml3
-rw-r--r--app/views/projects/services/_form.html.haml12
-rw-r--r--app/views/projects/services/edit.html.haml3
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml4
-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.haml32
-rw-r--r--app/views/projects/services/slack_slash_commands/_help.html.haml2
-rw-r--r--app/views/projects/settings/_head.html.haml30
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml26
-rw-r--r--app/views/projects/settings/integrations/_project_hook.html.haml4
-rw-r--r--app/views/projects/settings/integrations/show.html.haml1
-rw-r--r--app/views/projects/settings/members/show.html.haml1
-rw-r--r--app/views/projects/settings/repository/show.html.haml6
-rw-r--r--app/views/projects/show.html.haml68
-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.haml16
-rw-r--r--app/views/projects/tags/new.html.haml28
-rw-r--r--app/views/projects/tags/show.html.haml19
-rw-r--r--app/views/projects/tree/_blob_item.html.haml7
-rw-r--r--app/views/projects/tree/_old_tree_content.html.haml24
-rw-r--r--app/views/projects/tree/_old_tree_header.html.haml70
-rw-r--r--app/views/projects/tree/_tree_content.html.haml29
-rw-r--r--app/views/projects/tree/_tree_header.html.haml81
-rw-r--r--app/views/projects/tree/_tree_item.html.haml4
-rw-r--r--app/views/projects/tree/_truncated_notice_tree_row.html.haml7
-rw-r--r--app/views/projects/tree/show.html.haml11
-rw-r--r--app/views/projects/update.js.haml2
-rw-r--r--app/views/projects/variables/show.html.haml1
-rw-r--r--app/views/projects/wikis/_form.html.haml30
-rw-r--r--app/views/projects/wikis/_main_links.html.haml6
-rw-r--r--app/views/projects/wikis/_new.html.haml11
-rw-r--r--app/views/projects/wikis/_pages_wiki_page.html.haml2
-rw-r--r--app/views/projects/wikis/_sidebar.html.haml7
-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.haml14
-rw-r--r--app/views/projects/wikis/history.html.haml21
-rw-r--r--app/views/projects/wikis/pages.html.haml8
-rw-r--r--app/views/projects/wikis/show.html.haml16
-rw-r--r--app/views/search/_category.html.haml17
-rw-r--r--app/views/search/_filter.html.haml2
-rw-r--r--app/views/search/_results.html.haml7
-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/sent_notifications/unsubscribe.html.haml2
-rw-r--r--app/views/shared/_auto_devops_callout.html.haml29
-rw-r--r--app/views/shared/_choose_group_avatar_button.html.haml11
-rw-r--r--app/views/shared/_clone_panel.html.haml4
-rw-r--r--app/views/shared/_delete_label_modal.html.haml20
-rw-r--r--app/views/shared/_email_with_badge.html.haml (renamed from app/views/profiles/gpg_keys/_email_with_badge.html.haml)4
-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.haml2
-rw-r--r--app/views/shared/_import_form.html.haml18
-rw-r--r--app/views/shared/_label.html.haml58
-rw-r--r--app/views/shared/_label_row.html.haml18
-rw-r--r--app/views/shared/_milestones_filter.html.haml2
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml2
-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/_outdated_browser.html.haml13
-rw-r--r--app/views/shared/_personal_access_tokens_form.html.haml4
-rw-r--r--app/views/shared/_recaptcha_form.html.haml20
-rw-r--r--app/views/shared/_ref_switcher.html.haml15
-rw-r--r--app/views/shared/_service_settings.html.haml2
-rw-r--r--app/views/shared/_show_aside.html.haml2
-rw-r--r--app/views/shared/_sidebar_toggle_button.html.haml6
-rw-r--r--app/views/shared/_sort_dropdown.html.haml42
-rw-r--r--app/views/shared/_target_switcher.html.haml16
-rw-r--r--app/views/shared/boards/_show.html.haml33
-rw-r--r--app/views/shared/boards/components/_board.html.haml1
-rw-r--r--app/views/shared/boards/components/_sidebar.html.haml3
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml2
-rw-r--r--app/views/shared/boards/components/sidebar/_notifications.html.haml10
-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.haml24
-rw-r--r--app/views/shared/empty_states/_labels.html.haml10
-rw-r--r--app/views/shared/empty_states/_merge_requests.html.haml17
-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/_add_new_project.svg2
-rw-r--r--app/views/shared/icons/_express.svg7
-rw-r--r--app/views/shared/icons/_icon_autodevops.svg2
-rw-r--r--app/views/shared/icons/_icon_hourglass.svg1
-rw-r--r--[-rwxr-xr-x]app/views/shared/icons/_icon_status_canceled.svg0
-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--app/views/shared/icons/_icon_status_notfound_borderless.svg1
-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--[-rwxr-xr-x]app/views/shared/icons/_icon_status_warning.svg0
-rw-r--r--app/views/shared/icons/_lightbulb.svg1
-rw-r--r--app/views/shared/icons/_rails.svg7
-rw-r--r--app/views/shared/icons/_spring.svg7
-rw-r--r--app/views/shared/issuable/_assignees.html.haml18
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml4
-rw-r--r--app/views/shared/issuable/_close_reopen_report_toggle.html.haml6
-rw-r--r--app/views/shared/issuable/_filter.html.haml3
-rw-r--r--app/views/shared/issuable/_form.html.haml4
-rw-r--r--app/views/shared/issuable/_label_page_create.html.haml8
-rw-r--r--app/views/shared/issuable/_label_page_default.html.haml10
-rw-r--r--app/views/shared/issuable/_nav.html.haml7
-rw-r--r--app/views/shared/issuable/_participants.html.haml18
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml4
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml75
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml18
-rw-r--r--app/views/shared/issuable/_sidebar_todo.html.haml8
-rw-r--r--app/views/shared/issuable/_user_dropdown_item.html.haml2
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml5
-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/_group.html.haml2
-rw-r--r--app/views/shared/members/_member.html.haml29
-rw-r--r--app/views/shared/members/_requests.html.haml19
-rw-r--r--app/views/shared/members/update.js.haml6
-rw-r--r--app/views/shared/milestones/_issuable.html.haml6
-rw-r--r--app/views/shared/milestones/_milestone.html.haml20
-rw-r--r--app/views/shared/milestones/_participants_tab.html.haml2
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml7
-rw-r--r--app/views/shared/milestones/_top.html.haml2
-rw-r--r--app/views/shared/notes/_comment_button.html.haml2
-rw-r--r--app/views/shared/notes/_form.html.haml35
-rw-r--r--app/views/shared/notes/_note.html.haml20
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml24
-rw-r--r--app/views/shared/projects/_dropdown.html.haml2
-rw-r--r--app/views/shared/projects/_list.html.haml6
-rw-r--r--app/views/shared/projects/_project.html.haml21
-rw-r--r--app/views/shared/repo/_editable_mode.html.haml2
-rw-r--r--app/views/shared/repo/_repo.html.haml7
-rw-r--r--app/views/shared/snippets/_form.html.haml1
-rw-r--r--app/views/shared/snippets/_header.html.haml21
-rw-r--r--app/views/shared/snippets/_snippet.html.haml4
-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/snippets/_snippets_scope_menu.html.haml2
-rw-r--r--app/views/u2f/_authenticate.html.haml1
-rw-r--r--app/views/u2f/_register.html.haml1
-rw-r--r--app/views/users/_groups.html.haml2
-rw-r--r--app/views/users/show.html.haml91
-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.rb44
-rw-r--r--app/workers/background_migration_worker.rb66
-rw-r--r--app/workers/build_coverage_worker.rb2
-rw-r--r--app/workers/build_finished_worker.rb11
-rw-r--r--app/workers/build_hooks_worker.rb4
-rw-r--r--app/workers/build_queue_worker.rb4
-rw-r--r--app/workers/build_success_worker.rb4
-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/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/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.rb8
-rw-r--r--app/workers/concerns/pipeline_background_queue.rb10
-rw-r--r--app/workers/concerns/pipeline_queue.rb10
-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.rb3
-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.rb6
-rw-r--r--app/workers/emails_on_push_worker.rb3
-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.rb4
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb6
-rw-r--r--app/workers/git_garbage_collect_worker.rb10
-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.rb3
-rw-r--r--app/workers/gitlab_usage_ping_worker.rb2
-rw-r--r--app/workers/group_destroy_worker.rb5
-rw-r--r--app/workers/import_export_project_cleanup_worker.rb2
-rw-r--r--app/workers/invalid_gpg_signature_update_worker.rb3
-rw-r--r--app/workers/irker_worker.rb4
-rw-r--r--app/workers/merge_worker.rb3
-rw-r--r--app/workers/namespaceless_project_destroy_worker.rb7
-rw-r--r--app/workers/new_issue_worker.rb3
-rw-r--r--app/workers/new_merge_request_worker.rb3
-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.rb5
-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.rb4
-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.rb3
-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.rb3
-rw-r--r--app/workers/project_export_worker.rb3
-rw-r--r--app/workers/project_migrate_hashed_storage_worker.rb34
-rw-r--r--app/workers/project_service_worker.rb3
-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.rb41
-rw-r--r--app/workers/repository_import_worker.rb34
-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.rb4
-rw-r--r--app/workers/storage_migrator_worker.rb29
-rw-r--r--app/workers/stuck_ci_jobs_worker.rb17
-rw-r--r--app/workers/stuck_import_jobs_worker.rb42
-rw-r--r--app/workers/stuck_merge_jobs_worker.rb9
-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/wait_for_cluster_creation_worker.rb12
-rw-r--r--app/workers/web_hook_worker.rb3
2338 files changed, 56128 insertions, 36889 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/icons.json b/app/assets/images/icons.json
index e5da75faf38..19843d24e22 100644
--- a/app/assets/images/icons.json
+++ b/app/assets/images/icons.json
@@ -1 +1 @@
-{"iconCount":134,"icons":["abuse","account","admin","angle-double-left","angle-down","angle-left","angle-right","angle-up","appearance","applications","approval","arrow-right","assignee","bold","book","branch","calendar","cancel","chevron-down","chevron-left","chevron-right","chevron-up","clock","code","comment-dots","comment-next","comment","comments","commit","credit-card","disk","doc_code","doc_image","doc_text","download","duplicate","earth","eye-slash","eye","file-additions","file-deletion","file-modified","filter","folder","fork","geo-nodes","git-merge","group","history","home","hook","issue-block","issue-child","issue-close","issue-duplicate","issue-new","issue-open-m","issue-open","issue-parent","issues","key-2","key","label","labels","leave","level-up","license","link","list-bulleted","list-numbered","location-dot","location","lock-open","lock","log","mail","merge-request-close-m","merge-request-close","messages","mobile-issue-close","monitor","more","notifications-off","notifications","overview","pencil","pipeline","play","plus-square-o","plus-square","plus","preferences","profile","project","push-rules","question-o","question","quote","redo","remove","repeat","retry","scale","screen-full","screen-normal","search","settings","shield","slight-frown","slight-smile","smile","smiley","snippet","spam","star-o","star","stop","talic","task-done","template","thump-down","thump-up","timer","todo-add","todo-done","token","unapproval","unassignee","unlink","user","users","volume-up","warning","work"]} \ No newline at end of file
+{"iconCount":191,"spriteSize":86607,"icons":["abuse","account","admin","angle-double-left","angle-double-right","angle-down","angle-left","angle-right","angle-up","appearance","applications","approval","arrow-down","arrow-right","assignee","bold","book","bookmark","branch","bullhorn","calendar","cancel","chart","chevron-down","chevron-left","chevron-right","chevron-up","clock","close","code","collapse","comment-dots","comment-next","comment","comments","commit","credit-card","cut","dashboard","disk","doc_code","doc_image","doc_text","double-headed-arrow","download","duplicate","earth","ellipsis_v","emoji_slightly_smiling_face","emoji_smile","emoji_smiley","epic","external-link","eye-slash","eye","file-addition","file-deletion","file-modified","filter","folder-o","folder-open","folder","fork","geo-nodes","git-merge","group","history","home","hook","hourglass","image-comment-dark","image-comment-light","import","issue-block","issue-child","issue-close","issue-duplicate","issue-external","issue-new","issue-open-m","issue-open","issue-parent","issues","italic","key-2","key","label","labels","leave","level-up","license","link","list-bulleted","list-numbered","location-dot","location","lock-open","lock","log","mail","menu","merge-request-close","messages","mobile-issue-close","monitor","more","notifications-off","notifications","overview","pencil-square","pencil","pipeline","play","plus-square-o","plus-square","plus","podcast","preferences","profile","project","push-rules","question-o","question","quote","redo","remove","repeat","retry","scale","screen-full","screen-normal","scroll_down","scroll_up","search","settings","shield","slight-frown","slight-smile","smile","smiley","snippet","soft-unwrap","soft-wrap","spam","spinner","staged","star-o","star","status_canceled_borderless","status_canceled","status_closed","status_created_borderless","status_created","status_failed_borderless","status_failed","status_manual_borderless","status_manual","status_notfound_borderless","status_notfound","status_open","status_pending_borderless","status_pending","status_running_borderless","status_running","status_skipped_borderless","status_skipped","status_success_borderless","status_success_solid","status_success","status_warning_borderless","status_warning","stop","task-done","template","terminal","thumb-down","thumb-up","thumbtack","timer","todo-add","todo-done","token","unapproval","unassignee","unlink","unstaged","user","users","volume-up","warning","work"]} \ No newline at end of file
diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg
index 5c3a9962bd3..6aec54d0543 100644
--- a/app/assets/images/icons.svg
+++ b/app/assets/images/icons.svg
@@ -1 +1 @@
-<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><symbol viewBox="0 0 16 16" id="abuse" xmlns="http://www.w3.org/2000/svg"><path d="M11.408.328l4.029 3.222A1.5 1.5 0 0 1 16 4.72v6.555a1.5 1.5 0 0 1-.563 1.171l-4.026 3.224a1.5 1.5 0 0 1-.937.329H5.529a1.5 1.5 0 0 1-.937-.328L.563 12.45A1.5 1.5 0 0 1 0 11.28V4.724a1.5 1.5 0 0 1 .563-1.171L4.589.329A1.5 1.5 0 0 1 5.526 0h4.945c.34 0 .67.116.937.328zM10.296 2H5.702L2 4.964v6.074L5.704 14h4.594L14 11.036V4.962L10.296 2zM8 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"/></symbol><symbol viewBox="0 0 16 16" id="account" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9.195 9.965l-.568-.875a.25.25 0 0 1 .015-.294l.405-.5a.25.25 0 0 1 .283-.075l.938.36c.257-.183.543-.325.851-.42l.322-.988A.25.25 0 0 1 11.679 7h.642a.25.25 0 0 1 .238.173l.322.988c.308.095.594.237.851.42l.938-.36a.25.25 0 0 1 .283.076l.405.5a.25.25 0 0 1 .015.293l-.568.875c.113.297.18.616.193.95l.898.54a.25.25 0 0 1 .115.27l-.144.626a.25.25 0 0 1-.222.193l-1.115.098a3.015 3.015 0 0 1-.512.608l.165 1.18a.25.25 0 0 1-.138.259l-.577.281a.25.25 0 0 1-.29-.05l-.874-.905a3.035 3.035 0 0 1-.608 0l-.875.904a.25.25 0 0 1-.289.051l-.577-.281a.25.25 0 0 1-.138-.26l.165-1.18a3.015 3.015 0 0 1-.512-.607l-1.115-.098a.25.25 0 0 1-.222-.193l-.144-.626a.25.25 0 0 1 .115-.27l.898-.54c.013-.334.08-.653.193-.95zM6.789 8.023A12.845 12.845 0 0 0 6 8c-5.036 0-6 2.74-6 4.48C0 14.22.076 15 6 15c.553 0 1.055-.006 1.51-.02A5.977 5.977 0 0 1 6 11c0-1.083.287-2.1.79-2.977zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6zM12 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="admin" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.162 2.5a3.5 3.5 0 0 1-3.163 5.479L6.08 14.766a1.5 1.5 0 0 1-2.598-1.5L7.4 6.479A3.5 3.5 0 0 1 10.564 1L8.9 3.88l2.599 1.5 1.663-2.88zm-8.63 11.949a.5.5 0 1 0 .5-.866.5.5 0 0 0-.5.866z"/></symbol><symbol viewBox="0 0 16 16" id="angle-double-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.414 7.95l4.243-4.243a1 1 0 0 0-1.414-1.414l-4.95 4.95a.997.997 0 0 0 0 1.414l4.95 4.95a1 1 0 1 0 1.414-1.415L10.414 7.95zm-7 0l4.243-4.243a1 1 0 0 0-1.414-1.414l-4.95 4.95a.997.997 0 0 0 0 1.414l4.95 4.95a1 1 0 0 0 1.414-1.415L3.414 7.95z"/></symbol><symbol viewBox="0 0 16 16" id="angle-down" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="angle-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.757 8l4.95-4.95a1 1 0 1 0-1.414-1.414L3.636 7.293a.997.997 0 0 0 0 1.414l5.657 5.657a1 1 0 0 0 1.414-1.414L5.757 8z"/></symbol><symbol viewBox="0 0 16 16" id="angle-right" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.243 8l-4.95-4.95a1 1 0 0 1 1.414-1.414l5.657 5.657a.997.997 0 0 1 0 1.414l-5.657 5.657a1 1 0 0 1-1.414-1.414L10.243 8z"/></symbol><symbol viewBox="0 0 16 16" id="angle-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 6.757l-4.95 4.95a1 1 0 1 1-1.414-1.414l5.657-5.657a.997.997 0 0 1 1.414 0l5.657 5.657a1 1 0 0 1-1.414 1.414L8 6.757z"/></symbol><symbol viewBox="0 0 16 16" id="appearance" xmlns="http://www.w3.org/2000/svg"><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.858a23.85 23.85 0 0 1-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-.023a9.7 9.7 0 0 0 .478-.035c-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"/></symbol><symbol viewBox="0 0 16 16" id="applications" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="approval" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.536 10.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 1 1 9.12 9.243l1.415 1.414zM7.632 8.109A2 2 0 0 0 7 11.364l2.121 2.121a1.996 1.996 0 0 0 2.807.021C11.686 14.554 10.627 15 6 15c-5.924 0-6-.78-6-2.52S.964 8 6 8c.6 0 1.142.038 1.632.109zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></symbol><symbol viewBox="0 0 16 16" id="arrow-right" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="assignee" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12 5V4a1 1 0 0 1 2 0v1h1a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0V7h-1a1 1 0 0 1 0-2h1zM5.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"/></symbol><symbol viewBox="0 0 16 16" id="bold" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 15V1a1 1 0 0 1 1-1h4.604c.93 0 1.762.088 2.495.264.733.176 1.353.445 1.863.807.509.363.897.82 1.164 1.369.268.549.401 1.197.401 1.945 0 .366-.045.718-.137 1.055-.091.337-.23.652-.417.945a3.453 3.453 0 0 1-.71.796 3.645 3.645 0 0 1-1.021.588c.469.117.87.295 1.203.533.333.238.608.515.824.83.216.315.374.657.473 1.027.099.37.148.75.148 1.138 0 1.553-.5 2.725-1.5 3.516-1 .791-2.423 1.187-4.27 1.187H3a1 1 0 0 1-1-1zm3.297-5.967v4.319H8.12c.425 0 .791-.053 1.099-.16.307-.106.564-.252.769-.44.205-.186.357-.406.456-.659.099-.252.148-.529.148-.83a3.04 3.04 0 0 0-.131-.928 1.78 1.78 0 0 0-.413-.703 1.8 1.8 0 0 0-.73-.445c-.3-.103-.66-.154-1.077-.154H5.297zm0-2.33h2.44c.842-.014 1.468-.192 1.878-.533.41-.34.616-.826.616-1.456 0-.725-.21-1.247-.632-1.566-.421-.318-1.086-.478-1.995-.478H5.297v4.033z"/></symbol><symbol viewBox="0 0 16 16" id="book" xmlns="http://www.w3.org/2000/svg"><path d="M7 2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2v4.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 7 6.191V2zM5 0h6a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H5a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4z"/></symbol><symbol viewBox="0 0 16 16" id="branch" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="calendar" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12 2h2a2 2 0 0 1 2 2H0a2 2 0 0 1 2-2h2V1a1 1 0 1 1 2 0v1h4V1a1 1 0 1 1 2 0v1zM0 4h16v9a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V4zm2 2.5V13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6.5a.5.5 0 0 0-.5-.5h-11a.5.5 0 0 0-.5.5zM5 8h2a1 1 0 1 1 0 2H5a1 1 0 1 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="cancel" xmlns="http://www.w3.org/2000/svg"><path d="M3.11 4.523a6 6 0 0 0 8.367 8.367L3.109 4.524zM4.522 3.11l8.368 8.368A6 6 0 0 0 4.524 3.11zM8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-down" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.078 8.2l3.535-3.536a2 2 0 0 1 2.828 2.828l-4.949 4.95c-.39.39-.902.586-1.414.586a1.994 1.994 0 0 1-1.414-.586l-4.95-4.95a2 2 0 1 1 2.828-2.828l3.536 3.535z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.977 7.998l3.535-3.535a2 2 0 1 0-2.828-2.828l-4.95 4.949c-.39.39-.586.902-.586 1.414 0 .512.196 1.024.586 1.414l4.95 4.95a2 2 0 1 0 2.828-2.828L7.977 7.998z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-right" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.22 7.998L4.683 4.463a2 2 0 0 1 2.828-2.828l4.95 4.949c.39.39.586.902.586 1.414a1.99 1.99 0 0 1-.586 1.414l-4.95 4.95a2 2 0 0 1-2.828-2.828l3.535-3.536z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.778 8.957l3.535 3.535a2 2 0 1 0 2.828-2.828l-4.949-4.95a1.994 1.994 0 0 0-1.414-.586c-.512 0-1.024.196-1.414.586l-4.95 4.95a2 2 0 1 0 2.828 2.828l3.536-3.535z"/></symbol><symbol viewBox="0 0 16 16" id="clock" xmlns="http://www.w3.org/2000/svg"><path d="M9 7h1a1 1 0 0 1 0 2H8a.997.997 0 0 1-1-1V5a1 1 0 1 1 2 0v2zm-1 9A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm0-2A6 6 0 1 0 8 2a6 6 0 0 0 0 12z"/></symbol><symbol viewBox="0 0 16 16" id="code" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M15.871 8.243a.997.997 0 0 0-.293-.707L12.75 4.707a1 1 0 0 0-1.414 1.414l2.12 2.122-2.12 2.121a1 1 0 0 0 1.414 1.414l2.828-2.828a.997.997 0 0 0 .293-.707zm-13.243 0L4.75 6.12a1 1 0 1 0-1.414-1.414L.507 7.536a.997.997 0 0 0 0 1.414l2.829 2.828a1 1 0 1 0 1.414-1.414L2.628 8.243zm6.407-4.107a1 1 0 0 1 .707 1.225L8.19 11.157a1 1 0 1 1-1.931-.518L7.81 4.843a1 1 0 0 1 1.224-.707z"/></symbol><symbol viewBox="0 0 16 16" id="comment" xmlns="http://www.w3.org/2000/svg"><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.586z"/></symbol><symbol viewBox="0 0 16 16" id="comment-dots" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="comment-next" xmlns="http://www.w3.org/2000/svg"><path d="M8 5V4a.5.5 0 0 1 .8-.4l2.667 2a.5.5 0 0 1 0 .8L8.8 8.4A.5.5 0 0 1 8 8V7H6a1 1 0 1 1 0-2h2zM1.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.586z"/></symbol><symbol viewBox="0 0 16 16" id="comments" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3.75 10L0 13V3a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3.75zM13 5h1a2 2 0 0 1 2 2v8l-2.667-2H8a2 2 0 0 1-2-2h4a3 3 0 0 0 3-3V5z"/></symbol><symbol viewBox="0 0 16 16" id="commit" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="credit-card" xmlns="http://www.w3.org/2000/svg"><path d="M14 5a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1h12zm0 3H2v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V8zM3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm6.5 8h3a.5.5 0 1 1 0 1h-3a.5.5 0 1 1 0-1z"/></symbol><symbol viewBox="0 0 16 16" id="disk" xmlns="http://www.w3.org/2000/svg"><path d="M16 11.764V3a3 3 0 0 0-3-3H3a3 3 0 0 0-3 3v8.764A2.989 2.989 0 0 1 2 11V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v8c.768 0 1.47.289 2 .764zM2 12h12a2 2 0 1 1 0 4H2a2 2 0 1 1 0-4zm10 1a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="doc_code" xmlns="http://www.w3.org/2000/svg"><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.414zm1.036 7.607a.498.498 0 0 1-.147.354l-1.414 1.414a.5.5 0 0 1-.707-.707l1.06-1.06-1.06-1.061a.5.5 0 0 1 .707-.707l1.414 1.414a.498.498 0 0 1 .147.353zm-4.822 0l1.06 1.061a.5.5 0 0 1-.706.707l-1.414-1.414a.498.498 0 0 1 0-.707l1.414-1.414a.5.5 0 1 1 .707.707l-1.06 1.06zM5 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-4z"/></symbol><symbol viewBox="0 0 16 16" id="doc_image" xmlns="http://www.w3.org/2000/svg"><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.414zM7.333 9.667l1.313-1.313a.5.5 0 0 1 .708 0L12 11H4l2.188-1.75a.5.5 0 0 1 .624 0l.521.417zM5 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 8a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zM4 11h8v.7a.3.3 0 0 1-.3.3H4.3a.3.3 0 0 1-.3-.3V11z"/></symbol><symbol viewBox="0 0 16 16" id="doc_text" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="download" xmlns="http://www.w3.org/2000/svg"><path d="M9 12h1a.5.5 0 0 1 .4.8l-2 2.667a.5.5 0 0 1-.8 0l-2-2.667A.5.5 0 0 1 6 12h1V8a1 1 0 1 1 2 0v4zM4 9a1 1 0 1 1 0 2 4 4 0 0 1-1.971-7.481 4 4 0 0 1 6.633-2.505 3.999 3.999 0 0 1 3.82 2.014A4 4 0 0 1 12 11a1 1 0 0 1 0-2 2 2 0 1 0 0-4h-1a2 2 0 0 0-3.112-1.662A2 2 0 1 0 4.268 5H4a2 2 0 1 0 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="duplicate" xmlns="http://www.w3.org/2000/svg"><path d="M14 10h-3a1 1 0 0 1-1-1V6H8.527A.527.527 0 0 0 8 6.527V13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-3zm-4-7H8.527c-.18 0-.355.013-.527.04V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2v2H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h4a3 3 0 0 1 3 3zM8.527 4h2.323a.5.5 0 0 1 .35.143l4.65 4.551a.5.5 0 0 1 .15.357V13a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V6.527A2.527 2.527 0 0 1 8.527 4z"/></symbol><symbol viewBox="0 0 16 16" id="earth" xmlns="http://www.w3.org/2000/svg"><path d="M8.7 2.04l-.082.177c.283.223.422.413.417.571-.008.237-.311.057-.444.274-.133.218.038.542-.112.637-.15.096-.398-.386-.479-.46-.054-.049-.166-.257-.336-.625l-.216-.225a.844.844 0 0 0-.418-.035c-.177.038-.075.1-.035.132.04.032.32.037.452.2.132.164.03.224-.05.298-.054.05-.157.062-.31.035H5.952l-.402.398.03.325.229.455.324-.463c.008-.206.058-.342.15-.41.14-.1.342-.15.534-.085.191.066-.057.218.011.271.068.053.204-.098.313-.02.11.08.07.155.104.322.036.167.254.114.398.328.144.215.19.29.147.483-.043.195-.168.26-.305.232-.138-.028-.107-.246-.275-.348-.168-.102-.266-.114-.386-.054-.12.06-.016.129.023.235.04.106.274.321.224.43-.05.107-.108.116-.42 0-.21-.077-.414-.007-.615.212l-.76.722c-.153.715-.3 1.13-.44 1.243-.211.17-.177-.483-.483-.656-.306-.174-.494-.047-.8-.07-.307-.023-.42.65-.38.873a.434.434 0 0 0 .221.321c.236-.141.39-.184.465-.128.11.084-.144.267-.074.425.07.158.314.069.386.283.073.213.084.48-.05.706-.135.227-.275.178-.4.053-.127-.126-.033-.375-.255-.704-.223-.329-.381-.337-.63-.787-.158-.287-.35-.743-.575-1.366a6 6 0 0 0 3.21 7.198l.001-.075c0-.577-.004-.944-.012-1.102-.011-.236-.95-.945-1.104-1.2-.154-.256-.34-.595-.355-.746-.016-.151.185-.232.344-.325.16-.093-.11-.367.028-.626.137-.258.395-.438.496-.356.101.081.058.228.267.333.209.104.077-.213.456-.178.38.035.143.201.252.216.11.016.113-.127.299-.143.186-.015.282.445.471.622.19.178.452.008.611.043.159.034.267.09.402.255.136.166-.03.352.073.557.103.205 1.07.22 1.433.255.364.034.371.011.371.324s-.166.314-.453.507c-.286.193-.166.462-.38.762-.212.3-.316.062-.622.14-.306.077-.413.382-.452.568-.039.186-.386.094-.877.232-.29.082-.429.144-.569.204a6.002 6.002 0 0 0 7.682-4.3c-.094-.384-.18-.63-.258-.74-.213-.297-.36.21-.924.49-.564.278-.57-.288-.81-.49-.16-.133-.212-.44-.158-.92-.005-.478.02-.828.077-1.049.057-.221.126-.543.207-.965.351-.373.606-.572.764-.595.237-.034.336.374.658.3a.315.315 0 0 0 .035-.01 5.993 5.993 0 0 0-.475-.824l-.309-.043a.646.646 0 0 0-.332-.117c-.205-.02-.025.128-.089.24-.064.112-.235.724-.437.685-.201-.039-.204-.374-.17-.668.036-.294-.077-.35-.2-.412-.124-.062-.325-.213-.556-.295-.232-.082-.123-.175-.093-.274.03-.1.208-.015.193-.058-.014-.044-.313-.135-.266-.167.03-.02.2-.02.506.003l.216-.012.293-.163a.58.58 0 0 0-.376-.22c-.233-.036-.513-.034-.73-.142-.205-.103-.458-.36-.643-.638A5.965 5.965 0 0 0 8.7 2.04zM8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16z"/></symbol><symbol viewBox="0 0 16 16" id="eye" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="eye-slash" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="file-additions" xmlns="http://www.w3.org/2000/svg"><path d="M7 7V5a1 1 0 1 1 2 0v2h2a1 1 0 0 1 0 2H9v2a1 1 0 0 1-2 0V9H5a1 1 0 1 1 0-2h2zM3 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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3z"/></symbol><symbol viewBox="0 0 16 16" id="file-deletion" xmlns="http://www.w3.org/2000/svg"><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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3zm2 6h6a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="file-modified" xmlns="http://www.w3.org/2000/svg"><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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3zm5 4a3 3 0 1 1 0 6 3 3 0 0 1 0-6z"/></symbol><symbol viewBox="0 0 16 16" id="filter" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10 6v9l-3.724-1.862A.5.5 0 0 1 6 12.691V6L1.854 1.854A.5.5 0 0 1 2.207 1h11.586a.5.5 0 0 1 .353.854L10 6z"/></symbol><symbol viewBox="0 0 16 16" id="folder" xmlns="http://www.w3.org/2000/svg"><path d="M7.228 5l-.475-1.335A1 1 0 0 0 5.81 3H2v9a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H7.228zM13 3a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a2 2 0 0 1 2-2h3.81a3 3 0 0 1 2.827 1.995L13 3z"/></symbol><symbol viewBox="0 0 16 16" id="fork" xmlns="http://www.w3.org/2000/svg"><path d="M9 12.268a2 2 0 1 1-2 0V8.874A4.002 4.002 0 0 1 4 5V3.732a2 2 0 1 1 2 0V5a2 2 0 1 0 4 0V3.732a2 2 0 1 1 2 0V5a4.002 4.002 0 0 1-3 3.874v3.394zM11 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM5 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="geo-nodes" xmlns="http://www.w3.org/2000/svg"><path d="M9.7 13.1l-.2.2c-.7.8-2 .9-2.8.1-.1 0-.1-.1-.1-.1l-.2-.2c-2 .2-3.4.7-3.4 1.4 0 .8 2.2 1.5 5 1.5s5-.7 5-1.5c0-.7-1.4-1.2-3.3-1.4M7.3 12.7c.4.4 1 .3 1.4-.1C11.6 9.5 13 7 13 5.3 13 2.4 10.8 0 8 0S3 2.4 3 5.3C3 7 4.4 9.5 7.3 12.7M8 2c1.6 0 3 1.4 3 3.3 0 1-1 2.8-3 5.2-2-2.4-3-4.2-3-5.2C5 3.4 6.4 2 8 2"/><circle cx="8" cy="5" r="1"/></symbol><symbol viewBox="0 0 16 16" id="git-merge" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="group" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3.048 11.997C-.377 11.975.013 11.782.013 10.56.013 9.235.653 8 4 8c.444 0 .84.022 1.194.062.164.435.426.82.76 1.132-1.786.389-2.721 1.353-2.906 2.803zm2.94-7.222a2.993 2.993 0 0 0-.976 1.95 2 2 0 1 1 .975-1.95zm6.964 7.222c-.185-1.45-1.12-2.414-2.906-2.803.334-.311.596-.697.76-1.132C11.16 8.022 11.556 8 12 8c3.346 0 3.987 1.235 3.987 2.56 0 1.222.39 1.415-3.035 1.437zm-1.964-5.272a2.993 2.993 0 0 0-.976-1.95 2 2 0 1 1 .976 1.95zM8 9a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 5c-2.177 0-3.987-.115-3.987-1.44S4.653 10 8 10c3.346 0 3.987 1.235 3.987 2.56S10.177 14 8 14z"/></symbol><symbol viewBox="0 0 16 16" id="history" xmlns="http://www.w3.org/2000/svg"><path d="M2.868 3.24a7 7 0 1 1-.043 9.475 1 1 0 0 1 1.478-1.348 5 5 0 1 0 .124-6.865l.796.645a.5.5 0 0 1-.193.873l-3.232.814a.5.5 0 0 1-.622-.504L1.3 3a.5.5 0 0 1 .814-.37l.754.61zM9 8h1a1 1 0 0 1 0 2H8a.997.997 0 0 1-1-1V6a1 1 0 1 1 2 0v2z"/></symbol><symbol viewBox="0 0 16 16" id="home" xmlns="http://www.w3.org/2000/svg"><path d="M8.462 2.177a.505.505 0 0 1-.038.044l.038-.044zm-.787 0l.038.043a.5.5 0 0 1-.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"/></symbol><symbol viewBox="0 0 16 16" id="hook" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="issue-block" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.803 8a5.97 5.97 0 0 0-.462 1H4.5a.5.5 0 0 1 0-1h1.303zM4.5 5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1zm7.5.083a6.04 6.04 0 0 0-2 0V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.083a5.96 5.96 0 0 0 .72 2H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v2.083zm1.121 3.796zM11 16a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm-1.293-2.292a3 3 0 0 0 4.001-4.001l-4.001 4zm-1.415-1.415l4.001-4a3 3 0 0 0-4.001 4.001z"/></symbol><symbol viewBox="0 0 16 16" id="issue-child" xmlns="http://www.w3.org/2000/svg"><path d="M11 8H5v1h1a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2V7a.997.997 0 0 1 1-1h3V4H4.5a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H9v2h3a.997.997 0 0 1 1 1v2h2a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-5a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h1V8zm-9 3v2h3v-2H2zm9 0v2h3v-2h-3z"/></symbol><symbol viewBox="0 0 16 16" id="issue-close" xmlns="http://www.w3.org/2000/svg"><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.414zM8 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 12z"/></symbol><symbol viewBox="0 0 16 16" id="issue-duplicate" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="issue-new" xmlns="http://www.w3.org/2000/svg"><path d="M10 2V1a1 1 0 0 1 2 0v1h1a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0V4H9a1 1 0 1 1 0-2h1zm0 6a1 1 0 0 1 2 0v5a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h1a1 1 0 1 1 0 2H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V8z"/></symbol><symbol viewBox="0 0 16 16" id="issue-open" xmlns="http://www.w3.org/2000/svg"><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 12zm0-2a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="issue-open-m" xmlns="http://www.w3.org/2000/svg"><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 12z"/></symbol><symbol viewBox="0 0 16 16" id="issue-parent" xmlns="http://www.w3.org/2000/svg"><path d="M11 11H5v1h1.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5H3v-2a.997.997 0 0 1 1-1h3V7H5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H9v2h3a.997.997 0 0 1 1 1v2h2.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5H11v-1zM6 3v2h4V3H6z"/></symbol><symbol viewBox="0 0 16 16" id="issues" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="key" xmlns="http://www.w3.org/2000/svg"><path d="M7.575 6.689a4.002 4.002 0 0 1 6.274-4.86 4 4 0 0 1-4.86 6.274l-2.21 2.21.706.708a1 1 0 1 1-1.414 1.414l-.707-.707-.707.707.707.707a1 1 0 1 1-1.414 1.414l-.707-.707a1 1 0 0 1-1.414-1.414l5.746-5.746zm2.032-.618a2 2 0 1 0 2.828-2.828A2 2 0 0 0 9.607 6.07z"/></symbol><symbol viewBox="0 0 16 16" id="key-2" xmlns="http://www.w3.org/2000/svg"><path d="M5.172 14.157l-.344.344-2.485.133a.462.462 0 0 1-.497-.503l.14-2.24a.599.599 0 0 1 .177-.382l5.155-5.155a4 4 0 1 1 2.828 2.828l-1.439 1.44-1.06-.354-.708.707.354 1.06-.707.708-1.06-.354-.708.707.354 1.06zm6.01-8.839a1 1 0 1 0 1.414-1.414 1 1 0 0 0-1.414 1.414z"/></symbol><symbol viewBox="0 0 16 16" id="label" xmlns="http://www.w3.org/2000/svg"><path d="M11.782 14.718a3 3 0 0 1-4.242 0L1.652 8.829a2 2 0 0 1-.565-1.702l.54-3.703a2 2 0 0 1 1.69-1.69l3.703-.54a2 2 0 0 1 1.703.564l5.888 5.888a3 3 0 0 1 0 4.243l-2.829 2.829zm1.415-5.657L7.309 3.173l-3.703.54-.54 3.702 5.888 5.888a1 1 0 0 0 1.414 0l2.829-2.828a1 1 0 0 0 0-1.414zM5.732 5.525A1 1 0 1 1 7.146 6.94a1 1 0 0 1-1.414-1.414z"/></symbol><symbol viewBox="0 0 16 16" id="labels" xmlns="http://www.w3.org/2000/svg"><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-.667zM.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"/></symbol><symbol viewBox="0 0 16 16" id="leave" xmlns="http://www.w3.org/2000/svg"><path d="M11 7V5.883a.5.5 0 0 1 .757-.429l3.528 2.117a.5.5 0 0 1 0 .858l-3.528 2.117a.5.5 0 0 1-.757-.43V9H7a1 1 0 1 1 0-2h4zm-2 6.256a1 1 0 0 1 2 0A2.744 2.744 0 0 1 8.256 16H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h5.19A2.81 2.81 0 0 1 11 2.81a1 1 0 0 1-2 0A.81.81 0 0 0 8.19 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h5.256c.41 0 .744-.333.744-.744z"/></symbol><symbol viewBox="0 0 16 16" id="level-up" xmlns="http://www.w3.org/2000/svg"><path fill="#2E2E2E" fill-rule="evenodd" d="M7 6h3.489a.5.5 0 0 0 .373-.832L6.374.117a.5.5 0 0 0-.748 0l-4.488 5.05A.5.5 0 0 0 1.51 6H5v7a3 3 0 0 0 3 3h6a1 1 0 0 0 0-2H8a1 1 0 0 1-1-1V6z"/></symbol><symbol viewBox="0 0 16 16" id="license" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12.56 8.9l2.66 4.606a.3.3 0 0 1-.243.45l-1.678.094a.1.1 0 0 0-.078.044l-.953 1.432a.3.3 0 0 1-.51-.016L9.097 10.9a5.994 5.994 0 0 0 3.464-2zm-5.23 2.063L4.707 15.51a.3.3 0 0 1-.51.016l-.953-1.432a.1.1 0 0 0-.078-.044l-1.678-.094a.3.3 0 0 1-.243-.45l2.48-4.297a5.983 5.983 0 0 0 3.607 1.754zM8 10A5 5 0 1 1 8 0a5 5 0 0 1 0 10zm0-2a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0-1a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="link" xmlns="http://www.w3.org/2000/svg"><path d="M6.986 3.35l2.12-2.122a4 4 0 0 1 5.657 5.657l-2.828 2.829a4 4 0 0 1-5.657 0 1 1 0 0 1 1.414-1.415 2 2 0 0 0 2.829 0l2.828-2.828a2 2 0 1 0-2.828-2.828l-1.001 1a5.018 5.018 0 0 0-2.534-.294zm2.12 9.192l-2.12 2.121a4 4 0 1 1-5.658-5.656l2.829-2.829a4 4 0 0 1 5.657 0 1 1 0 1 1-1.415 1.414 2 2 0 0 0-2.828 0l-2.828 2.829a2 2 0 1 0 2.828 2.828l1.001-1.001a5.018 5.018 0 0 0 2.534.294z"/></symbol><symbol viewBox="0 0 16 16" id="list-bulleted" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm0 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4-7h10a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2zm0 5h10a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2zm-4 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4-2h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="list-numbered" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6 2h8a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2zm0 5h8a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2zm0 5h8a1 1 0 0 1 0 2H6a1 1 0 0 1 0-2zM1.156 5v-.828h.816V2.204h-.72v-.636c.432-.084.708-.192.996-.372h.756v2.976h.684V5H1.156zm-.18 5v-.588c.9-.828 1.596-1.464 1.596-1.98 0-.342-.192-.504-.468-.504-.252 0-.444.18-.624.36l-.552-.552c.396-.42.756-.612 1.32-.612.768 0 1.308.492 1.308 1.248 0 .612-.576 1.284-1.092 1.812.192-.024.468-.048.636-.048h.636V10H.976zm1.26 5.072c-.618 0-1.068-.204-1.356-.54l.468-.648c.234.216.51.36.78.36.336 0 .552-.12.552-.36 0-.288-.15-.456-.948-.456v-.72c.636 0 .828-.168.828-.432 0-.228-.138-.348-.396-.348-.252 0-.432.108-.672.312l-.516-.624c.372-.312.768-.492 1.236-.492.84 0 1.38.384 1.38 1.074 0 .366-.204.642-.612.822v.024c.432.132.732.432.732.912 0 .72-.684 1.116-1.476 1.116z"/></symbol><symbol viewBox="0 0 16 16" id="location" xmlns="http://www.w3.org/2000/svg"><path d="M8.755 15.144a1 1 0 0 1-1.51 0C3.748 11.114 2 8.065 2 6a6 6 0 1 1 12 0c0 2.065-1.748 5.113-5.245 9.144zM12 6a4 4 0 1 0-8 0c0 1.314 1.312 3.71 4 6.944C10.688 9.71 12 7.314 12 6zM8 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="location-dot" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6.314 13.087C4.382 13.295 3 13.85 3 14.5c0 .828 2.239 1.5 5 1.5s5-.672 5-1.5c0-.65-1.382-1.205-3.314-1.413l-.202.225a2 2 0 0 1-2.968 0l-.202-.225zm2.428-.445a1 1 0 0 1-1.484 0C4.419 9.5 3 7.037 3 5.252 3 2.353 5.239 0 8 0s5 2.352 5 5.253c0 1.784-1.42 4.247-4.258 7.389zM11 5.252C11 3.436 9.634 2 8 2S5 3.435 5 5.253c0 1.027.974 2.824 3 5.203 2.026-2.38 3-4.176 3-5.203zM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="lock" xmlns="http://www.w3.org/2000/svg"><path d="M10 5V4h2v1a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3V4h2v1h4zM4 7a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H4zm0-3a4 4 0 1 1 8 0h-2a2 2 0 1 0-4 0H4z"/></symbol><symbol viewBox="0 0 16 16" id="lock-open" xmlns="http://www.w3.org/2000/svg"><path d="M4.044 4a4 4 0 0 1 6.99-2.658 1 1 0 1 1-1.495 1.33A2 2 0 0 0 6.044 4a.998.998 0 0 1-.07.367v.701H12a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3v-5a3 3 0 0 1 2.974-3V4h.07zM4 7.07a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H4z"/></symbol><symbol viewBox="0 0 16 16" id="log" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="mail" xmlns="http://www.w3.org/2000/svg"><path d="M14 5.6L9.338 9.796a2 2 0 0 1-2.676 0L2 5.6V11a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5.6zM3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm.212 2L8 8.31 12.788 4H3.212z"/></symbol><symbol viewBox="0 0 16 16" id="merge-request-close" xmlns="http://www.w3.org/2000/svg"><path d="M9.414 8l1.414 1.414a1 1 0 1 1-1.414 1.414L8 9.414l-1.414 1.414a1 1 0 1 1-1.414-1.414L6.586 8 5.172 6.586a1 1 0 1 1 1.414-1.414L8 6.586l1.414-1.414a1 1 0 1 1 1.414 1.414L9.414 8zM8 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 12z"/></symbol><symbol viewBox="0 0 16 16" id="merge-request-close-m" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9.414 8l4.95-4.95a1 1 0 0 0-1.414-1.414L8 6.586l-4.95-4.95A1 1 0 0 0 1.636 3.05L6.586 8l-4.95 4.95a1 1 0 1 0 1.414 1.414L8 9.414l4.95 4.95a1 1 0 1 0 1.414-1.414L9.414 8z"/></symbol><symbol viewBox="0 0 16 16" id="messages" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="mobile-issue-close" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.657 10.728L2.12 7.192A1 1 0 1 0 .707 8.607l4.243 4.242a.997.997 0 0 0 1.414 0l8.485-8.485a1 1 0 1 0-1.414-1.414l-7.778 7.778z"/></symbol><symbol viewBox="0 0 16 16" id="monitor" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="more" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 4a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 6a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 6a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="notifications" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="notifications-off" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.26 5.089c.243.757.382 1.478.5 2.017.187.86.74 2.188 1.66 3.982A2 2 0 0 1 13.64 14H10a2 2 0 1 1-4 0H4.35l2-2h7.29c-.993-1.937-1.6-3.396-1.835-4.468-.07-.326-.129-.59-.178-.81l1.634-1.633zM10.943 1.75l-1.48 1.48C9.07 3.076 8.612 3 8.069 3c-1.608 0-2.49.718-3.103 2.197-.28.676-.356.982-.654 2.428-.065.317-.17.673-.317 1.073L.45 12.242a1.99 1.99 0 0 1 .224-1.19c.962-1.787 1.521-3.064 1.68-3.831.322-1.566.947-5.501 4.65-6.134a1 1 0 1 1 1.994-.024 4.867 4.867 0 0 1 1.944.688zm2.932-.105a1 1 0 0 1 0 1.415L2.561 14.374a1 1 0 1 1-1.415-1.414L12.46 1.646a1 1 0 0 1 1.414 0z"/></symbol><symbol viewBox="0 0 16 16" id="overview" xmlns="http://www.w3.org/2000/svg"><path d="M2 0h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm0 2v3h3V2H2zm9-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-2zm0 2v3h3V2h-3zM2 9h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2zm0 2v3h3v-3H2zm9-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-2zm0 2v3h3v-3h-3z"/></symbol><symbol viewBox="0 0 16 16" id="pencil" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="pipeline" xmlns="http://www.w3.org/2000/svg"><path d="M8.969 7.25a2 2 0 1 1-1.938 0A1.002 1.002 0 0 1 7 7V5.083a.2.2 0 0 1 .06-.142l.877-.87a.1.1 0 0 1 .141 0l.864.87A.2.2 0 0 1 9 5.083V7c0 .086-.01.17-.031.25zM8 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 12zm4.5-4a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-2 6a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-5 9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-2 6a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zM8 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="play" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2.765 15.835c-.545.321-1.258.159-1.593-.363A1.075 1.075 0 0 1 1 14.89V1.11C1 .496 1.518 0 2.158 0c.214 0 .424.057.607.165l11.684 6.89c.544.321.714 1.005.38 1.526a1.135 1.135 0 0 1-.38.364l-11.684 6.89z"/></symbol><symbol viewBox="0 0 16 16" id="plus" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7 7V1a1 1 0 1 1 2 0v6h6a1 1 0 0 1 0 2H9v6a1 1 0 0 1-2 0V9H1a1 1 0 1 1 0-2h6z"/></symbol><symbol viewBox="0 0 16 16" id="plus-square" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9 7V4a1 1 0 1 0-2 0v3H4a1 1 0 1 0 0 2h3v3a1 1 0 0 0 2 0V9h3a1 1 0 0 0 0-2H9zM3 0h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3z"/></symbol><symbol viewBox="0 0 16 16" id="plus-square-o" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7 7V5a1 1 0 1 1 2 0v2h2a1 1 0 0 1 0 2H9v2a1 1 0 0 1-2 0V9H5a1 1 0 1 1 0-2h2zM3 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"/></symbol><symbol viewBox="0 0 16 16" id="preferences" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5 12h10a1 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-2zm11-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 2h9V7zM6 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 2h2V2z"/></symbol><symbol viewBox="0 0 16 16" id="profile" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="project" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="push-rules" xmlns="http://www.w3.org/2000/svg"><path d="M6.268 9a2 2 0 0 1 3.464 0H11a1 1 0 0 1 0 2H9.732a2 2 0 0 1-3.464 0H5a1 1 0 0 1 0-2h1.268zM7 2H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1h-1v3.515a.3.3 0 0 1-.434.268l-1.432-.716a.3.3 0 0 0-.268 0l-1.432.716A.3.3 0 0 1 7 5.515V2zM4 0h8a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm4 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="question" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm-1.46-5.602h2.233a3.97 3.97 0 0 1 .051-.558c.029-.17.073-.326.133-.469.06-.143.14-.28.242-.41.102-.13.228-.263.38-.399.26-.24.504-.467.733-.683a5.03 5.03 0 0 0 .598-.668c.17-.23.302-.477.399-.742a2.66 2.66 0 0 0 .144-.907c0-.505-.083-.95-.25-1.335a2.55 2.55 0 0 0-.723-.97 3.2 3.2 0 0 0-1.152-.589 5.441 5.441 0 0 0-1.531-.2c-.516 0-.998.063-1.445.188a3.19 3.19 0 0 0-1.168.59c-.331.268-.594.61-.79 1.027-.195.417-.295.917-.3 1.5h2.64c.006-.224.04-.416.102-.578.062-.161.142-.293.238-.394a.921.921 0 0 1 .332-.227 1.04 1.04 0 0 1 .39-.074c.34 0 .593.095.763.285.169.19.254.488.254.895 0 .328-.106.63-.317.906-.21.276-.499.565-.863.867-.214.182-.39.374-.531.574-.141.2-.253.42-.336.657a3.656 3.656 0 0 0-.176.777 7.89 7.89 0 0 0-.05.937zm-.321 2.375c0 .188.035.362.105.524.07.161.17.3.301.418.13.117.284.21.46.277.178.068.376.102.595.102.218 0 .416-.034.593-.102.178-.068.331-.16.461-.277a1.2 1.2 0 0 0 .301-.418c.07-.162.106-.336.106-.524a1.3 1.3 0 0 0-.106-.523 1.2 1.2 0 0 0-.3-.418 1.461 1.461 0 0 0-.462-.277 1.651 1.651 0 0 0-.593-.102c-.22 0-.417.034-.594.102a1.46 1.46 0 0 0-.461.277 1.2 1.2 0 0 0-.3.418 1.284 1.284 0 0 0-.106.523z"/></symbol><symbol viewBox="0 0 16 16" id="question-o" xmlns="http://www.w3.org/2000/svg"><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-.778-4.151c0-.301.014-.575.044-.82a3.2 3.2 0 0 1 .154-.68c.073-.208.17-.4.294-.575.123-.176.278-.343.465-.503a4.81 4.81 0 0 0 .755-.758c.185-.242.277-.506.277-.793 0-.356-.074-.617-.222-.783-.148-.166-.37-.25-.667-.25a.92.92 0 0 0-.342.065.806.806 0 0 0-.29.199 1.04 1.04 0 0 0-.209.345 1.5 1.5 0 0 0-.088.506H5.082c.005-.51.092-.948.263-1.313.171-.364.401-.664.69-.899.29-.234.63-.406 1.023-.516a4.66 4.66 0 0 1 1.264-.164c.497 0 .944.058 1.34.174.397.117.733.289 1.008.517.276.227.487.51.633.847.146.337.218.727.218 1.17 0 .295-.042.56-.126.792a2.52 2.52 0 0 1-.349.65 4.4 4.4 0 0 1-.523.584c-.2.19-.414.389-.642.598a2.73 2.73 0 0 0-.332.349c-.089.114-.16.233-.212.359a1.868 1.868 0 0 0-.116.41 3.39 3.39 0 0 0-.044.489H7.222zm-.28 2.078c0-.164.03-.317.092-.458a1.05 1.05 0 0 1 .263-.366c.114-.103.248-.183.403-.243a1.45 1.45 0 0 1 .52-.089c.191 0 .364.03.52.09.154.059.289.14.403.242.114.103.201.224.263.366.061.141.092.294.092.458 0 .164-.03.316-.092.458a1.05 1.05 0 0 1-.263.365 1.278 1.278 0 0 1-.404.243 1.43 1.43 0 0 1-.52.089c-.19 0-.364-.03-.519-.089-.155-.06-.29-.14-.403-.243a1.05 1.05 0 0 1-.263-.365 1.135 1.135 0 0 1-.093-.458z"/></symbol><symbol viewBox="0 0 16 16" id="quote" xmlns="http://www.w3.org/2000/svg"><path d="M15 3v8a3 3 0 0 1-3 3 1 1 0 0 1 0-2 1 1 0 0 0 1-1V9h-2a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h3a1 1 0 0 1 1 1zM7 3v8a3 3 0 0 1-3 3 1 1 0 0 1 0-2 1 1 0 0 0 1-1V9H3a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h3a1 1 0 0 1 1 1z"/></symbol><symbol viewBox="0 0 16 16" id="redo" xmlns="http://www.w3.org/2000/svg"><path d="M4.666 4.423a5 5 0 1 1-.203 6.944 1 1 0 1 0-1.478 1.347 7 7 0 1 0 .12-9.556L1.842 2.137a.5.5 0 0 0-.815.385L1 7.26a.5.5 0 0 0 .607.492l4.629-1.013a.5.5 0 0 0 .207-.877L4.666 4.423z"/></symbol><symbol viewBox="0 0 16 16" id="remove" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 3a1 1 0 1 1 0-2h12a1 1 0 0 1 0 2v10a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V3zm3-2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1H5zM4 3v10a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3H4zm2.5 2a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5zm3 0a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5z"/></symbol><symbol viewBox="0 0 16 16" id="repeat" xmlns="http://www.w3.org/2000/svg"><path d="M11.494 4.423a5 5 0 1 0 .203 6.944 1 1 0 1 1 1.478 1.347 7 7 0 1 1-.12-9.556l1.262-1.021a.5.5 0 0 1 .815.385l.028 4.738a.5.5 0 0 1-.607.492L9.924 6.739a.5.5 0 0 1-.207-.877l1.777-1.439z"/></symbol><symbol viewBox="0 0 16 16" id="retry" xmlns="http://www.w3.org/2000/svg"><path d="M4.009 6.958a4 4 0 0 0 5.283 4.775 1 1 0 0 1 .712 1.87A6 6 0 0 1 2.077 6.44l-.741-.2a.5.5 0 0 1-.12-.915L3.41 4.058a.5.5 0 0 1 .683.183l1.268 2.196a.5.5 0 0 1-.563.733l-.79-.212zm7.777 2.084a4 4 0 0 0-5.284-4.775 1 1 0 0 1-.711-1.87 6 6 0 0 1 7.927 7.162l.74.2a.5.5 0 0 1 .121.915l-2.196 1.268a.5.5 0 0 1-.683-.183l-1.267-2.196a.5.5 0 0 1 .562-.733l.79.212z"/></symbol><symbol viewBox="0 0 16 16" id="scale" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.99 9a.792.792 0 0 0-.078-.231L13 7l-.912 1.769a.791.791 0 0 0-.077.231h1.978zm-10 0a.792.792 0 0 0-.078-.231L3 7l-.912 1.769A.791.791 0 0 0 2.011 9h1.978zM2 0h12a1 1 0 0 1 0 2H2a1 1 0 1 1 0-2zm3 14h6a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2zM8 4a1 1 0 0 1 1 1v9H7V5a1 1 0 0 1 1-1zm-4.53-.714l2.265 4.735c.68 1.42.006 3.091-1.504 3.73A3.161 3.161 0 0 1 3 12c-1.657 0-3-1.263-3-2.821 0-.4.09-.794.264-1.158L2.53 3.286a.53.53 0 0 1 .94 0zm10 0l2.265 4.735c.68 1.42.006 3.091-1.504 3.73A3.161 3.161 0 0 1 13 12c-1.657 0-3-1.263-3-2.821 0-.4.09-.794.264-1.158l2.266-4.735a.53.53 0 0 1 .94 0z"/></symbol><symbol viewBox="0 0 16 16" id="screen-full" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M14 14v-2a1 1 0 0 1 2 0v3a.997.997 0 0 1-1 1h-3a1 1 0 0 1 0-2h2zM2 14v-2a1 1 0 0 0-2 0v3a1 1 0 0 0 1 1h3a1 1 0 0 0 0-2H2zM15.707.293A.997.997 0 0 1 16 1v3a1 1 0 0 1-2 0V2h-2a1 1 0 0 1 0-2h3c.276 0 .526.112.707.293zM2 2v2a1 1 0 1 1-2 0V1a.997.997 0 0 1 1-1h3a1 1 0 1 1 0 2H2zm4 4h4a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/></symbol><symbol viewBox="0 0 16 16" id="screen-normal" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 3V1a1 1 0 1 1 2 0v3a.997.997 0 0 1-1 1H1a1 1 0 1 1 0-2h2zm10 0h2a1 1 0 0 1 0 2h-3a.997.997 0 0 1-1-1V1a1 1 0 0 1 2 0v2zM3 13H1a1 1 0 0 1 0-2h3a.997.997 0 0 1 1 1v3a1 1 0 0 1-2 0v-2zm10 0v2a1 1 0 0 1-2 0v-3a.997.997 0 0 1 1-1h3a1 1 0 0 1 0 2h-2zM6.5 7h3a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5z"/></symbol><symbol viewBox="0 0 16 16" id="search" xmlns="http://www.w3.org/2000/svg"><path d="M8.853 8.854a3.5 3.5 0 1 0-4.95-4.95 3.5 3.5 0 0 0 4.95 4.95zm.207 2.328a5.5 5.5 0 1 1 2.121-2.121l3.329 3.328a1.5 1.5 0 0 1-2.121 2.121L9.06 11.182z"/></symbol><symbol viewBox="0 0 16 16" id="settings" xmlns="http://www.w3.org/2000/svg"><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.918a5.97 5.97 0 0 1 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"/></symbol><symbol viewBox="0 0 16 16" id="shield" xmlns="http://www.w3.org/2000/svg"><path d="M4 0h8a3 3 0 0 1 3 3v7.186a3 3 0 0 1-1.426 2.554l-4 2.465a3 3 0 0 1-3.148 0l-4-2.465A3 3 0 0 1 1 10.186V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v7.186a1 1 0 0 0 .475.852l4 2.464a1 1 0 0 0 1.05 0l4-2.464a1 1 0 0 0 .475-.852V3a1 1 0 0 0-1-1H4zm0 1.5a.5.5 0 0 1 .5-.5h4v8.837a.5.5 0 0 1-.753.431l-3.5-2.052A.5.5 0 0 1 4 9.785V3.5z"/></symbol><symbol viewBox="0 0 16 16" id="slight-frown" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="slight-smile" xmlns="http://www.w3.org/2000/svg"><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 12zM5 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 2zm-5.163 2.254a.5.5 0 1 1 .865-.502 1.499 1.499 0 0 0 2.607-.018.5.5 0 1 1 .871.49 2.499 2.499 0 0 1-4.343.03z"/></symbol><symbol viewBox="0 0 16 16" id="smile" xmlns="http://www.w3.org/2000/svg"><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 12zM6.18 6.27a.5.5 0 0 1-.873.487.5.5 0 0 0-.872-.003.5.5 0 1 1-.87-.495 1.5 1.5 0 0 1 2.616.012zm6 0a.5.5 0 1 1-.873.487.5.5 0 0 0-.872-.003.5.5 0 1 1-.87-.495 1.5 1.5 0 0 1 2.616.012zM5 9a3 3 0 0 0 6 0H5z"/></symbol><symbol viewBox="0 0 16 16" id="smiley" xmlns="http://www.w3.org/2000/svg"><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 12zM5 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 2zM5 9h6a3 3 0 0 1-6 0z"/></symbol><symbol viewBox="0 0 16 16" id="snippet" xmlns="http://www.w3.org/2000/svg"><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 0 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 0 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"/></symbol><symbol viewBox="0 0 16 16" id="spam" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="star" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.609 14.394l-3.465 1.473a1 1 0 0 1-1.39-.989l.276-4.024a1 1 0 0 0-.219-.694L.303 7.037A1 1 0 0 1 .83 5.443l3.715-.964a1 1 0 0 0 .609-.457L7.14.682a1 1 0 0 1 1.72 0l1.985 3.34a1 1 0 0 0 .609.457l3.715.964a1 1 0 0 1 .528 1.594L13.19 10.16a1 1 0 0 0-.219.694l.275 4.024a1 1 0 0 1-1.389.989l-3.465-1.473a1 1 0 0 0-.782 0z"/></symbol><symbol viewBox="0 0 16 16" id="star-o" xmlns="http://www.w3.org/2000/svg"><path d="M10.975 10.99a3 3 0 0 1 .655-2.083l1.54-1.916-2.219-.576a3 3 0 0 1-1.825-1.37L8 3.15 6.874 5.044a3 3 0 0 1-1.825 1.371l-2.218.576 1.54 1.916a3 3 0 0 1 .654 2.083l-.165 2.4 1.965-.836a3 3 0 0 1 2.348 0l1.965.836-.164-2.399zM7.61 14.394l-3.465 1.473a1 1 0 0 1-1.39-.989l.276-4.024a1 1 0 0 0-.219-.694L.303 7.037A1 1 0 0 1 .83 5.443l3.715-.964a1 1 0 0 0 .609-.457L7.14.682a1 1 0 0 1 1.72 0l1.985 3.34a1 1 0 0 0 .609.457l3.715.964a1 1 0 0 1 .528 1.594L13.19 10.16a1 1 0 0 0-.219.694l.275 4.024a1 1 0 0 1-1.389.989l-3.465-1.473a1 1 0 0 0-.782 0z"/></symbol><symbol viewBox="0 0 16 16" id="stop" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 0h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2z"/></symbol><symbol viewBox="0 0 16 16" id="talic" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6 0h7a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2zm2 2h3L8 14H5L8 2zM3 14h7a1 1 0 0 1 0 2H3a1 1 0 0 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="task-done" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="template" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="thump-down" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.33 11h5.282a2 2 0 0 0 1.963-2.38l-.563-2.905a3 3 0 0 0-.243-.732l-1.103-2.286A3 3 0 0 0 10.964 1H7a3 3 0 0 0-3 3v6.3a2 2 0 0 0 .436 1.247l3.11 3.9a.632.632 0 0 0 .941.053l.137-.137a1 1 0 0 0 .28-.87L8.329 11zM1 10h2V3H1a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1z"/></symbol><symbol viewBox="0 0 16 16" id="thump-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.33 5h5.282a2 2 0 0 1 1.963 2.38l-.563 2.905a3 3 0 0 1-.243.732l-1.103 2.286A3 3 0 0 1 10.964 15H7a3 3 0 0 1-3-3V5.7a2 2 0 0 1 .436-1.247l3.11-3.9A.632.632 0 0 1 8.487.5l.137.137a1 1 0 0 1 .28.87L8.329 5zM1 6h2v7H1a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/></symbol><symbol viewBox="0 0 16 16" id="timer" xmlns="http://www.w3.org/2000/svg"><path d="M12.022 3.27l.77-.77a1 1 0 0 1 1.415 1.414l-.728.729a7 7 0 1 1-1.456-1.372zM8 14A5 5 0 1 0 8 4a5 5 0 0 0 0 10zm0-9a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1zM6 0h4a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="todo-add" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10 4V2a1 1 0 0 1 2 0v2h2a1 1 0 0 1 0 2h-2v2a1 1 0 0 1-2 0V6H8a1 1 0 1 1 0-2h2zm2 7a1 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"/></symbol><symbol viewBox="0 0 16 16" id="todo-done" xmlns="http://www.w3.org/2000/svg"><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 0L4.707 6.778a1 1 0 0 1 1.414-1.414l2.122 2.121zM12 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"/></symbol><symbol viewBox="0 0 16 16" id="token" xmlns="http://www.w3.org/2000/svg"><path d="M3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3zm1 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="unapproval" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.95 8.536l1.06-1.061a1 1 0 0 1 1.415 1.414l-1.061 1.06 1.06 1.061a1 1 0 0 1-1.414 1.415l-1.06-1.061-1.06 1.06a1 1 0 1 1-1.415-1.414l1.06-1.06-1.06-1.06a1 1 0 0 1 1.414-1.415l1.06 1.06zm-3.768-.33c.006.503.201 1.006.586 1.39l.353.354-.353.353a2 2 0 1 0 2.828 2.829l.354-.354.047.048C11.964 14.363 11.527 15 6 15c-5.924 0-6-.78-6-2.52S.964 8 6 8c.834 0 1.557.074 2.182.205zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></symbol><symbol viewBox="0 0 16 16" id="unassignee" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11 5h4a1 1 0 0 1 0 2h-4a1 1 0 0 1 0-2zM5.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"/></symbol><symbol viewBox="0 0 16 16" id="unlink" xmlns="http://www.w3.org/2000/svg"><path d="M11.295 8.845l-.659-1.664a1.78 1.78 0 0 0 .04-.04l1.415-1.414c.586-.586.654-1.468.152-1.97s-1.384-.434-1.97.152L8.859 5.323a1.781 1.781 0 0 0-.04.04l-1.664-.658c.141-.208.305-.408.491-.594l1.415-1.414c1.366-1.367 3.424-1.525 4.596-.354 1.171 1.172 1.013 3.23-.354 4.596L11.89 8.354c-.186.186-.386.35-.594.491zm-2.45 2.45a4.075 4.075 0 0 1-.491.594l-1.415 1.414c-1.366 1.367-3.424 1.525-4.596.354-1.171-1.172-1.013-3.23.354-4.596L4.11 7.646c.186-.186.386-.35.594-.491l.659 1.664a1.781 1.781 0 0 0-.04.04l-1.415 1.414c-.586.586-.654 1.468-.152 1.97s1.384.434 1.97-.152l1.414-1.414a1.78 1.78 0 0 0 .04-.04l1.664.658zm3.812-2.088h2a.5.5 0 0 1 .5.5v.05a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-.05a.5.5 0 0 1 .5-.5zm-.384 2.116l1.415 1.414a.5.5 0 0 1 0 .708l-.037.036a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 0-.707l.036-.037a.5.5 0 0 1 .707 0zm-2.823 1.09a.5.5 0 0 1 .5-.5h.052a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H9.95a.5.5 0 0 1-.5-.5v-2zm-2.748-9.16a.5.5 0 0 1-.5.5h-.05a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5h.05a.5.5 0 0 1 .5.5v2zm-2.116.383a.5.5 0 0 1 0 .707l-.036.036a.5.5 0 0 1-.707 0L2.428 2.965a.5.5 0 0 1 0-.707l.037-.036a.5.5 0 0 1 .707 0l1.414 1.414zm-1.09 2.823h-2a.5.5 0 0 1-.5-.5v-.051a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v.05a.5.5 0 0 1-.5.5z"/></symbol><symbol viewBox="0 0 16 16" id="user" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 8c-6.888 0-6.976-.78-6.976-2.52S2.144 8 8 8s6.976 2.692 6.976 4.48c0 1.788-.088 2.52-6.976 2.52z"/></symbol><symbol viewBox="0 0 16 16" id="users" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="volume-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1 5h1v6H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zm2 0l4.445-2.964A1 1 0 0 1 9 2.87v10.26a1 1 0 0 1-1.555.833L3 11V5zm10.283 7.89a.5.5 0 0 1-.66-.752A5.485 5.485 0 0 0 14.5 8c0-1.601-.687-3.09-1.865-4.128a.5.5 0 0 1 .661-.75A6.484 6.484 0 0 1 15.5 8a6.485 6.485 0 0 1-2.217 4.89zm-2.002-2.236a.5.5 0 1 1-.652-.758c.55-.472.871-1.157.871-1.896 0-.732-.315-1.411-.856-1.883a.5.5 0 0 1 .658-.753A3.492 3.492 0 0 1 12.5 8c0 1.033-.45 1.994-1.219 2.654z"/></symbol><symbol viewBox="0 0 16 16" id="warning" xmlns="http://www.w3.org/2000/svg"><path d="M15.34 10.479A3 3 0 0 1 12.756 15h-9.51A3 3 0 0 1 .66 10.479l4.755-8.083a3 3 0 0 1 5.172 0l4.755 8.083zm-6.478-7.07a1 1 0 0 0-1.724 0l-4.755 8.084A1 1 0 0 0 3.245 13h9.51a1 1 0 0 0 .862-1.507L8.862 3.41zM8 5a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="work" xmlns="http://www.w3.org/2000/svg"><path d="M12 3h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h1V2a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1zM6 2v1h4V2H6zM3 5a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H3zm1.5 1a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5zm7 0a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5z"/></symbol></svg> \ No newline at end of file
+<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><symbol viewBox="0 0 16 16" id="abuse" xmlns="http://www.w3.org/2000/svg"><path d="M11.408.328l4.029 3.222A1.5 1.5 0 0 1 16 4.72v6.555a1.5 1.5 0 0 1-.563 1.171l-4.026 3.224a1.5 1.5 0 0 1-.937.329H5.529a1.5 1.5 0 0 1-.937-.328L.563 12.45A1.5 1.5 0 0 1 0 11.28V4.724a1.5 1.5 0 0 1 .563-1.171L4.589.329A1.5 1.5 0 0 1 5.526 0h4.945c.34 0 .67.116.937.328zM10.296 2H5.702L2 4.964v6.074L5.704 14h4.594L14 11.036V4.962L10.296 2zM8 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"/></symbol><symbol viewBox="0 0 16 16" id="account" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9.195 9.965l-.568-.875a.25.25 0 0 1 .015-.294l.405-.5a.25.25 0 0 1 .283-.075l.938.36c.257-.183.543-.325.851-.42l.322-.988A.25.25 0 0 1 11.679 7h.642a.25.25 0 0 1 .238.173l.322.988c.308.095.594.237.851.42l.938-.36a.25.25 0 0 1 .283.076l.405.5a.25.25 0 0 1 .015.293l-.568.875c.113.297.18.616.193.95l.898.54a.25.25 0 0 1 .115.27l-.144.626a.25.25 0 0 1-.222.193l-1.115.098a3.015 3.015 0 0 1-.512.608l.165 1.18a.25.25 0 0 1-.138.259l-.577.281a.25.25 0 0 1-.29-.05l-.874-.905a3.035 3.035 0 0 1-.608 0l-.875.904a.25.25 0 0 1-.289.051l-.577-.281a.25.25 0 0 1-.138-.26l.165-1.18a3.015 3.015 0 0 1-.512-.607l-1.115-.098a.25.25 0 0 1-.222-.193l-.144-.626a.25.25 0 0 1 .115-.27l.898-.54c.013-.334.08-.653.193-.95zM6.789 8.023A12.845 12.845 0 0 0 6 8c-5.036 0-6 2.74-6 4.48C0 14.22.076 15 6 15c.553 0 1.055-.006 1.51-.02A5.977 5.977 0 0 1 6 11c0-1.083.287-2.1.79-2.977zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6zM12 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="admin" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.162 2.5a3.5 3.5 0 0 1-3.163 5.479L6.08 14.766a1.5 1.5 0 0 1-2.598-1.5L7.4 6.479A3.5 3.5 0 0 1 10.564 1L8.9 3.88l2.599 1.5 1.663-2.88zm-8.63 11.949a.5.5 0 1 0 .5-.866.5.5 0 0 0-.5.866z"/></symbol><symbol viewBox="0 0 16 16" id="angle-double-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.414 7.95l4.243-4.243a1 1 0 0 0-1.414-1.414l-4.95 4.95a.997.997 0 0 0 0 1.414l4.95 4.95a1 1 0 1 0 1.414-1.415L10.414 7.95zm-7 0l4.243-4.243a1 1 0 0 0-1.414-1.414l-4.95 4.95a.997.997 0 0 0 0 1.414l4.95 4.95a1 1 0 0 0 1.414-1.415L3.414 7.95z"/></symbol><symbol viewBox="0 0 16 16" id="angle-double-right" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.536 7.95L1.293 3.707a1 1 0 0 1 1.414-1.414l4.95 4.95a.997.997 0 0 1 0 1.414l-4.95 4.95a1 1 0 1 1-1.414-1.415L5.536 7.95zm7 0L8.293 3.707a1 1 0 0 1 1.414-1.414l4.95 4.95a.997.997 0 0 1 0 1.414l-4.95 4.95a1 1 0 0 1-1.414-1.415l4.243-4.242z"/></symbol><symbol viewBox="0 0 16 16" id="angle-down" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="angle-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.757 8l4.95-4.95a1 1 0 1 0-1.414-1.414L3.636 7.293a.997.997 0 0 0 0 1.414l5.657 5.657a1 1 0 0 0 1.414-1.414L5.757 8z"/></symbol><symbol viewBox="0 0 16 16" id="angle-right" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.243 8l-4.95-4.95a1 1 0 0 1 1.414-1.414l5.657 5.657a.997.997 0 0 1 0 1.414l-5.657 5.657a1 1 0 0 1-1.414-1.414L10.243 8z"/></symbol><symbol viewBox="0 0 16 16" id="angle-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 6.757l-4.95 4.95a1 1 0 1 1-1.414-1.414l5.657-5.657a.997.997 0 0 1 1.414 0l5.657 5.657a1 1 0 0 1-1.414 1.414L8 6.757z"/></symbol><symbol viewBox="0 0 16 16" id="appearance" xmlns="http://www.w3.org/2000/svg"><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.858a23.85 23.85 0 0 1-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-.023a9.7 9.7 0 0 0 .478-.035c-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"/></symbol><symbol viewBox="0 0 16 16" id="applications" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="approval" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.536 10.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 1 1 9.12 9.243l1.415 1.414zM7.632 8.109A2 2 0 0 0 7 11.364l2.121 2.121a1.996 1.996 0 0 0 2.807.021C11.686 14.554 10.627 15 6 15c-5.924 0-6-.78-6-2.52S.964 8 6 8c.6 0 1.142.038 1.632.109zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></symbol><symbol viewBox="0 0 16 16" id="arrow-down" xmlns="http://www.w3.org/2000/svg"><path d="M10.472 7.282a.862.862 0 0 1 1.26-.006c.357.364.357.958 0 1.285L8.627 11.73A.886.886 0 0 1 8 12a.849.849 0 0 1-.627-.27L4.275 8.561a.904.904 0 0 1-.013-1.285.861.861 0 0 1 1.26-.007l2.486 2.527z"/></symbol><symbol viewBox="0 0 16 16" id="arrow-right" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="assignee" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12 5V4a1 1 0 0 1 2 0v1h1a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0V7h-1a1 1 0 0 1 0-2h1zM5.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"/></symbol><symbol viewBox="0 0 16 16" id="bold" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M4 12.5v-9A1.5 1.5 0 0 1 5.5 2h2.104c2.182 0 3.879.681 3.879 2.982 0 1.067-.517 2.227-1.374 2.595v.073C11.176 7.963 12 8.865 12 10.466 12 12.914 10.19 14 7.911 14H5.5A1.5 1.5 0 0 1 4 12.5zm2.376-5.696H7.49c1.164 0 1.665-.552 1.665-1.417 0-.94-.534-1.289-1.649-1.289h-1.13v2.706zm0 5.098h1.341c1.293 0 1.956-.515 1.956-1.62 0-1.049-.647-1.472-1.956-1.472H6.376v3.092z"/></symbol><symbol viewBox="0 0 16 16" id="book" xmlns="http://www.w3.org/2000/svg"><path d="M7 2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2v4.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 7 6.191V2zM5 0h6a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H5a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4z"/></symbol><symbol viewBox="0 0 16 16" id="bookmark" xmlns="http://www.w3.org/2000/svg"><path d="M6.746 10.505a2 2 0 0 1 2.508 0L11 11.911V3H5v8.91l1.746-1.405zM5 1h6a2 2 0 0 1 2 2v10.999a1 1 0 0 1-1.627.779L8 12.064l-3.373 2.714A1 1 0 0 1 3 13.998V3a2 2 0 0 1 2-2z"/></symbol><symbol viewBox="0 0 16 16" id="branch" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="bullhorn" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6.143 10H7V4H3a3 3 0 1 0 0 6h.143l.734 5.141a1 1 0 0 0 .99.859h1.556a.5.5 0 0 0 .495-.57L6.143 10zM8 4c1.034.02 2.039-.274 3.014-.883.727-.455 1.836-1.334 3.328-2.637A1 1 0 0 1 16 1.233v10.764a1 1 0 0 1-1.595.803c-1.658-1.227-2.788-1.992-3.392-2.294-.781-.39-1.785-.559-3.013-.506V4z"/></symbol><symbol viewBox="0 0 16 16" id="calendar" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12 2h2a2 2 0 0 1 2 2H0a2 2 0 0 1 2-2h2V1a1 1 0 1 1 2 0v1h4V1a1 1 0 1 1 2 0v1zM0 4h16v9a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V4zm2 2.5V13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6.5a.5.5 0 0 0-.5-.5h-11a.5.5 0 0 0-.5.5zM5 8h2a1 1 0 1 1 0 2H5a1 1 0 1 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="cancel" xmlns="http://www.w3.org/2000/svg"><path d="M3.11 4.523a6 6 0 0 0 8.367 8.367L3.109 4.524zM4.522 3.11l8.368 8.368A6 6 0 0 0 4.524 3.11zM8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16z"/></symbol><symbol viewBox="0 0 16 16" id="chart" xmlns="http://www.w3.org/2000/svg"><path d="M15 14a1 1 0 0 1 0 2H2a2 2 0 0 1-2-2V1a1 1 0 1 1 2 0v13h13zM3.142 8.735l2.502-2.561a.5.5 0 0 1 .714-.003L8 7.833l3.592-4.553a.5.5 0 0 1 .796.015l2.516 3.454a.5.5 0 0 1 .096.295V12.5a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5V9.085a.5.5 0 0 1 .142-.35z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-down" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.078 8.2l3.535-3.536a2 2 0 0 1 2.828 2.828l-4.949 4.95c-.39.39-.902.586-1.414.586a1.994 1.994 0 0 1-1.414-.586l-4.95-4.95a2 2 0 1 1 2.828-2.828l3.536 3.535z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.977 7.998l3.535-3.535a2 2 0 1 0-2.828-2.828l-4.95 4.949c-.39.39-.586.902-.586 1.414 0 .512.196 1.024.586 1.414l4.95 4.95a2 2 0 1 0 2.828-2.828L7.977 7.998z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-right" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.22 7.998L4.683 4.463a2 2 0 0 1 2.828-2.828l4.95 4.949c.39.39.586.902.586 1.414a1.99 1.99 0 0 1-.586 1.414l-4.95 4.95a2 2 0 0 1-2.828-2.828l3.535-3.536z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.778 8.957l3.535 3.535a2 2 0 1 0 2.828-2.828l-4.949-4.95a1.994 1.994 0 0 0-1.414-.586c-.512 0-1.024.196-1.414.586l-4.95 4.95a2 2 0 1 0 2.828 2.828l3.536-3.535z"/></symbol><symbol viewBox="0 0 16 16" id="clock" xmlns="http://www.w3.org/2000/svg"><path d="M9 7h1a1 1 0 0 1 0 2H8a.997.997 0 0 1-1-1V5a1 1 0 1 1 2 0v2zm-1 9A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm0-2A6 6 0 1 0 8 2a6 6 0 0 0 0 12z"/></symbol><symbol viewBox="0 0 16 16" id="close" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9.414 8l4.95-4.95a1 1 0 0 0-1.414-1.414L8 6.586l-4.95-4.95A1 1 0 0 0 1.636 3.05L6.586 8l-4.95 4.95a1 1 0 1 0 1.414 1.414L8 9.414l4.95 4.95a1 1 0 1 0 1.414-1.414L9.414 8z"/></symbol><symbol viewBox="0 0 16 16" id="code" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M15.871 8.243a.997.997 0 0 0-.293-.707L12.75 4.707a1 1 0 0 0-1.414 1.414l2.12 2.122-2.12 2.121a1 1 0 0 0 1.414 1.414l2.828-2.828a.997.997 0 0 0 .293-.707zm-13.243 0L4.75 6.12a1 1 0 1 0-1.414-1.414L.507 7.536a.997.997 0 0 0 0 1.414l2.829 2.828a1 1 0 1 0 1.414-1.414L2.628 8.243zm6.407-4.107a1 1 0 0 1 .707 1.225L8.19 11.157a1 1 0 1 1-1.931-.518L7.81 4.843a1 1 0 0 1 1.224-.707z"/></symbol><symbol viewBox="0 0 9 13" id="collapse"><path d="M.084.25C.01.18-.015.12.008.071.031.024.093 0 .194 0h8.521c.1 0 .162.024.185.072.023.048-.002.107-.075.177l-4.11 3.935a.372.372 0 0 1-.11.072h-.301a.508.508 0 0 1-.11-.072L.084.249zM.377 6.88a.364.364 0 0 1-.26-.105.334.334 0 0 1-.11-.25v-.709c0-.096.036-.179.11-.249a.364.364 0 0 1 .26-.105h8.15c.101 0 .188.035.261.105.074.07.11.153.11.25v.709c0 .096-.036.179-.11.249a.364.364 0 0 1-.26.105H.377zM.084 12.132c-.074.07-.099.129-.076.177.023.048.085.072.186.072h8.521c.1 0 .162-.024.185-.072.023-.048-.002-.107-.075-.177l-4.11-3.935a.372.372 0 0 0-.11-.072h-.301a.508.508 0 0 0-.11.072l-4.11 3.935z"/></symbol><symbol viewBox="0 0 16 16" id="comment" xmlns="http://www.w3.org/2000/svg"><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.586z"/></symbol><symbol viewBox="0 0 16 16" id="comment-dots" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="comment-next" xmlns="http://www.w3.org/2000/svg"><path d="M8 5V4a.5.5 0 0 1 .8-.4l2.667 2a.5.5 0 0 1 0 .8L8.8 8.4A.5.5 0 0 1 8 8V7H6a1 1 0 1 1 0-2h2zM1.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.586z"/></symbol><symbol viewBox="0 0 16 16" id="comments" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3.75 10L0 13V3a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3.75zM13 5h1a2 2 0 0 1 2 2v8l-2.667-2H8a2 2 0 0 1-2-2h4a3 3 0 0 0 3-3V5z"/></symbol><symbol viewBox="0 0 16 16" id="commit" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="credit-card" xmlns="http://www.w3.org/2000/svg"><path d="M14 5a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1h12zm0 3H2v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V8zM3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm6.5 8h3a.5.5 0 1 1 0 1h-3a.5.5 0 1 1 0-1z"/></symbol><symbol viewBox="0 0 16 16" id="cut" xmlns="http://www.w3.org/2000/svg"><rect width="16" height="2" y="7" fill-rule="evenodd" rx="1"/></symbol><symbol viewBox="0 0 16 16" id="dashboard" xmlns="http://www.w3.org/2000/svg"><path d="M7.709 10.021l.696-2.6a.5.5 0 0 1 .966.26l-.657 2.45A2 2 0 0 1 10 12H6a2 2 0 0 1 1.709-1.979zM0 8.9a8 8 0 0 1 15.998 0H16v3.6a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5V8.9zM14 9A6 6 0 1 0 2 9v3.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5V9zM3.5 9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm9 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-7-3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm5 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1z"/></symbol><symbol viewBox="0 0 16 16" id="disk" xmlns="http://www.w3.org/2000/svg"><path d="M16 11.764V3a3 3 0 0 0-3-3H3a3 3 0 0 0-3 3v8.764A2.989 2.989 0 0 1 2 11V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v8c.768 0 1.47.289 2 .764zM2 12h12a2 2 0 1 1 0 4H2a2 2 0 1 1 0-4zm10 1a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="doc_code" xmlns="http://www.w3.org/2000/svg"><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.414zm1.036 7.607a.498.498 0 0 1-.147.354l-1.414 1.414a.5.5 0 0 1-.707-.707l1.06-1.06-1.06-1.061a.5.5 0 0 1 .707-.707l1.414 1.414a.498.498 0 0 1 .147.353zm-4.822 0l1.06 1.061a.5.5 0 0 1-.706.707l-1.414-1.414a.498.498 0 0 1 0-.707l1.414-1.414a.5.5 0 1 1 .707.707l-1.06 1.06zM5 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-4z"/></symbol><symbol viewBox="0 0 16 16" id="doc_image" xmlns="http://www.w3.org/2000/svg"><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.414zM7.333 9.667l1.313-1.313a.5.5 0 0 1 .708 0L12 11H4l2.188-1.75a.5.5 0 0 1 .624 0l.521.417zM5 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 8a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zM4 11h8v.7a.3.3 0 0 1-.3.3H4.3a.3.3 0 0 1-.3-.3V11z"/></symbol><symbol viewBox="0 0 16 16" id="doc_text" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 105 26" id="double-headed-arrow" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1.018 11.089L15.138.614c1.23-.911 3.086-.795 4.147.26.461.46.715 1.045.715 1.651v20.95C20 24.869 18.684 26 17.06 26a3.238 3.238 0 0 1-1.921-.614L1.019 14.911C-.212 14-.347 12.405.714 11.35c.094-.094.195-.18.303-.261zm102.964 0c.108.08.21.167.303.26 1.061 1.056.925 2.65-.303 3.562l-14.12 10.475A3.238 3.238 0 0 1 87.94 26C86.316 26 85 24.87 85 23.475V2.525c0-.606.254-1.192.715-1.65 1.061-1.056 2.917-1.172 4.146-.26l14.12 10.474zM35 17a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm18 0a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm18 0a4 4 0 1 1 0-8 4 4 0 0 1 0 8z"/></symbol><symbol viewBox="0 0 16 16" id="download" xmlns="http://www.w3.org/2000/svg"><path d="M9 12h1a.5.5 0 0 1 .4.8l-2 2.667a.5.5 0 0 1-.8 0l-2-2.667A.5.5 0 0 1 6 12h1V8a1 1 0 1 1 2 0v4zM4 9a1 1 0 1 1 0 2 4 4 0 0 1-1.971-7.481 4 4 0 0 1 6.633-2.505 3.999 3.999 0 0 1 3.82 2.014A4 4 0 0 1 12 11a1 1 0 0 1 0-2 2 2 0 1 0 0-4h-1a2 2 0 0 0-3.112-1.662A2 2 0 1 0 4.268 5H4a2 2 0 1 0 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="duplicate" xmlns="http://www.w3.org/2000/svg"><path d="M14 10h-3a1 1 0 0 1-1-1V6H8.527A.527.527 0 0 0 8 6.527V13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-3zm-4-7H8.527c-.18 0-.355.013-.527.04V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2v2H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h4a3 3 0 0 1 3 3zM8.527 4h2.323a.5.5 0 0 1 .35.143l4.65 4.551a.5.5 0 0 1 .15.357V13a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V6.527A2.527 2.527 0 0 1 8.527 4z"/></symbol><symbol viewBox="0 0 16 16" id="earth" xmlns="http://www.w3.org/2000/svg"><path d="M8.7 2.04l-.082.177c.283.223.422.413.417.571-.008.237-.311.057-.444.274-.133.218.038.542-.112.637-.15.096-.398-.386-.479-.46-.054-.049-.166-.257-.336-.625l-.216-.225a.844.844 0 0 0-.418-.035c-.177.038-.075.1-.035.132.04.032.32.037.452.2.132.164.03.224-.05.298-.054.05-.157.062-.31.035H5.952l-.402.398.03.325.229.455.324-.463c.008-.206.058-.342.15-.41.14-.1.342-.15.534-.085.191.066-.057.218.011.271.068.053.204-.098.313-.02.11.08.07.155.104.322.036.167.254.114.398.328.144.215.19.29.147.483-.043.195-.168.26-.305.232-.138-.028-.107-.246-.275-.348-.168-.102-.266-.114-.386-.054-.12.06-.016.129.023.235.04.106.274.321.224.43-.05.107-.108.116-.42 0-.21-.077-.414-.007-.615.212l-.76.722c-.153.715-.3 1.13-.44 1.243-.211.17-.177-.483-.483-.656-.306-.174-.494-.047-.8-.07-.307-.023-.42.65-.38.873a.434.434 0 0 0 .221.321c.236-.141.39-.184.465-.128.11.084-.144.267-.074.425.07.158.314.069.386.283.073.213.084.48-.05.706-.135.227-.275.178-.4.053-.127-.126-.033-.375-.255-.704-.223-.329-.381-.337-.63-.787-.158-.287-.35-.743-.575-1.366a6 6 0 0 0 3.21 7.198l.001-.075c0-.577-.004-.944-.012-1.102-.011-.236-.95-.945-1.104-1.2-.154-.256-.34-.595-.355-.746-.016-.151.185-.232.344-.325.16-.093-.11-.367.028-.626.137-.258.395-.438.496-.356.101.081.058.228.267.333.209.104.077-.213.456-.178.38.035.143.201.252.216.11.016.113-.127.299-.143.186-.015.282.445.471.622.19.178.452.008.611.043.159.034.267.09.402.255.136.166-.03.352.073.557.103.205 1.07.22 1.433.255.364.034.371.011.371.324s-.166.314-.453.507c-.286.193-.166.462-.38.762-.212.3-.316.062-.622.14-.306.077-.413.382-.452.568-.039.186-.386.094-.877.232-.29.082-.429.144-.569.204a6.002 6.002 0 0 0 7.682-4.3c-.094-.384-.18-.63-.258-.74-.213-.297-.36.21-.924.49-.564.278-.57-.288-.81-.49-.16-.133-.212-.44-.158-.92-.005-.478.02-.828.077-1.049.057-.221.126-.543.207-.965.351-.373.606-.572.764-.595.237-.034.336.374.658.3a.315.315 0 0 0 .035-.01 5.993 5.993 0 0 0-.475-.824l-.309-.043a.646.646 0 0 0-.332-.117c-.205-.02-.025.128-.089.24-.064.112-.235.724-.437.685-.201-.039-.204-.374-.17-.668.036-.294-.077-.35-.2-.412-.124-.062-.325-.213-.556-.295-.232-.082-.123-.175-.093-.274.03-.1.208-.015.193-.058-.014-.044-.313-.135-.266-.167.03-.02.2-.02.506.003l.216-.012.293-.163a.58.58 0 0 0-.376-.22c-.233-.036-.513-.034-.73-.142-.205-.103-.458-.36-.643-.638A5.965 5.965 0 0 0 8.7 2.04zM8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16z"/></symbol><symbol viewBox="0 0 1600 1600" id="ellipsis_v" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 18 18" id="emoji_slightly_smiling_face" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369.721.721 0 0 1 .568.047.715.715 0 0 1 .37.445 2.91 2.91 0 0 0 1.084 1.518A2.93 2.93 0 0 0 9 12.75a2.93 2.93 0 0 0 1.775-.58 2.913 2.913 0 0 0 1.084-1.518.711.711 0 0 1 .375-.445.737.737 0 0 1 .575-.047c.195.063.34.186.433.37.094.183.11.372.047.568zM7.5 6c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 6 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 6 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 12 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 12 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 18 18" id="emoji_smile" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568zM14 6.37c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm-6.5 0c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm9 2.63a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 18 18" id="emoji_smiley" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568h.001zM7.5 6c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 6 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 6 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 12 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 12 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6c.92.397 1.91.6 2.912.598a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39c.397-.92.6-1.91.598-2.912zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z"/></symbol><symbol viewBox="0 0 16 16" id="epic" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M14.985 8.044l-.757 2.272a1 1 0 0 1-.949.684H1.618a1 1 0 0 1-.894-1.447l.318-.637A2 2 0 0 0 1.618 9h11.661a2 2 0 0 0 1.706-.956zm0 3l-.757 2.272a1 1 0 0 1-.949.684H1.618a1 1 0 0 1-.894-1.447l.318-.637a2 2 0 0 0 .576.084h11.661a2 2 0 0 0 1.706-.956zM3.618 2h10.995a1 1 0 0 1 .948 1.316l-1.333 4a1 1 0 0 1-.949.684H1.618a1 1 0 0 1-.894-1.447l2-4A1 1 0 0 1 3.618 2zm-.382 4h9.322l.667-2H4.236l-1 2z"/></symbol><symbol viewBox="0 0 16 16" id="external-link" xmlns="http://www.w3.org/2000/svg"><path d="M13.121 4.177l-4.95 4.95a1 1 0 1 1-1.414-1.414l4.95-4.95-1.386-1.386a.5.5 0 0 1 .299-.85l4.709-.524a.5.5 0 0 1 .552.552l-.523 4.71a.5.5 0 0 1-.851.297l-1.386-1.385zM12 8.884a1 1 0 0 1 2 0v4a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3v-8a3 3 0 0 1 3-3h4a1 1 0 1 1 0 2H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-4z"/></symbol><symbol viewBox="0 0 16 16" id="eye" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="eye-slash" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="file-addition" xmlns="http://www.w3.org/2000/svg"><path d="M7 7V5a1 1 0 1 1 2 0v2h2a1 1 0 0 1 0 2H9v2a1 1 0 0 1-2 0V9H5a1 1 0 1 1 0-2h2zM3 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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3z"/></symbol><symbol viewBox="0 0 16 16" id="file-deletion" xmlns="http://www.w3.org/2000/svg"><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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3zm2 6h6a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="file-modified" xmlns="http://www.w3.org/2000/svg"><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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3zm5 4a3 3 0 1 1 0 6 3 3 0 0 1 0-6z"/></symbol><symbol viewBox="0 0 16 16" id="filter" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10 6v9l-3.724-1.862A.5.5 0 0 1 6 12.691V6L1.854 1.854A.5.5 0 0 1 2.207 1h11.586a.5.5 0 0 1 .353.854L10 6z"/></symbol><symbol viewBox="0 0 16 16" id="folder" xmlns="http://www.w3.org/2000/svg"><path d="M13 3a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a2 2 0 0 1 2-2h3.81a3 3 0 0 1 2.827 1.995L13 3z"/></symbol><symbol viewBox="0 0 16 16" id="folder-o" xmlns="http://www.w3.org/2000/svg"><path d="M13 5l-4.365-.005a2 2 0 0 1-1.882-1.33A1 1 0 0 0 5.81 3H2v9a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1zm0-2a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a2 2 0 0 1 2-2h3.81a3 3 0 0 1 2.827 1.995L13 3z"/></symbol><symbol viewBox="0 0 16 16" id="folder-open" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M14.59 5.464a2.998 2.998 0 0 1 1.096 3.845l-1.666 3.436A4 4 0 0 1 10.46 15H3a3 3 0 0 1-3-3V3a2 2 0 0 1 2-2h3.558a2 2 0 0 1 1.898 1.368l.21.632h4.973a2 2 0 0 1 2 2 2 2 0 0 1-.027.329l-.023.135zM5.285 7a1 1 0 0 0-.9.564l-1.939 4a1 1 0 0 0 .9 1.436h7.074a2 2 0 0 0 1.8-1.128l1.665-3.436a1 1 0 0 0-.9-1.436h-7.7z"/></symbol><symbol viewBox="0 0 16 16" id="fork" xmlns="http://www.w3.org/2000/svg"><path d="M9 12.268a2 2 0 1 1-2 0V8.874A4.002 4.002 0 0 1 4 5V3.732a2 2 0 1 1 2 0V5a2 2 0 1 0 4 0V3.732a2 2 0 1 1 2 0V5a4.002 4.002 0 0 1-3 3.874v3.394zM11 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM5 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="geo-nodes" xmlns="http://www.w3.org/2000/svg"><path d="M9.7 13.1l-.2.2c-.7.8-2 .9-2.8.1-.1 0-.1-.1-.1-.1l-.2-.2c-2 .2-3.4.7-3.4 1.4 0 .8 2.2 1.5 5 1.5s5-.7 5-1.5c0-.7-1.4-1.2-3.3-1.4M7.3 12.7c.4.4 1 .3 1.4-.1C11.6 9.5 13 7 13 5.3 13 2.4 10.8 0 8 0S3 2.4 3 5.3C3 7 4.4 9.5 7.3 12.7M8 2c1.6 0 3 1.4 3 3.3 0 1-1 2.8-3 5.2-2-2.4-3-4.2-3-5.2C5 3.4 6.4 2 8 2"/><circle cx="8" cy="5" r="1"/></symbol><symbol viewBox="0 0 16 16" id="git-merge" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="group" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3.048 11.997C-.377 11.975.013 11.782.013 10.56.013 9.235.653 8 4 8c.444 0 .84.022 1.194.062.164.435.426.82.76 1.132-1.786.389-2.721 1.353-2.906 2.803zm2.94-7.222a2.993 2.993 0 0 0-.976 1.95 2 2 0 1 1 .975-1.95zm6.964 7.222c-.185-1.45-1.12-2.414-2.906-2.803.334-.311.596-.697.76-1.132C11.16 8.022 11.556 8 12 8c3.346 0 3.987 1.235 3.987 2.56 0 1.222.39 1.415-3.035 1.437zm-1.964-5.272a2.993 2.993 0 0 0-.976-1.95 2 2 0 1 1 .976 1.95zM8 9a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 5c-2.177 0-3.987-.115-3.987-1.44S4.653 10 8 10c3.346 0 3.987 1.235 3.987 2.56S10.177 14 8 14z"/></symbol><symbol viewBox="0 0 16 16" id="history" xmlns="http://www.w3.org/2000/svg"><path d="M2.868 3.24a7 7 0 1 1-.043 9.475 1 1 0 0 1 1.478-1.348 5 5 0 1 0 .124-6.865l.796.645a.5.5 0 0 1-.193.873l-3.232.814a.5.5 0 0 1-.622-.504L1.3 3a.5.5 0 0 1 .814-.37l.754.61zM9 8h1a1 1 0 0 1 0 2H8a.997.997 0 0 1-1-1V6a1 1 0 1 1 2 0v2z"/></symbol><symbol viewBox="0 0 16 16" id="home" xmlns="http://www.w3.org/2000/svg"><path d="M9 13h3v-3H4v3h3v-1a1 1 0 0 1 2 0v1zm5-3v3.659c0 .729-.657 1.341-1.5 1.341h-9c-.843 0-1.5-.612-1.5-1.341V10h-.88C.502 10 0 9.486 0 8.853c0-.307.12-.601.333-.816l6.405-6.463a1.56 1.56 0 0 1 2.374-.052L15.66 8.03c.444.441.455 1.167.024 1.622a1.108 1.108 0 0 1-.804.348H14zM7.95 3.273l-4.595 4.64h9.264l-4.67-4.64z"/></symbol><symbol viewBox="0 0 16 16" id="hook" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="hourglass" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 38 38" id="image-comment-dark" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><circle cx="19" cy="19" r="18" fill="#1F78D1"/><path fill="#FFF" fill-rule="nonzero" d="M19 38C8.507 38 0 29.493 0 19S8.507 0 19 0s19 8.507 19 19-8.507 19-19 19zm0-2c9.389 0 17-7.611 17-17S28.389 2 19 2 2 9.611 2 19s7.611 17 17 17zm-6.293-8.293c-.63.63-1.707.184-1.707-.707V15a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-7.586l-3.707 3.707zM13 24.586l2.293-2.293A1 1 0 0 1 16 22h8a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1H14a1 1 0 0 0-1 1v9.586z"/></g></symbol><symbol viewBox="0 0 38 38" id="image-comment-light" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><circle cx="19" cy="19" r="18" fill="#FFF"/><path fill="#1F78D1" fill-rule="nonzero" d="M19 38C8.507 38 0 29.493 0 19S8.507 0 19 0s19 8.507 19 19-8.507 19-19 19zm0-2c9.389 0 17-7.611 17-17S28.389 2 19 2 2 9.611 2 19s7.611 17 17 17zm-6.293-8.293c-.63.63-1.707.184-1.707-.707V15a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-7.586l-3.707 3.707zM13 24.586l2.293-2.293A1 1 0 0 1 16 22h8a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1H14a1 1 0 0 0-1 1v9.586z"/></g></symbol><symbol viewBox="0 0 16 16" id="import" xmlns="http://www.w3.org/2000/svg"><path d="M9 8h1a.5.5 0 0 1 .4.8l-2 2.667a.5.5 0 0 1-.8 0L5.6 8.8A.5.5 0 0 1 6 8h1V1a1 1 0 1 1 2 0v7zM0 8a1 1 0 1 1 2 0 6 6 0 1 0 12 0 1 1 0 0 1 2 0A8 8 0 1 1 0 8z"/></symbol><symbol viewBox="0 0 16 16" id="issue-block" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.803 8a5.97 5.97 0 0 0-.462 1H4.5a.5.5 0 0 1 0-1h1.303zM4.5 5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1zm7.5.083a6.04 6.04 0 0 0-2 0V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.083a5.96 5.96 0 0 0 .72 2H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v2.083zm1.121 3.796zM11 16a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm-1.293-2.292a3 3 0 0 0 4.001-4.001l-4.001 4zm-1.415-1.415l4.001-4a3 3 0 0 0-4.001 4.001z"/></symbol><symbol viewBox="0 0 16 16" id="issue-child" xmlns="http://www.w3.org/2000/svg"><path d="M11 8H5v1h1a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2V7a.997.997 0 0 1 1-1h3V4H4.5a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H9v2h3a.997.997 0 0 1 1 1v2h2a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-5a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h1V8zm-9 3v2h3v-2H2zm9 0v2h3v-2h-3z"/></symbol><symbol viewBox="0 0 16 16" id="issue-close" xmlns="http://www.w3.org/2000/svg"><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.414zM8 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 12z"/></symbol><symbol viewBox="0 0 16 16" id="issue-duplicate" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="issue-external" xmlns="http://www.w3.org/2000/svg"><path d="M11 4a5.99 5.99 0 0 0-2 .341V3a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2.528a6.003 6.003 0 0 0 2.705 1.736A2.99 2.99 0 0 1 8 16H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h4a3 3 0 0 1 3 3v1zM8.212 8.97l-.568-.876A.25.25 0 0 1 7.66 7.8l.404-.5a.25.25 0 0 1 .284-.076l.938.36c.256-.182.543-.325.85-.42l.323-.988a.25.25 0 0 1 .237-.173h.643a.25.25 0 0 1 .238.173l.321.989c.308.094.595.237.852.418l.937-.359a.25.25 0 0 1 .284.076l.404.5a.25.25 0 0 1 .016.293l-.568.875c.113.297.18.616.192.95l.9.54a.25.25 0 0 1 .114.27l-.145.627a.25.25 0 0 1-.221.192l-1.115.098a3.015 3.015 0 0 1-.512.608l.165 1.18a.25.25 0 0 1-.138.259l-.577.282a.25.25 0 0 1-.29-.051l-.874-.905a3.035 3.035 0 0 1-.608 0l-.875.905a.25.25 0 0 1-.29.05l-.577-.281a.25.25 0 0 1-.138-.26L9 12.254a3.015 3.015 0 0 1-.512-.607l-1.114-.098a.25.25 0 0 1-.222-.192l-.145-.627a.25.25 0 0 1 .115-.27l.899-.54c.012-.334.08-.653.192-.95zm2.806 2.034a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="issue-new" xmlns="http://www.w3.org/2000/svg"><path d="M10 2V1a1 1 0 0 1 2 0v1h1a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0V4H9a1 1 0 1 1 0-2h1zm0 6a1 1 0 0 1 2 0v5a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h1a1 1 0 1 1 0 2H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V8z"/></symbol><symbol viewBox="0 0 16 16" id="issue-open" xmlns="http://www.w3.org/2000/svg"><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 12zm0-2a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="issue-open-m" xmlns="http://www.w3.org/2000/svg"><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 12z"/></symbol><symbol viewBox="0 0 16 16" id="issue-parent" xmlns="http://www.w3.org/2000/svg"><path d="M11 11H5v1h1.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5H3v-2a.997.997 0 0 1 1-1h3V7H5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H9v2h3a.997.997 0 0 1 1 1v2h2.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5H11v-1zM6 3v2h4V3H6z"/></symbol><symbol viewBox="0 0 16 16" id="issues" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="italic" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.5 12l2-8H6a1 1 0 1 1 0-2h6a1 1 0 0 1 0 2h-1.5l-2 8H10a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2h1.5z"/></symbol><symbol viewBox="0 0 16 16" id="key" xmlns="http://www.w3.org/2000/svg"><path d="M7.575 6.689a4.002 4.002 0 0 1 6.274-4.86 4 4 0 0 1-4.86 6.274l-2.21 2.21.706.708a1 1 0 1 1-1.414 1.414l-.707-.707-.707.707.707.707a1 1 0 1 1-1.414 1.414l-.707-.707a1 1 0 0 1-1.414-1.414l5.746-5.746zm2.032-.618a2 2 0 1 0 2.828-2.828A2 2 0 0 0 9.607 6.07z"/></symbol><symbol viewBox="0 0 16 16" id="key-2" xmlns="http://www.w3.org/2000/svg"><path d="M5.172 14.157l-.344.344-2.485.133a.462.462 0 0 1-.497-.503l.14-2.24a.599.599 0 0 1 .177-.382l5.155-5.155a4 4 0 1 1 2.828 2.828l-1.439 1.44-1.06-.354-.708.707.354 1.06-.707.708-1.06-.354-.708.707.354 1.06zm6.01-8.839a1 1 0 1 0 1.414-1.414 1 1 0 0 0-1.414 1.414z"/></symbol><symbol viewBox="0 0 16 16" id="label" xmlns="http://www.w3.org/2000/svg"><path d="M11.782 14.718a3 3 0 0 1-4.242 0L1.652 8.829a2 2 0 0 1-.565-1.702l.54-3.703a2 2 0 0 1 1.69-1.69l3.703-.54a2 2 0 0 1 1.703.564l5.888 5.888a3 3 0 0 1 0 4.243l-2.829 2.829zm1.415-5.657L7.309 3.173l-3.703.54-.54 3.702 5.888 5.888a1 1 0 0 0 1.414 0l2.829-2.828a1 1 0 0 0 0-1.414zM5.732 5.525A1 1 0 1 1 7.146 6.94a1 1 0 0 1-1.414-1.414z"/></symbol><symbol viewBox="0 0 16 16" id="labels" xmlns="http://www.w3.org/2000/svg"><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-.667zM.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"/></symbol><symbol viewBox="0 0 16 16" id="leave" xmlns="http://www.w3.org/2000/svg"><path d="M11 7V5.883a.5.5 0 0 1 .757-.429l3.528 2.117a.5.5 0 0 1 0 .858l-3.528 2.117a.5.5 0 0 1-.757-.43V9H7a1 1 0 1 1 0-2h4zm-2 6.256a1 1 0 0 1 2 0A2.744 2.744 0 0 1 8.256 16H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h5.19A2.81 2.81 0 0 1 11 2.81a1 1 0 0 1-2 0A.81.81 0 0 0 8.19 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h5.256c.41 0 .744-.333.744-.744z"/></symbol><symbol viewBox="0 0 16 16" id="level-up" xmlns="http://www.w3.org/2000/svg"><path fill="#2E2E2E" fill-rule="evenodd" d="M7 6h3.489a.5.5 0 0 0 .373-.832L6.374.117a.5.5 0 0 0-.748 0l-4.488 5.05A.5.5 0 0 0 1.51 6H5v7a3 3 0 0 0 3 3h6a1 1 0 0 0 0-2H8a1 1 0 0 1-1-1V6z"/></symbol><symbol viewBox="0 0 16 16" id="license" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12.56 8.9l2.66 4.606a.3.3 0 0 1-.243.45l-1.678.094a.1.1 0 0 0-.078.044l-.953 1.432a.3.3 0 0 1-.51-.016L9.097 10.9a5.994 5.994 0 0 0 3.464-2zm-5.23 2.063L4.707 15.51a.3.3 0 0 1-.51.016l-.953-1.432a.1.1 0 0 0-.078-.044l-1.678-.094a.3.3 0 0 1-.243-.45l2.48-4.297a5.983 5.983 0 0 0 3.607 1.754zM8 10A5 5 0 1 1 8 0a5 5 0 0 1 0 10zm0-2a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0-1a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="link" xmlns="http://www.w3.org/2000/svg"><path d="M6.986 3.35l2.12-2.122a4 4 0 0 1 5.657 5.657l-2.828 2.829a4 4 0 0 1-5.657 0 1 1 0 0 1 1.414-1.415 2 2 0 0 0 2.829 0l2.828-2.828a2 2 0 1 0-2.828-2.828l-1.001 1a5.018 5.018 0 0 0-2.534-.294zm2.12 9.192l-2.12 2.121a4 4 0 1 1-5.658-5.656l2.829-2.829a4 4 0 0 1 5.657 0 1 1 0 1 1-1.415 1.414 2 2 0 0 0-2.828 0l-2.828 2.829a2 2 0 1 0 2.828 2.828l1.001-1.001a5.018 5.018 0 0 0 2.534.294z"/></symbol><symbol viewBox="0 0 16 16" id="list-bulleted" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm0 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4-7h10a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2zm0 5h10a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2zm-4 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4-2h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="list-numbered" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6 2h8a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2zm0 5h8a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2zm0 5h8a1 1 0 0 1 0 2H6a1 1 0 0 1 0-2zM1.156 5v-.828h.816V2.204h-.72v-.636c.432-.084.708-.192.996-.372h.756v2.976h.684V5H1.156zm-.18 5v-.588c.9-.828 1.596-1.464 1.596-1.98 0-.342-.192-.504-.468-.504-.252 0-.444.18-.624.36l-.552-.552c.396-.42.756-.612 1.32-.612.768 0 1.308.492 1.308 1.248 0 .612-.576 1.284-1.092 1.812.192-.024.468-.048.636-.048h.636V10H.976zm1.26 5.072c-.618 0-1.068-.204-1.356-.54l.468-.648c.234.216.51.36.78.36.336 0 .552-.12.552-.36 0-.288-.15-.456-.948-.456v-.72c.636 0 .828-.168.828-.432 0-.228-.138-.348-.396-.348-.252 0-.432.108-.672.312l-.516-.624c.372-.312.768-.492 1.236-.492.84 0 1.38.384 1.38 1.074 0 .366-.204.642-.612.822v.024c.432.132.732.432.732.912 0 .72-.684 1.116-1.476 1.116z"/></symbol><symbol viewBox="0 0 16 16" id="location" xmlns="http://www.w3.org/2000/svg"><path d="M8.755 15.144a1 1 0 0 1-1.51 0C3.748 11.114 2 8.065 2 6a6 6 0 1 1 12 0c0 2.065-1.748 5.113-5.245 9.144zM12 6a4 4 0 1 0-8 0c0 1.314 1.312 3.71 4 6.944C10.688 9.71 12 7.314 12 6zM8 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="location-dot" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6.314 13.087C4.382 13.295 3 13.85 3 14.5c0 .828 2.239 1.5 5 1.5s5-.672 5-1.5c0-.65-1.382-1.205-3.314-1.413l-.202.225a2 2 0 0 1-2.968 0l-.202-.225zm2.428-.445a1 1 0 0 1-1.484 0C4.419 9.5 3 7.037 3 5.252 3 2.353 5.239 0 8 0s5 2.352 5 5.253c0 1.784-1.42 4.247-4.258 7.389zM11 5.252C11 3.436 9.634 2 8 2S5 3.435 5 5.253c0 1.027.974 2.824 3 5.203 2.026-2.38 3-4.176 3-5.203zM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="lock" xmlns="http://www.w3.org/2000/svg"><path d="M10 5V4h2v1a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3V4h2v1h4zM4 7a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H4zm0-3a4 4 0 1 1 8 0h-2a2 2 0 1 0-4 0H4z"/></symbol><symbol viewBox="0 0 16 16" id="lock-open" xmlns="http://www.w3.org/2000/svg"><path d="M4.044 4a4 4 0 0 1 6.99-2.658 1 1 0 1 1-1.495 1.33A2 2 0 0 0 6.044 4a.998.998 0 0 1-.07.367v.701H12a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3v-5a3 3 0 0 1 2.974-3V4h.07zM4 7.07a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H4z"/></symbol><symbol viewBox="0 0 16 16" id="log" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="mail" xmlns="http://www.w3.org/2000/svg"><path d="M14 5.6L9.338 9.796a2 2 0 0 1-2.676 0L2 5.6V11a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5.6zM3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm.212 2L8 8.31 12.788 4H3.212z"/></symbol><symbol viewBox="0 0 16 16" id="menu" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1.143 2h13.714C15.488 2 16 2.448 16 3s-.512 1-1.143 1H1.143C.512 4 0 3.552 0 3s.512-1 1.143-1zm0 5h13.714C15.488 7 16 7.448 16 8s-.512 1-1.143 1H1.143C.512 9 0 8.552 0 8s.512-1 1.143-1zm0 5h13.714c.631 0 1.143.448 1.143 1s-.512 1-1.143 1H1.143C.512 14 0 13.552 0 13s.512-1 1.143-1z"/></symbol><symbol viewBox="0 0 16 16" id="merge-request-close" xmlns="http://www.w3.org/2000/svg"><path d="M9.414 8l1.414 1.414a1 1 0 1 1-1.414 1.414L8 9.414l-1.414 1.414a1 1 0 1 1-1.414-1.414L6.586 8 5.172 6.586a1 1 0 1 1 1.414-1.414L8 6.586l1.414-1.414a1 1 0 1 1 1.414 1.414L9.414 8zM8 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 12z"/></symbol><symbol viewBox="0 0 16 16" id="messages" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="mobile-issue-close" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.657 10.728L2.12 7.192A1 1 0 1 0 .707 8.607l4.243 4.242a.997.997 0 0 0 1.414 0l8.485-8.485a1 1 0 1 0-1.414-1.414l-7.778 7.778z"/></symbol><symbol viewBox="0 0 16 16" id="monitor" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="more" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 4a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 6a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 6a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="notifications" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="notifications-off" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.26 5.089c.243.757.382 1.478.5 2.017.187.86.74 2.188 1.66 3.982A2 2 0 0 1 13.64 14H10a2 2 0 1 1-4 0H4.35l2-2h7.29c-.993-1.937-1.6-3.396-1.835-4.468-.07-.326-.129-.59-.178-.81l1.634-1.633zM10.943 1.75l-1.48 1.48C9.07 3.076 8.612 3 8.069 3c-1.608 0-2.49.718-3.103 2.197-.28.676-.356.982-.654 2.428-.065.317-.17.673-.317 1.073L.45 12.242a1.99 1.99 0 0 1 .224-1.19c.962-1.787 1.521-3.064 1.68-3.831.322-1.566.947-5.501 4.65-6.134a1 1 0 1 1 1.994-.024 4.867 4.867 0 0 1 1.944.688zm2.932-.105a1 1 0 0 1 0 1.415L2.561 14.374a1 1 0 1 1-1.415-1.414L12.46 1.646a1 1 0 0 1 1.414 0z"/></symbol><symbol viewBox="0 0 16 16" id="overview" xmlns="http://www.w3.org/2000/svg"><path d="M2 0h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm0 2v3h3V2H2zm9-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-2zm0 2v3h3V2h-3zM2 9h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2zm0 2v3h3v-3H2zm9-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-2zm0 2v3h3v-3h-3z"/></symbol><symbol viewBox="0 0 16 16" id="pencil" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="pencil-square" xmlns="http://www.w3.org/2000/svg"><path d="M12 9a1 1 0 0 1 2 0v4a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h4a1 1 0 1 1 0 2H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V9zm.778-7.179l1.414 1.415-6.476 6.476a1 1 0 0 1-.498.27l-1.51.325.323-1.512a1 1 0 0 1 .27-.497l6.477-6.477zM15.607.407a1 1 0 0 1 0 1.414l-.708.707-1.414-1.414.707-.707a1 1 0 0 1 1.415 0z"/></symbol><symbol viewBox="0 0 16 16" id="pipeline" xmlns="http://www.w3.org/2000/svg"><path d="M8.969 7.25a2 2 0 1 1-1.938 0A1.002 1.002 0 0 1 7 7V5.083a.2.2 0 0 1 .06-.142l.877-.87a.1.1 0 0 1 .141 0l.864.87A.2.2 0 0 1 9 5.083V7c0 .086-.01.17-.031.25zM8 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 12zm4.5-4a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-2 6a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-5 9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-2 6a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zM8 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="play" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2.765 15.835c-.545.321-1.258.159-1.593-.363A1.075 1.075 0 0 1 1 14.89V1.11C1 .496 1.518 0 2.158 0c.214 0 .424.057.607.165l11.684 6.89c.544.321.714 1.005.38 1.526a1.135 1.135 0 0 1-.38.364l-11.684 6.89z"/></symbol><symbol viewBox="0 0 16 16" id="plus" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7 7H2a1 1 0 1 0 0 2h5v5a1 1 0 0 0 2 0V9h5a1 1 0 0 0 0-2H9V2a1 1 0 1 0-2 0v5z"/></symbol><symbol viewBox="0 0 16 16" id="plus-square" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9 7V4a1 1 0 1 0-2 0v3H4a1 1 0 1 0 0 2h3v3a1 1 0 0 0 2 0V9h3a1 1 0 0 0 0-2H9zM3 0h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3z"/></symbol><symbol viewBox="0 0 16 16" id="plus-square-o" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7 7V5a1 1 0 1 1 2 0v2h2a1 1 0 0 1 0 2H9v2a1 1 0 0 1-2 0V9H5a1 1 0 1 1 0-2h2zM3 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"/></symbol><symbol viewBox="0 0 16 16" id="podcast" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.588 8.942l1.173 5.862a1 1 0 0 1-.785 1.177A1 1 0 0 1 8.78 16H7.22a1 1 0 0 1-1-1 1 1 0 0 1 .02-.196l1.172-5.862a3.014 3.014 0 0 0 1.176 0zM8 7.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zM4.464 2.464A1 1 0 0 1 5.88 3.88a3 3 0 0 0 0 4.242 1 1 0 0 1-1.415 1.415 5 5 0 0 1 0-7.072zm7.072 7.072A1 1 0 0 1 10.12 8.12a3 3 0 0 0 0-4.242 1 1 0 0 1 1.415-1.415 5 5 0 0 1 0 7.072zM2.343.343a1 1 0 1 1 1.414 1.414 6 6 0 0 0 0 8.486 1 1 0 1 1-1.414 1.414 8 8 0 0 1 0-11.314zm11.314 11.314a1 1 0 1 1-1.414-1.414 6 6 0 0 0 0-8.486A1 1 0 0 1 13.657.343a8 8 0 0 1 0 11.314z"/></symbol><symbol viewBox="0 0 16 16" id="preferences" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5 12h10a1 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-2zm11-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 2h9V7zM6 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 2h2V2z"/></symbol><symbol viewBox="0 0 16 16" id="profile" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="project" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="push-rules" xmlns="http://www.w3.org/2000/svg"><path d="M6.268 9a2 2 0 0 1 3.464 0H11a1 1 0 0 1 0 2H9.732a2 2 0 0 1-3.464 0H5a1 1 0 0 1 0-2h1.268zM7 2H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1h-1v3.515a.3.3 0 0 1-.434.268l-1.432-.716a.3.3 0 0 0-.268 0l-1.432.716A.3.3 0 0 1 7 5.515V2zM4 0h8a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm4 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="question" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm-1.46-5.602h2.233a3.97 3.97 0 0 1 .051-.558c.029-.17.073-.326.133-.469.06-.143.14-.28.242-.41.102-.13.228-.263.38-.399.26-.24.504-.467.733-.683a5.03 5.03 0 0 0 .598-.668c.17-.23.302-.477.399-.742a2.66 2.66 0 0 0 .144-.907c0-.505-.083-.95-.25-1.335a2.55 2.55 0 0 0-.723-.97 3.2 3.2 0 0 0-1.152-.589 5.441 5.441 0 0 0-1.531-.2c-.516 0-.998.063-1.445.188a3.19 3.19 0 0 0-1.168.59c-.331.268-.594.61-.79 1.027-.195.417-.295.917-.3 1.5h2.64c.006-.224.04-.416.102-.578.062-.161.142-.293.238-.394a.921.921 0 0 1 .332-.227 1.04 1.04 0 0 1 .39-.074c.34 0 .593.095.763.285.169.19.254.488.254.895 0 .328-.106.63-.317.906-.21.276-.499.565-.863.867-.214.182-.39.374-.531.574-.141.2-.253.42-.336.657a3.656 3.656 0 0 0-.176.777 7.89 7.89 0 0 0-.05.937zm-.321 2.375c0 .188.035.362.105.524.07.161.17.3.301.418.13.117.284.21.46.277.178.068.376.102.595.102.218 0 .416-.034.593-.102.178-.068.331-.16.461-.277a1.2 1.2 0 0 0 .301-.418c.07-.162.106-.336.106-.524a1.3 1.3 0 0 0-.106-.523 1.2 1.2 0 0 0-.3-.418 1.461 1.461 0 0 0-.462-.277 1.651 1.651 0 0 0-.593-.102c-.22 0-.417.034-.594.102a1.46 1.46 0 0 0-.461.277 1.2 1.2 0 0 0-.3.418 1.284 1.284 0 0 0-.106.523z"/></symbol><symbol viewBox="0 0 16 16" id="question-o" xmlns="http://www.w3.org/2000/svg"><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-.778-4.151c0-.301.014-.575.044-.82a3.2 3.2 0 0 1 .154-.68c.073-.208.17-.4.294-.575.123-.176.278-.343.465-.503a4.81 4.81 0 0 0 .755-.758c.185-.242.277-.506.277-.793 0-.356-.074-.617-.222-.783-.148-.166-.37-.25-.667-.25a.92.92 0 0 0-.342.065.806.806 0 0 0-.29.199 1.04 1.04 0 0 0-.209.345 1.5 1.5 0 0 0-.088.506H5.082c.005-.51.092-.948.263-1.313.171-.364.401-.664.69-.899.29-.234.63-.406 1.023-.516a4.66 4.66 0 0 1 1.264-.164c.497 0 .944.058 1.34.174.397.117.733.289 1.008.517.276.227.487.51.633.847.146.337.218.727.218 1.17 0 .295-.042.56-.126.792a2.52 2.52 0 0 1-.349.65 4.4 4.4 0 0 1-.523.584c-.2.19-.414.389-.642.598a2.73 2.73 0 0 0-.332.349c-.089.114-.16.233-.212.359a1.868 1.868 0 0 0-.116.41 3.39 3.39 0 0 0-.044.489H7.222zm-.28 2.078c0-.164.03-.317.092-.458a1.05 1.05 0 0 1 .263-.366c.114-.103.248-.183.403-.243a1.45 1.45 0 0 1 .52-.089c.191 0 .364.03.52.09.154.059.289.14.403.242.114.103.201.224.263.366.061.141.092.294.092.458 0 .164-.03.316-.092.458a1.05 1.05 0 0 1-.263.365 1.278 1.278 0 0 1-.404.243 1.43 1.43 0 0 1-.52.089c-.19 0-.364-.03-.519-.089-.155-.06-.29-.14-.403-.243a1.05 1.05 0 0 1-.263-.365 1.135 1.135 0 0 1-.093-.458z"/></symbol><symbol viewBox="0 0 16 16" id="quote" xmlns="http://www.w3.org/2000/svg"><path d="M15 3v8a3 3 0 0 1-3 3 1 1 0 0 1 0-2 1 1 0 0 0 1-1V9h-2a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h3a1 1 0 0 1 1 1zM7 3v8a3 3 0 0 1-3 3 1 1 0 0 1 0-2 1 1 0 0 0 1-1V9H3a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h3a1 1 0 0 1 1 1z"/></symbol><symbol viewBox="0 0 16 16" id="redo" xmlns="http://www.w3.org/2000/svg"><path d="M4.625 4.423A4.897 4.897 0 0 1 8.079 3c2.73 0 4.944 2.239 4.944 5s-2.214 5-4.944 5c-1.41 0-2.723-.6-3.655-1.633a.98.98 0 0 0-1.397-.066 1.008 1.008 0 0 0-.064 1.413A6.87 6.87 0 0 0 8.079 15C11.9 15 15 11.866 15 8s-3.099-7-6.921-7A6.866 6.866 0 0 0 3.08 3.158L1.833 2.137a.49.49 0 0 0-.695.074.504.504 0 0 0-.11.311L1 7.26a.497.497 0 0 0 .6.492l4.576-1.013a.5.5 0 0 0 .206-.877L4.625 4.423z"/></symbol><symbol viewBox="0 0 16 16" id="remove" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 3a1 1 0 1 1 0-2h12a1 1 0 0 1 0 2v10a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V3zm3-2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1H5zM4 3v10a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3H4zm2.5 2a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5zm3 0a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5z"/></symbol><symbol viewBox="0 0 16 16" id="repeat" xmlns="http://www.w3.org/2000/svg"><path d="M11.375 4.423A4.897 4.897 0 0 0 7.921 3c-2.73 0-4.944 2.239-4.944 5s2.214 5 4.944 5c1.41 0 2.723-.6 3.655-1.633a.98.98 0 0 1 1.397-.066c.403.373.432 1.005.064 1.413A6.87 6.87 0 0 1 7.921 15C4.1 15 1 11.866 1 8s3.099-7 6.921-7c1.915 0 3.706.792 4.999 2.158l1.247-1.021a.49.49 0 0 1 .695.074c.07.088.11.198.11.311L15 7.26a.497.497 0 0 1-.6.492L9.824 6.739a.5.5 0 0 1-.206-.877l1.757-1.439z"/></symbol><symbol viewBox="0 0 16 16" id="retry" xmlns="http://www.w3.org/2000/svg"><path d="M4.114 6.958a4 4 0 0 0 5.283 4.775 1 1 0 1 1 .712 1.87A6 6 0 0 1 2.182 6.44l-.741-.2a.5.5 0 0 1-.12-.915l2.195-1.268a.5.5 0 0 1 .683.183l1.268 2.196a.5.5 0 0 1-.563.733l-.79-.212zm7.777 2.084a4 4 0 0 0-5.284-4.775 1 1 0 0 1-.712-1.87 6 6 0 0 1 7.927 7.162l.742.2a.5.5 0 0 1 .12.915l-2.196 1.268a.5.5 0 0 1-.683-.183l-1.267-2.196a.5.5 0 0 1 .562-.733l.79.212z"/></symbol><symbol viewBox="0 0 16 16" id="scale" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.99 9a.792.792 0 0 0-.078-.231L13 7l-.912 1.769a.791.791 0 0 0-.077.231h1.978zm-10 0a.792.792 0 0 0-.078-.231L3 7l-.912 1.769A.791.791 0 0 0 2.011 9h1.978zM2 0h12a1 1 0 0 1 0 2H2a1 1 0 1 1 0-2zm3 14h6a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2zM8 4a1 1 0 0 1 1 1v9H7V5a1 1 0 0 1 1-1zm-4.53-.714l2.265 4.735c.68 1.42.006 3.091-1.504 3.73A3.161 3.161 0 0 1 3 12c-1.657 0-3-1.263-3-2.821 0-.4.09-.794.264-1.158L2.53 3.286a.53.53 0 0 1 .94 0zm10 0l2.265 4.735c.68 1.42.006 3.091-1.504 3.73A3.161 3.161 0 0 1 13 12c-1.657 0-3-1.263-3-2.821 0-.4.09-.794.264-1.158l2.266-4.735a.53.53 0 0 1 .94 0z"/></symbol><symbol viewBox="0 0 16 16" id="screen-full" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M14 14v-2a1 1 0 0 1 2 0v3a.997.997 0 0 1-1 1h-3a1 1 0 0 1 0-2h2zM2 14v-2a1 1 0 0 0-2 0v3a1 1 0 0 0 1 1h3a1 1 0 0 0 0-2H2zM15.707.293A.997.997 0 0 1 16 1v3a1 1 0 0 1-2 0V2h-2a1 1 0 0 1 0-2h3c.276 0 .526.112.707.293zM2 2v2a1 1 0 1 1-2 0V1a.997.997 0 0 1 1-1h3a1 1 0 1 1 0 2H2zm4 4h4a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/></symbol><symbol viewBox="0 0 16 16" id="screen-normal" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 3V1a1 1 0 1 1 2 0v3a.997.997 0 0 1-1 1H1a1 1 0 1 1 0-2h2zm10 0h2a1 1 0 0 1 0 2h-3a.997.997 0 0 1-1-1V1a1 1 0 0 1 2 0v2zM3 13H1a1 1 0 0 1 0-2h3a.997.997 0 0 1 1 1v3a1 1 0 0 1-2 0v-2zm10 0v2a1 1 0 0 1-2 0v-3a.997.997 0 0 1 1-1h3a1 1 0 0 1 0 2h-2zM6.5 7h3a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5z"/></symbol><symbol viewBox="0 0 12 16" id="scroll_down" xmlns="http://www.w3.org/2000/svg"><path class="fbfirst-triangle" d="M1.048 14.155a.508.508 0 0 0-.32.105c-.091.07-.136.154-.136.25v.71c0 .095.045.178.135.249.09.07.197.105.321.105h10.043a.51.51 0 0 0 .321-.105c.09-.07.136-.154.136-.25v-.71c0-.095-.045-.178-.136-.249a.508.508 0 0 0-.32-.105"/><path class="fbsecond-triangle" d="M.687 8.027c-.09-.087-.122-.16-.093-.22.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 12.91a.458.458 0 0 1-.136.089h-.37a.626.626 0 0 1-.136-.09"/><path class="fbthird-triangle" d="M.687 1.027C.597.94.565.867.594.807c.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 5.91a.458.458 0 0 1-.136.09h-.37a.626.626 0 0 1-.136-.09"/></symbol><symbol viewBox="0 0 12 16" id="scroll_up" xmlns="http://www.w3.org/2000/svg"><path d="M1.048 1.845a.508.508 0 0 1-.32-.105c-.091-.07-.136-.154-.136-.25V.78c0-.095.045-.178.135-.249a.508.508 0 0 1 .321-.105h10.043a.51.51 0 0 1 .321.105c.09.07.136.154.136.25v.71c0 .095-.045.178-.136.249a.508.508 0 0 1-.32.105M.687 7.973c-.09.087-.122.16-.093.22.028.06.104.09.228.09h10.5c.123 0 .2-.03.228-.09.029-.06-.002-.133-.093-.22L6.393 3.09A.458.458 0 0 0 6.257 3h-.37a.626.626 0 0 0-.136.09M.687 14.973c-.09.087-.122.16-.093.22.028.06.104.09.228.09h10.5c.123 0 .2-.03.228-.09.029-.06-.002-.133-.093-.22L6.393 10.09a.458.458 0 0 0-.136-.09h-.37a.626.626 0 0 0-.136.09"/></symbol><symbol viewBox="0 0 16 16" id="search" xmlns="http://www.w3.org/2000/svg"><path d="M8.853 8.854a3.5 3.5 0 1 0-4.95-4.95 3.5 3.5 0 0 0 4.95 4.95zm.207 2.328a5.5 5.5 0 1 1 2.121-2.121l3.329 3.328a1.5 1.5 0 0 1-2.121 2.121L9.06 11.182z"/></symbol><symbol viewBox="0 0 16 16" id="settings" xmlns="http://www.w3.org/2000/svg"><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.918a5.97 5.97 0 0 1 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"/></symbol><symbol viewBox="0 0 16 16" id="shield" xmlns="http://www.w3.org/2000/svg"><path d="M4 0h8c1.657 0 3 1.373 3 3.067v7.346c0 1.065-.54 2.053-1.426 2.611l-4 2.52a2.944 2.944 0 0 1-3.148 0l-4-2.52A3.083 3.083 0 0 1 1 10.414V3.066C1 1.373 2.343 0 4 0zm0 2.045c-.552 0-1 .457-1 1.022v7.346c0 .355.18.685.475.87l4 2.52a.981.981 0 0 0 1.05 0l4-2.52c.295-.185.475-.515.475-.87V3.067c0-.565-.448-1.022-1-1.022H4zm0 1.533c0-.282.224-.511.5-.511h4V12.1a.52.52 0 0 1-.069.258.494.494 0 0 1-.684.183l-3.5-2.098a.513.513 0 0 1-.247-.44V3.577z"/></symbol><symbol viewBox="0 0 16 16" id="slight-frown" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="slight-smile" xmlns="http://www.w3.org/2000/svg"><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 12zM5 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 2zm-5.163 2.254a.5.5 0 1 1 .865-.502 1.499 1.499 0 0 0 2.607-.018.5.5 0 1 1 .871.49 2.499 2.499 0 0 1-4.343.03z"/></symbol><symbol viewBox="0 0 16 16" id="smile" xmlns="http://www.w3.org/2000/svg"><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 12zM6.18 6.27a.5.5 0 0 1-.873.487.5.5 0 0 0-.872-.003.5.5 0 1 1-.87-.495 1.5 1.5 0 0 1 2.616.012zm6 0a.5.5 0 1 1-.873.487.5.5 0 0 0-.872-.003.5.5 0 1 1-.87-.495 1.5 1.5 0 0 1 2.616.012zM5 9a3 3 0 0 0 6 0H5z"/></symbol><symbol viewBox="0 0 16 16" id="smiley" xmlns="http://www.w3.org/2000/svg"><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 12zM5 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 2zM5 9h6a3 3 0 0 1-6 0z"/></symbol><symbol viewBox="0 0 16 16" id="snippet" xmlns="http://www.w3.org/2000/svg"><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 0 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 0 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"/></symbol><symbol viewBox="0 0 16 16" id="soft-unwrap" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6.5 11v-.598a.5.5 0 0 1 .765-.424l2.557 1.598a.5.5 0 0 1 0 .848l-2.557 1.598a.5.5 0 0 1-.765-.424V13H2a1 1 0 0 1 0-2h4.5zM2 3h12a1 1 0 0 1 0 2H2a1 1 0 1 1 0-2zm0 4h12a1 1 0 0 1 0 2H2a1 1 0 1 1 0-2zm10 4h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="soft-wrap" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.5 13v.598a.5.5 0 0 1-.765.424l-2.557-1.598a.5.5 0 0 1 0-.848l2.557-1.598a.5.5 0 0 1 .765.424V11H12a1 1 0 0 0 0-2H2a1 1 0 1 1 0-2h10a3 3 0 0 1 0 6h-1.5zM2 3h12a1 1 0 0 1 0 2H2a1 1 0 1 1 0-2zm0 8h3a1 1 0 0 1 0 2H2a1 1 0 0 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="spam" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 14 14" id="spinner" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><circle cx="7" cy="7" r="6" stroke="#000" stroke-opacity=".1" stroke-width="2"/><path fill="#000" fill-opacity=".1" fill-rule="nonzero" d="M7 0a7 7 0 0 1 7 7h-2a5 5 0 0 0-5-5V0z"/></g></symbol><symbol viewBox="0 0 16 16" id="staged" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 3h4a1 1 0 1 1 0 2H2a1 1 0 1 1 0-2zm9 6a3 3 0 1 1 0-6 3 3 0 0 1 0 6zM2 7h4a1 1 0 1 1 0 2H2a1 1 0 1 1 0-2zm0 4h12a1 1 0 0 1 0 2H2a1 1 0 0 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="star" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.609 14.394l-3.465 1.473a1 1 0 0 1-1.39-.989l.276-4.024a1 1 0 0 0-.219-.694L.303 7.037A1 1 0 0 1 .83 5.443l3.715-.964a1 1 0 0 0 .609-.457L7.14.682a1 1 0 0 1 1.72 0l1.985 3.34a1 1 0 0 0 .609.457l3.715.964a1 1 0 0 1 .528 1.594L13.19 10.16a1 1 0 0 0-.219.694l.275 4.024a1 1 0 0 1-1.389.989l-3.465-1.473a1 1 0 0 0-.782 0z"/></symbol><symbol viewBox="0 0 16 16" id="star-o" xmlns="http://www.w3.org/2000/svg"><path d="M10.975 10.99a3 3 0 0 1 .655-2.083l1.54-1.916-2.219-.576a3 3 0 0 1-1.825-1.37L8 3.15 6.874 5.044a3 3 0 0 1-1.825 1.371l-2.218.576 1.54 1.916a3 3 0 0 1 .654 2.083l-.165 2.4 1.965-.836a3 3 0 0 1 2.348 0l1.965.836-.164-2.399zM7.61 14.394l-3.465 1.473a1 1 0 0 1-1.39-.989l.276-4.024a1 1 0 0 0-.219-.694L.303 7.037A1 1 0 0 1 .83 5.443l3.715-.964a1 1 0 0 0 .609-.457L7.14.682a1 1 0 0 1 1.72 0l1.985 3.34a1 1 0 0 0 .609.457l3.715.964a1 1 0 0 1 .528 1.594L13.19 10.16a1 1 0 0 0-.219.694l.275 4.024a1 1 0 0 1-1.389.989l-3.465-1.473a1 1 0 0 0-.782 0z"/></symbol><symbol viewBox="0 0 14 14" id="status_canceled" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M5.2 3.8l4.9 4.9c.2.2.2.5 0 .7l-.7.7c-.2.2-.5.2-.7 0L3.8 5.2c-.2-.2-.2-.5 0-.7l.7-.7c.2-.2.5-.2.7 0"/></g></symbol><symbol viewBox="0 0 22 22" id="status_canceled_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M8.171 5.971l7.7 7.7a.76.76 0 0 1 0 1.1l-1.1 1.1a.76.76 0 0 1-1.1 0l-7.7-7.7a.76.76 0 0 1 0-1.1l1.1-1.1a.76.76 0 0 1 1.1 0"/></symbol><symbol viewBox="0 0 16 16" id="status_closed" xmlns="http://www.w3.org/2000/svg"><path d="M7.536 8.657l2.828-2.83a1 1 0 0 1 1.414 1.416l-3.535 3.535a1 1 0 0 1-1.415.001l-2.12-2.12a1 1 0 1 1 1.413-1.415zM8 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 12z"/></symbol><symbol viewBox="0 0 14 14" id="status_created" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><circle cx="7" cy="7" r="3.25"/></g></symbol><symbol viewBox="0 0 22 22" id="status_created_borderless" xmlns="http://www.w3.org/2000/svg"><circle cx="11" cy="11" r="5.107"/></symbol><symbol viewBox="0 0 14 14" id="status_failed" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M7 5.969L5.599 4.568a.29.29 0 0 0-.413.004l-.614.614a.294.294 0 0 0-.004.413L5.968 7l-1.4 1.401a.29.29 0 0 0 .004.413l.614.614c.113.114.3.117.413.004L7 8.032l1.401 1.4a.29.29 0 0 0 .413-.004l.614-.614a.294.294 0 0 0 .004-.413L8.032 7l1.4-1.401a.29.29 0 0 0-.004-.413l-.614-.614a.294.294 0 0 0-.413-.004L7 5.968z"/></g></symbol><symbol viewBox="0 0 22 22" id="status_failed_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M11 9.38L8.798 7.178a.455.455 0 0 0-.65.006l-.964.965a.462.462 0 0 0-.006.65L9.38 11l-2.202 2.202a.455.455 0 0 0 .006.65l.965.964a.462.462 0 0 0 .65.006L11 12.62l2.202 2.202a.455.455 0 0 0 .65-.006l.964-.965a.462.462 0 0 0 .006-.65L12.62 11l2.202-2.202a.455.455 0 0 0-.006-.65l-.965-.964a.462.462 0 0 0-.65-.006L11 9.38z"/></symbol><symbol viewBox="0 0 14 14" id="status_manual" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M10.5 7.63V6.37l-.787-.13c-.044-.175-.132-.349-.263-.61l.481-.652-.918-.913-.657.478a2.346 2.346 0 0 0-.612-.26L7.656 3.5H6.388l-.132.783c-.219.043-.394.13-.612.26l-.657-.478-.918.913.437.652c-.131.218-.175.392-.262.61l-.744.086v1.261l.787.13c.044.218.132.392.263.61l-.438.651.92.913.655-.434c.175.086.394.173.613.26l.131.783h1.313l.131-.783c.219-.043.394-.13.613-.26l.656.478.918-.913-.48-.652c.13-.218.218-.435.262-.61l.656-.13zM7 8.283a1.285 1.285 0 0 1-1.313-1.305c0-.739.57-1.304 1.313-1.304.744 0 1.313.565 1.313 1.304 0 .74-.57 1.305-1.313 1.305z"/></g></symbol><symbol viewBox="0 0 22 22" id="status_manual_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M16.5 11.99v-1.98l-1.238-.206c-.068-.273-.206-.546-.412-.956l.756-1.025-1.444-1.435-1.03.752a3.686 3.686 0 0 0-.963-.41L12.03 5.5h-1.994l-.206 1.23c-.343.068-.618.205-.962.41l-1.031-.752-1.444 1.435.687 1.025c-.206.341-.275.615-.412.956L5.5 9.941v1.981l1.237.205c.07.342.207.615.413.957l-.688 1.025 1.444 1.434 1.032-.683c.274.137.618.274.962.41l.206 1.23h2.063l.206-1.23c.344-.068.619-.205.963-.41l1.03.752 1.444-1.435-.756-1.025c.207-.341.344-.683.413-.956l1.031-.205zM11 13.017c-1.169 0-2.063-.889-2.063-2.05 0-1.162.894-2.05 2.063-2.05s2.063.888 2.063 2.05c0 1.161-.894 2.05-2.063 2.05z"/></symbol><symbol viewBox="0 0 14 14" id="status_notfound" xmlns="http://www.w3.org/2000/svg"><path d="M7 14A7 7 0 1 1 7 0a7 7 0 0 1 0 14z"/><path d="M7 13A6 6 0 1 0 7 1a6 6 0 0 0 0 12z" fill="#FFF"/><path d="M8.16 7.184c.519-.37.904-.857 1.07-1.477.384-1.427-.619-2.897-2.246-2.897-.732 0-1.327.26-1.766.692a2.163 2.163 0 0 0-.509.743.75.75 0 0 0 1.4.54.78.78 0 0 1 .16-.213c.168-.165.39-.262.715-.262.597 0 .936.496.798 1.007-.067.249-.235.462-.492.644-.231.165-.47.264-.601.3a.75.75 0 0 0-.556.724v1.421a.75.75 0 0 0 1.5 0v-.909a3.74 3.74 0 0 0 .526-.313z"/><ellipse cx="6.889" cy="10.634" rx="1" ry="1"/></symbol><symbol viewBox="0 0 22 22" id="status_notfound_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M12.822 11.29c.816-.581 1.421-1.348 1.683-2.322.603-2.243-.973-4.553-3.53-4.553-1.15 0-2.085.41-2.775 1.089-.42.413-.672.835-.8 1.167a1.179 1.179 0 0 0 2.2.847c.016-.043.1-.184.252-.334.264-.259.613-.412 1.123-.412.938 0 1.47.78 1.254 1.584-.105.39-.37.726-.773 1.012a3.25 3.25 0 0 1-.945.47 1.179 1.179 0 0 0-.874 1.138v2.234a1.179 1.179 0 1 0 2.358 0v-1.43a5.9 5.9 0 0 0 .827-.492z"/><ellipse cx="10.825" cy="16.711" rx="1.275" ry="1.322"/></symbol><symbol viewBox="0 0 14 14" id="status_open" 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"/><path d="M7 9.219a2.218 2.218 0 1 0 0-4.436A2.218 2.218 0 0 0 7 9.22zm0 1.12a3.338 3.338 0 1 1 0-6.676 3.338 3.338 0 0 1 0 6.676z"/></symbol><symbol viewBox="0 0 14 14" id="status_pending" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M4.7 5.3c0-.2.1-.3.3-.3h.9c.2 0 .3.1.3.3v3.4c0 .2-.1.3-.3.3H5c-.2 0-.3-.1-.3-.3V5.3m3 0c0-.2.1-.3.3-.3h.9c.2 0 .3.1.3.3v3.4c0 .2-.1.3-.3.3H8c-.2 0-.3-.1-.3-.3V5.3"/></g></symbol><symbol viewBox="0 0 22 22" id="status_pending_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M7.386 8.329c0-.315.157-.472.471-.472h1.414c.315 0 .472.157.472.472v5.342c0 .315-.157.472-.472.472H7.857c-.314 0-.471-.157-.471-.472V8.33m4.714 0c0-.315.157-.472.471-.472h1.415c.314 0 .471.157.471.472v5.342c0 .315-.157.472-.471.472H12.57c-.314 0-.471-.157-.471-.472V8.33"/></symbol><symbol viewBox="0 0 14 14" id="status_running" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M7 3c2.2 0 4 1.8 4 4s-1.8 4-4 4c-1.3 0-2.5-.7-3.3-1.7L7 7V3"/></g></symbol><symbol viewBox="0 0 22 22" id="status_running_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M11 4.714c3.457 0 6.286 2.829 6.286 6.286 0 3.457-2.829 6.286-6.286 6.286-2.043 0-3.929-1.1-5.186-2.672L11 11V4.714"/></symbol><symbol viewBox="0 0 14 14" id="status_skipped" xmlns="http://www.w3.org/2000/svg"><path d="M7 14A7 7 0 1 1 7 0a7 7 0 0 1 0 14z"/><path d="M7 13A6 6 0 1 0 7 1a6 6 0 0 0 0 12z" fill="#FFF"/><path d="M6.415 7.04L4.579 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L5.341 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L6.415 7.04zm2.54 0L7.119 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L7.881 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L8.955 7.04z"/></symbol><symbol viewBox="0 0 22 22" id="status_skipped_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M14.072 11.063l-2.82 2.82a.46.46 0 0 0-.001.652l.495.495a.457.457 0 0 0 .653-.001l3.7-3.7a.46.46 0 0 0 .001-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.479-3.479a.464.464 0 0 0-.654.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/><path d="M10.08 11.063l-2.819 2.82a.46.46 0 0 0-.002.652l.496.495a.457.457 0 0 0 .652-.001l3.7-3.7a.46.46 0 0 0 .002-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.48-3.479a.464.464 0 0 0-.653.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/></symbol><symbol viewBox="0 0 14 14" id="status_success" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="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"/></g></symbol><symbol viewBox="0 0 22 22" id="status_success_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M9.866 12.095l-1.95-1.95a.462.462 0 0 0-.647.01l-.964.964a.46.46 0 0 0-.01.646l3.013 3.014a.787.787 0 0 0 1.106.008l.425-.425 4.854-4.853a.462.462 0 0 0 .002-.659l-.964-.964a.468.468 0 0 0-.658.002l-4.207 4.207z"/></symbol><symbol viewBox="0 0 14 14" id="status_success_solid" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7zm6.278.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"/></symbol><symbol viewBox="0 0 14 14" id="status_warning" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M6 3.5c0-.3.2-.5.5-.5h1c.3 0 .5.2.5.5v4c0 .3-.2.5-.5.5h-1c-.3 0-.5-.2-.5-.5v-4m0 6c0-.3.2-.5.5-.5h1c.3 0 .5.2.5.5v1c0 .3-.2.5-.5.5h-1c-.3 0-.5-.2-.5-.5v-1"/></g></symbol><symbol viewBox="0 0 22 22" id="status_warning_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M9.429 5.5c0-.471.314-.786.785-.786h1.572c.471 0 .785.315.785.786v6.286c0 .471-.314.785-.785.785h-1.572c-.471 0-.785-.314-.785-.785V5.5m0 9.429c0-.472.314-.786.785-.786h1.572c.471 0 .785.314.785.786V16.5c0 .471-.314.786-.785.786h-1.572c-.471 0-.785-.315-.785-.786v-1.571"/></symbol><symbol viewBox="0 0 16 16" id="stop" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 0h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2z"/></symbol><symbol viewBox="0 0 16 16" id="task-done" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="template" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="terminal" xmlns="http://www.w3.org/2000/svg"><path d="M7 8a.997.997 0 0 1-.293.707l-1.414 1.414a1 1 0 1 1-1.414-1.414L4.586 8l-.707-.707a1 1 0 1 1 1.414-1.414l1.414 1.414A.997.997 0 0 1 7 8zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H4zm5 7h2a1 1 0 0 1 0 2H9a1 1 0 0 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="thumb-down" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.33 11h5.282a2 2 0 0 0 1.963-2.38l-.563-2.905a3 3 0 0 0-.243-.732l-1.103-2.286A3 3 0 0 0 10.964 1H7a3 3 0 0 0-3 3v6.3a2 2 0 0 0 .436 1.247l3.11 3.9a.632.632 0 0 0 .941.053l.137-.137a1 1 0 0 0 .28-.87L8.329 11zM1 10h2V3H1a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1z"/></symbol><symbol viewBox="0 0 16 16" id="thumb-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.33 5h5.282a2 2 0 0 1 1.963 2.38l-.563 2.905a3 3 0 0 1-.243.732l-1.103 2.286A3 3 0 0 1 10.964 15H7a3 3 0 0 1-3-3V5.7a2 2 0 0 1 .436-1.247l3.11-3.9A.632.632 0 0 1 8.487.5l.137.137a1 1 0 0 1 .28.87L8.329 5zM1 6h2v7H1a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/></symbol><symbol viewBox="0 0 16 16" id="thumbtack" xmlns="http://www.w3.org/2000/svg"><path d="M7.125 9h-2.19a.5.5 0 0 1-.417-.777L6 6V2L5.362.724A.5.5 0 0 1 5.809 0h4.382a.5.5 0 0 1 .447.724L10 2v4l1.482 2.223a.5.5 0 0 1-.416.777H8.875L8 16l-.875-7z" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 16 16" id="timer" xmlns="http://www.w3.org/2000/svg"><path d="M12.022 3.27l.77-.77a1 1 0 0 1 1.415 1.414l-.728.729a7 7 0 1 1-1.456-1.372zM8 14A5 5 0 1 0 8 4a5 5 0 0 0 0 10zm0-9a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1zM6 0h4a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="todo-add" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10 4V2a1 1 0 0 1 2 0v2h2a1 1 0 0 1 0 2h-2v2a1 1 0 0 1-2 0V6H8a1 1 0 1 1 0-2h2zm2 7a1 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"/></symbol><symbol viewBox="0 0 16 16" id="todo-done" xmlns="http://www.w3.org/2000/svg"><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 0L4.707 6.778a1 1 0 0 1 1.414-1.414l2.122 2.121zM12 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"/></symbol><symbol viewBox="0 0 16 16" id="token" xmlns="http://www.w3.org/2000/svg"><path d="M3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3zm1 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="unapproval" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.95 8.536l1.06-1.061a1 1 0 0 1 1.415 1.414l-1.061 1.06 1.06 1.061a1 1 0 0 1-1.414 1.415l-1.06-1.061-1.06 1.06a1 1 0 1 1-1.415-1.414l1.06-1.06-1.06-1.06a1 1 0 0 1 1.414-1.415l1.06 1.06zm-3.768-.33c.006.503.201 1.006.586 1.39l.353.354-.353.353a2 2 0 1 0 2.828 2.829l.354-.354.047.048C11.964 14.363 11.527 15 6 15c-5.924 0-6-.78-6-2.52S.964 8 6 8c.834 0 1.557.074 2.182.205zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></symbol><symbol viewBox="0 0 16 16" id="unassignee" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11 5h4a1 1 0 0 1 0 2h-4a1 1 0 0 1 0-2zM5.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"/></symbol><symbol viewBox="0 0 16 16" id="unlink" xmlns="http://www.w3.org/2000/svg"><path d="M11.295 8.845l-.659-1.664a1.78 1.78 0 0 0 .04-.04l1.415-1.414c.586-.586.654-1.468.152-1.97s-1.384-.434-1.97.152L8.859 5.323a1.781 1.781 0 0 0-.04.04l-1.664-.658c.141-.208.305-.408.491-.594l1.415-1.414c1.366-1.367 3.424-1.525 4.596-.354 1.171 1.172 1.013 3.23-.354 4.596L11.89 8.354c-.186.186-.386.35-.594.491zm-2.45 2.45a4.075 4.075 0 0 1-.491.594l-1.415 1.414c-1.366 1.367-3.424 1.525-4.596.354-1.171-1.172-1.013-3.23.354-4.596L4.11 7.646c.186-.186.386-.35.594-.491l.659 1.664a1.781 1.781 0 0 0-.04.04l-1.415 1.414c-.586.586-.654 1.468-.152 1.97s1.384.434 1.97-.152l1.414-1.414a1.78 1.78 0 0 0 .04-.04l1.664.658zm3.812-2.088h2a.5.5 0 0 1 .5.5v.05a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-.05a.5.5 0 0 1 .5-.5zm-.384 2.116l1.415 1.414a.5.5 0 0 1 0 .708l-.037.036a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 0-.707l.036-.037a.5.5 0 0 1 .707 0zm-2.823 1.09a.5.5 0 0 1 .5-.5h.052a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H9.95a.5.5 0 0 1-.5-.5v-2zm-2.748-9.16a.5.5 0 0 1-.5.5h-.05a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5h.05a.5.5 0 0 1 .5.5v2zm-2.116.383a.5.5 0 0 1 0 .707l-.036.036a.5.5 0 0 1-.707 0L2.428 2.965a.5.5 0 0 1 0-.707l.037-.036a.5.5 0 0 1 .707 0l1.414 1.414zm-1.09 2.823h-2a.5.5 0 0 1-.5-.5v-.051a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v.05a.5.5 0 0 1-.5.5z"/></symbol><symbol viewBox="0 0 16 16" id="unstaged" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 3h12a1 1 0 0 1 0 2H2a1 1 0 1 1 0-2zm0 4h12a1 1 0 0 1 0 2H2a1 1 0 1 1 0-2zm0 4h12a1 1 0 0 1 0 2H2a1 1 0 0 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="user" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 8c-6.888 0-6.976-.78-6.976-2.52S2.144 8 8 8s6.976 2.692 6.976 4.48c0 1.788-.088 2.52-6.976 2.52z"/></symbol><symbol viewBox="0 0 16 16" id="users" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="volume-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1 5h1v6H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zm2 0l4.445-2.964A1 1 0 0 1 9 2.87v10.26a1 1 0 0 1-1.555.833L3 11V5zm10.283 7.89a.5.5 0 0 1-.66-.752A5.485 5.485 0 0 0 14.5 8c0-1.601-.687-3.09-1.865-4.128a.5.5 0 0 1 .661-.75A6.484 6.484 0 0 1 15.5 8a6.485 6.485 0 0 1-2.217 4.89zm-2.002-2.236a.5.5 0 1 1-.652-.758c.55-.472.871-1.157.871-1.896 0-.732-.315-1.411-.856-1.883a.5.5 0 0 1 .658-.753A3.492 3.492 0 0 1 12.5 8c0 1.033-.45 1.994-1.219 2.654z"/></symbol><symbol viewBox="0 0 16 16" id="warning" xmlns="http://www.w3.org/2000/svg"><path d="M15.572 10.506c.867 1.42.375 3.247-1.098 4.082a3.184 3.184 0 0 1-1.57.412h-9.81C1.387 15 0 13.665 0 12.018a2.9 2.9 0 0 1 .427-1.512L5.332 2.47C6.2 1.05 8.096.577 9.57 1.412c.453.257.831.622 1.098 1.059l4.905 8.035zM8.89 3.479a1.014 1.014 0 0 0-.366-.353 1.053 1.053 0 0 0-1.412.353l-4.905 8.035a.967.967 0 0 0-.143.504c0 .549.462.994 1.032.994h9.81c.184 0 .364-.048.523-.137a.974.974 0 0 0 .366-1.361L8.889 3.479zM8 5a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="work" xmlns="http://www.w3.org/2000/svg"><path d="M12 3h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h1V2a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1zM6 2v1h4V2H6zM3 5a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H3zm1.5 1a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5zm7 0a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5z"/></symbol></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/cluster_popover.svg b/app/assets/images/illustrations/cluster_popover.svg
new file mode 100644
index 00000000000..202231373f1
--- /dev/null
+++ b/app/assets/images/illustrations/cluster_popover.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="142" height="104" viewBox="0 0 142 104"><g fill="none" fill-rule="evenodd"><g transform="translate(112 4)"><path fill="#FFF" d="M8 4a4 4 0 0 0-4 4v14a4 4 0 0 0 4 4h14a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4H8z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M8 4a4 4 0 0 0-4 4v14a4 4 0 0 0 4 4h14a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4H8zm0-4h14a8 8 0 0 1 8 8v14a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8V8a8 8 0 0 1 8-8z"/><rect width="10" height="10" x="10" y="10" fill="#FC6D26" rx="5"/></g><g transform="translate(5 74)"><rect width="30" height="30" fill="#FFF" rx="8"/><path fill="#E1DBF1" fill-rule="nonzero" d="M8 4a4 4 0 0 0-4 4v14a4 4 0 0 0 4 4h14a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4H8zm0-4h14a8 8 0 0 1 8 8v14a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8V8a8 8 0 0 1 8-8z"/><rect width="10" height="10" x="10" y="10" fill="#6B4FBB" rx="5"/></g><path fill="#FFF" d="M6 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M6 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6zm0-4h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6a6 6 0 0 1 6-6z"/><rect width="8" height="8" x="8" y="8" fill="#FC6D26" rx="4"/><g transform="translate(112 77)"><rect width="24" height="24" fill="#FFF" rx="6"/><path fill="#E1DBF1" fill-rule="nonzero" d="M6 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6zm0-4h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6a6 6 0 0 1 6-6z"/><rect width="8" height="8" x="8" y="8" fill="#6B4FBB" rx="4"/></g><g transform="translate(46 29)"><rect width="46" height="46" y="2" fill="#E1DBF1" rx="10"/><rect width="46" height="46" fill="#E1DBF1" rx="10"/><path fill="#C3B8E3" fill-rule="nonzero" d="M10 4a6 6 0 0 0-6 6v26a6 6 0 0 0 6 6h26a6 6 0 0 0 6-6V10a6 6 0 0 0-6-6H10zm0-4h26c5.523 0 10 4.477 10 10v26c0 5.523-4.477 10-10 10H10C4.477 46 0 41.523 0 36V10C0 4.477 4.477 0 10 0z"/><rect width="14" height="14" x="16" y="16" fill="#6B4FBB" rx="2"/></g><path fill="#E1DBF1" fill-rule="nonzero" d="M98.413 35.682a2 2 0 1 1-2.826-2.83l2.122-2.12a2 2 0 1 1 2.827 2.83l-2.123 2.12z"/><path fill="#C3B8E3" d="M104.78 29.32a2 2 0 0 1-2.826-2.829l2.122-2.12a2 2 0 0 1 2.827 2.83l-2.122 2.12z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M42.413 89.682a2 2 0 1 1-2.826-2.83l2.122-2.12a2 2 0 1 1 2.827 2.83l-2.123 2.12z"/><path fill="#E1DBF1" d="M48.78 83.32a2 2 0 1 1-2.826-2.829l2.122-2.12a2 2 0 1 1 2.827 2.83l-2.122 2.12z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M27.713 26.531a2 2 0 1 1 2.574-3.062l2.296 1.93a2 2 0 1 1-2.573 3.062l-2.297-1.93z"/><path fill="#C3B8E3" d="M34.604 32.321a2 2 0 1 1 2.573-3.062l2.297 1.93A2 2 0 0 1 36.9 34.25l-2.297-1.93z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M93.74 74.553a2 2 0 0 1 2.52-3.106l2.33 1.891a2 2 0 1 1-2.521 3.106l-2.33-1.891z"/><path fill="#E1DBF1" d="M100.727 80.225a2 2 0 1 1 2.521-3.105l2.33 1.89a2 2 0 1 1-2.522 3.106l-2.33-1.89z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/clusters_empty.svg b/app/assets/images/illustrations/clusters_empty.svg
new file mode 100644
index 00000000000..39627a1c314
--- /dev/null
+++ b/app/assets/images/illustrations/clusters_empty.svg
@@ -0,0 +1 @@
+<svg height="128" viewBox="0 0 142 128" width="142" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M94 62h20v4H94z" fill="#f0edf8"/><path d="M84.828 84l17.678 17.678-2.828 2.828L82 86.828z" fill="#fee1d3"/><path d="M42.828 24l17.678 17.678-2.828 2.828L40 26.828zM40 101.678L57.678 84l2.828 2.828-17.678 17.678z" fill="#f0edf8"/><path d="M82 41.678L99.678 24l2.828 2.828-17.678 17.678zM28 62h20v4H28zM3 52h24v24H3z" fill="#fee1d3"/><path d="M31 3h24v24H31z" fill="#f0edf8"/><path d="M87 3h24v24H87z" fill="#fef0e8"/><path d="M115 52h24v24h-24z" fill="#f0edf8"/><path d="M87 101h24v24H87z" fill="#fee1d3"/><path d="M31 101h24v24H31z" fill="#f0edf8"/><path d="M49 42h44v44H49z" fill="#c3b8e3"/><g fill-rule="nonzero"><path d="M5 53a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1V54a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5H5a5 5 0 0 1-5-5V54a5 5 0 0 1 5-5z" fill="#fdc4a8"/><path d="M56 43a6 6 0 0 0-6 6v30a6 6 0 0 0 6 6h30a6 6 0 0 0 6-6V49a6 6 0 0 0-6-6zm0-4h30c5.523 0 10 4.477 10 10v30c0 5.523-4.477 10-10 10H56c-5.523 0-10-4.477-10-10V49c0-5.523 4.477-10 10-10z" fill="#6b4fbb"/><path d="M89 4a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5H89a5 5 0 0 1-5-5V5a5 5 0 0 1 5-5z" fill="#fee1d3"/><path d="M89 102a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1v-20a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5H89a5 5 0 0 1-5-5v-20a5 5 0 0 1 5-5z" fill="#fdc4a8"/><path d="M117 53a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1V54a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5h-20a5 5 0 0 1-5-5V54a5 5 0 0 1 5-5zM33 102a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1v-20a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5H33a5 5 0 0 1-5-5v-20a5 5 0 0 1 5-5zM33 4a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5H33a5 5 0 0 1-5-5V5a5 5 0 0 1 5-5z" fill="#e1dbf1"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/convdev_no_data.svg b/app/assets/images/illustrations/convdev/convdev_no_data.svg
new file mode 100644
index 00000000000..b90eddcccfa
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/convdev_no_data.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="220" viewBox="0 0 360 220"><g fill="none" fill-rule="evenodd"><path fill="#000" fill-opacity=".02" d="M125 44V24.003C125 18.48 129.483 14 135.005 14h89.99C230.52 14 235 18.477 235 24.003V43h84.992C326.624 43 332 48.372 332 55.002v144.996c0 6.63-5.38 12.002-12.008 12.002h-85.984c-6.632 0-12.008-5.372-12.008-12.002V183h-78v17.002c0 6.626-5.38 11.998-12.008 11.998H46.008C39.376 212 34 206.624 34 200.002V55.998C34 49.372 39.38 44 46.008 44H125z"/><g transform="translate(214 36)"><rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/><path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77a2 2 0 0 0-2.773-2.883A11.974 11.974 0 0 0 0 12.006a2 2 0 1 0 4 0zM14.388 4h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm17.51.227a8.015 8.015 0 0 1 5.022 3.756 2 2 0 1 0 3.458-2.011A12.01 12.01 0 0 0 104.844.34a2 2 0 0 0-.946 3.887zM110 16.78v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm-.024 17.844a7.99 7.99 0 0 1-2.903 5.558 2 2 0 0 0 2.54 3.09 11.977 11.977 0 0 0 4.35-8.338 2.002 2.002 0 0 0-1.838-2.15 2.003 2.003 0 0 0-2.15 1.84zM98.826 168h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-17.334-.4a8.032 8.032 0 0 1-4.71-4.143 1.998 1.998 0 0 0-2.667-.938 1.997 1.997 0 0 0-.938 2.667 12.022 12.022 0 0 0 7.063 6.21 1.998 1.998 0 1 0 1.252-3.798zM4 154.434v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0z"/><path fill="#F0EDF8" fill-rule="nonzero" d="M57 111c-11.598 0-21-9.402-21-21s9.402-21 21-21 21 9.402 21 21-9.402 21-21 21zm0-4c9.39 0 17-7.61 17-17s-7.61-17-17-17-17 7.61-17 17 7.61 17 17 17z"/><path fill="#6B4FBB" d="M58 88v-6.997c0-1.11-.895-2.003-2-2.003-1.112 0-2 .897-2 2.003v8.994a1.999 1.999 0 0 0 2.503 1.94c.162.04.33.063.506.063h7.98a2 2 0 0 0 .001-4H58z"/><rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/><path fill="#EEE" d="M21 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 99 16z"/></g><g transform="translate(118 7)"><rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/><path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77a2 2 0 0 0-2.773-2.883A11.974 11.974 0 0 0 0 12.006a2 2 0 1 0 4 0zM14.388 4h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm17.51.227a8.015 8.015 0 0 1 5.022 3.756 2 2 0 1 0 3.458-2.011A12.01 12.01 0 0 0 104.844.34a2 2 0 0 0-.946 3.887zM110 16.78v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm-.024 17.844a7.99 7.99 0 0 1-2.903 5.558 2 2 0 0 0 2.54 3.09 11.977 11.977 0 0 0 4.35-8.338 2.002 2.002 0 0 0-1.838-2.15 2.003 2.003 0 0 0-2.15 1.84zM98.826 168h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-17.334-.4a8.032 8.032 0 0 1-4.71-4.143 1.998 1.998 0 0 0-2.667-.938 1.997 1.997 0 0 0-.938 2.667 12.022 12.022 0 0 0 7.063 6.21 1.998 1.998 0 1 0 1.252-3.798zM4 154.434v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0z"/><g fill-rule="nonzero"><path fill="#F0EDF8" d="M57 112c-12.15 0-22-9.85-22-22s9.85-22 22-22 22 9.85 22 22-9.85 22-22 22zm0-6c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16z"/><path fill="#6B4FBB" d="M41.692 105.8A21.93 21.93 0 0 0 57 112c12.15 0 22-9.85 22-22s-9.85-22-22-22v6c8.837 0 16 7.163 16 16s-7.163 16-16 16a15.935 15.935 0 0 1-11.133-4.508l-4.175 4.31z"/></g><path fill="#EEE" d="M8 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2H9.998A1.995 1.995 0 0 1 8 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004A1.995 1.995 0 0 1 99 16z"/></g><g transform="translate(26 36)"><rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/><path fill="#EEE" fill-rule="nonzero" d="M4 12.006v147.988A8 8 0 0 0 12.005 168h89.99a8.007 8.007 0 0 0 8.005-8.006V12.006A8 8 0 0 0 101.995 4h-89.99A8.007 8.007 0 0 0 4 12.006zm-4 0C0 5.376 5.377 0 12.005 0h89.99C108.628 0 114 5.37 114 12.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C5.372 172 0 166.63 0 159.994V12.006z"/><g transform="translate(21 82)"><rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/></g><g transform="translate(69 82)"><rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/></g><g transform="translate(38 42)"><rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/><rect width="38" height="4" y="12" fill="#FB722E" rx="2"/></g><path fill="#EEE" d="M4 14h106v4H4z"/><path fill="#333" d="M35.724 138h9.696v-2.856h-2.856V122.76h-2.592c-1.08.648-2.136 1.08-3.792 1.392v2.184h2.856v8.808h-3.312V138zm17.736.288c-2.952 0-5.76-2.208-5.76-7.56 0-5.688 2.952-8.256 6.168-8.256 2.016 0 3.48.84 4.44 1.824l-1.848 2.112c-.528-.576-1.488-1.08-2.376-1.08-1.68 0-3.024 1.2-3.144 4.752.792-1.008 2.112-1.608 3.048-1.608 2.616 0 4.536 1.488 4.536 4.704 0 3.168-2.304 5.112-5.064 5.112zm-.072-2.64c1.056 0 1.92-.744 1.92-2.472 0-1.608-.84-2.208-1.992-2.208-.792 0-1.68.432-2.304 1.512.312 2.4 1.32 3.168 2.376 3.168zM63.9 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/convdev_no_index.svg b/app/assets/images/illustrations/convdev/convdev_no_index.svg
new file mode 100644
index 00000000000..4aaf505e0b8
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/convdev_no_index.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200"><g fill="none" fill-rule="evenodd" transform="translate(3 11)"><rect width="110" height="168" x="6" y="8" fill="#000" fill-opacity=".02" rx="10"/><g transform="translate(0 2)"><rect width="110" height="168" fill="#FFF" rx="10"/><path fill="#EEE" fill-rule="nonzero" d="M2 10.006v147.988A8 8 0 0 0 10.005 166h89.99a8.007 8.007 0 0 0 8.005-8.006V10.006A8 8 0 0 0 99.995 2h-89.99A8.007 8.007 0 0 0 2 10.006zm-4 0C-2 3.376 3.377-2 10.005-2h89.99C106.628-2 112 3.37 112 10.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C3.372 170-2 164.63-2 157.994V10.006z"/><g transform="translate(19 80)"><rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/></g><g transform="translate(67 80)"><rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/></g><g transform="translate(36 40)"><rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/><rect width="38" height="4" y="12" fill="#FB722E" rx="2"/></g><path fill="#EEE" d="M2 12h106v4H2z"/><path fill="#333" d="M38.048 127.792c.792 0 1.68-.432 2.28-1.512-.312-2.4-1.296-3.168-2.376-3.168-1.032 0-1.92.744-1.92 2.472 0 1.608.864 2.208 2.016 2.208zm-.552 8.496c-2.016 0-3.504-.864-4.464-1.824l1.872-2.112c.504.576 1.464 1.08 2.352 1.08 1.704 0 3.024-1.2 3.144-4.752-.792 1.008-2.112 1.608-3.048 1.608-2.592 0-4.536-1.488-4.536-4.704 0-3.168 2.304-5.112 5.064-5.112 2.952 0 5.784 2.208 5.784 7.56 0 5.688-2.976 8.256-6.168 8.256zm13.488 0c-3.048 0-5.304-1.704-5.304-4.176 0-1.848 1.152-2.976 2.592-3.744v-.096c-1.176-.888-2.04-1.992-2.04-3.6 0-2.592 2.04-4.2 4.872-4.2 2.784 0 4.632 1.656 4.632 4.176 0 1.464-.936 2.64-1.992 3.336v.096c1.464.792 2.64 1.968 2.64 3.984 0 2.4-2.16 4.224-5.4 4.224zm.96-9.168c.6-.696.936-1.44.936-2.232 0-1.176-.696-1.968-1.848-1.968-.936 0-1.704.576-1.704 1.752 0 1.248 1.056 1.848 2.616 2.448zm-.888 6.72c1.176 0 2.04-.624 2.04-1.896 0-1.344-1.296-1.848-3.216-2.664-.672.624-1.176 1.488-1.176 2.424 0 1.344 1.08 2.136 2.352 2.136zm10.8-3.84c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/></g><g transform="translate(122)"><rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/><path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77a2 2 0 0 0-2.773-2.883A11.974 11.974 0 0 0 0 12.006a2 2 0 1 0 4 0zM14.388 4h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm17.51.227a8.015 8.015 0 0 1 5.022 3.756 2 2 0 1 0 3.458-2.011A12.01 12.01 0 0 0 104.844.34a2 2 0 0 0-.946 3.887zM110 16.78v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm-.024 17.844a7.99 7.99 0 0 1-2.903 5.558 2 2 0 0 0 2.54 3.09 11.977 11.977 0 0 0 4.35-8.338 2.002 2.002 0 0 0-1.838-2.15 2.003 2.003 0 0 0-2.15 1.84zM98.826 168h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-17.334-.4a8.032 8.032 0 0 1-4.71-4.143 1.998 1.998 0 0 0-2.667-.938 1.997 1.997 0 0 0-.938 2.667 12.022 12.022 0 0 0 7.063 6.21 1.998 1.998 0 1 0 1.252-3.798zM4 154.434v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0z"/><g transform="translate(21 82)"><rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/></g><g transform="translate(69 82)"><rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/></g><path fill="#FEE1D3" d="M44 44a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 44 44zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 54 44zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 64 44zM34 56a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 34 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 44 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 54 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 64 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 74 56z"/><rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/><path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/></g><g transform="translate(243)"><rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/><path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77a2 2 0 0 0-2.773-2.883A11.974 11.974 0 0 0 0 12.006a2 2 0 1 0 4 0zM14.388 4h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm17.51.227a8.015 8.015 0 0 1 5.022 3.756 2 2 0 1 0 3.458-2.011A12.01 12.01 0 0 0 104.844.34a2 2 0 0 0-.946 3.887zM110 16.78v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm-.024 17.844a7.99 7.99 0 0 1-2.903 5.558 2 2 0 0 0 2.54 3.09 11.977 11.977 0 0 0 4.35-8.338 2.002 2.002 0 0 0-1.838-2.15 2.003 2.003 0 0 0-2.15 1.84zM98.826 168h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-17.334-.4a8.032 8.032 0 0 1-4.71-4.143 1.998 1.998 0 0 0-2.667-.938 1.997 1.997 0 0 0-.938 2.667 12.022 12.022 0 0 0 7.063 6.21 1.998 1.998 0 1 0 1.252-3.798zM4 154.434v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0z"/><path fill="#FEE1D3" d="M44 44a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 44 44zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 54 44zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 64 44zM34 56a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 34 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 44 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 54 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 64 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 74 56z"/><g transform="translate(21 82)"><rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/></g><g transform="translate(69 82)"><rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/></g><rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/><rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/><path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/convdev_overview.svg b/app/assets/images/illustrations/convdev/convdev_overview.svg
new file mode 100644
index 00000000000..a06d70812ca
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/convdev_overview.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="208" height="127" viewBox="0 0 208 127" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="58" height="98" y="17" rx="6"/><rect id="b" width="58" height="98" x="3.5" y="17" rx="6"/><rect id="c" width="58" height="98.394" rx="6"/></defs><g fill="none" fill-rule="evenodd" transform="translate(1)"><path fill="#000" fill-opacity=".06" fill-rule="nonzero" d="M16 11.06c0-1.39.56-2.69 1.534-3.635.398-.386.41-1.025.027-1.426a.993.993 0 0 0-1.413-.028A7.075 7.075 0 0 0 14 11.062c0 .556.448 1.007 1 1.007s1-.452 1-1.01zm6.432-5.043h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0H185a4.95 4.95 0 0 1 3.254 1.215.995.995 0 0 0 1.41-.108c.36-.423.312-1.06-.107-1.422A6.944 6.944 0 0 0 185 4h-.568c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zM190 11.932v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm0 10.89v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.84c0 .555.448 1.007 1 1.007s1-.453 1-1.01v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008V44.6c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01zm0 10.888v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.007zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm-.24 21.446a5.06 5.06 0 0 1-2.572 2.985 1.01 1.01 0 0 0-.46 1.348c.24.5.84.708 1.336.464a7.06 7.06 0 0 0 3.598-4.178c.17-.53-.12-1.098-.644-1.27a1 1 0 0 0-1.26.65zm-8.063 3.49h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.577-.116a5.009 5.009 0 0 1-3.19-2.3.994.994 0 0 0-1.373-.333c-.472.29-.62.91-.332 1.386.99 1.632 2.6 2.8 4.465 3.215a1 1 0 0 0 1.192-.768 1.005 1.005 0 0 0-.762-1.2zM16 105.292v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01zm0-10.89v-4.84c0-.555-.448-1.007-1-1.007s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.007zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01zm0-11.888v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-9.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.007v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01z"/><g transform="translate(74)"><rect width="58" height="98" y="20" fill="#000" fill-opacity=".02" rx="6"/><use fill="#FFF" xlink:href="#a"/><rect width="56" height="96" x="1" y="18" stroke="#EEE" stroke-width="2" rx="6"/><g transform="translate(16 45.185)"><path fill="#333" d="M.59 33.815h5.655V32.15H4.58v-7.225H3.066c-.63.378-1.246.63-2.212.812v1.274H2.52v5.14H.59v1.665zm10.093.168c-1.778 0-3.094-.994-3.094-2.436 0-1.078.67-1.736 1.51-2.184v-.056c-.685-.518-1.19-1.162-1.19-2.1 0-1.512 1.19-2.45 2.843-2.45 1.624 0 2.702.966 2.702 2.436 0 .854-.546 1.54-1.162 1.946v.055c.854.462 1.54 1.148 1.54 2.324 0 1.4-1.26 2.463-3.15 2.463zm.56-5.348c.35-.406.546-.84.546-1.302 0-.686-.407-1.148-1.08-1.148-.545 0-.993.336-.993 1.022 0 .728.616 1.078 1.526 1.428zm-.518 3.92c.686 0 1.19-.364 1.19-1.106 0-.785-.756-1.08-1.876-1.555-.393.364-.687.868-.687 1.414 0 .783.63 1.245 1.372 1.245zm6.3-2.24c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.063 2.282 2.883 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.463h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.883 2.27-2.883 1.315 0 2.28 1.064 2.28 2.884 0 1.835-.965 2.913-2.28 2.913zm0-1.148c.46 0 .84-.462.84-1.764 0-1.3-.38-1.735-.84-1.735-.463 0-.84.434-.84 1.736 0 1.303.377 1.765.84 1.765z"/><rect width="13" height="2" x="6" y=".815" fill="#FB722E" rx="1"/><path fill="#F0EDF8" d="M3 47.815c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992a.994.994 0 0 1-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992a.994.994 0 0 1-.992-1z"/><rect width="20" height="2" x="3" y="6.815" fill="#FEE1D3" rx="1"/></g><g transform="translate(10.81)"><circle cx="18.19" cy="18" r="18" fill="#FFF"/><path fill="#F0EDF8" fill-rule="nonzero" d="M18.19 34c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16zm0 2c-9.94 0-18-8.06-18-18s8.06-18 18-18 18 8.06 18 18-8.06 18-18 18z"/><g transform="translate(10 11)"><path fill="#C3B8E3" fill-rule="nonzero" d="M2.19 13.32L5.397 11h7.783a.998.998 0 0 0 1.01-1V3c0-.55-.45-1-1.01-1H3.2a.998.998 0 0 0-1.01 1v10.32zM6.045 13l-3.422 2.476C1.28 16.45.19 15.892.19 14.23V3c0-1.657 1.337-3 3.01-3h9.98a3.004 3.004 0 0 1 3.01 3v7c0 1.657-1.337 3-3.01 3H6.045z"/><rect width="4" height="2" x="5.19" y="4" fill="#6B4FBB" rx="1"/><rect width="6" height="2" x="5.19" y="7" fill="#6B4FBB" rx="1"/></g></g></g><g transform="translate(144.5)"><rect width="58" height="98" x=".5" y="20" fill="#000" fill-opacity=".02" rx="6"/><use fill="#FFF" xlink:href="#b"/><rect width="56" height="96" x="4.5" y="18" stroke="#EEE" stroke-width="2" rx="6"/><g transform="translate(19 46.185)"><path fill="#333" d="M4.01 33.746c1.793 0 3.305-.938 3.305-2.59 0-1.148-.742-1.876-1.764-2.17v-.056c.953-.406 1.485-1.05 1.485-1.974 0-1.554-1.232-2.436-3.066-2.436-1.093 0-1.99.434-2.8 1.134l1.035 1.26c.56-.49 1.036-.784 1.666-.784.7 0 1.093.364 1.093.98 0 .714-.504 1.19-2.1 1.19v1.456c1.932 0 2.394.49 2.394 1.274 0 .672-.574 1.05-1.442 1.05-.756 0-1.414-.378-1.946-.896l-.953 1.302c.644.756 1.652 1.26 3.094 1.26zm4.51-.168h6.257v-1.736h-1.792c-.42 0-1.036.056-1.484.112 1.443-1.512 2.843-3.108 2.843-4.606 0-1.708-1.19-2.828-2.94-2.828-1.274 0-2.1.476-2.982 1.414l1.12 1.106c.45-.476.94-.91 1.583-.91.77 0 1.26.476 1.26 1.344 0 1.26-1.596 2.786-3.864 4.928v1.176zm9.505-3.5c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.064 2.282 2.884 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.464h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.064 2.28 2.884 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/><rect width="13" height="2.008" x="7.5" fill="#FB722E" rx="1.004"/><path fill="#F0EDF8" d="M3.5 47.19c0-.556.455-1.005 1.006-1.005h17.988c.556 0 1.006.445 1.006 1.004 0 .553-.455 1.003-1.006 1.003H4.506A1.003 1.003 0 0 1 3.5 47.188zm0 6.023c0-.555.455-1.004 1.006-1.004h17.988c.556 0 1.006.444 1.006 1.003 0 .554-.455 1.004-1.006 1.004H4.506A1.003 1.003 0 0 1 3.5 53.212z"/><rect width="20" height="2.008" x="4" y="6.024" fill="#FEE1D3" rx="1.004"/></g><g transform="translate(14.413)"><circle cx="18.087" cy="18" r="18" fill="#FFF"/><path fill="#F0EDF8" fill-rule="nonzero" d="M18.087 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M18.087 24a6 6 0 1 0 0-12 6 6 0 0 0 0 12zm0 2c-4.42 0-8-3.582-8-8s3.58-8 8-8a8 8 0 0 1 0 16z"/><path fill="#6B4FBB" d="M19.087 17v-2c0-.556-.448-1-1-1-.557 0-1 .448-1 1v3a.997.997 0 0 0 .998 1h3c.557 0 1-.448 1-1 0-.556-.447-1-1-1h-2z"/></g></g><rect width="58" height="98" x="3" y="20" fill="#000" fill-opacity=".02" rx="6"/><g transform="translate(0 16.754)"><use fill="#FFF" xlink:href="#c"/><rect width="56" height="96.394" x="1" y="1" stroke="#EEE" stroke-width="2" rx="6"/><g transform="translate(16 29.618)"><path fill="#333" d="M3.137 27.84c.462 0 .98-.253 1.33-.883-.182-1.4-.756-1.848-1.386-1.848-.6 0-1.12.433-1.12 1.44 0 .94.505 1.29 1.177 1.29zm-.322 4.955A3.626 3.626 0 0 1 .21 31.73l1.093-1.23c.294.335.854.63 1.372.63.994 0 1.764-.7 1.834-2.773-.463.588-1.233.938-1.78.938-1.51 0-2.645-.868-2.645-2.744 0-1.847 1.344-2.98 2.954-2.98 1.72 0 3.373 1.287 3.373 4.41 0 3.317-1.736 4.815-3.598 4.815zm8.12 0c-1.722 0-3.36-1.288-3.36-4.41 0-3.318 1.722-4.816 3.598-4.816 1.176 0 2.03.49 2.59 1.063l-1.078 1.232c-.308-.336-.868-.63-1.386-.63-.98 0-1.765.7-1.835 2.772.462-.588 1.232-.938 1.778-.938 1.526 0 2.646.867 2.646 2.743 0 1.848-1.345 2.982-2.955 2.982zm-.042-1.54c.616 0 1.12-.434 1.12-1.442 0-.938-.49-1.288-1.162-1.288-.46 0-.98.252-1.343.882.182 1.4.77 1.848 1.386 1.848zm6.132-2.128c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.065 2.282 2.885 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.463.84-1.765 0-1.302-.378-1.736-.84-1.736-.462 0-.84.433-.84 1.735s.378 1.764.84 1.764zm.308 4.815l4.928-9.464h1.19l-4.927 9.465h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.063 2.28 2.883 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/><rect width="13" height="2.008" x="6.5" y=".314" fill="#FEE1D3" rx="1.004"/><path fill="#F0EDF8" d="M3 46.627c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992a.994.994 0 0 1-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992a.994.994 0 0 1-.992-1z"/><rect width="20" height="2" x="3" y="5.627" fill="#FB722E" rx="1"/></g></g><g transform="translate(10.41)"><circle cx="18.589" cy="18" r="18" fill="#FFF"/><path fill="#F0EDF8" fill-rule="nonzero" d="M18.59 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/><path fill="#C3B8E3" d="M17.05 19.262h3.367l.248-2.808H17.3l-.25 2.808zm-.177 2.008l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627H13.59a1.001 1.001 0 0 1-1.003-1.004c0-.555.455-1.004 1.002-1.004h1.325l.248-2.808h-1.15a1 1 0 0 1-1.004-1.004 1.01 1.01 0 0 1 1.004-1.004h1.33l.106-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h3.365l.107-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h1.03c.554 0 1.003.446 1.003 1.004 0 .555-.455 1.004-1 1.004H22.8l-.25 2.808h1.037a1 1 0 0 1 1.002 1.004c0 .554-.456 1.004-1.003 1.004h-1.214l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627H16.87z"/><path fill="#6B4FBB" d="M17.05 19.262l-.177 2.008H14.74l.177-2.008h2.134zm-1.707-4.816h2.135l-.178 2.008h-2.135l.178-2.008zm5.5 0h2.135l-.178 2.008h-2.135l.178-2.008zm1.708 4.816l-.177 2.008H20.24l.177-2.008h2.134z"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_1.svg b/app/assets/images/illustrations/convdev/i2p_step_1.svg
new file mode 100644
index 00000000000..67467b1513d
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/i2p_step_1.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M45.688 18.854c-4.869-1.989-10.488-1.975-15.29-.001a20.014 20.014 0 0 0-6.493 4.268 19.798 19.798 0 0 0-4.346 6.381 19.135 19.135 0 0 0-1.525 7.537c0 2.066.33 4.118.983 6.104a20.142 20.142 0 0 0 1.83 3.937 5.983 5.983 0 0 0-2.086 4.538c0 3.309 2.691 6 6 6s6-2.691 6-6-2.691-6-6-6c-.779 0-1.522.154-2.205.425a18.13 18.13 0 0 1-1.642-3.533 17.467 17.467 0 0 1-.881-5.472c0-2.351.459-4.623 1.391-6.814a17.721 17.721 0 0 1 3.88-5.675 18.057 18.057 0 0 1 5.85-3.845c4.329-1.778 9.392-1.79 13.78.002a18.077 18.077 0 0 1 5.843 3.84c3.39 3.34 5.257 7.776 5.257 12.493a17.463 17.463 0 0 1-.878 5.481 17.451 17.451 0 0 1-2.569 4.923c-2.134 2.866-3.818 4.698-5.174 6.173-2.424 2.643-3.98 4.599-4.383 8.384H32.215a1 1 0 1 0 0 2h11.739a1 1 0 0 0 .999-.947c.19-3.645 1.345-5.263 3.934-8.09 1.385-1.506 3.107-3.381 5.304-6.331a19.422 19.422 0 0 0 2.864-5.489c.651-1.98.98-4.04.979-6.109 0-5.256-2.078-10.198-5.856-13.92a20.079 20.079 0 0 0-6.49-4.265M28.761 51.612c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4M40 74h-4a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2M42 70h-8a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2M38 10a1 1 0 0 0 1-1V1a1 1 0 1 0-2 0v8a1 1 0 0 0 1 1M20.828 15.828a.999.999 0 0 0 .707-1.707l-5.656-5.656a.999.999 0 1 0-1.414 1.414l5.656 5.656a.997.997 0 0 0 .707.293M10 33H2a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2M60.12 8.465l-5.656 5.656a.999.999 0 1 0 1.414 1.414l5.656-5.656a.999.999 0 1 0-1.414-1.414M74 33h-8a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2M43 66H33a1 1 0 1 0 0 2h10a1 1 0 1 0 0-2"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_10.svg b/app/assets/images/illustrations/convdev/i2p_step_10.svg
new file mode 100644
index 00000000000..588ecd81414
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/i2p_step_10.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M5 43a1 1 0 1 0 2 0v-4h4a1 1 0 1 0 0-2H7v-4a1 1 0 1 0-2 0v4H1a1 1 0 1 0 0 2h4v4M75 37h-4v-4a1 1 0 1 0-2 0v4h-4a1 1 0 1 0 0 2h4v4a1 1 0 1 0 2 0v-4h4a1 1 0 1 0 0-2M21 38a1 1 0 0 0 .47.848l8 5a.999.999 0 0 0 1.061-1.696L23.887 38l6.644-4.152a1 1 0 1 0-1.061-1.695l-8 5A.998.998 0 0 0 21 38M55 38a1 1 0 0 0-.47-.848l-8-5a.999.999 0 1 0-1.061 1.695L52.113 38l-6.644 4.152a1 1 0 1 0 1.061 1.696l8-5A1 1 0 0 0 55 38M41.803 26.05a1 1 0 0 0-1.256.65l-7 22a1.001 1.001 0 0 0 .953 1.303 1 1 0 0 0 .953-.697l7-22a1.001 1.001 0 0 0-.65-1.256M62 7c3.859 0 7 3.141 7 7v11a1 1 0 1 0 2 0V14c0-4.963-4.04-9-9-9H45.91c-.479-2.833-2.943-5-5.91-5-3.309 0-6 2.691-6 6s2.691 6 6 6c2.967 0 5.431-2.167 5.91-5H62m-22 3c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4M6 26a1 1 0 0 0 1-1V14c0-3.859 3.141-7 7-7h11.09l-3.293 3.293a.999.999 0 1 0 1.414 1.414l5-5a.999.999 0 0 0 0-1.414l-5-5a.999.999 0 1 0-1.414 1.414L25.09 5H14c-4.963 0-9 4.04-9 9v11a1 1 0 0 0 1 1M36 64c-2.967 0-5.431 2.167-5.91 5H14c-3.859 0-7-3.141-7-7V51a1 1 0 1 0-2 0v11c0 4.963 4.04 9 9 9h16.09c.478 2.833 2.942 5 5.91 5 3.309 0 6-2.691 6-6s-2.691-6-6-6m0 10c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4M70 50a1 1 0 0 0-1 1v11c0 3.859-3.141 7-7 7H50.91l3.293-3.293a.999.999 0 1 0-1.414-1.414l-5 5a.999.999 0 0 0 0 1.414l5 5a.997.997 0 0 0 1.414 0 .999.999 0 0 0 0-1.414L50.91 71H62c4.963 0 9-4.04 9-9V51a1 1 0 0 0-1-1"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_2.svg b/app/assets/images/illustrations/convdev/i2p_step_2.svg
new file mode 100644
index 00000000000..4280024c23c
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/i2p_step_2.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M42.26 40.44a.989.989 0 0 0 1.109-.877l2.625-22.444a.997.997 0 0 0-.993-1.117h-14a1 1 0 0 0-.994 1.108l3.454 31.575a6.981 6.981 0 0 0-2.46 5.317c0 3.859 3.141 7 7 7s7-3.141 7-7-3.141-7-7-7c-.94 0-1.835.189-2.655.527l-3.23-29.527h11.761L41.383 39.33a1 1 0 0 0 .877 1.11m.741 13.562c0 2.757-2.243 5-5 5s-5-2.243-5-5 2.243-5 5-5 5 2.243 5 5"/><path d="M73.236 23.749a1 1 0 0 0-1.854.75A35.788 35.788 0 0 1 74 38c0 19.851-16.149 36-36 36S2 57.851 2 38 18.149 2 38 2c7.6 0 14.83 2.332 20.965 6.74A5.955 5.955 0 0 0 58 12c0 1.603.624 3.109 1.758 4.242A5.956 5.956 0 0 0 64 18a5.956 5.956 0 0 0 4.242-1.758C69.376 15.109 70 13.603 70 12s-.624-3.109-1.758-4.242A5.956 5.956 0 0 0 64 6a5.943 5.943 0 0 0-3.668 1.259C53.812 2.512 46.104 0 38 0 17.047 0 0 17.047 0 38s17.047 38 38 38 38-17.047 38-38c0-4.93-.93-9.725-2.764-14.251zM64 8c1.068 0 2.072.416 2.828 1.172S68 10.932 68 12s-.416 2.072-1.172 2.828c-1.512 1.512-4.145 1.512-5.656 0C60.416 14.072 60 13.068 60 12s.416-2.072 1.172-2.828S62.932 8 64 8z"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_3.svg b/app/assets/images/illustrations/convdev/i2p_step_3.svg
new file mode 100644
index 00000000000..7690f91b420
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/i2p_step_3.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M12 8c0-3.309-2.691-6-6-6S0 4.691 0 8c0 2.967 2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909 0 3.309 2.691 6 6 6s6-2.691 6-6c0-2.967-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.479 5-2.943 5-5.91M2 8c0-2.206 1.794-4 4-4s4 1.794 4 4-1.794 4-4 4-4-1.794-4-4m8 60c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4M21 6h54a1 1 0 1 0 0-2H21a1 1 0 1 0 0 2M21 12h35a1 1 0 1 0 0-2H21a1 1 0 1 0 0 2M75 24H21a1 1 0 1 0 0 2h54a1 1 0 1 0 0-2M21 32h34a1 1 0 1 0 0-2H21a1 1 0 1 0 0 2M75 44H21a1 1 0 1 0 0 2h54a1 1 0 1 0 0-2M21 52h34a1 1 0 1 0 0-2H21a1 1 0 1 0 0 2M75 64H21a1 1 0 1 0 0 2h54a1 1 0 1 0 0-2M55 70H21a1 1 0 1 0 0 2h34a1 1 0 1 0 0-2"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_4.svg b/app/assets/images/illustrations/convdev/i2p_step_4.svg
new file mode 100644
index 00000000000..ba21b9e2c3a
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/i2p_step_4.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M67.7 10h-6.751C60.442 4.402 55.728 0 50 0c-6.06 0-11 4.935-11 11s4.935 11 11 11c5.728 0 10.442-4.402 10.949-10H67.7c1.269 0 2.3.987 2.3 2.2v57.6c0 1.213-1.031 2.2-2.3 2.2H8.3C7.031 74 6 73.013 6 71.8V14.2C6 12.987 7.031 12 8.3 12h15.15a1 1 0 1 0 0-2H8.3C5.929 10 4 11.884 4 14.2v57.6C4 74.116 5.929 76 8.3 76h59.4c2.371 0 4.3-1.884 4.3-4.2V14.2c0-2.316-1.929-4.2-4.3-4.2M50 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/><path d="M21.293 29.29a.999.999 0 0 0 0 1.414l12.975 12.975-12.975 12.974a.999.999 0 1 0 1.414 1.414l13.682-13.682a.999.999 0 0 0 0-1.414L22.707 29.29a.999.999 0 0 0-1.414 0M54 59a1 1 0 1 0 0-2H42a1 1 0 1 0 0 2h12"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_5.svg b/app/assets/images/illustrations/convdev/i2p_step_5.svg
new file mode 100644
index 00000000000..3c8f8422a97
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/i2p_step_5.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M48.949 37C48.442 31.402 43.728 27 38 27s-10.442 4.402-10.949 10h-13.05a1 1 0 1 0 0 2h13.05c.507 5.598 5.221 10 10.949 10s10.442-4.402 10.949-10h12.24a1 1 0 1 0 0-2h-12.24M38 47c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/><path d="M73.236 23.749a1 1 0 0 0-1.854.75A35.788 35.788 0 0 1 74 38c0 19.851-16.149 36-36 36S2 57.851 2 38 18.149 2 38 2c7.6 0 14.83 2.332 20.965 6.74A5.955 5.955 0 0 0 58 12c0 1.603.624 3.109 1.758 4.242A5.956 5.956 0 0 0 64 18a5.956 5.956 0 0 0 4.242-1.758C69.376 15.109 70 13.603 70 12s-.624-3.109-1.758-4.242A5.956 5.956 0 0 0 64 6a5.943 5.943 0 0 0-3.668 1.259C53.812 2.512 46.104 0 38 0 17.047 0 0 17.047 0 38s17.047 38 38 38 38-17.047 38-38c0-4.93-.93-9.725-2.764-14.251zM64 8c1.068 0 2.072.416 2.828 1.172S68 10.932 68 12s-.416 2.072-1.172 2.828c-1.512 1.512-4.145 1.512-5.656 0C60.416 14.072 60 13.068 60 12s.416-2.072 1.172-2.828S62.932 8 64 8z"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_6.svg b/app/assets/images/illustrations/convdev/i2p_step_6.svg
new file mode 100644
index 00000000000..933860798ad
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/i2p_step_6.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M14.267 7.32l-4.896 5.277-1.702-1.533a.999.999 0 1 0-1.338 1.486l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062a.99.99 0 0 0 .752-.016c.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6a1 1 0 0 0-1.466-1.36M31 9h44a1 1 0 1 0 0-2H31a1 1 0 1 0 0 2M31 15h24a1 1 0 1 0 0-2H31a1 1 0 1 0 0 2"/><path d="M11 0C4.93 0 0 4.935 0 11s4.935 11 11 11 11-4.935 11-11S17.065 0 11 0m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9M14.267 34.32l-4.896 5.277-1.702-1.533a1 1 0 1 0-1.338 1.486l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062a.99.99 0 0 0 .752-.016c.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6a1 1 0 0 0-1.466-1.36M75 34H31a1 1 0 1 0 0 2h44a1 1 0 1 0 0-2M31 42h24a1 1 0 1 0 0-2H31a1 1 0 1 0 0 2"/><path d="M11 27C4.93 27 0 31.935 0 38s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9M14.267 61.32l-4.896 5.277-1.702-1.533a1 1 0 1 0-1.338 1.486l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062a.99.99 0 0 0 .752-.016c.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6a1 1 0 0 0-1.466-1.36"/><path d="M11 54C4.93 54 0 58.935 0 65s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9M75 61H31a1 1 0 1 0 0 2h44a1 1 0 1 0 0-2M55 67H31a1 1 0 1 0 0 2h24a1 1 0 1 0 0-2"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_7.svg b/app/assets/images/illustrations/convdev/i2p_step_7.svg
new file mode 100644
index 00000000000..d97c8f7c2d4
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/i2p_step_7.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M73.236 23.749a1 1 0 1 0-1.854.75A35.788 35.788 0 0 1 74 38c0 19.851-16.149 36-36 36S2 57.851 2 38 18.149 2 38 2c7.6 0 14.83 2.332 20.965 6.74A5.955 5.955 0 0 0 58 12c0 1.603.624 3.109 1.758 4.242A5.956 5.956 0 0 0 64 18a5.956 5.956 0 0 0 4.242-1.758C69.376 15.109 70 13.603 70 12s-.624-3.109-1.758-4.242A5.956 5.956 0 0 0 64 6a5.943 5.943 0 0 0-3.668 1.259C53.812 2.512 46.104 0 38 0 17.047 0 0 17.047 0 38s17.047 38 38 38 38-17.047 38-38c0-4.93-.93-9.725-2.764-14.251zM64 8c1.068 0 2.072.416 2.828 1.172S68 10.932 68 12s-.416 2.072-1.172 2.828c-1.512 1.512-4.145 1.512-5.656 0C60.416 14.072 60 13.068 60 12s.416-2.072 1.172-2.828S62.932 8 64 8z"/><path d="M27.19 32.17a.997.997 0 0 0-1.366-.364L13.17 39.132a1 1 0 0 0 0 1.73l12.654 7.326a1 1 0 0 0 1.002-1.73l-11.159-6.461 11.159-6.461a.998.998 0 0 0 .364-1.366M48.808 47.827a1 1 0 0 0 1.366.364l12.654-7.326a1 1 0 0 0 0-1.73l-12.654-7.326a1 1 0 0 0-1.002 1.73L60.331 40l-11.159 6.461a.998.998 0 0 0-.364 1.366M42.71 23.06L31.398 56.29a1 1 0 0 0 1.892.645l11.312-33.23a1 1 0 0 0-1.892-.645"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_8.svg b/app/assets/images/illustrations/convdev/i2p_step_8.svg
new file mode 100644
index 00000000000..919bbeff319
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/i2p_step_8.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M62.44 54.765l-9.912-11.09c.315-3.881.481-7.241.508-10.271-.029-13.871-3.789-23.05-13.413-32.746-.855-.859-2.411-.828-3.294.059-7.594 7.65-11.139 13.934-12.575 22.3a6.94 6.94 0 0 0-4.699 2.039c-1.321 1.321-2.05 3.079-2.05 4.949s.729 3.628 2.051 4.949c1.321 1.322 3.079 2.051 4.949 2.051s3.628-.729 4.949-2.051a6.951 6.951 0 0 0 2.051-4.949 6.955 6.955 0 0 0-2.051-4.949c-.9-.9-2-1.517-3.205-1.824 1.373-7.859 4.764-13.818 11.999-21.11.128-.13.356-.158.456-.059 9.207 9.274 12.805 18.06 12.832 31.33-.026 3.079-.202 6.527-.536 10.54a.997.997 0 0 0 .25.749l10.166 11.379c.062.076.109.23.093.32l-4.547 17.407c-.004.015-.009.036-.079.106a.403.403 0 0 1-.2.106l-3.577.002c-.144-.009-.265-.077-.309-.153l-5.425-10.328a1.002 1.002 0 0 0-.886-.535H30.024c-.371 0-.713.206-.886.535l-5.407 10.303-.069.072a.366.366 0 0 1-.199.105l-3.588.001c-.179-.009-.304-.123-.33-.227l-4.531-17.338a.525.525 0 0 1 .049-.34L25.26 44.682a1 1 0 0 0-1.492-1.332L13.539 54.803c-.448.554-.63 1.312-.474 2.084l4.544 17.396c.253.963 1.146 1.669 2.218 1.719h3.636c.581 0 1.187-.261 1.615-.693.114-.114.286-.286.406-.528l5.144-9.793h14.754l5.16 9.822c.396.697 1.124 1.143 2.01 1.192l3.712-.003a2.396 2.396 0 0 0 1.544-.694c.313-.316.504-.646.598-1.022l4.557-17.451a2.502 2.502 0 0 0-.518-2.066M29.01 30.001c0 1.335-.521 2.591-1.465 3.535s-2.2 1.465-3.535 1.465-2.591-.521-3.535-1.465-1.465-2.2-1.465-3.535.521-2.591 1.465-3.535 2.2-1.465 3.535-1.465 2.591.521 3.535 1.465 1.465 2.2 1.465 3.535"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_9.svg b/app/assets/images/illustrations/convdev/i2p_step_9.svg
new file mode 100644
index 00000000000..2d1b10d430d
--- /dev/null
+++ b/app/assets/images/illustrations/convdev/i2p_step_9.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M68 67c-1.725 0-3.36.541-4.723 1.545A12.998 12.998 0 0 0 52 62c-2.734 0-5.359.853-7.555 2.43L42.159 49h1.228l3.829 7.645c.339.598.962.979 1.724 1.022l2.812-.003a2.07 2.07 0 0 0 1.316-.595c.264-.266.433-.559.514-.882l3.433-13.145a2.138 2.138 0 0 0-.449-1.763l-7.385-8.268c.231-2.875.354-5.376.374-7.641C49.532 14.863 46.684 7.908 39.393.564c-.737-.742-2.072-.715-2.829.044-5.617 5.659-8.309 10.336-9.446 16.463a5.95 5.95 0 0 0-3.36 1.686C22.624 19.891 22 21.397 22 23s.624 3.109 1.758 4.242C24.891 28.376 26.397 29 28 29s3.109-.624 4.242-1.758C33.376 26.109 34 24.603 34 23s-.624-3.109-1.758-4.242a5.952 5.952 0 0 0-3.098-1.648c1.095-5.538 3.637-9.855 8.83-15.14 6.874 6.924 9.561 13.485 9.581 23.392-.021 2.316-.151 4.903-.402 7.91a.999.999 0 0 0 .25.749l7.663 8.572-3.391 13.07-2.695.036-4.081-8.15a1.001 1.001 0 0 0-.895-.553h-12.01c-.379 0-.725.214-.895.553l-4.04 8.114-2.707.015-3.427-13.07 7.671-8.588a1 1 0 0 0-1.492-1.332l-7.7 8.623c-.383.47-.54 1.116-.406 1.787l3.419 13.08c.216.829.98 1.438 1.907 1.48h2.735c.508 0 1.016-.218 1.391-.595.091-.09.242-.241.358-.475l3.804-7.597h1.228l-2.286 15.43a12.914 12.914 0 0 0-7.555-2.43c-4.685 0-8.979 2.53-11.277 6.545a7.943 7.943 0 0 0-4.723-1.545c-4.411 0-8 3.589-8 8a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1c0-4.411-3.589-8-8-8m-36-44a3.973 3.973 0 0 1-1.172 2.828c-1.512 1.512-4.145 1.512-5.656 0-.756-.756-1.172-1.76-1.172-2.828s.416-2.072 1.172-2.828 1.76-1.172 2.828-1.172 2.072.416 2.828 1.172 1.172 1.76 1.172 2.828m-29.917 51a6.01 6.01 0 0 1 5.917-5c1.638 0 3.17.652 4.313 1.836a.998.998 0 0 0 1.634-.289 11.011 11.011 0 0 1 10.05-6.547c2.836 0 5.532 1.085 7.593 3.055a1.001 1.001 0 0 0 1.681-.576l2.588-17.479h4.275l2.589 17.479a.999.999 0 1 0 1.681.576 10.945 10.945 0 0 1 7.593-3.055c4.343 0 8.288 2.57 10.05 6.547a.998.998 0 0 0 1.634.289 5.948 5.948 0 0 1 4.313-1.836 6.01 6.01 0 0 1 5.917 5H2.076"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/epics.svg b/app/assets/images/illustrations/epics.svg
new file mode 100644
index 00000000000..1a37e6bba5f
--- /dev/null
+++ b/app/assets/images/illustrations/epics.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="430" height="300" viewBox="0 0 430 300"><g fill="none" fill-rule="evenodd"><g transform="translate(75 53)"><rect width="284" height="208" y="5" fill="#F9F9F9" rx="10"/><rect width="284" height="208" fill="#FFF" rx="10"/><path fill="#EEE" fill-rule="nonzero" d="M10 4a6 6 0 0 0-6 6v188a6 6 0 0 0 6 6h264a6 6 0 0 0 6-6V10a6 6 0 0 0-6-6H10zm0-4h264c5.523 0 10 4.477 10 10v188c0 5.523-4.477 10-10 10H10c-5.523 0-10-4.477-10-10V10C0 4.477 4.477 0 10 0z"/><path fill="#EEE" fill-rule="nonzero" d="M25.168 153.995c3.837-.215 7.173.028 10.119.691a3 3 0 1 0 1.318-5.853c-3.509-.79-7.4-1.074-11.773-.828a3 3 0 1 0 .336 5.99zm19.043 4.66c2.401 1.704 4.388 3.61 7.569 7.083a3 3 0 0 0 4.424-4.054c-3.448-3.763-5.686-5.911-8.522-7.923a3 3 0 1 0-3.471 4.894zm15.575 15.173c3.181 2.675 6.52 4.665 10.397 6.039a3 3 0 0 0 2.004-5.655c-3.162-1.121-5.884-2.743-8.54-4.976a3 3 0 1 0-3.861 4.592zm22.133 8.148c1.02.037 2.067.045 3.143.023a72.664 72.664 0 0 0 8.346-.638 3 3 0 1 0-.812-5.945c-2.442.334-4.996.53-7.658.585a48.55 48.55 0 0 1-2.796-.021 3 3 0 0 0-.223 5.996zm22.778-3.286c3.9-1.37 7.427-3.15 10.54-5.305a3 3 0 0 0-3.415-4.933c-2.665 1.845-5.712 3.382-9.114 4.578a3 3 0 0 0 1.989 5.66zm19.156-13.62a33.752 33.752 0 0 0 5.276-10.817 3 3 0 1 0-5.773-1.633 27.753 27.753 0 0 1-4.341 8.9 3 3 0 1 0 4.838 3.55zm6.577-22.657c-.187-3.817-.926-7.71-2.204-11.596a3 3 0 0 0-5.7 1.874c1.113 3.384 1.75 6.745 1.91 10.016a3 3 0 1 0 5.994-.294zm-7.097-22.26c-1.897-3.2-4.152-6.325-6.748-9.344a3 3 0 0 0-4.55 3.913c2.372 2.756 4.421 5.597 6.136 8.49a3 3 0 0 0 5.162-3.06zm-11.546-17.793c-.938-3.025-1.402-6.42-1.365-9.976a3 3 0 0 0-6-.063c-.043 4.163.506 8.177 1.634 11.816a3 3 0 1 0 5.731-1.777zm.053-20.107c.905-3.341 2.22-6.538 3.904-9.448a3 3 0 0 0-5.194-3.004c-1.948 3.368-3.463 7.048-4.501 10.884a3 3 0 1 0 5.791 1.568zm10.134-17.305c2.475-2.28 5.265-4.09 8.335-5.374a3 3 0 1 0-2.314-5.536c-3.725 1.558-7.105 3.75-10.086 6.497a3 3 0 1 0 4.065 4.413zm18.177-7.586c3.202-.18 6.599.092 10.18.843a3 3 0 0 0 1.23-5.872c-4.086-.857-8.009-1.172-11.747-.962a3 3 0 1 0 .337 5.99zm20.047 3.95c3.068 1.268 6.232 2.842 9.487 4.728a3 3 0 0 0 3.009-5.191c-3.48-2.017-6.883-3.71-10.204-5.083a3 3 0 1 0-2.292 5.545zm19.578 9.955c3.711 1.586 7.376 2.77 10.997 3.565a3 3 0 0 0 1.286-5.86c-3.248-.713-6.555-1.782-9.925-3.222a3 3 0 1 0-2.358 5.517zm22.591 4.789c3.94-.04 7.808-.553 11.61-1.513a3 3 0 1 0-1.468-5.817 43.358 43.358 0 0 1-10.203 1.33 3 3 0 0 0 .061 6zm22.52-5.558c3.335-1.637 6.607-3.613 9.845-5.916a3 3 0 1 0-3.477-4.89c-2.984 2.122-5.98 3.931-9.011 5.42a3 3 0 1 0 2.643 5.386zm18.678-13.054a3 3 0 0 1-4.02-4.454 130.547 130.547 0 0 0 5.31-5.088 3 3 0 1 1 4.265 4.22 136.507 136.507 0 0 1-5.555 5.322zm-48.722 25.641a3 3 0 1 1 4.314-4.17c3.056 3.16 5.075 6.744 6.172 10.754a3 3 0 0 1-5.787 1.584c-.834-3.047-2.35-5.739-4.699-8.168zm5.347 18.049a3 3 0 1 1 5.978.52c-.282 3.232-.805 6.273-1.832 11.206a3 3 0 0 1-5.874-1.222c.981-4.717 1.473-7.572 1.728-10.504zm-3.777 21.555a3 3 0 0 1 5.953.747c-.5 3.988-.397 7.09.399 9.67a3 3 0 1 1-5.733 1.769c-1.087-3.52-1.217-7.426-.62-12.186zm7.393 22.444a3 3 0 0 1 4.461-4.013c2.703 3.005 5.224 5.296 7.594 6.947a3 3 0 0 1-3.429 4.924c-2.775-1.932-5.632-4.53-8.626-7.858zm20.352 12.28a3 3 0 1 1 .334-5.99c2.77.154 5.453-.554 9.224-2.254a3 3 0 0 1 2.466 5.47c-4.57 2.06-8.103 2.993-12.024 2.775zm21.784-7.058a3 3 0 0 1-1.815-5.719c4.227-1.342 8.24-1.61 12.496-.572a3 3 0 0 1-1.421 5.83c-3.116-.76-6.025-.566-9.26.46zM106.53 56.038a3 3 0 1 1-3.45 4.909c-1.074-.755-6.723-6.044-8.083-7.204a68.019 68.019 0 0 0-.332-.281 3 3 0 1 1 3.865-4.59l.362.306c1.643 1.402 6.971 6.391 7.638 6.86zM88.536 42.422a3 3 0 0 1-2.285 5.548c-3.14-1.293-5.78-1.34-8.105-.05a3 3 0 0 1-2.91-5.247c4.087-2.266 8.597-2.187 13.3-.25zM66.698 48.73a3 3 0 0 1 2.029 5.647c-4.432 1.592-8.786.835-13.166-1.88a3 3 0 1 1 3.16-5.1c2.93 1.816 5.425 2.25 7.977 1.333zm-15.636-8.038a3 3 0 0 1-4.352 4.13c-.911-.96-1.85-1.98-3.061-3.32-.295-.325-2.437-2.703-3.07-3.4-.47-.518-.9-.988-1.313-1.436a3 3 0 0 1 4.41-4.068c.425.46.866.942 1.346 1.47.642.709 2.79 3.092 3.076 3.41a180.865 180.865 0 0 0 2.964 3.214z"/><path fill="#E1DBF1" d="M254.66 72.196l2-3.464a2 2 0 1 0-3.464-2l-2 3.464-3.464-2a2 2 0 0 0-2 3.464l3.464 2-2 3.464a2 2 0 0 0 3.464 2l2-3.464 3.464 2a2 2 0 1 0 2-3.464l-3.464-2zm-151.904 78.732l2.829-2.828a2 2 0 0 0-2.829-2.829l-2.828 2.829-2.828-2.829a2 2 0 0 0-2.829 2.829l2.829 2.828-2.829 2.829a2 2 0 1 0 2.829 2.828l2.828-2.828 2.828 2.828a2 2 0 1 0 2.829-2.828l-2.829-2.829z"/><path fill="#6B4FBB" d="M210.66 173.66l3.464-2a2 2 0 1 0-2-3.464l-3.464 2-2-3.464a2 2 0 0 0-3.464 2l2 3.464-3.464 2a2 2 0 1 0 2 3.464l3.464-2 2 3.464a2 2 0 1 0 3.464-2l-2-3.464z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M27 181a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm0-4a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M138 85a7 7 0 1 1 0-14 7 7 0 0 1 0 14zm0-4a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M200 57a7 7 0 1 1 0-14 7 7 0 0 1 0 14zm0-4a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path fill="#FC6D26" fill-rule="nonzero" d="M222.647 121.647v5h5v-5h-5zm0-4h5a4 4 0 0 1 4 4v5a4 4 0 0 1-4 4h-5a4 4 0 0 1-4-4v-5a4 4 0 0 1 4-4z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M103.647 28.647v5h5v-5h-5zm0-4h5a4 4 0 0 1 4 4v5a4 4 0 0 1-4 4h-5a4 4 0 0 1-4-4v-5a4 4 0 0 1 4-4z"/><path fill="#FC6D26" fill-rule="nonzero" d="M85 103.488L81.841 108h6.318L85 103.488zm6.436 2.218A4 4 0 0 1 88.159 112H81.84a4 4 0 0 1-3.277-6.294l3.16-4.512a4 4 0 0 1 6.553 0l3.159 4.512z"/></g><path fill="#F9F9F9" d="M334.376 99.43A48.805 48.805 0 0 0 366 111c27.062 0 49-21.938 49-49s-21.938-49-49-49-49 21.938-49 49c0 9.454 2.677 18.283 7.315 25.77l-3.05 11.306a2.5 2.5 0 0 0 3.064 3.065l10.047-2.71z"/><path fill="#FFF" d="M339.376 94.43A48.805 48.805 0 0 0 371 106c27.062 0 49-21.938 49-49S398.062 8 371 8s-49 21.938-49 49c0 9.454 2.677 18.283 7.315 25.77l-3.05 11.306a2.5 2.5 0 0 0 3.064 3.065l10.047-2.71z"/><path fill="#EEE" fill-rule="nonzero" d="M329.85 99.072a4.5 4.5 0 0 1-5.516-5.517l2.827-10.48C322.501 75.258 320 66.31 320 57c0-28.167 22.833-51 51-51s51 22.833 51 51-22.833 51-51 51c-11.859 0-23.096-4.064-32.102-11.37l-9.048 2.442zm10.817-6.169C349.091 100.027 359.737 104 371 104c25.957 0 47-21.043 47-47s-21.043-47-47-47-47 21.043-47 47c0 8.859 2.453 17.351 7.016 24.716l.456.737-3.277 12.144c.072.527.347.685.613.613l11.059-2.984.8.677z"/><g transform="translate(354 34)"><path fill="#E1DBF1" fill-rule="nonzero" d="M13 4a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-8zm0-4h8a5 5 0 0 1 5 5v1a5 5 0 0 1-5 5h-8a5 5 0 0 1-5-5V5a5 5 0 0 1 5-5z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M5 11a1 1 0 0 0 0 2h24a1 1 0 0 0 0-2H5zm0-4h24a5 5 0 0 1 0 10H5A5 5 0 0 1 5 7z"/><rect width="12" height="4" x="11" y="31" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="19" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="37" fill="#E1DBF1" rx="2"/><rect width="12" height="4" x="11" y="43" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="25" fill="#E1DBF1" rx="2"/></g><path fill="#F9F9F9" d="M344.238 225.072A38.83 38.83 0 0 1 368 217c21.54 0 39 17.46 39 39s-17.46 39-39 39-39-17.46-39-39a38.84 38.84 0 0 1 4.001-17.227l-3.737-13.85a2.5 2.5 0 0 1 3.065-3.064l11.91 3.213z"/><path fill="#FFF" d="M348.238 221.072A38.83 38.83 0 0 1 372 213c21.54 0 39 17.46 39 39s-17.46 39-39 39-39-17.46-39-39a38.84 38.84 0 0 1 4.001-17.227l-3.737-13.85a2.5 2.5 0 0 1 3.065-3.064l11.91 3.213z"/><path fill="#EEE" fill-rule="nonzero" d="M336.85 215.928a4.5 4.5 0 0 0-5.516 5.517l3.543 13.13A40.848 40.848 0 0 0 331 252c0 22.644 18.356 41 41 41s41-18.356 41-41-18.356-41-41-41a40.82 40.82 0 0 0-24.182 7.887l-10.968-2.96zm12.608 6.73A36.824 36.824 0 0 1 372 215c20.435 0 37 16.565 37 37s-16.565 37-37 37-37-16.565-37-37c0-5.747 1.31-11.304 3.795-16.343l.334-.677-3.934-14.577a.5.5 0 0 1 .613-.613l12.865 3.471.785-.604z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M356.097 255.962a7 7 0 0 0 8.81 10.88l1.093-.885v1.454a7 7 0 1 0 14 0v-1.454l1.092.885a7 7 0 1 0 8.81-10.88l-1.185-.96 1.455-.337a7 7 0 1 0-3.15-13.64l-1.4.323.623-1.278a7 7 0 0 0-12.583-6.137l-.662 1.356-.662-1.356a7 7 0 0 0-12.583 6.137l.623 1.278-1.4-.324a7 7 0 1 0-3.15 13.641l1.455.336-1.186.96zm5.464-.913a11.914 11.914 0 0 1-.444-1.95l-.19-1.362-4.2-.97a3 3 0 0 1 1.35-5.845l4.178.964.768-1.145c.373-.557.793-1.082 1.254-1.57l.95-1.006-1.877-3.849a3 3 0 0 1 5.393-2.63l1.892 3.879 1.363-.113a12.188 12.188 0 0 1 2.004 0l1.363.113 1.892-3.879a3 3 0 0 1 5.393 2.63l-1.877 3.849.95 1.006c.461.488.88 1.013 1.254 1.57l.768 1.145 4.178-.964a3 3 0 1 1 1.35 5.846l-4.2.97-.19 1.36a11.914 11.914 0 0 1-.444 1.95l-.413 1.302 3.36 2.72a3 3 0 1 1-3.776 4.663l-3.32-2.688-1.196.706a11.94 11.94 0 0 1-1.808.873l-1.286.492v4.295a3 3 0 1 1-6 0v-4.295l-1.286-.492a11.94 11.94 0 0 1-1.808-.873l-1.196-.706-3.32 2.688a3 3 0 1 1-3.776-4.663l3.36-2.72-.413-1.301z"/><path fill="#FC6D26" fill-rule="nonzero" d="M373 245.411a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm0 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/><g><path fill="#F9F9F9" d="M94.624 162.43A48.805 48.805 0 0 1 63 174c-27.062 0-49-21.938-49-49s21.938-49 49-49 49 21.938 49 49c0 9.454-2.677 18.283-7.315 25.77l3.05 11.306a2.5 2.5 0 0 1-3.064 3.065l-10.047-2.71z"/><path fill="#FFF" stroke="#EEE" stroke-width="4" d="M89.624 157.43A48.805 48.805 0 0 1 58 169c-27.062 0-49-21.938-49-49s21.938-49 49-49 49 21.938 49 49c0 9.454-2.677 18.283-7.315 25.77l3.05 11.306a2.5 2.5 0 0 1-3.064 3.065l-10.047-2.71z"/><path fill="#EEE" fill-rule="nonzero" d="M99.15 162.072a4.5 4.5 0 0 0 5.516-5.517l-2.827-10.48C106.499 138.258 109 129.31 109 120c0-28.167-22.833-51-51-51S7 91.833 7 120s22.833 51 51 51c11.859 0 23.096-4.064 32.102-11.37l9.048 2.442zm-10.817-6.169C79.909 163.027 69.263 167 58 167c-25.957 0-47-21.043-47-47s21.043-47 47-47 47 21.043 47 47c0 8.859-2.453 17.351-7.016 24.716l-.456.737 3.277 12.144c-.072.527-.347.685-.613.613l-11.059-2.984-.8.677z"/><g fill-rule="nonzero"><path fill="#FEE1D3" d="M55.47 94.47l-16.148 6.688a4 4 0 0 0-2.164 2.164l-6.689 16.147a4 4 0 0 0 0 3.062l6.689 16.147a4 4 0 0 0 2.164 2.164l16.147 6.689a4 4 0 0 0 3.062 0l16.147-6.689a4 4 0 0 0 2.164-2.164l6.689-16.147a4 4 0 0 0 0-3.062l-6.689-16.147a4 4 0 0 0-2.164-2.164L58.53 94.469a4 4 0 0 0-3.062 0zM57 98.164l16.147 6.688L79.835 121l-6.688 16.147L57 143.835l-16.147-6.688L34.165 121l6.688-16.147L57 98.165zM57 107a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm12 4a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm4 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-4 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-12 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-12-6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-4-11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm4-11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm12 20c6.075 0 11-4.925 11-11s-4.925-11-11-11-11 4.925-11 11 4.925 11 11 11zm0-4a7 7 0 1 1 0-14 7 7 0 0 1 0 14z"/><path fill="#FC6D26" d="M57 126.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0-3a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5z"/></g></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/gitlab_logo.svg b/app/assets/images/illustrations/gitlab_logo.svg
new file mode 100644
index 00000000000..8dbd75a340e
--- /dev/null
+++ b/app/assets/images/illustrations/gitlab_logo.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="492.509" height="453.68" viewBox="0 0 492.50943 453.67966"><g fill="none" fill-rule="evenodd"><path d="M491.589 259.398l-27.559-84.814L409.413 6.486c-2.81-8.648-15.045-8.648-17.856 0l-54.619 168.098H155.572L100.952 6.486c-2.81-8.648-15.046-8.648-17.856 0L28.478 174.584.921 259.398a18.775 18.775 0 0 0 6.82 20.992l238.513 173.29L484.77 280.39a18.777 18.777 0 0 0 6.82-20.992" fill="#fc6d26"/><path d="M246.255 453.68l90.684-279.096H155.57z" fill="#e24329"/><path d="M246.255 453.68L155.57 174.583H28.479z" fill="#fc6d26"/><path d="M28.479 174.584L.92 259.4a18.773 18.773 0 0 0 6.821 20.99l238.514 173.29z" fill="#fca326"/><path d="M28.479 174.584H155.57L100.952 6.487c-2.81-8.65-15.047-8.65-17.856 0z" fill="#e24329"/><path d="M246.255 453.68l90.684-279.096H464.03z" fill="#fc6d26"/><path d="M464.03 174.584l27.56 84.815a18.773 18.773 0 0 1-6.822 20.99L246.255 453.68z" fill="#fca326"/><path d="M464.03 174.584H336.94L391.557 6.487c2.811-8.65 15.047-8.65 17.856 0z" fill="#e24329"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/image_comment_light_cursor.svg b/app/assets/images/illustrations/image_comment_light_cursor.svg
new file mode 100644
index 00000000000..ac712ea0c96
--- /dev/null
+++ b/app/assets/images/illustrations/image_comment_light_cursor.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 38 38"><g fill="none" fill-rule="evenodd"><circle cx="19" cy="19" r="18" fill="#FFF"/><path fill="#1F78D1" fill-rule="nonzero" d="M19 38C8.507 38 0 29.493 0 19S8.507 0 19 0s19 8.507 19 19-8.507 19-19 19zm0-2c9.389 0 17-7.611 17-17S28.389 2 19 2 2 9.611 2 19s7.611 17 17 17zm-6.293-8.293c-.63.63-1.707.184-1.707-.707V15a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-7.586l-3.707 3.707zM13 24.586l2.293-2.293A1 1 0 0 1 16 22h8a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1H14a1 1 0 0 0-1 1v9.586z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/image_comment_light_cursor@2x.svg b/app/assets/images/illustrations/image_comment_light_cursor@2x.svg
new file mode 100644
index 00000000000..02943acd9d7
--- /dev/null
+++ b/app/assets/images/illustrations/image_comment_light_cursor@2x.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 38 38"><g fill="none" fill-rule="evenodd"><circle cx="19" cy="19" r="18" fill="#FFF"/><path fill="#1F78D1" fill-rule="nonzero" d="M19 38C8.507 38 0 29.493 0 19S8.507 0 19 0s19 8.507 19 19-8.507 19-19 19zm0-2c9.389 0 17-7.611 17-17S28.389 2 19 2 2 9.611 2 19s7.611 17 17 17zm-6.293-8.293c-.63.63-1.707.184-1.707-.707V15a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-7.586l-3.707 3.707zM13 24.586l2.293-2.293A1 1 0 0 1 16 22h8a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1H14a1 1 0 0 0-1 1v9.586z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/job_not_triggered.svg b/app/assets/images/illustrations/job_not_triggered.svg
new file mode 100644
index 00000000000..e13c1cb0a7d
--- /dev/null
+++ b/app/assets/images/illustrations/job_not_triggered.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 310 141" xmlns:xlink="http://www.w3.org/1999/xlink"><g fill="none" fill-rule="evenodd"><g fill-rule="nonzero"><path fill="#e5e5e5" d="M48 69c0-1.105.887-2 1.998-2h4c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4A1.992 1.992 0 0 1 48 69m14 0c0-1.105.887-2 1.998-2h4c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4A1.992 1.992 0 0 1 62 69"/><g fill="#31af64"><path d="M19 88C8.507 88 0 79.493 0 69s8.507-19 19-19 19 8.507 19 19-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 71.02l-2.829-2.828a1.995 1.995 0 0 0-2.828 0 1.997 1.997 0 0 0 0 2.83l4.243 4.243a1.993 1.993 0 0 0 2.823.005l7.79-7.79a1.998 1.998 0 0 0-.007-2.822 1.99 1.99 0 0 0-2.822-.006l-6.37 6.37v-.001"/></g></g><g transform="translate(187)"><rect width="116" height="134" y="7" fill="#f9f9f9" rx="10"/><rect width="116" height="134" x="5" y="2" fill="#fff" rx="10"/><path fill="#eee" fill-rule="nonzero" d="M15 4a8 8 0 0 0-8 8v114a8 8 0 0 0 8 8h96a8 8 0 0 0 8-8V12a8 8 0 0 0-8-8H15m0-4h96c6.627 0 12 5.373 12 12v114c0 6.627-5.373 12-12 12H15c-6.627 0-12-5.373-12-12V12C3 5.373 8.373 0 15 0"/><g transform="translate(23 25)"><g fill="#e1dbf1"><rect width="16" height="4" rx="2"/><rect width="16" height="4" x="32" y="12" rx="2"/></g><rect width="16" height="4" x="44" fill="#eee" rx="2"/><rect width="16" height="4" x="12" y="24" fill="#e1dbf1" rx="2"/><rect width="16" height="4" x="64" y="36" fill="#fef0e8" rx="2"/><rect width="8" height="4" x="20" fill="#fee1d3" rx="2" id="a"/><rect width="8" height="4" x="32" y="36" fill="#fc6d26" rx="2"/><rect width="8" height="4" x="52" y="12" fill="#fef0e8" rx="2"/><rect width="8" height="4" x="64" fill="#fef0e8" rx="2" id="b"/><rect width="12" height="4" x="16" y="48" fill="#e1dbf1" rx="2"/><rect width="8" height="4" x="44" y="36" fill="#fc6d26" rx="2"/><g fill="#e1dbf1"><rect width="4" height="4" x="56" y="36" rx="2"/><rect width="4" height="4" x="64" y="60" rx="2"/></g><rect width="4" height="4" x="72" y="60" fill="#fc6d26" rx="2"/><rect width="8" height="4" x="32" fill="#fc6d26" rx="2" id="c"/><g fill="#eee"><rect width="28" height="4" y="36" rx="2"/><rect width="28" height="4" x="44" y="48" rx="2"/></g><rect width="28" height="4" x="32" y="60" fill="#efedf8" rx="2"/><rect width="28" height="4" y="12" fill="#6b4fbb" rx="2"/><rect width="28" height="4" x="32" y="24" fill="#c3b8e3" rx="2"/><rect width="8" height="4" y="24" fill="#fef0e8" rx="2"/><rect width="8" height="4" x="32" y="48" fill="#6b4fbb" rx="2"/><rect width="12" height="4" y="48" fill="#fc6d26" rx="2"/><g fill="#fef0e8"><rect width="12" height="4" y="60" rx="2"/><rect width="12" height="4" x="16" y="60" rx="2"/></g></g><g transform="translate(23 97)"><rect width="16" height="4" fill="#efedf8" rx="2"/><rect width="16" height="4" x="18" y="12" fill="#fc6d26" rx="2"/><rect width="16" height="4" x="44" fill="#6b4fbb" rx="2"/><use xlink:href="#a"/><rect width="8" height="4" x="38" y="12" fill="#fef0e8" rx="2"/><use xlink:href="#b"/><use xlink:href="#c"/><rect width="14" height="4" y="12" fill="#eee" rx="2"/></g></g><g fill-rule="nonzero"><path fill="#eee" d="M109 101a2 2 0 1 1 0-4c2.524 0 5-.346 7.379-1.02a2 2 0 0 1 1.091 3.849 31.007 31.007 0 0 1-8.47 1.172m18.09-5.825a31.174 31.174 0 0 0 6.187-5.899 2 2 0 1 0-3.131-2.489 27.133 27.133 0 0 1-5.393 5.142 2.001 2.001 0 0 0 2.337 3.247m11.297-15.288a30.923 30.923 0 0 0 1.576-8.407 2 2 0 1 0-3.996-.188 26.875 26.875 0 0 1-1.372 7.32 2 2 0 1 0 3.791 1.275m.283-18.89a30.855 30.855 0 0 0-3.593-7.763 2 2 0 1 0-3.362 2.166 26.905 26.905 0 0 1 3.128 6.757 2 2 0 0 0 3.828-1.16M127.875 45.41a30.973 30.973 0 0 0-7.435-4.228 2 2 0 0 0-1.477 3.717 26.936 26.936 0 0 1 6.474 3.682 2 2 0 0 0 2.438-3.172m-17.834-6.391a31.09 31.09 0 0 0-8.5.886 2 2 0 0 0 .959 3.883 27.06 27.06 0 0 1 7.408-.771 2 2 0 1 0 .132-3.998m-18.272 5.207a31.139 31.139 0 0 0-6.383 5.688 2 2 0 1 0 3.045 2.593 27.152 27.152 0 0 1 5.564-4.957 2 2 0 1 0-2.226-3.324M79.96 59.121a30.864 30.864 0 0 0-1.862 8.349 2 2 0 1 0 3.987.323c.203-2.506.75-4.946 1.62-7.268a2 2 0 1 0-3.746-1.404m-.923 18.873a30.827 30.827 0 0 0 3.327 7.881 2.001 2.001 0 0 0 3.435-2.051 26.785 26.785 0 0 1-2.895-6.859 2 2 0 0 0-3.865 1.029M89.301 93.94a31.008 31.008 0 0 0 7.286 4.476 2 2 0 1 0 1.603-3.665 26.983 26.983 0 0 1-6.346-3.899 2 2 0 0 0-2.543 3.087m17.61 6.991a2 2 0 0 1 .265-3.991c.601.04 1.205.06 1.812.06a1.999 1.999 0 1 1-.001 3.999c-.695 0-1.387-.023-2.076-.069"/><path fill="#fc0" d="M117.78 63.798c.241.268.288.563.14.884l-10.848 23.24c-.174.334-.455.502-.843.502-.054 0-.148-.014-.282-.04a.855.855 0 0 1-.512-.382.761.761 0 0 1-.09-.603l3.957-16.232-8.156 2.03a1.08 1.08 0 0 1-.241.02.93.93 0 0 1-.623-.222c-.24-.2-.328-.462-.26-.783l4.04-16.574a.858.858 0 0 1 .321-.462.917.917 0 0 1 .563-.18h6.59c.254 0 .468.083.642.25a.797.797 0 0 1 .261.593.818.818 0 0 1-.1.362l-3.435 9.301 7.955-1.969c.107-.027.187-.04.241-.04.254 0 .482.1.683.301"/><path fill="#e5e5e5" d="M148 69c0-1.105.887-2 1.998-2h4c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4A1.992 1.992 0 0 1 148 69m14 0c0-1.105.887-2 1.998-2h4c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4A1.992 1.992 0 0 1 162 69"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/logos/go_logo.svg b/app/assets/images/illustrations/logos/go_logo.svg
new file mode 100644
index 00000000000..7fd49118006
--- /dev/null
+++ b/app/assets/images/illustrations/logos/go_logo.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 16 16"><g fill-rule="evenodd"><path d="M14 16.01h1V7.99C15 4.128 11.866.999 8 .999c-3.858 0-7 3.13-7 6.991v8.02h1V7.99c0-3.306 2.691-5.991 6-5.991 3.314 0 6 2.682 6 5.991v8.02M3.48 2.656a2 2 0 1 0-2.155 3.228c.102-.321.226-.631.371-.93a1.001 1.001 0 1 1 1.069-1.599 6.96 6.96 0 0 1 .717-.699m9.04-.002a2 2 0 1 1 2.155 3.23 6.835 6.835 0 0 0-.37-.931 1 1 0 1 0-1.068-1.599 6.96 6.96 0 0 0-.717-.699"/><path d="M5.726 8.04h1.557v.124c0 .283-.033.534-.1.752a1.583 1.583 0 0 1-.33.566c-.35.394-.795.591-1.335.591-.527 0-.979-.19-1.355-.571a1.893 1.893 0 0 1-.564-1.377c0-.547.191-1.01.574-1.391a1.902 1.902 0 0 1 1.396-.574c.295 0 .57.06.825.181.244.12.484.316.72.586l-.405.388c-.309-.412-.686-.618-1.13-.618-.399 0-.733.138-1 .413-.27.27-.405.609-.405 1.015 0 .42.151.766.452 1.037.282.252.587.378.915.378.28 0 .531-.094.754-.283.223-.19.347-.418.373-.683h-.94v-.535m2.884.061c0-.53.194-.986.583-1.367a1.919 1.919 0 0 1 1.396-.571c.537 0 .998.192 1.382.576.386.384.578.845.578 1.384 0 .542-.194 1-.581 1.379a1.944 1.944 0 0 1-1.408.569c-.487 0-.923-.168-1.311-.505-.426-.373-.64-.861-.64-1.465m.574.007c0 .417.14.759.42 1.028.278.269.6.403.964.403.395 0 .729-.137 1-.41.272-.277.408-.613.408-1.01 0-.402-.134-.739-.403-1.01a1.33 1.33 0 0 0-.991-.41c-.392 0-.723.137-.993.41a1.36 1.36 0 0 0-.405 1m-.184 3.918c.525.026.812.063.812.063.271.025.324-.096.116-.273 0 0-.775-.813-1.933-.813-1.159 0-1.923.813-1.923.813-.211.174-.153.3.12.273 0 0 .286-.037.81-.063v.477c0 .268.224.5.5.5.268 0 .5-.223.5-.498v-.252.25c0 .268.224.5.5.5.268 0 .5-.223.5-.498v-.478m-1-1.023c.552 0 1-.224 1-.5s-.448-.5-1-.5-1 .224-1 .5.448.5 1 .5"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/logos/mattermost_logo.svg b/app/assets/images/illustrations/logos/mattermost_logo.svg
new file mode 100644
index 00000000000..b577c0599aa
--- /dev/null
+++ b/app/assets/images/illustrations/logos/mattermost_logo.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" version="1" viewBox="0 0 501 501"><path d="M236 .7C137.7 7.5 54 68.2 18.2 158.5c-32 81-19.6 172.8 33 242.5 39.8 53 97.2 87 164.3 97 16.5 2.7 48 3.2 63.5 1.2 48.7-6.3 92.2-24.6 129-54.2 13-10.5 33-31.2 42.2-43.7 26.4-35.5 42.8-75.8 49-120.3 1.6-12.3 1.6-48.7 0-61-4-28.3-12-54.8-24.2-79.5-12.8-26-26.5-45.3-46.8-65.8C417.8 64 400.2 49 398.4 49c-.6 0-.4 10.5.3 26l1.3 26 7 8.7c19 23.7 32.8 53.5 38.2 83 2.5 14 3 43 1 55.8-4.5 27.8-15.2 54-31 76.5-8.6 12.2-28 31.6-40.2 40.2-24 17-50 27.6-80 33-10 1.8-49 1.8-59 0-43-7.7-78.8-26-107.2-54.8-29.3-29.7-46.5-64-52.4-104.4-2-14-1.5-42 1-55C90 121.4 132 72 192 49.7c8-3 18.4-5.8 29.5-8.2 1.7-.4 34.4-38 35.3-40.6.3-1-10.2-1-20.8-.4z"/><path d="M322.2 24.6c-1.3.8-8.4 9.3-16 18.7-7.4 9.5-22.4 28-33.2 41.2-51 62.2-66 81.6-70.6 91-6 12-8.4 21-9 33-1.2 19.8 5 36 19 50C222 268 230 273 243 277.2c9 3 10.4 3.2 24 3.2 13.8 0 15 0 22.6-3 23.2-9 39-28.4 45-55.7 2-8.2 2-28.7.4-79.7l-2-72c-1-36.8-1.4-41.8-3-44-2-3-4.8-3.6-7.8-1.4z"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/manual_action.svg b/app/assets/images/illustrations/manual_action.svg
new file mode 100644
index 00000000000..85735855b46
--- /dev/null
+++ b/app/assets/images/illustrations/manual_action.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 398 151" xmlns:xlink="http://www.w3.org/1999/xlink"><g fill="none" fill-rule="evenodd"><path fill="#fef0e8" stroke="#fc6d26" stroke-width="4" d="M57.7 106.5h21.6a4.2 4.2 0 0 1 4.2 4.2v5.6a4.2 4.2 0 0 1-4.2 4.2H57.7a4.2 4.2 0 0 1-4.2-4.2v-5.6a4.2 4.2 0 0 1 4.2-4.2"/><g transform="translate(42 117)"><rect width="52" height="23" x=".5" y=".5" fill="#fff" stroke="#eee" stroke-width="4" rx="4.2"/><g fill="#fdc4a8"><rect width="11" height="2" x="8" y="8" rx="1"/><rect width="11" height="2" x="8" y="14" rx="1"/></g></g><g fill-rule="nonzero"><path fill="#e1dbf1" d="M96.31 132.32c1.048 0 1.648.007 4.319.042 11.523.153 18.377-.12 26.32-1.533 24.23-4.309 38.521-18.02 38.521-45.03 0-31.02 21.885-44.487 66.903-40.522l.351-3.985c-47.09-4.147-71.25 10.727-71.25 44.507 0 24.868-12.746 37.1-35.22 41.09-7.623 1.356-14.284 1.621-25.567 1.471a287.717 287.717 0 0 0-4.372-.042v4"/><path fill="#eee" d="M242 57.678c-6.29-1.373-11-6.976-11-13.678 0-6.702 4.71-12.304 11-13.678v4.136c-4.057 1.274-7 5.065-7 9.542 0 4.478 2.943 8.268 7 9.542v4.136"/></g><g transform="translate(242)"><rect width="116" height="134" y="7" fill="#f9f9f9" rx="10"/><rect width="116" height="134" x="5" y="2" fill="#fff" rx="10"/><path fill="#eee" fill-rule="nonzero" d="M15 4a8 8 0 0 0-8 8v114a8 8 0 0 0 8 8h96a8 8 0 0 0 8-8V12a8 8 0 0 0-8-8H15m0-4h96c6.627 0 12 5.373 12 12v114c0 6.627-5.373 12-12 12H15c-6.627 0-12-5.373-12-12V12C3 5.373 8.373 0 15 0"/><g transform="translate(23 25)"><g fill="#e1dbf1"><rect width="16" height="4" rx="2"/><rect width="16" height="4" x="32" y="12" rx="2"/></g><rect width="16" height="4" x="44" fill="#eee" rx="2"/><rect width="16" height="4" x="12" y="24" fill="#e1dbf1" rx="2"/><rect width="16" height="4" x="64" y="36" fill="#fef0e8" rx="2"/><rect width="8" height="4" x="20" fill="#fee1d3" rx="2" id="a"/><rect width="8" height="4" x="32" y="36" fill="#fc6d26" rx="2"/><rect width="8" height="4" x="52" y="12" fill="#fef0e8" rx="2"/><rect width="8" height="4" x="64" fill="#fef0e8" rx="2" id="b"/><rect width="12" height="4" x="16" y="48" fill="#e1dbf1" rx="2"/><rect width="8" height="4" x="44" y="36" fill="#fc6d26" rx="2"/><g fill="#e1dbf1"><rect width="4" height="4" x="56" y="36" rx="2"/><rect width="4" height="4" x="64" y="60" rx="2"/></g><rect width="4" height="4" x="72" y="60" fill="#fc6d26" rx="2"/><rect width="8" height="4" x="32" fill="#fc6d26" rx="2" id="c"/><g fill="#eee"><rect width="28" height="4" y="36" rx="2"/><rect width="28" height="4" x="44" y="48" rx="2"/></g><rect width="28" height="4" x="32" y="60" fill="#efedf8" rx="2"/><rect width="28" height="4" y="12" fill="#6b4fbb" rx="2"/><rect width="28" height="4" x="32" y="24" fill="#c3b8e3" rx="2"/><rect width="8" height="4" y="24" fill="#fef0e8" rx="2"/><rect width="8" height="4" x="32" y="48" fill="#6b4fbb" rx="2"/><rect width="12" height="4" y="48" fill="#fc6d26" rx="2"/><g fill="#fef0e8"><rect width="12" height="4" y="60" rx="2"/><rect width="12" height="4" x="16" y="60" rx="2"/></g><g transform="translate(0 72)"><rect width="16" height="4" fill="#efedf8" rx="2"/><rect width="16" height="4" x="18" y="12" fill="#fc6d26" rx="2"/><rect width="16" height="4" x="44" fill="#6b4fbb" rx="2"/><use xlink:href="#a"/><rect width="8" height="4" x="38" y="12" fill="#fef0e8" rx="2"/><use xlink:href="#b"/><use xlink:href="#c"/><rect width="14" height="4" y="12" fill="#eee" rx="2"/></g></g></g><g transform="translate(330 83)"><circle cx="33" cy="33" r="33" fill="#fff"/><g fill-rule="nonzero"><path fill="#eee" d="M33 68C13.67 68-2 52.33-2 33S13.67-2 33-2s35 15.67 35 35-15.67 35-35 35m0-4c17.12 0 31-13.879 31-31C64 15.88 50.121 2 33 2 15.88 2 2 15.879 2 33c0 17.12 13.879 31 31 31"/><path fill="#6b4fbb" stroke="#6b4fbb" stroke-width=".968" d="M42.383 34.655v-3.308l-2.112-.343c-.116-.456-.351-.913-.703-1.598l1.29-1.711-2.463-2.398-1.76 1.256a6.347 6.347 0 0 0-1.642-.684l-.233-2.055h-3.401l-.352 2.055c-.586.114-1.055.342-1.642.684l-1.76-1.255-2.463 2.397 1.173 1.711c-.352.57-.469 1.027-.704 1.598l-1.995.228v3.31l2.112.342c.116.57.351 1.027.703 1.598l-1.172 1.712 2.463 2.397 1.759-1.141c.469.227 1.056.456 1.642.684l.352 2.055h3.518l.352-2.055c.586-.114 1.055-.342 1.642-.684l1.76 1.255 2.463-2.397-1.29-1.712a6.03 6.03 0 0 0 .703-1.598l1.76-.344M33 36.367c-1.994 0-3.519-1.484-3.519-3.424 0-1.941 1.525-3.424 3.519-3.424 1.994 0 3.519 1.483 3.519 3.424 0 1.94-1.525 3.424-3.519 3.424" stroke-linecap="round" stroke-linejoin="bevel"/><path fill="#e1dbf1" d="M33 53.563c-11.598 0-21-9.206-21-20.563s9.402-20.563 21-20.563S54 21.643 54 33s-9.402 20.563-21 20.563m0-4.375c9.13 0 16.532-7.248 16.532-16.188 0-8.94-7.402-16.188-16.532-16.188-9.13 0-16.532 7.248-16.532 16.188 0 8.94 7.402 16.188 16.532 16.188"/></g></g><path fill="#fff" d="M164 114c14.912 0 27-12.09 27-27 0-14.912-12.09-27-27-27-14.912 0-27 12.09-27 27 0 14.912 12.09 27 27 27"/><g fill-rule="nonzero"><path fill="#eee" d="M164 118c-17.12 0-31-13.879-31-31 0-17.12 13.879-31 31-31 17.12 0 31 13.879 31 31 0 17.12-13.879 31-31 31m0-4c14.912 0 27-12.09 27-27 0-14.912-12.09-27-27-27-14.912 0-27 12.09-27 27 0 14.912 12.09 27 27 27"/><path fill="#fc0" d="M172.78 80.798c.241.268.288.563.14.884l-10.848 23.24c-.174.334-.455.502-.843.502-.054 0-.148-.014-.282-.04a.855.855 0 0 1-.512-.382.761.761 0 0 1-.09-.603l3.957-16.232-8.156 2.03a1.08 1.08 0 0 1-.241.02.93.93 0 0 1-.623-.222c-.24-.2-.328-.462-.26-.783l4.04-16.574a.858.858 0 0 1 .321-.462.917.917 0 0 1 .563-.18h6.59c.254 0 .468.083.642.25a.797.797 0 0 1 .261.593.818.818 0 0 1-.1.362l-3.435 9.301 7.955-1.969c.107-.027.187-.04.241-.04.254 0 .482.1.683.301"/></g><g><path fill="#eee" fill-rule="nonzero" d="M37.801 99.01l5.355 2.648c2.271 1.122 4.643-.252 4.809-2.778l.487-7.546a27.675 27.675 0 0 0 2.87-4.076c7.594-13.152 3.088-29.972-10.07-37.565-13.153-7.594-29.971-3.087-37.566 10.07-7.594 13.154-3.087 29.973 10.07 37.565a27.46 27.46 0 0 0 24.05 1.687m.952-3.992a2.002 2.002 0 0 0-1.698-.035 23.454 23.454 0 0 1-21.299-1.124c-11.24-6.488-15.09-20.86-8.602-32.1 6.49-11.239 20.862-15.09 32.1-8.601 11.239 6.489 15.09 20.862 8.6 32.1a23.519 23.519 0 0 1-2.849 3.939 1.995 1.995 0 0 0-.504 1.204l-.466 7.229-5.285-2.613"/><path fill="#fdc4a8" d="M21.137 70.471A7.495 7.495 0 0 0 27.5 74c2.684 0 5.04-1.41 6.363-3.529C36.377 71.869 38 74.267 38 77.674c0 5.799-2.739 9.587-10.5 9.587S17 83.473 17 77.674c0-3.407 1.622-5.804 4.137-7.203M27.5 72a5.5 5.5 0 1 1 0-11 5.5 5.5 0 1 1 0 11"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/merge_request_changes_empty.svg b/app/assets/images/illustrations/merge_request_changes_empty.svg
new file mode 100644
index 00000000000..40efeb2de57
--- /dev/null
+++ b/app/assets/images/illustrations/merge_request_changes_empty.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="374" height="268" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><circle id="a" cx="44" cy="44" r="44"/><circle id="b" cx="31" cy="31" r="31"/><circle id="c" cx="35" cy="35" r="35"/><rect id="d" width="230" height="176" rx="10"/><circle id="e" cx="31" cy="31" r="31"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(4 98)"><circle cx="53" cy="53" r="44" fill="#F9F9F9"/><g transform="translate(6 6)"><use fill="#FFF" xlink:href="#a"/><circle cx="44" cy="44" r="42" stroke="#EEE" stroke-width="4"/><path fill="#FEE1D3" fill-rule="nonzero" d="M34.394 55.736A4 4 0 0 1 36.706 55H56a6 6 0 0 0 6-6V35a6 6 0 0 0-6-6H34a6 6 0 0 0-6 6v25.265l6.394-4.53zM36.706 59l-7.972 5.647A3 3 0 0 1 24 62.199V35c0-5.523 4.477-10 10-10h22c5.523 0 10 4.477 10 10v14c0 5.523-4.477 10-10 10H36.706z"/><path fill="#FC6D26" d="M38 40a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm7 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm7 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/></g></g><g transform="translate(50 2)"><circle cx="39" cy="39" r="31" fill="#F9F9F9"/><g transform="translate(5 5)"><use fill="#FFF" xlink:href="#b"/><circle cx="31" cy="31" r="29" stroke="#EEE" stroke-width="4"/><rect width="20" height="4" x="21" y="29" fill="#6B4FBB" rx="2"/></g></g><path fill="#F9F9F9" d="M235.58 229H102c-6.627 0-12-5.373-12-12V65c0-6.627 5.373-12 12-12h206c6.627 0 12 5.373 12 12v18.399A34.842 34.842 0 0 1 337 79c19.33 0 35 15.67 35 35s-15.67 35-35 35a34.842 34.842 0 0 1-17-4.399V217c0 6.627-5.373 12-12 12h-11.58c.38 1.941.58 3.947.58 6 0 17.12-13.88 31-31 31s-31-13.88-31-31c0-2.053.2-4.059.58-6z"/><g transform="translate(87 50)"><g transform="translate(212 26)"><use fill="#FFF" xlink:href="#c"/><circle cx="35" cy="35" r="33" stroke="#EEE" stroke-width="4"/><g transform="translate(20 19)"><circle cx="15" cy="16" r="15" fill="#F4F1FA" stroke="#6B4FBB" stroke-width="3"/><path fill="#6B4FBB" d="M19.419 6.996h-.007L16.959 4l-2.454 2.997h-.006L12.045 4 9.59 6.998h-.003L7.132 4 4.676 7H2c2.605-4.204 7.23-7 12.502-7C19.771 0 24.394 2.793 27 6.994h-2.676L21.872 4l-2.453 2.996z"/><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><use fill="#FFF" xlink:href="#d"/><rect width="226" height="172" x="2" y="2" stroke="#EEE" stroke-width="4" rx="10"/><rect width="4" height="122" x="33" y="42" fill="#EEE" rx="2"/><g transform="translate(13 59)"><rect width="10" height="4" fill="#FEE1D3" rx="2"/><rect width="10" height="4" y="12" fill="#F0EDF8" rx="2"/><rect width="10" height="4" y="24" fill="#FEF0E9" rx="2"/><rect width="10" height="4" y="36" fill="#FEE1D3" rx="2"/><rect width="10" height="4" y="48" fill="#E1DBF1" rx="2"/><rect width="10" height="4" y="60" fill="#F0EDF8" rx="2"/><rect width="10" height="4" y="72" fill="#FEF0E9" rx="2"/><rect width="10" height="4" y="84" fill="#FEE1D3" rx="2"/></g><g transform="translate(55 59)"><rect width="14" height="4" fill="#6B4FBB" rx="2"/><rect width="14" height="4" x="20" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="40" fill="#FEF0E9" rx="2"/><rect width="14" height="4" y="12" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="40" y="24" fill="#FEF0E9" rx="2"/><rect width="14" height="4" y="48" fill="#E1DBF1" rx="2"/><rect width="14" height="4" x="40" y="36" fill="#FEF0E9" rx="2"/><rect width="7" height="4" x="20" y="12" fill="#FEE1D3" rx="2"/><rect width="7" height="4" x="27" y="36" fill="#6B4FBB" rx="2"/><rect width="7" height="4" x="20" y="48" fill="#FEE1D3" rx="2"/><rect width="7" height="4" y="24" fill="#FC6D26" rx="2"/><rect width="21" height="4" x="13" y="24" fill="#E1DBF1" rx="2"/><rect width="21" height="4" y="36" fill="#EEE" rx="2"/><rect width="7" height="4" x="33" y="12" fill="#6B4FBB" rx="2"/><g transform="translate(98)"><rect width="14" height="4" fill="#FEE1D3" rx="2"/><rect width="14" height="4" x="20" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="40" fill="#FC6D26" rx="2"/><rect width="14" height="4" y="12" fill="#FEF0E9" rx="2"/><rect width="14" height="4" x="40" y="24" fill="#E1DBF1" rx="2"/><rect width="14" height="4" y="48" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="40" y="36" fill="#FEE1D3" rx="2"/><rect width="7" height="4" x="20" y="12" fill="#FC6D26" rx="2"/><rect width="7" height="4" x="27" y="36" fill="#6B4FBB" rx="2"/><rect width="7" height="4" x="20" y="48" fill="#FC6D26" rx="2"/><rect width="7" height="4" y="24" fill="#6B4FBB" rx="2"/><rect width="21" height="4" x="13" y="24" fill="#FEE1D3" rx="2"/><rect width="21" height="4" y="36" fill="#FEF0E9" rx="2"/><rect width="7" height="4" x="33" y="12" fill="#6B4FBB" rx="2"/></g><g transform="translate(0 60)"><rect width="14" height="4" fill="#F0EDF8" rx="2"/><rect width="14" height="4" x="20" fill="#6B4FBB" rx="2"/><rect width="14" height="4" x="40" fill="#E1DBF1" rx="2"/><rect width="14" height="4" y="12" fill="#FEF0E9" rx="2"/><rect width="14" height="4" x="40" y="24" fill="#FEE1D3" rx="2"/><rect width="7" height="4" x="20" y="12" fill="#EEE" rx="2"/><rect width="7" height="4" y="24" fill="#6B4FBB" rx="2"/><rect width="21" height="4" x="13" y="24" fill="#FEF0E9" rx="2"/><rect width="7" height="4" x="33" y="12" fill="#FC6D26" rx="2"/></g><rect width="4" height="63" x="74" y="13" fill="#EEE" rx="2"/></g><rect width="230" height="4" y="27" fill="#EEE" rx="2"/></g><g transform="translate(233 201)"><use fill="#FFF" xlink:href="#e"/><circle cx="31" cy="31" r="29" stroke="#EEE" stroke-width="4"/><path fill="#FC6D26" d="M29 29v-6a2 2 0 1 1 4 0v6h6a2 2 0 1 1 0 4h-6v6a2 2 0 1 1-4 0v-6h-6a2 2 0 1 1 0-4h6z"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/monitoring/getting_started.svg b/app/assets/images/illustrations/monitoring/getting_started.svg
index db7a1c2e708..ff783bdd388 100644
--- a/app/assets/images/illustrations/monitoring/getting_started.svg
+++ b/app/assets/images/illustrations/monitoring/getting_started.svg
@@ -1 +1 @@
-<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
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="159.8" height="127.81" x=".196" y="5" rx="10"/><rect id="b" width="160" height="128" x=".666" y=".41" rx="10"/><rect id="c" width="160.19" height="128.19" x=".339" y=".59" rx="10"/><mask id="d" width="159.8" height="127.81" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><mask id="e" width="160" height="128" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><mask id="f" width="160.19" height="128.19" x="0" y="0" fill="#fff"><use xlink:href="#c"/></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="rotate(5 202.071 210.085)" rx="10"/><g transform="rotate(15 -104.714 891.23)"><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(#d)" xlink:href="#a"/><path fill="#d2caea" fill-rule="nonzero" d="M96.153 81.151a2.001 2.001 0 0 0 2.184-.496l35.956-38.34a2 2 0 1 0-2.918-2.736l-35.03 37.36-41.888-16.285a2 2 0 0 0-2.16.471l-26.368 27.16a2 2 0 1 0 2.87 2.786l25.444-26.21 41.911 16.294"/><g fill="#fff" transform="translate(24.368 36.951)"><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><rect width="160.19" height="128.19" x="36.28" y="86.74" fill="#f9f9f9" transform="rotate(-5 116.372 150.825)" rx="10"/><g transform="rotate(5 -1514.687 1518.752)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#e)" xlink:href="#b"/><path fill="#6b4fbb" stroke="#6b4fbb" stroke-width="3.2" d="M84.67 28.41c18.225 0 33 15.07 33 33.651h-33V28.41" stroke-linecap="round" stroke-linejoin="round"/><path fill="#d2caea" fill-rule="nonzero" d="M78.67 66.41h30a2 2 0 0 1 2 2c0 18.778-15.222 34-34 34s-34-15.222-34-34 15.222-34 34-34a2 2 0 0 1 2 2v30m-32 2c0 16.569 13.431 30 30 30 15.896 0 28.905-12.364 29.934-28H76.67a2 2 0 0 1-2-2V38.476c-15.636 1.029-28 14.04-28 29.934"/></g><g transform="rotate(-5 1023.06 -299.524)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#f)" xlink:href="#c"/><path fill="#fef0ea" d="M42 47.391c0-.768.628-1.391 1.4-1.391h9.2c.773 0 1.4.626 1.4 1.391V97H42V47.391"/><path fill="#fb722e" d="M108 55.406c0-.777.628-1.406 1.4-1.406h9.2a1.4 1.4 0 0 1 1.4 1.406V97h-12V55.406"/><path fill="#6b4fbb" d="M64 35.404c0-.776.628-1.404 1.4-1.404h9.2a1.4 1.4 0 0 1 1.4 1.404v61.6H64v-61.6"/><path fill="#d2caea" d="M86 73.4a1.4 1.4 0 0 1 1.4-1.398h9.2c.773 0 1.4.618 1.4 1.398v23.602H86V73.4"/></g><g fill="#fee8dc"><path d="M3.592 93.86l-2.454-1.562c-.93-.592-.924-1.554 0-2.143l2.454-1.562 1.562-2.454c.592-.93 1.554-.925 2.143 0l1.562 2.454 2.454 1.562c.93.591.924 1.554 0 2.143L8.86 93.86l-1.562 2.454c-.591.93-1.554.924-2.143 0L3.592 93.86M309.489 52.07l-3.14-1.998c-1.12-.713-1.128-1.863 0-2.581l3.14-2 1.999-3.14c.713-1.12 1.863-1.127 2.58 0l2 3.14 3.14 2c1.12.713 1.128 1.863 0 2.58l-3.14 2-2 3.14c-.712 1.12-1.862 1.128-2.58 0l-1.999-3.14"/></g><path fill="#e1dcf1" d="M128.073 11.066l-1.99 3.126c-.718 1.129-1.88 1.131-2.6 0l-1.99-3.126-3.126-1.989c-1.128-.718-1.13-1.88 0-2.6l3.127-1.99 1.989-3.126c.718-1.128 1.88-1.13 2.6 0l1.99 3.126 3.126 1.99c1.128.718 1.13 1.88 0 2.6l-3.126 1.99"/><path fill="#d2caea" d="M378.07 243.068l-1.989 3.126c-.718 1.129-1.88 1.131-2.6 0l-1.99-3.126-3.126-1.989c-1.128-.718-1.13-1.88 0-2.6l3.127-1.99 1.989-3.126c.718-1.128 1.88-1.13 2.6 0l1.99 3.126 3.126 1.99c1.128.718 1.13 1.88 0 2.6l-3.126 1.99"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/monitoring/loading.svg b/app/assets/images/illustrations/monitoring/loading.svg
index 6bbd7a6c5b9..1e196fc8ad1 100644
--- a/app/assets/images/illustrations/monitoring/loading.svg
+++ b/app/assets/images/illustrations/monitoring/loading.svg
@@ -1 +1 @@
-<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
+<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="d" width="151" height="32" x="20" rx="10"/><rect id="a" width="191" height="62" y="10" rx="10"/><circle id="b" cx="23" cy="41" r="9"/><circle id="k" cx="36.5" cy="36.5" r="36.5"/><circle id="e" cx="262.5" cy="169.5" r="15.5"/><circle id="g" cx="79.5" cy="169.5" r="15.5"/><circle id="j" cx="45" cy="41" r="9"/><circle id="f" cx="30.5" cy="30.5" r="30.5"/><circle id="h" cx="18" cy="34" r="3"/><ellipse id="i" cx="43.5" cy="43.5" rx="43.5" ry="43.5"/><mask id="t" width="191" height="62" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><mask id="u" width="18" height="18" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><mask id="r" width="161" height="100" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask><mask id="s" width="151" height="32" x="0" y="0" fill="#fff"><use xlink:href="#d"/></mask><mask id="p" width="31" height="31" x="0" y="0" fill="#fff"><use xlink:href="#e"/></mask><mask id="l" width="61" height="61" x="0" y="0" fill="#fff"><use xlink:href="#f"/></mask><mask id="q" width="31" height="31" x="0" y="0" fill="#fff"><use xlink:href="#g"/></mask><mask id="m" width="6" height="6" x="0" y="0" fill="#fff"><use xlink:href="#h"/></mask><mask id="o" width="87" height="87" x="0" y="0" fill="#fff"><use xlink:href="#i"/></mask><mask id="v" width="18" height="18" x="0" y="0" fill="#fff"><use xlink:href="#j"/></mask><mask id="n" width="73" height="73" x="0" y="0" fill="#fff"><use xlink:href="#k"/></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(#l)" xlink:href="#f"/><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(#m)" xlink:href="#h"/><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(#n)" xlink:href="#k"/><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.993H111c-5.177 0-9.436-3.927-9.954-8.96a9.96 9.96 0 0 0 4.705 1.883 6.008 6.008 0 0 0 5.248 3.077h125.99a6 6 0 0 0 5.526-3.637 10.027 10.027 0 0 0 4.48-3.359m1.947-8.962a10.001 10.001 0 0 1-9.95 8.958h-131.99a10 10 0 0 1-9.851-8.25 9.942 9.942 0 0 0 4.649 1.248 6 6 0 0 0 5.202 3h131.99a6.002 6.002 0 0 0 5.245-3.076 9.943 9.943 0 0 0 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(#o)" xlink:href="#i"/><path stroke-width="4" d="M18.595 49C21.11 60.44 31.305 69 43.5 69 57.58 69 69 57.583 69 43.5c0-12.195-8.56-22.391-20-24.905v15.959c3 1.848 5 5.164 5 8.946C54 49.299 49.299 54 43.5 54c-3.782 0-7.098-2-8.946-5H18.595" stroke-linejoin="round"/></g><path stroke="#d2caea" stroke-width="4" d="M18 44a27.69 27.69 0 0 1-.005-.5c0-14.08 11.417-25.5 25.5-25.5.167 0 .334.002.5.005v15.01a10.365 10.365 0 0 0-.5-.012c-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(#p)" xlink:href="#e"/><use mask="url(#q)" xlink:href="#g"/><use mask="url(#r)" 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 fill="#fff" stroke="#eee" stroke-width="8" transform="translate(76 128)"><use mask="url(#s)" xlink:href="#d"/><use mask="url(#t)" xlink:href="#a"/></g><g fill="#d2caea" transform="translate(76 128)"><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" transform="translate(76 128)"><use stroke="#fee8dc" mask="url(#u)" xlink:href="#b"/><use stroke="#fb722e" mask="url(#v)" xlink:href="#j"/></g><g fill="#fb722e"><path d="M3.597 219.858l-2.455-1.562c-.929-.59-.924-1.553 0-2.142l2.455-1.562 1.562-2.455c.59-.929 1.553-.924 2.142 0l1.562 2.455 2.454 1.562c.93.591.925 1.553 0 2.142l-2.454 1.562-1.562 2.455c-.591.929-1.553.924-2.142 0l-1.562-2.455M253.597 8.859l-2.454-1.562c-.93-.592-.925-1.554 0-2.143l2.454-1.562 1.562-2.454c.591-.93 1.554-.925 2.143 0l1.562 2.454 2.454 1.562c.93.591.924 1.554 0 2.143l-2.454 1.562-1.562 2.454c-.592.93-1.554.924-2.143 0l-1.562-2.454" opacity=".2"/></g><path fill="#fee8dc" d="M309.49 149.07l-3.141-1.999c-1.12-.712-1.128-1.863 0-2.58l3.14-2 2-3.14c.712-1.12 1.863-1.128 2.58 0l2 3.14 3.14 2c1.12.712 1.127 1.863 0 2.58l-3.14 2-2 3.14c-.713 1.12-1.863 1.128-2.58 0l-2-3.14"/><path fill="#6b4fbb" d="M47.068 79.067l-1.99 3.126c-.718 1.129-1.88 1.13-2.6 0l-1.99-3.126-3.125-1.99c-1.129-.718-1.131-1.88 0-2.6l3.126-1.989 1.989-3.126c.718-1.129 1.88-1.13 2.6 0l1.99 3.126 3.126 1.99c1.128.718 1.13 1.88 0 2.6l-3.126 1.989" opacity=".2"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/monitoring/unable_to_connect.svg b/app/assets/images/illustrations/monitoring/unable_to_connect.svg
index 62537d87d5d..314c052f931 100644
--- a/app/assets/images/illustrations/monitoring/unable_to_connect.svg
+++ b/app/assets/images/illustrations/monitoring/unable_to_connect.svg
@@ -1 +1 @@
-<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
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><use id="g" xlink:href="#a"/><use id="f" xlink:href="#a"/><use id="h" xlink:href="#a"/><path id="e" d="M74 93h26v47H74z"/><path id="c" d="M74 93h26v47H74z"/><rect id="b" width="65" height="14" x="55" y="135" rx="4"/><rect id="d" width="175" height="118" rx="10"/><rect id="a" width="159" rx="10" height="56"/><rect id="i" width="160" y="2" rx="10" height="56" fill="#f9f9f9"/><mask id="q" width="65" height="14" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><mask id="p" width="26" height="47" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask><mask id="r" width="175" height="118" x="0" y="0" fill="#fff"><use xlink:href="#d"/></mask><mask id="o" width="26" height="47" x="0" y="0" fill="#fff"><use xlink:href="#e"/></mask><mask id="k" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#f"/></mask><mask id="j" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#g"/></mask><mask id="l" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#h"/></mask></defs><g fill="none" fill-rule="evenodd"><g transform="translate(245 65)"><use xlink:href="#i"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#j)" xlink:href="#g"/><g fill-rule="nonzero"><path fill="#fb722e" d="M134 31a2 2 0 1 0 .001-3.999A2 2 0 0 0 134 31m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/><path fill="#fee8dc" d="M117 31a2 2 0 1 0 .001-3.999A2 2 0 0 0 117 31m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12m-17-4a2 2 0 1 0 .001-3.999A2 2 0 0 0 100 31m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/></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="#i"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#k)" xlink:href="#f"/><g fill-rule="nonzero"><path fill="#fee8dc" d="M134 30a2 2 0 1 0 .001-3.999A2 2 0 0 0 134 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/><path fill="#fb722e" d="M117 30a2 2 0 1 0 .001-3.999A2 2 0 0 0 117 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/><path fill="#fee8dc" d="M100 30a2 2 0 1 0 .001-3.999A2 2 0 0 0 100 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/></g><rect width="50" height="4" x="19" y="19" fill="#d2caea" rx="2" id="m"/><rect width="50" height="4" x="19" y="33" fill="#d2caea" rx="2" id="n"/></g><g transform="translate(0 118)"><use xlink:href="#i"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#l)" xlink:href="#h"/><g fill-rule="nonzero"><path fill="#fb722e" d="M134 30a2 2 0 1 0 .001-3.999A2 2 0 0 0 134 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/><path fill="#fee8dc" d="M117 30a2 2 0 1 0 .001-3.999A2 2 0 0 0 117 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12m-17-4a2 2 0 1 0 .001-3.999A2 2 0 0 0 100 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/></g><use xlink:href="#m"/><use xlink:href="#n"/></g></g><g fill="#eee" transform="translate(164 120)"><rect width="29" height="4" y="29" rx="2"/><rect width="28" height="4" x="55" y="29" rx="2"/></g><g transform="translate(180 120)"><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.481a1.492 1.492 0 0 0-.006-2.115 1.491 1.491 0 0 0-2.115-.006L30.5 28.379l-2.481-2.481a1.492 1.492 0 0 0-2.115.006 1.491 1.491 0 0 0-.006 2.115l2.481 2.481-2.481 2.481a1.492 1.492 0 0 0 .006 2.115c.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.115L32.621 30.5"/></g><g transform="translate(1 78)"><rect width="65" height="14" x="55" y="137" fill="#f9f9f9" rx="4"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#o)" xlink:href="#e"/><rect width="175" height="118" y="3" fill="#f9f9f9" rx="10"/><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#p)" xlink:href="#c"/><use mask="url(#q)" xlink:href="#b"/><use mask="url(#r)" xlink:href="#d"/></g><g fill-rule="nonzero"><path fill="#eee" d="M163 105V12H11v93h152M7 11.99A3.998 3.998 0 0 1 10.995 8h152.01A3.999 3.999 0 0 1 167 11.99v93.02a3.998 3.998 0 0 1-3.995 3.99H10.995A3.999 3.999 0 0 1 7 105.01V11.99"/><path fill="#d2caea" d="M86 92c-11.598 0-21-9.402-21-21s9.402-21 21-21 21 9.402 21 21-9.402 21-21 21m0-4c9.389 0 17-7.611 17-17s-7.611-17-17-17-17 7.611-17 17 7.611 17 17 17"/></g><path fill="#6b4fbb" d="M83 63a3.001 3.001 0 0 1 6 0v7.993a3.001 3.001 0 0 1-6 0V63m3 18.997a3 3 0 1 1 0-6 3 3 0 0 1 0 6"/><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/assets/images/illustrations/multi-editor_all_changes_committed_empty.svg b/app/assets/images/illustrations/multi-editor_all_changes_committed_empty.svg
new file mode 100644
index 00000000000..06d73941c33
--- /dev/null
+++ b/app/assets/images/illustrations/multi-editor_all_changes_committed_empty.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80"><g fill="none" fill-rule="evenodd"><path fill="#EEE" d="M44.242 59.348c-3.7 1.576-7.3 1.994-10.902.84a7.002 7.002 0 0 1-9.085-.699l-4.243-4.243a7 7 0 0 1-.238-9.649c-.701-3.024-.419-6.083.646-9.206l-6.287-2.426a5.6 5.6 0 0 1-2.274-8.824l8.233-9.811a5.6 5.6 0 0 1 6.306-1.625l8.045 3.105c.772-.797 1.564-1.6 2.374-2.41C44.841 6.376 55.265 2.135 68.09 1.677a10 10 0 0 1 1.119.023c5.507.42 9.63 5.226 9.209 10.733-.935 12.225-5.373 22.309-13.315 30.25a410.76 410.76 0 0 1-1.661 1.653l3.247 8.412a5.6 5.6 0 0 1-1.625 6.306l-9.81 8.233a5.6 5.6 0 0 1-8.825-2.274l-2.186-5.665zm-22.92-26.923l10.406-12.402-6.822-2.633a1.6 1.6 0 0 0-1.801.464l-8.233 9.811a1.6 1.6 0 0 0 .65 2.521l5.8 2.239zm26.646 25.4l2.239 5.8a1.6 1.6 0 0 0 2.521.649l9.81-8.232a1.6 1.6 0 0 0 .465-1.802l-2.633-6.822-12.402 10.406zm-19.69-5.627c8.751 8.752 16.065 5.587 33.995-12.343 7.25-7.25 11.292-16.433 12.155-27.727a6 6 0 0 0-6.196-6.454c-11.846.423-21.303 4.271-28.586 11.554-17.03 17.03-20.414 25.924-11.368 34.97z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M52.54 28.376a4 4 0 1 0 5.656-5.657 4 4 0 0 0-5.657 5.657zm-2.83 2.829A8 8 0 1 1 61.025 19.89a8 8 0 0 1-11.313 11.314z"/><path fill="#FEE1D3" d="M15.063 54.54a2 2 0 0 1 0 2.828L3.749 68.68A2 2 0 1 1 .92 65.853l11.314-11.314a2 2 0 0 1 2.829 0zm9.899 9.899a2 2 0 0 1 0 2.828l-8.485 8.485a2 2 0 1 1-2.829-2.828l8.486-8.485a2 2 0 0 1 2.828 0z"/><path fill="#FDC4A8" d="M20.012 59.489a2 2 0 0 1 0 2.828L4.456 77.874a2 2 0 0 1-2.829-2.829L17.184 59.49a2 2 0 0 1 2.828 0z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/multi-editor_no_changes_empty.svg b/app/assets/images/illustrations/multi-editor_no_changes_empty.svg
new file mode 100644
index 00000000000..ebeea1f3dd9
--- /dev/null
+++ b/app/assets/images/illustrations/multi-editor_no_changes_empty.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80"><g fill="none" fill-rule="evenodd" transform="translate(7 3)"><path fill="#EEE" fill-rule="nonzero" d="M54 18a2 2 0 1 1 0-4h4c.843 0 1.675.105 2.48.31a2 2 0 1 1-.99 3.876A6.015 6.015 0 0 0 58 18h-4zm9.735 4.228a2 2 0 0 1 3.822-1.18A10 10 0 0 1 68 24v3.513a2 2 0 1 1-4 0V24c0-.61-.09-1.204-.265-1.772zM64 35.513a2 2 0 1 1 4 0v6a2 2 0 1 1-4 0v-6zm0 14a2 2 0 1 1 4 0v6a2 2 0 1 1-4 0v-6zm0 14a2 2 0 1 1 4 0V66a9.97 9.97 0 0 1-.963 4.286 2 2 0 1 1-3.613-1.716A5.969 5.969 0 0 0 64 66v-2.487zm-5.255 8.441a2 2 0 1 1 .49 3.97c-.401.05-.806.075-1.218.076h-5.042a2 2 0 1 1 0-4h5.038c.246 0 .49-.016.732-.046zM44.975 72a2 2 0 1 1 0 4h-6a2 2 0 1 1 0-4h6zm-14 0a2 2 0 1 1 0 4H26c-.429 0-.855-.027-1.276-.08a2 2 0 0 1 .506-3.969c.254.033.51.049.77.049h4.975zm-10.438-3.514a2 2 0 1 1-3.64 1.66A9.97 9.97 0 0 1 16 66v-2.538a2 2 0 1 1 4 0V66c0 .871.185 1.713.537 2.486zM8 2a6 6 0 0 0-6 6v42a6 6 0 0 0 6 6h32a6 6 0 0 0 6-6V8a6 6 0 0 0-6-6H8zm0-4h32c5.523 0 10 4.477 10 10v42c0 5.523-4.477 10-10 10H8C2.477 60-2 55.523-2 50V8C-2 2.477 2.477-2 8-2z"/><rect width="10" height="4" x="8" y="16" fill="#EFEDF8" rx="2"/><rect width="10" height="4" x="21" y="16" fill="#6B4FBB" rx="2"/><rect width="10" height="4" x="8" y="32" fill="#E1DBF1" rx="2"/><rect width="6" height="4" x="34" y="16" fill="#EFEDF8" rx="2"/><rect width="6" height="4" x="8" y="24" fill="#6B4FBB" rx="2"/><rect width="6" height="4" x="17" y="24" fill="#EFEDF8" rx="2"/><rect width="6" height="4" x="21" y="32" fill="#6B4FBB" rx="2"/><rect width="6" height="4" x="8" y="40" fill="#6B4FBB" rx="2"/><rect width="6" height="4" x="17" y="40" fill="#EFEDF8" rx="2"/><rect width="6" height="4" x="26" y="40" fill="#C3B8E3" rx="2"/><rect width="10" height="4" x="26" y="24" fill="#C3B8E3" rx="2"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/multi-editor_no_staged_files_empty.svg b/app/assets/images/illustrations/multi-editor_no_staged_files_empty.svg
new file mode 100644
index 00000000000..08321ef526b
--- /dev/null
+++ b/app/assets/images/illustrations/multi-editor_no_staged_files_empty.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80"><g fill="none" fill-rule="evenodd" transform="translate(0 3)"><path fill="#EEE" fill-rule="nonzero" d="M40.843 5.864a2 2 0 1 1 .348-3.985l5.977.523a2 2 0 1 1-.348 3.985l-5.977-.523zm13.946 1.22a2 2 0 1 1 .349-3.985l5.977.523a2 2 0 1 1-.348 3.985l-5.978-.523zm13.947 1.22a2 2 0 1 1 .349-3.984 11.952 11.952 0 0 1 6.655 2.75 2 2 0 1 1-2.569 3.066 7.953 7.953 0 0 0-4.435-1.832zm7.28 7.357a2 2 0 1 1 3.99-.301c.048.639.045 1.283-.01 1.934l-.385 4.4a2 2 0 1 1-3.985-.349l.384-4.395c.037-.433.039-.863.007-1.29zm-1.088 13.654a2 2 0 0 1 3.985.348l-.523 5.978a2 2 0 1 1-3.984-.349l.522-5.977zm-1.22 13.947a2 2 0 1 1 3.985.348l-.523 5.977a2 2 0 1 1-3.985-.348l.523-5.977zM72.305 56.7a2 2 0 0 1 3.79 1.282 11.995 11.995 0 0 1-4.253 5.81 2 2 0 0 1-2.373-3.22 7.996 7.996 0 0 0 2.836-3.872zm-9.054 5.33a2 2 0 1 1-.349 3.985l-5.977-.522a2 2 0 1 1 .349-3.985l5.977.523zM32.793 10.675a2 2 0 1 1-3.675-1.579 12.02 12.02 0 0 1 4.696-5.456 2 2 0 0 1 2.112 3.397 8.02 8.02 0 0 0-3.133 3.638z"/><rect width="48" height="58" x="2" y="14" fill="#FAFAFA" rx="10"/><path fill="#EEE" fill-rule="nonzero" d="M12 16a8 8 0 0 0-8 8v38a8 8 0 0 0 8 8h28a8 8 0 0 0 8-8V24a8 8 0 0 0-8-8H12zm0-4h28c6.627 0 12 5.373 12 12v38c0 6.627-5.373 12-12 12H12C5.373 74 0 68.627 0 62V24c0-6.627 5.373-12 12-12z"/><rect width="24" height="4" x="11" y="30" fill="#E5E5E5" rx="2"/><rect width="30" height="4" x="11" y="41" fill="#E5E5E5" rx="2"/><rect width="20" height="4" x="11" y="52" fill="#E5E5E5" rx="2"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/multi_file_editor_empty.svg b/app/assets/images/illustrations/multi_file_editor_empty.svg
new file mode 100644
index 00000000000..bd376f0a050
--- /dev/null
+++ b/app/assets/images/illustrations/multi_file_editor_empty.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="430" height="300"><g fill="none" fill-rule="evenodd" transform="translate(35 29)"><path fill="#EEE" fill-rule="nonzero" d="M90 23a2 2 0 1 1 0-4h10a2 2 0 0 1 0 4H90zm20 0a2 2 0 0 1 0-4h10a2 2 0 0 1 0 4h-10zm20 0a2 2 0 0 1 0-4h10a2 2 0 0 1 0 4h-10zm20 0a2 2 0 0 1 0-4h10a2 2 0 0 1 0 4h-10zm20 0a2 2 0 0 1 0-4h10a2 2 0 0 1 0 4h-10zm20 0a2 2 0 0 1 0-4h10a2 2 0 0 1 0 4h-10zm20 0a2 2 0 0 1 0-4h10a2 2 0 0 1 0 4h-10zm20 0a2 2 0 0 1 0-4h10a2 2 0 0 1 0 4h-10zm20 0a2 2 0 0 1 0-4h10a2 2 0 0 1 0 4h-10zm20 0a2 2 0 0 1 0-4h10a2 2 0 0 1 0 4h-10zm20 0a2 2 0 0 1 0-4h10a2 2 0 0 1 0 4h-10zm20 0a2 2 0 0 1 0-4h10a2 2 0 0 1 0 4h-10zm20 0a2 2 0 0 1 0-4h1a11.98 11.98 0 0 1 9.457 4.612 2 2 0 0 1-3.151 2.464A7.981 7.981 0 0 0 331 23h-1zm9 11.39a2 2 0 0 1 4 0v10a2 2 0 0 1-4 0v-10zm0 180a2 2 0 1 1 4 0V223c0 .56-.038 1.114-.114 1.662a2 2 0 0 1-3.962-.55A8.21 8.21 0 0 0 339 223v-8.61zm-4.769 15.931a2 2 0 0 1 1.618 3.658A11.967 11.967 0 0 1 331 235h-5.782a2 2 0 0 1 0-4H331c1.13 0 2.224-.233 3.231-.679zm-19.013.679a2 2 0 1 1 0 4h-10a2 2 0 0 1 0-4h10zm-20 0a2 2 0 1 1 0 4h-10a2 2 0 0 1 0-4h10zm-20 0a2 2 0 1 1 0 4h-10a2 2 0 0 1 0-4h10zm-20 0a2 2 0 1 1 0 4h-10a2 2 0 0 1 0-4h10zm-20 0a2 2 0 1 1 0 4h-10a2 2 0 0 1 0-4h10zm-20 0a2 2 0 1 1 0 4h-10a2 2 0 0 1 0-4h10zm-20 0a2 2 0 1 1 0 4h-10a2 2 0 0 1 0-4h10zm-20 0a2 2 0 1 1 0 4h-10a2 2 0 0 1 0-4h10zm-20 0a2 2 0 1 1 0 4h-10a2 2 0 0 1 0-4h10zm-20 0a2 2 0 1 1 0 4h-10a2 2 0 0 1 0-4h10zM115 231a2 2 0 0 1 0 4h-10a2 2 0 0 1 0-4h10zm-26.2 4c.131-.646.2-1.315.2-2v-2h4a2 2 0 0 1 0 4h-4.2z"/><path fill="#EEE" fill-rule="nonzero" d="M103 211h258a6 6 0 0 0 6-6V63a6 6 0 0 0-6-6H166a5 5 0 0 1-5-5v-8.5a5.5 5.5 0 0 0-5.5-5.5H109a6 6 0 0 0-6 6v167zm62-167.5V52a1 1 0 0 0 1 1h195c5.523 0 10 4.477 10 10v142c0 5.523-4.477 10-10 10H99V44c0-5.523 4.477-10 10-10h46.5a9.5 9.5 0 0 1 9.5 9.5z"/><rect width="40" height="4" x="118" y="78" fill="#6B4FBB" rx="2"/><rect width="30" height="4" x="118" y="90" fill="#EFEDF8" rx="2"/><rect width="30" height="4" x="153" y="90" fill="#E1DBF1" rx="2"/><rect width="150" height="4" x="118" y="102" fill="#EFEDF8" rx="2"/><rect width="90" height="4" x="118" y="114" fill="#E1DBF1" rx="2"/><rect width="60" height="4" x="118" y="138" fill="#EFEDF8" rx="2"/><rect width="20" height="4" x="118" y="150" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="144" y="150" fill="#C3B8E3" rx="2"/><rect width="20" height="4" x="170" y="150" fill="#E1DBF1" rx="2"/><rect width="130" height="4" x="118" y="162" fill="#EFEDF8" rx="2"/><rect width="30" height="4" x="118" y="174" fill="#C3B8E3" rx="2"/><rect width="30" height="4" x="154" y="174" fill="#EFEDF8" rx="2"/><rect width="30" height="4" x="190" y="174" fill="#EFEDF8" rx="2"/><rect width="40" height="4" x="118" y="186" fill="#E1DBF1" rx="2"/><path fill="#F9F9F9" d="M89 24.292l11.434 19.326v170.326L89 226.336V24.292z"/><path fill="#EEE" fill-rule="nonzero" d="M89 229.286v-5.9l9.434-10.223V44.165L89 28.22v-7.856l13.434 22.707v171.655L89 229.286zM10 4a6 6 0 0 0-6 6v223a6 6 0 0 0 6 6h69a6 6 0 0 0 6-6V10a6 6 0 0 0-6-6H10zm0-4h69c5.523 0 10 4.477 10 10v223c0 5.523-4.477 10-10 10H10c-5.523 0-10-4.477-10-10V10C0 4.477 4.477 0 10 0z"/><circle cx="25" cy="23" r="11" fill="#FEF0E8"/><path fill="#FEE1D3" d="M46 17h16a2 2 0 1 1 0 4H46a2 2 0 1 1 0-4zm0 8h27a2 2 0 1 1 0 4H46a2 2 0 1 1 0-4z"/><path fill="#EEE" d="M16 50h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm14 2h24a2 2 0 1 1 0 4H30a2 2 0 1 1 0-4zm-4 12h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm14 2h24a2 2 0 1 1 0 4H40a2 2 0 1 1 0-4zM26 78h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm14 2h24a2 2 0 1 1 0 4H40a2 2 0 1 1 0-4z"/><g transform="translate(14 110)"><rect width="8" height="8" fill="#FEE1D3" rx="2"/><rect width="28" height="4" x="14" y="2" fill="#FEF0E8" rx="2"/></g><path fill="#EEE" d="M16 140h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm14 2h24a2 2 0 1 1 0 4H30a2 2 0 1 1 0-4zm-14 14h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm14 2h24a2 2 0 1 1 0 4H30a2 2 0 1 1 0-4zm-14 14h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm14 2h24a2 2 0 1 1 0 4H30a2 2 0 1 1 0-4zm-14 14h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm14 2h24a2 2 0 1 1 0 4H30a2 2 0 1 1 0-4z"/><g transform="translate(24 124)"><rect width="8" height="8" fill="#FEE1D3" rx="2"/><rect width="28" height="4" x="14" y="2" fill="#FEF0E8" rx="2"/></g><g fill="#FC6D26" transform="translate(24 92)"><rect width="8" height="8" rx="2"/><rect width="28" height="4" x="14" y="2" rx="2"/></g><path fill="#FDC4A8" fill-rule="nonzero" d="M152 50.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm0-3a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/no_commits.svg b/app/assets/images/illustrations/no_commits.svg
new file mode 100644
index 00000000000..76fa25156dd
--- /dev/null
+++ b/app/assets/images/illustrations/no_commits.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168 107" xmlns:xlink="http://www.w3.org/1999/xlink"><g fill="#eee" fill-rule="evenodd"><path d="M4.01 2h1.102a1 1 0 0 0 0-2H4.01A4.001 4.001 0 0 0 0 4a1 1 0 0 0 2 0c0-1.108.892-2 2.01-2m12.702 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7M164 2c.822 0 1.554.503 1.86 1.254a1 1 0 1 0 1.853-.753 4.01 4.01 0 0 0-3.712-2.5h-2.188a1 1 0 0 0 0 2h2.188m2.01 12.518a1 1 0 0 0 2 0v-5.7a1 1 0 0 0-2 0v5.7m0 11.6a1 1 0 0 0 2 0v-5.7a1 1 0 0 0-2 0v5.7m0 11.6a1 1 0 0 0 2 0v-5.7a1 1 0 0 0-2 0v5.7m0 6.282c0 1.108-.892 2-2.01 2h-.72a1 1 0 0 0 0 2h.72a4.001 4.001 0 0 0 4.01-4v-.382a1 1 0 0 0-2 0v.382m-14.325 2a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-8.47 0a2.01 2.01 0 0 1-1.782-1.085 1 1 0 0 0-1.775.923 4.007 4.007 0 0 0 3.556 2.162h2.57a1 1 0 0 0 0-2h-2.57m-2.01-12.136a1 1 0 0 0-2 0v5.7a1 1 0 0 0 2 0v-5.7m0-11.6a1 1 0 0 0-2 0v5.7a1 1 0 0 0 2 0v-5.7m0-11.6a1 1 0 0 0-2 0v5.7a1 1 0 0 0 2 0v-5.7m0-6.664a1 1 0 0 0-2 0v.764a1 1 0 0 0 2 0v-.764" id="a"/><circle cx="21" cy="24" r="10"/><rect width="33" height="3" x="37" y="18" rx="1.5" id="b"/><rect width="53" height="3" x="37" y="27" rx="1.5" id="c"/><path d="M131 29c0 .552.447.999.996.999h22.01c.545 0 .996-.451.996-.999v-9a.998.998 0 0 0-.996-.999h-22.01c-.545 0-.996.451-.996.999v9m.996-12h22.01a2.998 2.998 0 0 1 2.996 2.999v9a3.003 3.003 0 0 1-2.996 2.999h-22.01A2.998 2.998 0 0 1 129 28.999v-9A3.003 3.003 0 0 1 131.996 17" id="d"/><g transform="translate(0 59)"><use xlink:href="#a"/><circle cx="21" cy="24" r="10"/><use xlink:href="#b"/><use xlink:href="#c"/><use xlink:href="#d"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/pending_job_empty.svg b/app/assets/images/illustrations/pending_job_empty.svg
new file mode 100644
index 00000000000..8de695afa18
--- /dev/null
+++ b/app/assets/images/illustrations/pending_job_empty.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="430" height="200" viewBox="0 0 430 200"><g fill="none" fill-rule="evenodd"><g transform="translate(138 65)"><path fill="#E5E5E5" fill-rule="nonzero" d="M35 70a2 2 0 1 1 0-4c2.542 0 5.042-.305 7.463-.904a2 2 0 1 1 .96 3.884A35.075 35.075 0 0 1 35 70zm18.21-5.105a2 2 0 1 1-2.083-3.414 31.143 31.143 0 0 0 5.896-4.664 2 2 0 1 1 2.842 2.815 35.143 35.143 0 0 1-6.654 5.263zM66.106 51.06a2 2 0 0 1-3.552-1.838 30.77 30.77 0 0 0 2.612-7.042 2 2 0 1 1 3.892.922 34.77 34.77 0 0 1-2.952 7.958zm3.816-18.433a2 2 0 1 1-3.991.268 30.873 30.873 0 0 0-1.407-7.38 2 2 0 0 1 3.808-1.223 34.873 34.873 0 0 1 1.59 8.335zm-6.346-17.842a2 2 0 0 1-3.264 2.312 31.188 31.188 0 0 0-5.054-5.564 2 2 0 0 1 2.615-3.027 35.188 35.188 0 0 1 5.703 6.279zM48.895 2.867a2 2 0 0 1-1.59 3.67 30.758 30.758 0 0 0-7.206-2.12 2 2 0 1 1 .653-3.946 34.758 34.758 0 0 1 8.143 2.396zM30.263.318a2 2 0 0 1 .537 3.964c-2.505.339-4.94.98-7.266 1.907a2 2 0 1 1-1.48-3.716A34.774 34.774 0 0 1 30.263.318zM12.907 7.853a2 2 0 0 1 2.527 3.1 31.196 31.196 0 0 0-5.213 5.416 2 2 0 0 1-3.196-2.406 35.196 35.196 0 0 1 5.882-6.11zM1.99 23.343a2 2 0 0 1 3.772 1.331 30.82 30.82 0 0 0-1.619 7.337 2 2 0 1 1-3.982-.38 34.82 34.82 0 0 1 1.829-8.289zM.719 42.086a2 2 0 1 1 3.917-.806 30.757 30.757 0 0 0 2.4 7.118 2 2 0 1 1-3.605 1.73 34.757 34.757 0 0 1-2.713-8.042zM9.393 58.86a2 2 0 0 1 2.926-2.728 31.167 31.167 0 0 0 5.751 4.841 2 2 0 1 1-2.187 3.349 35.167 35.167 0 0 1-6.49-5.462zm16.245 9.873a2 2 0 1 1 1.067-3.855 30.979 30.979 0 0 0 7.434 1.11 2 2 0 1 1-.11 3.998 34.979 34.979 0 0 1-8.391-1.253z"/><circle cx="35" cy="35" r="16" stroke="#E1DBF1" stroke-width="4"/><path fill="#6B4FBB" d="M37 33h5a2 2 0 1 1 0 4h-7a2 2 0 0 1-2-2v-8a2 2 0 1 1 4 0v6z"/></g><g transform="translate(247 30)"><rect width="116" height="135" y="5" fill="#F9F9F9" rx="10"/><rect width="116" height="134" x="5" fill="#FFF" stroke="#EEE" stroke-width="4" stroke-linecap="round" rx="10"/><g transform="translate(23 23)"><rect width="16" height="4" fill="#E1DBF1" rx="2"/><rect width="16" height="4" x="32" y="12" fill="#E1DBF1" rx="2"/><rect width="16" height="4" x="44" fill="#EEE" rx="2"/><rect width="16" height="4" x="12" y="24" fill="#E1DBF1" rx="2"/><rect width="16" height="4" x="64" y="36" fill="#FEF0E8" rx="2"/><rect width="8" height="4" x="20" fill="#FEE1D3" rx="2"/><rect width="8" height="4" x="32" y="36" fill="#FC6D26" rx="2"/><rect width="8" height="4" x="52" y="12" fill="#FEF0E8" rx="2"/><rect width="8" height="4" x="64" fill="#FEF0E8" rx="2"/><rect width="12" height="4" x="16" y="48" fill="#E1DBF1" rx="2"/><rect width="8" height="4" x="44" y="36" fill="#FC6D26" rx="2"/><rect width="4" height="4" x="56" y="36" fill="#E1DBF1" rx="2"/><rect width="4" height="4" x="64" y="60" fill="#E1DBF1" rx="2"/><rect width="4" height="4" x="72" y="60" fill="#FC6D26" rx="2"/><rect width="8" height="4" x="32" fill="#FC6D26" rx="2"/><rect width="28" height="4" y="36" fill="#EEE" rx="2"/><rect width="28" height="4" x="44" y="48" fill="#EEE" rx="2"/><rect width="28" height="4" x="32" y="60" fill="#EFEDF8" rx="2"/><rect width="28" height="4" y="12" fill="#6B4FBB" rx="2"/><rect width="28" height="4" x="32" y="24" fill="#C3B8E3" rx="2"/><rect width="8" height="4" y="24" fill="#FEF0E8" rx="2"/><rect width="8" height="4" x="32" y="48" fill="#6B4FBB" rx="2"/><rect width="12" height="4" y="48" fill="#FC6D26" rx="2"/><rect width="12" height="4" y="60" fill="#FEF0E8" rx="2"/><rect width="12" height="4" x="16" y="60" fill="#FEF0E8" rx="2"/></g><g transform="translate(23 95)"><rect width="16" height="4" fill="#EFEDF8" rx="2"/><rect width="16" height="4" x="18" y="12" fill="#FC6D26" rx="2"/><rect width="16" height="4" x="44" fill="#6B4FBB" rx="2"/><rect width="8" height="4" x="20" fill="#FEE1D3" rx="2"/><rect width="8" height="4" x="38" y="12" fill="#FEF0E8" rx="2"/><rect width="8" height="4" x="64" fill="#FEF0E8" rx="2"/><rect width="8" height="4" x="32" fill="#FC6D26" rx="2"/><rect width="14" height="4" y="12" fill="#EEE" rx="2"/></g></g><path fill="#FC6D26" fill-rule="nonzero" d="M81 119c-10.493 0-19-8.507-19-19s8.507-19 19-19 19 8.507 19 19-8.507 19-19 19zm0-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 15zm-5-20a2 2 0 0 1 2 2v6a2 2 0 1 1-4 0v-6a2 2 0 0 1 2-2zm10 0a2 2 0 0 1 2 2v6a2 2 0 1 1-4 0v-6a2 2 0 0 1 2-2z"/><path fill="#E5E5E5" fill-rule="nonzero" d="M108 102c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004a1.994 1.994 0 0 1-1.998-2zm14 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004a1.994 1.994 0 0 1-1.998-2zm93 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004a1.994 1.994 0 0 1-1.998-2zm14 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004a1.994 1.994 0 0 1-1.998-2z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/pipelines_pending.svg b/app/assets/images/illustrations/pipelines_pending.svg
new file mode 100644
index 00000000000..25038366e92
--- /dev/null
+++ b/app/assets/images/illustrations/pipelines_pending.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="430" height="220" viewBox="0 0 430 220"><g fill="none" fill-rule="evenodd"><path fill="#EEE" fill-rule="nonzero" d="M189.8 182l2.4-12H114c-5.523 0-10-4.477-10-10V34c0-5.523 4.477-10 10-10h200c5.523 0 10 4.477 10 10v126c0 5.523-4.477 10-10 10h-78.2l2.4 12h22.52a9.651 9.651 0 0 1 9.28 7 5.491 5.491 0 0 1-5.28 7H164.159a5.787 5.787 0 0 1-5.659-7 8.855 8.855 0 0 1 8.659-7H189.8zM114 28a6 6 0 0 0-6 6v126a6 6 0 0 0 6 6h200a6 6 0 0 0 6-6V34a6 6 0 0 0-6-6H114zm5 6h190a5 5 0 0 1 5 5v116a5 5 0 0 1-5 5H119a5 5 0 0 1-5-5V39a5 5 0 0 1 5-5zm0 4a1 1 0 0 0-1 1v116a1 1 0 0 0 1 1h190a1 1 0 0 0 1-1V39a1 1 0 0 0-1-1H119zm112.72 132h-35.44l-2.4 12h40.24l-2.4-12zm-64.561 16c-2.29 0-4.268 1.6-4.748 3.838A1.787 1.787 0 0 0 164.16 192h100.56a1.491 1.491 0 0 0 1.435-1.901A5.651 5.651 0 0 0 260.72 186h-93.561z"/><path fill="#FEF0E8" d="M177.965 99H194a2 2 0 1 1 0 4h-16.322c-1.374 6.29-6.976 11-13.678 11-6.702 0-12.304-4.71-13.678-11h-3.365l-7.395 9.249a2 2 0 0 1-3.049.089L128.11 103h-5.844a2 2 0 1 1 0-4H129a2 2 0 0 1 1.487.662l7.423 8.248 6.523-8.159a2 2 0 0 1 1.562-.751h4.04c.513-7.265 6.57-13 13.965-13 7.396 0 13.452 5.735 13.965 13zM164 110c5.523 0 10-4.477 10-10s-4.477-10-10-10-10 4.477-10 10 4.477 10 10 10z"/><path fill="#EFEDF8" d="M273.847 103c-.962 6.23-6.347 11-12.847 11-6.5 0-11.885-4.77-12.847-11H232a2 2 0 0 1 0-4h16.153c.962-6.23 6.347-11 12.847-11 6.5 0 11.885 4.77 12.847 11h3.998l8.404-9.338a2 2 0 0 1 3.048.09L296.692 99H305a2 2 0 0 1 0 4h-9.27a2 2 0 0 1-1.562-.751l-6.523-8.16-7.423 8.249a2 2 0 0 1-1.487.662h-4.888zM261 110a9 9 0 1 0 0-18 9 9 0 0 0 0 18z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M213 119c-10.493 0-19-8.507-19-19s8.507-19 19-19 19 8.507 19 19-8.507 19-19 19zm0-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 15z"/><path fill="#FC6D26" d="M211.586 101.828L208.757 99a2 2 0 1 0-2.828 2.828l4.243 4.243c.39.39.902.586 1.414.586.512 0 1.023-.195 1.414-.586L220.071 99a2 2 0 1 0-2.828-2.828l-5.657 5.656z"/><path fill="#FDC4A8" d="M162.95 101.07l-1.768-1.767a1.5 1.5 0 0 0-2.121 2.121l2.828 2.829c.293.293.677.439 1.06.439.385 0 .769-.146 1.062-.44l4.242-4.242a1.5 1.5 0 1 0-2.121-2.121l-3.182 3.182z"/><path fill="#6B4FBB" d="M256.39 104.841A6 6 0 1 0 261 95v6l-4.61 3.841z"/><path fill="#FEF0E8" fill-rule="nonzero" d="M99 99h-5a2 2 0 1 0 0 4h5a2 2 0 1 0 0-4zm-16 0h-5a2 2 0 1 0 0 4h5a2 2 0 1 0 0-4zm-14.384-.078l-3.643-3.425a2 2 0 1 0-2.74 2.914l3.643 3.425a2 2 0 1 0 2.74-2.914zm-11.657-10.96l-3.642-3.425a2 2 0 1 0-2.74 2.914l3.642 3.425a2 2 0 0 0 2.74-2.914zm-11.656-10.96l-3.643-3.425a2 2 0 0 0-2.74 2.914l3.643 3.425a2 2 0 1 0 2.74-2.914zm-14.367-3.885l-3.593 3.477a2 2 0 0 0 2.782 2.875l3.593-3.477a2 2 0 0 0-2.782-2.875zM19.44 84.244l-3.593 3.477a2 2 0 1 0 2.781 2.874l3.593-3.477a2 2 0 0 0-2.781-2.874zM7.94 95.371l-3.593 3.477a2 2 0 1 0 2.782 2.874l3.593-3.477a2 2 0 1 0-2.782-2.874z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M423.611 99.56l-3.598 3.472a2 2 0 0 0 2.777 2.879l3.599-3.472a2 2 0 0 0-2.778-2.878zm-11.514 11.11l-3.598 3.472a2 2 0 0 0 2.777 2.878l3.598-3.471a2 2 0 0 0-2.777-2.879zm-11.514 11.11l-3.599 3.471a2 2 0 1 0 2.778 2.879l3.598-3.472a2 2 0 1 0-2.777-2.879zm-8.799 4.48l-3.642-3.426a2 2 0 0 0-2.74 2.915l3.642 3.425a2 2 0 0 0 2.74-2.915zm-11.656-10.96l-3.643-3.426a2 2 0 1 0-2.74 2.914l3.643 3.426a2 2 0 1 0 2.74-2.915zm-11.657-10.96l-3.643-3.426a2 2 0 1 0-2.74 2.914l3.643 3.425a2 2 0 1 0 2.74-2.914zM353.001 99h-5a2 2 0 1 0 0 4h5a2 2 0 0 0 0-4zm-16 0h-5a2 2 0 1 0 0 4h5a2 2 0 0 0 0-4z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/service_desk_callout.svg b/app/assets/images/illustrations/service_desk_callout.svg
new file mode 100644
index 00000000000..2886388279e
--- /dev/null
+++ b/app/assets/images/illustrations/service_desk_callout.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"/><rect width="7" height="1" x="59" y="38" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M60.5 42a3.5 3.5 0 0 0 0-7v7z"/><rect width="7" height="1" x="12" y="38" fill="#E1DBF2" transform="matrix(-1 0 0 1 31 0)" rx=".5"/><path fill="#6B4FBB" d="M17.5 42a3.5 3.5 0 0 1 0-7v7z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M39 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M35 56a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M26.5 40c0 4.143 3.355 7.5 7.494 7.5h10.012A7.497 7.497 0 0 0 51.5 40c0-4.143-3.355-7.5-7.494-7.5H33.994A7.497 7.497 0 0 0 26.5 40zm-3 0c0-5.799 4.698-10.5 10.494-10.5h10.012C49.802 29.5 54.5 34.2 54.5 40c0 5.799-4.698 10.5-10.494 10.5H33.994C28.198 50.5 23.5 45.8 23.5 40z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M35.255 42.406a1 1 0 1 1 1.872-.703 2.001 2.001 0 0 0 3.76-.038 1 1 0 1 1 1.886.665 4 4 0 0 1-7.518.076zM31.5 40a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm15 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/><path fill="#6B4FBB" d="M38 22h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2zm0 3h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z" style="mix-blend-mode:multiply"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/service_desk_empty.svg b/app/assets/images/illustrations/service_desk_empty.svg
new file mode 100644
index 00000000000..daaaeae6a17
--- /dev/null
+++ b/app/assets/images/illustrations/service_desk_empty.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="226" height="178" viewBox="0 0 226 178"><g fill="none" fill-rule="evenodd"><path fill="#EEE" fill-rule="nonzero" d="M109.496 165.895a78.17 78.17 0 0 0 6.158.08 2 2 0 0 0-.11-4c-1.94.053-3.886.028-5.84-.074a2 2 0 0 0-2.1 1.893 1.996 1.996 0 0 0 1.89 2.102zm18.408-1.245a76 76 0 0 0 6-1.4 2 2 0 1 0-1.064-3.856c-1.875.52-3.772.96-5.686 1.327a2.001 2.001 0 0 0 .75 3.93zm17.572-5.636a76.28 76.28 0 0 0 5.486-2.803 2 2 0 1 0-1.962-3.485 72.42 72.42 0 0 1-5.2 2.656 2.003 2.003 0 0 0 1.676 3.635zm44.342-74.897a75.786 75.786 0 0 0-.674-6.127 2.002 2.002 0 0 0-3.956.598c.29 1.92.505 3.857.64 5.805a1.998 1.998 0 0 0 2.133 1.857 2 2 0 0 0 1.858-2.133zm-3.505-18.144a76.141 76.141 0 0 0-2.13-5.78 2.001 2.001 0 0 0-3.695 1.534 72.381 72.381 0 0 1 2.02 5.476 1.999 1.999 0 1 0 3.805-1.229zm-7.754-16.73a77.053 77.053 0 0 0-3.454-5.1 1.998 1.998 0 0 0-2.797-.423 1.998 1.998 0 0 0-.424 2.796 73.06 73.06 0 0 1 3.273 4.835c.58.94 1.814 1.23 2.753.647a2.001 2.001 0 0 0 .646-2.754zm-11.582-14.446a76.37 76.37 0 0 0-4.572-4.128 1.999 1.999 0 1 0-2.559 3.073 72.633 72.633 0 0 1 4.334 3.913 2.001 2.001 0 1 0 2.798-2.86zm-101.422-4.91a77.634 77.634 0 0 0-4.64 4.05 2.001 2.001 0 0 0 2.749 2.906 72.611 72.611 0 0 1 4.4-3.84 2 2 0 1 0-2.509-3.115zM52.7 43.062a75.962 75.962 0 0 0-3.546 5.04 2 2 0 1 0 3.363 2.168 72.314 72.314 0 0 1 3.36-4.777 2 2 0 0 0-3.177-2.432zm-9.373 15.924c-.82 1.882-1.56 3.8-2.226 5.745a2 2 0 1 0 3.787 1.294 72.253 72.253 0 0 1 2.108-5.443 1.998 1.998 0 0 0-1.036-2.63 2.001 2.001 0 0 0-2.633 1.036zm-5.26 17.74a76.33 76.33 0 0 0-.777 6.11 2 2 0 0 0 3.985.347c.17-1.947.415-3.88.737-5.793a2 2 0 0 0-3.945-.664zM74.87 155.55a76.028 76.028 0 0 0 5.437 2.897 2 2 0 1 0 1.737-3.603 71.34 71.34 0 0 1-5.152-2.745 1.998 1.998 0 0 0-2.737.714 2.002 2.002 0 0 0 .715 2.738zm16.97 7.34a76.606 76.606 0 0 0 5.975 1.498 2 2 0 1 0 .816-3.916 72.52 72.52 0 0 1-5.662-1.42 1.999 1.999 0 1 0-1.129 3.837z"/><path fill="#F9F9F9" d="M2.12 130c-.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-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M39 166c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 92 39 92 4 107.67 4 127s15.67 35 35 35z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M53.925 116.226A1.995 1.995 0 0 0 53 116H25a1.99 1.99 0 0 0-.898.212l14.663 13.406c.39.357.99.348 1.37-.02l13.79-13.372zm1.075 4.53L42.92 132.47a5 5 0 0 1-6.854.1L23 120.624V138a2 2 0 0 0 2 2h28a2 2 0 0 0 2-2v-17.244zM25 112h28a6 6 0 0 1 6 6v20a6 6 0 0 1-6 6H25a6 6 0 0 1-6-6v-20a6 6 0 0 1 6-6z"/><path fill="#F9F9F9" d="M150.12 131c-.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-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M187 167c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35s-15.67-35-35-35-35 15.67-35 35 15.67 35 35 35z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M180.51 137H199a2 2 0 0 0 2-2v-16a2 2 0 0 0-2-2h-24a2 2 0 0 0-2 2v22.743l7.51-4.743zm1.157 4l-9.6 6.062a2 2 0 0 1-3.067-1.69V119a6 6 0 0 1 6-6h24a6 6 0 0 1 6 6v16a6 6 0 0 1-6 6h-17.333z"/><path fill="#6B4FBB" d="M180 129a2 2 0 1 1-.001-3.999A2 2 0 0 1 180 129zm7 0a2 2 0 1 1-.001-3.999A2 2 0 0 1 187 129zm7 0a2 2 0 1 1-.001-3.999A2 2 0 0 1 194 129z"/><g><path fill="#F9F9F9" d="M76.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-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M113 78c-21.54 0-39-17.46-39-39S91.46 0 113 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S132.33 4 113 4 78 19.67 78 39s15.67 35 35 35z"/><g transform="translate(133 35)"><rect width="7" height="1" y="3" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M1.5 7a3.5 3.5 0 1 0 0-7v7z"/></g><g transform="matrix(-1 0 0 1 93 35)"><rect width="7" height="1" y="3" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M1.5 7a3.5 3.5 0 1 0 0-7v7z"/></g><path fill="#E1DBF1" fill-rule="nonzero" d="M113 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M109 56a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M97.5 40c0-5.8 4.698-10.5 10.494-10.5h10.012c5.796 0 10.494 4.7 10.494 10.5s-4.698 10.5-10.494 10.5h-10.012C102.198 50.5 97.5 45.8 97.5 40zm3 0c0 4.143 3.355 7.5 7.494 7.5h10.012A7.496 7.496 0 0 0 125.5 40c0-4.143-3.355-7.5-7.494-7.5h-10.012A7.496 7.496 0 0 0 100.5 40z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M109.255 42.406a.998.998 0 0 1 .584-1.287.997.997 0 0 1 1.287.583 2 2 0 0 0 3.76-.038 1 1 0 0 1 1.886.665 4.001 4.001 0 0 1-7.518.076zM105.5 40a1.5 1.5 0 1 1 .001-3.001A1.5 1.5 0 0 1 105.5 40zm15 0a1.5 1.5 0 1 1 .001-3.001A1.5 1.5 0 0 1 120.5 40z"/><path fill="#6B4FBB" d="M112 22h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2zm0 3h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z" style="mix-blend-mode:multiply"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/slack_logo.svg b/app/assets/images/illustrations/slack_logo.svg
new file mode 100644
index 00000000000..b8d7906c2e1
--- /dev/null
+++ b/app/assets/images/illustrations/slack_logo.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" viewBox="0 0 121.94154 121.84154" width="121.942" height="121.842"><style id="style200">.st0{fill:#ecb32d}.st1{fill:#63c1a0}.st2{fill:#e01a59}.st3{fill:#331433}.st4{fill:#d62027}.st5{fill:#89d3df}.st6{fill:#258b74}.st7{fill:#819c3c}</style><path class="st0" d="M79.03 7.511c-1.9-5.7-8-8.8-13.7-7-5.7 1.9-8.8 8-7 13.7l28.1 86.4c1.9 5.3 7.7 8.3 13.2 6.7 5.8-1.7 9.3-7.8 7.4-13.4 0-.2-28-86.4-28-86.4z" id="path202" fill="#ecb32d"/><path class="st1" d="M35.53 21.611c-1.9-5.7-8-8.8-13.7-7-5.7 1.9-8.8 8-7 13.7l28.1 86.4c1.9 5.3 7.7 8.3 13.2 6.7 5.8-1.7 9.3-7.8 7.4-13.4 0-.2-28-86.4-28-86.4z" id="path204" fill="#63c1a0"/><path class="st2" d="M114.43 79.011c5.7-1.9 8.8-8 7-13.7-1.9-5.7-8-8.8-13.7-7l-86.5 28.2c-5.3 1.9-8.3 7.7-6.7 13.2 1.7 5.8 7.8 9.3 13.4 7.4.2 0 86.5-28.1 86.5-28.1z" id="path206" fill="#e01a59"/><path class="st3" d="M39.23 103.511c5.6-1.8 12.9-4.2 20.7-6.7-1.8-5.6-4.2-12.9-6.7-20.7l-20.7 6.7z" id="path208" fill="#331433"/><path class="st4" d="M82.83 89.311c7.8-2.5 15.1-4.9 20.7-6.7-1.8-5.6-4.2-12.9-6.7-20.7l-20.7 6.7z" id="path210" fill="#d62027"/><path class="st5" d="M100.23 35.511c5.7-1.9 8.8-8 7-13.7-1.9-5.7-8-8.8-13.7-7l-86.4 28.1c-5.3 1.9-8.3 7.7-6.7 13.2 1.7 5.8 7.8 9.3 13.4 7.4.2 0 86.4-28 86.4-28z" id="path212" fill="#89d3df"/><path class="st6" d="M25.13 59.911c5.6-1.8 12.9-4.2 20.7-6.7-2.5-7.8-4.9-15.1-6.7-20.7l-20.7 6.7z" id="path214" fill="#258b74"/><path class="st7" d="M68.63 45.811c7.8-2.5 15.1-4.9 20.7-6.7-2.5-7.8-4.9-15.1-6.7-20.7l-20.7 6.7z" id="path216" fill="#819c3c"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/welcome/add_new_group.svg b/app/assets/images/illustrations/welcome/add_new_group.svg
new file mode 100644
index 00000000000..b10a3ae8812
--- /dev/null
+++ b/app/assets/images/illustrations/welcome/add_new_group.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="#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-4a12.996 12.996 0 0 0-18.76 0h5.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> \ No newline at end of file
diff --git a/app/assets/images/illustrations/welcome/add_new_project.svg b/app/assets/images/illustrations/welcome/add_new_project.svg
new file mode 100644
index 00000000000..4b8dc34c088
--- /dev/null
+++ b/app/assets/images/illustrations/welcome/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-4h18a8 8 0 0 1 8 8v22a8 8 0 0 1-8 8H30a8 8 0 0 1-8-8V28a8 8 0 0 1 8-8z"/><path fill="#6B4FBB" d="M33 30h8a2 2 0 1 1 0 4h-8a2 2 0 1 1 0-4zm0 7h12a2 2 0 1 1 0 4H33a2 2 0 1 1 0-4zm0 7h12a2 2 0 1 1 0 4H33a2 2 0 1 1 0-4z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/welcome/add_new_user.svg b/app/assets/images/illustrations/welcome/add_new_user.svg
new file mode 100644
index 00000000000..d4c184989bf
--- /dev/null
+++ b/app/assets/images/illustrations/welcome/add_new_user.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="#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> \ No newline at end of file
diff --git a/app/assets/images/illustrations/welcome/configure_server.svg b/app/assets/images/illustrations/welcome/configure_server.svg
new file mode 100644
index 00000000000..f9dda816f11
--- /dev/null
+++ b/app/assets/images/illustrations/welcome/configure_server.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="#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.15a4.012 4.012 0 0 1-.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-.87a4.011 4.011 0 0 1-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> \ No newline at end of file
diff --git a/app/assets/images/illustrations/welcome/ee_trial.svg b/app/assets/images/illustrations/welcome/ee_trial.svg
new file mode 100644
index 00000000000..6d0dcf0020c
--- /dev/null
+++ b/app/assets/images/illustrations/welcome/ee_trial.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="330" height="132" viewBox="0 0 330 132"><g fill="none" fill-rule="evenodd"><path fill="#000" fill-opacity=".03" d="M174.12 42c-.08 1-.12 2-.12 3 0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3-1.53 19.03-17.46 34-36.88 34s-35.35-14.97-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M211 78c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S230.33 4 211 4s-35 15.67-35 35 15.67 35 35 35z"/><g fill-rule="nonzero"><path fill="#FEE1D3" d="M211.5 51c-6.42 0-12.26-2.84-17.43-8.4a4.008 4.008 0 0 1-.27-5.13C199 30.57 204.92 27 211.5 27s12.5 3.56 17.7 10.47a3.994 3.994 0 0 1-.27 5.12c-5.17 5.53-11 8.4-17.43 8.4zm0-4c5.25 0 10.05-2.34 14.5-7.13-4.5-5.98-9.3-8.87-14.5-8.87-5.2 0-10 2.9-14.5 8.87 4.45 4.8 9.25 7.13 14.5 7.13z"/><path fill="#FC6D26" d="M211 47c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-4c2.2 0 4-1.8 4-4s-1.8-4-4-4-4 1.8-4 4 1.8 4 4 4zm0-1c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/></g><path fill="#000" fill-opacity=".03" d="M88.12 83c-.08 1-.12 2-.12 3 0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3-1.53 19.03-17.46 34-36.88 34s-35.35-14.97-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M125 119c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35s-15.67-35-35-35-35 15.67-35 35 15.67 35 35 35z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M116 86.34c2.33.83 4 3.05 4 5.66 0 3.3-2.7 6-6 6s-6-2.7-6-6c0-2.6 1.67-4.83 4-5.66V72h4v14.34zM128 66c5.52 0 10 4.48 10 10v12h-4V76c0-3.3-2.7-6-6-6v1.83c0 .55-.45 1-1 1-.24 0-.47-.1-.65-.24l-4.46-3.87c-.46-.36-.5-1-.15-1.4.03-.05.07-.1.1-.12l4.47-3.82c.42-.35 1.05-.3 1.4.1.16.2.25.43.25.66V66zm-14 28c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/><path fill="#FC6D26" fill-rule="nonzero" d="M114 74c-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 2zm22 28c-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"/><path fill="#000" fill-opacity=".03" d="M2.12 52C2.04 53 2 54 2 55c0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3C74.35 71.03 58.42 86 39 86S3.65 71.03 2.12 52z"/><path fill="#EEE" fill-rule="nonzero" d="M39 88C17.46 88 0 70.54 0 49s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 14 39 14 4 29.67 4 49s15.67 35 35 35z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M48 41h-4c0-2.76-2.24-5-5-5s-5 2.24-5 5h-4a9 9 0 0 1 18 0zm-18 0h4v3h-4v-3zm14 0h4v3h-4v-3z"/><path fill="#E1DBF2" fill-rule="nonzero" d="M30 47c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h18c.55 0 1-.45 1-1V48c0-.55-.45-1-1-1H30zm0-4h18c2.76 0 5 2.24 5 5v12c0 2.76-2.24 5-5 5H30c-2.76 0-5-2.24-5-5V48c0-2.76 2.24-5 5-5z"/><path fill="#6B4FBB" d="M38 53.73c-.6-.34-1-1-1-1.73 0-1.1.9-2 2-2s2 .9 2 2c0 .74-.4 1.4-1 1.73V55c0 .55-.45 1-1 1s-1-.45-1-1v-1.27z"/><path fill="#000" fill-opacity=".03" d="M254.12 92c-.08 1-.12 2-.12 3 0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3-1.53 19.03-17.46 34-36.88 34s-35.35-14.97-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M291 128c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35s-15.67-35-35-35-35 15.67-35 35 15.67 35 35 35z"/><path fill="#6B4BBE" fill-rule="nonzero" d="M292 78c5.52 0 10 4.48 10 10 0 2.28-.76 4.43-2.14 6.18-1.03 1.3-.8 3.2.5 4.22 1.3 1.02 3.2.8 4.2-.5 2.22-2.8 3.44-6.26 3.44-9.9 0-8.84-7.16-16-16-16v-3.13c0-.2-.06-.4-.17-.56-.3-.42-.93-.54-1.38-.23l-9.2 6.13c-.1.06-.2.16-.28.27-.3.45-.18 1.08.28 1.38l9.2 6.13c.16.1.35.17.55.17.55 0 1-.45 1-1V78z"/><path fill="#E1DBF2" fill-rule="nonzero" d="M290 100c-5.52 0-10-4.48-10-10 0-2.25.74-4.38 2.1-6.12 1-1.3.77-3.2-.54-4.2-1.3-1.02-3.2-.78-4.2.53A15.796 15.796 0 0 0 274 90c0 8.84 7.16 16 16 16v3.13c0 .55.45 1 1 1 .2 0 .4-.06.55-.17l9.2-6.13c.46-.3.6-.93.28-1.38-.07-.1-.17-.2-.28-.28l-9.2-6.13c-.45-.3-1.08-.2-1.38.27-.1.2-.17.4-.17.6v3.1z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/welcome/globe.svg b/app/assets/images/illustrations/welcome/globe.svg
new file mode 100644
index 00000000000..c2daae5f317
--- /dev/null
+++ b/app/assets/images/illustrations/welcome/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/assets/images/illustrations/welcome/lightbulb.svg b/app/assets/images/illustrations/welcome/lightbulb.svg
new file mode 100644
index 00000000000..fce10312085
--- /dev/null
+++ b/app/assets/images/illustrations/welcome/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 52h12a2 2 0 1 1 0 4H33a2 2 0 1 1 0-4zm1 5h10a2 2 0 1 1 0 4H34a2 2 0 1 1 0-4z"/><path fill="#E1DBF2" fill-rule="nonzero" d="M45.542 46.932l.346-2.36a8.004 8.004 0 0 1 1.566-3.705c3.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.846a8.009 8.009 0 0 1 1.536 3.683l.353 2.456 13.08-.054zm-17.038.624L28.15 45.1a3.997 3.997 0 0 0-.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.3a3.99 3.99 0 0 0-.784 1.853l-.346 2.36a4.003 4.003 0 0 1-3.942 3.42l-13.08.053a4 4 0 0 1-3.974-3.43z"/><path fill="#6B4FBB" d="M41 38.732a2 2 0 1 1 2 0V42a1 1 0 0 1-2 0v-3.268zm-6 0a2 2 0 1 1 2 0V42a1 1 0 0 1-2 0v-3.268z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/wiki-fro-logged-out-users.svg b/app/assets/images/illustrations/wiki-fro-logged-out-users.svg
new file mode 100644
index 00000000000..c71841f72e5
--- /dev/null
+++ b/app/assets/images/illustrations/wiki-fro-logged-out-users.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="412" height="260" viewBox="0 0 412 260" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M6.447.894L12 12H0L5.553.894a.5.5 0 0 1 .894 0z"/></defs><g fill="none" fill-rule="evenodd"><path fill="#FEF0E8" fill-rule="nonzero" d="M338 50.287C322.695 41.45 303.124 46.694 294.287 62c-8.836 15.305-3.592 34.876 11.713 43.712 15.306 8.837 34.877 3.593 43.713-11.712 8.837-15.306 3.593-34.877-11.713-43.713zm2-3.464C357.22 56.763 363.118 78.78 353.177 96c-9.941 17.218-31.958 23.118-49.177 13.176-17.218-9.94-23.118-31.958-13.177-49.176C300.764 42.78 322.782 36.88 340 46.823z"/><g transform="rotate(-150 171.003 8.53)"><path fill="#FC6D26" fill-rule="nonzero" d="M4 16v25a2 2 0 1 0 4 0V16H4zm8-4v29a6 6 0 1 1-12 0V12h12z"/><use fill="#D8D8D8" xlink:href="#a"/><path stroke="#FDC4A8" stroke-width="4" d="M6 4.472L3.236 10h5.528L6 4.472z"/><path fill="#FC6D26" d="M9 6L6.447.894a.5.5 0 0 0-.894 0L3 6c.836.628 1.874 1 3 1a4.978 4.978 0 0 0 3-1z"/></g><path fill="#F9F9F9" d="M263.116 237.116A10.002 10.002 0 0 1 254 243h-86c-11.046 0-20-8.954-20-20V121c0-4.056 2.414-7.547 5.884-9.116A9.964 9.964 0 0 0 153 116v106c0 8.837 7.163 16 16 16h90c1.467 0 2.86-.316 4.116-.884z"/><path fill="#EEE" fill-rule="nonzero" d="M214.5 106H163c-5.523 0-10 4.477-10 10v106c0 8.837 7.163 16 16 16h90c5.523 0 10-4.477 10-10v-17.999a10.036 10.036 0 0 1-4 3.167V228a6 6 0 0 1-6 6h-90c-6.627 0-12-5.373-12-12V116a6 6 0 0 1 6-6h7v-4h44.5z"/><path fill="#EEE" fill-rule="nonzero" d="M260 218.268V214h-90a6 6 0 0 0 0 12h86a4 4 0 0 0 4-4v-.268a1.99 1.99 0 0 1-1 .268h-50a2 2 0 0 1 0-4h50c.364 0 .706.097 1 .268zM170 210h90.5a3.5 3.5 0 0 1 3.5 3.5v8.5a8 8 0 0 1-8 8h-86c-5.523 0-10-4.477-10-10s4.477-10 10-10z"/><path fill="#EEE" fill-rule="nonzero" d="M174 110v100h87a6 6 0 0 0 6-6v-88a6 6 0 0 0-6-6h-87zm-4-4h91c5.523 0 10 4.477 10 10v88c0 5.523-4.477 10-10 10h-91V106z"/><path fill="#EFEDF8" d="M230 99h18a6 6 0 0 1 6 6v31.35a3 3 0 0 1-4.68 2.484l-9.277-6.274a1.5 1.5 0 0 0-1.664-.01l-9.731 6.395a3 3 0 0 1-4.648-2.507V105a6 6 0 0 1 6-6z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M236.182 129.207a5.5 5.5 0 0 1 6.102.04l7.716 5.219V105a2 2 0 0 0-2-2h-18a2 2 0 0 0-2 2v29.584l8.182-5.377zM230 99h18a6 6 0 0 1 6 6v31.35a3 3 0 0 1-4.68 2.484l-9.277-6.274a1.5 1.5 0 0 0-1.664-.01l-9.731 6.395a3 3 0 0 1-4.648-2.507V105a6 6 0 0 1 6-6z"/><g fill-rule="nonzero"><path fill="#EFEDF8" d="M156 74c14.912 0 27-12.088 27-27s-12.088-27-27-27-27 12.088-27 27 12.088 27 27 27zm0 4c-17.12 0-31-13.88-31-31s13.88-31 31-31 31 13.88 31 31-13.88 31-31 31z"/><path fill="#6B4FBB" d="M147.535 44.916l-.116 1.086a8.446 8.446 0 0 0 .093 2.44l.2 1.08-2.262 1.202a.495.495 0 0 0-.213.678l.941 1.77c.128.239.434.332.68.201l2.25-1.196.785.775a8.544 8.544 0 0 0 1.967 1.45l.975.522-.486 2.5a.495.495 0 0 0 .392.59l1.968.383a.504.504 0 0 0 .585-.401l.489-2.515 1.086-.13a8.584 8.584 0 0 0 2.363-.633l1.005-.43 1.68 1.933a.495.495 0 0 0 .708.055l1.513-1.315a.504.504 0 0 0 .044-.708l-1.67-1.922.583-.94c.431-.696.761-1.45.978-2.239l.292-1.063 2.547-.089a.495.495 0 0 0 .488-.515l-.07-2.003a.504.504 0 0 0-.523-.48l-2.56.09-.367-1.037a8.446 8.446 0 0 0-1.139-2.159l-.644-.882 1.509-2.076a.495.495 0 0 0-.106-.702l-1.621-1.178a.504.504 0 0 0-.7.116l-1.494 2.057-1.05-.362a8.459 8.459 0 0 0-2.398-.455l-1.1-.047-.66-2.466a.495.495 0 0 0-.613-.36l-1.936.519a.504.504 0 0 0-.35.617l.661 2.466-.93.59a8.459 8.459 0 0 0-1.848 1.594l-.728.838-2.322-1.034a.495.495 0 0 0-.665.25l-.815 1.83a.504.504 0 0 0 .26.661l2.344 1.044zm-3.565 1.697a3.504 3.504 0 0 1-1.78-4.622l.815-1.83a3.495 3.495 0 0 1 4.626-1.77l.346.154c.259-.245.529-.477.81-.697l-.106-.394a3.504 3.504 0 0 1 2.471-4.292l1.936-.519a3.495 3.495 0 0 1 4.286 2.481l.106.395c.353.05.703.116 1.05.198l.222-.306a3.504 3.504 0 0 1 4.89-.78l1.622 1.178a3.495 3.495 0 0 1 .769 4.892l-.258.355c.184.312.354.633.508.962l.42-.014a3.504 3.504 0 0 1 3.625 3.373l.07 2.003a3.495 3.495 0 0 1-3.382 3.618l-.4.014c-.127.332-.27.659-.426.978l.256.294a3.504 3.504 0 0 1-.34 4.941l-1.512 1.315a3.495 3.495 0 0 1-4.94-.351l-.283-.325a11.669 11.669 0 0 1-1.05.28l-.082.424a3.504 3.504 0 0 1-4.103 2.774l-1.967-.382a3.495 3.495 0 0 1-2.765-4.11l.075-.383a11.547 11.547 0 0 1-.858-.633l-.354.188a3.504 3.504 0 0 1-4.738-1.442l-.94-1.77a3.495 3.495 0 0 1 1.453-4.734l.37-.197a11.436 11.436 0 0 1-.041-1.088l-.4-.178zm13.326 5.608a5.5 5.5 0 1 1-2.847-10.625 5.5 5.5 0 0 1 2.847 10.625zm-.776-2.898a2.5 2.5 0 1 0-1.294-4.83 2.5 2.5 0 0 0 1.294 4.83z"/></g><g fill-rule="nonzero"><path fill="#EFEDF8" d="M326.979 222.047c14.403 3.86 29.209-4.688 33.068-19.092 3.86-14.403-4.688-29.209-19.092-33.068-14.403-3.86-29.209 4.688-33.068 19.092-3.86 14.404 4.688 29.209 19.092 33.068zm-1.035 3.864c-16.538-4.431-26.352-21.43-21.92-37.967 4.43-16.538 21.429-26.352 37.966-21.92 16.538 4.43 26.352 21.429 21.92 37.966-4.43 16.538-21.429 26.352-37.966 21.92z"/><path fill="#6B4FBB" d="M329.376 201.598c-4.668-2.621-7.155-8.157-5.706-13.566 1.715-6.402 8.295-10.201 14.697-8.486 6.402 1.716 10.2 8.296 8.485 14.697-1.45 5.41-6.371 8.96-11.725 8.897a3.03 3.03 0 0 1-.074.365l-1.812 6.761a3 3 0 0 1-5.795-1.552l1.812-6.762a3.03 3.03 0 0 1 .118-.354zm3.815-2.733a8 8 0 1 0 4.14-15.455 8 8 0 0 0-4.14 15.455z"/></g><path fill="#FEF0E8" fill-rule="nonzero" d="M91.373 193c17.071-4.574 27.202-22.12 22.628-39.191-4.575-17.071-22.121-27.202-39.192-22.628-17.071 4.574-27.202 22.121-22.628 39.192 4.574 17.071 22.121 27.202 39.192 22.627zm1.035 3.864c-19.204 5.146-38.945-6.25-44.09-25.456-5.146-19.204 6.25-38.945 25.455-44.09 19.205-5.146 38.945 6.25 44.091 25.455 5.146 19.205-6.25 38.945-25.456 44.091z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M70.067 152.122l6.73 25.114 19.318-5.176-6.73-25.114-19.318 5.176zm-1.035-3.864l19.318-5.176a4 4 0 0 1 4.9 2.828l6.729 25.114a4 4 0 0 1-2.829 4.9L77.832 181.1a4 4 0 0 1-4.9-2.829l-6.729-25.114a4 4 0 0 1 2.829-4.899z"/><path fill="#FC6D26" d="M76.898 154.433l7.727-2.07a2 2 0 0 1 1.036 3.863l-7.728 2.07a2 2 0 1 1-1.035-3.863zm1.812 6.761l5.795-1.553a2 2 0 0 1 1.035 3.864l-5.795 1.553a2 2 0 1 1-1.035-3.864zm1.811 6.762l7.728-2.07a2 2 0 0 1 1.035 3.863l-7.727 2.07a2 2 0 1 1-1.036-3.863z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/wiki_login_empty.svg b/app/assets/images/illustrations/wiki_login_empty.svg
new file mode 100644
index 00000000000..1cfa47220a5
--- /dev/null
+++ b/app/assets/images/illustrations/wiki_login_empty.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="386" height="298" viewBox="0 0 386 298" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M4 51h16v15.997A5.003 5.003 0 0 1 15.003 72H8.997A5.005 5.005 0 0 1 4 66.997V51z"/><rect id="b" width="24" height="10" y="44" rx="3"/></defs><g fill="none" fill-rule="evenodd" transform="translate(0 3)"><g transform="rotate(15 23.151 968.24)"><rect width="53" height="44" fill="#FFF" stroke="#FDE5D8" stroke-width="3" stroke-linecap="round" rx="5"/><path fill="#FDE5D8" d="M29.5 28.3l2.758-3.861c.962-1.347 2.527-1.34 3.484 0l6.516 9.122c.962 1.347.399 2.439-1.252 2.439H17.994c-1.653 0-2.21-1.099-1.252-2.439l6.516-9.122c.962-1.347 2.527-1.34 3.484 0L29.5 28.3z" opacity=".6"/><circle cx="16" cy="16" r="6" fill="#FDB997"/></g><g transform="scale(-1 1) rotate(25 -75.08 -334.15)"><rect width="3" height="11" x="12.45" y="23.45" fill="#6B4FBB" transform="rotate(45 13.95 28.95)" rx="1.5"/><rect width="3" height="14" x="9.45" y="15.45" fill="#6B4FBB" transform="rotate(45 10.95 22.45)" rx="1.5"/><path fill="#FFF" stroke="#E1DCF1" stroke-width="3" d="M16 39.6C6.871 37.747 0 29.676 0 20 0 8.954 8.954 0 20 0s20 8.954 20 20c0 8.955-5.886 16.536-14 19.084v15.91A5.007 5.007 0 0 1 21 60c-2.761 0-5-2.244-5-5.006V39.6zm4-7.6c6.627 0 12-5.373 12-12S26.627 8 20 8 8 13.373 8 20s5.373 12 12 12z"/></g><g transform="scale(1 -1) rotate(-15 -383.616 -172.407)"><path stroke="#FDE5D8" stroke-width="3" d="M1.5 38.5h9V4c0-1.378-1.12-2.5-2.496-2.5H3.996A2.503 2.503 0 0 0 1.5 4v34.5z"/><rect width="2" height="27" x="5" y="7" fill="#FDA77D" opacity=".8" rx="1"/><path stroke="#FDE5D8" stroke-width="3" d="M2.427 41.553h7.146L6 48.699l-3.573-7.146z"/></g><g transform="rotate(-30 420.145 -545.422)"><path fill="#FFF" stroke="#FDE5D8" stroke-width="3" d="M9 3c0-1.657 1.347-3 3-3 1.657 0 3 1.352 3 3v43H9V3z"/><use fill="#FFF" xlink:href="#a"/><path stroke="#FDE5D8" stroke-width="3" d="M5.5 52.5v14.497A3.505 3.505 0 0 0 8.997 70.5h6.006a3.503 3.503 0 0 0 3.497-3.503V52.5h-13z"/><rect width="2" height="14" x="9" y="51" fill="#FDA77D" rx="1"/><rect width="2" height="14" x="13" y="51" fill="#FDA77D" rx="1"/><use fill="#FFF" xlink:href="#b"/><rect width="21" height="7" x="1.5" y="45.5" stroke="#FDE5D8" stroke-width="3" rx="3"/></g><g transform="translate(72 97.488)"><rect width="125" height="160" fill="#FFF" stroke="#E5E5E5" stroke-width="4" stroke-linecap="round" rx="10"/><rect width="125" height="160" x="125" fill="#FFF" stroke="#E5E5E5" stroke-width="4" stroke-linecap="round" rx="10"/><path fill="#FFF" stroke="#E5E5E5" stroke-width="4" d="M7 12.008C7 8.69 9.686 6 12.993 6H125v148H12.993C9.683 154 7 151.305 7 147.992V12.008zm236 0C243 8.69 240.314 6 237.007 6H125v148h112.007c3.31 0 5.993-2.695 5.993-6.008V12.008z" stroke-linecap="round"/><rect width="84" height="42" x="142" y="29" stroke="#EEE" stroke-width="4" rx="3"/><rect width="88" height="4" x="141" y="93" fill="#E5E5E5" rx="2"/><rect width="88" height="4" x="141" y="107" fill="#BFBFBF" rx="2"/><rect width="56" height="4" x="141" y="121" fill="#E5E5E5" rx="2"/><rect width="56" height="4" x="22" y="93" fill="#E5E5E5" rx="2"/><rect width="26" height="4" x="22" y="27" fill="#BFBFBF" rx="2"/><rect width="56" height="4" x="22" y="41" fill="#E5E5E5" rx="2"/><rect width="36" height="4" x="22" y="55" fill="#BFBFBF" rx="2"/><rect width="56" height="4" x="22" y="69" fill="#E5E5E5" rx="2"/><rect width="36" height="4" x="22" y="107" fill="#E5E5E5" rx="2"/><rect width="56" height="4" x="22" y="121" fill="#BFBFBF" rx="2"/></g><path stroke="#B5A7DD" stroke-width="2.5" d="M23.139 182.922l-1.347-.6a2.004 2.004 0 0 1-1.02-2.64l.815-1.831a1.995 1.995 0 0 1 2.645-1.01l1.308.583a9.959 9.959 0 0 1 2.177-1.876l-.376-1.402a2.004 2.004 0 0 1 1.41-2.455l1.937-.519a1.995 1.995 0 0 1 2.449 1.421l.375 1.402a9.959 9.959 0 0 1 2.824.536l.84-1.158a2.004 2.004 0 0 1 2.796-.448l1.622 1.178a1.995 1.995 0 0 1 .437 2.797l-.867 1.193a9.946 9.946 0 0 1 1.341 2.541l1.461-.05a2.004 2.004 0 0 1 2.075 1.926l.07 2.003a1.995 1.995 0 0 1-1.935 2.067l-1.445.05c-.256.93-.644 1.817-1.15 2.632l.944 1.087a2.004 2.004 0 0 1-.191 2.825l-1.513 1.315a1.995 1.995 0 0 1-2.824-.204l-.963-1.108a10.084 10.084 0 0 1-2.776.744l-.28 1.441a2.004 2.004 0 0 1-2.344 1.588l-1.967-.382a1.995 1.995 0 0 1-1.579-2.35l.275-1.414a10.044 10.044 0 0 1-2.312-1.704l-1.277.678a2.004 2.004 0 0 1-2.709-.822l-.94-1.77a1.995 1.995 0 0 1 .833-2.705l1.29-.687a9.946 9.946 0 0 1-.11-2.872zm10.98 4.93a4 4 0 1 0-2.07-7.727 4 4 0 0 0 2.07 7.728z"/><ellipse cx="197" cy="289.988" fill="#F9F9F9" rx="125" ry="4.5"/><path fill="#6B4FBB" d="M164 100.492a3.002 3.002 0 0 1 3.001-3.004H183a3.006 3.006 0 0 1 3.001 3.004v34.988c0 2.213-1.45 2.954-3.24 1.651l-7.76-5.643-7.76 5.643c-1.789 1.302-3.24.566-3.24-1.651v-34.988z"/><g opacity=".2"><path fill="#FC8A51" d="M5.747 234.768l-2.688 1.114c-1.017.422-1.803-.134-1.754-1.228l.128-2.907-1.115-2.688c-.422-1.017.135-1.803 1.229-1.754l2.907.128 2.687-1.115c1.018-.422 1.803.135 1.755 1.229l-.128 2.907 1.114 2.687c.422 1.018-.134 1.803-1.228 1.755l-2.907-.128zM191.564 37.953l-3.72.164c-1.326.059-1.992-.88-1.48-2.115l1.426-3.438-.164-3.72c-.059-1.326.88-1.992 2.115-1.48l3.438 1.426 3.72-.164c1.326-.059 1.992.88 1.48 2.114l-1.426 3.44.164 3.719c.059 1.326-.88 1.992-2.114 1.48l-3.44-1.426z"/><path fill="#6B4FBB" d="M348.789 75.876l-1.967-2.144c-.744-.812-.49-1.74.555-2.07l2.775-.873 2.144-1.967c.812-.744 1.74-.49 2.07.555l.873 2.775 1.967 2.144c.744.812.49 1.74-.555 2.07l-2.775.873-2.144 1.967c-.812.745-1.74.49-2.07-.555l-.873-2.775zm9.261 164.735l-2.907-.125c-1.1-.048-1.577-.884-1.07-1.855l1.344-2.58.126-2.908c.047-1.1.883-1.577 1.855-1.07l2.58 1.344 2.907.126c1.1.047 1.577.883 1.07 1.855l-1.344 2.58-.125 2.907c-.048 1.1-.884 1.577-1.856 1.07l-2.58-1.344zM88.789 75.876l-1.967-2.144c-.744-.812-.49-1.74.555-2.07l2.775-.873 2.144-1.967c.812-.744 1.74-.49 2.07.555l.873 2.775 1.967 2.144c.744.812.49 1.74-.555 2.07l-2.775.873-2.144 1.967c-.812.745-1.74.49-2.07-.555l-.873-2.775z"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/wiki_logout_empty.svg b/app/assets/images/illustrations/wiki_logout_empty.svg
new file mode 100644
index 00000000000..c71841f72e5
--- /dev/null
+++ b/app/assets/images/illustrations/wiki_logout_empty.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="412" height="260" viewBox="0 0 412 260" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M6.447.894L12 12H0L5.553.894a.5.5 0 0 1 .894 0z"/></defs><g fill="none" fill-rule="evenodd"><path fill="#FEF0E8" fill-rule="nonzero" d="M338 50.287C322.695 41.45 303.124 46.694 294.287 62c-8.836 15.305-3.592 34.876 11.713 43.712 15.306 8.837 34.877 3.593 43.713-11.712 8.837-15.306 3.593-34.877-11.713-43.713zm2-3.464C357.22 56.763 363.118 78.78 353.177 96c-9.941 17.218-31.958 23.118-49.177 13.176-17.218-9.94-23.118-31.958-13.177-49.176C300.764 42.78 322.782 36.88 340 46.823z"/><g transform="rotate(-150 171.003 8.53)"><path fill="#FC6D26" fill-rule="nonzero" d="M4 16v25a2 2 0 1 0 4 0V16H4zm8-4v29a6 6 0 1 1-12 0V12h12z"/><use fill="#D8D8D8" xlink:href="#a"/><path stroke="#FDC4A8" stroke-width="4" d="M6 4.472L3.236 10h5.528L6 4.472z"/><path fill="#FC6D26" d="M9 6L6.447.894a.5.5 0 0 0-.894 0L3 6c.836.628 1.874 1 3 1a4.978 4.978 0 0 0 3-1z"/></g><path fill="#F9F9F9" d="M263.116 237.116A10.002 10.002 0 0 1 254 243h-86c-11.046 0-20-8.954-20-20V121c0-4.056 2.414-7.547 5.884-9.116A9.964 9.964 0 0 0 153 116v106c0 8.837 7.163 16 16 16h90c1.467 0 2.86-.316 4.116-.884z"/><path fill="#EEE" fill-rule="nonzero" d="M214.5 106H163c-5.523 0-10 4.477-10 10v106c0 8.837 7.163 16 16 16h90c5.523 0 10-4.477 10-10v-17.999a10.036 10.036 0 0 1-4 3.167V228a6 6 0 0 1-6 6h-90c-6.627 0-12-5.373-12-12V116a6 6 0 0 1 6-6h7v-4h44.5z"/><path fill="#EEE" fill-rule="nonzero" d="M260 218.268V214h-90a6 6 0 0 0 0 12h86a4 4 0 0 0 4-4v-.268a1.99 1.99 0 0 1-1 .268h-50a2 2 0 0 1 0-4h50c.364 0 .706.097 1 .268zM170 210h90.5a3.5 3.5 0 0 1 3.5 3.5v8.5a8 8 0 0 1-8 8h-86c-5.523 0-10-4.477-10-10s4.477-10 10-10z"/><path fill="#EEE" fill-rule="nonzero" d="M174 110v100h87a6 6 0 0 0 6-6v-88a6 6 0 0 0-6-6h-87zm-4-4h91c5.523 0 10 4.477 10 10v88c0 5.523-4.477 10-10 10h-91V106z"/><path fill="#EFEDF8" d="M230 99h18a6 6 0 0 1 6 6v31.35a3 3 0 0 1-4.68 2.484l-9.277-6.274a1.5 1.5 0 0 0-1.664-.01l-9.731 6.395a3 3 0 0 1-4.648-2.507V105a6 6 0 0 1 6-6z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M236.182 129.207a5.5 5.5 0 0 1 6.102.04l7.716 5.219V105a2 2 0 0 0-2-2h-18a2 2 0 0 0-2 2v29.584l8.182-5.377zM230 99h18a6 6 0 0 1 6 6v31.35a3 3 0 0 1-4.68 2.484l-9.277-6.274a1.5 1.5 0 0 0-1.664-.01l-9.731 6.395a3 3 0 0 1-4.648-2.507V105a6 6 0 0 1 6-6z"/><g fill-rule="nonzero"><path fill="#EFEDF8" d="M156 74c14.912 0 27-12.088 27-27s-12.088-27-27-27-27 12.088-27 27 12.088 27 27 27zm0 4c-17.12 0-31-13.88-31-31s13.88-31 31-31 31 13.88 31 31-13.88 31-31 31z"/><path fill="#6B4FBB" d="M147.535 44.916l-.116 1.086a8.446 8.446 0 0 0 .093 2.44l.2 1.08-2.262 1.202a.495.495 0 0 0-.213.678l.941 1.77c.128.239.434.332.68.201l2.25-1.196.785.775a8.544 8.544 0 0 0 1.967 1.45l.975.522-.486 2.5a.495.495 0 0 0 .392.59l1.968.383a.504.504 0 0 0 .585-.401l.489-2.515 1.086-.13a8.584 8.584 0 0 0 2.363-.633l1.005-.43 1.68 1.933a.495.495 0 0 0 .708.055l1.513-1.315a.504.504 0 0 0 .044-.708l-1.67-1.922.583-.94c.431-.696.761-1.45.978-2.239l.292-1.063 2.547-.089a.495.495 0 0 0 .488-.515l-.07-2.003a.504.504 0 0 0-.523-.48l-2.56.09-.367-1.037a8.446 8.446 0 0 0-1.139-2.159l-.644-.882 1.509-2.076a.495.495 0 0 0-.106-.702l-1.621-1.178a.504.504 0 0 0-.7.116l-1.494 2.057-1.05-.362a8.459 8.459 0 0 0-2.398-.455l-1.1-.047-.66-2.466a.495.495 0 0 0-.613-.36l-1.936.519a.504.504 0 0 0-.35.617l.661 2.466-.93.59a8.459 8.459 0 0 0-1.848 1.594l-.728.838-2.322-1.034a.495.495 0 0 0-.665.25l-.815 1.83a.504.504 0 0 0 .26.661l2.344 1.044zm-3.565 1.697a3.504 3.504 0 0 1-1.78-4.622l.815-1.83a3.495 3.495 0 0 1 4.626-1.77l.346.154c.259-.245.529-.477.81-.697l-.106-.394a3.504 3.504 0 0 1 2.471-4.292l1.936-.519a3.495 3.495 0 0 1 4.286 2.481l.106.395c.353.05.703.116 1.05.198l.222-.306a3.504 3.504 0 0 1 4.89-.78l1.622 1.178a3.495 3.495 0 0 1 .769 4.892l-.258.355c.184.312.354.633.508.962l.42-.014a3.504 3.504 0 0 1 3.625 3.373l.07 2.003a3.495 3.495 0 0 1-3.382 3.618l-.4.014c-.127.332-.27.659-.426.978l.256.294a3.504 3.504 0 0 1-.34 4.941l-1.512 1.315a3.495 3.495 0 0 1-4.94-.351l-.283-.325a11.669 11.669 0 0 1-1.05.28l-.082.424a3.504 3.504 0 0 1-4.103 2.774l-1.967-.382a3.495 3.495 0 0 1-2.765-4.11l.075-.383a11.547 11.547 0 0 1-.858-.633l-.354.188a3.504 3.504 0 0 1-4.738-1.442l-.94-1.77a3.495 3.495 0 0 1 1.453-4.734l.37-.197a11.436 11.436 0 0 1-.041-1.088l-.4-.178zm13.326 5.608a5.5 5.5 0 1 1-2.847-10.625 5.5 5.5 0 0 1 2.847 10.625zm-.776-2.898a2.5 2.5 0 1 0-1.294-4.83 2.5 2.5 0 0 0 1.294 4.83z"/></g><g fill-rule="nonzero"><path fill="#EFEDF8" d="M326.979 222.047c14.403 3.86 29.209-4.688 33.068-19.092 3.86-14.403-4.688-29.209-19.092-33.068-14.403-3.86-29.209 4.688-33.068 19.092-3.86 14.404 4.688 29.209 19.092 33.068zm-1.035 3.864c-16.538-4.431-26.352-21.43-21.92-37.967 4.43-16.538 21.429-26.352 37.966-21.92 16.538 4.43 26.352 21.429 21.92 37.966-4.43 16.538-21.429 26.352-37.966 21.92z"/><path fill="#6B4FBB" d="M329.376 201.598c-4.668-2.621-7.155-8.157-5.706-13.566 1.715-6.402 8.295-10.201 14.697-8.486 6.402 1.716 10.2 8.296 8.485 14.697-1.45 5.41-6.371 8.96-11.725 8.897a3.03 3.03 0 0 1-.074.365l-1.812 6.761a3 3 0 0 1-5.795-1.552l1.812-6.762a3.03 3.03 0 0 1 .118-.354zm3.815-2.733a8 8 0 1 0 4.14-15.455 8 8 0 0 0-4.14 15.455z"/></g><path fill="#FEF0E8" fill-rule="nonzero" d="M91.373 193c17.071-4.574 27.202-22.12 22.628-39.191-4.575-17.071-22.121-27.202-39.192-22.628-17.071 4.574-27.202 22.121-22.628 39.192 4.574 17.071 22.121 27.202 39.192 22.627zm1.035 3.864c-19.204 5.146-38.945-6.25-44.09-25.456-5.146-19.204 6.25-38.945 25.455-44.09 19.205-5.146 38.945 6.25 44.091 25.455 5.146 19.205-6.25 38.945-25.456 44.091z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M70.067 152.122l6.73 25.114 19.318-5.176-6.73-25.114-19.318 5.176zm-1.035-3.864l19.318-5.176a4 4 0 0 1 4.9 2.828l6.729 25.114a4 4 0 0 1-2.829 4.9L77.832 181.1a4 4 0 0 1-4.9-2.829l-6.729-25.114a4 4 0 0 1 2.829-4.899z"/><path fill="#FC6D26" d="M76.898 154.433l7.727-2.07a2 2 0 0 1 1.036 3.863l-7.728 2.07a2 2 0 1 1-1.035-3.863zm1.812 6.761l5.795-1.553a2 2 0 0 1 1.035 3.864l-5.795 1.553a2 2 0 1 1-1.035-3.864zm1.811 6.762l7.728-2.07a2 2 0 0 1 1.035 3.863l-7.727 2.07a2 2 0 1 1-1.036-3.863z"/></g></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 f98ca15d787..00000000000
--- a/app/assets/images/new_nav.png
+++ /dev/null
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/sprite.symbol.html b/app/assets/images/sprite.symbol.html
deleted file mode 100644
index d928d3f73b8..00000000000
--- a/app/assets/images/sprite.symbol.html
+++ /dev/null
@@ -1,3297 +0,0 @@
-<!DOCTYPE html>
-<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
- <head>
- <meta charset="utf-8"/>
- <meta http-equiv="X-UA-Compatible" content="IE=Edge"/>
- <script src="https://rawgit.com/jonathantneal/svg4everybody/master/dist/svg4everybody.js"></script>
- <script>svg4everybody();</script>
- <title>SVG &lt;symbol&gt; sprite preview | svg-sprite</title>
- <style>@charset "UTF-8";body{padding:0;margin:0;color:#666;background:#fafafa;font-family:Arial,Helvetica,sans-serif;font-size:1em;line-height:1.4}header{display:block;padding:3em 3em 2em 3em;background-color:#fff}header p{margin:2em 0 0 0}section{border-top:1px solid #eee;padding:2em 3em 0 3em}section ul{margin:0;padding:0}section li{display:inline;display:inline-block;background-color:#fff;position:relative;margin:0 2em 2em 0;vertical-align:top;border:1px solid #ccc;padding:1em 1em 3em 1em;cursor:default}.icon-box{margin:0;width:144px;height:144px;position:relative;background:#ccc url("data:image/gif;base64,R0lGODlhDAAMAIAAAMzMzP///yH/C1hNUCBEYXRhWE1QPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS4wLWMwNjEgNjQuMTQwOTQ5LCAyMDEwLzEyLzA3LTEwOjU3OjAxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M1LjEgV2luZG93cyIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDozQjk4OTI0MUY5NTIxMUUyQkJDMEI5NEFEM0Y1QTYwQyIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDozQjk4OTI0MkY5NTIxMUUyQkJDMEI5NEFEM0Y1QTYwQyI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjNCOTg5MjNGRjk1MjExRTJCQkMwQjk0QUQzRjVBNjBDIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjNCOTg5MjQwRjk1MjExRTJCQkMwQjk0QUQzRjVBNjBDIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+Af/+/fz7+vn49/b19PPy8fDv7u3s6+rp6Ofm5eTj4uHg397d3Nva2djX1tXU09LR0M/OzczLysnIx8bFxMPCwcC/vr28u7q5uLe2tbSzsrGwr66trKuqqainpqWko6KhoJ+enZybmpmYl5aVlJOSkZCPjo2Mi4qJiIeGhYSDgoGAf359fHt6eXh3dnV0c3JxcG9ubWxramloZ2ZlZGNiYWBfXl1cW1pZWFdWVVRTUlFQT05NTEtKSUhHRkVEQ0JBQD8+PTw7Ojk4NzY1NDMyMTAvLi0sKyopKCcmJSQjIiEgHx4dHBsaGRgXFhUUExIREA8ODQwLCgkIBwYFBAMCAQAAIfkEAAAAAAAsAAAAAAwADAAAAhaEH6mHmmzcgzJAUG/NVGrfOZ8YLlABADs=") top left repeat;border:1px solid #ccc;display:table-cell;vertical-align:middle;text-align:center}.icon{display:inline;display:inline-block}h1{margin-top:0}h2{margin:0;padding:0;font-size:1em;font-weight:normal;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:absolute;left:1em;right:1em;bottom:1em}footer{display:block;margin:0;padding:0 3em 3em 3em}footer p{margin:0;font-size:.7em}footer a{color:#0f7595;margin-left:0}</style>
-
-<!--
-
-Sprite shape dimensions
-====================================================================================================
-You will need to set the sprite shape dimensions via CSS when you use them as inline SVG, otherwise
-they would become a huge 100% in size. You may use the following dimension classes for doing so.
-They might well be outsourced to an external stylesheet of course.
-
--->
-
-<style type="text/css">
- .svg-abuse-dims { width: 16px; height: 16px; }
- .svg-account-dims { width: 16px; height: 16px; }
- .svg-admin-dims { width: 16px; height: 16px; }
- .svg-angle-double-left-dims { width: 16px; height: 16px; }
- .svg-angle-down-dims { width: 16px; height: 16px; }
- .svg-angle-left-dims { width: 16px; height: 16px; }
- .svg-angle-right-dims { width: 16px; height: 16px; }
- .svg-angle-up-dims { width: 16px; height: 16px; }
- .svg-appearance-dims { width: 16px; height: 16px; }
- .svg-applications-dims { width: 16px; height: 16px; }
- .svg-approval-dims { width: 16px; height: 16px; }
- .svg-arrow-right-dims { width: 16px; height: 16px; }
- .svg-assignee-dims { width: 16px; height: 16px; }
- .svg-bold-dims { width: 16px; height: 16px; }
- .svg-book-dims { width: 16px; height: 16px; }
- .svg-branch-dims { width: 16px; height: 16px; }
- .svg-calendar-dims { width: 16px; height: 16px; }
- .svg-cancel-dims { width: 16px; height: 16px; }
- .svg-chevron-down-dims { width: 16px; height: 16px; }
- .svg-chevron-left-dims { width: 16px; height: 16px; }
- .svg-chevron-right-dims { width: 16px; height: 16px; }
- .svg-chevron-up-dims { width: 16px; height: 16px; }
- .svg-clock-dims { width: 16px; height: 16px; }
- .svg-code-dims { width: 16px; height: 16px; }
- .svg-comment-dims { width: 16px; height: 16px; }
- .svg-comment-dots-dims { width: 16px; height: 16px; }
- .svg-comment-next-dims { width: 16px; height: 16px; }
- .svg-comments-dims { width: 16px; height: 16px; }
- .svg-commit-dims { width: 16px; height: 16px; }
- .svg-credit-card-dims { width: 16px; height: 16px; }
- .svg-disk-dims { width: 16px; height: 16px; }
- .svg-doc_code-dims { width: 16px; height: 16px; }
- .svg-doc_image-dims { width: 16px; height: 16px; }
- .svg-doc_text-dims { width: 16px; height: 16px; }
- .svg-download-dims { width: 16px; height: 16px; }
- .svg-duplicate-dims { width: 16px; height: 16px; }
- .svg-earth-dims { width: 16px; height: 16px; }
- .svg-eye-dims { width: 16px; height: 16px; }
- .svg-eye-slash-dims { width: 16px; height: 16px; }
- .svg-file-additions-dims { width: 16px; height: 16px; }
- .svg-file-deletion-dims { width: 16px; height: 16px; }
- .svg-file-modified-dims { width: 16px; height: 16px; }
- .svg-filter-dims { width: 16px; height: 16px; }
- .svg-folder-dims { width: 16px; height: 16px; }
- .svg-fork-dims { width: 16px; height: 16px; }
- .svg-git-merge-dims { width: 16px; height: 16px; }
- .svg-group-dims { width: 16px; height: 16px; }
- .svg-history-dims { width: 16px; height: 16px; }
- .svg-home-dims { width: 16px; height: 16px; }
- .svg-hook-dims { width: 16px; height: 16px; }
- .svg-issue-block-dims { width: 16px; height: 16px; }
- .svg-issue-child-dims { width: 16px; height: 16px; }
- .svg-issue-close-dims { width: 16px; height: 16px; }
- .svg-issue-duplicate-dims { width: 16px; height: 16px; }
- .svg-issue-new-dims { width: 16px; height: 16px; }
- .svg-issue-open-dims { width: 16px; height: 16px; }
- .svg-issue-open-m-dims { width: 16px; height: 16px; }
- .svg-issue-parent-dims { width: 16px; height: 16px; }
- .svg-issues-dims { width: 16px; height: 16px; }
- .svg-key-dims { width: 16px; height: 16px; }
- .svg-key-2-dims { width: 16px; height: 16px; }
- .svg-label-dims { width: 16px; height: 16px; }
- .svg-labels-dims { width: 16px; height: 16px; }
- .svg-leave-dims { width: 16px; height: 16px; }
- .svg-level-up-dims { width: 16px; height: 16px; }
- .svg-license-dims { width: 16px; height: 16px; }
- .svg-link-dims { width: 16px; height: 16px; }
- .svg-list-bulleted-dims { width: 16px; height: 16px; }
- .svg-list-numbered-dims { width: 16px; height: 16px; }
- .svg-location-dims { width: 16px; height: 16px; }
- .svg-location-dot-dims { width: 16px; height: 16px; }
- .svg-lock-dims { width: 16px; height: 16px; }
- .svg-lock-open-dims { width: 16px; height: 16px; }
- .svg-log-dims { width: 16px; height: 16px; }
- .svg-mail-dims { width: 16px; height: 16px; }
- .svg-merge-request-close-dims { width: 16px; height: 16px; }
- .svg-merge-request-close-m-dims { width: 16px; height: 16px; }
- .svg-messages-dims { width: 16px; height: 16px; }
- .svg-mobile-issue-close-dims { width: 16px; height: 16px; }
- .svg-monitor-dims { width: 16px; height: 16px; }
- .svg-more-dims { width: 16px; height: 16px; }
- .svg-notifications-dims { width: 16px; height: 16px; }
- .svg-notifications-off-dims { width: 16px; height: 16px; }
- .svg-overview-dims { width: 16px; height: 16px; }
- .svg-pencil-dims { width: 16px; height: 16px; }
- .svg-pipeline-dims { width: 16px; height: 16px; }
- .svg-play-dims { width: 16px; height: 16px; }
- .svg-plus-dims { width: 16px; height: 16px; }
- .svg-plus-square-dims { width: 16px; height: 16px; }
- .svg-plus-square-o-dims { width: 16px; height: 16px; }
- .svg-preferences-dims { width: 16px; height: 16px; }
- .svg-profile-dims { width: 16px; height: 16px; }
- .svg-project-dims { width: 16px; height: 16px; }
- .svg-push-rules-dims { width: 16px; height: 16px; }
- .svg-question-dims { width: 16px; height: 16px; }
- .svg-question-o-dims { width: 16px; height: 16px; }
- .svg-quote-dims { width: 16px; height: 16px; }
- .svg-redo-dims { width: 16px; height: 16px; }
- .svg-remove-dims { width: 16px; height: 16px; }
- .svg-repeat-dims { width: 16px; height: 16px; }
- .svg-retry-dims { width: 16px; height: 16px; }
- .svg-scale-dims { width: 16px; height: 16px; }
- .svg-screen-full-dims { width: 16px; height: 16px; }
- .svg-screen-normal-dims { width: 16px; height: 16px; }
- .svg-search-dims { width: 16px; height: 16px; }
- .svg-settings-dims { width: 16px; height: 16px; }
- .svg-shield-dims { width: 16px; height: 16px; }
- .svg-slight-frown-dims { width: 16px; height: 16px; }
- .svg-slight-smile-dims { width: 16px; height: 16px; }
- .svg-smile-dims { width: 16px; height: 16px; }
- .svg-smiley-dims { width: 16px; height: 16px; }
- .svg-snippet-dims { width: 16px; height: 16px; }
- .svg-spam-dims { width: 16px; height: 16px; }
- .svg-star-dims { width: 16px; height: 16px; }
- .svg-star-o-dims { width: 16px; height: 16px; }
- .svg-stop-dims { width: 16px; height: 16px; }
- .svg-talic-dims { width: 16px; height: 16px; }
- .svg-task-done-dims { width: 16px; height: 16px; }
- .svg-template-dims { width: 16px; height: 16px; }
- .svg-thump-down-dims { width: 16px; height: 16px; }
- .svg-thump-up-dims { width: 16px; height: 16px; }
- .svg-timer-dims { width: 16px; height: 16px; }
- .svg-todo-add-dims { width: 16px; height: 16px; }
- .svg-todo-done-dims { width: 16px; height: 16px; }
- .svg-token-dims { width: 16px; height: 16px; }
- .svg-unapproval-dims { width: 16px; height: 16px; }
- .svg-unassignee-dims { width: 16px; height: 16px; }
- .svg-unlink-dims { width: 16px; height: 16px; }
- .svg-user-dims { width: 16px; height: 16px; }
- .svg-users-dims { width: 16px; height: 16px; }
- .svg-volume-up-dims { width: 16px; height: 16px; }
- .svg-warning-dims { width: 16px; height: 16px; }
- .svg-work-dims { width: 16px; height: 16px; }
-</style>
-<!--
-====================================================================================================
--->
-
- </head>
- <body>
-
-<!--
-
-Inline <symbol> SVG sprite
-====================================================================================================
-This is an inlined version of the generated SVG sprite. The single images may be <use>d everywhere
-below within this document. Please see
-
- https://github.com/jkphl/svg-sprite/blob/master/docs/configuration.md#defs--symbol-mode
-
-for further details on how to create this embeddable sprite variant.
-
--->
-
-<svg width="0" height="0" style="position:absolute">
- <symbol viewBox="0 0 16 16" id="abuse" xmlns="http://www.w3.org/2000/svg"><path d="M11.408.328l4.029 3.222A1.5 1.5 0 0 1 16 4.72v6.555a1.5 1.5 0 0 1-.563 1.171l-4.026 3.224a1.5 1.5 0 0 1-.937.329H5.529a1.5 1.5 0 0 1-.937-.328L.563 12.45A1.5 1.5 0 0 1 0 11.28V4.724a1.5 1.5 0 0 1 .563-1.171L4.589.329A1.5 1.5 0 0 1 5.526 0h4.945c.34 0 .67.116.937.328zM10.296 2H5.702L2 4.964v6.074L5.704 14h4.594L14 11.036V4.962L10.296 2zM8 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"/></symbol>
- <symbol viewBox="0 0 16 16" id="account" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9.195 9.965l-.568-.875a.25.25 0 0 1 .015-.294l.405-.5a.25.25 0 0 1 .283-.075l.938.36c.257-.183.543-.325.851-.42l.322-.988A.25.25 0 0 1 11.679 7h.642a.25.25 0 0 1 .238.173l.322.988c.308.095.594.237.851.42l.938-.36a.25.25 0 0 1 .283.076l.405.5a.25.25 0 0 1 .015.293l-.568.875c.113.297.18.616.193.95l.898.54a.25.25 0 0 1 .115.27l-.144.626a.25.25 0 0 1-.222.193l-1.115.098a3.015 3.015 0 0 1-.512.608l.165 1.18a.25.25 0 0 1-.138.259l-.577.281a.25.25 0 0 1-.29-.05l-.874-.905a3.035 3.035 0 0 1-.608 0l-.875.904a.25.25 0 0 1-.289.051l-.577-.281a.25.25 0 0 1-.138-.26l.165-1.18a3.015 3.015 0 0 1-.512-.607l-1.115-.098a.25.25 0 0 1-.222-.193l-.144-.626a.25.25 0 0 1 .115-.27l.898-.54c.013-.334.08-.653.193-.95zM6.789 8.023A12.845 12.845 0 0 0 6 8c-5.036 0-6 2.74-6 4.48C0 14.22.076 15 6 15c.553 0 1.055-.006 1.51-.02A5.977 5.977 0 0 1 6 11c0-1.083.287-2.1.79-2.977zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6zM12 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="admin" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.162 2.5a3.5 3.5 0 0 1-3.163 5.479L6.08 14.766a1.5 1.5 0 0 1-2.598-1.5L7.4 6.479A3.5 3.5 0 0 1 10.564 1L8.9 3.88l2.599 1.5 1.663-2.88zm-8.63 11.949a.5.5 0 1 0 .5-.866.5.5 0 0 0-.5.866z"/></symbol>
- <symbol viewBox="0 0 16 16" id="angle-double-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.414 7.95l4.243-4.243a1 1 0 0 0-1.414-1.414l-4.95 4.95a.997.997 0 0 0 0 1.414l4.95 4.95a1 1 0 1 0 1.414-1.415L10.414 7.95zm-7 0l4.243-4.243a1 1 0 0 0-1.414-1.414l-4.95 4.95a.997.997 0 0 0 0 1.414l4.95 4.95a1 1 0 0 0 1.414-1.415L3.414 7.95z"/></symbol>
- <symbol viewBox="0 0 16 16" id="angle-down" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="angle-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.757 8l4.95-4.95a1 1 0 1 0-1.414-1.414L3.636 7.293a.997.997 0 0 0 0 1.414l5.657 5.657a1 1 0 0 0 1.414-1.414L5.757 8z"/></symbol>
- <symbol viewBox="0 0 16 16" id="angle-right" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.243 8l-4.95-4.95a1 1 0 0 1 1.414-1.414l5.657 5.657a.997.997 0 0 1 0 1.414l-5.657 5.657a1 1 0 0 1-1.414-1.414L10.243 8z"/></symbol>
- <symbol viewBox="0 0 16 16" id="angle-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 6.757l-4.95 4.95a1 1 0 1 1-1.414-1.414l5.657-5.657a.997.997 0 0 1 1.414 0l5.657 5.657a1 1 0 0 1-1.414 1.414L8 6.757z"/></symbol>
- <symbol viewBox="0 0 16 16" id="appearance" xmlns="http://www.w3.org/2000/svg"><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.858a23.85 23.85 0 0 1-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-.023a9.7 9.7 0 0 0 .478-.035c-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"/></symbol>
- <symbol viewBox="0 0 16 16" id="applications" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="approval" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.536 10.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 1 1 9.12 9.243l1.415 1.414zM7.632 8.109A2 2 0 0 0 7 11.364l2.121 2.121a1.996 1.996 0 0 0 2.807.021C11.686 14.554 10.627 15 6 15c-5.924 0-6-.78-6-2.52S.964 8 6 8c.6 0 1.142.038 1.632.109zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></symbol>
- <symbol viewBox="0 0 16 16" id="arrow-right" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="assignee" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12 5V4a1 1 0 0 1 2 0v1h1a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0V7h-1a1 1 0 0 1 0-2h1zM5.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"/></symbol>
- <symbol viewBox="0 0 16 16" id="bold" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 15V1a1 1 0 0 1 1-1h4.604c.93 0 1.762.088 2.495.264.733.176 1.353.445 1.863.807.509.363.897.82 1.164 1.369.268.549.401 1.197.401 1.945 0 .366-.045.718-.137 1.055-.091.337-.23.652-.417.945a3.453 3.453 0 0 1-.71.796 3.645 3.645 0 0 1-1.021.588c.469.117.87.295 1.203.533.333.238.608.515.824.83.216.315.374.657.473 1.027.099.37.148.75.148 1.138 0 1.553-.5 2.725-1.5 3.516-1 .791-2.423 1.187-4.27 1.187H3a1 1 0 0 1-1-1zm3.297-5.967v4.319H8.12c.425 0 .791-.053 1.099-.16.307-.106.564-.252.769-.44.205-.186.357-.406.456-.659.099-.252.148-.529.148-.83a3.04 3.04 0 0 0-.131-.928 1.78 1.78 0 0 0-.413-.703 1.8 1.8 0 0 0-.73-.445c-.3-.103-.66-.154-1.077-.154H5.297zm0-2.33h2.44c.842-.014 1.468-.192 1.878-.533.41-.34.616-.826.616-1.456 0-.725-.21-1.247-.632-1.566-.421-.318-1.086-.478-1.995-.478H5.297v4.033z"/></symbol>
- <symbol viewBox="0 0 16 16" id="book" xmlns="http://www.w3.org/2000/svg"><path d="M7 2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2v4.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 7 6.191V2zM5 0h6a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H5a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4z"/></symbol>
- <symbol viewBox="0 0 16 16" id="branch" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="calendar" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12 2h2a2 2 0 0 1 2 2H0a2 2 0 0 1 2-2h2V1a1 1 0 1 1 2 0v1h4V1a1 1 0 1 1 2 0v1zM0 4h16v9a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V4zm2 2.5V13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6.5a.5.5 0 0 0-.5-.5h-11a.5.5 0 0 0-.5.5zM5 8h2a1 1 0 1 1 0 2H5a1 1 0 1 1 0-2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="cancel" xmlns="http://www.w3.org/2000/svg"><path d="M3.11 4.523a6 6 0 0 0 8.367 8.367L3.109 4.524zM4.522 3.11l8.368 8.368A6 6 0 0 0 4.524 3.11zM8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16z"/></symbol>
- <symbol viewBox="0 0 16 16" id="chevron-down" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.078 8.2l3.535-3.536a2 2 0 0 1 2.828 2.828l-4.949 4.95c-.39.39-.902.586-1.414.586a1.994 1.994 0 0 1-1.414-.586l-4.95-4.95a2 2 0 1 1 2.828-2.828l3.536 3.535z"/></symbol>
- <symbol viewBox="0 0 16 16" id="chevron-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.977 7.998l3.535-3.535a2 2 0 1 0-2.828-2.828l-4.95 4.949c-.39.39-.586.902-.586 1.414 0 .512.196 1.024.586 1.414l4.95 4.95a2 2 0 1 0 2.828-2.828L7.977 7.998z"/></symbol>
- <symbol viewBox="0 0 16 16" id="chevron-right" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.22 7.998L4.683 4.463a2 2 0 0 1 2.828-2.828l4.95 4.949c.39.39.586.902.586 1.414a1.99 1.99 0 0 1-.586 1.414l-4.95 4.95a2 2 0 0 1-2.828-2.828l3.535-3.536z"/></symbol>
- <symbol viewBox="0 0 16 16" id="chevron-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.778 8.957l3.535 3.535a2 2 0 1 0 2.828-2.828l-4.949-4.95a1.994 1.994 0 0 0-1.414-.586c-.512 0-1.024.196-1.414.586l-4.95 4.95a2 2 0 1 0 2.828 2.828l3.536-3.535z"/></symbol>
- <symbol viewBox="0 0 16 16" id="clock" xmlns="http://www.w3.org/2000/svg"><path d="M9 7h1a1 1 0 0 1 0 2H8a.997.997 0 0 1-1-1V5a1 1 0 1 1 2 0v2zm-1 9A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm0-2A6 6 0 1 0 8 2a6 6 0 0 0 0 12z"/></symbol>
- <symbol viewBox="0 0 16 16" id="code" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M15.871 8.243a.997.997 0 0 0-.293-.707L12.75 4.707a1 1 0 0 0-1.414 1.414l2.12 2.122-2.12 2.121a1 1 0 0 0 1.414 1.414l2.828-2.828a.997.997 0 0 0 .293-.707zm-13.243 0L4.75 6.12a1 1 0 1 0-1.414-1.414L.507 7.536a.997.997 0 0 0 0 1.414l2.829 2.828a1 1 0 1 0 1.414-1.414L2.628 8.243zm6.407-4.107a1 1 0 0 1 .707 1.225L8.19 11.157a1 1 0 1 1-1.931-.518L7.81 4.843a1 1 0 0 1 1.224-.707z"/></symbol>
- <symbol viewBox="0 0 16 16" id="comment" xmlns="http://www.w3.org/2000/svg"><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.586z"/></symbol>
- <symbol viewBox="0 0 16 16" id="comment-dots" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="comment-next" xmlns="http://www.w3.org/2000/svg"><path d="M8 5V4a.5.5 0 0 1 .8-.4l2.667 2a.5.5 0 0 1 0 .8L8.8 8.4A.5.5 0 0 1 8 8V7H6a1 1 0 1 1 0-2h2zM1.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.586z"/></symbol>
- <symbol viewBox="0 0 16 16" id="comments" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3.75 10L0 13V3a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3.75zM13 5h1a2 2 0 0 1 2 2v8l-2.667-2H8a2 2 0 0 1-2-2h4a3 3 0 0 0 3-3V5z"/></symbol>
- <symbol viewBox="0 0 16 16" id="commit" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="credit-card" xmlns="http://www.w3.org/2000/svg"><path d="M14 5a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1h12zm0 3H2v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V8zM3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm6.5 8h3a.5.5 0 1 1 0 1h-3a.5.5 0 1 1 0-1z"/></symbol>
- <symbol viewBox="0 0 16 16" id="disk" xmlns="http://www.w3.org/2000/svg"><path d="M16 11.764V3a3 3 0 0 0-3-3H3a3 3 0 0 0-3 3v8.764A2.989 2.989 0 0 1 2 11V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v8c.768 0 1.47.289 2 .764zM2 12h12a2 2 0 1 1 0 4H2a2 2 0 1 1 0-4zm10 1a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="doc_code" xmlns="http://www.w3.org/2000/svg"><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.414zm1.036 7.607a.498.498 0 0 1-.147.354l-1.414 1.414a.5.5 0 0 1-.707-.707l1.06-1.06-1.06-1.061a.5.5 0 0 1 .707-.707l1.414 1.414a.498.498 0 0 1 .147.353zm-4.822 0l1.06 1.061a.5.5 0 0 1-.706.707l-1.414-1.414a.498.498 0 0 1 0-.707l1.414-1.414a.5.5 0 1 1 .707.707l-1.06 1.06zM5 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-4z"/></symbol>
- <symbol viewBox="0 0 16 16" id="doc_image" xmlns="http://www.w3.org/2000/svg"><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.414zM7.333 9.667l1.313-1.313a.5.5 0 0 1 .708 0L12 11H4l2.188-1.75a.5.5 0 0 1 .624 0l.521.417zM5 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 8a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zM4 11h8v.7a.3.3 0 0 1-.3.3H4.3a.3.3 0 0 1-.3-.3V11z"/></symbol>
- <symbol viewBox="0 0 16 16" id="doc_text" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="download" xmlns="http://www.w3.org/2000/svg"><path d="M9 12h1a.5.5 0 0 1 .4.8l-2 2.667a.5.5 0 0 1-.8 0l-2-2.667A.5.5 0 0 1 6 12h1V8a1 1 0 1 1 2 0v4zM4 9a1 1 0 1 1 0 2 4 4 0 0 1-1.971-7.481 4 4 0 0 1 6.633-2.505 3.999 3.999 0 0 1 3.82 2.014A4 4 0 0 1 12 11a1 1 0 0 1 0-2 2 2 0 1 0 0-4h-1a2 2 0 0 0-3.112-1.662A2 2 0 1 0 4.268 5H4a2 2 0 1 0 0 4z"/></symbol>
- <symbol viewBox="0 0 16 16" id="duplicate" xmlns="http://www.w3.org/2000/svg"><path d="M14 10h-3a1 1 0 0 1-1-1V6H8.527A.527.527 0 0 0 8 6.527V13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-3zm-4-7H8.527c-.18 0-.355.013-.527.04V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2v2H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h4a3 3 0 0 1 3 3zM8.527 4h2.323a.5.5 0 0 1 .35.143l4.65 4.551a.5.5 0 0 1 .15.357V13a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V6.527A2.527 2.527 0 0 1 8.527 4z"/></symbol>
- <symbol viewBox="0 0 16 16" id="earth" xmlns="http://www.w3.org/2000/svg"><path d="M8.7 2.04l-.082.177c.283.223.422.413.417.571-.008.237-.311.057-.444.274-.133.218.038.542-.112.637-.15.096-.398-.386-.479-.46-.054-.049-.166-.257-.336-.625l-.216-.225a.844.844 0 0 0-.418-.035c-.177.038-.075.1-.035.132.04.032.32.037.452.2.132.164.03.224-.05.298-.054.05-.157.062-.31.035H5.952l-.402.398.03.325.229.455.324-.463c.008-.206.058-.342.15-.41.14-.1.342-.15.534-.085.191.066-.057.218.011.271.068.053.204-.098.313-.02.11.08.07.155.104.322.036.167.254.114.398.328.144.215.19.29.147.483-.043.195-.168.26-.305.232-.138-.028-.107-.246-.275-.348-.168-.102-.266-.114-.386-.054-.12.06-.016.129.023.235.04.106.274.321.224.43-.05.107-.108.116-.42 0-.21-.077-.414-.007-.615.212l-.76.722c-.153.715-.3 1.13-.44 1.243-.211.17-.177-.483-.483-.656-.306-.174-.494-.047-.8-.07-.307-.023-.42.65-.38.873a.434.434 0 0 0 .221.321c.236-.141.39-.184.465-.128.11.084-.144.267-.074.425.07.158.314.069.386.283.073.213.084.48-.05.706-.135.227-.275.178-.4.053-.127-.126-.033-.375-.255-.704-.223-.329-.381-.337-.63-.787-.158-.287-.35-.743-.575-1.366a6 6 0 0 0 3.21 7.198l.001-.075c0-.577-.004-.944-.012-1.102-.011-.236-.95-.945-1.104-1.2-.154-.256-.34-.595-.355-.746-.016-.151.185-.232.344-.325.16-.093-.11-.367.028-.626.137-.258.395-.438.496-.356.101.081.058.228.267.333.209.104.077-.213.456-.178.38.035.143.201.252.216.11.016.113-.127.299-.143.186-.015.282.445.471.622.19.178.452.008.611.043.159.034.267.09.402.255.136.166-.03.352.073.557.103.205 1.07.22 1.433.255.364.034.371.011.371.324s-.166.314-.453.507c-.286.193-.166.462-.38.762-.212.3-.316.062-.622.14-.306.077-.413.382-.452.568-.039.186-.386.094-.877.232-.29.082-.429.144-.569.204a6.002 6.002 0 0 0 7.682-4.3c-.094-.384-.18-.63-.258-.74-.213-.297-.36.21-.924.49-.564.278-.57-.288-.81-.49-.16-.133-.212-.44-.158-.92-.005-.478.02-.828.077-1.049.057-.221.126-.543.207-.965.351-.373.606-.572.764-.595.237-.034.336.374.658.3a.315.315 0 0 0 .035-.01 5.993 5.993 0 0 0-.475-.824l-.309-.043a.646.646 0 0 0-.332-.117c-.205-.02-.025.128-.089.24-.064.112-.235.724-.437.685-.201-.039-.204-.374-.17-.668.036-.294-.077-.35-.2-.412-.124-.062-.325-.213-.556-.295-.232-.082-.123-.175-.093-.274.03-.1.208-.015.193-.058-.014-.044-.313-.135-.266-.167.03-.02.2-.02.506.003l.216-.012.293-.163a.58.58 0 0 0-.376-.22c-.233-.036-.513-.034-.73-.142-.205-.103-.458-.36-.643-.638A5.965 5.965 0 0 0 8.7 2.04zM8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16z"/></symbol>
- <symbol viewBox="0 0 16 16" id="eye" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="eye-slash" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="file-additions" xmlns="http://www.w3.org/2000/svg"><path d="M7 7V5a1 1 0 1 1 2 0v2h2a1 1 0 0 1 0 2H9v2a1 1 0 0 1-2 0V9H5a1 1 0 1 1 0-2h2zM3 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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3z"/></symbol>
- <symbol viewBox="0 0 16 16" id="file-deletion" xmlns="http://www.w3.org/2000/svg"><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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3zm2 6h6a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="file-modified" xmlns="http://www.w3.org/2000/svg"><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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3zm5 4a3 3 0 1 1 0 6 3 3 0 0 1 0-6z"/></symbol>
- <symbol viewBox="0 0 16 16" id="filter" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10 6v9l-3.724-1.862A.5.5 0 0 1 6 12.691V6L1.854 1.854A.5.5 0 0 1 2.207 1h11.586a.5.5 0 0 1 .353.854L10 6z"/></symbol>
- <symbol viewBox="0 0 16 16" id="folder" xmlns="http://www.w3.org/2000/svg"><path d="M7.228 5l-.475-1.335A1 1 0 0 0 5.81 3H2v9a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H7.228zM13 3a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a2 2 0 0 1 2-2h3.81a3 3 0 0 1 2.827 1.995L13 3z"/></symbol>
- <symbol viewBox="0 0 16 16" id="fork" xmlns="http://www.w3.org/2000/svg"><path d="M9 12.268a2 2 0 1 1-2 0V8.874A4.002 4.002 0 0 1 4 5V3.732a2 2 0 1 1 2 0V5a2 2 0 1 0 4 0V3.732a2 2 0 1 1 2 0V5a4.002 4.002 0 0 1-3 3.874v3.394zM11 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM5 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="git-merge" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="group" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3.048 11.997C-.377 11.975.013 11.782.013 10.56.013 9.235.653 8 4 8c.444 0 .84.022 1.194.062.164.435.426.82.76 1.132-1.786.389-2.721 1.353-2.906 2.803zm2.94-7.222a2.993 2.993 0 0 0-.976 1.95 2 2 0 1 1 .975-1.95zm6.964 7.222c-.185-1.45-1.12-2.414-2.906-2.803.334-.311.596-.697.76-1.132C11.16 8.022 11.556 8 12 8c3.346 0 3.987 1.235 3.987 2.56 0 1.222.39 1.415-3.035 1.437zm-1.964-5.272a2.993 2.993 0 0 0-.976-1.95 2 2 0 1 1 .976 1.95zM8 9a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 5c-2.177 0-3.987-.115-3.987-1.44S4.653 10 8 10c3.346 0 3.987 1.235 3.987 2.56S10.177 14 8 14z"/></symbol>
- <symbol viewBox="0 0 16 16" id="history" xmlns="http://www.w3.org/2000/svg"><path d="M2.868 3.24a7 7 0 1 1-.043 9.475 1 1 0 0 1 1.478-1.348 5 5 0 1 0 .124-6.865l.796.645a.5.5 0 0 1-.193.873l-3.232.814a.5.5 0 0 1-.622-.504L1.3 3a.5.5 0 0 1 .814-.37l.754.61zM9 8h1a1 1 0 0 1 0 2H8a.997.997 0 0 1-1-1V6a1 1 0 1 1 2 0v2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="home" xmlns="http://www.w3.org/2000/svg"><path d="M8.462 2.177a.505.505 0 0 1-.038.044l.038-.044zm-.787 0l.038.043a.5.5 0 0 1-.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"/></symbol>
- <symbol viewBox="0 0 16 16" id="hook" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="issue-block" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.803 8a5.97 5.97 0 0 0-.462 1H4.5a.5.5 0 0 1 0-1h1.303zM4.5 5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1zm7.5.083a6.04 6.04 0 0 0-2 0V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.083a5.96 5.96 0 0 0 .72 2H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v2.083zm1.121 3.796zM11 16a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm-1.293-2.292a3 3 0 0 0 4.001-4.001l-4.001 4zm-1.415-1.415l4.001-4a3 3 0 0 0-4.001 4.001z"/></symbol>
- <symbol viewBox="0 0 16 16" id="issue-child" xmlns="http://www.w3.org/2000/svg"><path d="M11 8H5v1h1a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2V7a.997.997 0 0 1 1-1h3V4H4.5a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H9v2h3a.997.997 0 0 1 1 1v2h2a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-5a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h1V8zm-9 3v2h3v-2H2zm9 0v2h3v-2h-3z"/></symbol>
- <symbol viewBox="0 0 16 16" id="issue-close" xmlns="http://www.w3.org/2000/svg"><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.414zM8 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 12z"/></symbol>
- <symbol viewBox="0 0 16 16" id="issue-duplicate" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="issue-new" xmlns="http://www.w3.org/2000/svg"><path d="M10 2V1a1 1 0 0 1 2 0v1h1a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0V4H9a1 1 0 1 1 0-2h1zm0 6a1 1 0 0 1 2 0v5a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h1a1 1 0 1 1 0 2H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V8z"/></symbol>
- <symbol viewBox="0 0 16 16" id="issue-open" xmlns="http://www.w3.org/2000/svg"><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 12zm0-2a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/></symbol>
- <symbol viewBox="0 0 16 16" id="issue-open-m" xmlns="http://www.w3.org/2000/svg"><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 12z"/></symbol>
- <symbol viewBox="0 0 16 16" id="issue-parent" xmlns="http://www.w3.org/2000/svg"><path d="M11 11H5v1h1.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5H3v-2a.997.997 0 0 1 1-1h3V7H5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H9v2h3a.997.997 0 0 1 1 1v2h2.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5H11v-1zM6 3v2h4V3H6z"/></symbol>
- <symbol viewBox="0 0 16 16" id="issues" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="key" xmlns="http://www.w3.org/2000/svg"><path d="M7.575 6.689a4.002 4.002 0 0 1 6.274-4.86 4 4 0 0 1-4.86 6.274l-2.21 2.21.706.708a1 1 0 1 1-1.414 1.414l-.707-.707-.707.707.707.707a1 1 0 1 1-1.414 1.414l-.707-.707a1 1 0 0 1-1.414-1.414l5.746-5.746zm2.032-.618a2 2 0 1 0 2.828-2.828A2 2 0 0 0 9.607 6.07z"/></symbol>
- <symbol viewBox="0 0 16 16" id="key-2" xmlns="http://www.w3.org/2000/svg"><path d="M5.172 14.157l-.344.344-2.485.133a.462.462 0 0 1-.497-.503l.14-2.24a.599.599 0 0 1 .177-.382l5.155-5.155a4 4 0 1 1 2.828 2.828l-1.439 1.44-1.06-.354-.708.707.354 1.06-.707.708-1.06-.354-.708.707.354 1.06zm6.01-8.839a1 1 0 1 0 1.414-1.414 1 1 0 0 0-1.414 1.414z"/></symbol>
- <symbol viewBox="0 0 16 16" id="label" xmlns="http://www.w3.org/2000/svg"><path d="M11.782 14.718a3 3 0 0 1-4.242 0L1.652 8.829a2 2 0 0 1-.565-1.702l.54-3.703a2 2 0 0 1 1.69-1.69l3.703-.54a2 2 0 0 1 1.703.564l5.888 5.888a3 3 0 0 1 0 4.243l-2.829 2.829zm1.415-5.657L7.309 3.173l-3.703.54-.54 3.702 5.888 5.888a1 1 0 0 0 1.414 0l2.829-2.828a1 1 0 0 0 0-1.414zM5.732 5.525A1 1 0 1 1 7.146 6.94a1 1 0 0 1-1.414-1.414z"/></symbol>
- <symbol viewBox="0 0 16 16" id="labels" xmlns="http://www.w3.org/2000/svg"><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-.667zM.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"/></symbol>
- <symbol viewBox="0 0 16 16" id="leave" xmlns="http://www.w3.org/2000/svg"><path d="M11 7V5.883a.5.5 0 0 1 .757-.429l3.528 2.117a.5.5 0 0 1 0 .858l-3.528 2.117a.5.5 0 0 1-.757-.43V9H7a1 1 0 1 1 0-2h4zm-2 6.256a1 1 0 0 1 2 0A2.744 2.744 0 0 1 8.256 16H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h5.19A2.81 2.81 0 0 1 11 2.81a1 1 0 0 1-2 0A.81.81 0 0 0 8.19 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h5.256c.41 0 .744-.333.744-.744z"/></symbol>
- <symbol viewBox="0 0 16 16" id="level-up" xmlns="http://www.w3.org/2000/svg"><path fill="#2E2E2E" fill-rule="evenodd" d="M7 6h3.489a.5.5 0 0 0 .373-.832L6.374.117a.5.5 0 0 0-.748 0l-4.488 5.05A.5.5 0 0 0 1.51 6H5v7a3 3 0 0 0 3 3h6a1 1 0 0 0 0-2H8a1 1 0 0 1-1-1V6z"/></symbol>
- <symbol viewBox="0 0 16 16" id="license" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12.56 8.9l2.66 4.606a.3.3 0 0 1-.243.45l-1.678.094a.1.1 0 0 0-.078.044l-.953 1.432a.3.3 0 0 1-.51-.016L9.097 10.9a5.994 5.994 0 0 0 3.464-2zm-5.23 2.063L4.707 15.51a.3.3 0 0 1-.51.016l-.953-1.432a.1.1 0 0 0-.078-.044l-1.678-.094a.3.3 0 0 1-.243-.45l2.48-4.297a5.983 5.983 0 0 0 3.607 1.754zM8 10A5 5 0 1 1 8 0a5 5 0 0 1 0 10zm0-2a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0-1a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol>
- <symbol viewBox="0 0 16 16" id="link" xmlns="http://www.w3.org/2000/svg"><path d="M6.986 3.35l2.12-2.122a4 4 0 0 1 5.657 5.657l-2.828 2.829a4 4 0 0 1-5.657 0 1 1 0 0 1 1.414-1.415 2 2 0 0 0 2.829 0l2.828-2.828a2 2 0 1 0-2.828-2.828l-1.001 1a5.018 5.018 0 0 0-2.534-.294zm2.12 9.192l-2.12 2.121a4 4 0 1 1-5.658-5.656l2.829-2.829a4 4 0 0 1 5.657 0 1 1 0 1 1-1.415 1.414 2 2 0 0 0-2.828 0l-2.828 2.829a2 2 0 1 0 2.828 2.828l1.001-1.001a5.018 5.018 0 0 0 2.534.294z"/></symbol>
- <symbol viewBox="0 0 16 16" id="list-bulleted" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm0 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4-7h10a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2zm0 5h10a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2zm-4 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4-2h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="list-numbered" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6 2h8a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2zm0 5h8a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2zm0 5h8a1 1 0 0 1 0 2H6a1 1 0 0 1 0-2zM1.156 5v-.828h.816V2.204h-.72v-.636c.432-.084.708-.192.996-.372h.756v2.976h.684V5H1.156zm-.18 5v-.588c.9-.828 1.596-1.464 1.596-1.98 0-.342-.192-.504-.468-.504-.252 0-.444.18-.624.36l-.552-.552c.396-.42.756-.612 1.32-.612.768 0 1.308.492 1.308 1.248 0 .612-.576 1.284-1.092 1.812.192-.024.468-.048.636-.048h.636V10H.976zm1.26 5.072c-.618 0-1.068-.204-1.356-.54l.468-.648c.234.216.51.36.78.36.336 0 .552-.12.552-.36 0-.288-.15-.456-.948-.456v-.72c.636 0 .828-.168.828-.432 0-.228-.138-.348-.396-.348-.252 0-.432.108-.672.312l-.516-.624c.372-.312.768-.492 1.236-.492.84 0 1.38.384 1.38 1.074 0 .366-.204.642-.612.822v.024c.432.132.732.432.732.912 0 .72-.684 1.116-1.476 1.116z"/></symbol>
- <symbol viewBox="0 0 16 16" id="location" xmlns="http://www.w3.org/2000/svg"><path d="M8.755 15.144a1 1 0 0 1-1.51 0C3.748 11.114 2 8.065 2 6a6 6 0 1 1 12 0c0 2.065-1.748 5.113-5.245 9.144zM12 6a4 4 0 1 0-8 0c0 1.314 1.312 3.71 4 6.944C10.688 9.71 12 7.314 12 6zM8 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol>
- <symbol viewBox="0 0 16 16" id="location-dot" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6.314 13.087C4.382 13.295 3 13.85 3 14.5c0 .828 2.239 1.5 5 1.5s5-.672 5-1.5c0-.65-1.382-1.205-3.314-1.413l-.202.225a2 2 0 0 1-2.968 0l-.202-.225zm2.428-.445a1 1 0 0 1-1.484 0C4.419 9.5 3 7.037 3 5.252 3 2.353 5.239 0 8 0s5 2.352 5 5.253c0 1.784-1.42 4.247-4.258 7.389zM11 5.252C11 3.436 9.634 2 8 2S5 3.435 5 5.253c0 1.027.974 2.824 3 5.203 2.026-2.38 3-4.176 3-5.203zM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="lock" xmlns="http://www.w3.org/2000/svg"><path d="M10 5V4h2v1a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3V4h2v1h4zM4 7a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H4zm0-3a4 4 0 1 1 8 0h-2a2 2 0 1 0-4 0H4z"/></symbol>
- <symbol viewBox="0 0 16 16" id="lock-open" xmlns="http://www.w3.org/2000/svg"><path d="M4.044 4a4 4 0 0 1 6.99-2.658 1 1 0 1 1-1.495 1.33A2 2 0 0 0 6.044 4a.998.998 0 0 1-.07.367v.701H12a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3v-5a3 3 0 0 1 2.974-3V4h.07zM4 7.07a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H4z"/></symbol>
- <symbol viewBox="0 0 16 16" id="log" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="mail" xmlns="http://www.w3.org/2000/svg"><path d="M14 5.6L9.338 9.796a2 2 0 0 1-2.676 0L2 5.6V11a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5.6zM3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm.212 2L8 8.31 12.788 4H3.212z"/></symbol>
- <symbol viewBox="0 0 16 16" id="merge-request-close" xmlns="http://www.w3.org/2000/svg"><path d="M9.414 8l1.414 1.414a1 1 0 1 1-1.414 1.414L8 9.414l-1.414 1.414a1 1 0 1 1-1.414-1.414L6.586 8 5.172 6.586a1 1 0 1 1 1.414-1.414L8 6.586l1.414-1.414a1 1 0 1 1 1.414 1.414L9.414 8zM8 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 12z"/></symbol>
- <symbol viewBox="0 0 16 16" id="merge-request-close-m" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9.414 8l4.95-4.95a1 1 0 0 0-1.414-1.414L8 6.586l-4.95-4.95A1 1 0 0 0 1.636 3.05L6.586 8l-4.95 4.95a1 1 0 1 0 1.414 1.414L8 9.414l4.95 4.95a1 1 0 1 0 1.414-1.414L9.414 8z"/></symbol>
- <symbol viewBox="0 0 16 16" id="messages" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="mobile-issue-close" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.657 10.728L2.12 7.192A1 1 0 1 0 .707 8.607l4.243 4.242a.997.997 0 0 0 1.414 0l8.485-8.485a1 1 0 1 0-1.414-1.414l-7.778 7.778z"/></symbol>
- <symbol viewBox="0 0 16 16" id="monitor" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="more" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 4a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 6a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 6a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol>
- <symbol viewBox="0 0 16 16" id="notifications" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="notifications-off" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.26 5.089c.243.757.382 1.478.5 2.017.187.86.74 2.188 1.66 3.982A2 2 0 0 1 13.64 14H10a2 2 0 1 1-4 0H4.35l2-2h7.29c-.993-1.937-1.6-3.396-1.835-4.468-.07-.326-.129-.59-.178-.81l1.634-1.633zM10.943 1.75l-1.48 1.48C9.07 3.076 8.612 3 8.069 3c-1.608 0-2.49.718-3.103 2.197-.28.676-.356.982-.654 2.428-.065.317-.17.673-.317 1.073L.45 12.242a1.99 1.99 0 0 1 .224-1.19c.962-1.787 1.521-3.064 1.68-3.831.322-1.566.947-5.501 4.65-6.134a1 1 0 1 1 1.994-.024 4.867 4.867 0 0 1 1.944.688zm2.932-.105a1 1 0 0 1 0 1.415L2.561 14.374a1 1 0 1 1-1.415-1.414L12.46 1.646a1 1 0 0 1 1.414 0z"/></symbol>
- <symbol viewBox="0 0 16 16" id="overview" xmlns="http://www.w3.org/2000/svg"><path d="M2 0h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm0 2v3h3V2H2zm9-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-2zm0 2v3h3V2h-3zM2 9h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2zm0 2v3h3v-3H2zm9-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-2zm0 2v3h3v-3h-3z"/></symbol>
- <symbol viewBox="0 0 16 16" id="pencil" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="pipeline" xmlns="http://www.w3.org/2000/svg"><path d="M8.969 7.25a2 2 0 1 1-1.938 0A1.002 1.002 0 0 1 7 7V5.083a.2.2 0 0 1 .06-.142l.877-.87a.1.1 0 0 1 .141 0l.864.87A.2.2 0 0 1 9 5.083V7c0 .086-.01.17-.031.25zM8 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 12zm4.5-4a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-2 6a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-5 9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-2 6a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zM8 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="play" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2.765 15.835c-.545.321-1.258.159-1.593-.363A1.075 1.075 0 0 1 1 14.89V1.11C1 .496 1.518 0 2.158 0c.214 0 .424.057.607.165l11.684 6.89c.544.321.714 1.005.38 1.526a1.135 1.135 0 0 1-.38.364l-11.684 6.89z"/></symbol>
- <symbol viewBox="0 0 16 16" id="plus" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7 7V1a1 1 0 1 1 2 0v6h6a1 1 0 0 1 0 2H9v6a1 1 0 0 1-2 0V9H1a1 1 0 1 1 0-2h6z"/></symbol>
- <symbol viewBox="0 0 16 16" id="plus-square" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9 7V4a1 1 0 1 0-2 0v3H4a1 1 0 1 0 0 2h3v3a1 1 0 0 0 2 0V9h3a1 1 0 0 0 0-2H9zM3 0h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3z"/></symbol>
- <symbol viewBox="0 0 16 16" id="plus-square-o" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7 7V5a1 1 0 1 1 2 0v2h2a1 1 0 0 1 0 2H9v2a1 1 0 0 1-2 0V9H5a1 1 0 1 1 0-2h2zM3 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"/></symbol>
- <symbol viewBox="0 0 16 16" id="preferences" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5 12h10a1 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-2zm11-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 2h9V7zM6 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 2h2V2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="profile" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="project" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="push-rules" xmlns="http://www.w3.org/2000/svg"><path d="M6.268 9a2 2 0 0 1 3.464 0H11a1 1 0 0 1 0 2H9.732a2 2 0 0 1-3.464 0H5a1 1 0 0 1 0-2h1.268zM7 2H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1h-1v3.515a.3.3 0 0 1-.434.268l-1.432-.716a.3.3 0 0 0-.268 0l-1.432.716A.3.3 0 0 1 7 5.515V2zM4 0h8a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm4 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="question" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm-1.46-5.602h2.233a3.97 3.97 0 0 1 .051-.558c.029-.17.073-.326.133-.469.06-.143.14-.28.242-.41.102-.13.228-.263.38-.399.26-.24.504-.467.733-.683a5.03 5.03 0 0 0 .598-.668c.17-.23.302-.477.399-.742a2.66 2.66 0 0 0 .144-.907c0-.505-.083-.95-.25-1.335a2.55 2.55 0 0 0-.723-.97 3.2 3.2 0 0 0-1.152-.589 5.441 5.441 0 0 0-1.531-.2c-.516 0-.998.063-1.445.188a3.19 3.19 0 0 0-1.168.59c-.331.268-.594.61-.79 1.027-.195.417-.295.917-.3 1.5h2.64c.006-.224.04-.416.102-.578.062-.161.142-.293.238-.394a.921.921 0 0 1 .332-.227 1.04 1.04 0 0 1 .39-.074c.34 0 .593.095.763.285.169.19.254.488.254.895 0 .328-.106.63-.317.906-.21.276-.499.565-.863.867-.214.182-.39.374-.531.574-.141.2-.253.42-.336.657a3.656 3.656 0 0 0-.176.777 7.89 7.89 0 0 0-.05.937zm-.321 2.375c0 .188.035.362.105.524.07.161.17.3.301.418.13.117.284.21.46.277.178.068.376.102.595.102.218 0 .416-.034.593-.102.178-.068.331-.16.461-.277a1.2 1.2 0 0 0 .301-.418c.07-.162.106-.336.106-.524a1.3 1.3 0 0 0-.106-.523 1.2 1.2 0 0 0-.3-.418 1.461 1.461 0 0 0-.462-.277 1.651 1.651 0 0 0-.593-.102c-.22 0-.417.034-.594.102a1.46 1.46 0 0 0-.461.277 1.2 1.2 0 0 0-.3.418 1.284 1.284 0 0 0-.106.523z"/></symbol>
- <symbol viewBox="0 0 16 16" id="question-o" xmlns="http://www.w3.org/2000/svg"><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-.778-4.151c0-.301.014-.575.044-.82a3.2 3.2 0 0 1 .154-.68c.073-.208.17-.4.294-.575.123-.176.278-.343.465-.503a4.81 4.81 0 0 0 .755-.758c.185-.242.277-.506.277-.793 0-.356-.074-.617-.222-.783-.148-.166-.37-.25-.667-.25a.92.92 0 0 0-.342.065.806.806 0 0 0-.29.199 1.04 1.04 0 0 0-.209.345 1.5 1.5 0 0 0-.088.506H5.082c.005-.51.092-.948.263-1.313.171-.364.401-.664.69-.899.29-.234.63-.406 1.023-.516a4.66 4.66 0 0 1 1.264-.164c.497 0 .944.058 1.34.174.397.117.733.289 1.008.517.276.227.487.51.633.847.146.337.218.727.218 1.17 0 .295-.042.56-.126.792a2.52 2.52 0 0 1-.349.65 4.4 4.4 0 0 1-.523.584c-.2.19-.414.389-.642.598a2.73 2.73 0 0 0-.332.349c-.089.114-.16.233-.212.359a1.868 1.868 0 0 0-.116.41 3.39 3.39 0 0 0-.044.489H7.222zm-.28 2.078c0-.164.03-.317.092-.458a1.05 1.05 0 0 1 .263-.366c.114-.103.248-.183.403-.243a1.45 1.45 0 0 1 .52-.089c.191 0 .364.03.52.09.154.059.289.14.403.242.114.103.201.224.263.366.061.141.092.294.092.458 0 .164-.03.316-.092.458a1.05 1.05 0 0 1-.263.365 1.278 1.278 0 0 1-.404.243 1.43 1.43 0 0 1-.52.089c-.19 0-.364-.03-.519-.089-.155-.06-.29-.14-.403-.243a1.05 1.05 0 0 1-.263-.365 1.135 1.135 0 0 1-.093-.458z"/></symbol>
- <symbol viewBox="0 0 16 16" id="quote" xmlns="http://www.w3.org/2000/svg"><path d="M15 3v8a3 3 0 0 1-3 3 1 1 0 0 1 0-2 1 1 0 0 0 1-1V9h-2a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h3a1 1 0 0 1 1 1zM7 3v8a3 3 0 0 1-3 3 1 1 0 0 1 0-2 1 1 0 0 0 1-1V9H3a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h3a1 1 0 0 1 1 1z"/></symbol>
- <symbol viewBox="0 0 16 16" id="redo" xmlns="http://www.w3.org/2000/svg"><path d="M4.666 4.423a5 5 0 1 1-.203 6.944 1 1 0 1 0-1.478 1.347 7 7 0 1 0 .12-9.556L1.842 2.137a.5.5 0 0 0-.815.385L1 7.26a.5.5 0 0 0 .607.492l4.629-1.013a.5.5 0 0 0 .207-.877L4.666 4.423z"/></symbol>
- <symbol viewBox="0 0 16 16" id="remove" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 3a1 1 0 1 1 0-2h12a1 1 0 0 1 0 2v10a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V3zm3-2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1H5zM4 3v10a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3H4zm2.5 2a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5zm3 0a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5z"/></symbol>
- <symbol viewBox="0 0 16 16" id="repeat" xmlns="http://www.w3.org/2000/svg"><path d="M11.494 4.423a5 5 0 1 0 .203 6.944 1 1 0 1 1 1.478 1.347 7 7 0 1 1-.12-9.556l1.262-1.021a.5.5 0 0 1 .815.385l.028 4.738a.5.5 0 0 1-.607.492L9.924 6.739a.5.5 0 0 1-.207-.877l1.777-1.439z"/></symbol>
- <symbol viewBox="0 0 16 16" id="retry" xmlns="http://www.w3.org/2000/svg"><path d="M4.009 6.958a4 4 0 0 0 5.283 4.775 1 1 0 0 1 .712 1.87A6 6 0 0 1 2.077 6.44l-.741-.2a.5.5 0 0 1-.12-.915L3.41 4.058a.5.5 0 0 1 .683.183l1.268 2.196a.5.5 0 0 1-.563.733l-.79-.212zm7.777 2.084a4 4 0 0 0-5.284-4.775 1 1 0 0 1-.711-1.87 6 6 0 0 1 7.927 7.162l.74.2a.5.5 0 0 1 .121.915l-2.196 1.268a.5.5 0 0 1-.683-.183l-1.267-2.196a.5.5 0 0 1 .562-.733l.79.212z"/></symbol>
- <symbol viewBox="0 0 16 16" id="scale" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.99 9a.792.792 0 0 0-.078-.231L13 7l-.912 1.769a.791.791 0 0 0-.077.231h1.978zm-10 0a.792.792 0 0 0-.078-.231L3 7l-.912 1.769A.791.791 0 0 0 2.011 9h1.978zM2 0h12a1 1 0 0 1 0 2H2a1 1 0 1 1 0-2zm3 14h6a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2zM8 4a1 1 0 0 1 1 1v9H7V5a1 1 0 0 1 1-1zm-4.53-.714l2.265 4.735c.68 1.42.006 3.091-1.504 3.73A3.161 3.161 0 0 1 3 12c-1.657 0-3-1.263-3-2.821 0-.4.09-.794.264-1.158L2.53 3.286a.53.53 0 0 1 .94 0zm10 0l2.265 4.735c.68 1.42.006 3.091-1.504 3.73A3.161 3.161 0 0 1 13 12c-1.657 0-3-1.263-3-2.821 0-.4.09-.794.264-1.158l2.266-4.735a.53.53 0 0 1 .94 0z"/></symbol>
- <symbol viewBox="0 0 16 16" id="screen-full" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M14 14v-2a1 1 0 0 1 2 0v3a.997.997 0 0 1-1 1h-3a1 1 0 0 1 0-2h2zM2 14v-2a1 1 0 0 0-2 0v3a1 1 0 0 0 1 1h3a1 1 0 0 0 0-2H2zM15.707.293A.997.997 0 0 1 16 1v3a1 1 0 0 1-2 0V2h-2a1 1 0 0 1 0-2h3c.276 0 .526.112.707.293zM2 2v2a1 1 0 1 1-2 0V1a.997.997 0 0 1 1-1h3a1 1 0 1 1 0 2H2zm4 4h4a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/></symbol>
- <symbol viewBox="0 0 16 16" id="screen-normal" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 3V1a1 1 0 1 1 2 0v3a.997.997 0 0 1-1 1H1a1 1 0 1 1 0-2h2zm10 0h2a1 1 0 0 1 0 2h-3a.997.997 0 0 1-1-1V1a1 1 0 0 1 2 0v2zM3 13H1a1 1 0 0 1 0-2h3a.997.997 0 0 1 1 1v3a1 1 0 0 1-2 0v-2zm10 0v2a1 1 0 0 1-2 0v-3a.997.997 0 0 1 1-1h3a1 1 0 0 1 0 2h-2zM6.5 7h3a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5z"/></symbol>
- <symbol viewBox="0 0 16 16" id="search" xmlns="http://www.w3.org/2000/svg"><path d="M8.853 8.854a3.5 3.5 0 1 0-4.95-4.95 3.5 3.5 0 0 0 4.95 4.95zm.207 2.328a5.5 5.5 0 1 1 2.121-2.121l3.329 3.328a1.5 1.5 0 0 1-2.121 2.121L9.06 11.182z"/></symbol>
- <symbol viewBox="0 0 16 16" id="settings" xmlns="http://www.w3.org/2000/svg"><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.918a5.97 5.97 0 0 1 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"/></symbol>
- <symbol viewBox="0 0 16 16" id="shield" xmlns="http://www.w3.org/2000/svg"><path d="M4 0h8a3 3 0 0 1 3 3v7.186a3 3 0 0 1-1.426 2.554l-4 2.465a3 3 0 0 1-3.148 0l-4-2.465A3 3 0 0 1 1 10.186V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v7.186a1 1 0 0 0 .475.852l4 2.464a1 1 0 0 0 1.05 0l4-2.464a1 1 0 0 0 .475-.852V3a1 1 0 0 0-1-1H4zm0 1.5a.5.5 0 0 1 .5-.5h4v8.837a.5.5 0 0 1-.753.431l-3.5-2.052A.5.5 0 0 1 4 9.785V3.5z"/></symbol>
- <symbol viewBox="0 0 16 16" id="slight-frown" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="slight-smile" xmlns="http://www.w3.org/2000/svg"><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 12zM5 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 2zm-5.163 2.254a.5.5 0 1 1 .865-.502 1.499 1.499 0 0 0 2.607-.018.5.5 0 1 1 .871.49 2.499 2.499 0 0 1-4.343.03z"/></symbol>
- <symbol viewBox="0 0 16 16" id="smile" xmlns="http://www.w3.org/2000/svg"><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 12zM6.18 6.27a.5.5 0 0 1-.873.487.5.5 0 0 0-.872-.003.5.5 0 1 1-.87-.495 1.5 1.5 0 0 1 2.616.012zm6 0a.5.5 0 1 1-.873.487.5.5 0 0 0-.872-.003.5.5 0 1 1-.87-.495 1.5 1.5 0 0 1 2.616.012zM5 9a3 3 0 0 0 6 0H5z"/></symbol>
- <symbol viewBox="0 0 16 16" id="smiley" xmlns="http://www.w3.org/2000/svg"><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 12zM5 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 2zM5 9h6a3 3 0 0 1-6 0z"/></symbol>
- <symbol viewBox="0 0 16 16" id="snippet" xmlns="http://www.w3.org/2000/svg"><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 0 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 0 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"/></symbol>
- <symbol viewBox="0 0 16 16" id="spam" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="star" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.609 14.394l-3.465 1.473a1 1 0 0 1-1.39-.989l.276-4.024a1 1 0 0 0-.219-.694L.303 7.037A1 1 0 0 1 .83 5.443l3.715-.964a1 1 0 0 0 .609-.457L7.14.682a1 1 0 0 1 1.72 0l1.985 3.34a1 1 0 0 0 .609.457l3.715.964a1 1 0 0 1 .528 1.594L13.19 10.16a1 1 0 0 0-.219.694l.275 4.024a1 1 0 0 1-1.389.989l-3.465-1.473a1 1 0 0 0-.782 0z"/></symbol>
- <symbol viewBox="0 0 16 16" id="star-o" xmlns="http://www.w3.org/2000/svg"><path d="M10.975 10.99a3 3 0 0 1 .655-2.083l1.54-1.916-2.219-.576a3 3 0 0 1-1.825-1.37L8 3.15 6.874 5.044a3 3 0 0 1-1.825 1.371l-2.218.576 1.54 1.916a3 3 0 0 1 .654 2.083l-.165 2.4 1.965-.836a3 3 0 0 1 2.348 0l1.965.836-.164-2.399zM7.61 14.394l-3.465 1.473a1 1 0 0 1-1.39-.989l.276-4.024a1 1 0 0 0-.219-.694L.303 7.037A1 1 0 0 1 .83 5.443l3.715-.964a1 1 0 0 0 .609-.457L7.14.682a1 1 0 0 1 1.72 0l1.985 3.34a1 1 0 0 0 .609.457l3.715.964a1 1 0 0 1 .528 1.594L13.19 10.16a1 1 0 0 0-.219.694l.275 4.024a1 1 0 0 1-1.389.989l-3.465-1.473a1 1 0 0 0-.782 0z"/></symbol>
- <symbol viewBox="0 0 16 16" id="stop" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 0h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="talic" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6 0h7a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2zm2 2h3L8 14H5L8 2zM3 14h7a1 1 0 0 1 0 2H3a1 1 0 0 1 0-2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="task-done" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="template" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="thump-down" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.33 11h5.282a2 2 0 0 0 1.963-2.38l-.563-2.905a3 3 0 0 0-.243-.732l-1.103-2.286A3 3 0 0 0 10.964 1H7a3 3 0 0 0-3 3v6.3a2 2 0 0 0 .436 1.247l3.11 3.9a.632.632 0 0 0 .941.053l.137-.137a1 1 0 0 0 .28-.87L8.329 11zM1 10h2V3H1a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1z"/></symbol>
- <symbol viewBox="0 0 16 16" id="thump-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.33 5h5.282a2 2 0 0 1 1.963 2.38l-.563 2.905a3 3 0 0 1-.243.732l-1.103 2.286A3 3 0 0 1 10.964 15H7a3 3 0 0 1-3-3V5.7a2 2 0 0 1 .436-1.247l3.11-3.9A.632.632 0 0 1 8.487.5l.137.137a1 1 0 0 1 .28.87L8.329 5zM1 6h2v7H1a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/></symbol>
- <symbol viewBox="0 0 16 16" id="timer" xmlns="http://www.w3.org/2000/svg"><path d="M12.022 3.27l.77-.77a1 1 0 0 1 1.415 1.414l-.728.729a7 7 0 1 1-1.456-1.372zM8 14A5 5 0 1 0 8 4a5 5 0 0 0 0 10zm0-9a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1zM6 0h4a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="todo-add" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10 4V2a1 1 0 0 1 2 0v2h2a1 1 0 0 1 0 2h-2v2a1 1 0 0 1-2 0V6H8a1 1 0 1 1 0-2h2zm2 7a1 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"/></symbol>
- <symbol viewBox="0 0 16 16" id="todo-done" xmlns="http://www.w3.org/2000/svg"><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 0L4.707 6.778a1 1 0 0 1 1.414-1.414l2.122 2.121zM12 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"/></symbol>
- <symbol viewBox="0 0 16 16" id="token" xmlns="http://www.w3.org/2000/svg"><path d="M3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3zm1 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="unapproval" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.95 8.536l1.06-1.061a1 1 0 0 1 1.415 1.414l-1.061 1.06 1.06 1.061a1 1 0 0 1-1.414 1.415l-1.06-1.061-1.06 1.06a1 1 0 1 1-1.415-1.414l1.06-1.06-1.06-1.06a1 1 0 0 1 1.414-1.415l1.06 1.06zm-3.768-.33c.006.503.201 1.006.586 1.39l.353.354-.353.353a2 2 0 1 0 2.828 2.829l.354-.354.047.048C11.964 14.363 11.527 15 6 15c-5.924 0-6-.78-6-2.52S.964 8 6 8c.834 0 1.557.074 2.182.205zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></symbol>
- <symbol viewBox="0 0 16 16" id="unassignee" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11 5h4a1 1 0 0 1 0 2h-4a1 1 0 0 1 0-2zM5.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"/></symbol>
- <symbol viewBox="0 0 16 16" id="unlink" xmlns="http://www.w3.org/2000/svg"><path d="M11.295 8.845l-.659-1.664a1.78 1.78 0 0 0 .04-.04l1.415-1.414c.586-.586.654-1.468.152-1.97s-1.384-.434-1.97.152L8.859 5.323a1.781 1.781 0 0 0-.04.04l-1.664-.658c.141-.208.305-.408.491-.594l1.415-1.414c1.366-1.367 3.424-1.525 4.596-.354 1.171 1.172 1.013 3.23-.354 4.596L11.89 8.354c-.186.186-.386.35-.594.491zm-2.45 2.45a4.075 4.075 0 0 1-.491.594l-1.415 1.414c-1.366 1.367-3.424 1.525-4.596.354-1.171-1.172-1.013-3.23.354-4.596L4.11 7.646c.186-.186.386-.35.594-.491l.659 1.664a1.781 1.781 0 0 0-.04.04l-1.415 1.414c-.586.586-.654 1.468-.152 1.97s1.384.434 1.97-.152l1.414-1.414a1.78 1.78 0 0 0 .04-.04l1.664.658zm3.812-2.088h2a.5.5 0 0 1 .5.5v.05a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-.05a.5.5 0 0 1 .5-.5zm-.384 2.116l1.415 1.414a.5.5 0 0 1 0 .708l-.037.036a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 0-.707l.036-.037a.5.5 0 0 1 .707 0zm-2.823 1.09a.5.5 0 0 1 .5-.5h.052a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H9.95a.5.5 0 0 1-.5-.5v-2zm-2.748-9.16a.5.5 0 0 1-.5.5h-.05a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5h.05a.5.5 0 0 1 .5.5v2zm-2.116.383a.5.5 0 0 1 0 .707l-.036.036a.5.5 0 0 1-.707 0L2.428 2.965a.5.5 0 0 1 0-.707l.037-.036a.5.5 0 0 1 .707 0l1.414 1.414zm-1.09 2.823h-2a.5.5 0 0 1-.5-.5v-.051a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v.05a.5.5 0 0 1-.5.5z"/></symbol>
- <symbol viewBox="0 0 16 16" id="user" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 8c-6.888 0-6.976-.78-6.976-2.52S2.144 8 8 8s6.976 2.692 6.976 4.48c0 1.788-.088 2.52-6.976 2.52z"/></symbol>
- <symbol viewBox="0 0 16 16" id="users" xmlns="http://www.w3.org/2000/svg"><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"/></symbol>
- <symbol viewBox="0 0 16 16" id="volume-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1 5h1v6H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zm2 0l4.445-2.964A1 1 0 0 1 9 2.87v10.26a1 1 0 0 1-1.555.833L3 11V5zm10.283 7.89a.5.5 0 0 1-.66-.752A5.485 5.485 0 0 0 14.5 8c0-1.601-.687-3.09-1.865-4.128a.5.5 0 0 1 .661-.75A6.484 6.484 0 0 1 15.5 8a6.485 6.485 0 0 1-2.217 4.89zm-2.002-2.236a.5.5 0 1 1-.652-.758c.55-.472.871-1.157.871-1.896 0-.732-.315-1.411-.856-1.883a.5.5 0 0 1 .658-.753A3.492 3.492 0 0 1 12.5 8c0 1.033-.45 1.994-1.219 2.654z"/></symbol>
- <symbol viewBox="0 0 16 16" id="warning" xmlns="http://www.w3.org/2000/svg"><path d="M15.34 10.479A3 3 0 0 1 12.756 15h-9.51A3 3 0 0 1 .66 10.479l4.755-8.083a3 3 0 0 1 5.172 0l4.755 8.083zm-6.478-7.07a1 1 0 0 0-1.724 0l-4.755 8.084A1 1 0 0 0 3.245 13h9.51a1 1 0 0 0 .862-1.507L8.862 3.41zM8 5a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol>
- <symbol viewBox="0 0 16 16" id="work" xmlns="http://www.w3.org/2000/svg"><path d="M12 3h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h1V2a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1zM6 2v1h4V2H6zM3 5a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H3zm1.5 1a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5zm7 0a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5z"/></symbol>
-</svg>
-
-<!--
-====================================================================================================
--->
-
- <header>
- <h1>SVG <code>&lt;symbol&gt;</code> sprite preview</h1>
- <p>This preview features two methods of using the generated sprite in conjunction with inline SVG. Please have a look at the HTML source for further details and be aware of the following constraints:</p>
- <ul>
- <li>Your browser has to <a href="http://caniuse.com/#feat=svg-html5" target="_blank">support inline SVG</a> for these techniques to work.</li>
- <li>The embedded sprite (A) slightly differs from the generated external one. Please <a href="https://github.com/jkphl/svg-sprite/blob/master/docs/configuration.md#defs--symbol-mode" target="_blank">see the documentation</a> for details on how to create such an embeddable sprite.</li>
- <li>Internet Explorer up to version 11 doesn't support external sprites for use with inline SVG. For IE 9-11, you may polyfill this functionality with <a href="https://github.com/jonathantneal/svg4everybody" target="_blank">SVG for Everybody</a>.</li>
- </ul>
- </header>
- <section>
-
-<!--
-
-A) Inline SVG with embedded sprite
-====================================================================================================
-These SVG images make use of fragment identifiers (IDs) and are extracted out of the inline sprite
-embedded above. They may be styled via CSS.
-
--->
-
- <h3>A) Inline SVG with embedded sprite</h3>
- <ul>
-
- <li title="abuse">
- <div class="icon-box">
-
- <!-- abuse -->
- <svg class="svg-abuse-dims">
- <use xlink:href="#abuse"></use>
- </svg>
-
- </div>
- <h2>abuse</h2>
- </li>
- <li title="account">
- <div class="icon-box">
-
- <!-- account -->
- <svg class="svg-account-dims">
- <use xlink:href="#account"></use>
- </svg>
-
- </div>
- <h2>account</h2>
- </li>
- <li title="admin">
- <div class="icon-box">
-
- <!-- admin -->
- <svg class="svg-admin-dims">
- <use xlink:href="#admin"></use>
- </svg>
-
- </div>
- <h2>admin</h2>
- </li>
- <li title="angle-double-left">
- <div class="icon-box">
-
- <!-- angle-double-left -->
- <svg class="svg-angle-double-left-dims">
- <use xlink:href="#angle-double-left"></use>
- </svg>
-
- </div>
- <h2>angle-double-left</h2>
- </li>
- <li title="angle-down">
- <div class="icon-box">
-
- <!-- angle-down -->
- <svg class="svg-angle-down-dims">
- <use xlink:href="#angle-down"></use>
- </svg>
-
- </div>
- <h2>angle-down</h2>
- </li>
- <li title="angle-left">
- <div class="icon-box">
-
- <!-- angle-left -->
- <svg class="svg-angle-left-dims">
- <use xlink:href="#angle-left"></use>
- </svg>
-
- </div>
- <h2>angle-left</h2>
- </li>
- <li title="angle-right">
- <div class="icon-box">
-
- <!-- angle-right -->
- <svg class="svg-angle-right-dims">
- <use xlink:href="#angle-right"></use>
- </svg>
-
- </div>
- <h2>angle-right</h2>
- </li>
- <li title="angle-up">
- <div class="icon-box">
-
- <!-- angle-up -->
- <svg class="svg-angle-up-dims">
- <use xlink:href="#angle-up"></use>
- </svg>
-
- </div>
- <h2>angle-up</h2>
- </li>
- <li title="appearance">
- <div class="icon-box">
-
- <!-- appearance -->
- <svg class="svg-appearance-dims">
- <use xlink:href="#appearance"></use>
- </svg>
-
- </div>
- <h2>appearance</h2>
- </li>
- <li title="applications">
- <div class="icon-box">
-
- <!-- applications -->
- <svg class="svg-applications-dims">
- <use xlink:href="#applications"></use>
- </svg>
-
- </div>
- <h2>applications</h2>
- </li>
- <li title="approval">
- <div class="icon-box">
-
- <!-- approval -->
- <svg class="svg-approval-dims">
- <use xlink:href="#approval"></use>
- </svg>
-
- </div>
- <h2>approval</h2>
- </li>
- <li title="arrow-right">
- <div class="icon-box">
-
- <!-- arrow-right -->
- <svg class="svg-arrow-right-dims">
- <use xlink:href="#arrow-right"></use>
- </svg>
-
- </div>
- <h2>arrow-right</h2>
- </li>
- <li title="assignee">
- <div class="icon-box">
-
- <!-- assignee -->
- <svg class="svg-assignee-dims">
- <use xlink:href="#assignee"></use>
- </svg>
-
- </div>
- <h2>assignee</h2>
- </li>
- <li title="bold">
- <div class="icon-box">
-
- <!-- bold -->
- <svg class="svg-bold-dims">
- <use xlink:href="#bold"></use>
- </svg>
-
- </div>
- <h2>bold</h2>
- </li>
- <li title="book">
- <div class="icon-box">
-
- <!-- book -->
- <svg class="svg-book-dims">
- <use xlink:href="#book"></use>
- </svg>
-
- </div>
- <h2>book</h2>
- </li>
- <li title="branch">
- <div class="icon-box">
-
- <!-- branch -->
- <svg class="svg-branch-dims">
- <use xlink:href="#branch"></use>
- </svg>
-
- </div>
- <h2>branch</h2>
- </li>
- <li title="calendar">
- <div class="icon-box">
-
- <!-- calendar -->
- <svg class="svg-calendar-dims">
- <use xlink:href="#calendar"></use>
- </svg>
-
- </div>
- <h2>calendar</h2>
- </li>
- <li title="cancel">
- <div class="icon-box">
-
- <!-- cancel -->
- <svg class="svg-cancel-dims">
- <use xlink:href="#cancel"></use>
- </svg>
-
- </div>
- <h2>cancel</h2>
- </li>
- <li title="chevron-down">
- <div class="icon-box">
-
- <!-- chevron-down -->
- <svg class="svg-chevron-down-dims">
- <use xlink:href="#chevron-down"></use>
- </svg>
-
- </div>
- <h2>chevron-down</h2>
- </li>
- <li title="chevron-left">
- <div class="icon-box">
-
- <!-- chevron-left -->
- <svg class="svg-chevron-left-dims">
- <use xlink:href="#chevron-left"></use>
- </svg>
-
- </div>
- <h2>chevron-left</h2>
- </li>
- <li title="chevron-right">
- <div class="icon-box">
-
- <!-- chevron-right -->
- <svg class="svg-chevron-right-dims">
- <use xlink:href="#chevron-right"></use>
- </svg>
-
- </div>
- <h2>chevron-right</h2>
- </li>
- <li title="chevron-up">
- <div class="icon-box">
-
- <!-- chevron-up -->
- <svg class="svg-chevron-up-dims">
- <use xlink:href="#chevron-up"></use>
- </svg>
-
- </div>
- <h2>chevron-up</h2>
- </li>
- <li title="clock">
- <div class="icon-box">
-
- <!-- clock -->
- <svg class="svg-clock-dims">
- <use xlink:href="#clock"></use>
- </svg>
-
- </div>
- <h2>clock</h2>
- </li>
- <li title="code">
- <div class="icon-box">
-
- <!-- code -->
- <svg class="svg-code-dims">
- <use xlink:href="#code"></use>
- </svg>
-
- </div>
- <h2>code</h2>
- </li>
- <li title="comment">
- <div class="icon-box">
-
- <!-- comment -->
- <svg class="svg-comment-dims">
- <use xlink:href="#comment"></use>
- </svg>
-
- </div>
- <h2>comment</h2>
- </li>
- <li title="comment-dots">
- <div class="icon-box">
-
- <!-- comment-dots -->
- <svg class="svg-comment-dots-dims">
- <use xlink:href="#comment-dots"></use>
- </svg>
-
- </div>
- <h2>comment-dots</h2>
- </li>
- <li title="comment-next">
- <div class="icon-box">
-
- <!-- comment-next -->
- <svg class="svg-comment-next-dims">
- <use xlink:href="#comment-next"></use>
- </svg>
-
- </div>
- <h2>comment-next</h2>
- </li>
- <li title="comments">
- <div class="icon-box">
-
- <!-- comments -->
- <svg class="svg-comments-dims">
- <use xlink:href="#comments"></use>
- </svg>
-
- </div>
- <h2>comments</h2>
- </li>
- <li title="commit">
- <div class="icon-box">
-
- <!-- commit -->
- <svg class="svg-commit-dims">
- <use xlink:href="#commit"></use>
- </svg>
-
- </div>
- <h2>commit</h2>
- </li>
- <li title="credit-card">
- <div class="icon-box">
-
- <!-- credit-card -->
- <svg class="svg-credit-card-dims">
- <use xlink:href="#credit-card"></use>
- </svg>
-
- </div>
- <h2>credit-card</h2>
- </li>
- <li title="disk">
- <div class="icon-box">
-
- <!-- disk -->
- <svg class="svg-disk-dims">
- <use xlink:href="#disk"></use>
- </svg>
-
- </div>
- <h2>disk</h2>
- </li>
- <li title="doc_code">
- <div class="icon-box">
-
- <!-- doc_code -->
- <svg class="svg-doc_code-dims">
- <use xlink:href="#doc_code"></use>
- </svg>
-
- </div>
- <h2>doc_code</h2>
- </li>
- <li title="doc_image">
- <div class="icon-box">
-
- <!-- doc_image -->
- <svg class="svg-doc_image-dims">
- <use xlink:href="#doc_image"></use>
- </svg>
-
- </div>
- <h2>doc_image</h2>
- </li>
- <li title="doc_text">
- <div class="icon-box">
-
- <!-- doc_text -->
- <svg class="svg-doc_text-dims">
- <use xlink:href="#doc_text"></use>
- </svg>
-
- </div>
- <h2>doc_text</h2>
- </li>
- <li title="download">
- <div class="icon-box">
-
- <!-- download -->
- <svg class="svg-download-dims">
- <use xlink:href="#download"></use>
- </svg>
-
- </div>
- <h2>download</h2>
- </li>
- <li title="duplicate">
- <div class="icon-box">
-
- <!-- duplicate -->
- <svg class="svg-duplicate-dims">
- <use xlink:href="#duplicate"></use>
- </svg>
-
- </div>
- <h2>duplicate</h2>
- </li>
- <li title="earth">
- <div class="icon-box">
-
- <!-- earth -->
- <svg class="svg-earth-dims">
- <use xlink:href="#earth"></use>
- </svg>
-
- </div>
- <h2>earth</h2>
- </li>
- <li title="eye">
- <div class="icon-box">
-
- <!-- eye -->
- <svg class="svg-eye-dims">
- <use xlink:href="#eye"></use>
- </svg>
-
- </div>
- <h2>eye</h2>
- </li>
- <li title="eye-slash">
- <div class="icon-box">
-
- <!-- eye-slash -->
- <svg class="svg-eye-slash-dims">
- <use xlink:href="#eye-slash"></use>
- </svg>
-
- </div>
- <h2>eye-slash</h2>
- </li>
- <li title="file-additions">
- <div class="icon-box">
-
- <!-- file-additions -->
- <svg class="svg-file-additions-dims">
- <use xlink:href="#file-additions"></use>
- </svg>
-
- </div>
- <h2>file-additions</h2>
- </li>
- <li title="file-deletion">
- <div class="icon-box">
-
- <!-- file-deletion -->
- <svg class="svg-file-deletion-dims">
- <use xlink:href="#file-deletion"></use>
- </svg>
-
- </div>
- <h2>file-deletion</h2>
- </li>
- <li title="file-modified">
- <div class="icon-box">
-
- <!-- file-modified -->
- <svg class="svg-file-modified-dims">
- <use xlink:href="#file-modified"></use>
- </svg>
-
- </div>
- <h2>file-modified</h2>
- </li>
- <li title="filter">
- <div class="icon-box">
-
- <!-- filter -->
- <svg class="svg-filter-dims">
- <use xlink:href="#filter"></use>
- </svg>
-
- </div>
- <h2>filter</h2>
- </li>
- <li title="folder">
- <div class="icon-box">
-
- <!-- folder -->
- <svg class="svg-folder-dims">
- <use xlink:href="#folder"></use>
- </svg>
-
- </div>
- <h2>folder</h2>
- </li>
- <li title="fork">
- <div class="icon-box">
-
- <!-- fork -->
- <svg class="svg-fork-dims">
- <use xlink:href="#fork"></use>
- </svg>
-
- </div>
- <h2>fork</h2>
- </li>
- <li title="git-merge">
- <div class="icon-box">
-
- <!-- git-merge -->
- <svg class="svg-git-merge-dims">
- <use xlink:href="#git-merge"></use>
- </svg>
-
- </div>
- <h2>git-merge</h2>
- </li>
- <li title="group">
- <div class="icon-box">
-
- <!-- group -->
- <svg class="svg-group-dims">
- <use xlink:href="#group"></use>
- </svg>
-
- </div>
- <h2>group</h2>
- </li>
- <li title="history">
- <div class="icon-box">
-
- <!-- history -->
- <svg class="svg-history-dims">
- <use xlink:href="#history"></use>
- </svg>
-
- </div>
- <h2>history</h2>
- </li>
- <li title="home">
- <div class="icon-box">
-
- <!-- home -->
- <svg class="svg-home-dims">
- <use xlink:href="#home"></use>
- </svg>
-
- </div>
- <h2>home</h2>
- </li>
- <li title="hook">
- <div class="icon-box">
-
- <!-- hook -->
- <svg class="svg-hook-dims">
- <use xlink:href="#hook"></use>
- </svg>
-
- </div>
- <h2>hook</h2>
- </li>
- <li title="issue-block">
- <div class="icon-box">
-
- <!-- issue-block -->
- <svg class="svg-issue-block-dims">
- <use xlink:href="#issue-block"></use>
- </svg>
-
- </div>
- <h2>issue-block</h2>
- </li>
- <li title="issue-child">
- <div class="icon-box">
-
- <!-- issue-child -->
- <svg class="svg-issue-child-dims">
- <use xlink:href="#issue-child"></use>
- </svg>
-
- </div>
- <h2>issue-child</h2>
- </li>
- <li title="issue-close">
- <div class="icon-box">
-
- <!-- issue-close -->
- <svg class="svg-issue-close-dims">
- <use xlink:href="#issue-close"></use>
- </svg>
-
- </div>
- <h2>issue-close</h2>
- </li>
- <li title="issue-duplicate">
- <div class="icon-box">
-
- <!-- issue-duplicate -->
- <svg class="svg-issue-duplicate-dims">
- <use xlink:href="#issue-duplicate"></use>
- </svg>
-
- </div>
- <h2>issue-duplicate</h2>
- </li>
- <li title="issue-new">
- <div class="icon-box">
-
- <!-- issue-new -->
- <svg class="svg-issue-new-dims">
- <use xlink:href="#issue-new"></use>
- </svg>
-
- </div>
- <h2>issue-new</h2>
- </li>
- <li title="issue-open">
- <div class="icon-box">
-
- <!-- issue-open -->
- <svg class="svg-issue-open-dims">
- <use xlink:href="#issue-open"></use>
- </svg>
-
- </div>
- <h2>issue-open</h2>
- </li>
- <li title="issue-open-m">
- <div class="icon-box">
-
- <!-- issue-open-m -->
- <svg class="svg-issue-open-m-dims">
- <use xlink:href="#issue-open-m"></use>
- </svg>
-
- </div>
- <h2>issue-open-m</h2>
- </li>
- <li title="issue-parent">
- <div class="icon-box">
-
- <!-- issue-parent -->
- <svg class="svg-issue-parent-dims">
- <use xlink:href="#issue-parent"></use>
- </svg>
-
- </div>
- <h2>issue-parent</h2>
- </li>
- <li title="issues">
- <div class="icon-box">
-
- <!-- issues -->
- <svg class="svg-issues-dims">
- <use xlink:href="#issues"></use>
- </svg>
-
- </div>
- <h2>issues</h2>
- </li>
- <li title="key">
- <div class="icon-box">
-
- <!-- key -->
- <svg class="svg-key-dims">
- <use xlink:href="#key"></use>
- </svg>
-
- </div>
- <h2>key</h2>
- </li>
- <li title="key-2">
- <div class="icon-box">
-
- <!-- key-2 -->
- <svg class="svg-key-2-dims">
- <use xlink:href="#key-2"></use>
- </svg>
-
- </div>
- <h2>key-2</h2>
- </li>
- <li title="label">
- <div class="icon-box">
-
- <!-- label -->
- <svg class="svg-label-dims">
- <use xlink:href="#label"></use>
- </svg>
-
- </div>
- <h2>label</h2>
- </li>
- <li title="labels">
- <div class="icon-box">
-
- <!-- labels -->
- <svg class="svg-labels-dims">
- <use xlink:href="#labels"></use>
- </svg>
-
- </div>
- <h2>labels</h2>
- </li>
- <li title="leave">
- <div class="icon-box">
-
- <!-- leave -->
- <svg class="svg-leave-dims">
- <use xlink:href="#leave"></use>
- </svg>
-
- </div>
- <h2>leave</h2>
- </li>
- <li title="level-up">
- <div class="icon-box">
-
- <!-- level-up -->
- <svg class="svg-level-up-dims">
- <use xlink:href="#level-up"></use>
- </svg>
-
- </div>
- <h2>level-up</h2>
- </li>
- <li title="license">
- <div class="icon-box">
-
- <!-- license -->
- <svg class="svg-license-dims">
- <use xlink:href="#license"></use>
- </svg>
-
- </div>
- <h2>license</h2>
- </li>
- <li title="link">
- <div class="icon-box">
-
- <!-- link -->
- <svg class="svg-link-dims">
- <use xlink:href="#link"></use>
- </svg>
-
- </div>
- <h2>link</h2>
- </li>
- <li title="list-bulleted">
- <div class="icon-box">
-
- <!-- list-bulleted -->
- <svg class="svg-list-bulleted-dims">
- <use xlink:href="#list-bulleted"></use>
- </svg>
-
- </div>
- <h2>list-bulleted</h2>
- </li>
- <li title="list-numbered">
- <div class="icon-box">
-
- <!-- list-numbered -->
- <svg class="svg-list-numbered-dims">
- <use xlink:href="#list-numbered"></use>
- </svg>
-
- </div>
- <h2>list-numbered</h2>
- </li>
- <li title="location">
- <div class="icon-box">
-
- <!-- location -->
- <svg class="svg-location-dims">
- <use xlink:href="#location"></use>
- </svg>
-
- </div>
- <h2>location</h2>
- </li>
- <li title="location-dot">
- <div class="icon-box">
-
- <!-- location-dot -->
- <svg class="svg-location-dot-dims">
- <use xlink:href="#location-dot"></use>
- </svg>
-
- </div>
- <h2>location-dot</h2>
- </li>
- <li title="lock">
- <div class="icon-box">
-
- <!-- lock -->
- <svg class="svg-lock-dims">
- <use xlink:href="#lock"></use>
- </svg>
-
- </div>
- <h2>lock</h2>
- </li>
- <li title="lock-open">
- <div class="icon-box">
-
- <!-- lock-open -->
- <svg class="svg-lock-open-dims">
- <use xlink:href="#lock-open"></use>
- </svg>
-
- </div>
- <h2>lock-open</h2>
- </li>
- <li title="log">
- <div class="icon-box">
-
- <!-- log -->
- <svg class="svg-log-dims">
- <use xlink:href="#log"></use>
- </svg>
-
- </div>
- <h2>log</h2>
- </li>
- <li title="mail">
- <div class="icon-box">
-
- <!-- mail -->
- <svg class="svg-mail-dims">
- <use xlink:href="#mail"></use>
- </svg>
-
- </div>
- <h2>mail</h2>
- </li>
- <li title="merge-request-close">
- <div class="icon-box">
-
- <!-- merge-request-close -->
- <svg class="svg-merge-request-close-dims">
- <use xlink:href="#merge-request-close"></use>
- </svg>
-
- </div>
- <h2>merge-request-close</h2>
- </li>
- <li title="merge-request-close-m">
- <div class="icon-box">
-
- <!-- merge-request-close-m -->
- <svg class="svg-merge-request-close-m-dims">
- <use xlink:href="#merge-request-close-m"></use>
- </svg>
-
- </div>
- <h2>merge-request-close-m</h2>
- </li>
- <li title="messages">
- <div class="icon-box">
-
- <!-- messages -->
- <svg class="svg-messages-dims">
- <use xlink:href="#messages"></use>
- </svg>
-
- </div>
- <h2>messages</h2>
- </li>
- <li title="mobile-issue-close">
- <div class="icon-box">
-
- <!-- mobile-issue-close -->
- <svg class="svg-mobile-issue-close-dims">
- <use xlink:href="#mobile-issue-close"></use>
- </svg>
-
- </div>
- <h2>mobile-issue-close</h2>
- </li>
- <li title="monitor">
- <div class="icon-box">
-
- <!-- monitor -->
- <svg class="svg-monitor-dims">
- <use xlink:href="#monitor"></use>
- </svg>
-
- </div>
- <h2>monitor</h2>
- </li>
- <li title="more">
- <div class="icon-box">
-
- <!-- more -->
- <svg class="svg-more-dims">
- <use xlink:href="#more"></use>
- </svg>
-
- </div>
- <h2>more</h2>
- </li>
- <li title="notifications">
- <div class="icon-box">
-
- <!-- notifications -->
- <svg class="svg-notifications-dims">
- <use xlink:href="#notifications"></use>
- </svg>
-
- </div>
- <h2>notifications</h2>
- </li>
- <li title="notifications-off">
- <div class="icon-box">
-
- <!-- notifications-off -->
- <svg class="svg-notifications-off-dims">
- <use xlink:href="#notifications-off"></use>
- </svg>
-
- </div>
- <h2>notifications-off</h2>
- </li>
- <li title="overview">
- <div class="icon-box">
-
- <!-- overview -->
- <svg class="svg-overview-dims">
- <use xlink:href="#overview"></use>
- </svg>
-
- </div>
- <h2>overview</h2>
- </li>
- <li title="pencil">
- <div class="icon-box">
-
- <!-- pencil -->
- <svg class="svg-pencil-dims">
- <use xlink:href="#pencil"></use>
- </svg>
-
- </div>
- <h2>pencil</h2>
- </li>
- <li title="pipeline">
- <div class="icon-box">
-
- <!-- pipeline -->
- <svg class="svg-pipeline-dims">
- <use xlink:href="#pipeline"></use>
- </svg>
-
- </div>
- <h2>pipeline</h2>
- </li>
- <li title="play">
- <div class="icon-box">
-
- <!-- play -->
- <svg class="svg-play-dims">
- <use xlink:href="#play"></use>
- </svg>
-
- </div>
- <h2>play</h2>
- </li>
- <li title="plus">
- <div class="icon-box">
-
- <!-- plus -->
- <svg class="svg-plus-dims">
- <use xlink:href="#plus"></use>
- </svg>
-
- </div>
- <h2>plus</h2>
- </li>
- <li title="plus-square">
- <div class="icon-box">
-
- <!-- plus-square -->
- <svg class="svg-plus-square-dims">
- <use xlink:href="#plus-square"></use>
- </svg>
-
- </div>
- <h2>plus-square</h2>
- </li>
- <li title="plus-square-o">
- <div class="icon-box">
-
- <!-- plus-square-o -->
- <svg class="svg-plus-square-o-dims">
- <use xlink:href="#plus-square-o"></use>
- </svg>
-
- </div>
- <h2>plus-square-o</h2>
- </li>
- <li title="preferences">
- <div class="icon-box">
-
- <!-- preferences -->
- <svg class="svg-preferences-dims">
- <use xlink:href="#preferences"></use>
- </svg>
-
- </div>
- <h2>preferences</h2>
- </li>
- <li title="profile">
- <div class="icon-box">
-
- <!-- profile -->
- <svg class="svg-profile-dims">
- <use xlink:href="#profile"></use>
- </svg>
-
- </div>
- <h2>profile</h2>
- </li>
- <li title="project">
- <div class="icon-box">
-
- <!-- project -->
- <svg class="svg-project-dims">
- <use xlink:href="#project"></use>
- </svg>
-
- </div>
- <h2>project</h2>
- </li>
- <li title="push-rules">
- <div class="icon-box">
-
- <!-- push-rules -->
- <svg class="svg-push-rules-dims">
- <use xlink:href="#push-rules"></use>
- </svg>
-
- </div>
- <h2>push-rules</h2>
- </li>
- <li title="question">
- <div class="icon-box">
-
- <!-- question -->
- <svg class="svg-question-dims">
- <use xlink:href="#question"></use>
- </svg>
-
- </div>
- <h2>question</h2>
- </li>
- <li title="question-o">
- <div class="icon-box">
-
- <!-- question-o -->
- <svg class="svg-question-o-dims">
- <use xlink:href="#question-o"></use>
- </svg>
-
- </div>
- <h2>question-o</h2>
- </li>
- <li title="quote">
- <div class="icon-box">
-
- <!-- quote -->
- <svg class="svg-quote-dims">
- <use xlink:href="#quote"></use>
- </svg>
-
- </div>
- <h2>quote</h2>
- </li>
- <li title="redo">
- <div class="icon-box">
-
- <!-- redo -->
- <svg class="svg-redo-dims">
- <use xlink:href="#redo"></use>
- </svg>
-
- </div>
- <h2>redo</h2>
- </li>
- <li title="remove">
- <div class="icon-box">
-
- <!-- remove -->
- <svg class="svg-remove-dims">
- <use xlink:href="#remove"></use>
- </svg>
-
- </div>
- <h2>remove</h2>
- </li>
- <li title="repeat">
- <div class="icon-box">
-
- <!-- repeat -->
- <svg class="svg-repeat-dims">
- <use xlink:href="#repeat"></use>
- </svg>
-
- </div>
- <h2>repeat</h2>
- </li>
- <li title="retry">
- <div class="icon-box">
-
- <!-- retry -->
- <svg class="svg-retry-dims">
- <use xlink:href="#retry"></use>
- </svg>
-
- </div>
- <h2>retry</h2>
- </li>
- <li title="scale">
- <div class="icon-box">
-
- <!-- scale -->
- <svg class="svg-scale-dims">
- <use xlink:href="#scale"></use>
- </svg>
-
- </div>
- <h2>scale</h2>
- </li>
- <li title="screen-full">
- <div class="icon-box">
-
- <!-- screen-full -->
- <svg class="svg-screen-full-dims">
- <use xlink:href="#screen-full"></use>
- </svg>
-
- </div>
- <h2>screen-full</h2>
- </li>
- <li title="screen-normal">
- <div class="icon-box">
-
- <!-- screen-normal -->
- <svg class="svg-screen-normal-dims">
- <use xlink:href="#screen-normal"></use>
- </svg>
-
- </div>
- <h2>screen-normal</h2>
- </li>
- <li title="search">
- <div class="icon-box">
-
- <!-- search -->
- <svg class="svg-search-dims">
- <use xlink:href="#search"></use>
- </svg>
-
- </div>
- <h2>search</h2>
- </li>
- <li title="settings">
- <div class="icon-box">
-
- <!-- settings -->
- <svg class="svg-settings-dims">
- <use xlink:href="#settings"></use>
- </svg>
-
- </div>
- <h2>settings</h2>
- </li>
- <li title="shield">
- <div class="icon-box">
-
- <!-- shield -->
- <svg class="svg-shield-dims">
- <use xlink:href="#shield"></use>
- </svg>
-
- </div>
- <h2>shield</h2>
- </li>
- <li title="slight-frown">
- <div class="icon-box">
-
- <!-- slight-frown -->
- <svg class="svg-slight-frown-dims">
- <use xlink:href="#slight-frown"></use>
- </svg>
-
- </div>
- <h2>slight-frown</h2>
- </li>
- <li title="slight-smile">
- <div class="icon-box">
-
- <!-- slight-smile -->
- <svg class="svg-slight-smile-dims">
- <use xlink:href="#slight-smile"></use>
- </svg>
-
- </div>
- <h2>slight-smile</h2>
- </li>
- <li title="smile">
- <div class="icon-box">
-
- <!-- smile -->
- <svg class="svg-smile-dims">
- <use xlink:href="#smile"></use>
- </svg>
-
- </div>
- <h2>smile</h2>
- </li>
- <li title="smiley">
- <div class="icon-box">
-
- <!-- smiley -->
- <svg class="svg-smiley-dims">
- <use xlink:href="#smiley"></use>
- </svg>
-
- </div>
- <h2>smiley</h2>
- </li>
- <li title="snippet">
- <div class="icon-box">
-
- <!-- snippet -->
- <svg class="svg-snippet-dims">
- <use xlink:href="#snippet"></use>
- </svg>
-
- </div>
- <h2>snippet</h2>
- </li>
- <li title="spam">
- <div class="icon-box">
-
- <!-- spam -->
- <svg class="svg-spam-dims">
- <use xlink:href="#spam"></use>
- </svg>
-
- </div>
- <h2>spam</h2>
- </li>
- <li title="star">
- <div class="icon-box">
-
- <!-- star -->
- <svg class="svg-star-dims">
- <use xlink:href="#star"></use>
- </svg>
-
- </div>
- <h2>star</h2>
- </li>
- <li title="star-o">
- <div class="icon-box">
-
- <!-- star-o -->
- <svg class="svg-star-o-dims">
- <use xlink:href="#star-o"></use>
- </svg>
-
- </div>
- <h2>star-o</h2>
- </li>
- <li title="stop">
- <div class="icon-box">
-
- <!-- stop -->
- <svg class="svg-stop-dims">
- <use xlink:href="#stop"></use>
- </svg>
-
- </div>
- <h2>stop</h2>
- </li>
- <li title="talic">
- <div class="icon-box">
-
- <!-- talic -->
- <svg class="svg-talic-dims">
- <use xlink:href="#talic"></use>
- </svg>
-
- </div>
- <h2>talic</h2>
- </li>
- <li title="task-done">
- <div class="icon-box">
-
- <!-- task-done -->
- <svg class="svg-task-done-dims">
- <use xlink:href="#task-done"></use>
- </svg>
-
- </div>
- <h2>task-done</h2>
- </li>
- <li title="template">
- <div class="icon-box">
-
- <!-- template -->
- <svg class="svg-template-dims">
- <use xlink:href="#template"></use>
- </svg>
-
- </div>
- <h2>template</h2>
- </li>
- <li title="thump-down">
- <div class="icon-box">
-
- <!-- thump-down -->
- <svg class="svg-thump-down-dims">
- <use xlink:href="#thump-down"></use>
- </svg>
-
- </div>
- <h2>thump-down</h2>
- </li>
- <li title="thump-up">
- <div class="icon-box">
-
- <!-- thump-up -->
- <svg class="svg-thump-up-dims">
- <use xlink:href="#thump-up"></use>
- </svg>
-
- </div>
- <h2>thump-up</h2>
- </li>
- <li title="timer">
- <div class="icon-box">
-
- <!-- timer -->
- <svg class="svg-timer-dims">
- <use xlink:href="#timer"></use>
- </svg>
-
- </div>
- <h2>timer</h2>
- </li>
- <li title="todo-add">
- <div class="icon-box">
-
- <!-- todo-add -->
- <svg class="svg-todo-add-dims">
- <use xlink:href="#todo-add"></use>
- </svg>
-
- </div>
- <h2>todo-add</h2>
- </li>
- <li title="todo-done">
- <div class="icon-box">
-
- <!-- todo-done -->
- <svg class="svg-todo-done-dims">
- <use xlink:href="#todo-done"></use>
- </svg>
-
- </div>
- <h2>todo-done</h2>
- </li>
- <li title="token">
- <div class="icon-box">
-
- <!-- token -->
- <svg class="svg-token-dims">
- <use xlink:href="#token"></use>
- </svg>
-
- </div>
- <h2>token</h2>
- </li>
- <li title="unapproval">
- <div class="icon-box">
-
- <!-- unapproval -->
- <svg class="svg-unapproval-dims">
- <use xlink:href="#unapproval"></use>
- </svg>
-
- </div>
- <h2>unapproval</h2>
- </li>
- <li title="unassignee">
- <div class="icon-box">
-
- <!-- unassignee -->
- <svg class="svg-unassignee-dims">
- <use xlink:href="#unassignee"></use>
- </svg>
-
- </div>
- <h2>unassignee</h2>
- </li>
- <li title="unlink">
- <div class="icon-box">
-
- <!-- unlink -->
- <svg class="svg-unlink-dims">
- <use xlink:href="#unlink"></use>
- </svg>
-
- </div>
- <h2>unlink</h2>
- </li>
- <li title="user">
- <div class="icon-box">
-
- <!-- user -->
- <svg class="svg-user-dims">
- <use xlink:href="#user"></use>
- </svg>
-
- </div>
- <h2>user</h2>
- </li>
- <li title="users">
- <div class="icon-box">
-
- <!-- users -->
- <svg class="svg-users-dims">
- <use xlink:href="#users"></use>
- </svg>
-
- </div>
- <h2>users</h2>
- </li>
- <li title="volume-up">
- <div class="icon-box">
-
- <!-- volume-up -->
- <svg class="svg-volume-up-dims">
- <use xlink:href="#volume-up"></use>
- </svg>
-
- </div>
- <h2>volume-up</h2>
- </li>
- <li title="warning">
- <div class="icon-box">
-
- <!-- warning -->
- <svg class="svg-warning-dims">
- <use xlink:href="#warning"></use>
- </svg>
-
- </div>
- <h2>warning</h2>
- </li>
- <li title="work">
- <div class="icon-box">
-
- <!-- work -->
- <svg class="svg-work-dims">
- <use xlink:href="#work"></use>
- </svg>
-
- </div>
- <h2>work</h2>
- </li>
- </ul>
-
-<!--
-====================================================================================================
--->
-
- </section>
- <section>
-
-<!--
-
-B) Inline SVG with external sprite (IE 9-11 with polyfill only)
-====================================================================================================
-These SVG images make use of an URL + fragment identifiers (IDs) and refer to the regular external
-SVG sprite. They may be styled via CSS. (IE 9-11 with polyfill only)
-
--->
-
- <h3>B) Inline SVG with external sprite (IE 9-11 with polyfill only)</h3>
- <ul>
-
- <li title="abuse">
- <div class="icon-box">
-
- <!-- abuse -->
- <svg class="svg-abuse-dims">
- <use xlink:href="icons.svg#abuse"></use>
- </svg>
-
- </div>
- <h2>abuse</h2>
- </li>
- <li title="account">
- <div class="icon-box">
-
- <!-- account -->
- <svg class="svg-account-dims">
- <use xlink:href="icons.svg#account"></use>
- </svg>
-
- </div>
- <h2>account</h2>
- </li>
- <li title="admin">
- <div class="icon-box">
-
- <!-- admin -->
- <svg class="svg-admin-dims">
- <use xlink:href="icons.svg#admin"></use>
- </svg>
-
- </div>
- <h2>admin</h2>
- </li>
- <li title="angle-double-left">
- <div class="icon-box">
-
- <!-- angle-double-left -->
- <svg class="svg-angle-double-left-dims">
- <use xlink:href="icons.svg#angle-double-left"></use>
- </svg>
-
- </div>
- <h2>angle-double-left</h2>
- </li>
- <li title="angle-down">
- <div class="icon-box">
-
- <!-- angle-down -->
- <svg class="svg-angle-down-dims">
- <use xlink:href="icons.svg#angle-down"></use>
- </svg>
-
- </div>
- <h2>angle-down</h2>
- </li>
- <li title="angle-left">
- <div class="icon-box">
-
- <!-- angle-left -->
- <svg class="svg-angle-left-dims">
- <use xlink:href="icons.svg#angle-left"></use>
- </svg>
-
- </div>
- <h2>angle-left</h2>
- </li>
- <li title="angle-right">
- <div class="icon-box">
-
- <!-- angle-right -->
- <svg class="svg-angle-right-dims">
- <use xlink:href="icons.svg#angle-right"></use>
- </svg>
-
- </div>
- <h2>angle-right</h2>
- </li>
- <li title="angle-up">
- <div class="icon-box">
-
- <!-- angle-up -->
- <svg class="svg-angle-up-dims">
- <use xlink:href="icons.svg#angle-up"></use>
- </svg>
-
- </div>
- <h2>angle-up</h2>
- </li>
- <li title="appearance">
- <div class="icon-box">
-
- <!-- appearance -->
- <svg class="svg-appearance-dims">
- <use xlink:href="icons.svg#appearance"></use>
- </svg>
-
- </div>
- <h2>appearance</h2>
- </li>
- <li title="applications">
- <div class="icon-box">
-
- <!-- applications -->
- <svg class="svg-applications-dims">
- <use xlink:href="icons.svg#applications"></use>
- </svg>
-
- </div>
- <h2>applications</h2>
- </li>
- <li title="approval">
- <div class="icon-box">
-
- <!-- approval -->
- <svg class="svg-approval-dims">
- <use xlink:href="icons.svg#approval"></use>
- </svg>
-
- </div>
- <h2>approval</h2>
- </li>
- <li title="arrow-right">
- <div class="icon-box">
-
- <!-- arrow-right -->
- <svg class="svg-arrow-right-dims">
- <use xlink:href="icons.svg#arrow-right"></use>
- </svg>
-
- </div>
- <h2>arrow-right</h2>
- </li>
- <li title="assignee">
- <div class="icon-box">
-
- <!-- assignee -->
- <svg class="svg-assignee-dims">
- <use xlink:href="icons.svg#assignee"></use>
- </svg>
-
- </div>
- <h2>assignee</h2>
- </li>
- <li title="bold">
- <div class="icon-box">
-
- <!-- bold -->
- <svg class="svg-bold-dims">
- <use xlink:href="icons.svg#bold"></use>
- </svg>
-
- </div>
- <h2>bold</h2>
- </li>
- <li title="book">
- <div class="icon-box">
-
- <!-- book -->
- <svg class="svg-book-dims">
- <use xlink:href="icons.svg#book"></use>
- </svg>
-
- </div>
- <h2>book</h2>
- </li>
- <li title="branch">
- <div class="icon-box">
-
- <!-- branch -->
- <svg class="svg-branch-dims">
- <use xlink:href="icons.svg#branch"></use>
- </svg>
-
- </div>
- <h2>branch</h2>
- </li>
- <li title="calendar">
- <div class="icon-box">
-
- <!-- calendar -->
- <svg class="svg-calendar-dims">
- <use xlink:href="icons.svg#calendar"></use>
- </svg>
-
- </div>
- <h2>calendar</h2>
- </li>
- <li title="cancel">
- <div class="icon-box">
-
- <!-- cancel -->
- <svg class="svg-cancel-dims">
- <use xlink:href="icons.svg#cancel"></use>
- </svg>
-
- </div>
- <h2>cancel</h2>
- </li>
- <li title="chevron-down">
- <div class="icon-box">
-
- <!-- chevron-down -->
- <svg class="svg-chevron-down-dims">
- <use xlink:href="icons.svg#chevron-down"></use>
- </svg>
-
- </div>
- <h2>chevron-down</h2>
- </li>
- <li title="chevron-left">
- <div class="icon-box">
-
- <!-- chevron-left -->
- <svg class="svg-chevron-left-dims">
- <use xlink:href="icons.svg#chevron-left"></use>
- </svg>
-
- </div>
- <h2>chevron-left</h2>
- </li>
- <li title="chevron-right">
- <div class="icon-box">
-
- <!-- chevron-right -->
- <svg class="svg-chevron-right-dims">
- <use xlink:href="icons.svg#chevron-right"></use>
- </svg>
-
- </div>
- <h2>chevron-right</h2>
- </li>
- <li title="chevron-up">
- <div class="icon-box">
-
- <!-- chevron-up -->
- <svg class="svg-chevron-up-dims">
- <use xlink:href="icons.svg#chevron-up"></use>
- </svg>
-
- </div>
- <h2>chevron-up</h2>
- </li>
- <li title="clock">
- <div class="icon-box">
-
- <!-- clock -->
- <svg class="svg-clock-dims">
- <use xlink:href="icons.svg#clock"></use>
- </svg>
-
- </div>
- <h2>clock</h2>
- </li>
- <li title="code">
- <div class="icon-box">
-
- <!-- code -->
- <svg class="svg-code-dims">
- <use xlink:href="icons.svg#code"></use>
- </svg>
-
- </div>
- <h2>code</h2>
- </li>
- <li title="comment">
- <div class="icon-box">
-
- <!-- comment -->
- <svg class="svg-comment-dims">
- <use xlink:href="icons.svg#comment"></use>
- </svg>
-
- </div>
- <h2>comment</h2>
- </li>
- <li title="comment-dots">
- <div class="icon-box">
-
- <!-- comment-dots -->
- <svg class="svg-comment-dots-dims">
- <use xlink:href="icons.svg#comment-dots"></use>
- </svg>
-
- </div>
- <h2>comment-dots</h2>
- </li>
- <li title="comment-next">
- <div class="icon-box">
-
- <!-- comment-next -->
- <svg class="svg-comment-next-dims">
- <use xlink:href="icons.svg#comment-next"></use>
- </svg>
-
- </div>
- <h2>comment-next</h2>
- </li>
- <li title="comments">
- <div class="icon-box">
-
- <!-- comments -->
- <svg class="svg-comments-dims">
- <use xlink:href="icons.svg#comments"></use>
- </svg>
-
- </div>
- <h2>comments</h2>
- </li>
- <li title="commit">
- <div class="icon-box">
-
- <!-- commit -->
- <svg class="svg-commit-dims">
- <use xlink:href="icons.svg#commit"></use>
- </svg>
-
- </div>
- <h2>commit</h2>
- </li>
- <li title="credit-card">
- <div class="icon-box">
-
- <!-- credit-card -->
- <svg class="svg-credit-card-dims">
- <use xlink:href="icons.svg#credit-card"></use>
- </svg>
-
- </div>
- <h2>credit-card</h2>
- </li>
- <li title="disk">
- <div class="icon-box">
-
- <!-- disk -->
- <svg class="svg-disk-dims">
- <use xlink:href="icons.svg#disk"></use>
- </svg>
-
- </div>
- <h2>disk</h2>
- </li>
- <li title="doc_code">
- <div class="icon-box">
-
- <!-- doc_code -->
- <svg class="svg-doc_code-dims">
- <use xlink:href="icons.svg#doc_code"></use>
- </svg>
-
- </div>
- <h2>doc_code</h2>
- </li>
- <li title="doc_image">
- <div class="icon-box">
-
- <!-- doc_image -->
- <svg class="svg-doc_image-dims">
- <use xlink:href="icons.svg#doc_image"></use>
- </svg>
-
- </div>
- <h2>doc_image</h2>
- </li>
- <li title="doc_text">
- <div class="icon-box">
-
- <!-- doc_text -->
- <svg class="svg-doc_text-dims">
- <use xlink:href="icons.svg#doc_text"></use>
- </svg>
-
- </div>
- <h2>doc_text</h2>
- </li>
- <li title="download">
- <div class="icon-box">
-
- <!-- download -->
- <svg class="svg-download-dims">
- <use xlink:href="icons.svg#download"></use>
- </svg>
-
- </div>
- <h2>download</h2>
- </li>
- <li title="duplicate">
- <div class="icon-box">
-
- <!-- duplicate -->
- <svg class="svg-duplicate-dims">
- <use xlink:href="icons.svg#duplicate"></use>
- </svg>
-
- </div>
- <h2>duplicate</h2>
- </li>
- <li title="earth">
- <div class="icon-box">
-
- <!-- earth -->
- <svg class="svg-earth-dims">
- <use xlink:href="icons.svg#earth"></use>
- </svg>
-
- </div>
- <h2>earth</h2>
- </li>
- <li title="eye">
- <div class="icon-box">
-
- <!-- eye -->
- <svg class="svg-eye-dims">
- <use xlink:href="icons.svg#eye"></use>
- </svg>
-
- </div>
- <h2>eye</h2>
- </li>
- <li title="eye-slash">
- <div class="icon-box">
-
- <!-- eye-slash -->
- <svg class="svg-eye-slash-dims">
- <use xlink:href="icons.svg#eye-slash"></use>
- </svg>
-
- </div>
- <h2>eye-slash</h2>
- </li>
- <li title="file-additions">
- <div class="icon-box">
-
- <!-- file-additions -->
- <svg class="svg-file-additions-dims">
- <use xlink:href="icons.svg#file-additions"></use>
- </svg>
-
- </div>
- <h2>file-additions</h2>
- </li>
- <li title="file-deletion">
- <div class="icon-box">
-
- <!-- file-deletion -->
- <svg class="svg-file-deletion-dims">
- <use xlink:href="icons.svg#file-deletion"></use>
- </svg>
-
- </div>
- <h2>file-deletion</h2>
- </li>
- <li title="file-modified">
- <div class="icon-box">
-
- <!-- file-modified -->
- <svg class="svg-file-modified-dims">
- <use xlink:href="icons.svg#file-modified"></use>
- </svg>
-
- </div>
- <h2>file-modified</h2>
- </li>
- <li title="filter">
- <div class="icon-box">
-
- <!-- filter -->
- <svg class="svg-filter-dims">
- <use xlink:href="icons.svg#filter"></use>
- </svg>
-
- </div>
- <h2>filter</h2>
- </li>
- <li title="folder">
- <div class="icon-box">
-
- <!-- folder -->
- <svg class="svg-folder-dims">
- <use xlink:href="icons.svg#folder"></use>
- </svg>
-
- </div>
- <h2>folder</h2>
- </li>
- <li title="fork">
- <div class="icon-box">
-
- <!-- fork -->
- <svg class="svg-fork-dims">
- <use xlink:href="icons.svg#fork"></use>
- </svg>
-
- </div>
- <h2>fork</h2>
- </li>
- <li title="git-merge">
- <div class="icon-box">
-
- <!-- git-merge -->
- <svg class="svg-git-merge-dims">
- <use xlink:href="icons.svg#git-merge"></use>
- </svg>
-
- </div>
- <h2>git-merge</h2>
- </li>
- <li title="group">
- <div class="icon-box">
-
- <!-- group -->
- <svg class="svg-group-dims">
- <use xlink:href="icons.svg#group"></use>
- </svg>
-
- </div>
- <h2>group</h2>
- </li>
- <li title="history">
- <div class="icon-box">
-
- <!-- history -->
- <svg class="svg-history-dims">
- <use xlink:href="icons.svg#history"></use>
- </svg>
-
- </div>
- <h2>history</h2>
- </li>
- <li title="home">
- <div class="icon-box">
-
- <!-- home -->
- <svg class="svg-home-dims">
- <use xlink:href="icons.svg#home"></use>
- </svg>
-
- </div>
- <h2>home</h2>
- </li>
- <li title="hook">
- <div class="icon-box">
-
- <!-- hook -->
- <svg class="svg-hook-dims">
- <use xlink:href="icons.svg#hook"></use>
- </svg>
-
- </div>
- <h2>hook</h2>
- </li>
- <li title="issue-block">
- <div class="icon-box">
-
- <!-- issue-block -->
- <svg class="svg-issue-block-dims">
- <use xlink:href="icons.svg#issue-block"></use>
- </svg>
-
- </div>
- <h2>issue-block</h2>
- </li>
- <li title="issue-child">
- <div class="icon-box">
-
- <!-- issue-child -->
- <svg class="svg-issue-child-dims">
- <use xlink:href="icons.svg#issue-child"></use>
- </svg>
-
- </div>
- <h2>issue-child</h2>
- </li>
- <li title="issue-close">
- <div class="icon-box">
-
- <!-- issue-close -->
- <svg class="svg-issue-close-dims">
- <use xlink:href="icons.svg#issue-close"></use>
- </svg>
-
- </div>
- <h2>issue-close</h2>
- </li>
- <li title="issue-duplicate">
- <div class="icon-box">
-
- <!-- issue-duplicate -->
- <svg class="svg-issue-duplicate-dims">
- <use xlink:href="icons.svg#issue-duplicate"></use>
- </svg>
-
- </div>
- <h2>issue-duplicate</h2>
- </li>
- <li title="issue-new">
- <div class="icon-box">
-
- <!-- issue-new -->
- <svg class="svg-issue-new-dims">
- <use xlink:href="icons.svg#issue-new"></use>
- </svg>
-
- </div>
- <h2>issue-new</h2>
- </li>
- <li title="issue-open">
- <div class="icon-box">
-
- <!-- issue-open -->
- <svg class="svg-issue-open-dims">
- <use xlink:href="icons.svg#issue-open"></use>
- </svg>
-
- </div>
- <h2>issue-open</h2>
- </li>
- <li title="issue-open-m">
- <div class="icon-box">
-
- <!-- issue-open-m -->
- <svg class="svg-issue-open-m-dims">
- <use xlink:href="icons.svg#issue-open-m"></use>
- </svg>
-
- </div>
- <h2>issue-open-m</h2>
- </li>
- <li title="issue-parent">
- <div class="icon-box">
-
- <!-- issue-parent -->
- <svg class="svg-issue-parent-dims">
- <use xlink:href="icons.svg#issue-parent"></use>
- </svg>
-
- </div>
- <h2>issue-parent</h2>
- </li>
- <li title="issues">
- <div class="icon-box">
-
- <!-- issues -->
- <svg class="svg-issues-dims">
- <use xlink:href="icons.svg#issues"></use>
- </svg>
-
- </div>
- <h2>issues</h2>
- </li>
- <li title="key">
- <div class="icon-box">
-
- <!-- key -->
- <svg class="svg-key-dims">
- <use xlink:href="icons.svg#key"></use>
- </svg>
-
- </div>
- <h2>key</h2>
- </li>
- <li title="key-2">
- <div class="icon-box">
-
- <!-- key-2 -->
- <svg class="svg-key-2-dims">
- <use xlink:href="icons.svg#key-2"></use>
- </svg>
-
- </div>
- <h2>key-2</h2>
- </li>
- <li title="label">
- <div class="icon-box">
-
- <!-- label -->
- <svg class="svg-label-dims">
- <use xlink:href="icons.svg#label"></use>
- </svg>
-
- </div>
- <h2>label</h2>
- </li>
- <li title="labels">
- <div class="icon-box">
-
- <!-- labels -->
- <svg class="svg-labels-dims">
- <use xlink:href="icons.svg#labels"></use>
- </svg>
-
- </div>
- <h2>labels</h2>
- </li>
- <li title="leave">
- <div class="icon-box">
-
- <!-- leave -->
- <svg class="svg-leave-dims">
- <use xlink:href="icons.svg#leave"></use>
- </svg>
-
- </div>
- <h2>leave</h2>
- </li>
- <li title="level-up">
- <div class="icon-box">
-
- <!-- level-up -->
- <svg class="svg-level-up-dims">
- <use xlink:href="icons.svg#level-up"></use>
- </svg>
-
- </div>
- <h2>level-up</h2>
- </li>
- <li title="license">
- <div class="icon-box">
-
- <!-- license -->
- <svg class="svg-license-dims">
- <use xlink:href="icons.svg#license"></use>
- </svg>
-
- </div>
- <h2>license</h2>
- </li>
- <li title="link">
- <div class="icon-box">
-
- <!-- link -->
- <svg class="svg-link-dims">
- <use xlink:href="icons.svg#link"></use>
- </svg>
-
- </div>
- <h2>link</h2>
- </li>
- <li title="list-bulleted">
- <div class="icon-box">
-
- <!-- list-bulleted -->
- <svg class="svg-list-bulleted-dims">
- <use xlink:href="icons.svg#list-bulleted"></use>
- </svg>
-
- </div>
- <h2>list-bulleted</h2>
- </li>
- <li title="list-numbered">
- <div class="icon-box">
-
- <!-- list-numbered -->
- <svg class="svg-list-numbered-dims">
- <use xlink:href="icons.svg#list-numbered"></use>
- </svg>
-
- </div>
- <h2>list-numbered</h2>
- </li>
- <li title="location">
- <div class="icon-box">
-
- <!-- location -->
- <svg class="svg-location-dims">
- <use xlink:href="icons.svg#location"></use>
- </svg>
-
- </div>
- <h2>location</h2>
- </li>
- <li title="location-dot">
- <div class="icon-box">
-
- <!-- location-dot -->
- <svg class="svg-location-dot-dims">
- <use xlink:href="icons.svg#location-dot"></use>
- </svg>
-
- </div>
- <h2>location-dot</h2>
- </li>
- <li title="lock">
- <div class="icon-box">
-
- <!-- lock -->
- <svg class="svg-lock-dims">
- <use xlink:href="icons.svg#lock"></use>
- </svg>
-
- </div>
- <h2>lock</h2>
- </li>
- <li title="lock-open">
- <div class="icon-box">
-
- <!-- lock-open -->
- <svg class="svg-lock-open-dims">
- <use xlink:href="icons.svg#lock-open"></use>
- </svg>
-
- </div>
- <h2>lock-open</h2>
- </li>
- <li title="log">
- <div class="icon-box">
-
- <!-- log -->
- <svg class="svg-log-dims">
- <use xlink:href="icons.svg#log"></use>
- </svg>
-
- </div>
- <h2>log</h2>
- </li>
- <li title="mail">
- <div class="icon-box">
-
- <!-- mail -->
- <svg class="svg-mail-dims">
- <use xlink:href="icons.svg#mail"></use>
- </svg>
-
- </div>
- <h2>mail</h2>
- </li>
- <li title="merge-request-close">
- <div class="icon-box">
-
- <!-- merge-request-close -->
- <svg class="svg-merge-request-close-dims">
- <use xlink:href="icons.svg#merge-request-close"></use>
- </svg>
-
- </div>
- <h2>merge-request-close</h2>
- </li>
- <li title="merge-request-close-m">
- <div class="icon-box">
-
- <!-- merge-request-close-m -->
- <svg class="svg-merge-request-close-m-dims">
- <use xlink:href="icons.svg#merge-request-close-m"></use>
- </svg>
-
- </div>
- <h2>merge-request-close-m</h2>
- </li>
- <li title="messages">
- <div class="icon-box">
-
- <!-- messages -->
- <svg class="svg-messages-dims">
- <use xlink:href="icons.svg#messages"></use>
- </svg>
-
- </div>
- <h2>messages</h2>
- </li>
- <li title="mobile-issue-close">
- <div class="icon-box">
-
- <!-- mobile-issue-close -->
- <svg class="svg-mobile-issue-close-dims">
- <use xlink:href="icons.svg#mobile-issue-close"></use>
- </svg>
-
- </div>
- <h2>mobile-issue-close</h2>
- </li>
- <li title="monitor">
- <div class="icon-box">
-
- <!-- monitor -->
- <svg class="svg-monitor-dims">
- <use xlink:href="icons.svg#monitor"></use>
- </svg>
-
- </div>
- <h2>monitor</h2>
- </li>
- <li title="more">
- <div class="icon-box">
-
- <!-- more -->
- <svg class="svg-more-dims">
- <use xlink:href="icons.svg#more"></use>
- </svg>
-
- </div>
- <h2>more</h2>
- </li>
- <li title="notifications">
- <div class="icon-box">
-
- <!-- notifications -->
- <svg class="svg-notifications-dims">
- <use xlink:href="icons.svg#notifications"></use>
- </svg>
-
- </div>
- <h2>notifications</h2>
- </li>
- <li title="notifications-off">
- <div class="icon-box">
-
- <!-- notifications-off -->
- <svg class="svg-notifications-off-dims">
- <use xlink:href="icons.svg#notifications-off"></use>
- </svg>
-
- </div>
- <h2>notifications-off</h2>
- </li>
- <li title="overview">
- <div class="icon-box">
-
- <!-- overview -->
- <svg class="svg-overview-dims">
- <use xlink:href="icons.svg#overview"></use>
- </svg>
-
- </div>
- <h2>overview</h2>
- </li>
- <li title="pencil">
- <div class="icon-box">
-
- <!-- pencil -->
- <svg class="svg-pencil-dims">
- <use xlink:href="icons.svg#pencil"></use>
- </svg>
-
- </div>
- <h2>pencil</h2>
- </li>
- <li title="pipeline">
- <div class="icon-box">
-
- <!-- pipeline -->
- <svg class="svg-pipeline-dims">
- <use xlink:href="icons.svg#pipeline"></use>
- </svg>
-
- </div>
- <h2>pipeline</h2>
- </li>
- <li title="play">
- <div class="icon-box">
-
- <!-- play -->
- <svg class="svg-play-dims">
- <use xlink:href="icons.svg#play"></use>
- </svg>
-
- </div>
- <h2>play</h2>
- </li>
- <li title="plus">
- <div class="icon-box">
-
- <!-- plus -->
- <svg class="svg-plus-dims">
- <use xlink:href="icons.svg#plus"></use>
- </svg>
-
- </div>
- <h2>plus</h2>
- </li>
- <li title="plus-square">
- <div class="icon-box">
-
- <!-- plus-square -->
- <svg class="svg-plus-square-dims">
- <use xlink:href="icons.svg#plus-square"></use>
- </svg>
-
- </div>
- <h2>plus-square</h2>
- </li>
- <li title="plus-square-o">
- <div class="icon-box">
-
- <!-- plus-square-o -->
- <svg class="svg-plus-square-o-dims">
- <use xlink:href="icons.svg#plus-square-o"></use>
- </svg>
-
- </div>
- <h2>plus-square-o</h2>
- </li>
- <li title="preferences">
- <div class="icon-box">
-
- <!-- preferences -->
- <svg class="svg-preferences-dims">
- <use xlink:href="icons.svg#preferences"></use>
- </svg>
-
- </div>
- <h2>preferences</h2>
- </li>
- <li title="profile">
- <div class="icon-box">
-
- <!-- profile -->
- <svg class="svg-profile-dims">
- <use xlink:href="icons.svg#profile"></use>
- </svg>
-
- </div>
- <h2>profile</h2>
- </li>
- <li title="project">
- <div class="icon-box">
-
- <!-- project -->
- <svg class="svg-project-dims">
- <use xlink:href="icons.svg#project"></use>
- </svg>
-
- </div>
- <h2>project</h2>
- </li>
- <li title="push-rules">
- <div class="icon-box">
-
- <!-- push-rules -->
- <svg class="svg-push-rules-dims">
- <use xlink:href="icons.svg#push-rules"></use>
- </svg>
-
- </div>
- <h2>push-rules</h2>
- </li>
- <li title="question">
- <div class="icon-box">
-
- <!-- question -->
- <svg class="svg-question-dims">
- <use xlink:href="icons.svg#question"></use>
- </svg>
-
- </div>
- <h2>question</h2>
- </li>
- <li title="question-o">
- <div class="icon-box">
-
- <!-- question-o -->
- <svg class="svg-question-o-dims">
- <use xlink:href="icons.svg#question-o"></use>
- </svg>
-
- </div>
- <h2>question-o</h2>
- </li>
- <li title="quote">
- <div class="icon-box">
-
- <!-- quote -->
- <svg class="svg-quote-dims">
- <use xlink:href="icons.svg#quote"></use>
- </svg>
-
- </div>
- <h2>quote</h2>
- </li>
- <li title="redo">
- <div class="icon-box">
-
- <!-- redo -->
- <svg class="svg-redo-dims">
- <use xlink:href="icons.svg#redo"></use>
- </svg>
-
- </div>
- <h2>redo</h2>
- </li>
- <li title="remove">
- <div class="icon-box">
-
- <!-- remove -->
- <svg class="svg-remove-dims">
- <use xlink:href="icons.svg#remove"></use>
- </svg>
-
- </div>
- <h2>remove</h2>
- </li>
- <li title="repeat">
- <div class="icon-box">
-
- <!-- repeat -->
- <svg class="svg-repeat-dims">
- <use xlink:href="icons.svg#repeat"></use>
- </svg>
-
- </div>
- <h2>repeat</h2>
- </li>
- <li title="retry">
- <div class="icon-box">
-
- <!-- retry -->
- <svg class="svg-retry-dims">
- <use xlink:href="icons.svg#retry"></use>
- </svg>
-
- </div>
- <h2>retry</h2>
- </li>
- <li title="scale">
- <div class="icon-box">
-
- <!-- scale -->
- <svg class="svg-scale-dims">
- <use xlink:href="icons.svg#scale"></use>
- </svg>
-
- </div>
- <h2>scale</h2>
- </li>
- <li title="screen-full">
- <div class="icon-box">
-
- <!-- screen-full -->
- <svg class="svg-screen-full-dims">
- <use xlink:href="icons.svg#screen-full"></use>
- </svg>
-
- </div>
- <h2>screen-full</h2>
- </li>
- <li title="screen-normal">
- <div class="icon-box">
-
- <!-- screen-normal -->
- <svg class="svg-screen-normal-dims">
- <use xlink:href="icons.svg#screen-normal"></use>
- </svg>
-
- </div>
- <h2>screen-normal</h2>
- </li>
- <li title="search">
- <div class="icon-box">
-
- <!-- search -->
- <svg class="svg-search-dims">
- <use xlink:href="icons.svg#search"></use>
- </svg>
-
- </div>
- <h2>search</h2>
- </li>
- <li title="settings">
- <div class="icon-box">
-
- <!-- settings -->
- <svg class="svg-settings-dims">
- <use xlink:href="icons.svg#settings"></use>
- </svg>
-
- </div>
- <h2>settings</h2>
- </li>
- <li title="shield">
- <div class="icon-box">
-
- <!-- shield -->
- <svg class="svg-shield-dims">
- <use xlink:href="icons.svg#shield"></use>
- </svg>
-
- </div>
- <h2>shield</h2>
- </li>
- <li title="slight-frown">
- <div class="icon-box">
-
- <!-- slight-frown -->
- <svg class="svg-slight-frown-dims">
- <use xlink:href="icons.svg#slight-frown"></use>
- </svg>
-
- </div>
- <h2>slight-frown</h2>
- </li>
- <li title="slight-smile">
- <div class="icon-box">
-
- <!-- slight-smile -->
- <svg class="svg-slight-smile-dims">
- <use xlink:href="icons.svg#slight-smile"></use>
- </svg>
-
- </div>
- <h2>slight-smile</h2>
- </li>
- <li title="smile">
- <div class="icon-box">
-
- <!-- smile -->
- <svg class="svg-smile-dims">
- <use xlink:href="icons.svg#smile"></use>
- </svg>
-
- </div>
- <h2>smile</h2>
- </li>
- <li title="smiley">
- <div class="icon-box">
-
- <!-- smiley -->
- <svg class="svg-smiley-dims">
- <use xlink:href="icons.svg#smiley"></use>
- </svg>
-
- </div>
- <h2>smiley</h2>
- </li>
- <li title="snippet">
- <div class="icon-box">
-
- <!-- snippet -->
- <svg class="svg-snippet-dims">
- <use xlink:href="icons.svg#snippet"></use>
- </svg>
-
- </div>
- <h2>snippet</h2>
- </li>
- <li title="spam">
- <div class="icon-box">
-
- <!-- spam -->
- <svg class="svg-spam-dims">
- <use xlink:href="icons.svg#spam"></use>
- </svg>
-
- </div>
- <h2>spam</h2>
- </li>
- <li title="star">
- <div class="icon-box">
-
- <!-- star -->
- <svg class="svg-star-dims">
- <use xlink:href="icons.svg#star"></use>
- </svg>
-
- </div>
- <h2>star</h2>
- </li>
- <li title="star-o">
- <div class="icon-box">
-
- <!-- star-o -->
- <svg class="svg-star-o-dims">
- <use xlink:href="icons.svg#star-o"></use>
- </svg>
-
- </div>
- <h2>star-o</h2>
- </li>
- <li title="stop">
- <div class="icon-box">
-
- <!-- stop -->
- <svg class="svg-stop-dims">
- <use xlink:href="icons.svg#stop"></use>
- </svg>
-
- </div>
- <h2>stop</h2>
- </li>
- <li title="talic">
- <div class="icon-box">
-
- <!-- talic -->
- <svg class="svg-talic-dims">
- <use xlink:href="icons.svg#talic"></use>
- </svg>
-
- </div>
- <h2>talic</h2>
- </li>
- <li title="task-done">
- <div class="icon-box">
-
- <!-- task-done -->
- <svg class="svg-task-done-dims">
- <use xlink:href="icons.svg#task-done"></use>
- </svg>
-
- </div>
- <h2>task-done</h2>
- </li>
- <li title="template">
- <div class="icon-box">
-
- <!-- template -->
- <svg class="svg-template-dims">
- <use xlink:href="icons.svg#template"></use>
- </svg>
-
- </div>
- <h2>template</h2>
- </li>
- <li title="thump-down">
- <div class="icon-box">
-
- <!-- thump-down -->
- <svg class="svg-thump-down-dims">
- <use xlink:href="icons.svg#thump-down"></use>
- </svg>
-
- </div>
- <h2>thump-down</h2>
- </li>
- <li title="thump-up">
- <div class="icon-box">
-
- <!-- thump-up -->
- <svg class="svg-thump-up-dims">
- <use xlink:href="icons.svg#thump-up"></use>
- </svg>
-
- </div>
- <h2>thump-up</h2>
- </li>
- <li title="timer">
- <div class="icon-box">
-
- <!-- timer -->
- <svg class="svg-timer-dims">
- <use xlink:href="icons.svg#timer"></use>
- </svg>
-
- </div>
- <h2>timer</h2>
- </li>
- <li title="todo-add">
- <div class="icon-box">
-
- <!-- todo-add -->
- <svg class="svg-todo-add-dims">
- <use xlink:href="icons.svg#todo-add"></use>
- </svg>
-
- </div>
- <h2>todo-add</h2>
- </li>
- <li title="todo-done">
- <div class="icon-box">
-
- <!-- todo-done -->
- <svg class="svg-todo-done-dims">
- <use xlink:href="icons.svg#todo-done"></use>
- </svg>
-
- </div>
- <h2>todo-done</h2>
- </li>
- <li title="token">
- <div class="icon-box">
-
- <!-- token -->
- <svg class="svg-token-dims">
- <use xlink:href="icons.svg#token"></use>
- </svg>
-
- </div>
- <h2>token</h2>
- </li>
- <li title="unapproval">
- <div class="icon-box">
-
- <!-- unapproval -->
- <svg class="svg-unapproval-dims">
- <use xlink:href="icons.svg#unapproval"></use>
- </svg>
-
- </div>
- <h2>unapproval</h2>
- </li>
- <li title="unassignee">
- <div class="icon-box">
-
- <!-- unassignee -->
- <svg class="svg-unassignee-dims">
- <use xlink:href="icons.svg#unassignee"></use>
- </svg>
-
- </div>
- <h2>unassignee</h2>
- </li>
- <li title="unlink">
- <div class="icon-box">
-
- <!-- unlink -->
- <svg class="svg-unlink-dims">
- <use xlink:href="icons.svg#unlink"></use>
- </svg>
-
- </div>
- <h2>unlink</h2>
- </li>
- <li title="user">
- <div class="icon-box">
-
- <!-- user -->
- <svg class="svg-user-dims">
- <use xlink:href="icons.svg#user"></use>
- </svg>
-
- </div>
- <h2>user</h2>
- </li>
- <li title="users">
- <div class="icon-box">
-
- <!-- users -->
- <svg class="svg-users-dims">
- <use xlink:href="icons.svg#users"></use>
- </svg>
-
- </div>
- <h2>users</h2>
- </li>
- <li title="volume-up">
- <div class="icon-box">
-
- <!-- volume-up -->
- <svg class="svg-volume-up-dims">
- <use xlink:href="icons.svg#volume-up"></use>
- </svg>
-
- </div>
- <h2>volume-up</h2>
- </li>
- <li title="warning">
- <div class="icon-box">
-
- <!-- warning -->
- <svg class="svg-warning-dims">
- <use xlink:href="icons.svg#warning"></use>
- </svg>
-
- </div>
- <h2>warning</h2>
- </li>
- <li title="work">
- <div class="icon-box">
-
- <!-- work -->
- <svg class="svg-work-dims">
- <use xlink:href="icons.svg#work"></use>
- </svg>
-
- </div>
- <h2>work</h2>
- </li>
- </ul>
-
-<!--
-====================================================================================================
--->
-
- </section>
- <footer>
- <p>Generated at Tue, 12 Sep 2017 09:08:46 GMT by <a href="https://github.com/jkphl/svg-sprite" target="_blank">svg-sprite</a>.</p>
- </footer>
- </body>
-</html>
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index 5d060165f4b..6a0662ba903 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -1,9 +1,10 @@
/* eslint-disable no-param-reassign, class-methods-use-this */
-/* global Pager */
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 +16,7 @@ class Activities {
}
updateTooltips() {
- gl.utils.localTimeAgo($('.js-timeago', '.content_list'));
+ localTimeAgo($('.js-timeago', '.content_list'));
}
reloadActivities() {
@@ -33,6 +34,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 8f5e2e545ec..2bc77859c26 100644
--- a/app/assets/javascripts/ajax_loading_spinner.js
+++ b/app/assets/javascripts/ajax_loading_spinner.js
@@ -1,4 +1,4 @@
-class AjaxLoadingSpinner {
+export default class AjaxLoadingSpinner {
static init() {
const $elements = $('.js-ajax-loading-spinner');
@@ -30,6 +30,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 38d1effc77c..464611f66f0 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -1,13 +1,15 @@
-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',
+ projectPath: '/api/:version/projects/:id',
projectLabelsPath: '/:namespace_path/:project_path/labels',
- groupLabelsPath: '/groups/:namespace_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',
@@ -15,46 +17,50 @@ const Api = {
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);
const defaults = {
search: query,
@@ -66,12 +72,22 @@ const Api = {
defaults.membership = true;
}
- return $.ajax({
- url,
- data: Object.assign(defaults, options),
- dataType: 'json',
+ 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) {
@@ -85,95 +101,93 @@ const Api = {
url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath);
}
- return $.ajax({
- url,
- type: 'POST',
- data: { label: data },
- dataType: 'json',
+ 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', id);
- return this.wrapAjaxCall({
- url,
- type: 'POST',
- contentType: 'application/json; charset=utf-8',
- data: JSON.stringify(data),
- dataType: 'json',
+ .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',
});
},
@@ -184,20 +198,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 4d2d4db7c0e..0da872db7e5 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -1,65 +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, resource) {
+export default class Autosave {
+ constructor(field, key) {
this.field = field;
+
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
- this.resource = resource;
if (key.join != null) {
key = key.join('/');
}
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);
}
- if (!this.resource && this.resource !== 'issue') {
- this.field.trigger('input');
- } else {
- // 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);
- if (field) {
- field.dispatchEvent(event);
- }
- }
- };
- 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 ec5be8664b2..26e62732b33 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -1,8 +1,10 @@
/* eslint-disable class-methods-use-this */
-/* global Flash */
import _ from 'underscore';
import Cookies from 'js-cookie';
-import { isInIssuePage, updateTooltipTitle } from './lib/utils/common_utils';
+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';
@@ -24,6 +26,9 @@ const categoryLabelMap = {
flags: 'Flags',
};
+const IS_VISIBLE = 'is-visible';
+const IS_RENDERED = 'is-rendered';
+
class AwardsHandler {
constructor(emoji) {
this.emoji = emoji;
@@ -45,13 +50,11 @@ 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'));
}
}
});
@@ -88,12 +91,12 @@ 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 {
@@ -103,7 +106,7 @@ 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);
});
@@ -236,12 +239,13 @@ class AwardsHandler {
}
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
- const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length;
+ const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length;
- if (isInIssuePage() && !isMainAwardsBlock) {
+ if (this.isInVueNoteablePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', '');
- $('.emoji-menu').removeClass('is-visible');
+ this.hideMenuElement($('.emoji-menu'));
+
$('.js-add-award.is-active').removeClass('is-active');
const toggleAwardEvent = new CustomEvent('toggleAward', {
detail: {
@@ -261,7 +265,8 @@ class AwardsHandler {
return typeof callback === 'function' ? callback() : undefined;
});
- $('.emoji-menu').removeClass('is-visible');
+ this.hideMenuElement($('.emoji-menu'));
+
return $('.js-add-award.is-active').removeClass('is-active');
}
@@ -288,8 +293,16 @@ class AwardsHandler {
}
}
+ isVueMRDiscussions() {
+ return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
+ }
+
+ isInVueNoteablePage() {
+ return isInIssuePage() || this.isVueMRDiscussions();
+ }
+
getVotesBlock() {
- if (isInIssuePage()) {
+ if (this.isInVueNoteablePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) {
@@ -307,7 +320,7 @@ class AwardsHandler {
}
getAwardUrl() {
- return this.getVotesBlock().data('award-url');
+ return this.getVotesBlock().data('awardUrl');
}
checkMutuality(votesBlock, emoji) {
@@ -436,13 +449,15 @@ 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.')));
}
}
@@ -529,6 +544,33 @@ class AwardsHandler {
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);
+
+ // 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);
+ }
+ });
+
+ $emojiMenu.removeClass(IS_VISIBLE);
+ }
+
destroy() {
this.eventListeners.forEach((entry) => {
entry.element.off.call(entry.element, ...entry.args);
diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js
index e00af4b2fa8..add43b81f6d 100644
--- a/app/assets/javascripts/behaviors/autosize.js
+++ b/app/assets/javascripts/behaviors/autosize.js
@@ -1,8 +1,8 @@
-import autosize from 'vendor/autosize';
+import Autosize from 'autosize';
document.addEventListener('DOMContentLoaded', () => {
const autosizeEls = document.querySelectorAll('.js-autosize');
- autosize(autosizeEls);
- autosize.update(autosizeEls);
+ Autosize(autosizeEls);
+ Autosize.update(autosizeEls);
});
diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/behaviors/copy_as_gfm.js
index e3e2c798570..ffe90595b5d 100644
--- a/app/assets/javascripts/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/copy_as_gfm.js
@@ -1,7 +1,8 @@
/* 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 'underscore';
-import { insertText, getSelectedFragment, nodeMatchesSelector } from './lib/utils/common_utils';
-import { placeholderImage } from './lazy_loader';
+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
@@ -73,6 +74,18 @@ const gfmRules = {
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\`\`\``;
@@ -284,8 +297,15 @@ const gfmRules = {
},
};
-class CopyAsGFM {
+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);
@@ -298,7 +318,7 @@ class CopyAsGFM {
const documentFragment = getSelectedFragment();
if (!documentFragment) return;
- const el = transformer(documentFragment.cloneNode(true));
+ const el = transformer(documentFragment.cloneNode(true), e.currentTarget);
if (!el) return;
e.preventDefault();
@@ -338,55 +358,64 @@ class CopyAsGFM {
}
static transformGFMSelection(documentFragment) {
- const gfmEls = documentFragment.querySelectorAll('.md, .wiki');
- switch (gfmEls.length) {
+ const gfmElements = documentFragment.querySelectorAll('.md, .wiki');
+ switch (gfmElements.length) {
case 0: {
return documentFragment;
}
case 1: {
- return gfmEls[0];
+ return gfmElements[0];
}
default: {
- const allGfmEl = document.createElement('div');
+ const allGfmElement = 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'));
+ for (let i = 0; i < gfmElements.length; i += 1) {
+ const gfmElement = gfmElements[i];
+ allGfmElement.appendChild(gfmElement);
+ allGfmElement.appendChild(document.createTextNode('\n\n'));
}
- return allGfmEl;
+ return allGfmElement;
}
}
}
- static transformCodeSelection(documentFragment) {
- const lineEls = documentFragment.querySelectorAll('.line');
+ static transformCodeSelection(documentFragment, target) {
+ let lineSelector = '.line';
- let codeEl;
- if (lineEls.length > 1) {
- codeEl = document.createElement('pre');
- codeEl.className = 'code highlight';
+ 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);
- const lang = lineEls[0].getAttribute('lang');
+ let codeElement;
+ if (lineElements.length > 1) {
+ codeElement = document.createElement('pre');
+ codeElement.className = 'code highlight';
+
+ const lang = lineElements[0].getAttribute('lang');
if (lang) {
- codeEl.setAttribute('lang', lang);
+ codeElement.setAttribute('lang', lang);
}
} else {
- codeEl = document.createElement('code');
+ codeElement = 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'));
+ 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 {
- codeEl.appendChild(documentFragment);
+ codeElement.appendChild(documentFragment);
}
- return codeEl;
+ return codeElement;
}
static nodeToGFM(node, respectWhitespaceParam = false) {
@@ -460,7 +489,12 @@ class CopyAsGFM {
}
}
-window.gl = window.gl || {};
-window.gl.CopyAsGFM = CopyAsGFM;
+// 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;
+}
-new CopyAsGFM();
+export default function initCopyAsGFM() {
+ return new CopyAsGFM();
+}
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..b669b63d23c
--- /dev/null
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -0,0 +1,73 @@
+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/index.js b/app/assets/javascripts/behaviors/index.js
index 44b2c974b9e..8d021de7998 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -1,9 +1,14 @@
import './autosize';
import './bind_in_out';
+import initCopyAsGFM from './copy_as_gfm';
+import initCopyToClipboard from './copy_to_clipboard';
import './details_behavior';
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/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index 2cf8f4fa935..312edc0cd69 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -43,7 +43,7 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
const $form = $(e.target).closest('form');
const $submitButton = $form.find('input[type=submit], button[type=submit]').first();
- if (!$submitButton.attr('disabled')) {
+ if (!$submitButton.prop('disabled')) {
$submitButton.trigger('click', [e]);
if (!isInIssuePage()) {
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index 035a7e5c431..e10cb2e3dc4 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -40,7 +40,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();
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 b70b0a9bbf8..81c89441424 100644
--- a/app/assets/javascripts/behaviors/toggler_behavior.js
+++ b/app/assets/javascripts/behaviors/toggler_behavior.js
@@ -5,13 +5,14 @@
// %button.js-toggle-button
// %div.js-toggle-content
//
+import { getLocationHash } from '../lib/utils/url_utility';
$(() => {
function toggleContainer(container, toggleState) {
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);
@@ -21,7 +22,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();
@@ -32,7 +33,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 ddd1fea3aca..83cac896f86 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -1,10 +1,11 @@
/* eslint-disable func-names, object-shorthand, prefer-arrow-callback */
-/* global Dropzone */
-
-import '../lib/utils/url_utility';
+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();
@@ -50,7 +51,7 @@ export default class BlobFileDropzone {
});
this.on('success', function (header, response) {
$('#modal-upload-blob').modal('hide');
- window.gl.utils.visitUrl(response.filePath);
+ visitUrl(response.filePath);
});
this.on('maxfilesexceeded', function (file) {
dropzoneMessage.addClass(HIDDEN_CLASS);
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..37074301b51 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -1,6 +1,5 @@
/* eslint-disable class-methods-use-this */
-/* global Flash */
-
+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 +235,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/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js
index 27312d718b0..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,49 +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(response => response.json())
- .then((res) => {
- this.json = res;
- 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/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index e0b73f13d36..92ea91c45a8 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -1,9 +1,11 @@
-/* global Flash */
+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();
}
@@ -15,6 +17,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;
@@ -127,25 +161,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 b5500ac116f..931ed042dfd 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -1,20 +1,19 @@
/* 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 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);
@@ -35,4 +34,4 @@ $(() => {
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..d4f6adaccbc 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -1,5 +1,8 @@
/* global ace */
+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 +59,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/components/board.js b/app/assets/javascripts/boards/components/board.js
index adb7360327c..9c4cc2338c8 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -1,8 +1,8 @@
/* eslint-disable comma-dangle, space-before-function-paren, one-var */
-/* global Sortable */
+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 edfe7c326db..72db626d3c7 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.js
+++ b/app/assets/javascripts/boards/components/board_blank_state.js
@@ -65,7 +65,7 @@ export default {
// Save the labels
gl.boardService.generateDefaultLists()
- .then(resp => resp.json())
+ .then(res => res.data)
.then((data) => {
data.forEach((listObj) => {
const list = Store.findList('title', listObj.title);
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_list.js b/app/assets/javascripts/boards/components/board_list.vue
index 6159680f1e6..0d03c1c419c 100644
--- a/app/assets/javascripts/boards/components/board_list.js
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -1,6 +1,7 @@
-/* global Sortable */
-import boardNewIssue from './board_new_issue';
-import boardCard from './board_card';
+<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';
@@ -8,7 +9,17 @@ 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,
@@ -42,46 +53,6 @@ export default {
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.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) {
- this.loadNextPage();
- }
- },
- },
watch: {
filters: {
handler() {
@@ -115,7 +86,7 @@ export default {
},
mounted() {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
- scroll: document.querySelectorAll('.boards-list')[0],
+ scroll: true,
group: 'issues',
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
@@ -157,51 +128,92 @@ export default {
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>
- <board-new-issue
- :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"
- :root-path="rootPath"
- :disabled="disabled"
- :key="issue.id" />
- <li
- class="board-list-count text-center"
- v-if="showCount"
- data-id="-1">
+ 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;
+ };
- <loading-icon
- v-show="list.loadingMore"
- label="Loading more issues"
- />
+ 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>
- <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>
+<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.vue
index 541b8049855..870d242e774 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.js
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -1,11 +1,21 @@
-/* global ListIssue */
+<script>
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,
@@ -15,8 +25,21 @@ export default {
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();
@@ -30,6 +53,7 @@ export default {
labels,
subscribed: true,
assignees: [],
+ project_id: this.selectedProject.id,
});
eventHub.$emit(`scroll-board-list-${this.list.id}`);
@@ -58,43 +82,62 @@ export default {
this.title = '';
eventHub.$emit(`hide-issue-form-${this.list.id}`);
},
+ setSelectedProject(selectedProject) {
+ this.selectedProject = selectedProject;
+ },
},
- mounted() {
- this.$refs.input.focus();
- },
- template: `
- <div class="card board-new-issue-form">
+};
+</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-container"
+ v-if="error"
+ >
<div class="flash-alert">
- An error occured. Please try again.
+ An error occurred. Please try again.
</div>
</div>
- <label class="label-light"
- :for="list.id + '-title'">
+ <label
+ class="label-light"
+ :for="list.id + '-title'"
+ >
Title
</label>
- <input class="form-control"
+ <input
+ class="form-control"
type="text"
v-model="title"
ref="input"
autocomplete="off"
- :id="list.id + '-title'" />
+ :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"
+ <button
+ class="btn btn-success pull-left"
type="submit"
- :disabled="title === ''"
- ref="submit-button">
+ :disabled="disabled"
+ ref="submit-button"
+ >
Submit issue
</button>
- <button class="btn btn-default pull-right"
+ <button
+ class="btn btn-default pull-right"
type="button"
- @click="cancel">
+ @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 590b7be36e3..9501e35b178 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -1,15 +1,18 @@
/* eslint-disable comma-dangle, space-before-function-paren, no-new */
-/* global IssuableContext */
-/* global MilestoneSelect */
-/* global LabelsSelect */
-/* global Sidebar */
-/* global Flash */
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';
+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;
@@ -93,7 +96,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'));
});
},
},
@@ -113,14 +116,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 bf474879024..fc2bad2415f 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -31,6 +31,10 @@ gl.issueBoards.IssueCardInner = Vue.extend({
required: false,
default: false,
},
+ groupId: {
+ type: Number,
+ required: false,
+ },
},
data() {
return {
@@ -64,7 +68,13 @@ gl.issueBoards.IssueCardInner = Vue.extend({
return this.issue.assignees.length > this.numberOverLimit;
},
cardUrl() {
- return `${this.issueLinkBase}/${this.issue.iid}`;
+ 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() {
if (this.issue.iid) {
@@ -148,7 +158,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({
class="card-number"
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/footer.js b/app/assets/javascripts/boards/components/modal/footer.js
index a656f0546c0..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: {
@@ -35,7 +35,7 @@ gl.issueBoards.ModalFooter = Vue.extend({
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 d2044f20ebe..d825ff38587 100644
--- a/app/assets/javascripts/boards/components/modal/index.js
+++ b/app/assets/javascripts/boards/components/modal/index.js
@@ -89,7 +89,7 @@ gl.issueBoards.IssuesModal = Vue.extend({
page: this.page,
per: this.perPage,
}))
- .then(resp => resp.json())
+ .then(res => res.data)
.then((data) => {
if (clearIssues) {
this.issues = [];
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index d7f203b3f96..362ef43e6f7 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -1,6 +1,8 @@
-/* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var,
+/* eslint-disable func-names, no-new, space-before-function-paren, one-var,
promise/catch-or-return */
+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 || {};
@@ -15,21 +17,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-list-labels-path'))
- .then((resp) => {
- callback(resp);
+ axios.get($this.attr('data-list-labels-path'))
+ .then(({ data }) => {
+ callback(data);
});
},
renderRow (label) {
@@ -38,17 +40,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,
@@ -66,13 +68,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..d99b222c305
--- /dev/null
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -0,0 +1,127 @@
+<script>
+ /* global ListIssue */
+ 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 1e623cf58b7..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;
@@ -25,7 +24,7 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
},
computed: {
updateUrl() {
- return this.issueUpdate;
+ return this.issueUpdate.replace(':project_path', this.issue.project.path);
},
},
methods: {
@@ -33,19 +32,23 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
const issue = this.issue;
const lists = issue.getLists();
const listLabelIds = lists.map(list => list.label.id);
- let labelIds = this.issue.labels
+
+ 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
Vue.http.patch(this.updateUrl, data).catch(() => {
- new Flash('Failed to remove issue from board, please try again.', 'alert');
+ Flash(__('Failed to remove issue from board, please try again.'));
lists.forEach((list) => {
list.addIssue(issue);
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/boards_bundle.js b/app/assets/javascripts/boards/index.js
index ea00efe4b46..efc0da2e7a2 100644
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,20 +1,23 @@
/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */
-/* global BoardService */
-/* global Flash */
import _ from 'underscore';
import Vue from 'vue';
-import VueResource from 'vue-resource';
+
+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/label';
import './models/list';
import './models/milestone';
+import './models/project';
import './models/assignee';
import './stores/boards_store';
import './stores/modal_store';
-import './services/board_service';
+import BoardService from './services/board_service';
import './mixins/modal_mixins';
import './mixins/sortable_default_options';
import './filters/due_date_filters';
@@ -22,11 +25,9 @@ import './components/board';
import './components/board_sidebar';
import './components/new_list_dropdown';
import './components/modal/index';
-import '../vue_shared/vue_resource_interceptor';
+import '~/vue_shared/vue_resource_interceptor'; // eslint-disable-line import/first
-Vue.use(VueResource);
-
-$(() => {
+export default () => {
const $boardApp = document.getElementById('board-app');
const Store = gl.issueBoards.BoardsStore;
const ModalStore = gl.issueBoards.ModalStore;
@@ -77,26 +78,30 @@ $(() => {
});
Store.rootPath = this.boardsEndpoint;
- this.filterManager = new FilteredSearchBoards(Store.filter, true);
- this.filterManager.setup();
-
- // Listen for updateTokens event
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(response => response.json())
- .then((resp) => {
- resp.forEach((board) => {
+ .then(res => res.data)
+ .then((data) => {
+ data.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;
}
@@ -107,11 +112,53 @@ $(() => {
Store.addBlankState();
this.loading = false;
})
- .catch(() => new Flash('An error occurred. Please try again.'));
+ .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'));
+ });
+ }
}
},
});
@@ -127,19 +174,15 @@ $(() => {
});
gl.IssueBoardsModalAddBtn = new Vue({
- mixins: [gl.issueBoards.ModalMixins],
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'),
};
},
- watch: {
- disabled() {
- this.updateTooltip();
- },
- },
computed: {
disabled() {
if (!this.store) {
@@ -155,6 +198,14 @@ $(() => {
return '';
},
},
+ watch: {
+ disabled() {
+ this.updateTooltip();
+ },
+ },
+ mounted() {
+ this.updateTooltip();
+ },
methods: {
updateTooltip() {
const $tooltip = $(this.$refs.addIssuesButton);
@@ -173,9 +224,6 @@ $(() => {
}
},
},
- mounted() {
- this.updateTooltip();
- },
template: `
<div class="board-extra-actions">
<button
@@ -186,10 +234,11 @@ $(() => {
: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..5e31c6314b2 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js
@@ -1,6 +1,8 @@
/* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */
/* global DocumentTouch */
+import sortableConfig from '../../sortable/sortable_config';
+
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
@@ -18,19 +20,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 407db176446..4c5079efc8b 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -4,6 +4,7 @@
/* global ListAssignee */
import Vue from 'vue';
+import IssueProject from './project';
class ListIssue {
constructor (obj, defaultAvatar) {
@@ -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/list.js b/app/assets/javascripts/boards/models/list.js
index df2809e1805..e210d69895e 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -40,7 +40,7 @@ class List {
save () {
return gl.boardService.createList(this.label.id)
- .then(resp => resp.json())
+ .then(res => res.data)
.then((data) => {
this.id = data.id;
this.type = data.list_type;
@@ -90,7 +90,7 @@ class List {
}
return gl.boardService.getIssuesForList(this.id, data)
- .then(resp => resp.json())
+ .then(res => res.data)
.then((data) => {
this.loading = false;
this.issuesSize = data.size;
@@ -108,7 +108,7 @@ class List {
this.issuesSize += 1;
return gl.boardService.newIssue(this.id, issue)
- .then(resp => resp.json())
+ .then(res => res.data)
.then((data) => {
issue.id = data.id;
issue.iid = data.iid;
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 38eea38f949..d78d4701974 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -1,82 +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';
-import Vue from 'vue';
+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;
+ }
-class BoardService {
- constructor ({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
- this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
- issues: {
- method: 'GET',
- url: `${gon.relative_url_root}/boards/${boardId}/issues.json`,
- }
- });
- this.lists = Vue.resource(`${listsEndpoint}{/id}`, {}, {
- generate: {
- method: 'POST',
- url: `${listsEndpoint}/generate.json`
- }
- });
- this.issue = Vue.resource(`${gon.relative_url_root}/boards/${boardId}/issues{/id}`, {});
- this.issues = Vue.resource(`${listsEndpoint}{/id}/issues`, {}, {
- bulkUpdate: {
- method: 'POST',
- url: bulkUpdatePath,
- },
- });
+ generateBoardsPath(id) {
+ return `${this.boardsEndpoint}${id ? `/${id}` : ''}.json`;
+ }
+
+ generateIssuesPath(id) {
+ return `${this.listsEndpoint}${id ? `/${id}` : ''}/issues`;
}
- all () {
- return this.lists.get();
+ static generateIssuePath(boardId, id) {
+ return `${gon.relative_url_root}/-/boards/${boardId ? `/${boardId}` : ''}/issues${id ? `/${id}` : ''}`;
}
- generateDefaultLists () {
- return this.lists.generate({});
+ all() {
+ return axios.get(this.listsEndpoint);
}
- createList (label_id) {
- return this.lists.save({}, {
+ generateDefaultLists() {
+ return axios.post(this.listsEndpointGenerate, {});
+ }
+
+ 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_id = null, move_after_id = null) {
- return this.issue.update({ id }, {
- from_list_id,
- to_list_id,
- move_before_id,
- move_after_id,
+ 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 = {}) {
@@ -86,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 ea82958e80d..348cdeec737 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -2,7 +2,7 @@
/* global List */
import _ from 'underscore';
import Cookies from 'js-cookie';
-import { getUrlParamsArray } from '../../lib/utils/common_utils';
+import { getUrlParamsArray } from '~/lib/utils/common_utils';
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
@@ -14,16 +14,18 @@ gl.issueBoards.BoardsStore = {
},
state: {},
detail: {
- issue: {}
+ issue: {},
},
moving: {
issue: {},
- list: {}
+ list: {},
},
create () {
this.state.lists = [];
this.filter.path = getUrlParamsArray().join('&');
- this.detail = { issue: {} };
+ 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/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_artifacts.js b/app/assets/javascripts/build_artifacts.js
index bd479700fd3..ace89398943 100644
--- a/app/assets/javascripts/build_artifacts.js
+++ b/app/assets/javascripts/build_artifacts.js
@@ -1,25 +1,45 @@
-/* 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 */
+import { visitUrl } from './lib/utils/url_utility';
+import { convertPermissionToBoolean } from './lib/utils/common_utils';
-window.BuildArtifacts = (function() {
- function BuildArtifacts() {
+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 c955a9ac2ea..35edf3e0017 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 */
+/* eslint-disable func-names*/
-$(function() {
- $('.reveal-variables').off('click').on('click', function() {
- $('.js-build-variables').toggle();
- $(this).hide();
- });
-});
+export default function handleRevealVariables() {
+ $('.js-reveal-variables')
+ .off('click')
+ .on('click', function () {
+ $('.js-build-variables').toggle();
+ $(this).hide();
+ });
+}
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..745f3404295
--- /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: '',
+ },
+ 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.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..d54ea7df1c3
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
@@ -0,0 +1,26 @@
+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/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..27136c7289f
--- /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')"
+ css-class="btn btn-default 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/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 4763985c802..6504a0bbbfc 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -1,220 +1,208 @@
/* 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) {
- 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);
- let loadedCount = 0;
-
- images.on('load', () => {
- loadedCount += 1;
-
- if (loadedCount === images.length) {
- _this.initView('two-up');
- }
- });
+
+// 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
- });
- // Set swipeBar left position to match image frame
- $swipeBar.css({
- left: 1
- });
-
- 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 1f9153d95bd..3d89bf1316e 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -15,7 +15,7 @@ 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) {
@@ -43,4 +43,4 @@ document.addEventListener('DOMContentLoaded', () => {
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 0661087a1ba..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,
@@ -17,18 +20,16 @@
type: String,
required: true,
},
- emptyStateSvgPath: {
+ errorStateSvgPath: {
type: String,
required: true,
},
- errorStateSvgPath: {
+ viewType: {
type: String,
- required: true,
+ required: false,
+ default: 'child',
},
},
- mixins: [
- pipelinesMixin,
- ],
data() {
const store = new PipelineStore();
@@ -40,23 +41,14 @@
},
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);
@@ -87,30 +79,29 @@
<div class="content-list pipelines">
<loading-icon
- label="Loading pipelines"
+ :label="s__('Pipelines|Loading Pipelines')"
size="3"
v-if="isLoading"
- />
+ class="prepend-top-20"
+ />
- <empty-state
- v-if="shouldRenderEmptyState"
- :help-page-path="helpPagePath"
- :empty-state-svg-path="emptyStateSvgPath"
- />
-
- <error-state
- v-if="shouldRenderErrorState"
- :error-state-svg-path="errorStateSvgPath"
- />
+ <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..f76c9b7e690
--- /dev/null
+++ b/app/assets/javascripts/commit_merge_requests.js
@@ -0,0 +1,73 @@
+/* global Flash */
+
+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 047544b1762..2be63bd8c76 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -1,74 +1,64 @@
-/* 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 { 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(parseInt(limit, 10), 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 +71,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 c11b7d5f340..db96da4ccba 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -13,6 +13,6 @@ 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 768453b28f1..0d2fe2925d8 100644
--- a/app/assets/javascripts/commons/index.js
+++ b/app/assets/javascripts/commons/index.js
@@ -3,3 +3,4 @@ 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 b93e94a3c97..a7ed175f7a4 100644
--- a/app/assets/javascripts/commons/jquery.js
+++ b/app/assets/javascripts/commons/jquery.js
@@ -6,5 +6,5 @@ import 'vendor/jquery.endless-scroll';
import 'vendor/jquery.caret';
import 'vendor/jquery.atwho';
import 'vendor/jquery.scrollTo';
-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 cb5a9a9f6b5..46232726510 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -8,8 +8,11 @@ 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';
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/vue.js b/app/assets/javascripts/commons/vue.js
index 8b62d78c043..798623b94fb 100644
--- a/app/assets/javascripts/commons/vue.js
+++ b/app/assets/javascripts/commons/vue.js
@@ -1,4 +1,5 @@
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..d5a35ed81a6 100644
--- a/app/assets/javascripts/compare.js
+++ b/app/assets/javascripts/compare.js
@@ -1,7 +1,9 @@
/* 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 */
+import { localTimeAgo } from './lib/utils/datetime_utility';
+import axios from './lib/utils/axios_utils';
-window.Compare = (function() {
- function Compare(opts) {
+export default class Compare {
+ constructor(opts) {
this.opts = opts;
this.source_loading = $(".js-source-loading");
this.target_loading = $(".js-target-loading");
@@ -11,7 +13,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 +36,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);
+
+ loading.show();
+ $target.empty();
- return Compare;
-})();
+ 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..fa341918fc1 100644
--- a/app/assets/javascripts/compare_autocomplete.js
+++ b/app/assets/javascripts/compare_autocomplete.js
@@ -1,68 +1,62 @@
/* 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 */
+import { __ } from './locale';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
-window.CompareAutocomplete = (function() {
- function CompareAutocomplete() {
- this.initDropdown();
- }
-
- 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();
- }
- });
- $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');
+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);
}
- });
+ },
+ 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/new_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index 997550b37fb..74520675a7c 100644
--- a/app/assets/javascripts/new_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -2,14 +2,14 @@ import Cookies from 'js-cookie';
import _ from 'underscore';
import bp from './breakpoints';
-export default class NewNavSidebar {
+export default class ContextualSidebar {
constructor() {
this.initDomElements();
this.render();
}
initDomElements() {
- this.$page = $('.page-with-sidebar');
+ this.$page = $('.layout-page');
this.$sidebar = $('.nav-sidebar');
this.$innerScroll = $('.nav-sidebar-inner-scroll', this.$sidebar);
this.$overlay = $('.mobile-overlay');
@@ -28,7 +28,7 @@ export default class NewNavSidebar {
this.$closeSidebar.on('click', () => this.toggleSidebarNav(false));
this.$overlay.on('click', () => this.toggleSidebarNav(false));
this.$sidebarToggle.on('click', () => {
- const value = !this.$sidebar.hasClass('sidebar-icons-only');
+ const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop');
this.toggleCollapsedSidebar(value);
});
@@ -43,19 +43,19 @@ export default class NewNavSidebar {
}
toggleSidebarNav(show) {
- this.$sidebar.toggleClass('nav-sidebar-expanded', show);
+ this.$sidebar.toggleClass('sidebar-expanded-mobile', show);
this.$overlay.toggleClass('mobile-nav-open', show);
- this.$sidebar.removeClass('sidebar-icons-only');
+ this.$sidebar.removeClass('sidebar-collapsed-desktop');
}
toggleCollapsedSidebar(collapsed) {
const breakpoint = bp.getBreakpointSize();
if (this.$sidebar.length) {
- this.$sidebar.toggleClass('sidebar-icons-only', collapsed);
+ this.$sidebar.toggleClass('sidebar-collapsed-desktop', collapsed);
this.$page.toggleClass('page-with-icon-sidebar', breakpoint === 'sm' ? true : collapsed);
}
- NewNavSidebar.setCollapsedCookie(collapsed);
+ ContextualSidebar.setCollapsedCookie(collapsed);
this.toggleSidebarOverflow();
}
diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js
deleted file mode 100644
index 1f3c7e1772d..00000000000
--- a/app/assets/javascripts/copy_to_clipboard.js
+++ /dev/null
@@ -1,74 +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');
-
- if (!$target.data('hideTooltip')) {
- $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..9a4c9bfcc80 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -1,8 +1,9 @@
-/* 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 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 +23,7 @@ class CreateLabelDropdown {
this.addBinding();
}
- cleanBinding () {
+ cleanBinding() {
this.$colorSuggestions.off('click');
this.$newLabelField.off('keyup change');
this.$newColorField.off('keyup change');
@@ -31,7 +32,7 @@ class CreateLabelDropdown {
this.$newLabelCreateButton.off('click');
}
- addBinding () {
+ addBinding() {
const self = this;
this.$colorSuggestions.on('click', function (e) {
@@ -44,7 +45,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 +56,7 @@ class CreateLabelDropdown {
this.$newLabelCreateButton.on('click', this.saveLabel.bind(this));
}
- addColorValue (e, $this) {
+ addColorValue(e, $this) {
e.preventDefault();
e.stopPropagation();
@@ -66,7 +67,7 @@ class CreateLabelDropdown {
.addClass('is-active');
}
- enableLabelCreateButton () {
+ enableLabelCreateButton() {
if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') {
this.$newLabelError.hide();
this.$newLabelCreateButton.enable();
@@ -75,7 +76,7 @@ class CreateLabelDropdown {
}
}
- resetForm () {
+ resetForm() {
this.$newLabelField
.val('')
.trigger('change');
@@ -90,13 +91,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 +108,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 +123,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 6583e471a48..46d89c825f9 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -1,62 +1,64 @@
-/* global Flash */
-
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,17 +79,17 @@ $(() => {
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;
});
},
@@ -100,27 +102,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 +132,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 b41d464475f..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">
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 6a008112203..3df082e8c0c 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -1,13 +1,15 @@
-/* eslint-disable class-methods-use-this */
-
-import './lib/utils/url_utility';
+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');
@@ -17,18 +19,22 @@ class Diff {
}
});
- FilesCommentButton.init($diffFile);
+ const tab = document.getElementById('diffs');
+ if (!tab || (tab && tab.dataset && tab.dataset.isLocked !== '')) FilesCommentButton.init($diffFile);
- $diffFile.each((index, file) => new gl.ImageFile(file));
+ 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();
}
@@ -62,15 +68,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;
@@ -99,11 +107,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) {
@@ -111,9 +131,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');
@@ -124,6 +144,3 @@ class Diff {
}
}
}
-
-window.gl = window.gl || {};
-window.gl.Diff = Diff;
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 06ce84d7599..300b02da663 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,8 @@
/* global CommentsStore */
-/* global notes */
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({
@@ -129,7 +129,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();
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 497c23f014f..fadc34959e1 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -171,7 +171,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
@@ -190,7 +197,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 efb6ced9f46..cc9192deae3 100644
--- a/app/assets/javascripts/diff_notes/components/resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js
@@ -1,9 +1,9 @@
/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, max-len */
/* global CommentsStore */
/* global ResolveService */
-/* global Flash */
import Vue from 'vue';
+import Flash from '../../flash';
const ResolveBtn = Vue.extend({
props: {
@@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus();
+ document.dispatchEvent(new CustomEvent('refreshVueNotes'));
this.updateTooltip();
})
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
index 0863c3406bd..5f49609fe88 100644
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js
+++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
@@ -14,9 +14,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 || {};
@@ -66,12 +68,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..1b8a9af9390 100644
--- a/app/assets/javascripts/diff_notes/models/discussion.js
+++ b/app/assets/javascripts/diff_notes/models/discussion.js
@@ -2,6 +2,7 @@
/* global NoteModel */
import Vue from 'vue';
+import { localTimeAgo } from '../../lib/utils/datetime_utility';
class DiscussionModel {
constructor (discussionId) {
@@ -71,7 +72,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 2f063f6fe1f..d16f9297de1 100644
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ b/app/assets/javascripts/diff_notes/services/resolve.js
@@ -1,15 +1,15 @@
-/* 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) {
@@ -43,8 +43,9 @@ class ResolveServiceClass {
discussion.resolveAllNotes(resolvedBy);
}
- gl.mrWidget.checkStatus();
+ if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data);
+ document.dispatchEvent(new CustomEvent('refreshVueNotes'));
})
.catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.'));
}
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 31214818496..1ccf96a75dc 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -1,660 +1,85 @@
/* 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 ProjectSelect */
-/* global ShortcutsNavigation */
-/* global IssuableIndex */
-/* global ShortcutsIssuable */
-/* global Milestone */
-/* global IssuableForm */
-/* global LabelsSelect */
-/* global MilestoneSelect */
-/* global Commit */
-/* global CommitsList */
-/* global NewBranchForm */
-/* global NotificationsForm */
-/* global NotificationsDropdown */
-/* global GroupAvatar */
-/* global LineHighlighter */
-/* global ProjectFork */
-/* global BuildArtifacts */
-/* global GroupsSelect */
-/* global Search */
-/* global Admin */
-/* global NamespaceSelects */
-/* global NewCommitForm */
-/* global NewBranchForm */
-/* global Project */
-/* global ProjectAvatar */
-/* global MergeRequest */
-/* global Compare */
-/* global CompareAutocomplete */
-/* global ProjectFindFile */
-/* global ProjectNew */
-/* global ProjectShow */
-/* global ProjectImport */
-/* global Labels */
-/* global Shortcuts */
-/* global ShortcutsFindFile */
-/* 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 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 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 Flash from './flash';
import GfmAutoComplete from './gfm_auto_complete';
-import ShortcutsBlob from './shortcuts_blob';
-import SigninTabsMemoizer from './signin_tabs_memoizer';
-import Star from './star';
-import Todos from './todos';
-import TreeView from './tree';
-import UsagePing from './usage_ping';
-import UsernameValidator from './username_validator';
-import VersionCheckImage from './version_check_image';
-import Wikis from './wikis';
-import ZenMode from './zen_mode';
-import initSettingsPanels from './settings_panels';
-import initExperimentalFlags from './experimental_flags';
-import OAuthRememberMe from './oauth_remember_me';
-import PerformanceBar from './performance_bar';
-import initNotes from './init_notes';
-import initLegacyFilters from './init_legacy_filters';
-import initIssuableSidebar from './init_issuable_sidebar';
-import initProjectVisibilitySelector from './project_visibility';
-import GpgBadges from './gpg_badges';
-import UserFeatureHelper from './helpers/user_feature_helper';
-import initChangesDropdown from './init_changes_dropdown';
-import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
-
-(function() {
- var Dispatcher;
-
- 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: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 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();
- }
-
- const filteredSearchEnabled = gl.FilteredSearchManager && document.querySelector('.filtered-search');
-
- switch (page) {
- case 'profiles:preferences:show':
- initExperimentalFlags();
- break;
- case 'sessions:new':
- new UsernameValidator();
- new SigninTabsMemoizer();
- new OAuthRememberMe({ container: $(".omniauth-container") }).bindEvents();
- 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 (filteredSearchEnabled) {
- const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
- filteredSearchManager.setup();
- }
- if (page === 'projects:merge_requests:index') {
- new UserCallout({ setCalloutPerProject: true });
- }
- 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();
- initIssuableSidebar();
- break;
- case 'dashboard:milestones:index':
- new ProjectSelect();
- break;
- case 'projects:milestones:show':
- case 'groups:milestones:show':
- case 'dashboard:milestones:show':
- new Milestone();
- new Sidebar();
- break;
- case 'dashboard:issues':
- case 'dashboard:merge_requests':
- new ProjectSelect();
- initLegacyFilters();
- break;
- case 'groups:issues':
- case 'groups:merge_requests':
- if (filteredSearchEnabled) {
- const filteredSearchManager = new gl.FilteredSearchManager(page === 'groups:issues' ? 'issues' : 'merge_requests');
- filteredSearchManager.setup();
- }
- new ProjectSelect();
- break;
- case 'dashboard:todos:index':
- new 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();
- initChangesDropdown();
- break;
- case 'projects:branches:new':
- case 'projects:branches:create':
- new NewBranchForm($('.js-create-branch-form'), JSON.parse(document.getElementById('availableRefs').innerHTML));
- 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:creations:new':
- const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare');
- if (mrNewCompareNode) {
- new Compare({
- targetProjectUrl: mrNewCompareNode.dataset.targetProjectUrl,
- sourceBranchUrl: mrNewCompareNode.dataset.sourceBranchUrl,
- targetBranchUrl: mrNewCompareNode.dataset.targetBranchUrl,
- });
- } else {
- const mrNewSubmitNode = document.querySelector('.js-merge-request-new-submit');
- new MergeRequest({
- action: mrNewSubmitNode.dataset.mrSubmitAction,
- });
- }
- case 'projects:merge_requests:creations: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'));
- break;
- case 'projects:snippets:show':
- initNotes();
- 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();
-
- initIssuableSidebar();
- initNotes();
-
- const mrShowNode = document.querySelector('.merge-request');
- window.mergeRequest = new MergeRequest({
- action: mrShowNode.dataset.mrAction,
- });
- break;
- case 'dashboard:activity':
- new gl.Activities();
- break;
- case 'projects:commit:show':
- new Commit();
- new gl.Diff();
- new ZenMode();
- shortcut_handler = new ShortcutsNavigation();
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
- initNotes();
- initChangesDropdown();
- $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
- break;
- case 'projects:commit:pipelines':
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
- $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
- break;
- case 'projects:activity':
- new gl.Activities();
- shortcut_handler = new ShortcutsNavigation();
- break;
- case 'projects:commits:show':
- CommitsList.init(document.querySelector('.js-project-commits-show').dataset.commitsLimit);
- shortcut_handler = new ShortcutsNavigation();
- GpgBadges.fetch();
- break;
- case 'projects:show':
- shortcut_handler = new ShortcutsNavigation();
- new NotificationsForm();
- new UserCallout({ setCalloutPerProject: true });
-
- if ($('#tree-slider').length) new TreeView();
- if ($('.blob-viewer').length) new BlobViewer();
- if ($('.project-show-activity').length) new gl.Activities();
- $('#tree-slider').waitForImages(function() {
- ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
- });
- break;
- case 'projects:edit':
- setupProjectEdit();
- // Initialize expandable settings panels
- initSettingsPanels();
- break;
- case 'projects:imports:show':
- new ProjectImport();
- break;
- case 'projects:pipelines:new':
- new NewBranchForm($('.js-new-pipeline-form'));
- break;
- case 'projects:pipelines:index':
- new UserCallout({ setCalloutPerProject: true });
- 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:project_members:index':
- 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();
-
- if (UserFeatureHelper.isNewRepoEnabled()) break;
-
- new TreeView();
- new BlobViewer();
- new NewCommitForm($('.js-create-dir-form'));
- new UserCallout({ setCalloutPerProject: true });
- $('#tree-slider').waitForImages(function() {
- ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
- });
- break;
- case 'projects:find_file:show':
- 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);
- shortcut_handler = true;
- break;
- case 'projects:blob:show':
- if (UserFeatureHelper.isNewRepoEnabled()) break;
- 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 'groups:labels:index':
- case 'projects:labels:index':
- if ($('.prioritized-labels').length) {
- new gl.LabelManager();
- }
- $('.label-subscription').each((i, el) => {
- const $el = $(el);
-
- if ($el.find('.dropdown-group-label').length) {
- new gl.GroupLabelSubscription($el);
- } else {
- new gl.ProjectLabelSubscription($el);
- }
- });
- 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':
- VersionCheckImage.bindErrorEvent($('img.js-version-status-badge'));
- break;
- case 'search:show':
- new Search();
- break;
- case 'projects:settings:repository:show':
- // Initialize expandable settings panels
- initSettingsPanels();
- break;
- case 'projects:settings:ci_cd:show':
- // Initialize expandable settings panels
- initSettingsPanels();
- case 'groups:settings: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();
- initNotes();
- break;
- case 'import:fogbugz:new_user_map':
- new UsersSelect();
- break;
- case 'profiles:personal_access_tokens:index':
- case 'admin:impersonation_tokens:index':
- new gl.DueDateSelectors();
- break;
- }
- switch (path[0]) {
- 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 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 'profiles':
- new NotificationsForm();
- new NotificationsDropdown();
- break;
- case 'projects':
- new Project();
- new ProjectAvatar();
- switch (path[1]) {
- case 'compare':
- new CompareAutocomplete();
- break;
- case 'edit':
- shortcut_handler = new ShortcutsNavigation();
- new ProjectNew();
- import(/* webpackChunkName: 'project_permissions' */ './projects/permissions')
- .then(permissions => permissions.default())
- .catch(() => {});
- break;
- case 'new':
- new ProjectNew();
- initProjectVisibilitySelector();
- break;
- case 'show':
- new Star();
- new ProjectNew();
- new ProjectShow();
- new NotificationsDropdown();
- break;
- case 'wikis':
- new 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();
- }
- break;
- case 'users':
- const action = path[1];
- import(/* webpackChunkName: 'user_profile' */ './users')
- .then(user => user.default(action))
- .catch(() => {});
- break;
- }
- // If we haven't installed a custom shortcut handler, install the default one
- if (!shortcut_handler) {
- new Shortcuts();
- }
-
- if (document.querySelector('#peek')) {
- new PerformanceBar({ container: '#peek' });
- }
- };
-
- 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;
- })();
-
- $(window).on('load', function() {
- 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);
+ });
+}
+
+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,
+ });
});
-}).call(window);
+}
+
+function initPerformanceBar() {
+ if (document.querySelector('#peek')) {
+ import('./performance_bar')
+ .then(m => new m.default({ container: '#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 3901bb177fe..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) {
@@ -110,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() {
@@ -117,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() {
@@ -128,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/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 1cba65d17cd..ba89e5726fa 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -1,305 +1,276 @@
-/* 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 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) {
- 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', (function(_this) {
- return function(event) {
- return handlePaste(event);
- };
- })(this));
-
- // 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) return;
-
- const dropzone = $formDropzone.dropzone({
- url: uploadsPath,
- dictDefaultMessage: '',
- clickable: true,
- paramName: 'file',
- maxFilesize: maxFileSize,
- uploadMultiple: false,
- headers: csrf.headers,
- 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');
- }
- });
-
- const 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('.js-main-target-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('.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, i) => {
- const file = failedFile;
-
- if (file.status === Dropzone.ERROR) {
- file.status = undefined;
- file.accepted = undefined;
- }
-
- return dropzoneInstance.addFile(file);
- });
- });
-
- handlePaste = function(event) {
- var filename, image, pasteEvent, text;
- pasteEvent = event.originalEvent;
- 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);
- }
- }
- };
-
- 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;
- }
- 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[0];
- };
-
- const showSpinner = function(e) {
- return $uploadingProgressContainer.removeClass('hide');
- };
-
- const closeSpinner = function() {
- return $uploadingProgressContainer.addClass('hide');
- };
+ 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;
- const showError = function(message) {
$uploadingErrorContainer.removeClass('hide');
$uploadingErrorMessage.html(message);
- };
-
- const closeAlertMessage = function() {
- return form.find('.div-dropzone-alert').alert('close');
- };
-
- const insertToTextArea = function(filename, url) {
- return $(child).val(function(index, val) {
- return val.replace(`{{${filename}}}`, url);
- });
- };
-
- const appendToTextArea = function(url) {
- return $(child).val(function(index, val) {
- return val + url + "\n";
- });
- };
-
- 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: csrf.headers,
- 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 -';
+ $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;
}
- messageContainer.text(attachingMessage);
- };
-
- form.find('.markdown-selector').click(function(e) {
- e.preventDefault();
- $(this).closest('.gfm-form').find('.div-dropzone').click();
- formTextarea.focus();
+ return dropzoneInstance.addFile(file);
});
- }
-
- return DropzoneInput;
-})();
+ });
+ // 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 = (data) => {
+ let i = 0;
+ while (i < data.clipboardData.items.length) {
+ const item = data.clipboardData.items[i];
+ if (item.type.indexOf('image') !== -1) {
+ return item;
+ }
+ 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();
+ });
+ };
+
+ 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 ee71728184f..417258e0092 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -1,8 +1,8 @@
-/* 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 */
import Pikaday from 'pikaday';
-import DateFix from './lib/utils/datefix';
+import axios from './lib/utils/axios_utils';
+import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
class DueDateSelect {
constructor({ $dropdown, $loading } = {}) {
@@ -17,9 +17,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;
@@ -39,20 +39,20 @@ class DueDateSelect {
hidden: () => {
this.$selectbox.hide();
this.$value.css('display', '');
- }
+ },
});
}
initDatePicker() {
const $dueDateInput = $(`input[name='${this.fieldName}']`);
- const dateFix = DateFix.dashedFix($dueDateInput.val());
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();
@@ -60,10 +60,10 @@ class DueDateSelect {
} else {
this.saveDueDate(true);
}
- }
+ },
});
- calendar.setDate(dateFix);
+ calendar.setDate(parsePikadayDate($dueDateInput.val()));
this.$datePicker.append(calendar.el);
this.$datePicker.data('pikaday', calendar);
}
@@ -79,8 +79,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);
}
});
}
@@ -111,7 +111,7 @@ class DueDateSelect {
this.datePayload = datePayload;
}
- updateIssueBoardIssue () {
+ updateIssueBoardIssue() {
this.$loading.fadeIn();
this.$dropdown.trigger('loading.gl.dropdown');
this.$selectbox.hide();
@@ -126,61 +126,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 dateFix = DateFix.dashedFix($datePicker.val());
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(dateFix);
+ calendar.setDate(parsePikadayDate($datePicker.val()));
$datePicker.data('pikaday', calendar);
});
@@ -191,19 +185,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/support/is_emoji_unicode_supported.js b/app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js
index 3fd23efa9f8..e9defb62cf8 100644
--- a/app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js
+++ b/app/assets/javascripts/emoji/support/is_emoji_unicode_supported.js
@@ -7,6 +7,17 @@ function isFlagEmoji(emojiUnicode) {
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
@@ -57,9 +68,11 @@ function isPersonZwjEmoji(emojiUnicode) {
// in `isEmojiUnicodeSupported` logic
function checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) {
const isFlagResult = isFlagEmoji(emojiUnicode);
+ const isRainbowFlagResult = isRainbowFlagEmoji(emojiUnicode);
return (
(unicodeSupportMap.flag && isFlagResult) ||
- !isFlagResult
+ (unicodeSupportMap.rainbowFlag && isRainbowFlagResult) ||
+ (!isFlagResult && !isRainbowFlagResult)
);
}
@@ -113,6 +126,7 @@ function isEmojiUnicodeSupported(unicodeSupportMap = {}, emojiUnicode, unicodeVe
export {
isEmojiUnicodeSupported as default,
isFlagEmoji,
+ isRainbowFlagEmoji,
isKeycapEmoji,
isSkinToneComboEmoji,
isHorceRacingSkinToneComboEmoji,
diff --git a/app/assets/javascripts/emoji/support/unicode_support_map.js b/app/assets/javascripts/emoji/support/unicode_support_map.js
index 755381c2f95..c18d07dad43 100644
--- a/app/assets/javascripts/emoji/support/unicode_support_map.js
+++ b/app/assets/javascripts/emoji/support/unicode_support_map.js
@@ -1,5 +1,7 @@
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}',
@@ -13,6 +15,7 @@ const unicodeSupportTestMap = {
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
@@ -141,23 +144,31 @@ function generateUnicodeSupportMap(testMap) {
}
export default function getUnicodeSupportMap() {
- let unicodeSupportMap;
- let userAgentFromCache;
-
const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
- if (isLocalStorageAvailable) userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent');
+ 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 || userAgentFromCache !== navigator.userAgent) {
+ 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));
}
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 14fde1afb16..00000000000
--- a/app/assets/javascripts/environments/components/environment.vue
+++ /dev/null
@@ -1,268 +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 { convertPermissionToBoolean, getParameterByName, setParamInURL } from '../../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,
- 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 getParameterByName('scope');
- },
-
- canReadEnvironmentParsed() {
- return convertPermissionToBoolean(this.canReadEnvironment);
- },
-
- canCreateDeploymentParsed() {
- return convertPermissionToBoolean(this.canCreateDeployment);
- },
-
- canCreateEnvironmentParsed() {
- return convertPermissionToBoolean(this.canCreateEnvironment);
- },
- },
-
- /**
- * Fetches all the environments and stores them.
- * Toggles loading property.
- */
- created() {
- const scope = getParameterByName('scope') || this.visibility;
- const page = getParameterByName('page') || this.pageNumber;
-
- this.service = new EnvironmentsService(this.endpoint);
-
- const poll = new Poll({
- resource: this.service,
- method: 'get',
- data: { scope, page },
- successCallback: this.successCallback,
- errorCallback: this.errorCallback,
- notificationCallback: (isMakingRequest) => {
- this.isMakingRequest = isMakingRequest;
- },
- });
-
- if (!Visibility.hidden()) {
- this.isLoading = true;
- poll.makeRequest();
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- poll.restart();
- } else {
- poll.stop();
- }
- });
-
- eventHub.$on('toggleFolder', this.toggleFolder);
- eventHub.$on('postAction', this.postAction);
- },
-
- beforeDestroy() {
- eventHub.$off('toggleFolder');
- eventHub.$off('postAction');
- },
-
- methods: {
- toggleFolder(folder) {
- this.store.toggleFolder(folder);
-
- if (!folder.isOpen) {
- this.fetchChildEnvironments(folder, true);
- }
- },
-
- /**
- * Will change the page number and update the URL.
- *
- * @param {Number} pageNumber desired page to go to.
- * @return {String}
- */
- changePage(pageNumber) {
- const param = setParamInURL('page', pageNumber);
-
- gl.utils.visitUrl(param);
- return param;
- },
-
- fetchEnvironments() {
- const scope = getParameterByName('scope') || this.visibility;
- const page = getParameterByName('page') || this.pageNumber;
-
- this.isLoading = true;
-
- return this.service.get({ scope, page })
- .then(this.successCallback)
- .catch(this.errorCallback);
- },
-
- 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(() => {
- // eslint-disable-next-line no-new
- new Flash('An error occurred while fetching the environments.');
- this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false);
- });
- },
-
- 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);
-
- // 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));
- }
- },
-
- 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"
- />
- </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 6de01fa53d0..79326ca3487 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,440 +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: () => ({}),
+ 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,
},
- canCreateDeployment: {
- type: Boolean,
- required: false,
- default: false,
+ directives: {
+ tooltip,
},
- 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 '';
+ props: {
+ model: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+
+ canCreateDeployment: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ canReadEnvironment: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
- /**
- * 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);
+ 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;
+ },
},
- /**
- * 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 {};
+ methods: {
+ onClickFolder() {
+ eventHub.$emit('toggleFolder', this.model);
+ },
},
-
- /**
- * 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
@@ -446,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">
@@ -486,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 flex-truncate-parent"
- :href="buildPath">
- <span class="flex-truncate-child">{{buildName}}</span>
+ :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"
@@ -514,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>
@@ -546,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..1eef17bf1fe 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -1,53 +1,53 @@
<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.
+ */
+ import eventHub from '../event_hub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
-export default {
- props: {
- stopUrl: {
- type: String,
- default: '',
+ 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 +58,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 175cc8f1f72..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,
},
@@ -30,63 +30,96 @@ export default {
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="model.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 35891240239..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 { convertPermissionToBoolean, getParameterByName, setParamInURL } from '../../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 getParameterByName('scope');
- },
-
- canReadEnvironmentParsed() {
- return convertPermissionToBoolean(this.canReadEnvironment);
- },
-
- canCreateDeploymentParsed() {
- return 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 = getParameterByName('scope') || this.visibility;
- const page = 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 = setParamInURL('page', pageNumber);
-
- gl.utils.visitUrl(param);
- return param;
- },
-
- fetchEnvironments() {
- const scope = getParameterByName('scope') || this.visibility;
- const page = 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 8f4066e3a6e..34d18d55120 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -1,15 +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 headers = resp.headers;
return resp.json().then((response) => {
this.isLoading = false;
- this.store.storeAvailableCount(response.available_count);
- this.store.storeStoppedCount(response.stopped_count);
- this.store.storeEnvironments(response.environments);
- this.store.setPagination(headers);
+ 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.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 aff8227c38c..5f2989ab854 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -36,7 +36,12 @@ export default class EnvironmentsStore {
storeEnvironments(environments = []) {
const filteredEnvironments = environments.map((env) => {
const oldEnvironmentState = this.state.environments
- .find(element => element.id === env.latest.id) || {};
+ .find((element) => {
+ if (env.latest) {
+ return element.id === env.latest.id;
+ }
+ return element.id === env.id;
+ }) || {};
let filtered = {};
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..d65cc6d5d7d
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight.js
@@ -0,0 +1,65 @@
+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..939d12237f3
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
@@ -0,0 +1,59 @@
+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 d02e4cd5876..6a4874e1ab8 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -1,12 +1,11 @@
-/* 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 notes */
-
/* 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';
@@ -18,18 +17,18 @@ 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
+ /* 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('can-create-note') === '';
+ this.userCanCreateNote = $diffFile.closest(DIFF_CONTAINER_SELECTOR).data('canCreateNote') === '';
}
- if (typeof notes !== 'undefined' && !this.isParallelView) {
- this.isParallelView = notes.isParallelView && notes.isParallelView();
- }
+ this.isParallelView = Cookies.get('diff_view') === 'parallel';
if (this.userCanCreateNote) {
$diffFile.on('mouseover', LINE_COLUMN_CLASSES, e => this.showButton(this.isParallelView, e))
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index 6d516a253bb..a10f027de53 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -1,4 +1,5 @@
import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
/**
* Makes search request for content when user types a value in the search input.
@@ -6,10 +7,11 @@ import _ from 'underscore';
*/
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;
}
@@ -32,10 +34,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);
@@ -53,32 +55,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
index f9bbbf0cbc1..5ddd0e5e690 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -1,10 +1,10 @@
-/* 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 DropdownEmoji extends gl.FilteredSearchDropdown {
+export default class DropdownEmoji extends FilteredSearchDropdown {
constructor(options = {}) {
super(options);
this.config = {
@@ -14,7 +14,7 @@ class DropdownEmoji extends gl.FilteredSearchDropdown {
loadingTemplate: this.loadingTemplate,
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 */
},
},
@@ -50,7 +50,7 @@ class DropdownEmoji extends gl.FilteredSearchDropdown {
itemClicked(e) {
super.itemClicked(e, (selected) => {
const name = selected.querySelector('.js-data-value').innerText.trim();
- return gl.DropdownUtils.getEscapedText(name);
+ return DropdownUtils.getEscapedText(name);
});
}
@@ -77,6 +77,3 @@ class DropdownEmoji extends gl.FilteredSearchDropdown {
.addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init();
}
}
-
-window.gl = window.gl || {};
-gl.DropdownEmoji = DropdownEmoji;
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 23040cd9eb8..184b34b7b5e 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -1,14 +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 {
+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(),
}),
@@ -45,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();
@@ -73,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 0bc4b6f22a9..2ffda7e2037 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -1,10 +1,10 @@
-/* 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 {
+export default class DropdownNonUser extends FilteredSearchDropdown {
constructor(options = {}) {
const { input, endpoint, symbol, preprocessing } = options;
super(options);
@@ -17,12 +17,12 @@ class DropdownNonUser extends gl.FilteredSearchDropdown {
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',
},
};
@@ -31,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)}`;
});
}
@@ -46,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 720fbc87ea0..d36f38a70b5 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -1,10 +1,11 @@
-/* global Flash */
-
-import AjaxFilter from '~/droplab/plugins/ajax_filter';
-import './filtered_search_dropdown';
+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';
-class DropdownUser extends gl.FilteredSearchDropdown {
+export default class DropdownUser extends FilteredSearchDropdown {
constructor(options = {}) {
const { tokenKeys } = options;
super(options);
@@ -13,7 +14,6 @@ class DropdownUser extends gl.FilteredSearchDropdown {
endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
searchKey: 'search',
params: {
- per_page: 20,
active: true,
group_id: this.getGroupId(),
project_id: this.getProjectId(),
@@ -26,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 */
},
},
@@ -57,8 +57,8 @@ class DropdownUser extends gl.FilteredSearchDropdown {
}
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 || '';
@@ -79,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 8d711e3213c..9bc36c1f9b6 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -1,7 +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;
@@ -24,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();
@@ -114,9 +117,9 @@ class DropdownUtils {
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);
@@ -140,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;
@@ -180,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;
@@ -201,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);
}
@@ -242,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 6d5dd747224..00000000000
--- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import './dropdown_emoji';
-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 9e9a9ef74be..fb4ae1d17dd 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -1,6 +1,9 @@
+import DropdownUtils from './dropdown_utils';
+import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
-class FilteredSearchDropdown {
+export default class FilteredSearchDropdown {
constructor({ droplab, dropdown, input, filter }) {
this.droplab = droplab;
this.hookId = input && input.id;
@@ -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 46c80dfd45e..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,57 +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',
+ gl: DropdownNonUser,
extraArguments: {
- endpoint: `${this.baseEndpoint}/milestones.json`,
+ endpoint: this.getMilestoneEndpoint(),
symbol: '%',
},
element: this.container.querySelector('#js-dropdown-milestone'),
},
label: {
reference: null,
- gl: 'DropdownNonUser',
+ gl: DropdownNonUser,
extraArguments: {
- endpoint: `${this.baseEndpoint}/labels.json`,
+ endpoint: this.getLabelsEndpoint(),
symbol: '~',
- preprocessing: gl.DropdownUtils.duplicateLabelPreprocessing,
+ preprocessing: DropdownUtils.duplicateLabelPreprocessing,
},
element: this.container.querySelector('#js-dropdown-label'),
},
'my-reaction': {
reference: null,
- gl: 'DropdownEmoji',
+ gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'),
},
- hint: {
- reference: null,
- gl: 'DropdownHint',
- element: this.container.querySelector('#js-dropdown-hint'),
- },
};
+
+ 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();
}
}
@@ -119,9 +161,9 @@ class FilteredSearchDropdownManager {
const extraArguments = mappingKey.extraArguments || {};
const glArguments = Object.assign({}, defaultArguments, extraArguments);
- // Passing glArguments to `new gl[glClass](<arguments>)`
+ // Passing glArguments to `new glClass(<arguments>)`
mappingKey.reference =
- new (Function.prototype.bind.apply(gl[glClass], [null, glArguments]))();
+ new (Function.prototype.bind.apply(glClass, [null, glArguments]))();
}
if (firstLoad) {
@@ -159,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());
@@ -204,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 9178fec085a..71b7e80335b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,19 +1,48 @@
+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(),
@@ -22,11 +51,7 @@ class FilteredSearchManager {
this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown');
const fullPath = this.searchHistoryDropdownElement ?
this.searchHistoryDropdownElement.dataset.fullPath : 'project';
- let recentSearchesPagePrefix = 'issue-recent-searches';
- if (this.page === 'merge_requests') {
- recentSearchesPagePrefix = 'merge-request-recent-searches';
- }
- const recentSearchesKey = `${fullPath}-${recentSearchesPagePrefix}`;
+ const recentSearchesKey = `${fullPath}-${this.recentsStorageKeyNames[this.page]}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
@@ -36,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 [];
})
@@ -54,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,
@@ -67,7 +99,6 @@ class FilteredSearchManager {
this.bindEvents();
this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown();
-
this.cleanupWrapper = this.cleanup.bind(this);
document.addEventListener('beforeunload', this.cleanupWrapper);
}
@@ -83,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);
@@ -125,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);
@@ -178,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) {
@@ -252,7 +288,7 @@ class FilteredSearchManager {
e.stopImmediatePropagation();
const button = e.target.closest('.selectable');
- gl.FilteredSearchVisualTokens.selectToken(button, true);
+ FilteredSearchVisualTokens.selectToken(button, true);
this.removeSelectedToken();
}
}
@@ -264,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();
}
}
@@ -277,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);
@@ -295,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;
@@ -315,7 +351,7 @@ class FilteredSearchManager {
}
removeSelectedToken() {
- gl.FilteredSearchVisualTokens.removeSelectedToken();
+ FilteredSearchVisualTokens.removeSelectedToken();
this.handleInputPlaceholder();
this.toggleClearSearchButton();
this.dropdownManager.updateCurrentDropdownOffset();
@@ -335,8 +371,8 @@ class FilteredSearchManager {
let canClearToken = t.classList.contains('js-visual-token');
if (canClearToken) {
- const tokenKey = t.querySelector('.name').textContent.trim();
- canClearToken = this.canEdit && this.canEdit(tokenKey);
+ const { tokenName, tokenValue } = DropdownUtils.getVisualTokenValues(t);
+ canClearToken = this.canEdit && this.canEdit(tokenName, tokenValue);
}
if (canClearToken) {
@@ -363,12 +399,12 @@ 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(':');
@@ -381,10 +417,10 @@ class FilteredSearchManager {
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 {
@@ -392,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();
@@ -408,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);
@@ -424,7 +460,7 @@ class FilteredSearchManager {
}
loadSearchParamsFromURL() {
- const urlParams = gl.utils.getUrlParamsArray();
+ const urlParams = getUrlParamsArray();
const params = this.getAllParams(urlParams);
const usernameParams = this.getUsernameParams();
let hasFilteredSearch = false;
@@ -440,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,
@@ -468,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,
@@ -480,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);
@@ -488,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;
@@ -520,13 +556,13 @@ 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) => {
@@ -565,7 +601,7 @@ class FilteredSearchManager {
if (this.updateObject) {
this.updateObject(parameterizedUrl);
} else {
- gl.utils.visitUrl(parameterizedUrl);
+ visitUrl(parameterizedUrl);
}
}
@@ -605,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 be595d7df1a..087ef5cd6f2 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -71,7 +71,7 @@ const conditions = [{
value: 'none',
}];
-class FilteredSearchTokenKeys {
+export default class FilteredSearchTokenKeys {
static get() {
return tokenKeys;
}
@@ -121,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 28e8240169d..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,21 +56,14 @@ 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>
`;
@@ -80,7 +91,7 @@ class FilteredSearchVisualTokens {
let processed = labels;
if (!labels.preprocessed) {
- processed = gl.DropdownUtils.duplicateLabelPreprocessing(labels);
+ processed = DropdownUtils.duplicateLabelPreprocessing(labels);
AjaxCache.override(labelsEndpoint, processed);
processed.preprocessed = true;
}
@@ -91,12 +102,15 @@ class FilteredSearchVisualTokens {
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(FilteredSearchVisualTokens.preprocessLabel.bind(null, labelsEndpoint))
.then((labels) => {
- const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue);
+ const matchingLabel = (labels || []).find(label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue);
if (!matchingLabel) {
return;
@@ -123,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 */
})
@@ -265,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);
}
@@ -330,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);
@@ -343,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
index 157280d66e3..8b4f3b05ee7 100644
--- a/app/assets/javascripts/fly_out_nav.js
+++ b/app/assets/javascripts/fly_out_nav.js
@@ -21,7 +21,7 @@ let headerHeight = 50;
export const getHeaderHeight = () => headerHeight;
-export const isSidebarCollapsed = () => sidebar && sidebar.classList.contains('sidebar-icons-only');
+export const isSidebarCollapsed = () => sidebar && sidebar.classList.contains('sidebar-collapsed-desktop');
export const canShowActiveSubItems = (el) => {
if (el.classList.contains('active') && !isSidebarCollapsed()) {
@@ -34,7 +34,7 @@ export const canShowActiveSubItems = (el) => {
export const canShowSubItems = () => bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md' || bp.getBreakpointSize() === 'lg';
export const getHideSubItemsInterval = () => {
- if (!currentOpenMenu) return 0;
+ if (!currentOpenMenu || !mousePos.length) return 0;
const currentMousePos = mousePos[mousePos.length - 1];
const prevMousePos = mousePos[0];
@@ -118,14 +118,14 @@ export const showSubLevelItems = (el) => {
moveSubItemsToPosition(el, subItems);
};
-export const mouseEnterTopItems = (el) => {
+export const mouseEnterTopItems = (el, timeout = getHideSubItemsInterval()) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (currentOpenMenu) hideMenu(currentOpenMenu);
showSubLevelItems(el);
- }, getHideSubItemsInterval());
+ }, timeout);
};
export const mouseLeaveTopItem = (el) => {
@@ -161,13 +161,16 @@ export default () => {
const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')];
- sidebar.querySelector('.sidebar-top-level-items').addEventListener('mouseleave', () => {
- clearTimeout(timeoutId);
+ 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());
- });
+ timeoutId = setTimeout(() => {
+ if (currentOpenMenu) hideMenu(currentOpenMenu);
+ }, getHideSubItemsInterval());
+ });
+ }
headerHeight = document.querySelector('.nav-sidebar').offsetTop;
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 5c624b79d45..57a1fa107e5 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -57,12 +57,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>';
@@ -287,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',
@@ -309,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;
+ },
},
});
}
@@ -338,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];
@@ -419,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 = {
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 50d822eba5a..6cf78bab6ad 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,6 +1,9 @@
/* 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 '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, GitLabDropdownInput;
@@ -210,25 +213,17 @@ 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;
@@ -298,7 +293,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');
}
@@ -330,7 +325,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) {
@@ -490,7 +485,7 @@ GitLabDropdown = (function() {
$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 {
@@ -513,10 +508,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);
}
@@ -548,6 +544,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.positionMenuAbove = function() {
var $menu = this.dropdown.find('.dropdown-menu');
+ $menu.addClass('dropdown-open-top');
$menu.css('top', 'initial');
$menu.css('bottom', '100%');
};
@@ -610,7 +607,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;
}
@@ -619,6 +629,7 @@ GitLabDropdown = (function() {
index = false;
}
html = document.createElement('li');
+
if (data === 'divider' || data === 'separator') {
html.className = data;
return html;
@@ -634,11 +645,9 @@ 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;
@@ -737,7 +746,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, '\\\'') + "']");
}
@@ -745,7 +754,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) {
@@ -786,24 +795,16 @@ GitLabDropdown = (function() {
return [selectedObject, isMarking];
};
- GitLabDropdown.prototype.focusTextInput = function(triggerFocus = false) {
+ GitLabDropdown.prototype.focusTextInput = function() {
if (this.options.filterable) {
- this.dropdown.one('transitionend', () => {
- const initialScrollTop = $(window).scrollTop();
-
- if (this.dropdown.is('.open')) {
- this.filterInput.focus();
- }
+ const initialScrollTop = $(window).scrollTop();
- if ($(window).scrollTop() < initialScrollTop) {
- $(window).scrollTop(initialScrollTop);
- }
- });
+ 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);
}
}
};
@@ -849,9 +850,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..bd63f6f16f0 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -54,7 +54,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 +159,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..73bcbd93565 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -1,42 +1,40 @@
-/* eslint-disable comma-dangle, class-methods-use-this, max-len, space-before-function-paren, arrow-parens, no-param-reassign */
-
-import './gl_field_error';
+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 +48,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 4e8141b2956..2d40856e038 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -1,104 +1,99 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-new, max-len */
-/* global GitLab */
-/* global DropzoneInput */
-/* global autosize */
-
+import autosize from 'autosize';
import GfmAutoComplete from './gfm_auto_complete';
-
-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();
- if (this.autoComplete) {
- this.autoComplete.destroy();
+import dropzoneInput from './dropzone_input';
+import textUtils 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);
}
- 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'));
- 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,
- });
- 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();
+ textUtils.init(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');
+ textUtils.removeListeners(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
index 7ac9dcd1112..6bf21f4f27d 100644
--- a/app/assets/javascripts/gpg_badges.js
+++ b/app/assets/javascripts/gpg_badges.js
@@ -1,3 +1,8 @@
+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');
@@ -5,13 +10,13 @@ export default class GpgBadges {
badges.html('<i class="fa fa-spinner fa-spin"></i>');
- $.get({
- url: form.data('signatures-path'),
- data: form.serialize(),
- }).done((response) => {
- response.signatures.forEach((signature) => {
+ 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 534bc535bb6..00000000000
--- a/app/assets/javascripts/graphs/graphs_bundle.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import Chart from 'vendor/Chart';
-
-// export to global scope
-window.Chart = Chart;
diff --git a/app/assets/javascripts/group_avatar.js b/app/assets/javascripts/group_avatar.js
index f03b47b1c1d..2168ff3a8ba 100644
--- a/app/assets/javascripts/group_avatar.js
+++ b/app/assets/javascripts/group_avatar.js
@@ -1,19 +1,12 @@
-/* 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 */
-
-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..df9429b1e02 100644
--- a/app/assets/javascripts/group_label_subscription.js
+++ b/app/assets/javascripts/group_label_subscription.js
@@ -1,6 +1,8 @@
-/* eslint-disable func-names, object-shorthand, comma-dangle, wrap-iife, space-before-function-paren, no-param-reassign, max-len */
+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 +17,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 +33,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 +44,3 @@ class GroupLabelSubscription {
this.$unsubscribeButtons.toggleClass('hidden');
}
}
-
-window.gl = window.gl || {};
-window.gl.GroupLabelSubscription = GroupLabelSubscription;
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
new file mode 100644
index 00000000000..0578f43d5af
--- /dev/null
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -0,0 +1,220 @@
+<script>
+/* global Flash */
+
+import { s__ } from '~/locale';
+import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+import modal from '~/vue_shared/components/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,
+ modal,
+ 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"
+ />
+ <modal
+ v-if="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 2060410e991..764b130fdb8 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -1,50 +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: {
@@ -53,51 +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;
- },
- hasAvatar() {
- return this.group.avatarUrl && this.group.avatarUrl.indexOf('/assets/no_group_avatar') === -1;
},
},
};
@@ -108,126 +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
v-if="hasAvatar"
- class="avatar s40"
+ class="avatar s24"
:src="group.avatarUrl"
/>
<identicon
v-else
- :entity-id=group.id
+ 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 d17a43b048a..adde8c8cdb3 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -1,40 +1,57 @@
<script>
-import tablePagination from '~/vue_shared/components/table_pagination.vue';
-import eventHub from '../event_hub';
-import { getParameterByName } from '../../lib/utils/common_utils';
+ 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 = getParameterByName('filter_groups');
- const sortParam = 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 83b102764ba..31d56d15c23 100644
--- a/app/assets/javascripts/groups/groups_filterable_list.js
+++ b/app/assets/javascripts/groups/groups_filterable_list.js
@@ -1,14 +1,15 @@
import FilterableList from '~/filterable_list';
import eventHub from './event_hub';
-import { getParameterByName } from '../lib/utils/common_utils';
+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() {
@@ -24,30 +25,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);
}
@@ -55,34 +60,48 @@ export default class GroupFilterableList extends FilterableList {
e.preventDefault();
const queryData = {};
- const sortParam = 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 9ad8e5c6052..57eaac72906 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -1,17 +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 { getParameterByName } from '../lib/utils/common_utils';
+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
@@ -19,176 +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 = getParameterByName('page');
- if (pageParam) {
- page = pageParam;
- }
-
- filterGroupsParam = getParameterByName('filter_groups');
- if (filterGroupsParam) {
- filterGroups = filterGroupsParam;
- }
-
- sortParam = 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);
-
- return response.json().then((data) => {
- this.updateGroups(data);
- 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(resp => resp.json())
- .then((response) => {
- $.scrollTo(0);
-
- this.store.removeGroup(group, collection);
-
- // eslint-disable-next-line no-new
- new Flash(response.notice, 'notice');
- })
- .catch((error) => {
- let message = 'An error occurred. Please try again.';
-
- if (error.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/services/groups_service.js b/app/assets/javascripts/groups/service/groups_service.js
index 97e02fcb76d..b79ba291463 100644
--- a/app/assets/javascripts/groups/services/groups_service.js
+++ b/app/assets/javascripts/groups/service/groups_service.js
@@ -1,14 +1,12 @@
import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-Vue.use(VueResource);
+import '../../vue_shared/vue_resource_interceptor';
export default class GroupsService {
constructor(endpoint) {
this.groups = Vue.resource(endpoint);
}
- getGroups(parentId, page, filterGroups, sort) {
+ getGroups(parentId, page, filterGroups, sort, archived) {
const data = {};
if (parentId) {
@@ -20,12 +18,16 @@ export default class GroupsService {
}
if (filterGroups) {
- data.filter_groups = filterGroups;
+ data.filter = filterGroups;
}
if (sort) {
data.sort = sort;
}
+
+ if (archived) {
+ data.archived = archived;
+ }
}
return this.groups.get(data);
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 f59ec677603..00000000000
--- a/app/assets/javascripts/groups/stores/groups_store.js
+++ /dev/null
@@ -1,167 +0,0 @@
-import Vue from 'vue';
-import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
-
-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 = normalizeHeaders(pagination);
- paginationInfo = 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..85b7b08db4d
--- /dev/null
+++ b/app/assets/javascripts/groups/transfer_dropdown.js
@@ -0,0 +1,34 @@
+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 90ca70289ab..12fc5f9b5c9 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -1,121 +1,86 @@
-/* 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 axios from './lib/utils/axios_utils';
import Api from './api';
-import { normalizeCRLFHeaders } from './lib/utils/common_utils';
+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 = 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) {
- this.dropdown.style.height = `${Math.floor(this.dropdown.scrollHeight)}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..33a352e158a 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -1,7 +1,18 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var */
+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
index 4a22ebf187d..d02477b19a2 100644
--- a/app/assets/javascripts/help/help.js
+++ b/app/assets/javascripts/help/help.js
@@ -1,6 +1,8 @@
// We will render the icons list here
-if ($('#user-content-gitlab-icons').length > 0) {
- const $iconsHeader = $('#user-content-gitlab-icons');
- const $iconsList = $('<div id="iconsList">ICONS</div>');
- $($iconsList).insertAfter($iconsHeader.parent());
-}
+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/user_feature_helper.js b/app/assets/javascripts/helpers/user_feature_helper.js
deleted file mode 100644
index 638118a5204..00000000000
--- a/app/assets/javascripts/helpers/user_feature_helper.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import Cookies from 'js-cookie';
-
-export default {
- isNewRepoEnabled() {
- return Cookies.get('new_repo') === 'true';
- },
-};
diff --git a/app/assets/javascripts/how_to_merge.js b/app/assets/javascripts/how_to_merge.js
index 19f4a946f73..12e6f24595a 100644
--- a/app/assets/javascripts/how_to_merge.js
+++ b/app/assets/javascripts/how_to_merge.js
@@ -1,12 +1,13 @@
-document.addEventListener('DOMContentLoaded', () => {
- const modal = $('#modal_merge_info').modal({
- modal: true,
- show: false,
- });
- $('.how_to_merge_link').on('click', () => {
- modal.show();
- });
- $('.modal-header .close').on('click', () => {
- modal.hide();
- });
-});
+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/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..f3af92cf2b0
--- /dev/null
+++ b/app/assets/javascripts/image_diff/image_diff.js
@@ -0,0 +1,143 @@
+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..35094f8e73b 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -1,83 +1,108 @@
-/* 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 './locale';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
-(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) {
+ this.jobsUrl = jobsUrl;
+ this.importUrl = importUrl;
+ 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,
+ })
+ .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');
+ job.find('.import-actions').html('<i class="fa fa-spinner fa-spin" aria-label="importing"></i> started');
+ })
+ .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;
+ 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(data.jobsImportPath, data.importPath);
+ }
+}
+
+export {
+ initImporterStatus as default,
+ ImporterStatus,
+};
diff --git a/app/assets/javascripts/init_changes_dropdown.js b/app/assets/javascripts/init_changes_dropdown.js
index f785ed29e6c..1bab7965c19 100644
--- a/app/assets/javascripts/init_changes_dropdown.js
+++ b/app/assets/javascripts/init_changes_dropdown.js
@@ -1,7 +1,7 @@
import stickyMonitor from './lib/utils/sticky';
-export default () => {
- stickyMonitor(document.querySelector('.js-diff-files-changed'));
+export default (stickyTop) => {
+ stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop);
$('.js-diff-stats-dropdown').glDropdown({
filterable: true,
diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js
index 29e3d2ea94e..e61b37a2d1f 100644
--- a/app/assets/javascripts/init_issuable_sidebar.js
+++ b/app/assets/javascripts/init_issuable_sidebar.js
@@ -1,8 +1,11 @@
/* eslint-disable no-new */
-/* global MilestoneSelect */
-/* global LabelsSelect */
-/* global IssuableContext */
-/* global Sidebar */
+
+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);
@@ -12,7 +15,6 @@ export default () => {
});
new LabelsSelect();
new IssuableContext(sidebarOptions.currentUser);
- gl.Subscription.bindAll('.subscription');
- new gl.DueDateSelectors();
- window.sidebar = new Sidebar();
+ 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..5f20055510f
--- /dev/null
+++ b/app/assets/javascripts/init_labels.js
@@ -0,0 +1,18 @@
+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
index 1211c2c802c..b6ff97d1279 100644
--- a/app/assets/javascripts/init_legacy_filters.js
+++ b/app/assets/javascripts/init_legacy_filters.js
@@ -1,15 +1,14 @@
/* eslint-disable no-new */
-/* global LabelsSelect */
-/* global MilestoneSelect */
-/* global IssueStatusSelect */
-/* global SubscriptionSelect */
-
+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();
- new IssueStatusSelect();
- new SubscriptionSelect();
+ issueStatusSelect();
+ subscriptionSelect();
};
diff --git a/app/assets/javascripts/init_notes.js b/app/assets/javascripts/init_notes.js
index 3a8b4360cb6..882aedfcc76 100644
--- a/app/assets/javascripts/init_notes.js
+++ b/app/assets/javascripts/init_notes.js
@@ -1,4 +1,4 @@
-/* global Notes */
+import Notes from './notes';
export default () => {
const dataEl = document.querySelector('.js-notes-data');
@@ -10,5 +10,7 @@ export default () => {
autocomplete,
} = JSON.parse(dataEl.innerHTML);
- window.notes = new Notes(notesUrl, notesIds, now, diffView, autocomplete);
+ // 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 cf1e6a14725..2848fe003cb 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -1,12 +1,13 @@
-/* global Flash */
+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 +96,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} ${res.service_response}`, 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..14a2bfbe4e0 100644
--- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js
+++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
@@ -11,6 +11,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 +37,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 c39ffdb2e0f..8c1b2e78ca4 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -1,7 +1,7 @@
/* 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 'underscore';
+import axios from './lib/utils/axios_utils';
+import Flash from './flash';
export default {
init({ container, form, issues, prefixId } = {}) {
@@ -23,15 +23,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 0e8a0519928..2056efe701b 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -1,10 +1,10 @@
/* eslint-disable class-methods-use-this, no-new */
-/* global LabelsSelect */
-/* global MilestoneSelect */
-/* global IssueStatusSelect */
-/* global SubscriptionSelect */
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 +20,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,8 +45,8 @@ export default class IssuableBulkUpdateSidebar {
initDropdowns() {
new LabelsSelect();
new MilestoneSelect();
- new IssueStatusSelect();
- new SubscriptionSelect();
+ issueStatusSelect();
+ subscriptionSelect();
}
setupBulkUpdateActions() {
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index 70c364e51fe..da99394ff90 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -1,33 +1,32 @@
-/* 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 */
import Cookies from 'js-cookie';
import bp from './breakpoints';
import UsersSelect from './users_select';
-const PARTICIPANTS_ROW_COUNT = 7;
+export default class IssuableContext {
+ constructor(currentUser) {
+ this.userSelect = new UsersSelect(currentUser);
-(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;
+ $('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();
@@ -35,44 +34,18 @@ const PARTICIPANTS_ROW_COUNT = 7;
$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() {
- $(document).on("click", ".js-participants-more", this.toggleHiddenParticipants);
- return $(".js-participants-author").each(function(i) {
- if (i >= 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 470c39c6f76..fdfad0b6a4f 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -1,108 +1,147 @@
-/* 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 Autosave */
-/* global dateFormat */
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';
-(function() {
- this.IssuableForm = (function() {
- 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]']");
- 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();
- $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()));
- }
+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;
+ }
+
+ 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()));
+ }
+
+ this.$targetBranchSelect = $('.js-target-branch-select', this.form);
+
+ 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();
}
+ }
- 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() {
- 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()));
- };
-
- return IssuableForm;
- })();
-}).call(window);
+ 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 ece0220c927..0683ca82a38 100644
--- a/app/assets/javascripts/issuable_index.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -1,171 +1,46 @@
-/* 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 'underscore';
+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;
- /* 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();
-
- if (isPristine) {
- return;
- }
+ this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
+ }
+ }
- if (!$input.length) {
- $filtersForm.append(`<input type='hidden' name='${$searchName}' value='${_.escape($searchValue)}'/>`);
- } else {
- $input.val($searchValue);
- }
+ static resetIncomingEmailToken() {
+ const $resetToken = $('.incoming-email-token-reset');
- 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;
+ $resetToken.on('click', (e) => {
+ e.preventDefault();
- $form.attr('action', baseIssuesUrl);
- gl.utils.visitUrl(baseIssuesUrl);
- });
- },
- initBulkUpdate: function(pagePrefix) {
- const userCanBulkUpdate = $('.issues-bulk-update').length > 0;
- const alreadyInitialized = !!this.bulkUpdateSidebar;
+ $resetToken.text('resetting...');
- if (userCanBulkUpdate && !alreadyInitialized) {
- IssuableBulkUpdateActions.init({
- prefixId: pagePrefix,
- });
+ axios.put($resetToken.attr('href'))
+ .then(({ data }) => {
+ $('#issuable_email').val(data.new_address).focus();
- this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
- }
- },
- resetIncomingEmailToken: function() {
- $('.incoming-email-token-reset').on('click', function(e) {
- e.preventDefault();
+ $resetToken.text('reset it');
+ })
+ .catch(() => {
+ flash(__('There was an error when reseting email token.'));
- $.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');
- }
+ $resetToken.text('reset it');
});
- });
- }
- };
-})(window);
+ });
+ }
+}
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index c0bd64814ca..333bbd9e0ba 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -1,27 +1,14 @@
/* 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 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 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');
@@ -37,6 +24,51 @@ class Issue {
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() {
@@ -55,41 +87,12 @@ class Issue {
this.disableCloseReopenButton($button);
url = $button.attr('href');
- return $.ajax({
- type: 'PUT',
- url: url
- })
- .fail(() => new Flash(issueFailMessage))
- .done((data) => {
- const isClosedBadge = $('div.status-box-closed');
- const isOpenBadge = $('div.status-box-open');
- const projectIssuesCounter = $('.issue_counter');
-
- if ('id' in data) {
- const isClosed = $button.hasClass('btn-close');
- 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(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);
- }
+ return axios.put(url)
+ .then(({ data }) => {
+ const isClosed = $button.hasClass('btn-close');
+ this.updateTopState(isClosed, data);
})
+ .catch(() => flash(issueFailMessage))
.then(() => {
this.disableCloseReopenButton($button, false);
});
@@ -128,26 +131,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 06f6ec241f4..e87a8ed7fea 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -1,242 +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,
},
- 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: () => [],
- },
- markdownPreviewPath: {
- type: String,
- required: true,
- },
- markdownDocsPath: {
- type: String,
- required: true,
- },
- projectPath: {
- type: String,
- required: true,
- },
- projectNamespace: {
- 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,
- description: this.state.descriptionText,
- lockedWarningVisible: false,
- 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() {
- this.service.updateIssuable(this.store.formState)
- .then(res => res.json())
- .then((data) => {
- if (location.pathname !== data.web_url) {
- gl.utils.visitUrl(data.web_url);
- }
+ 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;
+ },
- 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();
+ 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;
+ },
- gl.utils.visitUrl(data.web_url);
- })
- .catch(() => {
- eventHub.$emit('close.form');
- return new Flash('Error deleting issue');
+ 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.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}`);
+ }
+ });
+ },
+
+ closeRecaptchaModal() {
+ this.store.setFormState({
+ updateLoading: false,
});
- },
- },
- created() {
- this.service = new Service(this.endpoint);
- this.poll = new Poll({
- resource: this.service,
- method: 'getData',
- successCallback: res => res.json().then(data => 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-destroy="canDestroy"
- :issuable-templates="issuableTemplates"
- :markdown-docs-path="markdownDocsPath"
- :markdown-preview-path="markdownPreviewPath"
- :project-path="projectPath"
- :project-namespace="projectNamespace"
- />
+ <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 48bad8f1e68..1338be0ec4b 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -1,9 +1,14 @@
<script>
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,
@@ -22,6 +27,16 @@
required: false,
default: '',
},
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ updateUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -41,6 +56,10 @@
this.updateTaskStatusText();
},
},
+ mounted() {
+ this.renderGFM();
+ this.updateTaskStatusText();
+ },
methods: {
renderGFM() {
$(this.$refs['gfm-content']).renderGFM();
@@ -48,12 +67,23 @@
if (this.canUpdate) {
// eslint-disable-next-line no-new
new TaskList({
- dataType: 'issue',
+ 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');
@@ -62,17 +92,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>
@@ -82,7 +112,8 @@
class="description"
:class="{
'js-task-list-container': canUpdate
- }">
+ }"
+ >
<div
class="wiki"
:class="{
@@ -95,7 +126,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 992b7064c13..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>
@@ -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/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index dc902eefc5f..d9fa2764d65 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -1,9 +1,11 @@
<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: {
@@ -18,9 +20,16 @@
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();
@@ -37,11 +46,14 @@
</label>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
- :markdown-docs-path="markdownDocsPath">
+ :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..1ad0e59287e 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,6 @@
<script>
+ import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors';
+
export default {
props: {
formState: {
@@ -32,7 +34,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/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 28bf6c67ea5..779705e19ac 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -6,6 +6,13 @@
import descriptionTemplate from './fields/description_template.vue';
export default {
+ components: {
+ lockedWarning,
+ titleField,
+ descriptionField,
+ descriptionTemplate,
+ editActions,
+ },
props: {
canDestroy: {
type: Boolean,
@@ -36,13 +43,21 @@
type: String,
required: true,
},
- },
- components: {
- lockedWarning,
- titleField,
- descriptionField,
- descriptionTemplate,
- editActions,
+ showDeleteButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
hasIssuableTemplates() {
@@ -63,24 +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-path="markdownPreviewPath"
- :markdown-docs-path="markdownDocsPath" />
+ :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 aca9dec2a96..75dfdedcf1b 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,49 +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,
- endpoint: this.endpoint,
- issuableRef: this.issuableRef,
- initialTitleHtml: this.initialTitleHtml,
- initialTitleText: this.initialTitleText,
- initialDescriptionHtml: this.initialDescriptionHtml,
- initialDescriptionText: this.initialDescriptionText,
- issuableTemplates: this.issuableTemplates,
- markdownPreviewPath: this.markdownPreviewPath,
- markdownDocsPath: this.markdownDocsPath,
- projectPath: this.projectPath,
- projectNamespace: this.projectNamespace,
- 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_status_select.js b/app/assets/javascripts/issue_status_select.js
index 56cb536dcde..71c0f894389 100644
--- a/app/assets/javascripts/issue_status_select.js
+++ b/app/assets/javascripts/issue_status_select.js
@@ -1,34 +1,23 @@
-/* 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");
- }
- });
- });
- }
-
- 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/build.js b/app/assets/javascripts/job.js
index 286a758b8a9..f39ae764d3c 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/job.js
@@ -1,27 +1,25 @@
-/* eslint-disable func-names, wrap-iife, no-use-before-define,
-consistent-return, prefer-rest-params */
import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
+import { visitUrl } from './lib/utils/url_utility';
import bp from './breakpoints';
-import { bytesToKiB } from './lib/utils/number_utils';
+import { numberToHumanSize } from './lib/utils/number_utils';
import { setCiStatusFavicon } from './lib/utils/common_utils';
-window.Build = (function () {
- Build.timeout = null;
- Build.state = null;
-
- function Build(options) {
+export default class Job {
+ constructor(options) {
+ this.timeout = null;
+ this.state = null;
+ this.fetchingStatusFavicon = false;
this.options = options || $('.js-build-options').data();
- this.pageUrl = this.options.pageUrl;
+ 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.hasBeenScrolled = false;
-
this.updateDropdown = this.updateDropdown.bind(this);
- this.getBuildTrace = this.getBuildTrace.bind(this);
this.$buildTrace = $('#build-trace');
this.$buildRefreshAnimation = $('.js-build-refresh');
@@ -33,7 +31,7 @@ window.Build = (function () {
this.$scrollTopBtn = $('.js-scroll-up');
this.$scrollBottomBtn = $('.js-scroll-down');
- clearTimeout(Build.timeout);
+ clearTimeout(this.timeout);
this.initSidebar();
this.populateJobs(this.buildStage);
@@ -59,33 +57,27 @@ window.Build = (function () {
this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
- $(window)
+ this.$window
.off('scroll')
.on('scroll', () => {
- const contentHeight = this.$buildTraceOutput.height();
- if (contentHeight > this.windowSize) {
- // means the user did not scroll, the content was updated.
- this.windowSize = contentHeight;
- } else {
- // User scrolled
- this.hasBeenScrolled = true;
+ if (!this.isScrolledToBottom()) {
this.toggleScrollAnimation(false);
+ } else if (this.isScrolledToBottom() && !this.isLogComplete) {
+ this.toggleScrollAnimation(true);
}
-
this.scrollThrottled();
});
- $(window)
+ this.$window
.off('resize.build')
.on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100));
- this.updateArtifactRemoveDate();
this.initAffixTopArea();
this.getBuildTrace();
}
- Build.prototype.initAffixTopArea = function () {
+ 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
@@ -100,15 +92,17 @@ window.Build = (function () {
top: offsetTop,
},
});
- };
+ }
- Build.prototype.canScroll = function () {
+ // eslint-disable-next-line class-methods-use-this
+ canScroll() {
return $(document).height() > $(window).height();
- };
+ }
- Build.prototype.toggleScroll = function () {
- const currentPosition = $(document).scrollTop();
- const scrollHeight = $(document).height();
+ toggleScroll() {
+ const $document = $(document);
+ const currentPosition = $document.scrollTop();
+ const scrollHeight = $document.height();
const windowHeight = $(window).height();
if (this.canScroll()) {
@@ -119,11 +113,11 @@ window.Build = (function () {
this.toggleDisableButton(this.$scrollTopBtn, false);
this.toggleDisableButton(this.$scrollBottomBtn, false);
} else if (currentPosition === 0) {
- // User is at Top of Build Log
+ // User is at Top of Log
this.toggleDisableButton(this.$scrollTopBtn, true);
this.toggleDisableButton(this.$scrollBottomBtn, false);
- } else if (scrollHeight - currentPosition === windowHeight) {
+ } else if (this.isScrolledToBottom()) {
// User is at the bottom of the build log.
this.toggleDisableButton(this.$scrollTopBtn, false);
@@ -133,50 +127,75 @@ window.Build = (function () {
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();
- Build.prototype.scrollDown = function () {
- $(document).scrollTop($(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());
+ }
- Build.prototype.scrollToBottom = function () {
+ scrollToBottom() {
this.scrollDown();
this.hasBeenScrolled = true;
this.toggleScroll();
- };
+ }
- Build.prototype.scrollToTop = function () {
+ scrollToTop() {
$(document).scrollTop(0);
this.hasBeenScrolled = true;
this.toggleScroll();
- };
+ }
- Build.prototype.toggleDisableButton = function ($button, disable) {
+ // eslint-disable-next-line class-methods-use-this
+ toggleDisableButton($button, disable) {
if (disable && $button.prop('disabled')) return;
$button.prop('disabled', disable);
- };
+ }
- Build.prototype.toggleScrollAnimation = function (toggle) {
+ toggleScrollAnimation(toggle) {
this.$scrollBottomBtn.toggleClass('animate', toggle);
- };
+ }
- Build.prototype.initSidebar = function () {
+ initSidebar() {
this.$sidebar = $('.js-build-sidebar');
- };
+ }
- Build.prototype.getBuildTrace = function () {
- return $.ajax({
- url: `${this.pageUrl}/trace.json`,
- data: this.state,
+ getBuildTrace() {
+ return axios.get(`${this.pagePath}/trace.json`, {
+ params: { state: this.state },
})
- .done((log) => {
- setCiStatusFavicon(`${this.pageUrl}/status.json`);
+ .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.windowSize = this.$buildTraceOutput.height();
+ this.isScrollInBottom = this.isScrolledToBottom();
if (log.append) {
this.$buildTraceOutput.append(log.html);
@@ -190,21 +209,16 @@ window.Build = (function () {
// 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);
+ 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) {
- if (!this.hasBeenScrolled) {
- this.toggleScrollAnimation(true);
- } else {
- this.toggleScrollAnimation(false);
- }
-
- Build.timeout = setTimeout(() => {
+ if (log.complete === false) {
+ this.timeout = setTimeout(() => {
this.getBuildTrace();
}, 4000);
} else {
@@ -213,26 +227,26 @@ window.Build = (function () {
}
if (log.status !== this.buildStatus) {
- gl.utils.visitUrl(this.pageUrl);
+ visitUrl(this.pagePath);
}
})
- .fail(() => {
+ .catch(() => {
this.$buildRefreshAnimation.remove();
})
.then(() => {
- if (!this.hasBeenScrolled) {
+ if (this.isScrollInBottom) {
this.scrollDown();
}
})
.then(() => this.toggleScroll());
- };
-
- Build.prototype.shouldHideSidebarForViewport = function () {
+ }
+ // eslint-disable-next-line class-methods-use-this
+ shouldHideSidebarForViewport() {
const bootstrapBreakpoint = bp.getBreakpointSize();
return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
- };
+ }
- Build.prototype.toggleSidebar = function (shouldHide) {
+ toggleSidebar(shouldHide) {
const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
const $toggleButton = $('.js-sidebar-build-toggle-header');
@@ -249,41 +263,30 @@ window.Build = (function () {
} else {
$toggleButton.removeClass('hidden');
}
- };
+ }
- Build.prototype.sidebarOnResize = function () {
+ sidebarOnResize() {
this.toggleSidebar(this.shouldHideSidebarForViewport());
- };
+ }
- Build.prototype.sidebarOnClick = function () {
+ sidebarOnClick() {
if (this.shouldHideSidebarForViewport()) this.toggleSidebar();
- };
-
- 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) {
+ // eslint-disable-next-line class-methods-use-this
+ populateJobs(stage) {
$('.build-job').hide();
$(`.build-job[data-stage="${stage}"]`).show();
- };
-
- Build.prototype.updateStageDropdownText = function (stage) {
+ }
+ // eslint-disable-next-line class-methods-use-this
+ updateStageDropdownText(stage) {
$('.stage-selection').text(stage);
- };
+ }
- Build.prototype.updateDropdown = function (e) {
+ updateDropdown(e) {
e.preventDefault();
const stage = e.currentTarget.text;
this.updateStageDropdownText(stage);
this.populateJobs(stage);
- };
-
- return Build;
-})();
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue
index 3f6f40d47ba..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() {
@@ -43,24 +55,9 @@
type: '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',
- });
- }
-
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 d0145fed396..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;
@@ -58,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
@@ -70,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 : 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>
@@ -92,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>
@@ -146,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>
@@ -156,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 f92e669414a..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,14 +13,14 @@ 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();
},
@@ -40,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: {
@@ -57,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 cc014b815c4..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()) {
@@ -55,7 +57,7 @@ export default class JobMediator {
successCallback(response) {
this.state.isLoading = false;
- return response.json().then(data => 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..61b40f79db1 100644
--- a/app/assets/javascripts/label_manager.js
+++ b/app/assets/javascripts/label_manager.js
@@ -1,124 +1,117 @@
/* 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 */
+import Sortable from 'vendor/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 flash from './flash';
+import axios from './lib/utils/axios_utils';
- bindEvents() {
- this.prioritizedLabels.find('.btn-action').on('mousedown', this, this.onButtonActionClick);
- return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick);
- }
+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();
+ }
- 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);
- }
+ 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..7aab13ed9c6 100644
--- a/app/assets/javascripts/labels.js
+++ b/app/assets/javascripts/labels.js
@@ -1,44 +1,35 @@
-/* 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();
- }
+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.addBinding = function() {
- $(document).on('click', '.suggest-colors a', this.setSuggestedColor);
- return $(document).on('input', 'input#label_color', this.updateColorPreview);
- };
+ 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.cleanBinding = function() {
- $(document).off('click', '.suggest-colors a');
- return $(document).off('input', 'input#label_color');
- };
-
- 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 2538d9c2093..9b46bbf83da 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -2,103 +2,99 @@
/* global Issuable */
/* global ListLabel */
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);
@@ -127,15 +123,15 @@ import DropdownUtils from './filtered_search/dropdown_utils';
$('.has-tooltip', $value).tooltip({
container: 'body'
});
- });
- };
- $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;
@@ -173,306 +169,334 @@ import DropdownUtils from './filtered_search/dropdown_utils';
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) {
- color = gl.DropdownUtils.duplicateLabelColor(label.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;
- }
- else if (selectedLabels.length) {
- return selectedLabels[0] + " +" + (selectedLabels.length - 1) + " more";
+ if ($dropdown.hasClass('js-multiselect') && removesAll) {
+ selectedClass.push('dropdown-clear-active');
}
- 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 ($('html').hasClass('issue-boards-page')) {
- return;
+ 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 ($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 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 {
+ 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 d064a2c0024..1b4900827b8 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -1,59 +1,51 @@
-/* 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 Cookies from 'js-cookie';
-import NewNavSidebar from './new_sidebar';
+import ContextualSidebar from './contextual_sidebar';
import initFlyOutNav from './fly_out_nav';
-(function() {
- var hideEndFade;
+function hideEndFade($scrollingTabs) {
+ $scrollingTabs.each(function scrollTabsLoop() {
+ const $this = $(this);
+ $this.siblings('.fade-right').toggleClass('scrolling', Math.round($this.width()) < $this.prop('scrollWidth'));
+ });
+}
- hideEndFade = function($scrollingTabs) {
- return $scrollingTabs.each(function() {
- var $this;
- $this = $(this);
- return $this.siblings('.fade-right').toggleClass('scrolling', $this.width() < $this.prop('scrollWidth'));
- });
- };
+export default function initLayoutNav() {
+ const contextualSidebar = new ContextualSidebar();
+ contextualSidebar.bindEvents();
+
+ 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);
}
}
});
- });
-
- $(() => {
- const newNavSidebar = new NewNavSidebar();
- newNavSidebar.bindEvents();
-
- initFlyOutNav();
- });
-}).call(window);
+ }).trigger('init.scrolling-tabs');
+}
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
index 3d64b121fa7..dbbf1637a47 100644
--- a/app/assets/javascripts/lazy_loader.js
+++ b/app/assets/javascripts/lazy_loader.js
@@ -1,5 +1,3 @@
-/* eslint-disable one-export, one-var, one-var-declaration-per-line */
-
import _ from 'underscore';
export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
@@ -21,7 +19,10 @@ export default class LazyLoader {
}
searchLazyImages() {
this.lazyImages = [].slice.call(document.querySelectorAll('.lazy'));
- this.checkElementsInView();
+
+ if (this.lazyImages.length) {
+ this.checkElementsInView();
+ }
}
startContentObserver() {
const contentNode = document.querySelector(this.observerNode) || document.querySelector('body');
@@ -45,15 +46,13 @@ export default class LazyLoader {
checkElementsInView() {
const scrollTop = pageYOffset;
const visHeight = scrollTop + innerHeight + SCROLL_THRESHOLD;
- let imgBoundRect, imgTop, imgBound;
// Loading Images which are in the current viewport or close to them
this.lazyImages = this.lazyImages.filter((selectedImage) => {
if (selectedImage.getAttribute('data-src')) {
- imgBoundRect = selectedImage.getBoundingClientRect();
-
- imgTop = scrollTop + imgBoundRect.top;
- imgBound = imgTop + imgBoundRect.height;
+ const imgBoundRect = selectedImage.getBoundingClientRect();
+ const imgTop = scrollTop + imgBoundRect.top;
+ const imgBound = imgTop + imgBoundRect.height;
if (scrollTop < imgBound && visHeight > imgTop) {
LazyLoader.loadImage(selectedImage);
diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js
index 629d8f44e18..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 {
@@ -18,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/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/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 ea2d61af9be..ed90db317df 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -1,5 +1,10 @@
+import jQuery 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').data('page').split(':')[index];
+export const getPagePath = (index = 0) => $('body').attr('data-page').split(':')[index];
export const isInGroupsPage = () => getPagePath() === 'groups';
@@ -19,23 +24,23 @@ export const getGroupSlug = () => {
return null;
};
-export const isInIssuePage = () => {
- const page = getPagePath(1);
- const action = getPagePath(2);
+export const checkPageAndAction = (page, action) => {
+ const pagePath = getPagePath(1);
+ const actionPath = getPagePath(2);
- return page === 'issues' && action === 'show';
+ return pagePath === page && actionPath === action;
};
-export const ajaxGet = url => $.ajax({
- type: 'GET',
- url,
- dataType: 'script',
-});
+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 ajaxPost = (url, data) => $.ajax({
- type: 'POST',
- url,
- data,
+export const ajaxGet = url => axios.get(url, {
+ params: { format: 'js' },
+ responseType: 'text',
+}).then(({ data }) => {
+ $.globalEval(data);
});
export const rstrip = (val) => {
@@ -65,12 +70,13 @@ export const disableButtonIfEmptyField = (fieldSelector, buttonSelector, eventNa
// 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 = window.gl.utils.getLocationHash();
+ 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');
@@ -78,25 +84,19 @@ export const handleLocationHash = () => {
let adjustment = 0;
if (fixedNav) adjustment -= fixedNav.offsetHeight;
- // scroll to user-generated markdown anchor if we cannot find a match
- if (document.getElementById(hash) === null) {
- const target = document.getElementById(`user-content-${hash}`);
- if (target && target.scrollIntoView) {
- target.scrollIntoView(true);
- window.scrollBy(0, adjustment);
- }
- } else {
- // only adjust for fixedTabs when not targeting user-generated content
- if (fixedTabs) {
- adjustment -= fixedTabs.offsetHeight;
- }
+ if (target && target.scrollIntoView) {
+ target.scrollIntoView(true);
+ }
- if (fixedDiffStats) {
- adjustment -= fixedDiffStats.offsetHeight;
- }
+ if (fixedTabs) {
+ adjustment -= fixedTabs.offsetHeight;
+ }
- window.scrollBy(0, adjustment);
+ if (fixedDiffStats) {
+ adjustment -= fixedDiffStats.offsetHeight;
}
+
+ window.scrollBy(0, adjustment);
};
// Check if element scrolled into viewport from above or below
@@ -140,7 +140,11 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
// 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 = ($el) => {
+export const scrollToElement = (element) => {
+ let $el = element;
+ if (!(element instanceof jQuery)) {
+ $el = $(element);
+ }
const top = $el.offset().top;
const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
@@ -177,7 +181,6 @@ export const getSelectedFragment = () => {
return documentFragment;
};
-// TODO: Update this name, there is a gl.text.insertText function.
export const insertText = (target, text) => {
// Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas
const selectionStart = target.selectionStart;
@@ -196,7 +199,7 @@ export const insertText = (target, text) => {
target.selectionStart = target.selectionEnd = selectionStart + insertedText.length;
// Trigger autosave
- $(target).trigger('input');
+ target.dispatchEvent(new Event('input'));
// Trigger autosize
const event = document.createEvent('Event');
@@ -237,7 +240,7 @@ export const nodeMatchesSelector = (node, selector) => {
export const normalizeHeaders = (headers) => {
const upperCaseHeaders = {};
- Object.keys(headers).forEach((e) => {
+ Object.keys(headers || {}).forEach((e) => {
upperCaseHeaders[e.toUpperCase()] = headers[e];
});
@@ -276,43 +279,47 @@ export const parseIntPagination = paginationInformation => ({
});
/**
- * Updates the search parameter of a URL given the parameter and value provided.
+ * Given a string of query parameters creates an object.
*
- * 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.
+ * @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]),
+ });
+ return acc;
+ }, {});
+};
+
+/**
+ * Converts object with key-value pairs
+ * into query-param string
*
- * @param {String} param
- * @param {Number|String|Undefined|Null} value
- * @return {String}
+ * @param {Object} params
*/
-export const 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('=');
- // eslint-disable-next-line no-param-reassign
- 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}`;
- }
+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);
- return search;
+/**
+ * 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);
};
/**
@@ -391,27 +398,55 @@ export const resetFavicon = () => {
}
};
-export const setCiStatusFavicon = (pageUrl) => {
- $.ajax({
- url: pageUrl,
- dataType: 'json',
- success: (data) => {
+export const setCiStatusFavicon = pageUrl =>
+ axios.get(pageUrl)
+ .then(({ data }) => {
if (data && data.favicon) {
setFavicon(data.favicon);
} else {
resetFavicon();
}
- },
- error: () => {
- resetFavicon();
- },
- });
+ })
+ .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>`;
};
-export const spriteIcon = icon => `<svg><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();
+ });
+ });
+};
+
window.gl = window.gl || {};
window.gl.utils = {
...(window.gl.utils || {}),
@@ -422,7 +457,6 @@ window.gl.utils = {
getGroupSlug,
isInIssuePage,
ajaxGet,
- ajaxPost,
rstrip,
updateTooltipTitle,
disableButtonIfEmptyField,
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 7a72509d234..9a61003ef30 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -1,3 +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
index ae41cc5e8a8..0bdb547d31a 100644
--- a/app/assets/javascripts/lib/utils/csrf.js
+++ b/app/assets/javascripts/lib/utils/csrf.js
@@ -14,6 +14,9 @@ If you need to compose a headers object, use the spread operator:
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 = {
@@ -53,4 +56,3 @@ if ($.rails) {
}
export default csrf;
-
diff --git a/app/assets/javascripts/lib/utils/datefix.js b/app/assets/javascripts/lib/utils/datefix.js
index 990dc3f6d1a..e98c9068367 100644
--- a/app/assets/javascripts/lib/utils/datefix.js
+++ b/app/assets/javascripts/lib/utils/datefix.js
@@ -1,8 +1,29 @@
-const DateFix = {
- dashedFix(val) {
- const [y, m, d] = val.split('-');
- return new Date(y, m - 1, d);
- },
+
+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);
};
-export default DateFix;
+/**
+ * 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 1d1763c3963..d6cccbef42b 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -1,133 +1,155 @@
-/* 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 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;
+/**
+ * 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')];
+};
- 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'];
+/**
+ * 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.formatDate = function(datetime) {
- return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
- };
+/**
+ * @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');
+
+/**
+ * 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, '_');
- w.gl.utils.getDayName = function(date) {
- return this.days[date.getDay()];
+let timeagoInstance;
+
+/**
+ * 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];
+ };
+ 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.localTimeAgo = function($timeagoEls, setTimeago = true) {
- $timeagoEls.each((i, el) => {
- el.setAttribute('title', el.getAttribute('title'));
+ timeago.register(timeagoLanguageCode, locale);
+ timeago.register(`${timeagoLanguageCode}-remaining`, localeRemaining);
+ timeagoInstance = timeago();
+ }
- 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>'
- });
- }
+ return timeagoInstance;
+}
- el.classList.add('js-timeago-render');
- });
+/**
+ * For the given element, renders a timeago instance.
+ * @param {jQuery} $els
+ */
+export const renderTimeago = ($els) => {
+ const timeagoEls = $els || document.querySelectorAll('.js-timeago-render');
- gl.utils.renderTimeago($timeagoEls);
- };
+ // timeago.js sets timeouts internally for each timeago value to be updated in real time
+ getTimeago().render(timeagoEls, timeagoLanguageCode);
+};
- 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;
- };
+/**
+ * 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.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;
- };
+ el.classList.add('js-timeago-render');
+ });
- w.gl.utils.renderTimeago = function($els) {
- const timeagoEls = $els || document.querySelectorAll('.js-timeago-render');
+ renderTimeago($timeagoEls);
+};
- // timeago.js sets timeouts internally for each timeago value to be updated in real time
- gl.utils.getTimeago().render(timeagoEls, lang);
- };
+/**
+ * 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();
+};
- 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());
+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);
- };
- })(window);
-}).call(window);
+ return Math.floor((date2 - date1) / millisecondsPerDay);
+};
/**
* Port of ruby helper time_interval_in_words.
@@ -135,7 +157,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);
@@ -143,9 +164,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/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 625e53ee9de..bb151929431 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -6,4 +6,6 @@ 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 917a45eb06b..a02c79b787e 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -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 1485e900945..7fca80c2fdb 100644
--- a/app/assets/javascripts/lib/utils/poll.js
+++ b/app/assets/javascripts/lib/utils/poll.js
@@ -3,7 +3,9 @@ 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({
@@ -60,7 +62,6 @@ export default class Poll {
checkConditions(response) {
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();
@@ -102,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/sticky.js b/app/assets/javascripts/lib/utils/sticky.js
index 283c0ec0410..098afcfa1b4 100644
--- a/app/assets/javascripts/lib/utils/sticky.js
+++ b/app/assets/javascripts/lib/utils/sticky.js
@@ -1,23 +1,39 @@
-export const isSticky = (el, scrollY, stickyTop) => {
+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) {
+ if (top <= stickyTop && !el.classList.contains('is-stuck')) {
+ const placeholder = insertPlaceholder ? createPlaceholder() : null;
+ const heightBefore = el.offsetHeight;
+
el.classList.add('is-stuck');
- } else {
+
+ 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) => {
+export default (el, stickyTop, insertPlaceholder = true) => {
if (!el) return;
- const computedStyle = window.getComputedStyle(el);
-
- if (!/sticky/.test(computedStyle.position)) return;
-
- const stickyTop = parseInt(computedStyle.top, 10);
+ if (typeof CSS === 'undefined' || !(CSS.supports('(position: -webkit-sticky) or (position: sticky)'))) return;
- document.addEventListener('scroll', () => isSticky(el, window.scrollY, stickyTop), {
+ 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..5dc98b4a920
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -0,0 +1,153 @@
+/* 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 */
+
+const textUtils = {};
+
+textUtils.selectedText = function(text, textarea) {
+ return text.substring(textarea.selectionStart, textarea.selectionEnd);
+};
+
+textUtils.lineBefore = function(text, textarea) {
+ var split;
+ split = text.substring(0, textarea.selectionStart).trim().split('\n');
+ return split[split.length - 1];
+};
+
+textUtils.lineAfter = function(text, textarea) {
+ return text.substring(textarea.selectionEnd).trim().split('\n')[0];
+};
+
+textUtils.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;
+ }
+};
+
+textUtils.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$/, '');
+ }
+
+ 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 !== '') {
+ 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);
+};
+
+textUtils.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;
+ }
+
+ if (removedLastNewLine) {
+ pos -= 1;
+ }
+
+ return textArea.setSelectionRange(pos, pos);
+ }
+};
+
+textUtils.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);
+};
+
+textUtils.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('mdTag'), $this.data('mdBlock'), !$this.data('mdPrepend'));
+ });
+};
+
+textUtils.removeListeners = function(form) {
+ return $('.js-md', form).off('click');
+};
+
+textUtils.replaceRange = function(s, start, end, substitute) {
+ return s.substring(0, start) + substitute + s.substring(end);
+};
+
+export default textUtils;
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 021f936a4fa..c0ce0786518 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -1,189 +1,96 @@
-/* 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 && blockTag !== ''))) {
- if (blockTag != null && blockTag !== '') {
- 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;
- }
+/**
+ * Capitalizes first character
+ *
+ * @param {String} text
+ * @return {String}
+ */
+export function capitalizeFirstCharacter(text) {
+ return `${text[0].toUpperCase()}${text.slice(1)}`;
+}
- if (removedLastNewLine) {
- insertText += '\n';
- }
+export function camelCase(str) {
+ return str.replace(/_+([a-z])/gi, ($1, $2) => $2.toUpperCase());
+}
- 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;
- }
+export function camelCaseKeys(obj = {}) {
+ return Object.keys(obj).reduce((acc, key) => {
+ const camelKey = camelCase(key);
+ return {
+ ...acc,
+ [camelKey]: obj[key],
+ };
+ }, {});
+}
- 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..e5c1fce3db9 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -28,148 +28,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/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/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/logo.js b/app/assets/javascripts/logo.js
index 729baa2e1a7..3688a57937e 100644
--- a/app/assets/javascripts/logo.js
+++ b/app/assets/javascripts/logo.js
@@ -1,7 +1,5 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback */
-
-(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 ec001b9b31c..53b01cca1d3 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -1,160 +1,50 @@
-/* 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 Flash */
+/* eslint-disable import/first */
/* global ConfirmDangerModal */
-/* global Aside */
import jQuery from 'jquery';
-import _ from 'underscore';
import Cookies from 'js-cookie';
-import Dropzone from 'dropzone';
-import Sortable from 'vendor/Sortable';
import svg4everybody from 'svg4everybody';
-// libraries with import side-effects
-import 'mousetrap';
-import 'mousetrap/plugins/pause/mousetrap-pause';
-import 'vendor/fuzzaldrin-plus';
-
// expose common libraries as globals (TODO: remove these)
window.jQuery = jQuery;
window.$ = jQuery;
-window._ = _;
-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';
-
-// templates
-import './templates/issuable_template_selector';
-import './templates/issuable_template_selectors';
-
-// commit
-import './commit/file';
-import './commit/image_file';
// lib/utils
-import './lib/utils/bootstrap_linked_tabs';
-import { handleLocationHash } from './lib/utils/common_utils';
-import './lib/utils/datetime_utility';
-import './lib/utils/pretty_time';
-import './lib/utils/text_utility';
-import './lib/utils/url_utility';
+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/';
-// 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 loadAwardsHandler from './awards_handler';
import bp from './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 './dropzone_input';
-import './due_date_select';
-import './files_comment_button';
-import './flash';
+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 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 './line_highlighter';
-import './logo';
-import './member_expiration_date';
-import './members';
-import './merge_request';
-import './merge_request_tabs';
-import './milestone';
+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_dropdown';
-import './projects_list';
-import './syntax_highlight';
-import './render_math';
import './render_gfm';
-import './right_sidebar';
-import './search';
-import './search_autocomplete';
-import './smart_interval';
-import './star';
-import './subscription';
-import './subscription_select';
import initBreadcrumbs from './breadcrumb';
-import './dispatcher';
+import initDispatcher from './dispatcher';
-// eslint-disable-next-line global-require, import/no-commonjs
-if (process.env.NODE_ENV !== 'production') require('./test_utils/');
-
-Dropzone.autoDiscover = false;
+// 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', function () {
+document.addEventListener('beforeunload', () => {
// Unbind scroll events
$(document).off('scroll');
// Close any open tooltips
@@ -171,33 +61,35 @@ window.addEventListener('load', function onLoad() {
gl.lazyLoader = new LazyLoader({
scrollContainer: window,
- observerNode: '#content-body'
+ observerNode: '#content-body',
});
-$(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;
+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()) {
+ $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')
@@ -205,168 +97,172 @@ $(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: '.page-with-sidebar'
+ 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', () => {
+ $('.header-content').toggleClass('menu-expanded');
+ gl.lazyLoader.loadCheck();
});
- $('.navbar-toggle').on('click', () => $('.header-content').toggleClass('menu-expanded'));
+
// 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');
+
+ $document.on('click', '.js-confirm-danger', (e) => {
+ const btn = $(e.target);
+ const form = btn.closest('form');
+ const text = btn.data('confirmDangerMessage');
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());
+
+ // eslint-disable-next-line no-new
+ new ConfirmDangerModal(form, text);
});
- $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();
- });
- loadAwardsHandler();
- new Aside();
+ }
- gl.utils.renderTimeago();
+ $window.on('resize.app', fitSidebarForSize);
- $(document).trigger('init.scrolling-tabs');
+ loadAwardsHandler();
- $('form.filter-form').on('submit', function (event) {
+ $('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();
- gl.utils.visitUrl(`${action}${$(this).serialize()}`);
+ visitUrl(`${action}${$(this).serialize()}`);
});
+
+ const flashContainer = document.querySelector('.flash-container');
+
+ if (flashContainer && flashContainer.children.length) {
+ flashContainer.querySelectorAll('.flash-alert, .flash-notice, .flash-success').forEach((flashEl) => {
+ removeFlashClickListener(flashEl);
+ });
+ }
+
+ initDispatcher();
});
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
index cc9016e74da..84e70e35bad 100644
--- a/app/assets/javascripts/member_expiration_date.js
+++ b/app/assets/javascripts/member_expiration_date.js
@@ -1,55 +1,53 @@
-/* global dateFormat */
-
import Pikaday from 'pikaday';
-
-(() => {
- // 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 { 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..330ebed5f73 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -1,81 +1,62 @@
-/* eslint-disable class-methods-use-this */
-(() => {
- window.gl = window.gl || {};
-
- 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');
- }
+export default class Members {
+ constructor() {
+ this.addListeners();
+ this.initGLDropdown();
+ }
- initGLDropdown() {
- $('.js-member-permissions-dropdown').each((i, btn) => {
- const $btn = $(btn);
+ 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');
+ }
- $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);
- },
- });
+ 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('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();
}
-
- gl.Members = Members;
-})();
+ // eslint-disable-next-line class-methods-use-this
+ getMemberListItems($el) {
+ const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`);
+
+ 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_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index d74cf5328ad..66b258839ae 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -1,7 +1,7 @@
/* eslint-disable new-cap, comma-dangle, no-new */
-/* global Flash */
import Vue from 'vue';
+import Flash from '../flash';
import initIssuableSidebar from '../init_issuable_sidebar';
import './merge_conflict_store';
import './merge_conflict_service';
@@ -10,8 +10,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;
@@ -24,12 +25,12 @@ $(() => {
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(); },
@@ -37,24 +38,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: {
@@ -81,14 +81,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 0db2abe507d..a64093afcf4 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,132 +1,142 @@
/* 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 { __ } from '~/locale';
import TaskList from './task_list';
-import './merge_request_tabs';
+import MergeRequestTabs from './merge_request_tabs';
import IssuablesHelper from './helpers/issuables_helper';
-
-(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();
- 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;
- }
- });
- }
- }
-
- // Local jQuery finder
- MergeRequest.prototype.$ = function(selector) {
- return this.$el.find(selector);
+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();
};
-
- MergeRequest.prototype.initTabs = function() {
- if (window.mrTabs) {
- window.mrTabs.unbindEvents();
- }
- window.mrTabs = new gl.MergeRequestTabs(this.opts);
- };
-
- MergeRequest.prototype.showAllCommits = function() {
- this.$('.first-commits').remove();
- return this.$('.all-commits').removeClass('hide');
- };
-
- 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 (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable();
-
- if (shouldSubmit) {
- if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) {
- e.preventDefault();
- e.stopImmediatePropagation();
-
- _this.submitNoteForm($this.closest('form'), $this);
- }
- }
- });
- };
-
- 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');
+ })(this));
+
+ 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;
}
- };
-
- MergeRequest.prototype.initCommitMessageListeners = function() {
- $(document).on('click', 'a.js-with-description-link', function(e) {
- var textarea = $('textarea.js-commit-message');
- e.preventDefault();
+ });
+ }
+}
+
+// Local jQuery finder
+MergeRequest.prototype.$ = function(selector) {
+ return this.$el.find(selector);
+};
+
+MergeRequest.prototype.initTabs = function() {
+ if (window.mrTabs) {
+ window.mrTabs.unbindEvents();
+ }
+ window.mrTabs = new MergeRequestTabs(this.opts);
+};
+
+MergeRequest.prototype.showAllCommits = function() {
+ this.$('.first-commits').remove();
+ return this.$('.all-commits').removeClass('hide');
+};
+
+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();
- });
- };
-
- MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) {
- $('.detail-page-header .status-box')
- .removeClass(classToRemove)
- .addClass(classToAdd)
- .find('span')
- .text(newStatusText);
- };
-
- 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);
-
- $el.text(gl.text.addDelimiter(count));
- };
-
- return MergeRequest;
- })();
-}).call(window);
+ _this.submitNoteForm($this.closest('form'), $this);
+ }
+ }
+ });
+};
+
+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.initCommitMessageListeners = function() {
+ $(document).on('click', 'a.js-with-description-link', function(e) {
+ var textarea = $('textarea.js-commit-message');
+ e.preventDefault();
+
+ 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');
+};
+
+export default MergeRequest;
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 8ae127776e8..46789e324c2 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,9 +1,8 @@
/* eslint-disable no-new, class-methods-use-this */
-/* global Flash */
-/* global notes */
import Cookies from 'js-cookie';
-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';
@@ -12,6 +11,12 @@ import {
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
@@ -59,376 +64,388 @@ import {
//
/* 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 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 (mergeRequestTabs) {
+ this.stickyTop += mergeRequestTabs.offsetHeight;
}
- bindEvents() {
- $(document)
- .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
- .on('click', '.js-show-tab', this.showTab);
-
- $('.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 && 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 (bp.getBreakpointSize() !== 'lg') {
- this.shrinkView();
- }
- if (this.diffViewType() === 'parallel') {
- this.expandViewContainer();
- }
- this.destroyPipelinesView();
- } else if (action === 'pipelines') {
- this.resetViewContainer();
- this.mountPipelinesView();
- } else {
- if (bp.getBreakpointSize() !== 'xs') {
- 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) {
- // important note: the .tab('show') method triggers 'shown.bs.tab' event itself
- $(`.merge-request-tabs a[data-action='${action}']`).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('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;
+ // 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'
- let newState = location.pathname.replace(/\/(commits|diffs|pipelines)(\.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 !== 'show' && this.currentAction !== 'new') {
- 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,
- emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
- errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
- autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath,
- },
- }).$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) {
- document.dispatchEvent(new CustomEvent('scroll'));
- 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 = parseUrlPathname(source);
-
- this.ajaxGet({
- url: `${urlPathname}.json${location.search}`,
- success: (data) => {
- const $container = $('#diffs');
- $container.html(data.html);
-
- initChangesDropdown();
-
- 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();
- 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 (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: '' }));
-
- // 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..b1d74250dfd 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -1,53 +1,45 @@
-/* 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 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 4675b1fcb8f..c259d5405bd 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -1,213 +1,214 @@
-/* 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 */
import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
+import { timeFor } from './lib/utils/datetime_utility';
-(function() {
- this.MilestoneSelect = (function() {
- function MilestoneSelect(currentProject, els) {
- var _this, $els;
- if (currentProject != null) {
- _this = this;
- this.currentProject = typeof currentProject === 'string' ? JSON.parse(currentProject) : currentProject;
- }
+export default class MilestoneSelect {
+ constructor(currentProject, els, options = {}) {
+ if (currentProject !== null) {
+ this.currentProject = typeof currentProject === 'string' ? JSON.parse(currentProject) : currentProject;
+ }
- $els = $(els);
+ this.init(els, options);
+ }
- if (!els) {
- $els = $('.js-milestone-select');
- }
+ init(els, options) {
+ let $els = $(els);
- $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="<%- name %><br /><%- remaining %>" data-placement="left" data-html="true"> <%- 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');
- }
+ if (!els) {
+ $els = $('.js-milestone-select');
+ }
- 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;
+ $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'
+ });
+ }
+ if (showUpcoming) {
+ extraOptions.push({
+ id: -2,
+ name: '#upcoming',
+ title: 'Upcoming'
+ });
}
- },
- 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 (showStarted) {
+ extraOptions.push({
+ id: -3,
+ name: '#started',
+ title: 'Started'
+ });
}
- $('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 (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));
@@ -215,13 +216,13 @@ import _ from 'underscore';
$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..c7bccd483ac 100644
--- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js
+++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
@@ -1,5 +1,6 @@
/* eslint-disable no-new */
-/* global Flash */
+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 +79,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
index f80a26b3fd4..8ca94ef3e2a 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,42 +1,119 @@
<script>
- /* global Flash */
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';
- import { convertPermissionToBoolean } from '../../lib/utils/common_utils';
export default {
+ components: {
+ Graph,
+ GraphGroup,
+ EmptyState,
+ },
- data() {
- const metricsData = document.querySelector('#prometheus-graphs').dataset;
- const store = new MonitoringStore();
+ 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,
+ },
+ emptyUnableToConnectSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
return {
- store,
+ store: new MonitoringStore(),
state: 'gettingStarted',
- hasMetrics: convertPermissionToBoolean(metricsData.hasMetrics),
- documentationPath: metricsData.documentationPath,
- settingsPath: metricsData.settingsPath,
- metricsEndpoint: metricsData.additionalMetrics,
- deploymentEndpoint: metricsData.deploymentEndpoint,
- emptyGettingStartedSvgPath: metricsData.emptyGettingStartedSvgPath,
- emptyLoadingSvgPath: metricsData.emptyLoadingSvgPath,
- emptyUnableToConnectSvgPath: metricsData.emptyUnableToConnectSvgPath,
showEmptyState: true,
updateAspectRatio: false,
updatedAspectRatios: 0,
+ hoverData: {},
resizeThrottled: {},
};
},
- components: {
- Graph,
- GraphGroup,
- EmptyState,
+ 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: {
@@ -49,7 +126,13 @@
.then(data => this.store.storeDeploymentData(data))
.catch(() => new Flash('Error getting deployment information.')),
])
- .then(() => { this.showEmptyState = false; })
+ .then(() => {
+ if (this.store.groups.length < 1) {
+ this.state = 'noData';
+ return;
+ }
+ this.showEmptyState = false;
+ })
.catch(() => { this.state = 'unableToConnect'; });
},
@@ -64,46 +147,36 @@
this.updatedAspectRatios = 0;
}
},
- },
- created() {
- this.service = new MonitoringService({
- metricsEndpoint: this.metricsEndpoint,
- deploymentEndpoint: this.deploymentEndpoint,
- });
- eventHub.$on('toggleAspectRatio', this.toggleAspectRatio);
- },
-
- beforeDestroy() {
- eventHub.$off('toggleAspectRatio', this.toggleAspectRatio);
- 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);
- }
+ hoverChanged(data) {
+ this.hoverData = data;
+ },
},
};
</script>
<template>
- <div v-if="!showEmptyState" class="prometheus-graphs">
+ <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>
@@ -112,6 +185,7 @@
:selected-state="state"
:documentation-path="documentationPath"
:settings-path="settingsPath"
+ :clusters-path="clustersPath"
:empty-getting-started-svg-path="emptyGettingStartedSvgPath"
:empty-loading-svg-path="emptyLoadingSvgPath"
:empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue
index a7b483f6786..9517b8ccb67 100644
--- a/app/assets/javascripts/monitoring/components/empty_state.vue
+++ b/app/assets/javascripts/monitoring/components/empty_state.vue
@@ -10,6 +10,11 @@
required: false,
default: '',
},
+ clustersPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
selectedState: {
type: String,
required: true,
@@ -33,20 +38,35 @@
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: 'Configure Prometheus',
+ 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.',
+ 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.emptyUnableToConnectSvgPath,
+ 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,
},
},
};
@@ -56,13 +76,6 @@
return this.states[this.selectedState];
},
- buttonPath() {
- if (this.selectedState === 'gettingStarted') {
- return this.settingsPath;
- }
- return this.documentationPath;
- },
-
showButtonDescription() {
if (this.selectedState === 'unableToConnect') return true;
return false;
@@ -73,34 +86,38 @@
<template>
<div class="prometheus-state">
- <div class="row">
- <div class="col-md-4 col-md-offset-4 state-svg svg-content">
- <img :src="currentState.svgUrl"/>
- </div>
- </div>
- <div class="row">
- <div class="col-md-6 col-md-offset-3">
- <h4 class="text-center state-title">
- {{currentState.title}}
- </h4>
- </div>
+ <div class="state-svg svg-content">
+ <img :src="currentState.svgUrl" />
</div>
- <div class="row">
- <div class="col-md-6 col-md-offset-3">
- <div class="description-text text-center state-description">
- {{currentState.description}}
- <a v-if="showButtonDescription" :href="settingsPath">
- Prometheus server
- </a>
- </div>
- </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="row state-button-section">
- <div class="col-md-4 col-md-offset-4 text-center state-button">
- <a class="btn btn-success" :href="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
index 6b3e341f936..9e67a6f2146 100644
--- a/app/assets/javascripts/monitoring/components/graph.vue
+++ b/app/assets/javascripts/monitoring/components/graph.vue
@@ -1,19 +1,31 @@
<script>
- import d3 from 'd3';
+ 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 GraphPath from './graph/path.vue';
import MonitoringMixin from '../mixins/monitoring_mixins';
import eventHub from '../event_hub';
import measurements from '../utils/measurements';
- import { timeScaleFormat } from '../utils/date_time_formatters';
+ import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters';
import createTimeSeries from '../utils/multiple_time_series';
import bp from '../../breakpoints';
- const bisectDate = d3.bisector(d => d.time).left;
+ const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select };
export default {
+ components: {
+ GraphLegend,
+ GraphFlag,
+ GraphDeployment,
+ GraphPath,
+ },
+
+ mixins: [MonitoringMixin],
+
props: {
graphData: {
type: Object,
@@ -27,10 +39,31 @@
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,
+ },
},
- mixins: [MonitoringMixin],
-
data() {
return {
baseGraphHeight: 450,
@@ -52,28 +85,19 @@
currentXCoordinate: 0,
currentFlagPosition: 0,
showFlag: false,
- showDeployInfo: true,
+ showFlagContent: false,
timeSeries: [],
+ realPixelRatio: 1,
};
},
- components: {
- GraphLegend,
- GraphFlag,
- GraphDeployment,
- GraphPath,
- },
-
computed: {
- outterViewBox() {
+ outerViewBox() {
return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`;
},
innerViewBox() {
- if ((this.baseGraphWidth - 150) > 0) {
- return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`;
- }
- return '0 0 0 0';
+ return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`;
},
axisTransform() {
@@ -85,6 +109,30 @@
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: {
@@ -92,7 +140,7 @@
const breakpointSize = bp.getBreakpointSize();
const query = this.graphData.queries[0];
this.margin = measurements.large.margin;
- if (breakpointSize === 'xs' || breakpointSize === 'sm') {
+ if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') {
this.graphHeight = 300;
this.margin = measurements.small.margin;
this.measurements = measurements.small;
@@ -105,6 +153,10 @@
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();
},
@@ -122,52 +174,46 @@
const d1 = firstTimeSeries.values[overlayIndex];
if (d0 === undefined || d1 === undefined) return;
const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay;
- this.currentData = evalTime ? d1 : d0;
- this.currentDataIndex = evalTime ? overlayIndex : (overlayIndex - 1);
- this.currentXCoordinate = Math.floor(firstTimeSeries.timeSeriesScaleX(this.currentData.time));
+ const hoveredDataIndex = evalTime ? overlayIndex : (overlayIndex - 1);
+ const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time;
const currentDeployXPos = this.mouseOverDeployInfo(point.x);
- if (this.currentXCoordinate > (this.graphWidth - 200)) {
- this.currentFlagPosition = this.currentXCoordinate - 103;
- } else {
- this.currentFlagPosition = this.currentXCoordinate;
- }
-
- if (currentDeployXPos) {
- this.showFlag = false;
- } else {
- this.showFlag = true;
- }
+ eventHub.$emit('hoverChanged', {
+ hoveredDate,
+ currentDeployXPos,
+ });
},
renderAxesPaths() {
- this.timeSeries = createTimeSeries(this.graphData.queries[0],
- this.graphWidth,
- this.graphHeight,
- this.graphHeightOffset);
+ this.timeSeries = createTimeSeries(
+ this.graphData.queries,
+ this.graphWidth,
+ this.graphHeight,
+ this.graphHeightOffset,
+ );
- if (this.timeSeries.length > 3) {
+ if (!this.showLegend) {
+ this.baseGraphHeight -= 50;
+ } else if (this.timeSeries.length > 3) {
this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20;
}
- const axisXScale = d3.time.scale()
- .range([0, this.graphWidth]);
- const axisYScale = d3.scale.linear()
+ const axisXScale = d3.scaleTime()
+ .range([0, this.graphWidth - 70]);
+ const axisYScale = d3.scaleLinear()
.range([this.graphHeight - this.graphHeightOffset, 0]);
- axisXScale.domain(d3.extent(this.timeSeries[0].values, d => d.time));
- axisYScale.domain([0, d3.max(this.timeSeries[0].values.map(d => d.value))]);
+ 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.svg.axis()
+ const xAxis = d3.axisBottom()
.scale(axisXScale)
- .ticks(d3.time.minute, 60)
- .tickFormat(timeScaleFormat)
- .orient('bottom');
+ .tickFormat(timeScaleFormat);
- const yAxis = d3.svg.axis()
+ const yAxis = d3.axisLeft()
.scale(axisYScale)
- .ticks(measurements.yTicks)
- .orient('left');
+ .ticks(measurements.yTicks);
d3.select(this.$refs.baseSvg).select('.x-axis').call(xAxis);
@@ -183,44 +229,34 @@
}); // This will select all of the ticks once they're rendered
},
},
-
- watch: {
- updateAspectRatio() {
- if (this.updateAspectRatio) {
- this.graphHeight = 450;
- this.graphWidth = 600;
- this.measurements = measurements.large;
- this.draw();
- eventHub.$emit('toggleAspectRatio');
- }
- },
- },
-
- mounted() {
- this.draw();
- },
};
</script>
<template>
- <div class="prometheus-graph">
+ <div
+ class="prometheus-graph"
+ @mouseover="showFlagContent = true"
+ @mouseleave="showFlagContent = false"
+ >
<h5 class="text-center graph-title">
- {{graphData.title}}
+ {{ graphData.title }}
</h5>
<div
class="prometheus-svg-container"
- :style="paddingBottomRootSvg">
+ :style="paddingBottomRootSvg"
+ >
<svg
- :viewBox="outterViewBox"
- ref="baseSvg">
+ :viewBox="outerViewBox"
+ ref="baseSvg"
+ >
<g
class="x-axis"
- :transform="axisTransform">
- </g>
+ :transform="axisTransform"
+ />
<g
class="y-axis"
- transform="translate(70, 20)">
- </g>
+ transform="translate(70, 20)"
+ />
<graph-legend
:graph-width="graphWidth"
:graph-height="graphHeight"
@@ -231,43 +267,50 @@
: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-color="path.lineColor"
- :area-color="path.areaColor"
- />
- <graph-deployment
- :show-deploy-info="showDeployInfo"
- :deployment-data="reducedDeploymentData"
- :graph-height="graphHeight"
- :graph-height-offset="graphHeightOffset"
- />
- <graph-flag
- v-if="showFlag"
- :current-x-coordinate="currentXCoordinate"
- :current-data="currentData"
- :current-flag-position="currentFlagPosition"
- :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)">
- </rect>
+ 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
index 3623d2ed946..98c25307b74 100644
--- a/app/assets/javascripts/monitoring/components/graph/deployment.vue
+++ b/app/assets/javascripts/monitoring/components/graph/deployment.vue
@@ -1,12 +1,6 @@
<script>
- import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
-
export default {
props: {
- showDeployInfo: {
- type: Boolean,
- required: true,
- },
deploymentData: {
type: Array,
required: true,
@@ -28,104 +22,52 @@
},
methods: {
- refText(d) {
- return d.tag ? d.ref : d.sha.slice(0, 6);
- },
-
- formatTime(deploymentTime) {
- return timeFormat(deploymentTime);
- },
-
- formatDate(deploymentTime) {
- return dateFormat(deploymentTime);
- },
-
- nameDeploymentClass(deployment) {
- return `deploy-info-${deployment.id}`;
- },
-
transformDeploymentGroup(deployment) {
- return `translate(${Math.floor(deployment.xPos) + 1}, 20)`;
+ return `translate(${Math.floor(deployment.xPos) - 5}, 20)`;
},
},
};
</script>
<template>
- <g
- class="deploy-info"
- v-if="showDeployInfo">
+ <g class="deploy-info">
<g
v-for="(deployment, index) in deploymentData"
:key="index"
- :class="nameDeploymentClass(deployment)"
:transform="transformDeploymentGroup(deployment)">
<rect
x="0"
y="0"
:height="calculatedHeight"
width="3"
- fill="url(#shadow-gradient)">
- </rect>
+ fill="url(#shadow-gradient)"
+ />
<line
class="deployment-line"
x1="0"
y1="0"
x2="0"
:y2="calculatedHeight"
- stroke="#000">
- </line>
- <svg
- v-if="deployment.showDeploymentFlag"
- class="js-deploy-info-box"
- x="3"
- y="0"
- width="92"
- height="60">
- <rect
- class="rect-text-metric deploy-info-rect rect-metric"
- x="1"
- y="1"
- rx="2"
- width="90"
- height="58">
- </rect>
- <g
- transform="translate(5, 2)">
- <text
- class="deploy-info-text text-metric-bold">
- {{refText(deployment)}}
- </text>
- </g>
- <text
- class="deploy-info-text"
- y="18"
- transform="translate(5, 2)">
- {{formatDate(deployment.time)}}
- </text>
- <text
- class="deploy-info-text text-metric-bold"
- y="38"
- transform="translate(5, 2)">
- {{formatTime(deployment.time)}}
- </text>
- </svg>
+ stroke="#000"
+ />
</g>
<svg
height="0"
- width="0">
+ width="0"
+ >
<defs>
<linearGradient
- id="shadow-gradient">
+ id="shadow-gradient"
+ >
<stop
offset="0%"
stop-color="#000"
- stop-opacity="0.4">
- </stop>
+ stop-opacity="0.4"
+ />
<stop
offset="100%"
stop-color="#000"
- stop-opacity="0">
- </stop>
+ stop-opacity="0"
+ />
</linearGradient>
</defs>
</svg>
diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue
index a98e3d06c18..07aa6a3e5de 100644
--- a/app/assets/javascripts/monitoring/components/graph/flag.vue
+++ b/app/assets/javascripts/monitoring/components/graph/flag.vue
@@ -1,20 +1,26 @@
<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,
},
- currentFlagPosition: {
- type: Number,
- required: true,
- },
currentData: {
type: Object,
required: true,
},
+ deploymentFlagData: {
+ type: Object,
+ required: false,
+ default: null,
+ },
graphHeight: {
type: Number,
required: true,
@@ -23,66 +29,173 @@
type: Number,
required: true,
},
- },
-
- data() {
- return {
- circleColorRgb: '#8fbce8',
- };
+ 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 timeFormat(this.currentData.time);
+ return this.deploymentFlagData ?
+ timeFormat(this.deploymentFlagData.time) :
+ timeFormat(this.currentData.time);
},
formatDate() {
- return dateFormat(this.currentData.time);
+ 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`,
+ };
},
- calculatedHeight() {
- return this.graphHeight - this.graphHeightOffset;
+ 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>
- <g class="mouse-over-flag">
- <line
- class="selected-metric-line"
- :x1="currentXCoordinate"
- :y1="0"
- :x2="currentXCoordinate"
- :y2="calculatedHeight"
- transform="translate(-5, 20)">
- </line>
- <svg
- class="rect-text-metric"
- :x="currentFlagPosition"
- y="0">
- <rect
- class="rect-metric"
- x="4"
- y="1"
- rx="2"
- width="90"
- height="40"
- transform="translate(-3, 20)">
- </rect>
- <text
- class="text-metric text-metric-bold"
- x="16"
- y="35"
- transform="translate(-5, 20)">
- {{formatTime}}
- </text>
- <text
- class="text-metric"
- x="16"
- y="15"
- transform="translate(-5, 20)">
- {{formatDate}}
- </text>
- </svg>
- </g>
+ <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
index dbc48c63747..3149397b61f 100644
--- a/app/assets/javascripts/monitoring/components/graph/legend.vue
+++ b/app/assets/javascripts/monitoring/components/graph/legend.vue
@@ -39,6 +39,11 @@
type: Number,
required: true,
},
+ showLegendGroup: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -57,8 +62,9 @@
},
rectTransform() {
- const yCoordinate = ((this.graphHeight - this.margin.top) / 2)
- + (this.yLabelWidth / 2) + 10 || 0;
+ const yCoordinate = (((this.graphHeight - this.margin.top)
+ + this.measurements.axisLabelLineOffset) / 2)
+ + (this.yLabelWidth / 2) || 0;
return `translate(0, ${yCoordinate}) rotate(-90)`;
},
@@ -73,22 +79,6 @@
},
},
- methods: {
- translateLegendGroup(index) {
- return `translate(0, ${12 * (index)})`;
- },
-
- formatMetricUsage(series) {
- return `${formatRelevantDigits(series.values[this.currentDataIndex].value)} ${this.unitOfDisplay}`;
- },
-
- createSeriesString(index, series) {
- if (series.metricTag) {
- return `${series.metricTag} ${this.formatMetricUsage(series)}`;
- }
- return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`;
- },
- },
mounted() {
this.$nextTick(() => {
const bbox = this.$refs.ylabel.getBBox();
@@ -104,11 +94,37 @@
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">
+ <g class="axis-label-container">
<line
class="label-x-axis-line"
stroke="#000000"
@@ -116,8 +132,8 @@
x1="10"
:y1="yPosition"
:x2="graphWidth + 20"
- :y2="yPosition">
- </line>
+ :y2="yPosition"
+ />
<line
class="label-y-axis-line"
stroke="#000000"
@@ -125,62 +141,72 @@
x1="10"
y1="0"
:x2="10"
- :y2="yPosition">
- </line>
+ :y2="yPosition"
+ />
<rect
class="rect-axis-text"
:transform="rectTransform"
:width="yLabelWidth"
- :height="yLabelHeight">
- </rect>
+ :height="yLabelHeight"
+ />
<text
class="label-axis-text y-label-text"
text-anchor="middle"
:transform="textTransform"
- ref="ylabel">
- {{yAxisLabel}}
+ ref="ylabel"
+ >
+ {{ yAxisLabel }}
</text>
<rect
class="rect-axis-text"
:x="xPosition + 60"
:y="graphHeight - 80"
width="35"
- height="50">
- </rect>
+ height="50"
+ />
<text
class="label-axis-text x-label-text"
:x="xPosition + 60"
:y="yPosition"
- dy=".35em">
+ dy=".35em"
+ >
Time
</text>
- <g class="legend-group"
- v-for="(series, index) in timeSeries"
- :key="index"
- :transform="translateLegendGroup(index)">
- <rect
- :fill="series.areaColor"
- :width="measurements.legends.width"
- :height="measurements.legends.height"
- x="20"
- :y="graphHeight - measurements.legendOffset">
- </rect>
- <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 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
index 043f1bf66bb..c9721c4cb01 100644
--- a/app/assets/javascripts/monitoring/components/graph_path.vue
+++ b/app/assets/javascripts/monitoring/components/graph/path.vue
@@ -9,6 +9,11 @@
type: String,
required: true,
},
+ lineStyle: {
+ type: String,
+ required: false,
+ default: '',
+ },
lineColor: {
type: String,
required: true,
@@ -18,6 +23,13 @@
required: true,
},
},
+ computed: {
+ strokeDashArray() {
+ if (this.lineStyle === 'dashed') return '3, 1';
+ if (this.lineStyle === 'dotted') return '1, 1';
+ return null;
+ },
+ },
};
</script>
<template>
@@ -26,15 +38,16 @@
class="metric-area"
:d="generatedAreaPath"
:fill="areaColor"
- transform="translate(-5, 20)">
- </path>
+ transform="translate(-5, 20)"
+ />
<path
class="metric-line"
:d="generatedLinePath"
:stroke="lineColor"
fill="none"
stroke-width="1"
- transform="translate(-5, 20)">
- </path>
+ :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
index 958f537d31b..f71cf614552 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -1,21 +1,35 @@
<script>
-export default {
- props: {
- name: {
- type: String,
- required: true,
+ export default {
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ showPanels: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
- },
-};
+ };
</script>
<template>
- <div class="panel panel-default prometheus-panel">
+ <div
+ v-if="showPanels"
+ class="panel panel-default prometheus-panel"
+ >
<div class="panel-heading">
- <h4>{{name}}</h4>
+ <h4>{{ name }}</h4>
</div>
<div class="panel-body prometheus-graph-group">
- <slot />
+ <slot></slot>
</div>
</div>
+ <div
+ v-else
+ class="prometheus-graph-group"
+ >
+ <slot></slot>
+ </div>
</template>
diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
index 345a0b37a76..6cc67ba57ee 100644
--- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
+++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
@@ -1,3 +1,5 @@
+import { bisectDate } from '../utils/date_time_formatters';
+
const mixins = {
methods: {
mouseOverDeployInfo(mouseXPos) {
@@ -18,6 +20,7 @@ const mixins = {
return dataFound;
},
+
formatDeployments() {
this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => {
const time = new Date(deployment.created_at);
@@ -26,13 +29,18 @@ const mixins = {
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,
});
}
@@ -40,6 +48,25 @@ const mixins = {
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;
+ }
+ },
},
};
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index ef280e02092..41270e015d4 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,10 +1,22 @@
import Vue from 'vue';
+import { convertPermissionToBoolean } from '~/lib/utils/common_utils';
import Dashboard from './components/dashboard.vue';
-document.addEventListener('DOMContentLoaded', () => new Vue({
- el: '#prometheus-graphs',
- components: {
- Dashboard,
- },
- render: createElement => createElement('dashboard'),
-}));
+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/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js
index fed884d5c94..6fcca36d2fa 100644
--- a/app/assets/javascripts/monitoring/services/monitoring_service.js
+++ b/app/assets/javascripts/monitoring/services/monitoring_service.js
@@ -1,10 +1,7 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
+import axios from '../../lib/utils/axios_utils';
import statusCodes from '../../lib/utils/http_status';
import { backOff } from '../../lib/utils/common_utils';
-Vue.use(VueResource);
-
const MAX_REQUESTS = 3;
function backOffRequest(makeRequestCallback) {
@@ -32,8 +29,8 @@ export default class MonitoringService {
}
getGraphsData() {
- return backOffRequest(() => Vue.http.get(this.metricsEndpoint))
- .then(resp => resp.json())
+ 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');
@@ -43,8 +40,11 @@ export default class MonitoringService {
}
getDeploymentData() {
- return backOffRequest(() => Vue.http.get(this.deploymentEndpoint))
- .then(resp => resp.json())
+ 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');
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
index 7592af5878e..854636e9a89 100644
--- a/app/assets/javascripts/monitoring/stores/monitoring_store.js
+++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js
@@ -13,7 +13,7 @@ function normalizeMetrics(metrics) {
...result,
values: result.values.map(([timestamp, value]) => ({
time: new Date(timestamp * 1000),
- value,
+ value: Number(value),
})),
})),
})),
diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js
index 26bcaa02511..f3c9acdd93e 100644
--- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js
+++ b/app/assets/javascripts/monitoring/utils/date_time_formatters.js
@@ -1,15 +1,42 @@
-import d3 from 'd3';
+import { timeFormat as time } from 'd3-time-format';
+import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from 'd3-time';
+import { bisector } from 'd3-array';
-export const dateFormat = d3.time.format('%b %-d, %Y');
-export const timeFormat = d3.time.format('%-I:%M%p');
+const d3 = {
+ time,
+ bisector,
+ timeSecond,
+ timeMinute,
+ timeHour,
+ timeDay,
+ timeWeek,
+ timeMonth,
+ timeYear,
+};
-export const timeScaleFormat = d3.time.format.multi([
- ['.%L', d => d.getMilliseconds()],
- [':%S', d => d.getSeconds()],
- ['%-I:%M', d => d.getMinutes()],
- ['%-I %p', d => d.getHours()],
- ['%a %-d', d => d.getDay() && d.getDate() !== 1],
- ['%b %-d', d => d.getDate() !== 1],
- ['%B', d => d.getMonth()],
- ['%Y', () => true],
-]);
+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
index ee3c45efacc..ee866850e13 100644
--- a/app/assets/javascripts/monitoring/utils/measurements.js
+++ b/app/assets/javascripts/monitoring/utils/measurements.js
@@ -7,15 +7,16 @@ export default {
left: 40,
},
legends: {
- width: 10,
+ width: 15,
height: 3,
+ offsetX: 20,
+ offsetY: 32,
},
backgroundLegend: {
width: 30,
height: 50,
},
axisLabelLineOffset: -20,
- legendOffset: 33,
},
large: { // This covers both md and lg screen sizes
margin: {
@@ -27,13 +28,14 @@ export default {
legends: {
width: 15,
height: 3,
+ offsetX: 20,
+ offsetY: 34,
},
backgroundLegend: {
width: 30,
height: 150,
},
axisLabelLineOffset: 20,
- legendOffset: 36,
},
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
index 3cbe06d8fd6..b5b8e3c255d 100644
--- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js
+++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
@@ -1,5 +1,10 @@
-import d3 from 'd3';
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'],
@@ -11,7 +16,9 @@ const defaultColorPalette = {
const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple'];
-export default function createTimeSeries(queryData, graphWidth, graphHeight, graphHeightOffset) {
+const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
+
+function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) {
let usedColors = [];
function pickColor(name) {
@@ -31,62 +38,77 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra
return defaultColorPalette[pick];
}
- const maxValues = queryData.result.map((timeSeries, index) => {
- const maxValue = d3.max(timeSeries.values.map(d => d.value));
- return {
- maxValue,
- index,
- };
- });
-
- const maxValueFromSeries = _.max(maxValues, val => val.maxValue);
-
- return queryData.result.map((timeSeries, timeSeriesNumber) => {
+ return query.result.map((timeSeries, timeSeriesNumber) => {
let metricTag = '';
let lineColor = '';
let areaColor = '';
- const timeSeriesScaleX = d3.time.scale()
+ const timeSeriesScaleX = d3.scaleTime()
.range([0, graphWidth - 70]);
- const timeSeriesScaleY = d3.scale.linear()
+ const timeSeriesScaleY = d3.scaleLinear()
.range([graphHeight - graphHeightOffset, 0]);
- timeSeriesScaleX.domain(d3.extent(timeSeries.values, d => d.time));
- timeSeriesScaleX.ticks(d3.time.minute, 60);
- timeSeriesScaleY.domain([0, maxValueFromSeries.maxValue]);
+ timeSeriesScaleX.domain(xDom);
+ timeSeriesScaleX.ticks(d3.timeMinute, 60);
+ timeSeriesScaleY.domain(yDom);
+
+ const defined = d => !isNaN(d.value) && d.value != null;
- const lineFunction = d3.svg.line()
- .interpolate('linear')
+ 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.svg.area()
- .interpolate('linear')
+ 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 = queryData.series != null &&
- _.findWhere(queryData.series[0].when,
- { value: timeSeriesMetricLabel });
- if (seriesCustomizationData != null) {
+ 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 || `series ${timeSeriesNumber + 1}`;
+ 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..972fdb2b791
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -0,0 +1,41 @@
+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() {
+ new Vue({ // eslint-disable-line
+ 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,
+ },
+ });
+ },
+ });
+
+ new Vue({ // eslint-disable-line
+ 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..aa377327107 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -1,85 +1,57 @@
-/* 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 */
+/* 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 */
import Api from './api';
+import { mergeUrlParams } from './lib/utils/url_utility';
-(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
- });
- }
-
- NamespaceSelect.prototype.onSelectItem = function(options) {
- const { e } = options;
- return e.preventDefault();
- };
+export default class NamespaceSelect {
+ constructor(opts) {
+ const isFilter = opts.dropdown.dataset.isFilter === 'true';
+ const fieldName = opts.dropdown.dataset.fieldName || 'namespace_id';
- return NamespaceSelect;
- })();
-
- 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..d3edcb724f1 100644
--- a/app/assets/javascripts/network/branch_graph.js
+++ b/app/assets/javascripts/network/branch_graph.js
@@ -1,5 +1,8 @@
/* 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 '../locale';
+import axios from '../lib/utils/axios_utils';
+import flash from '../flash';
import Raphael from './raphael';
export default (function() {
@@ -26,16 +29,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_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..77733b67c4d 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,97 +1,93 @@
/* 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';
+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();
+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 93aa29454a0..c640003d958 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -5,28 +5,30 @@ 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 axios from './lib/utils/axios_utils';
+import { getLocationHash } from './lib/utils/url_utility';
+import Flash from './flash';
import CommentTypeToggle from './comment_type_toggle';
+import GLForm from './gl_form';
import loadAwardsHandler from './awards_handler';
-import './autosave';
-import './dropzone_input';
+import Autosave from './autosave';
import TaskList from './task_list';
-import { ajaxPost, isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils';
+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;
-window.Dropzone = Dropzone;
+window.autosize = Autosize;
function normalizeNewlines(str) {
return str.replace(/\r\n/g, '\n');
@@ -36,12 +38,23 @@ 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);
@@ -93,64 +106,77 @@ export default class Notes {
}
addBinding() {
+ this.$wrapperEl = hasVueMRDiscussionsCookie() ? $(document).find('.diffs') : $(document);
+
// 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);
// 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);
+ this.eventsBound = true;
}
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');
+ if (!this.eventsBound) {
+ return;
+ }
+
+ 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('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);
}
@@ -207,7 +233,7 @@ 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?')) {
@@ -240,27 +266,23 @@ 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;
+ });
}
/**
@@ -307,7 +329,7 @@ export default class Notes {
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();
@@ -327,7 +349,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);
@@ -344,12 +366,12 @@ export default class Notes {
}
if (!noteEntity.valid) {
- if (noteEntity.errors.commands_only) {
+ 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);
+ this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline.get(0));
this.refresh();
}
return;
@@ -357,6 +379,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) {
@@ -393,6 +419,8 @@ export default class Notes {
this.setupNewNote($updatedNote);
}
}
+
+ Notes.refreshVueNotes();
}
isParallelView() {
@@ -400,18 +428,23 @@ 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');
+ 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');
// is this the first note of discussion?
@@ -423,7 +456,7 @@ 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 {
@@ -440,7 +473,9 @@ export default class Notes {
// 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 (!hasVueMRDiscussionsCookie()) {
+ Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
+ }
}
} else {
// append new note to all matching discussions
@@ -449,10 +484,11 @@ export default class Notes {
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);
}
@@ -546,7 +582,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',
@@ -561,7 +597,7 @@ export default class Notes {
form.find('#note_line_code').val(),
// DiffNote
- form.find('#note_position').val()
+ form.find('#note_position').val(),
];
return new Autosave(textarea, key);
}
@@ -582,7 +618,7 @@ 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) {
@@ -596,9 +632,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);
@@ -621,7 +657,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
@@ -717,7 +752,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();
}
@@ -733,12 +768,13 @@ 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'));
}
/**
@@ -763,7 +799,7 @@ export default class Notes {
var $note, $notes;
$note = $(el);
$notes = $note.closest('.discussion-notes');
- const discussionId = $('.notes', $notes).data('discussion-id');
+ const discussionId = $('.notes', $notes).data('discussionId');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) {
@@ -783,15 +819,29 @@ export default class Notes {
$(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
// The notes tr can contain multiple lists of notes, like on the parallel diff
- if (notesTr.find('.discussion-notes').length > 1) {
+ // 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 {
+ } else if (notesTr.length > 0) {
notesTr.remove();
}
}
};
})(this));
+ Notes.refreshVueNotes();
Notes.checkMergeRequestStatus();
return this.updateNotesCount(-1);
}
@@ -841,7 +891,11 @@ export default class Notes {
*/
setupDiscussionNoteForm(dataHolder, form) {
// setup note target
- const diffFileData = dataHolder.closest('.text-file');
+ let diffFileData = dataHolder.closest('.text-file');
+
+ if (diffFileData.length === 0) {
+ diffFileData = dataHolder.closest('.image');
+ }
var discussionID = dataHolder.data('discussionId');
@@ -867,7 +921,7 @@ 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);
@@ -907,6 +961,31 @@ export default class Notes {
});
}
+ 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,
@@ -982,7 +1061,7 @@ 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)
@@ -999,10 +1078,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);
}
/**
@@ -1052,8 +1146,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);
}
@@ -1080,14 +1174,14 @@ 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)
+ .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);
@@ -1145,13 +1239,13 @@ export default class Notes {
}
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;
}
}
@@ -1189,7 +1283,7 @@ export default class Notes {
}
static checkMergeRequestStatus() {
- if (getPagePath(1) === 'merge_requests') {
+ if (getPagePath(1) === 'merge_requests' && gl.mrWidget) {
gl.mrWidget.checkStatus();
}
}
@@ -1210,14 +1304,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,
};
}
@@ -1327,7 +1427,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 `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
@@ -1349,7 +1449,7 @@ export default class Notes {
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 { formData, formContent, formAction, formContentOriginal } = this.getFormData($form);
let noteUniqueId;
let systemNoteUniqueId;
let hasQuickActions = false;
@@ -1409,11 +1509,22 @@ export default class Notes {
/* eslint-disable promise/catch-or-return */
// Make request to submit comment on server
- ajaxPost(formAction, formData)
- .then((note) => {
+ axios.post(`${formAction}?html=true`, formData)
+ .then((res) => {
+ const note = res.data;
+
// 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');
@@ -1430,18 +1541,41 @@ 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>');
}
+
+ 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);
@@ -1453,10 +1587,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();
+ 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();
}
@@ -1468,12 +1612,12 @@ export default class Notes {
$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'));
+ return $closeBtn.text($closeBtn.data('originalText'));
}
/**
@@ -1482,7 +1626,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 `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
@@ -1500,6 +1644,8 @@ 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();
@@ -1511,12 +1657,12 @@ export default class Notes {
/* eslint-disable promise/catch-or-return */
// Make request to update comment on server
- 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');
@@ -1526,7 +1672,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/issue_comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index fa7ac994058..b85c1a6ad72 100644
--- a/app/assets/javascripts/notes/components/issue_comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,75 +1,100 @@
<script>
- /* global Flash, Autosave */
import { mapActions, mapGetters } from 'vuex';
import _ from 'underscore';
- import autosize from 'vendor/autosize';
- import '../../autosave';
+ 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 confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
- import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
+ 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: 'issueCommentForm',
+ name: 'CommentForm',
+ components: {
+ issueWarning,
+ noteSignedOutWidget,
+ discussionLockedWidget,
+ markdownField,
+ userAvatarLink,
+ loadingButton,
+ },
+ mixins: [
+ issuableStateMixin,
+ ],
+ props: {
+ noteableType: {
+ type: String,
+ required: true,
+ },
+ },
data() {
return {
note: '',
noteType: constants.COMMENT,
- // Can't use mapGetters,
- // this needs to be in the data object because it belongs to the state
- issueState: this.$store.getters.getIssueData.state,
isSubmitting: false,
isSubmitButtonDisabled: true,
};
},
- components: {
- confidentialIssue,
- issueNoteSignedOutWidget,
- markdownField,
- userAvatarLink,
- },
- watch: {
- note(newNote) {
- this.setIsSubmitButtonDisabled(newNote, this.isSubmitting);
- },
- isSubmitting(newValue) {
- this.setIsSubmitButtonDisabled(this.note, newValue);
- },
- },
computed: {
...mapGetters([
'getCurrentUserLastNote',
'getUserData',
- 'getIssueData',
+ 'getNoteableData',
'getNotesData',
+ 'openState',
]),
+ noteableDisplayName() {
+ return this.noteableType.replace(/_/g, ' ');
+ },
isLoggedIn() {
return this.getUserData.id;
},
commentButtonTitle() {
return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
},
- isIssueOpen() {
- return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
+ isOpen() {
+ return this.openState === constants.OPENED || this.openState === constants.REOPENED;
+ },
+ canCreateNote() {
+ return this.getNoteableData.current_user.can_create_note;
},
issueActionButtonTitle() {
- if (this.note.length) {
- const actionText = this.isIssueOpen ? 'close' : 'reopen';
+ const openOrClose = this.isOpen ? 'close' : 'reopen';
- return this.noteType === constants.COMMENT ? `Comment & ${actionText} issue` : `Start discussion & ${actionText} issue`;
+ if (this.note.length) {
+ return sprintf(
+ __('%{actionText} & %{openOrClose} %{noteable}'),
+ {
+ actionText: this.commentButtonTitle,
+ openOrClose,
+ noteable: this.noteableDisplayName,
+ },
+ );
}
- return this.isIssueOpen ? 'Close issue' : 'Reopen issue';
+ return sprintf(
+ __('%{openOrClose} %{noteable}'),
+ {
+ openOrClose: capitalizeFirstCharacter(openOrClose),
+ noteable: this.noteableDisplayName,
+ },
+ );
},
actionButtonClassNames() {
return {
- 'btn-reopen': !this.isIssueOpen,
- 'btn-close': this.isIssueOpen,
- 'js-note-target-close': this.isIssueOpen,
- 'js-note-target-reopen': !this.isIssueOpen,
+ 'btn-reopen': !this.isOpen,
+ 'btn-close': this.isOpen,
+ 'js-note-target-close': this.isOpen,
+ 'js-note-target-reopen': !this.isOpen,
};
},
markdownDocsPath() {
@@ -79,27 +104,44 @@
return this.getNotesData.quickActionsDocsPath;
},
markdownPreviewPath() {
- return this.getIssueData.preview_note_path;
+ return this.getNoteableData.preview_note_path;
},
author() {
return this.getUserData;
},
canUpdateIssue() {
- return this.getIssueData.current_user.can_update;
+ return this.getNoteableData.current_user.can_update;
},
endpoint() {
- return this.getIssueData.create_note_path;
+ return this.getNoteableData.create_note_path;
},
- isConfidentialIssue() {
- return this.getIssueData.confidential;
+ },
+ 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',
]),
setIsSubmitButtonDisabled(note, isSubmitting) {
if (!_.isEmpty(note) && !isSubmitting) {
@@ -109,14 +151,16 @@
}
},
handleSave(withIssueAction) {
+ this.isSubmitting = true;
+
if (this.note.length) {
const noteData = {
endpoint: this.endpoint,
flashContainer: this.$el,
data: {
note: {
- noteable_type: constants.NOTEABLE_TYPE,
- noteable_id: this.getIssueData.id,
+ noteable_type: this.noteableType,
+ noteable_id: this.getNoteableData.id,
note: this.note,
},
},
@@ -125,7 +169,6 @@
if (this.noteType === constants.DISCUSSION) {
noteData.data.note.type = constants.DISCUSSION_NOTE;
}
- this.isSubmitting = true;
this.note = ''; // Empty textarea while being requested. Repopulate in catch
this.resizeTextarea();
this.stopPolling();
@@ -142,7 +185,7 @@
Flash(
'Something went wrong while adding your comment. Please try again.',
'alert',
- $(this.$refs.commentForm),
+ this.$refs.commentForm,
);
}
} else {
@@ -156,8 +199,10 @@
.catch(() => {
this.isSubmitting = false;
this.discard(false);
- const msg = 'Your comment could not be submitted! Please check your network connection and try again.';
- Flash(msg, 'alert', $(this.$el));
+ 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();
});
@@ -165,13 +210,35 @@
this.toggleIssueState();
}
},
+ enableButton() {
+ this.isSubmitting = false;
+ },
toggleIssueState() {
- this.issueState = this.isIssueOpen ? constants.CLOSED : constants.REOPENED;
-
- // This is out of scope for the Notes Vue component.
- // It was the shortest path to update the issue state and relevant places.
- const btnClass = this.isIssueOpen ? 'btn-reopen' : 'btn-close';
- $(`.js-btn-issue-action.${btnClass}:visible`).trigger('click');
+ if (this.isOpen) {
+ this.closeIssue()
+ .then(() => this.enableButton())
+ .catch(() => {
+ this.enableButton();
+ 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();
+ 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.
@@ -185,7 +252,6 @@
this.$refs.markdownField.previewMarkdown = false;
}
- // reset autostave
this.autosave.reset();
},
setNoteType(type) {
@@ -204,7 +270,12 @@
},
initAutoSave() {
if (this.isLoggedIn) {
- this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getIssueData.id], 'issue');
+ const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
+
+ this.autosave = new Autosave(
+ $(this.$refs.textarea),
+ ['Note', noteableType, this.getNoteableData.id],
+ );
}
},
initTaskList() {
@@ -216,25 +287,20 @@
},
resizeTextarea() {
this.$nextTick(() => {
- autosize.update(this.$refs.textarea);
+ Autosize.update(this.$refs.textarea);
});
},
},
- mounted() {
- // jQuery is needed here because it is a custom event being dispatched with jQuery.
- $(document).on('issuable:change', (e, isClosed) => {
- this.issueState = isClosed ? constants.CLOSED : constants.REOPENED;
- });
-
- this.initAutoSave();
- this.initTaskList();
- },
};
</script>
<template>
<div>
- <issue-note-signed-out-widget v-if="!isLoggedIn" />
+ <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">
@@ -248,43 +314,55 @@
: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 js-quick-submit common-note-form gfm-form js-main-target-form">
- <confidentialIssue v-if="isConfidentialIssue" />
+ 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"
- :is-confidential-issue="isConfidentialIssue"
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"
+ 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.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">
+ <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}}
+ {{ __(commentButtonTitle) }}
</button>
<button
:disabled="isSubmitButtonDisabled"
@@ -312,7 +390,7 @@
<div class="description">
<strong>Comment</strong>
<p>
- Add a general comment to this issue.
+ Add a general comment to this {{ noteableDisplayName }}.
</p>
</div>
</button>
@@ -326,7 +404,7 @@
<i
aria-hidden="true"
class="fa fa-check icon">
- </i>
+ </i>
<div class="description">
<strong>Start discussion</strong>
<p>
@@ -337,14 +415,19 @@
</li>
</ul>
</div>
- <button
- type="button"
- @click="handleSave(true)"
+
+ <loading-button
v-if="canUpdateIssue"
- :class="actionButtonClassNames"
- class="btn btn-comment btn-comment-and-close">
- {{issueActionButtonTitle}}
- </button>
+ :loading="isSubmitting"
+ @click="handleSave(true)"
+ :container-class="[
+ actionButtonClassNames,
+ 'btn btn-comment btn-comment-and-close js-action-button'
+ ]"
+ :disabled="isSubmitting"
+ :label="issueActionButtonTitle"
+ />
+
<button
type="button"
v-if="note.length"
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..fe5baa3537f
--- /dev/null
+++ b/app/assets/javascripts/notes/components/diff_file_header.vue
@@ -0,0 +1,92 @@
+<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"
+ />
+ </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"
+ />
+
+ <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..75a32709ad5
--- /dev/null
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -0,0 +1,96 @@
+<script>
+ 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..0158f58b569
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -0,0 +1,119 @@
+<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..fc0722042cc
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
@@ -0,0 +1,28 @@
+<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/issue_discussion.vue b/app/assets/javascripts/notes/components/issue_discussion.vue
deleted file mode 100644
index b131ef4b182..00000000000
--- a/app/assets/javascripts/notes/components/issue_discussion.vue
+++ /dev/null
@@ -1,232 +0,0 @@
-<script>
- /* global Flash */
- import { mapActions, mapGetters } from 'vuex';
- import { SYSTEM_NOTE } from '../constants';
- import issueNote from './issue_note.vue';
- import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
- import issueNoteHeader from './issue_note_header.vue';
- import issueNoteActions from './issue_note_actions.vue';
- import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
- import issueNoteEditedText from './issue_note_edited_text.vue';
- import issueNoteForm from './issue_note_form.vue';
- import placeholderNote from './issue_placeholder_note.vue';
- import placeholderSystemNote from './issue_placeholder_system_note.vue';
- import autosave from '../mixins/autosave';
-
- export default {
- props: {
- note: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- isReplying: false,
- };
- },
- components: {
- issueNote,
- userAvatarLink,
- issueNoteHeader,
- issueNoteActions,
- issueNoteSignedOutWidget,
- issueNoteEditedText,
- issueNoteForm,
- placeholderNote,
- placeholderSystemNote,
- },
- mixins: [
- autosave,
- ],
- computed: {
- ...mapGetters([
- 'getIssueData',
- ]),
- discussion() {
- return this.note.notes[0];
- },
- author() {
- return this.discussion.author;
- },
- canReply() {
- return this.getIssueData.current_user.can_create_note;
- },
- newNotePath() {
- return this.getIssueData.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;
- },
- },
- methods: {
- ...mapActions([
- 'saveNote',
- 'toggleDiscussion',
- 'removePlaceholderNotes',
- ]),
- componentName(note) {
- if (note.isPlaceholderNote) {
- if (note.placeholderType === SYSTEM_NOTE) {
- return placeholderSystemNote;
- }
- return placeholderNote;
- }
-
- return issueNote;
- },
- componentData(note) {
- return note.isPlaceholderNote ? note.notes[0] : note;
- },
- toggleDiscussionHandler() {
- this.toggleDiscussion({ discussionId: this.note.id });
- },
- showReplyForm() {
- this.isReplying = true;
- },
- cancelReplyForm(shouldConfirm) {
- if (shouldConfirm && this.$refs.noteForm.isDirty) {
- // eslint-disable-next-line no-alert
- if (!confirm('Are you sure you want to cancel creating this comment?')) {
- 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: 'issue',
- 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);
- });
- });
- },
- },
- mounted() {
- if (this.isReplying) {
- this.initAutoSave();
- }
- },
- updated() {
- if (this.isReplying) {
- if (!this.autosave) {
- this.initAutoSave();
- } else {
- this.setAutoSave();
- }
- }
- },
- };
-</script>
-
-<template>
- <li 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">
- <issue-note-header
- :author="author"
- :created-at="discussion.created_at"
- :note-id="discussion.id"
- :include-toggle="true"
- @toggleHandler="toggleDiscussionHandler"
- action-text="started a discussion"
- class="discussion"
- />
- <issue-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>
- <div
- v-if="note.expanded"
- class="discussion-body">
- <div class="panel panel-default">
- <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">
- <button
- v-if="canReply && !isReplying"
- @click="showReplyForm"
- type="button"
- class="js-vue-discussion-reply btn btn-text-field"
- title="Add a reply">Reply...</button>
- <issue-note-form
- v-if="isReplying"
- save-button-title="Comment"
- :discussion="note"
- :is-editing="false"
- @handleFormUpdate="saveReply"
- @cancelFormEdition="cancelReplyForm"
- ref="noteForm"
- />
- <issue-note-signed-out-widget v-if="!canReply" />
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </li>
-</template>
diff --git a/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue b/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue
deleted file mode 100644
index 80a8ef56a83..00000000000
--- a/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue
+++ /dev/null
@@ -1,21 +0,0 @@
-<script>
- 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/notes/components/issue_note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index feb3e73194b..c26aa6fa15d 100644
--- a/app/assets/javascripts/notes/components/issue_note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -4,12 +4,20 @@
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';
+ import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+ import tooltip from '~/vue_shared/directives/tooltip';
export default {
- name: 'issueNoteActions',
+ name: 'NoteActions',
+ directives: {
+ tooltip,
+ },
+ components: {
+ loadingIcon,
+ },
props: {
authorId: {
type: Number,
@@ -36,17 +44,31 @@
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,
},
},
- directives: {
- tooltip,
- },
- components: {
- loadingIcon,
- },
computed: {
...mapGetters([
'getUserDataByProp',
@@ -63,13 +85,14 @@
currentUserId() {
return this.getUserDataByProp('id');
},
- },
- methods: {
- onEdit() {
- this.$emit('handleEdit');
- },
- onDelete() {
- this.$emit('handleDelete');
+ resolveButtonTitle() {
+ let title = 'Mark as resolved';
+
+ if (this.resolvedBy) {
+ title = `Resolved by ${this.resolvedBy.name}`;
+ }
+
+ return title;
},
},
created() {
@@ -78,6 +101,19 @@
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>
@@ -86,7 +122,34 @@
<div class="note-actions">
<span
v-if="accessLevel"
- class="note-role note-role-access">{{accessLevel}}</span>
+ 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">
@@ -98,20 +161,21 @@
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>
+ 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
@@ -125,9 +189,10 @@
class="note-action-button js-note-edit btn btn-transparent"
data-container="body"
data-placement="bottom">
- <span
- v-html="editSvg"
- class="link-highlight"></span>
+ <span
+ v-html="editSvg"
+ class="link-highlight">
+ </span>
</button>
</div>
<div
@@ -141,9 +206,10 @@
data-toggle="dropdown"
data-container="body"
data-placement="bottom">
- <span
- class="icon"
- v-html="ellipsisSvg"></span>
+ <span
+ class="icon"
+ v-html="ellipsisSvg">
+ </span>
</button>
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<li v-if="canReportAsAbuse">
diff --git a/app/assets/javascripts/notes/components/issue_note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue
index 7134a3eb47e..618b807b9cc 100644
--- a/app/assets/javascripts/notes/components/issue_note_attachment.vue
+++ b/app/assets/javascripts/notes/components/note_attachment.vue
@@ -1,6 +1,6 @@
<script>
export default {
- name: 'issueNoteAttachment',
+ name: 'NoteAttachment',
props: {
attachment: {
type: Object,
@@ -19,7 +19,8 @@
rel="noopener noreferrer">
<img
:src="attachment.url"
- class="note-image-attach" />
+ class="note-image-attach"
+ />
</a>
<div class="attachment">
<a
@@ -29,8 +30,9 @@
rel="noopener noreferrer">
<i
class="fa fa-paperclip"
- aria-hidden="true"></i>
- {{attachment.filename}}
+ aria-hidden="true">
+ </i>
+ {{ attachment.filename }}
</a>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/issue_note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index d42e61e3899..caa9701e03f 100644
--- a/app/assets/javascripts/notes/components/issue_note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -1,14 +1,16 @@
<script>
- /* global Flash */
-
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,
@@ -27,9 +29,6 @@
required: true,
},
},
- directives: {
- tooltip,
- },
computed: {
...mapGetters([
'getUserData',
@@ -74,6 +73,11 @@
return this.getUserData.id;
},
},
+ created() {
+ this.emojiSmiling = emojiSmiling;
+ this.emojiSmile = emojiSmile;
+ this.emojiSmiley = emojiSmiley;
+ },
methods: {
...mapActions([
'toggleAwardRequest',
@@ -169,11 +173,6 @@
.catch(() => Flash('Something went wrong on our end.'));
},
},
- created() {
- this.emojiSmiling = emojiSmiling;
- this.emojiSmile = emojiSmile;
- this.emojiSmiley = emojiSmiley;
- },
};
</script>
@@ -192,7 +191,7 @@
type="button">
<span v-html="getAwardHTML(awardName)"></span>
<span class="award-control-text js-counter">
- {{awardList.length}}
+ {{ awardList.length }}
</span>
</button>
<div
diff --git a/app/assets/javascripts/notes/components/issue_note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 5f9003bfd87..ca12df9db64 100644
--- a/app/assets/javascripts/notes/components/issue_note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -1,12 +1,21 @@
<script>
- import issueNoteEditedText from './issue_note_edited_text.vue';
- import issueNoteAwardsList from './issue_note_awards_list.vue';
- import issueNoteAttachment from './issue_note_attachment.vue';
- import issueNoteForm from './issue_note_form.vue';
+ 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,
@@ -22,20 +31,31 @@
default: false,
},
},
- mixins: [
- autosave,
- ],
- components: {
- issueNoteEditedText,
- issueNoteAwardsList,
- issueNoteAttachment,
- issueNoteForm,
- },
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();
@@ -56,26 +76,6 @@
this.$emit('cancelFormEdition', shouldConfirm, isDirty);
},
},
- mounted() {
- this.renderGFM();
- this.initTaskList();
-
- if (this.isEditing) {
- this.initAutoSave();
- }
- },
- updated() {
- this.initTaskList();
- this.renderGFM();
-
- if (this.isEditing) {
- if (!this.autosave) {
- this.initAutoSave();
- } else {
- this.setAutoSave();
- }
- }
- },
};
</script>
@@ -87,7 +87,7 @@
<div
v-html="note.note_html"
class="note-text md"></div>
- <issue-note-form
+ <note-form
v-if="isEditing"
ref="noteForm"
@handleFormUpdate="handleFormUpdate"
@@ -95,28 +95,28 @@
: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>
- <issue-note-edited-text
+ <note-edited-text
v-if="note.last_edited_at"
:edited-at="note.last_edited_at"
:edited-by="note.last_edited_by"
action-text="Edited"
- />
- <issue-note-awards-list
+ />
+ <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"
- />
- <issue-note-attachment
+ />
+ <note-attachment
v-if="note.attachment"
:attachment="note.attachment"
- />
+ />
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/issue_note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue
index 49e09f0ecc5..ae2e52554d2 100644
--- a/app/assets/javascripts/notes/components/issue_note_edited_text.vue
+++ b/app/assets/javascripts/notes/components/note_edited_text.vue
@@ -2,7 +2,10 @@
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default {
- name: 'editedNoteText',
+ name: 'EditedNoteText',
+ components: {
+ timeAgoTooltip,
+ },
props: {
actionText: {
type: String,
@@ -15,6 +18,7 @@
editedBy: {
type: Object,
required: false,
+ default: () => ({}),
},
className: {
type: String,
@@ -22,25 +26,22 @@
default: 'edited-text',
},
},
- components: {
- timeAgoTooltip,
- },
};
</script>
<template>
<div :class="className">
- {{actionText}}
+ {{ 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}}
+ {{ editedBy.name }}
</a>
</template>
</div>
diff --git a/app/assets/javascripts/notes/components/issue_note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 626c0f2ce18..1a13fdbeb7c 100644
--- a/app/assets/javascripts/notes/components/issue_note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,11 +1,21 @@
<script>
- import { mapGetters } from 'vuex';
+ import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub';
- import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
+ 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',
+ name: 'IssueNoteForm',
+ components: {
+ issueWarning,
+ markdownField,
+ },
+ mixins: [
+ issuableStateMixin,
+ resolvable,
+ ],
props: {
noteBody: {
type: String,
@@ -15,13 +25,14 @@
noteId: {
type: Number,
required: false,
+ default: 0,
},
saveButtonTitle: {
type: String,
required: false,
default: 'Save comment',
},
- discussion: {
+ note: {
type: Object,
required: false,
default: () => ({}),
@@ -33,19 +44,18 @@
},
data() {
return {
- note: this.noteBody,
+ updatedNoteBody: this.noteBody,
conflictWhileEditing: false,
isSubmitting: false,
+ isResolving: false,
+ resolveAsThread: true,
};
},
- components: {
- confidentialIssue,
- markdownField,
- },
computed: {
...mapGetters([
'getDiscussionLastNote',
- 'getIssueDataByProp',
+ 'getNoteableData',
+ 'getNoteableDataByProp',
'getNotesDataByProp',
'getUserDataByProp',
]),
@@ -53,7 +63,7 @@
return `#note_${this.noteId}`;
},
markdownPreviewPath() {
- return this.getIssueDataByProp('preview_note_path');
+ return this.getNoteableDataByProp('preview_note_path');
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
@@ -65,23 +75,40 @@
return this.getUserDataByProp('id');
},
isDisabled() {
- return !this.note.length || this.isSubmitting;
+ return !this.updatedNoteBody.length || this.isSubmitting;
},
- isConfidentialIssue() {
- return this.getIssueDataByProp('confidential');
+ },
+ watch: {
+ noteBody() {
+ if (this.updatedNoteBody === this.noteBody) {
+ this.updatedNoteBody = this.noteBody;
+ } else {
+ this.conflictWhileEditing = true;
+ }
},
},
+ mounted() {
+ this.$refs.textarea.focus();
+ },
methods: {
- handleUpdate() {
+ ...mapActions([
+ 'toggleResolveNote',
+ ]),
+ handleUpdate(shouldResolve) {
+ const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true;
- this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => {
+ this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
this.isSubmitting = false;
+
+ if (shouldResolve) {
+ this.resolveHandler(beforeSubmitDiscussionState);
+ }
});
},
editMyLastNote() {
- if (this.note === '') {
- const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
+ if (this.updatedNoteBody === '') {
+ const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody);
if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', {
@@ -92,26 +119,16 @@
},
cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed
- this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note);
- },
- },
- mounted() {
- this.$refs.textarea.focus();
- },
- watch: {
- noteBody() {
- if (this.note === this.noteBody) {
- this.note = this.noteBody;
- } else {
- this.conflictWhileEditing = true;
- }
+ this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.updatedNoteBody);
},
},
};
</script>
<template>
- <div ref="editNoteForm" class="note-edit-form current-note-edit-form">
+ <div
+ ref="editNoteForm"
+ class="note-edit-form current-note-edit-form">
<div
v-if="conflictWhileEditing"
class="js-conflict-edit-warning alert alert-danger">
@@ -119,13 +136,20 @@
<a
:href="noteHash"
target="_blank"
- rel="noopener noreferrer">updated comment</a>
- to ensure information is not lost.
+ 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">
- <confidentialIssue v-if="isConfidentialIssue" />
+ <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"
@@ -134,14 +158,16 @@
<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"
+ 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="note"
+ 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>
@@ -152,7 +178,14 @@
@click="handleUpdate()"
:disabled="isDisabled"
class="js-vue-issue-save btn btn-save">
- {{saveButtonTitle}}
+ {{ 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()"
diff --git a/app/assets/javascripts/notes/components/issue_note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 63aa3d777d0..4743d95b951 100644
--- a/app/assets/javascripts/notes/components/issue_note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -3,6 +3,9 @@
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default {
+ components: {
+ timeAgoTooltip,
+ },
props: {
author: {
type: Object,
@@ -31,18 +34,15 @@
required: false,
default: false,
},
- },
- data() {
- return {
- isExpanded: true,
- };
- },
- components: {
- timeAgoTooltip,
+ expanded: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
toggleChevronClass() {
- return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down';
+ return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
},
noteTimestampLink() {
return `#note_${this.noteId}`;
@@ -53,7 +53,6 @@
'setTargetNoteHash',
]),
handleToggle() {
- this.isExpanded = !this.isExpanded;
this.$emit('toggleHandler');
},
updateTargetNoteHash() {
@@ -66,17 +65,15 @@
<template>
<div class="note-header-info">
<a :href="author.path">
- <span class="note-header-author-name">
- {{author.name}}
- </span>
+ <span class="note-header-author-name">{{ author.name }}</span>
<span class="note-headline-light">
- @{{author.username}}
+ @{{ author.username }}
</span>
</a>
<span class="note-headline-light">
<span class="note-headline-meta">
<template v-if="actionText">
- {{actionText}}
+ {{ actionText }}
</template>
<span
v-if="actionTextHtml"
@@ -90,12 +87,13 @@
<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">
+ aria-hidden="true"
+ >
</i>
</span>
</span>
@@ -106,12 +104,12 @@
@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
+ <i
+ :class="toggleChevronClass"
+ class="fa"
+ aria-hidden="true">
+ </i>
+ Toggle discussion
</button>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue
index 77af3594c1c..45d3c2de355 100644
--- a/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue
+++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue
@@ -2,7 +2,6 @@
import { mapGetters } from 'vuex';
export default {
- name: 'singInLinksNotes',
computed: {
...mapGetters([
'getNotesDataByProp',
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..76bb53eaf2f
--- /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) {
+ // eslint-disable-next-line no-alert
+ if (!confirm('Are you sure you want to cancel creating this comment?')) {
+ 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/issue_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 3483f6c7538..4d17bd5acc2 100644
--- a/app/assets/javascripts/notes/components/issue_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -1,14 +1,26 @@
<script>
- /* global Flash */
-
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 issueNoteHeader from './issue_note_header.vue';
- import issueNoteActions from './issue_note_actions.vue';
- import issueNoteBody from './issue_note_body.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,
@@ -20,14 +32,9 @@
isEditing: false,
isDeleting: false,
isRequesting: false,
+ isResolving: false,
};
},
- components: {
- userAvatarLink,
- issueNoteHeader,
- issueNoteActions,
- issueNoteBody,
- },
computed: {
...mapGetters([
'targetNoteHash',
@@ -51,10 +58,21 @@
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() {
@@ -62,7 +80,7 @@
},
deleteHandler() {
// eslint-disable-next-line no-alert
- if (confirm('Are you sure you want to delete this list?')) {
+ if (confirm('Are you sure you want to delete this comment?')) {
this.isDeleting = true;
this.deleteNote(this.note)
@@ -79,19 +97,20 @@
const data = {
endpoint: this.note.path,
note: {
- target_type: 'issue',
+ 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 = noteText;
+ 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();
@@ -101,7 +120,7 @@
this.isEditing = true;
this.$nextTick(() => {
const msg = 'Something went wrong while editing your comment. Please try again.';
- Flash(msg, 'alert', $(this.$el));
+ Flash(msg, 'alert', this.$el);
this.recoverNoteContent(noteText);
callback();
});
@@ -123,17 +142,9 @@
// 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 = noteText; // TODO: This could be better
+ this.$refs.noteBody.$refs.noteForm.note.note = noteText;
},
},
- created() {
- eventHub.$on('enterEditMode', ({ noteId }) => {
- if (noteId === this.note.id) {
- this.isEditing = true;
- this.scrollToNoteIfNeeded($(this.$el));
- }
- });
- },
};
</script>
@@ -150,17 +161,17 @@
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
- />
+ />
</div>
<div class="timeline-content">
<div class="note-header">
- <issue-note-header
+ <note-header
:author="author"
:created-at="note.created_at"
:note-id="note.id"
action-text="commented"
- />
- <issue-note-actions
+ />
+ <note-actions
:author-id="author.id"
:note-id="note.id"
:access-level="note.human_access"
@@ -168,18 +179,23 @@
: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>
- <issue-note-body
+ <note-body
:note="note"
:can-edit="note.current_user.can_edit"
:is-editing="isEditing"
@handleFormUpdate="formUpdateHandler"
@cancelFormEdition="formCancelHandler"
ref="noteBody"
- />
+ />
</div>
</div>
</li>
diff --git a/app/assets/javascripts/notes/components/issue_notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index b6fc5e5036f..74afed5560b 100644
--- a/app/assets/javascripts/notes/components/issue_notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -1,20 +1,31 @@
<script>
- /* global Flash */
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 issueNote from './issue_note.vue';
- import issueDiscussion from './issue_discussion.vue';
- import issueSystemNote from './issue_system_note.vue';
- import issueCommentForm from './issue_comment_form.vue';
- import placeholderNote from './issue_placeholder_note.vue';
- import placeholderSystemNote from './issue_placeholder_system_note.vue';
+ 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: 'issueNotesApp',
+ name: 'NotesApp',
+ components: {
+ noteableNote,
+ noteableDiscussion,
+ systemNote,
+ commentForm,
+ loadingIcon,
+ placeholderNote,
+ placeholderSystemNote,
+ },
props: {
- issueData: {
+ noteableData: {
type: Object,
required: true,
},
@@ -25,7 +36,7 @@
userData: {
type: Object,
required: false,
- default: {},
+ default: () => ({}),
},
},
store,
@@ -34,20 +45,50 @@
isLoading: true,
};
},
- components: {
- issueNote,
- issueDiscussion,
- issueSystemNote,
- issueCommentForm,
- loadingIcon,
- placeholderNote,
- placeholderSystemNote,
- },
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({
@@ -56,22 +97,25 @@
actionToggleAward: 'toggleAward',
scrollToNoteIfNeeded: 'scrollToNoteIfNeeded',
setNotesData: 'setNotesData',
- setIssueData: 'setIssueData',
+ 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 ? issueSystemNote : issueNote;
+ return note.notes[0].system ? systemNote : noteableNote;
}
- return issueDiscussion;
+ return noteableDiscussion;
},
getComponentData(note) {
return note.individual_note ? note.notes[0] : note;
@@ -86,16 +130,21 @@
.then(() => this.checkLocationHash())
.catch(() => {
this.isLoading = false;
- Flash('Something went wrong while fetching issue comments. Please try again.');
+ 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 = gl.utils.getLocationHash();
+ const hash = getLocationHash();
const element = document.getElementById(hash);
if (hash && element) {
@@ -104,48 +153,25 @@
}
},
},
- created() {
- this.setNotesData(this.notesData);
- this.setIssueData(this.issueData);
- 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 });
- });
- }
- },
};
</script>
<template>
<div id="notes">
- <div
- v-if="isLoading"
- class="js-loading loading">
- <loading-icon />
- </div>
-
<ul
- v-if="!isLoading"
id="notes-list"
class="notes main-notes-list timeline">
<component
- v-for="note in notes"
+ v-for="note in allNotes"
:is="getComponentName(note)"
:note="getComponentData(note)"
:key="note.id"
- />
+ />
</ul>
- <issue-comment-form />
+ <comment-form
+ :noteable-type="noteableType"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index a6961063c01..f4f407ffd8a 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -1,4 +1,5 @@
export const DISCUSSION_NOTE = 'DiscussionNote';
+export const DIFF_NOTE = 'DiffNote';
export const DISCUSSION = 'discussion';
export const NOTE = 'note';
export const SYSTEM_NOTE = 'systemNote';
@@ -8,4 +9,7 @@ export const REOPENED = 'reopened';
export const CLOSED = 'closed';
export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown';
-export const NOTEABLE_TYPE = 'Issue';
+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/index.js b/app/assets/javascripts/notes/index.js
index e2ea37408cf..545bf2c99a7 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,32 +1,32 @@
import Vue from 'vue';
-import issueNotesApp from './components/issue_notes_app.vue';
+import notesApp from './components/notes_app.vue';
document.addEventListener('DOMContentLoaded', () => new Vue({
el: '#js-vue-notes',
components: {
- issueNotesApp,
+ notesApp,
},
data() {
const notesDataset = document.getElementById('js-vue-notes').dataset;
+ const parsedUserData = JSON.parse(notesDataset.currentUserData);
+ const currentUserData = parsedUserData ? {
+ id: parsedUserData.id,
+ name: parsedUserData.name,
+ username: parsedUserData.username,
+ avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url,
+ path: parsedUserData.path,
+ } : {};
return {
- issueData: JSON.parse(notesDataset.issueData),
- currentUserData: JSON.parse(notesDataset.currentUserData),
- notesData: {
- lastFetchedAt: notesDataset.lastFetchedAt,
- discussionsPath: notesDataset.discussionsPath,
- newSessionPath: notesDataset.newSessionPath,
- registerPath: notesDataset.registerPath,
- notesPath: notesDataset.notesPath,
- markdownDocsPath: notesDataset.markdownDocsPath,
- quickActionsDocsPath: notesDataset.quickActionsDocsPath,
- },
+ noteableData: JSON.parse(notesDataset.noteableData),
+ currentUserData,
+ notesData: JSON.parse(notesDataset.notesData),
};
},
render(createElement) {
- return createElement('issue-notes-app', {
+ return createElement('notes-app', {
props: {
- issueData: this.issueData,
+ 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
index 5843b97f225..a3d897f2f12 100644
--- a/app/assets/javascripts/notes/mixins/autosave.js
+++ b/app/assets/javascripts/notes/mixins/autosave.js
@@ -1,10 +1,10 @@
-/* globals Autosave */
-import '../../autosave';
+import Autosave from '../../autosave';
+import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default {
methods: {
- initAutoSave() {
- this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue');
+ initAutoSave(noteableType) {
+ this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', capitalizeFirstCharacter(noteableType), this.note.id]);
},
resetAutoSave() {
this.autosave.reset();
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..ab1ae115e52
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -0,0 +1,50 @@
+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/issue_notes_service.js b/app/assets/javascripts/notes/services/notes_service.js
index b51b0cb2013..4766351dfc5 100644
--- a/app/assets/javascripts/notes/services/issue_notes_service.js
+++ b/app/assets/javascripts/notes/services/notes_service.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueResource from 'vue-resource';
+import * as constants from '../constants';
Vue.use(VueResource);
@@ -19,6 +20,12 @@ export default {
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, lastFetchedAt } = data;
const options = {
@@ -32,4 +39,7 @@ export default {
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
index 1a791039909..42fc2a131b8 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -1,10 +1,10 @@
-/* global Flash */
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/issue_notes_service';
+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';
@@ -12,7 +12,7 @@ import { isInViewport, scrollToElement } from '../../lib/utils/common_utils';
let eTagPoll;
export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data);
-export const setIssueData = ({ commit }, data) => commit(types.SET_ISSUE_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);
@@ -61,6 +61,48 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service
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 }) => service
+ .toggleIssueState(state.notesData.closePath)
+ .then(res => res.json())
+ .then((data) => {
+ commit(types.CLOSE_ISSUE);
+ dispatch('emitStateChangedEvent', data);
+ });
+
+export const reopenIssue = ({ commit, dispatch, state }) => service
+ .toggleIssueState(state.notesData.reopenPath)
+ .then(res => res.json())
+ .then((data) => {
+ commit(types.REOPEN_ISSUE);
+ dispatch('emitStateChangedEvent', data);
+ });
+
+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;
@@ -99,7 +141,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
eTagPoll.makeRequest();
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
- Flash('Commands applied', 'notice', $(noteData.flashContainer));
+ Flash('Commands applied', 'notice', noteData.flashContainer);
}
if (commandsChanges) {
@@ -114,8 +156,8 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
.catch(() => {
Flash(
'Something went wrong while adding your award. Please try again.',
- null,
- $(noteData.flashContainer),
+ 'alert',
+ noteData.flashContainer,
);
});
}
@@ -126,7 +168,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
}
if (errors && errors.commands_only) {
- Flash(errors.commands_only, 'notice', $(noteData.flashContainer));
+ Flash(errors.commands_only, 'notice', noteData.flashContainer);
}
commit(types.REMOVE_PLACEHOLDER_NOTES);
@@ -141,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => {
resp.notes.forEach((note) => {
if (notesById[note.id]) {
commit(types.UPDATE_NOTE, note);
- } else if (note.type === constants.DISCUSSION_NOTE) {
+ } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) {
const discussion = utils.findNoteObjectById(state.notes, note.discussion_id);
if (discussion) {
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 1f0c6af6156..e6180101c58 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -6,8 +6,9 @@ export const targetNoteHash = state => state.targetNoteHash;
export const getNotesData = state => state.notesData;
export const getNotesDataByProp = state => prop => state.notesData[prop];
-export const getIssueData = state => state.issueData;
-export const getIssueDataByProp = state => prop => state.issueData[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];
@@ -29,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten(
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
index 8e0c8531bbc..488a9ca38d3 100644
--- a/app/assets/javascripts/notes/stores/index.js
+++ b/app/assets/javascripts/notes/stores/index.js
@@ -15,7 +15,7 @@ export default new Vuex.Store({
// holds endpoints and permissions provided through haml
notesData: {},
userData: {},
- issueData: {},
+ noteableData: {},
},
actions,
getters,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index cd71533ba9d..da1b5a9e51a 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -3,7 +3,7 @@ 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_ISSUE_DATA = 'SET_ISSUE_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';
@@ -12,3 +12,8 @@ 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';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index c2a08f3d6fe..963b40be3fd 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -1,22 +1,32 @@
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: !(type === constants.DISCUSSION_NOTE),
+ 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'));
}
},
@@ -25,6 +35,7 @@ export default {
if (noteObj) {
noteObj.notes.push(note);
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}
},
@@ -41,6 +52,8 @@ export default {
state.notes.splice(state.notes.indexOf(noteObj), 1);
}
}
+
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.REMOVE_PLACEHOLDER_NOTES](state) {
@@ -66,8 +79,8 @@ export default {
Object.assign(state, { notesData: data });
},
- [types.SET_ISSUE_DATA](state, data) {
- Object.assign(state, { issueData: data });
+ [types.SET_NOTEABLE_DATA](state, data) {
+ Object.assign(state, { noteableData: data });
},
[types.SET_USER_DATA](state, data) {
@@ -77,15 +90,19 @@ export default {
const notes = [];
notesData.forEach((note) => {
+ const nn = Object.assign({}, note);
+
// To support legacy notes, should be very rare case.
if (note.individual_note && note.notes.length > 1) {
note.notes.forEach((n) => {
- const nn = Object.assign({}, note);
nn.notes = [n]; // override notes array to only have one item to mimick individual_note
notes.push(nn);
});
} else {
- notes.push(note);
+ const oldNote = utils.findNoteObjectById(state.notes, note.id);
+ nn.expanded = oldNote ? oldNote.expanded : note.expanded;
+
+ notes.push(nn);
}
});
@@ -134,6 +151,8 @@ export default {
user: { id, name, username },
});
}
+
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.TOGGLE_DISCUSSION](state, { discussionId }) {
@@ -151,5 +170,31 @@ export default {
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 });
},
};
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
index 6074115e855..275263a2aaa 100644
--- a/app/assets/javascripts/notes/stores/utils.js
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -28,4 +28,3 @@ export const getQuickActionText = (note) => {
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..479a512ed65 100644
--- a/app/assets/javascripts/notifications_dropdown.js
+++ b/app/assets/javascripts/notifications_dropdown.js
@@ -1,31 +1,25 @@
-/* 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 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..4e0afe13590 100644
--- a/app/assets/javascripts/notifications_form.js
+++ b/app/assets/javascripts/notifications_form.js
@@ -1,55 +1,50 @@
-/* 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 './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 e3fc1e2fc2f..7e85bce0d73 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -1,78 +1,73 @@
import { getParameterByName } from '~/lib/utils/common_utils';
-import '~/lib/utils/url_utility';
+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(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/abuse_reports.js b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
index 346de4ad11e..66702ec4ca0 100644
--- a/app/assets/javascripts/abuse_reports.js
+++ b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
@@ -1,7 +1,9 @@
+import { truncate } from '../../../lib/utils/text_utility';
+
const MAX_MESSAGE_LENGTH = 500;
const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
-class AbuseReports {
+export default class AbuseReports {
constructor() {
$(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage);
$(document)
@@ -13,25 +15,22 @@ class AbuseReports {
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));
+ $messageCellElement.data('originalMessage', reportMessage);
+ $messageCellElement.data('messageTruncated', 'true');
+ $messageCellElement.text(truncate(reportMessage, MAX_MESSAGE_LENGTH));
}
}
toggleMessageTruncation() {
const $messageCellElement = $(this);
- const originalMessage = $messageCellElement.data('original-message');
+ const originalMessage = $messageCellElement.data('originalMessage');
if (!originalMessage) return;
- if ($messageCellElement.data('message-truncated') === 'true') {
- $messageCellElement.data('message-truncated', 'false');
+ if ($messageCellElement.data('messageTruncated') === 'true') {
+ $messageCellElement.data('messageTruncated', 'false');
$messageCellElement.text(originalMessage);
} else {
- $messageCellElement.data('message-truncated', 'true');
+ $messageCellElement.data('messageTruncated', 'true');
$messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`);
}
}
}
-
-window.gl = window.gl || {};
-window.gl.AbuseReports = AbuseReports;
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..45e05f111a7
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/admin.js
@@ -0,0 +1,59 @@
+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..f92450cbaa7
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
@@ -0,0 +1,35 @@
+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..14315d5492e
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
@@ -0,0 +1,125 @@
+<script>
+ import _ from 'underscore';
+ import modal from '~/vue_shared/components/modal.vue';
+ import { s__, sprintf } from '~/locale';
+
+ export default {
+ components: {
+ modal,
+ },
+ 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>
+ <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>
+ </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..3c597a1093e
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/projects/index/index.js
@@ -0,0 +1,37 @@
+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..7b5e333011e
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -0,0 +1,174 @@
+<script>
+ import _ from 'underscore';
+ import modal from '~/vue_shared/components/modal.vue';
+ import { s__, sprintf } from '~/locale';
+
+ export default {
+ components: {
+ modal,
+ },
+ 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>
+ <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>
+ </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..4f5d6b55031
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/index.js
@@ -0,0 +1,43 @@
+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/ci_lint_editor.js b/app/assets/javascripts/pages/ci/lints/ci_lint_editor.js
index dd4a08a2f31..9ab73be80a0 100644
--- a/app/assets/javascripts/ci_lint_editor.js
+++ b/app/assets/javascripts/pages/ci/lints/ci_lint_editor.js
@@ -1,17 +1,19 @@
-
-window.gl = window.gl || {};
-
-class CILintEditor {
+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));
}
-}
-gl.CILintEditor = CILintEditor;
+ clear() {
+ this.editor.setValue('');
+ }
+}
diff --git a/app/assets/javascripts/pages/ci/lints/create/index.js b/app/assets/javascripts/pages/ci/lints/create/index.js
new file mode 100644
index 00000000000..8e8a843da0b
--- /dev/null
+++ b/app/assets/javascripts/pages/ci/lints/create/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/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index 2fffe09c74e..42f7460ad55 100644
--- a/app/assets/javascripts/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -1,7 +1,10 @@
/* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */
-
-import UsersSelect from './users_select';
-import { isMetaClick } from './lib/utils/common_utils';
+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() {
@@ -59,18 +62,12 @@ export default class Todos {
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) => {
+
+ axios[target.dataset.method](target.dataset.href)
+ .then(({ data }) => {
this.updateRowState(target);
- return this.updateBadges(data);
- },
- });
+ this.updateBadges(data);
+ }).catch(() => flash(__('Error updating todo status.')));
}
updateRowState(target) {
@@ -98,19 +95,15 @@ export default class Todos {
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);
- },
- });
+
+ 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) {
@@ -150,7 +143,7 @@ export default class Todos {
window.open(todoLink, windowTarget);
} else {
- gl.utils.visitUrl(todoLink);
+ 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..d44874c8741
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -0,0 +1,7 @@
+import groupAvatar from '~/group_avatar';
+import TransferDropdown from '~/groups/transfer_dropdown';
+
+document.addEventListener('DOMContentLoaded', () => {
+ groupAvatar();
+ new TransferDropdown(); // eslint-disable-line no-new
+});
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..05c81fc618b
--- /dev/null
+++ b/app/assets/javascripts/pages/help/index/index.js
@@ -0,0 +1,7 @@
+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..c43e0a0490f
--- /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 modal from '~/vue_shared/components/modal.vue';
+ import { n__, s__, sprintf } from '~/locale';
+ import { redirectTo } from '~/lib/utils/url_utility';
+ import eventHub from '../event_hub';
+
+ export default {
+ components: {
+ modal,
+ },
+ 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>
+ <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>
+
+ </modal>
+</template>
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..327e2cf569c
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/index.js
@@ -0,0 +1,88 @@
+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');
+ for (let i = 0; i < deleteMilestoneButtons.length; i += 1) {
+ const button = deleteMilestoneButtons[i];
+ button.addEventListener('click', onDeleteButtonClick);
+ }
+
+ eventHub.$once('deleteMilestoneModal.mounted', () => {
+ for (let i = 0; i < deleteMilestoneButtons.length; i += 1) {
+ const button = deleteMilestoneButtons[i];
+ 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/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/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..c52ad7bc335
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/index.js
@@ -0,0 +1,16 @@
+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/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/two_factor_auth.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
index d26f61562a5..5b2473e0989 100644
--- a/app/assets/javascripts/two_factor_auth.js
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -1,4 +1,5 @@
-/* global U2FRegister */
+import U2FRegister from '~/u2f/register';
+
document.addEventListener('DOMContentLoaded', () => {
const twoFactorNode = document.querySelector('.js-two-factor-auth');
const skippable = twoFactorNode.dataset.twoFactorSkippable === 'true';
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..26cbb279d4a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -0,0 +1,7 @@
+import BlobViewer from '~/blob/viewer/index';
+import initBlob from '~/pages/projects/init_blob';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new BlobViewer(); // eslint-disable-line no-new
+ initBlob();
+});
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..d32d5c6cb29
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/branches/new/index.js
@@ -0,0 +1,5 @@
+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..cd923f13ce8
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
@@ -0,0 +1,10 @@
+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..1aeed197385
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -0,0 +1,24 @@
+/* eslint-disable no-new */
+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..064de22dfd6
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -0,0 +1,14 @@
+import initSettingsPanels from '~/settings_panels';
+import setupProjectEdit from '~/project_edit';
+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();
+});
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..23d857d69ec
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/find_file/show/index.js
@@ -0,0 +1,12 @@
+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/graphs/graphs_charts.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
index ec6eab34989..42df19c2968 100644
--- a/app/assets/javascripts/graphs/graphs_charts.js
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -1,4 +1,4 @@
-import Chart from 'vendor/Chart';
+import Chart from 'chart.js';
import _ from 'underscore';
document.addEventListener('DOMContentLoaded', () => {
diff --git a/app/assets/javascripts/graphs/graphs_show.js b/app/assets/javascripts/pages/projects/graphs/show/index.js
index 36bad6db3e1..f516ff20995 100644
--- a/app/assets/javascripts/graphs/graphs_show.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/index.js
@@ -1,11 +1,13 @@
+import flash from '~/flash';
+import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
import ContributorsStatGraph from './stat_graph_contributors';
document.addEventListener('DOMContentLoaded', () => {
- $.ajax({
- type: 'GET',
- url: document.querySelector('.js-graphs-show').dataset.projectGraphPath,
- dataType: 'json',
- success(data) {
+ const url = document.querySelector('.js-graphs-show').dataset.projectGraphPath;
+
+ axios.get(url)
+ .then(({ data }) => {
const graph = new ContributorsStatGraph();
graph.init(data);
@@ -16,6 +18,6 @@ document.addEventListener('DOMContentLoaded', () => {
$('.stat-graph').fadeIn();
$('.loading-graph').hide();
- },
- });
+ })
+ .catch(() => flash(__('Error fetching contributors data.')));
});
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
index cdc4fcf6573..9ac0b4c07e5 100644
--- a/app/assets/javascripts/graphs/stat_graph_contributors.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
@@ -1,12 +1,14 @@
/* 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 'underscore';
-import d3 from 'd3';
+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() {}
+ function ContributorsStatGraph() {
+ this.dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
+ }
ContributorsStatGraph.prototype.init = function(log) {
var author_commits, total_commits;
@@ -44,7 +46,7 @@ export default (function() {
commits = $('<span/>', {
"class": 'graph-author-commits-count'
});
- commits.text(author.commits + " commits");
+ commits.text(n__('%d commit', '%d commits', author.commits));
return $('<span/>').append(commits);
};
@@ -82,9 +84,12 @@ export default (function() {
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();
+ 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));
};
@@ -94,18 +99,26 @@ export default (function() {
};
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);
+ 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_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);
+ 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/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
index f64b4638485..6ffaa277a0a 100644
--- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
@@ -1,6 +1,15 @@
/* 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 'underscore';
-import d3 from 'd3';
+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;
@@ -70,8 +79,8 @@ export const ContributorsGraph = (function() {
};
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();
+ this.x = d3.scaleTime().range([0, width]).clamp(true);
+ return this.y = d3.scaleLinear().range([height, 0]).nice();
};
ContributorsGraph.prototype.draw_x_axis = function() {
@@ -93,9 +102,12 @@ 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() - 70;
+ this.width = $('.content').width() - parentPadding - (this.MARGIN.left + this.MARGIN.right);
this.height = 200;
this.x = null;
this.y = null;
@@ -120,7 +132,7 @@ export const ContributorsMasterGraph = (function(superClass) {
ContributorsMasterGraph.prototype.parse_dates = function(data) {
var parseDate;
- parseDate = d3.time.format("%Y-%m-%d").parse;
+ parseDate = d3.timeParse("%Y-%m-%d");
return data.forEach(function(d) {
return d.date = parseDate(d.date);
});
@@ -131,8 +143,10 @@ export const ContributorsMasterGraph = (function(superClass) {
};
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);
+ 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() {
@@ -140,16 +154,16 @@ export const ContributorsMasterGraph = (function(superClass) {
};
ContributorsMasterGraph.prototype.create_area = function(x, y) {
- return this.area = d3.svg.area().x(function(d) {
+ 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);
- }).interpolate("basis");
+ });
};
ContributorsMasterGraph.prototype.create_brush = function() {
- return this.brush = d3.svg.brush().x(this.x).on("brushend", this.update_content);
+ 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) {
@@ -161,7 +175,12 @@ export const ContributorsMasterGraph = (function(superClass) {
};
ContributorsMasterGraph.prototype.update_content = function() {
- ContributorsGraph.set_x_domain(this.brush.empty() ? this.x_max_domain : this.brush.extent());
+ // 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');
};
@@ -219,14 +238,17 @@ export const ContributorsAuthorGraph = (function(superClass) {
};
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);
+ 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.svg.area().x(function(d) {
+ return this.area = d3.area().x(function(d) {
var parseDate;
- parseDate = d3.time.format("%Y-%m-%d").parse;
+ parseDate = d3.timeParse("%Y-%m-%d");
return x(parseDate(d));
}).y0(this.height).y1((function(_this) {
return function(d) {
@@ -236,11 +258,12 @@ export const ContributorsAuthorGraph = (function(superClass) {
return y(0);
}
};
- })(this)).interpolate("basis");
+ })(this));
};
ContributorsAuthorGraph.prototype.create_svg = function() {
- this.list_item = d3.selectAll(".person")[0].pop();
+ 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 + ")");
};
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
index 77135ad1f0e..77135ad1f0e 100644
--- a/app/assets/javascripts/graphs/stat_graph_contributors_util.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
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..5c7daf84738
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -0,0 +1,16 @@
+/* eslint-disable no-new */
+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/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/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js
new file mode 100644
index 00000000000..6e45de2a724
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/labels/index/index.js
@@ -0,0 +1,3 @@
+import initLabels from '~/init_labels';
+
+document.addEventListener('DOMContentLoaded', initLabels);
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..8bfac606aab
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -0,0 +1,19 @@
+/* eslint-disable no-new */
+
+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/network/network.js b/app/assets/javascripts/pages/projects/network/network.js
index a3fd22aff2a..7354243e4c8 100644
--- a/app/assets/javascripts/network/network.js
+++ b/app/assets/javascripts/pages/projects/network/network.js
@@ -1,6 +1,6 @@
/* 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';
+import BranchGraph from '../../../network/branch_graph';
export default (function() {
function Network(opts) {
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..e7dfd2d0128
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/network/show/index.js
@@ -0,0 +1,16 @@
+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/pipeline_schedules/pipeline_schedules_index_bundle.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
index a6c945e22b0..544360dcd51 100644
--- a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import PipelineSchedulesCallout from './components/pipeline_schedules_callout.vue';
+import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue';
document.addEventListener('DOMContentLoaded', () => new Vue({
el: '#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/pipeline_schedules/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index b5d85299cf8..2d18fa2044b 100644
--- a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -32,6 +32,20 @@
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;
@@ -43,20 +57,6 @@
}
},
},
- created() {
- if (this.intervalIsPreset) {
- this.enableCustomInput = false;
- }
- },
- watch: {
- cronInterval() {
- // updates field validation state when model changes, as
- // glFieldError only updates on input.
- this.$nextTick(() => {
- gl.pipelineScheduleFieldErrors.updateFormValidityState();
- });
- },
- },
};
</script>
@@ -78,7 +78,12 @@
</label>
<span class="cron-syntax-link-wrap">
- (<a :href="cronSyntaxUrl" target="_blank">{{ __('Cron syntax') }}</a>)
+ (<a
+ :href="cronSyntaxUrl"
+ target="_blank"
+ >
+ {{ __('Cron syntax') }}
+ </a>)
</span>
</div>
@@ -93,7 +98,10 @@
@click="toggleCustomInput(false)"
/>
- <label class="label-light" for="every-day">
+ <label
+ class="label-light"
+ for="every-day"
+ >
{{ __('Every day (at 4:00am)') }}
</label>
</div>
@@ -109,7 +117,10 @@
@click="toggleCustomInput(false)"
/>
- <label class="label-light" for="every-week">
+ <label
+ class="label-light"
+ for="every-week"
+ >
{{ __('Every week (Sundays at 4:00am)') }}
</label>
</div>
@@ -125,7 +136,10 @@
@click="toggleCustomInput(false)"
/>
- <label class="label-light" for="every-month">
+ <label
+ class="label-light"
+ for="every-month"
+ >
{{ __('Every month (on the 1st at 4:00am)') }}
</label>
</div>
diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
index 6e0bc2d697a..77508e62cef 100644
--- a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -1,7 +1,7 @@
<script>
import Vue from 'vue';
import Cookies from 'js-cookie';
- import Translate from '../../vue_shared/translate';
+ import Translate from '../../../../../vue_shared/translate';
import illustrationSvg from '../icons/intro_illustration.svg';
Vue.use(Translate);
@@ -16,15 +16,15 @@
calloutDismissed: Cookies.get(cookieKey) === 'true',
};
},
+ created() {
+ this.illustrationSvg = illustrationSvg;
+ },
methods: {
dismissCallout() {
this.calloutDismissed = true;
Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 });
},
},
- created() {
- this.illustrationSvg = illustrationSvg;
- },
};
</script>
<template>
@@ -41,17 +41,25 @@
class="fa fa-times">
</i>
</button>
- <div class="svg-container" v-html="illustrationSvg"></div>
+ <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.') }}
+ {{ __(`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 -->
+ rel="nofollow"
+ >
+ {{ s__('Learn more in the|pipeline schedules documentation') }}</a>.
+ <!-- oneline to prevent extra space before period -->
</p>
</div>
</div>
diff --git a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js
index 0c3926d76b5..0c3926d76b5 100644
--- a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js
diff --git a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
index 95ed9c7dc21..95ed9c7dc21 100644
--- a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
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/pipeline_schedules/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
index 50c725aa3d5..cfd30d6053f 100644
--- a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -1,9 +1,10 @@
import Vue from 'vue';
-import Translate from '../vue_shared/translate';
+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 { setupPipelineVariableList } from './setup_pipeline_variable_list';
+import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list';
Vue.use(Translate);
@@ -26,7 +27,7 @@ function initIntervalPatternInput() {
});
}
-document.addEventListener('DOMContentLoaded', () => {
+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 */
@@ -39,7 +40,10 @@ document.addEventListener('DOMContentLoaded', () => {
gl.timezoneDropdown = new TimezoneDropdown();
gl.targetBranchDropdown = new TargetBranchDropdown();
- gl.pipelineScheduleFieldErrors = new gl.GlFieldErrors(formElement);
+ gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement);
- setupPipelineVariableList($('.js-pipeline-variable-list'));
-});
+ 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..bb92f4e1459
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/charts/index.js
@@ -0,0 +1,56 @@
+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..da20bd995e9
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/new/index.js
@@ -0,0 +1,5 @@
+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..d23ad9a92f4
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -0,0 +1,134 @@
+/* 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 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..0bf53a8de09
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/releases/edit/index.js
@@ -0,0 +1,3 @@
+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/projects/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index ce47562f259..ce47562f259 100644
--- a/app/assets/javascripts/projects/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
diff --git a/app/assets/javascripts/projects/permissions/external.js b/app/assets/javascripts/pages/projects/shared/permissions/external.js
index 460af4a2111..460af4a2111 100644
--- a/app/assets/javascripts/projects/permissions/external.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/external.js
diff --git a/app/assets/javascripts/projects/permissions/index.js b/app/assets/javascripts/pages/projects/shared/permissions/index.js
index dbde8dda634..dbde8dda634 100644
--- a/app/assets/javascripts/projects/permissions/index.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/index.js
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..56627aa155c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/project_avatar.js
@@ -0,0 +1,13 @@
+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..86faba0b910
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/project_new.js
@@ -0,0 +1,151 @@
+/* eslint-disable func-names, no-var, no-underscore-dangle, prefer-template, prefer-arrow-callback*/
+
+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..9b87f249f09
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -0,0 +1,27 @@
+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',
+ });
+
+ if ($('#tree-slider').length) new TreeView(); // eslint-disable-line no-new
+ if ($('.blob-viewer').length) new BlobViewer(); // eslint-disable-line no-new
+ if ($('.project-show-activity').length) new Activities(); // eslint-disable-line no-new
+ $('#tree-slider').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..c15f798b630
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/snippets/edit/index.js
@@ -0,0 +1,7 @@
+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..c15f798b630
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/snippets/new/index.js
@@ -0,0 +1,7 @@
+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..191c98b36bb
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/new/index.js
@@ -0,0 +1,9 @@
+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..ed7d3f1747c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tree/show/index.js
@@ -0,0 +1,38 @@
+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..b9f8707fd6e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/index.js
@@ -0,0 +1,11 @@
+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/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js
index a0025ddb598..34a12ef76a1 100644
--- a/app/assets/javascripts/wikis.js
+++ b/app/assets/javascripts/pages/projects/wikis/wikis.js
@@ -1,4 +1,5 @@
-import bp from './breakpoints';
+import bp from '../../../breakpoints';
+import { slugify } from '../../../lib/utils/text_utility';
export default class Wikis {
constructor() {
@@ -23,7 +24,7 @@ export default class Wikis {
if (!this.newWikiForm) return;
const slugInput = this.newWikiForm.querySelector('#new_wiki_path');
- const slug = gl.text.slugify(slugInput.value);
+ const slug = slugify(slugInput.value);
if (slug.length > 0) {
const wikisPath = slugInput.getAttribute('data-wikis-path');
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..cf44e291199
--- /dev/null
+++ b/app/assets/javascripts/pages/search/show/search.js
@@ -0,0 +1,115 @@
+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..a0aa0499776
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -0,0 +1,11 @@
+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/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
index ffc2dd6bbca..ffc2dd6bbca 100644
--- a/app/assets/javascripts/oauth_remember_me.js
+++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
diff --git a/app/assets/javascripts/signin_tabs_memoizer.js b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
index 20255398047..08f0afdcce3 100644
--- a/app/assets/javascripts/signin_tabs_memoizer.js
+++ b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
@@ -1,6 +1,4 @@
-/* eslint no-param-reassign: ["error", { "props": false }]*/
-/* eslint no-new: "off" */
-import AccessorUtilities from './lib/utils/accessor';
+import AccessorUtilities from '~/lib/utils/accessor';
/**
* Memorize the last selected tab after reloading a page.
@@ -11,6 +9,10 @@ export default class SigninTabsMemoizer {
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();
}
diff --git a/app/assets/javascripts/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js
index bb34d5d2008..745543c22da 100644
--- a/app/assets/javascripts/username_validator.js
+++ b/app/assets/javascripts/pages/sessions/new/username_validator.js
@@ -1,6 +1,9 @@
/* eslint-disable comma-dangle, consistent-return, class-methods-use-this, arrow-parens, no-param-reassign, max-len */
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';
@@ -77,12 +80,9 @@ export default class UsernameValidator {
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)
- });
+ axios.get(`${gon.relative_url_root}/users/${username}/exists`)
+ .then(({ data }) => this.setAvailabilityState(data.exists))
+ .catch(() => flash(__('An error occurred while validating username')));
}
}
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..f996d3cd74e
--- /dev/null
+++ b/app/assets/javascripts/pages/snippets/form.js
@@ -0,0 +1,7 @@
+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/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 5e947769f8a..57306322aa4 100644
--- a/app/assets/javascripts/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -1,5 +1,12 @@
import _ from 'underscore';
-import d3 from 'd3';
+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">
@@ -17,7 +24,7 @@ function getSystemDate(systemUtcOffsetSeconds) {
function formatTooltipText({ date, count }) {
const dateObject = new Date(date);
- const dateDayName = gl.utils.getDayName(dateObject);
+ const dateDayName = getDayName(dateObject);
const dateText = dateObject.format('mmm d, yyyy');
let contribText = 'No contributions';
@@ -27,7 +34,7 @@ function formatTooltipText({ date, count }) {
return `${contribText}<br />${dateDayName} ${dateText}`;
}
-const initColorKey = () => d3.scale.linear().range(['#acd5f2', '#254e77']).domain([0, 3]);
+const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]);
export default class ActivityCalendar {
constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0) {
@@ -51,7 +58,7 @@ export default class ActivityCalendar {
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(today.getFullYear() - 1);
- const days = gl.utils.getDayDifference(oneYearAgo, today);
+ const days = getDayDifference(oneYearAgo, today);
for (let i = 0; i <= days; i += 1) {
const date = new Date(oneYearAgo);
@@ -94,7 +101,7 @@ export default class ActivityCalendar {
const secondLastColMonth = this.timestampsTmp[group - 2][0].date.getMonth();
if (lastColMonth !== secondLastColMonth) {
- extraWidthPadding = 3;
+ extraWidthPadding = 6;
}
return extraWidthPadding;
@@ -204,7 +211,7 @@ export default class ActivityCalendar {
initColor() {
const 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);
+ return d3.scaleThreshold().domain([0, 10, 20, 30]).range(colorRange);
}
clickDay(stamp) {
@@ -217,14 +224,16 @@ export default class ActivityCalendar {
this.currentSelectedDate.getDate(),
].join('-');
- $.ajax({
- url: this.calendarActivitiesPath,
- data: { date },
- cache: false,
- dataType: 'html',
- beforeSend: () => $('.user-calendar-activities').html(LOADING_HTML),
- success: data => $('.user-calendar-activities').html(data),
- });
+ $('.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/users/index.js b/app/assets/javascripts/pages/users/index.js
index 33a83f8dae5..899dcd42e37 100644
--- a/app/assets/javascripts/users/index.js
+++ b/app/assets/javascripts/pages/users/index.js
@@ -1,7 +1,8 @@
+import UserCallout from '~/user_callout';
import Cookies from 'js-cookie';
import UserTabs from './user_tabs';
-export default function initUserProfile(action) {
+function initUserProfile(action) {
// place profile avatars to top
$('.profile-groups-avatars').tooltip({
placement: 'top',
@@ -17,3 +18,10 @@ export default function initUserProfile(action) {
$(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/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 1215b265e28..c1217623467 100644
--- a/app/assets/javascripts/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -1,3 +1,8 @@
+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';
/**
@@ -129,18 +134,20 @@ export default class UserTabs {
}
loadTab(action, endpoint) {
- return $.ajax({
- beforeSend: () => this.toggleLoading(true),
- complete: () => this.toggleLoading(false),
- dataType: 'json',
- url: endpoint,
- success: (data) => {
+ this.toggleLoading(true);
+
+ return axios.get(endpoint)
+ .then(({ data }) => {
const tabSelector = `div#${action}`;
this.$parentEl.find(tabSelector).html(data.html);
this.loaded[action] = true;
- gl.utils.localTimeAgo($('.js-timeago', tabSelector));
- },
- });
+ localTimeAgo($('.js-timeago', tabSelector));
+
+ this.toggleLoading(false);
+ })
+ .catch(() => {
+ this.toggleLoading(false);
+ });
}
loadActivities() {
@@ -156,20 +163,18 @@ export default class UserTabs {
utcFormatted = `UTC${utcOffset > 0 ? '+' : ''}${(utcOffset / 3600)}`;
}
- $.ajax({
- dataType: 'json',
- url: calendarPath,
- success: (activityData) => {
+ 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', activityData, calendarActivitiesPath, utcOffset);
- },
- });
+ 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 gl.Activities();
+ new Activities();
this.loaded.activity = true;
}
diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue
index b874e484d45..00f32d9de78 100644
--- a/app/assets/javascripts/pdf/index.vue
+++ b/app/assets/javascripts/pdf/index.vue
@@ -1,13 +1,3 @@
-<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 'vendor/pdf';
import workerSrc from 'vendor/pdf.worker.min';
@@ -15,6 +5,7 @@
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/performance_bar.js b/app/assets/javascripts/performance_bar.js
index 9bbdf7f513c..0562a681c4b 100644
--- a/app/assets/javascripts/performance_bar.js
+++ b/app/assets/javascripts/performance_bar.js
@@ -1,5 +1,6 @@
import 'vendor/peek';
import 'vendor/peek.performance_bar';
+import { getParameterValues } from './lib/utils/url_utility';
export default class PerformanceBar {
constructor(opts) {
@@ -39,7 +40,7 @@ export default class PerformanceBar {
}
handleLineProfileLink(e) {
- const lineProfilerParameter = gl.utils.getParameterValues('lineprofiler');
+ const lineProfilerParameter = getParameterValues('lineprofiler');
const lineProfilerParameterRegex = new RegExp(`lineprofiler=${lineProfilerParameter[0]}`);
const shouldToggleModal = lineProfilerParameter.length > 0 &&
lineProfilerParameterRegex.test(e.currentTarget.href);
diff --git a/app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js b/app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js
deleted file mode 100644
index 9e0e5cacb11..00000000000
--- a/app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import { convertPermissionToBoolean } from '../lib/utils/common_utils';
-
-function insertRow($row) {
- const $rowClone = $row.clone();
- $rowClone.removeAttr('data-is-persisted');
- $rowClone.find('input, textarea').val('');
- $row.after($rowClone);
-}
-
-function removeRow($row) {
- const isPersisted = convertPermissionToBoolean($row.attr('data-is-persisted'));
-
- if (isPersisted) {
- $row.hide();
- $row
- .find('.js-destroy-input')
- .val(1);
- } else {
- $row.remove();
- }
-}
-
-function checkIfRowTouched($row) {
- return $row.find('.js-user-input').toArray().some(el => $(el).val().length > 0);
-}
-
-function setupPipelineVariableList(parent = document) {
- const $parent = $(parent);
-
- $parent.on('click', '.js-row-remove-button', (e) => {
- const $row = $(e.currentTarget).closest('.js-row');
- removeRow($row);
-
- e.preventDefault();
- });
-
- // Remove any empty rows except the last r
- $parent.on('blur', '.js-user-input', (e) => {
- const $row = $(e.currentTarget).closest('.js-row');
-
- const isTouched = checkIfRowTouched($row);
- if ($row.is(':not(:last-child)') && !isTouched) {
- removeRow($row);
- }
- });
-
- // Always make sure there is an empty last row
- $parent.on('input', '.js-user-input', () => {
- const $lastRow = $parent.find('.js-row').last();
-
- const isTouched = checkIfRowTouched($lastRow);
- if (isTouched) {
- insertRow($lastRow);
- }
- });
-
- // Clear out the empty last row so it
- // doesn't get submitted and throw validation errors
- $parent.closest('form').on('submit', () => {
- const $lastRow = $parent.find('.js-row').last();
-
- const isTouched = checkIfRowTouched($lastRow);
- if (!isTouched) {
- $lastRow.find('input, textarea').attr('name', '');
- }
- });
-}
-
-export {
- setupPipelineVariableList,
- insertRow,
- removeRow,
-};
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 0eaac8dd64f..10ac8c08bed 100644
--- a/app/assets/javascripts/pipelines/components/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/empty_state.vue
@@ -1,36 +1,62 @@
<script>
-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,
+ },
},
- emptyStateSvgPath: {
- type: String,
- required: true,
- },
- },
-};
+ };
</script>
-
<template>
<div class="row empty-state js-empty-state">
<div class="col-xs-12">
- <div class="svg-content">
- <img :src="emptyStateSvgPath"/>
+ <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 012853b201d..00000000000
--- a/app/assets/javascripts/pipelines/components/error_state.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-export default {
- props: {
- errorStateSvgPath: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div class="row empty-state js-pipelines-error-state">
- <div class="col-xs-12">
- <div class="svg-content">
- <img :src="errorStateSvgPath"/>
- </div>
- </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 3e5d6d15909..b86e95f0b4a 100644
--- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
@@ -18,7 +18,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 +27,6 @@
* }
*/
export default {
- props: {
- job: {
- type: Object,
- required: true,
- },
- },
-
directives: {
tooltip,
},
@@ -43,12 +36,23 @@
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
@@ -59,16 +63,13 @@
* target the click event of this component.
*/
stopDropdownClickPropagation() {
- $(this.$el.querySelectorAll('.js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item'))
+ $(this.$el
+ .querySelectorAll('.js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item'))
.on('click', (e) => {
e.stopPropagation();
});
},
},
-
- mounted() {
- this.stopDropdownClickPropagation();
- },
};
</script>
<template>
@@ -83,22 +84,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 66bc1d1979c..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 '~/flash';
import stageColumnComponent from './stage_column_component.vue';
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 3933509a6f4..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(' ');
},
/**
@@ -78,45 +95,49 @@
<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 f46d21bd6d7..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 class="ci-job-name-component">
- <ci-icon
- :status="status" />
+ <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..383ab51fe56 100644
--- a/app/assets/javascripts/pipelines/components/nav_controls.vue
+++ b/app/assets/javascripts/pipelines/components/nav_controls.vue
@@ -1,54 +1,52 @@
<script>
-export default {
- name: 'PipelineNavControls',
- props: {
- newPipelinePath: {
- type: String,
- required: true,
- },
-
- hasCiEnabled: {
- type: Boolean,
- required: true,
- },
+ export default {
+ name: 'PipelineNavControls',
+ props: {
+ newPipelinePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
- helpPagePath: {
- type: String,
- required: true,
- },
-
- ciLintPath: {
- type: String,
- required: true,
- },
+ resetCachePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
- canCreatePipeline: {
- type: Boolean,
- required: true,
+ ciLintPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
- },
-};
+ };
</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
+ v-if="resetCachePath"
+ data-method="post"
+ :href="resetCachePath"
+ class="btn btn-default js-clear-cache"
+ >
+ {{ s__('Pipelines|Clear Runner Caches') }}
</a>
<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 73f7e3a0cad..00000000000
--- a/app/assets/javascripts/pipelines/components/navigation_tabs.vue
+++ /dev/null
@@ -1,90 +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');
- },
- methods: {
- shouldRenderBadge(count) {
- // 0 is valid in a badge, but evaluates to false, we need to check for undefined
- return count !== undefined;
- },
- },
-};
-</script>
-<template>
- <ul class="nav-links scrolling-tabs">
- <li
- class="js-pipelines-tab-all"
- :class="{ active: scope === 'all'}">
- <a :href="paths.allPath">
- All
- <span
- v-if="shouldRenderBadge(count.all)"
- 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
- v-if="shouldRenderBadge(count.pending)"
- class="badge">
- {{count.pending}}
- </span>
- </a>
- </li>
- <li
- class="js-pipelines-tab-running"
- :class="{ active: scope === 'running'}">
- <a :href="paths.runningPath">
- Running
- <span
- v-if="shouldRenderBadge(count.running)"
- class="badge">
- {{count.running}}
- </span>
- </a>
- </li>
- <li
- class="js-pipelines-tab-finished"
- :class="{ active: scope === 'finished'}">
- <a :href="paths.finishedPath">
- Finished
- <span
- v-if="shouldRenderBadge(count.finished)"
- 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 f0b44dfa6d8..ceb4d9ca604 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -4,6 +4,13 @@
import popover from '../../vue_shared/directives/popover';
export default {
+ components: {
+ userAvatarLink,
+ },
+ directives: {
+ tooltip,
+ popover,
+ },
props: {
pipeline: {
type: Object,
@@ -14,13 +21,6 @@
required: true,
},
},
- components: {
- userAvatarLink,
- },
- directives: {
- tooltip,
- popover,
- },
computed: {
user() {
return this.pipeline.user;
@@ -28,11 +28,18 @@
popoverOptions() {
return {
html: true,
- delay: { hide: 600 },
- trigger: 'hover',
+ 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>`,
+ 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>`,
};
},
},
@@ -43,7 +50,7 @@
<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
@@ -73,8 +80,16 @@
:title="pipeline.yaml_errors">
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">
diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue
index 085bd20cefe..6e5ee68eeb1 100644
--- a/app/assets/javascripts/pipelines/components/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines.vue
@@ -1,142 +1,269 @@
<script>
+ import _ from 'underscore';
+ import { __, sprintf, s__ } from '../../locale';
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 { convertPermissionToBoolean, getParameterByName, setParamInURL } from '../../lib/utils/common_utils';
+ 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,
- helpPagePath: pipelinesData.helpPagePath,
- emptyStateSvgPath: pipelinesData.emptyStateSvgPath,
- errorStateSvgPath: pipelinesData.errorStateSvgPath,
- autoDevopsPath: pipelinesData.helpAutoDevopsPath,
- 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: {},
};
},
- computed: {
- canCreatePipelineParsed() {
- return convertPermissionToBoolean(this.canCreatePipeline);
- },
- scope() {
- const scope = 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 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 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 = setParamInURL('page', pageNumber);
+ updateContent(parameters) {
+ this.updateInternalState(parameters);
- gl.utils.visitUrl(param);
- return param;
- },
+ // fetch new data
+ return this.service.getPipelines(this.requestData)
+ .then((response) => {
+ this.isLoading = false;
+ this.successCallback(response);
- successCallback(resp) {
- return resp.json().then((response) => {
- this.store.storeCount(response.count);
- this.store.storePagination(resp.headers);
- this.setCommonData(response.pipelines);
- });
+ // restart polling
+ this.poll.restart({ data: this.requestData });
+ })
+ .catch(() => {
+ this.isLoading = false;
+ this.errorCallback();
+
+ // restart polling
+ this.poll.restart({ data: this.requestData });
+ });
},
},
};
@@ -145,75 +272,85 @@
<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"
+ />
</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"
- :error-state-svg-path="errorStateSvgPath"
- />
+ <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 7aa0c0e8a7f..c9028952ddd 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 modal from '~/vue_shared/components/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,
+ modal,
+ },
props: {
pipelines: {
type: Array,
@@ -21,9 +28,56 @@
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>
@@ -31,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>
@@ -59,6 +118,22 @@
:pipeline="model"
:update-graph-dropdown="updateGraphDropdown"
:auto-devops-help-path="autoDevopsHelpPath"
+ :view-type="viewType"
/>
+ <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>
+ </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 5b9bb6c3750..4cbd67e0372 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -1,233 +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,
+ },
},
- autoDevopsHelpPath: {
- 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"
:auto-devops-help-path="autoDevopsHelpPath"
- />
+ />
- <div class="table-section section-25">
+ <div class="table-section section-20">
<div
class="table-mobile-header"
role="rowheader">
@@ -240,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"
@@ -274,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"
@@ -288,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 a4a27247406..ecf2b10486e 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -1,132 +1,133 @@
<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,
+ /**
+ * 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,
},
- },
-
- 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();
- }
- },
- },
- 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 => 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;
- });
+ 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>
@@ -142,35 +143,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..9fcc07abee5 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 {
@@ -86,6 +81,7 @@ export default {
this.hasError = true;
this.isLoading = false;
this.updateGraphDropdown = false;
+ this.hasMadeRequest = true;
},
setIsMakingRequest(isMakingRequest) {
this.isMakingRequest = isMakingRequest;
@@ -97,7 +93,7 @@ export default {
postAction(endpoint) {
this.service.postAction(endpoint)
.then(() => eventHub.$emit('refreshPipelines'))
- .catch(() => new Flash('An error occured while making the request.'));
+ .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_mediator.js
index 385e7430a7d..10f238fe73b 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
@@ -1,7 +1,7 @@
-/* global Flash */
-
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';
@@ -48,7 +48,7 @@ export default class pipelinesMediator {
errorCallback() {
this.state.isLoading = false;
- return new Flash('An error occurred while fetching the pipeline.');
+ Flash(__('An error occurred while fetching the pipeline.'));
}
refreshPipeline() {
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/pipelines_charts.js b/app/assets/javascripts/pipelines/pipelines_charts.js
deleted file mode 100644
index 001faf4be33..00000000000
--- a/app/assets/javascripts/pipelines/pipelines_charts.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Chart from 'vendor/Chart';
-
-document.addEventListener('DOMContentLoaded', () => {
- const chartData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML);
- const buildChart = (chartScope) => {
- const data = {
- labels: chartScope.labels,
- datasets: [{
- fillColor: '#7f8fa4',
- strokeColor: '#7f8fa4',
- pointColor: '#7f8fa4',
- pointStrokeColor: '#EEE',
- data: chartScope.totalValues,
- },
- {
- fillColor: '#44aa22',
- strokeColor: '#44aa22',
- pointColor: '#44aa22',
- pointStrokeColor: '#fff',
- data: chartScope.successValues,
- },
- ],
- };
- const ctx = $(`#${chartScope.scope}Chart`).get(0).getContext('2d');
- const 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);
- };
-
- chartData.forEach(scope => buildChart(scope));
-});
diff --git a/app/assets/javascripts/pipelines/pipelines_times.js b/app/assets/javascripts/pipelines/pipelines_times.js
deleted file mode 100644
index b5e7a0e53d9..00000000000
--- a/app/assets/javascripts/pipelines/pipelines_times.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import Chart from 'vendor/Chart';
-
-document.addEventListener('DOMContentLoaded', () => {
- const chartData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML);
- const data = {
- labels: chartData.labels,
- datasets: [{
- fillColor: 'rgba(220,220,220,0.5)',
- strokeColor: 'rgba(220,220,220,1)',
- barStrokeWidth: 1,
- barValueSpacing: 1,
- barDatasetSpacing: 1,
- data: chartData.values,
- }],
- };
- const ctx = $('#build_timesChart').get(0).getContext('2d');
- const 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);
-});
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/preview_markdown.js b/app/assets/javascripts/preview_markdown.js
index 141333b2b4d..464bfb351e7 100644
--- a/app/assets/javascripts/preview_markdown.js
+++ b/app/assets/javascripts/preview_markdown.js
@@ -6,195 +6,193 @@
// (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);
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
+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;
+ 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();
}
-
- 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);
- }
-
- // 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..1ffe482d782
--- /dev/null
+++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
@@ -0,0 +1,135 @@
+<script>
+ import modal from '~/vue_shared/components/modal.vue';
+ import { __, s__, sprintf } from '~/locale';
+ import csrf from '~/lib/utils/csrf';
+
+ export default {
+ components: {
+ modal,
+ },
+ 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>
+ <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>
+
+ </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 291ae24aa68..4bdda611cfc 100644
--- a/app/assets/javascripts/profile/gl_crop.js
+++ b/app/assets/javascripts/profile/gl_crop.js
@@ -73,7 +73,8 @@ import _ from 'underscore';
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 3deb242bc1f..a811781853b 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,101 +1,85 @@
/* 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 */
-import { getPagePath } from '../lib/utils/common_utils';
+import Cookies from 'js-cookie';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import flash from '../flash';
-((global) => {
- class Profile {
- constructor({ form } = {}) {
- this.onSubmitForm = this.onSubmitForm.bind(this);
- this.form = form || $('.edit-user');
- this.bindEvents();
- this.initAvatarGlCrop();
- }
-
- 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.newRepoActivated = Cookies.get('new_repo');
+ 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);
+ $('input[name="user[multi_file]"]').on('change', this.setNewRepoCookie);
+ $('#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');
- }
+ 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));
+ }
- 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();
- }
- });
+ setNewRepoCookie() {
+ if (this.value === 'off') {
+ Cookies.remove('new_repo');
+ } else {
+ Cookies.set('new_repo', true, { expires_in: 365 });
}
}
- $(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 (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 fe6602259e2..00000000000
--- a/app/assets/javascripts/project.js
+++ /dev/null
@@ -1,139 +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() {
- 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');
-
- e.preventDefault();
-
- $('.is-active', $cloneOptions).not($this).removeClass('is-active');
- $this.toggleClass('is-active');
- $projectCloneField.val(url);
- $cloneBtnText.text($this.text());
-
- return $('.clone').text(url);
- });
- // 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));
- };
-
- 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,
- inputFieldName: $dropdown.data('input-field-name'),
- 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 $visit = $dropdown.data('visit');
- var shouldVisit = $visit ? true : $visit;
- var action = $form.attr('action');
- var divider = action.indexOf('?') === -1 ? '?' : '&';
- if (shouldVisit) {
- 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_find_file.js b/app/assets/javascripts/project_find_file.js
index 11f9754780d..4fd639cce8e 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -1,169 +1,162 @@
/* 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 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..65d46fa9a73 100644
--- a/app/assets/javascripts/project_fork.js
+++ b/app/assets/javascripts/project_fork.js
@@ -1,13 +1,7 @@
-/* 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();
- });
- }
+export default () => {
+ $('.js-fork-thumbnail').on('click', function forkThumbnailClicked() {
+ if ($(this).hasClass('disabled')) return false;
- return ProjectFork;
- })();
-}).call(window);
+ 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..64b7dd540f9 100644
--- a/app/assets/javascripts/project_label_subscription.js
+++ b/app/assets/javascripts/project_label_subscription.js
@@ -1,55 +1,51 @@
-/* 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 './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 fd89a1a85c3..00000000000
--- a/app/assets/javascripts/project_new.js
+++ /dev/null
@@ -1,159 +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 */
-
-import VisibilitySelect from './visibility_select';
-
-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 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 fb01390f91c..412aca7bfed 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -2,75 +2,73 @@
import Api from './api';
import ProjectSelectComboButton from './project_select_combo_button';
-(function() {
- this.ProjectSelect = (function() {
- function ProjectSelect() {
- $('.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";
+ }
- $(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 JSON.stringify({
- name: project.name,
- url: 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 new ProjectSelectComboButton(select);
- });
- }
-
- 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_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/projects/permissions/components/project_feature_setting.vue b/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue
deleted file mode 100644
index 80c5d39f736..00000000000
--- a/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue
+++ /dev/null
@@ -1,104 +0,0 @@
-<script>
-import projectFeatureToggle from './project_feature_toggle.vue';
-
-export default {
- 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,
- },
- },
-
- components: {
- projectFeatureToggle,
- },
-
- 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;
- },
- },
-
- model: {
- prop: 'value',
- event: 'change',
- },
-
- 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"
- :disabledInput="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/projects/permissions/components/project_feature_toggle.vue b/app/assets/javascripts/projects/permissions/components/project_feature_toggle.vue
deleted file mode 100644
index 2403c60186a..00000000000
--- a/app/assets/javascripts/projects/permissions/components/project_feature_toggle.vue
+++ /dev/null
@@ -1,51 +0,0 @@
-<script>
-export default {
- props: {
- name: {
- type: String,
- required: false,
- default: '',
- },
- value: {
- type: Boolean,
- required: true,
- },
- disabledInput: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- model: {
- prop: 'value',
- event: 'change',
- },
-
- 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"
- aria-label="Toggle"
- class="project-feature-toggle"
- data-enabled-text="Enabled"
- data-disabled-text="Disabled"
- :class="{ checked: value, disabled: disabledInput }"
- @click="toggleFeature"
- />
- </label>
-</template>
diff --git a/app/assets/javascripts/projects/permissions/components/project_setting_row.vue b/app/assets/javascripts/projects/permissions/components/project_setting_row.vue
deleted file mode 100644
index 6140d74fea8..00000000000
--- a/app/assets/javascripts/projects/permissions/components/project_setting_row.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-<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 />
- </div>
-</template>
diff --git a/app/assets/javascripts/projects/permissions/components/settings_panel.vue b/app/assets/javascripts/projects/permissions/components/settings_panel.vue
deleted file mode 100644
index 326d9105666..00000000000
--- a/app/assets/javascripts/projects/permissions/components/settings_panel.vue
+++ /dev/null
@@ -1,312 +0,0 @@
-<script>
-import projectFeatureSetting from './project_feature_setting.vue';
-import projectFeatureToggle from './project_feature_toggle.vue';
-import projectSettingRow from './project_setting_row.vue';
-import { visibilityOptions, visibilityLevelDescriptions } from '../constants';
-import { toggleHiddenClassBySelector } from '../external';
-
-export default {
- 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,
- },
- lfsHelpPath: {
- type: String,
- required: false,
- },
- registryHelpPath: {
- type: String,
- required: false,
- },
- },
-
- 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 };
- },
-
- components: {
- projectFeatureSetting,
- projectFeatureToggle,
- projectSettingRow,
- },
-
- 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];
- },
- },
-
- methods: {
- highlightChanges() {
- this.highlightChangesClass = true;
- this.$nextTick(() => {
- this.highlightChangesClass = false;
- });
- },
-
- visibilityAllowed(option) {
- return this.allowedVisibilityOptions.includes(option);
- },
- },
-
- 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);
- },
- },
-};
-
-</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.PUBLIC" 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"
- :disabledInput="!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"
- :disabledInput="!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"
- :disabledInput="!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"
- :disabledInput="!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/projects/project_import_gitlab_project.js b/app/assets/javascripts/projects/project_import_gitlab_project.js
index c34927499fc..d2c7d77bb2d 100644
--- a/app/assets/javascripts/projects/project_import_gitlab_project.js
+++ b/app/assets/javascripts/projects/project_import_gitlab_project.js
@@ -1,14 +1,8 @@
-import '../lib/utils/url_utility';
+import { getParameterValues } from '../lib/utils/url_utility';
-const bindEvents = () => {
- const path = gl.utils.getParameterValues('path')[0];
+export default () => {
+ const path = getParameterValues('path')[0];
// get the path url and append it in the inputS
$('.js-path-name').val(path);
};
-
-document.addEventListener('DOMContentLoaded', bindEvents);
-
-export default {
- bindEvents,
-};
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 7f972b6f6ee..8da37d14f0b 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,9 @@
+import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
+
let hasUserDefinedProjectPath = false;
-const deriveProjectPathFromUrl = ($projectImportUrl, $projectPath) => {
+const deriveProjectPathFromUrl = ($projectImportUrl) => {
+ const $currentProjectPath = $projectImportUrl.parents('.toggle-import-form').find('#project_path');
if (hasUserDefinedProjectPath) {
return;
}
@@ -21,7 +24,7 @@ const deriveProjectPathFromUrl = ($projectImportUrl, $projectPath) => {
// extract everything after the last slash
const pathMatch = /\/([^/]+)$/.exec(importUrl);
if (pathMatch) {
- $projectPath.val(pathMatch[1]);
+ $currentProjectPath.val(pathMatch[1]);
}
};
@@ -29,6 +32,13 @@ 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;
@@ -48,6 +58,68 @@ const bindEvents = () => {
$('.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());
});
@@ -56,11 +128,9 @@ const bindEvents = () => {
hasUserDefinedProjectPath = $projectPath.val().trim().length > 0;
});
- $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl, $projectPath));
+ $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl));
};
-document.addEventListener('DOMContentLoaded', bindEvents);
-
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
index 7606605be32..34a60dd574b 100644
--- a/app/assets/javascripts/projects_dropdown/components/app.vue
+++ b/app/assets/javascripts/projects_dropdown/components/app.vue
@@ -47,6 +47,22 @@ export default {
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;
@@ -108,22 +124,6 @@ export default {
this.toggleSearchProjectsList(true);
},
},
- 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);
- },
};
</script>
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
index 093554cd0bc..246dbeaaded 100644
--- a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
@@ -1,32 +1,32 @@
<script>
-import { s__ } from '../../locale';
-import projectsListItem from './projects_list_item.vue';
+ import { s__ } from '../../locale';
+ import projectsListItem from './projects_list_item.vue';
-export default {
- components: {
- projectsListItem,
- },
- props: {
- projects: {
- type: Array,
- required: true,
+ export default {
+ components: {
+ projectsListItem,
},
- localStorageFailed: {
- type: Boolean,
- required: true,
+ props: {
+ projects: {
+ type: Array,
+ required: true,
+ },
+ localStorageFailed: {
+ type: Boolean,
+ required: true,
+ },
},
- },
- computed: {
- isListEmpty() {
- return this.projects.length === 0;
+ 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');
+ },
},
- listEmptyMessage() {
- return this.localStorageFailed ?
- s__('ProjectsDropdown|This feature requires browser localStorage support') :
- s__('ProjectsDropdown|Projects you visit often will appear here');
- },
- },
-};
+ };
</script>
<template>
@@ -40,7 +40,7 @@ export default {
class="section-empty"
v-if="isListEmpty"
>
- {{listEmptyMessage}}
+ {{ listEmptyMessage }}
</li>
<projects-list-item
v-else
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
index fe5179de206..759cdd1ded9 100644
--- a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
@@ -1,55 +1,77 @@
<script>
-import identicon from '../../vue_shared/components/identicon.vue';
+ /* 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,
+ export default {
+ components: {
+ identicon,
},
- 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';
+ 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);
+ 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 (matches && matches.length > 0) {
- return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`);
+ if (namespaceArr.length > 2) {
+ namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
}
- }
- return this.projectName;
+
+ return namespace;
+ },
},
- },
-};
+ };
</script>
<template>
@@ -71,7 +93,7 @@ export default {
<identicon
v-else
size-class="s32"
- :entity-id=projectId
+ :entity-id="projectId"
:entity-name="projectName"
/>
</div>
@@ -87,9 +109,7 @@ export default {
<div
class="project-namespace"
:title="namespace"
- >
- {{namespace}}
- </div>
+ >{{ truncatedNamespace }}</div>
</div>
</a>
</li>
diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue
index 53bc76d0f2d..0c46ed184be 100644
--- a/app/assets/javascripts/projects_dropdown/components/search.vue
+++ b/app/assets/javascripts/projects_dropdown/components/search.vue
@@ -1,47 +1,47 @@
<script>
-import _ from 'underscore';
-import eventHub from '../event_hub';
+ import _ from 'underscore';
+ import eventHub from '../event_hub';
-export default {
- data() {
- return {
- searchQuery: '',
- };
- },
- watch: {
- searchQuery() {
- this.handleInput();
+ export default {
+ data() {
+ return {
+ searchQuery: '',
+ };
},
- },
- methods: {
- setFocus() {
- this.$refs.search.focus();
+ watch: {
+ searchQuery() {
+ this.handleInput();
+ },
},
- emitSearchEvents() {
- if (this.searchQuery) {
- eventHub.$emit('searchProjects', this.searchQuery);
- } else {
- eventHub.$emit('searchCleared');
- }
+ mounted() {
+ eventHub.$on('dropdownOpen', this.setFocus);
},
- /**
- * 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),
- },
- mounted() {
- eventHub.$on('dropdownOpen', this.setFocus);
- },
- beforeDestroy() {
- eventHub.$off('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>
@@ -59,6 +59,7 @@ export default {
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/index.js b/app/assets/javascripts/projects_dropdown/index.js
index 2660da3c558..e78ebce2923 100644
--- a/app/assets/javascripts/projects_dropdown/index.js
+++ b/app/assets/javascripts/projects_dropdown/index.js
@@ -19,11 +19,8 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
- $(navEl).on('show.bs.dropdown', (e) => {
- const dropdownEl = $(e.currentTarget).find('.projects-dropdown-menu');
- dropdownEl.one('transitionend', () => {
- eventHub.$emit('dropdownOpen');
- });
+ $(navEl).on('shown.bs.dropdown', () => {
+ eventHub.$emit('dropdownOpen');
});
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js
index fad956b4c26..7231f520933 100644
--- a/app/assets/javascripts/projects_dropdown/service/projects_service.js
+++ b/app/assets/javascripts/projects_dropdown/service/projects_service.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import Vue from 'vue';
import VueResource from 'vue-resource';
@@ -19,7 +20,7 @@ export default class ProjectsService {
getSearchedProjects(searchQuery) {
return this.projectsPath.get({
- simple: false,
+ simple: true,
per_page: 20,
membership: !!gon.current_user_id,
order_by: 'last_activity_at',
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 a4d50a52315..e8126ac573d 100644
--- a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
+++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
@@ -1,3 +1,4 @@
+import axios from '../lib/utils/axios_utils';
import PANEL_STATE from './constants';
import { backOff } from '../lib/utils/common_utils';
@@ -18,7 +19,7 @@ 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.$panelToggle.on('click', e => this.handlePanelToggle(e));
}
@@ -81,20 +82,20 @@ export default class PrometheusMetrics {
loadActiveMetrics() {
this.showMonitoringMetricsPanelState(PANEL_STATE.LOADING);
backOff((next, stop) => {
- $.getJSON(this.activeMetricsEndpoint)
- .done((res) => {
- if (res && res.success) {
- stop(res);
+ 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) {
diff --git a/app/assets/javascripts/protected_branches/index.js b/app/assets/javascripts/protected_branches/index.js
deleted file mode 100644
index c9e7af127d2..00000000000
--- a/app/assets/javascripts/protected_branches/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-/* eslint-disable no-unused-vars */
-
-import ProtectedBranchCreate from './protected_branch_create';
-import ProtectedBranchEditList from './protected_branch_edit_list';
-
-$(() => {
- const protectedBranchCreate = new ProtectedBranchCreate();
- const protectedBranchEditList = new ProtectedBranchEditList();
-});
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 38b1406a99f..40a873833e1 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
@@ -9,8 +9,8 @@ export default class ProtectedBranchAccessDropdown {
$dropdown.glDropdown({
data,
selectable: true,
- inputId: $dropdown.data('input-id'),
- fieldName: $dropdown.data('field-name'),
+ inputId: $dropdown.data('inputId'),
+ fieldName: $dropdown.data('fieldName'),
toggleLabel(item, $el) {
if ($el.is('.is-active')) {
return item.text;
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 10da3783123..8fc87633e18 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -1,15 +1,22 @@
+import _ from 'underscore';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
-import ProtectedBranchDropdown from './protected_branch_dropdown';
+import CreateItemDropdown from '../create_item_dropdown';
+import AccessorUtilities from '../lib/utils/accessor';
+
+const PB_LOCAL_STORAGE_KEY = 'protected-branches-defaults';
export default class ProtectedBranchCreate {
constructor() {
this.$form = $('.js-new-protected-branch');
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+ this.currentProjectUserDefaults = {};
this.buildDropdowns();
}
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);
@@ -28,15 +35,15 @@ export default class ProtectedBranchCreate {
onSelect: this.onSelectCallback,
});
- // Select default
- $allowedToPushDropdown.data('glDropdown').selectRowAtIndex(0);
- $allowedToMergeDropdown.data('glDropdown').selectRowAtIndex(0);
-
- // Protected branch dropdown
- this.protectedBranchDropdown = new ProtectedBranchDropdown({
- $dropdown: this.$form.find('.js-protected-branch-select'),
+ this.createItemDropdown = new CreateItemDropdown({
+ $dropdown: $protectedBranchDropdown,
+ defaultToggleLabel: 'Protected Branch',
+ fieldName: 'protected_branch[name]',
onSelect: this.onSelectCallback,
+ getData: ProtectedBranchCreate.getProtectedBranches,
});
+
+ this.loadPreviousSelection($allowedToMergeDropdown.data('glDropdown'), $allowedToPushDropdown.data('glDropdown'));
}
// This will run after clicked callback
@@ -45,7 +52,45 @@ export default class ProtectedBranchCreate {
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));
+ }
}
}
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 678882a8d2c..00000000000
--- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import _ from 'underscore';
-
-export default class ProtectedBranchDropdown {
- /**
- * @param {Object} options containing
- * `$dropdown` target element
- * `onSelect` event callback
- * $dropdown must be an element created using `dropdown_branch()` 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.$protectedBranch = this.$dropdownContainer.find('.js-create-new-protected-branch');
-
- this.buildDropdown();
- this.bindEvents();
-
- // Hide footer
- this.toggleFooter(true);
- }
-
- 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) => {
- 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) {
- if (branchName) {
- this.selectedBranch = {
- title: branchName,
- id: branchName,
- text: branchName,
- };
-
- this.$dropdownContainer
- .find('.js-create-new-protected-branch code')
- .text(branchName);
- }
-
- this.toggleFooter(!branchName);
- }
-
- toggleFooter(toggleState) {
- this.$dropdownFooter.toggleClass('hidden', toggleState);
- }
-}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 3b920942a3f..54560d08ad7 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -1,6 +1,5 @@
-/* eslint-disable no-new */
-/* global Flash */
-
+import flash from '../flash';
+import axios from '../lib/utils/axios_utils';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
export default class ProtectedBranchEdit {
@@ -39,29 +38,25 @@ export default class ProtectedBranchEdit {
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() {
- new Flash('Failed to update branch!', null, $('.js-protected-branches-list'));
+ 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(),
+ }],
},
- }).always(() => {
+ }).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_tags/index.js b/app/assets/javascripts/protected_tags/index.js
deleted file mode 100644
index b1618e24e49..00000000000
--- a/app/assets/javascripts/protected_tags/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-/* eslint-disable no-unused-vars */
-
-import ProtectedTagCreate from './protected_tag_create';
-import ProtectedTagEditList from './protected_tag_edit_list';
-
-$(() => {
- const protectedtTagCreate = new ProtectedTagCreate();
- const protectedtTagEditList = new ProtectedTagEditList();
-});
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..2f94ffe2507 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_create.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_create.js
@@ -1,5 +1,5 @@
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 +24,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 +39,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 a0224213aa0..00000000000
--- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import _ from 'underscore';
-
-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/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js
index 65e4101352c..56c25a35e6d 100644
--- a/app/assets/javascripts/ref_select_dropdown.js
+++ b/app/assets/javascripts/ref_select_dropdown.js
@@ -6,7 +6,7 @@ class RefSelectDropdown {
filterable: true,
filterByText: true,
remote: false,
- fieldName: $dropdownButton.data('field-name'),
+ fieldName: $dropdownButton.data('fieldName'),
filterInput: 'input[type="search"]',
selectable: true,
isSelectable(branch, $el) {
@@ -24,7 +24,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..b4906ba4ee5
--- /dev/null
+++ b/app/assets/javascripts/registry/components/collapsible_container.vue
@@ -0,0 +1,134 @@
+<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"
+ />
+
+ <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..bef850eddc0
--- /dev/null
+++ b/app/assets/javascripts/registry/components/table_registry.vue
@@ -0,0 +1,144 @@
+<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)"
+ />
+ </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
index bcdc0fd67b8..05a623ca6d9 100644
--- a/app/assets/javascripts/render_gfm.js
+++ b/app/assets/javascripts/render_gfm.js
@@ -1,15 +1,16 @@
-/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-else-return, prefer-arrow-callback, max-len */
+import renderMath from './render_math';
+import renderMermaid from './render_mermaid';
+import syntaxHighlight from './syntax_highlight';
// Render Gitlab flavoured Markdown
//
-// Delegates to syntax highlight and render math
+// Delegates to syntax highlight and render math & mermaid diagrams.
//
-(function() {
- $.fn.renderGFM = function() {
- this.find('.js-syntax-highlight').syntaxHighlight();
- this.find('.js-render-math').renderMath();
- return this;
- };
+$.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());
-}).call(window);
+$(() => $('body').renderGFM());
diff --git a/app/assets/javascripts/render_math.js b/app/assets/javascripts/render_math.js
index 8b3fee49cb9..eabdb01b2a9 100644
--- a/app/assets/javascripts/render_math.js
+++ b/app/assets/javascripts/render_math.js
@@ -1,5 +1,5 @@
-/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-else-return, prefer-arrow-callback, max-len, no-console */
-/* global katex */
+import { __ } from './locale';
+import flash from './flash';
// Renders math using KaTeX in any element with the
// `js-render-math` class
@@ -8,49 +8,30 @@
//
// <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);
+// Loop over all math elements and render math
+function renderWithKaTeX(elements, katex) {
+ elements.each(function katexElementsLoop() {
+ const mathNode = $('<span></span>');
+ const $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
- });
- });
+ 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;
}
- };
-}).call(window);
+ });
+}
+
+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/render_mermaid.js b/app/assets/javascripts/render_mermaid.js
new file mode 100644
index 00000000000..d4f18955bd2
--- /dev/null
+++ b/app/assets/javascripts/render_mermaid.js
@@ -0,0 +1,57 @@
+// 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>
+//
+
+import Flash from './flash';
+
+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/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue
deleted file mode 100644
index d6c864cb976..00000000000
--- a/app/assets/javascripts/repo/components/repo.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-<script>
-import RepoSidebar from './repo_sidebar.vue';
-import RepoCommitSection from './repo_commit_section.vue';
-import RepoTabs from './repo_tabs.vue';
-import RepoFileButtons from './repo_file_buttons.vue';
-import RepoPreview from './repo_preview.vue';
-import RepoMixin from '../mixins/repo_mixin';
-import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
-import Store from '../stores/repo_store';
-import Helper from '../helpers/repo_helper';
-import MonacoLoaderHelper from '../helpers/monaco_loader_helper';
-
-export default {
- data: () => Store,
- mixins: [RepoMixin],
- components: {
- RepoSidebar,
- RepoTabs,
- RepoFileButtons,
- 'repo-editor': MonacoLoaderHelper.repoEditorLoader,
- RepoCommitSection,
- PopupDialog,
- RepoPreview,
- },
-
- mounted() {
- Helper.getContent().catch(Helper.loadingError);
- },
-
- methods: {
- toggleDialogOpen(toggle) {
- this.dialog.open = toggle;
- },
-
- dialogSubmitted(status) {
- this.toggleDialogOpen(false);
- this.dialog.status = status;
- },
-
- toggleBlobView: Store.toggleBlobView,
- },
-};
-</script>
-
-<template>
- <div class="repository-view">
- <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isMini}">
- <repo-sidebar/>
- <div v-if="isMini"
- class="panel-right"
- :class="{'edit-mode': editMode}">
- <repo-tabs/>
- <component
- :is="currentBlobView"
- class="blob-viewer-container"/>
- <repo-file-buttons/>
- </div>
- </div>
- <repo-commit-section/>
- <popup-dialog
- v-show="dialog.open"
- :primary-button-label="__('Discard changes')"
- kind="warning"
- :title="__('Are you sure?')"
- :body="__('Are you sure you want to discard your changes?')"
- @toggle="toggleDialogOpen"
- @submit="dialogSubmitted"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue
deleted file mode 100644
index 1282828b504..00000000000
--- a/app/assets/javascripts/repo/components/repo_commit_section.vue
+++ /dev/null
@@ -1,132 +0,0 @@
-<script>
-/* global Flash */
-import Store from '../stores/repo_store';
-import RepoMixin from '../mixins/repo_mixin';
-import Service from '../services/repo_service';
-
-export default {
- data: () => Store,
-
- mixins: [RepoMixin],
-
- computed: {
- showCommitable() {
- return this.isCommitable && this.changedFiles.length;
- },
-
- branchPaths() {
- return this.changedFiles.map(f => f.path);
- },
-
- cantCommitYet() {
- return !this.commitMessage || this.submitCommitsLoading;
- },
-
- filePluralize() {
- return this.changedFiles.length > 1 ? 'files' : 'file';
- },
- },
-
- methods: {
- makeCommit() {
- // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
- const commitMessage = this.commitMessage;
- const actions = this.changedFiles.map(f => ({
- action: 'update',
- file_path: f.path,
- content: f.newContent,
- }));
- const payload = {
- branch: Store.targetBranch,
- commit_message: commitMessage,
- actions,
- };
- Store.submitCommitsLoading = true;
- Service.commitFiles(payload)
- .then(this.resetCommitState)
- .catch(() => Flash('An error occured while committing your changes'));
- },
-
- resetCommitState() {
- this.submitCommitsLoading = false;
- this.changedFiles = [];
- this.commitMessage = '';
- this.editMode = false;
- window.scrollTo(0, 0);
- },
- },
-};
-</script>
-
-<template>
-<div
- v-if="showCommitable"
- id="commit-area">
- <form
- class="form-horizontal"
- @submit.prevent="makeCommit">
- <fieldset>
- <div class="form-group">
- <label class="col-md-4 control-label staged-files">
- Staged files ({{changedFiles.length}})
- </label>
- <div class="col-md-6">
- <ul class="list-unstyled changed-files">
- <li
- v-for="branchPath in branchPaths"
- :key="branchPath">
- <span class="help-block">
- {{branchPath}}
- </span>
- </li>
- </ul>
- </div>
- </div>
- <div class="form-group">
- <label
- class="col-md-4 control-label"
- for="commit-message">
- Commit message
- </label>
- <div class="col-md-6">
- <textarea
- id="commit-message"
- class="form-control"
- name="commit-message"
- v-model="commitMessage">
- </textarea>
- </div>
- </div>
- <div class="form-group target-branch">
- <label
- class="col-md-4 control-label"
- for="target-branch">
- Target branch
- </label>
- <div class="col-md-6">
- <span class="help-block">
- {{targetBranch}}
- </span>
- </div>
- </div>
- <div class="col-md-offset-4 col-md-6">
- <button
- ref="submitCommit"
- type="submit"
- :disabled="cantCommitYet"
- class="btn btn-success">
- <i
- v-if="submitCommitsLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true"
- aria-label="loading">
- </i>
- <span class="commit-summary">
- Commit {{changedFiles.length}} {{filePluralize}}
- </span>
- </button>
- </div>
- </fieldset>
- </form>
-</div>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/repo/components/repo_edit_button.vue
deleted file mode 100644
index 29b76975561..00000000000
--- a/app/assets/javascripts/repo/components/repo_edit_button.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<script>
-import Store from '../stores/repo_store';
-import RepoMixin from '../mixins/repo_mixin';
-
-export default {
- data: () => Store,
- mixins: [RepoMixin],
- computed: {
- buttonLabel() {
- return this.editMode ? this.__('Cancel edit') : this.__('Edit');
- },
-
- showButton() {
- return this.isCommitable &&
- !this.activeFile.render_error &&
- !this.binary &&
- this.openedFiles.length;
- },
- },
- methods: {
- editCancelClicked() {
- if (this.changedFiles.length) {
- this.dialog.open = true;
- return;
- }
- this.editMode = !this.editMode;
- Store.toggleBlobView();
- },
- toggleProjectRefsForm() {
- $('.project-refs-form').toggleClass('disabled', this.editMode);
- $('.js-tree-ref-target-holder').toggle(this.editMode);
- },
- },
-
- watch: {
- editMode() {
- this.toggleProjectRefsForm();
- },
- },
-};
-</script>
-
-<template>
-<button
- v-if="showButton"
- class="btn btn-default"
- type="button"
- @click.prevent="editCancelClicked">
- <i
- v-if="!editMode"
- class="fa fa-pencil"
- aria-hidden="true">
- </i>
- <span>
- {{buttonLabel}}
- </span>
-</button>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue
deleted file mode 100644
index 96d6a75bb61..00000000000
--- a/app/assets/javascripts/repo/components/repo_editor.vue
+++ /dev/null
@@ -1,117 +0,0 @@
-<script>
-/* global monaco */
-import Store from '../stores/repo_store';
-import Service from '../services/repo_service';
-import Helper from '../helpers/repo_helper';
-
-const RepoEditor = {
- data: () => Store,
-
- destroyed() {
- if (Helper.monacoInstance) {
- Helper.monacoInstance.destroy();
- }
- },
-
- mounted() {
- Service.getRaw(this.activeFile.raw_path)
- .then((rawResponse) => {
- Store.blobRaw = rawResponse.data;
- Store.activeFile.plain = rawResponse.data;
-
- const monacoInstance = Helper.monaco.editor.create(this.$el, {
- model: null,
- readOnly: false,
- contextmenu: false,
- });
-
- Helper.monacoInstance = monacoInstance;
-
- this.addMonacoEvents();
-
- this.setupEditor();
- })
- .catch(Helper.loadingError);
- },
-
- methods: {
- setupEditor() {
- this.showHide();
-
- Helper.setMonacoModelFromLanguage();
- },
-
- showHide() {
- if (!this.openedFiles.length || (this.binary && !this.activeFile.raw)) {
- this.$el.style.display = 'none';
- } else {
- this.$el.style.display = 'inline-block';
- }
- },
-
- addMonacoEvents() {
- Helper.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp);
- Helper.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this));
- },
-
- onMonacoEditorKeysPressed() {
- Store.setActiveFileContents(Helper.monacoInstance.getValue());
- },
-
- onMonacoEditorMouseUp(e) {
- if (!e.target.position) return;
- const lineNumber = e.target.position.lineNumber;
- if (e.target.element.classList.contains('line-numbers')) {
- location.hash = `L${lineNumber}`;
- Store.activeLine = lineNumber;
-
- Helper.monacoInstance.setPosition({
- lineNumber: this.activeLine,
- column: 1,
- });
- }
- },
- },
-
- watch: {
- dialog: {
- handler(obj) {
- const newObj = obj;
- if (newObj.status) {
- newObj.status = false;
- this.openedFiles = this.openedFiles.map((file) => {
- const f = file;
- if (f.active) {
- this.blobRaw = f.plain;
- }
- f.changed = false;
- delete f.newContent;
-
- return f;
- });
- this.editMode = false;
- Store.toggleBlobView();
- }
- },
- deep: true,
- },
-
- blobRaw() {
- if (Helper.monacoInstance && !this.isTree) {
- this.setupEditor();
- }
- },
- },
- computed: {
- shouldHideEditor() {
- return !this.openedFiles.length || (this.binary && !this.activeFile.raw);
- },
- },
-};
-
-export default RepoEditor;
-</script>
-
-<template>
-<div id="ide" v-if='!shouldHideEditor'></div>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue
deleted file mode 100644
index 20ebf840774..00000000000
--- a/app/assets/javascripts/repo/components/repo_file.vue
+++ /dev/null
@@ -1,107 +0,0 @@
-<script>
-import TimeAgoMixin from '../../vue_shared/mixins/timeago';
-
-const RepoFile = {
- mixins: [TimeAgoMixin],
- props: {
- file: {
- type: Object,
- required: true,
- },
- isMini: {
- type: Boolean,
- required: false,
- default: false,
- },
- loading: {
- type: Object,
- required: false,
- default() { return { tree: false }; },
- },
- hasFiles: {
- type: Boolean,
- required: false,
- default: false,
- },
- activeFile: {
- type: Object,
- required: true,
- },
- },
-
- computed: {
- canShowFile() {
- return !this.loading.tree || this.hasFiles;
- },
-
- fileIcon() {
- const classObj = {
- 'fa-spinner fa-spin': this.file.loading,
- [this.file.icon]: !this.file.loading,
- };
- return classObj;
- },
-
- fileIndentation() {
- return {
- 'margin-left': `${this.file.level * 10}px`,
- };
- },
-
- activeFileClass() {
- return {
- active: this.activeFile.url === this.file.url,
- };
- },
- },
-
- methods: {
- linkClicked(file) {
- this.$emit('linkclicked', file);
- },
- },
-};
-
-export default RepoFile;
-</script>
-
-<template>
-<tr
- v-if="canShowFile"
- class="file"
- :class="activeFileClass"
- @click.prevent="linkClicked(file)">
- <td>
- <i
- class="fa fa-fw file-icon"
- :class="fileIcon"
- :style="fileIndentation"
- aria-label="file icon">
- </i>
- <a
- :href="file.url"
- class="repo-file-name"
- :title="file.url">
- {{file.name}}
- </a>
- </td>
-
- <template v-if="!isMini">
- <td class="hidden-sm hidden-xs">
- <div class="commit-message">
- <a @click.stop :href="file.lastCommitUrl">
- {{file.lastCommitMessage}}
- </a>
- </div>
- </td>
-
- <td class="hidden-xs">
- <span
- class="commit-update"
- :title="tooltipTitle(file.lastCommitUpdate)">
- {{timeFormated(file.lastCommitUpdate)}}
- </span>
- </td>
- </template>
-</tr>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue
deleted file mode 100644
index e43ef366f47..00000000000
--- a/app/assets/javascripts/repo/components/repo_file_buttons.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<script>
-import Store from '../stores/repo_store';
-import Helper from '../helpers/repo_helper';
-import RepoMixin from '../mixins/repo_mixin';
-
-const RepoFileButtons = {
- data: () => Store,
-
- mixins: [RepoMixin],
-
- computed: {
-
- rawDownloadButtonLabel() {
- return this.binary ? 'Download' : 'Raw';
- },
-
- canPreview() {
- return Helper.isRenderable();
- },
- },
-
- methods: {
- rawPreviewToggle: Store.toggleRawPreview,
- },
-};
-
-export default RepoFileButtons;
-</script>
-
-<template>
- <div id="repo-file-buttons">
- <a
- :href="activeFile.raw_path"
- target="_blank"
- class="btn btn-default raw"
- rel="noopener noreferrer">
- {{rawDownloadButtonLabel}}
- </a>
-
- <div
- class="btn-group"
- role="group"
- aria-label="File actions">
- <a
- :href="activeFile.blame_path"
- class="btn btn-default blame">
- Blame
- </a>
- <a
- :href="activeFile.commits_path"
- class="btn btn-default history">
- History
- </a>
- <a
- :href="activeFile.permalink"
- class="btn btn-default permalink">
- Permalink
- </a>
- </div>
-
- <a
- v-if="canPreview"
- href="#"
- @click.prevent="rawPreviewToggle"
- class="btn btn-default preview">
- {{activeFileLabel}}
- </a>
- </div>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_file_options.vue b/app/assets/javascripts/repo/components/repo_file_options.vue
deleted file mode 100644
index 6a15755f029..00000000000
--- a/app/assets/javascripts/repo/components/repo_file_options.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-<script>
-const RepoFileOptions = {
- props: {
- isMini: {
- type: Boolean,
- required: false,
- default: false,
- },
- projectName: {
- type: String,
- required: true,
- },
- },
-};
-
-export default RepoFileOptions;
-</script>
-
-<template>
- <tr v-if="isMini" class="repo-file-options">
- <td>
- <span class="title">{{projectName}}</span>
- </td>
- </tr>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue
deleted file mode 100644
index bc8c64c8362..00000000000
--- a/app/assets/javascripts/repo/components/repo_loading_file.vue
+++ /dev/null
@@ -1,76 +0,0 @@
-<script>
-const RepoLoadingFile = {
- props: {
- loading: {
- type: Object,
- required: false,
- default: {},
- },
- hasFiles: {
- type: Boolean,
- required: false,
- default: false,
- },
- isMini: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- computed: {
- showGhostLines() {
- return this.loading.tree && !this.hasFiles;
- },
- },
-
- methods: {
- lineOfCode(n) {
- return `skeleton-line-${n}`;
- },
- },
-};
-
-export default RepoLoadingFile;
-</script>
-
-<template>
- <tr
- v-if="showGhostLines"
- class="loading-file">
- <td>
- <div
- class="animation-container animation-container-small">
- <div
- v-for="n in 6"
- :key="n"
- :class="lineOfCode(n)">
- </div>
- </div>
- </td>
-
- <td
- v-if="!isMini"
- class="hidden-sm hidden-xs">
- <div class="animation-container">
- <div
- v-for="n in 6"
- :key="n"
- :class="lineOfCode(n)">
- </div>
- </div>
- </td>
-
- <td
- v-if="!isMini"
- class="hidden-xs">
- <div class="animation-container animation-container-small">
- <div
- v-for="n in 6"
- :key="n"
- :class="lineOfCode(n)">
- </div>
- </div>
- </td>
- </tr>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/repo/components/repo_prev_directory.vue
deleted file mode 100644
index bbdbdc61e38..00000000000
--- a/app/assets/javascripts/repo/components/repo_prev_directory.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<script>
-import RepoMixin from '../mixins/repo_mixin';
-
-const RepoPreviousDirectory = {
- props: {
- prevUrl: {
- type: String,
- required: true,
- },
- },
-
- mixins: [RepoMixin],
-
- computed: {
- colSpanCondition() {
- return this.isMini ? undefined : 3;
- },
- },
-
- methods: {
- linkClicked(file) {
- this.$emit('linkclicked', file);
- },
- },
-};
-
-export default RepoPreviousDirectory;
-</script>
-
-<template>
-<tr class="prev-directory">
- <td
- :colspan="colSpanCondition"
- @click.prevent="linkClicked(prevUrl)">
- <a :href="prevUrl">..</a>
- </td>
-</tr>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue
deleted file mode 100644
index 2200754cbef..00000000000
--- a/app/assets/javascripts/repo/components/repo_preview.vue
+++ /dev/null
@@ -1,52 +0,0 @@
-<script>
-import Store from '../stores/repo_store';
-
-export default {
- data: () => Store,
- mounted() {
- this.highlightFile();
- },
- computed: {
- html() {
- return this.activeFile.html;
- },
- },
-
- methods: {
- highlightFile() {
- $(this.$el).find('.file-content').syntaxHighlight();
- },
- },
-
- watch: {
- html() {
- this.$nextTick(() => {
- this.highlightFile();
- });
- },
- },
-};
-</script>
-
-<template>
-<div>
- <div
- v-if="!activeFile.render_error"
- v-html="activeFile.html">
- </div>
- <div
- v-else-if="activeFile.tooLarge"
- class="vertical-center render-error">
- <p class="text-center">
- The source could not be displayed because it is too large. You can <a :href="activeFile.raw_path">download</a> it instead.
- </p>
- </div>
- <div
- v-else
- class="vertical-center render-error">
- <p class="text-center">
- The source could not be displayed because a rendering error occured. You can <a :href="activeFile.raw_path">download</a> it instead.
- </p>
- </div>
-</div>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue
deleted file mode 100644
index 3414128526d..00000000000
--- a/app/assets/javascripts/repo/components/repo_sidebar.vue
+++ /dev/null
@@ -1,103 +0,0 @@
-<script>
-import Service from '../services/repo_service';
-import Helper from '../helpers/repo_helper';
-import Store from '../stores/repo_store';
-import RepoPreviousDirectory from './repo_prev_directory.vue';
-import RepoFileOptions from './repo_file_options.vue';
-import RepoFile from './repo_file.vue';
-import RepoLoadingFile from './repo_loading_file.vue';
-import RepoMixin from '../mixins/repo_mixin';
-
-export default {
- mixins: [RepoMixin],
- components: {
- 'repo-file-options': RepoFileOptions,
- 'repo-previous-directory': RepoPreviousDirectory,
- 'repo-file': RepoFile,
- 'repo-loading-file': RepoLoadingFile,
- },
-
- created() {
- this.addPopEventListener();
- },
-
- data: () => Store,
-
- methods: {
- addPopEventListener() {
- window.addEventListener('popstate', () => {
- if (location.href.indexOf('#') > -1) return;
- this.linkClicked({
- url: location.href,
- });
- });
- },
-
- fileClicked(clickedFile) {
- let file = clickedFile;
- if (file.loading) return;
- file.loading = true;
- if (file.type === 'tree' && file.opened) {
- file = Store.removeChildFilesOfTree(file);
- file.loading = false;
- } else {
- Service.url = file.url;
- Helper.getContent(file)
- .then(() => {
- file.loading = false;
- Helper.scrollTabsRight();
- })
- .catch(Helper.loadingError);
- }
- },
-
- goToPreviousDirectoryClicked(prevURL) {
- Service.url = prevURL;
- Helper.getContent(null)
- .then(() => Helper.scrollTabsRight())
- .catch(Helper.loadingError);
- },
- },
-};
-</script>
-
-<template>
-<div id="sidebar" :class="{'sidebar-mini' : isMini}">
- <table class="table">
- <thead v-if="!isMini">
- <tr>
- <th class="name">Name</th>
- <th class="hidden-sm hidden-xs last-commit">Last Commit</th>
- <th class="hidden-xs last-update">Last Update</th>
- </tr>
- </thead>
- <tbody>
- <repo-file-options
- :is-mini="isMini"
- :project-name="projectName"
- />
- <repo-previous-directory
- v-if="isRoot"
- :prev-url="prevURL"
- @linkclicked="goToPreviousDirectoryClicked(prevURL)"/>
- <repo-loading-file
- v-for="n in 5"
- :key="n"
- :loading="loading"
- :has-files="!!files.length"
- :is-mini="isMini"
- />
- <repo-file
- v-for="file in files"
- :key="file.id"
- :file="file"
- :is-mini="isMini"
- @linkclicked="fileClicked(file)"
- :is-tree="isTree"
- :has-files="!!files.length"
- :active-file="activeFile"
- />
- </tbody>
- </table>
-</div>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue
deleted file mode 100644
index 0d0c34ec741..00000000000
--- a/app/assets/javascripts/repo/components/repo_tab.vue
+++ /dev/null
@@ -1,63 +0,0 @@
-<script>
-import Store from '../stores/repo_store';
-
-const RepoTab = {
- props: {
- tab: {
- type: Object,
- required: true,
- },
- },
-
- computed: {
- closeLabel() {
- if (this.tab.changed) {
- return `${this.tab.name} changed`;
- }
- return `Close ${this.tab.name}`;
- },
- changedClass() {
- const tabChangedObj = {
- 'fa-times close-icon': !this.tab.changed,
- 'fa-circle unsaved-icon': this.tab.changed,
- };
- return tabChangedObj;
- },
- },
-
- methods: {
- tabClicked: Store.setActiveFiles,
-
- closeTab(file) {
- if (file.changed) return;
- this.$emit('tabclosed', file);
- },
- },
-};
-
-export default RepoTab;
-</script>
-
-<template>
-<li @click="tabClicked(tab)">
- <a
- href="#0"
- class="close"
- @click.stop.prevent="closeTab(tab)"
- :aria-label="closeLabel">
- <i
- class="fa"
- :class="changedClass"
- aria-hidden="true">
- </i>
- </a>
-
- <a
- href="#"
- class="repo-tab"
- :title="tab.url"
- @click.prevent="tabClicked(tab)">
- {{tab.name}}
- </a>
-</li>
-</template>
diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue
deleted file mode 100644
index 9c5bfc5d0cf..00000000000
--- a/app/assets/javascripts/repo/components/repo_tabs.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-<script>
-import Store from '../stores/repo_store';
-import RepoTab from './repo_tab.vue';
-import RepoMixin from '../mixins/repo_mixin';
-
-const RepoTabs = {
- mixins: [RepoMixin],
-
- components: {
- 'repo-tab': RepoTab,
- },
-
- data: () => Store,
-
- methods: {
- tabClosed(file) {
- Store.removeFromOpenedFiles(file);
- },
- },
-};
-
-export default RepoTabs;
-</script>
-
-<template>
-<ul id="tabs">
- <repo-tab
- v-for="tab in openedFiles"
- :key="tab.id"
- :tab="tab"
- :class="{'active' : tab.active}"
- @tabclosed="tabClosed"
- />
- <li class="tabs-divider" />
-</ul>
-</template>
diff --git a/app/assets/javascripts/repo/helpers/monaco_loader_helper.js b/app/assets/javascripts/repo/helpers/monaco_loader_helper.js
deleted file mode 100644
index f8729bbf585..00000000000
--- a/app/assets/javascripts/repo/helpers/monaco_loader_helper.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/* global monaco */
-import RepoEditor from '../components/repo_editor.vue';
-import Store from '../stores/repo_store';
-import Helper from '../helpers/repo_helper';
-import monacoLoader from '../monaco_loader';
-
-function repoEditorLoader() {
- Store.monacoLoading = true;
- return new Promise((resolve, reject) => {
- monacoLoader(['vs/editor/editor.main'], () => {
- Helper.monaco = monaco;
- Store.monacoLoading = false;
- resolve(RepoEditor);
- }, () => {
- Store.monacoLoading = false;
- reject();
- });
- });
-}
-
-const MonacoLoaderHelper = {
- repoEditorLoader,
-};
-
-export default MonacoLoaderHelper;
diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js
deleted file mode 100644
index 2bd8d7eea65..00000000000
--- a/app/assets/javascripts/repo/helpers/repo_helper.js
+++ /dev/null
@@ -1,271 +0,0 @@
-/* global Flash */
-import Service from '../services/repo_service';
-import Store from '../stores/repo_store';
-import '../../flash';
-
-const RepoHelper = {
- monacoInstance: null,
-
- getDefaultActiveFile() {
- return {
- active: true,
- binary: false,
- extension: '',
- html: '',
- mime_type: '',
- name: '',
- plain: '',
- size: 0,
- url: '',
- raw: false,
- newContent: '',
- changed: false,
- loading: false,
- };
- },
-
- key: '',
-
- isTree(data) {
- return Object.hasOwnProperty.call(data, 'blobs');
- },
-
- Time: window.performance
- && window.performance.now
- ? window.performance
- : Date,
-
- getFileExtension(fileName) {
- return fileName.split('.').pop();
- },
-
- getLanguageIDForFile(file, langs) {
- const ext = RepoHelper.getFileExtension(file.name);
- const foundLang = RepoHelper.findLanguage(ext, langs);
-
- return foundLang ? foundLang.id : 'plaintext';
- },
-
- setMonacoModelFromLanguage() {
- RepoHelper.monacoInstance.setModel(null);
- const languages = RepoHelper.monaco.languages.getLanguages();
- const languageID = RepoHelper.getLanguageIDForFile(Store.activeFile, languages);
- const newModel = RepoHelper.monaco.editor.createModel(Store.blobRaw, languageID);
- RepoHelper.monacoInstance.setModel(newModel);
- },
-
- findLanguage(ext, langs) {
- return langs.find(lang => lang.extensions && lang.extensions.indexOf(`.${ext}`) > -1);
- },
-
- setDirectoryOpen(tree) {
- const file = tree;
- if (!file) return undefined;
-
- file.opened = true;
- file.icon = 'fa-folder-open';
- RepoHelper.updateHistoryEntry(file.url, file.name);
- return file;
- },
-
- isRenderable() {
- const okExts = ['md', 'svg'];
- return okExts.indexOf(Store.activeFile.extension) > -1;
- },
-
- setBinaryDataAsBase64(file) {
- Service.getBase64Content(file.raw_path)
- .then((response) => {
- Store.blobRaw = response;
- file.base64 = response; // eslint-disable-line no-param-reassign
- })
- .catch(RepoHelper.loadingError);
- },
-
- // when you open a directory you need to put the directory files under
- // the directory... This will merge the list of the current directory and the new list.
- getNewMergedList(inDirectory, currentList, newList) {
- const newListSorted = newList.sort(this.compareFilesCaseInsensitive);
- if (!inDirectory) return newListSorted;
- const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url);
- if (!indexOfFile) return newListSorted;
- return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile);
- },
-
- // within the get new merged list this does the merging of the current list of files
- // and the new list of files. The files are never "in" another directory they just
- // appear like they are because of the margin.
- mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) {
- newList.reverse().forEach((newFile) => {
- const fileIndex = indexOfFile + 1;
- const file = newFile;
- file.level = inDirectory.level + 1;
- oldList.splice(fileIndex, 0, file);
- });
-
- return oldList;
- },
-
- compareFilesCaseInsensitive(a, b) {
- const aName = a.name.toLowerCase();
- const bName = b.name.toLowerCase();
- if (a.level > 0) return 0;
- if (aName < bName) { return -1; }
- if (aName > bName) { return 1; }
- return 0;
- },
-
- isRoot(url) {
- // the url we are requesting -> split by the project URL. Grab the right side.
- const isRoot = !!url.split(Store.projectUrl)[1]
- // remove the first "/"
- .slice(1)
- // split this by "/"
- .split('/')
- // remove the first two items of the array... usually /tree/master.
- .slice(2)
- // we want to know the length of the array.
- // If greater than 0 not root.
- .length;
- return isRoot;
- },
-
- getContent(treeOrFile) {
- let file = treeOrFile;
- return Service.getContent()
- .then((response) => {
- const data = response.data;
- Store.isTree = RepoHelper.isTree(data);
- if (!Store.isTree) {
- if (!file) file = data;
- Store.binary = data.binary;
-
- if (data.binary) {
- // file might be undefined
- RepoHelper.setBinaryDataAsBase64(data);
- Store.setViewToPreview();
- } else if (!Store.isPreviewView()) {
- if (!data.render_error) {
- Service.getRaw(data.raw_path)
- .then((rawResponse) => {
- Store.blobRaw = rawResponse.data;
- data.plain = rawResponse.data;
- RepoHelper.setFile(data, file);
- }).catch(RepoHelper.loadingError);
- }
- }
-
- if (Store.isPreviewView()) {
- RepoHelper.setFile(data, file);
- }
-
- // if the file tree is empty
- if (Store.files.length === 0) {
- const parentURL = Service.blobURLtoParentTree(Service.url);
- Service.url = parentURL;
- RepoHelper.getContent();
- }
- } else {
- // it's a tree
- if (!file) Store.isRoot = RepoHelper.isRoot(Service.url);
- file = RepoHelper.setDirectoryOpen(file);
- const newDirectory = RepoHelper.dataToListOfFiles(data);
- Store.addFilesToDirectory(file, Store.files, newDirectory);
- Store.prevURL = Service.blobURLtoParentTree(Service.url);
- }
- }).catch(RepoHelper.loadingError);
- },
-
- setFile(data, file) {
- const newFile = data;
-
- newFile.url = file.url;
- if (newFile.render_error === 'too_large' || newFile.render_error === 'collapsed') {
- newFile.tooLarge = true;
- }
- newFile.newContent = '';
-
- Store.addToOpenedFiles(newFile);
- Store.setActiveFiles(newFile);
- },
-
- serializeBlob(blob) {
- const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob);
- simpleBlob.lastCommitMessage = blob.last_commit.message;
- simpleBlob.lastCommitUpdate = blob.last_commit.committed_date;
- simpleBlob.loading = false;
-
- return simpleBlob;
- },
-
- serializeTree(tree) {
- return RepoHelper.serializeRepoEntity('tree', tree);
- },
-
- serializeSubmodule(submodule) {
- return RepoHelper.serializeRepoEntity('submodule', submodule);
- },
-
- serializeRepoEntity(type, entity) {
- const { url, name, icon, last_commit } = entity;
- const returnObj = {
- type,
- name,
- url,
- icon: `fa-${icon}`,
- level: 0,
- loading: false,
- };
-
- if (entity.last_commit) {
- returnObj.lastCommitUrl = `${Store.projectUrl}/commit/${last_commit.id}`;
- } else {
- returnObj.lastCommitUrl = '';
- }
- return returnObj;
- },
-
- scrollTabsRight() {
- // wait for the transition. 0.1 seconds.
- setTimeout(() => {
- const tabs = document.getElementById('tabs');
- if (!tabs) return;
- tabs.scrollLeft = tabs.scrollWidth;
- }, 200);
- },
-
- dataToListOfFiles(data) {
- const { blobs, trees, submodules } = data;
- return [
- ...blobs.map(blob => RepoHelper.serializeBlob(blob)),
- ...trees.map(tree => RepoHelper.serializeTree(tree)),
- ...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)),
- ];
- },
-
- genKey() {
- return RepoHelper.Time.now().toFixed(3);
- },
-
- updateHistoryEntry(url, title) {
- const history = window.history;
-
- RepoHelper.key = RepoHelper.genKey();
-
- history.pushState({ key: RepoHelper.key }, '', url);
-
- if (title) {
- document.title = `${title} · GitLab`;
- }
- },
-
- findOpenedFileFromActive() {
- return Store.openedFiles.find(openedFile => Store.activeFile.url === openedFile.url);
- },
-
- loadingError() {
- Flash('Unable to load this content at this time.');
- },
-};
-
-export default RepoHelper;
diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js
deleted file mode 100644
index 6c1d468e937..00000000000
--- a/app/assets/javascripts/repo/index.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import $ from 'jquery';
-import Vue from 'vue';
-import Service from './services/repo_service';
-import Store from './stores/repo_store';
-import Repo from './components/repo.vue';
-import RepoEditButton from './components/repo_edit_button.vue';
-import Translate from '../vue_shared/translate';
-
-function initDropdowns() {
- $('.js-tree-ref-target-holder').hide();
-}
-
-function addEventsForNonVueEls() {
- $(document).on('change', '.dropdown', () => {
- Store.targetBranch = $('.project-refs-target-form input[name="ref"]').val();
- });
-
- window.onbeforeunload = function confirmUnload(e) {
- const hasChanged = Store.openedFiles
- .some(file => file.changed);
- if (!hasChanged) return undefined;
- const event = e || window.event;
- if (event) event.returnValue = 'Are you sure you want to lose unsaved changes?';
- // For Safari
- return 'Are you sure you want to lose unsaved changes?';
- };
-}
-
-function setInitialStore(data) {
- Store.service = Service;
- Store.service.url = data.url;
- Store.service.refsUrl = data.refsUrl;
- Store.projectId = data.projectId;
- Store.projectName = data.projectName;
- Store.projectUrl = data.projectUrl;
- Store.canCommit = data.canCommit;
- Store.onTopOfBranch = data.onTopOfBranch;
- Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref');
- Store.checkIsCommitable();
-}
-
-function initRepo(el) {
- return new Vue({
- el,
- components: {
- repo: Repo,
- },
- render(createElement) {
- return createElement('repo');
- },
- });
-}
-
-function initRepoEditButton(el) {
- return new Vue({
- el,
- components: {
- repoEditButton: RepoEditButton,
- },
- });
-}
-
-function initRepoBundle() {
- const repo = document.getElementById('repo');
- const editButton = document.querySelector('.editable-mode');
- setInitialStore(repo.dataset);
- addEventsForNonVueEls();
- initDropdowns();
-
- Vue.use(Translate);
-
- initRepo(repo);
- initRepoEditButton(editButton);
-}
-
-$(initRepoBundle);
-
-export default initRepoBundle;
diff --git a/app/assets/javascripts/repo/mixins/repo_mixin.js b/app/assets/javascripts/repo/mixins/repo_mixin.js
deleted file mode 100644
index c8e8238a0d3..00000000000
--- a/app/assets/javascripts/repo/mixins/repo_mixin.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Store from '../stores/repo_store';
-
-const RepoMixin = {
- computed: {
- isMini() {
- return !!Store.openedFiles.length;
- },
-
- changedFiles() {
- const changedFileList = this.openedFiles
- .filter(file => file.changed);
- return changedFileList;
- },
- },
-};
-
-export default RepoMixin;
diff --git a/app/assets/javascripts/repo/monaco_loader.js b/app/assets/javascripts/repo/monaco_loader.js
deleted file mode 100644
index af83a1ec0b4..00000000000
--- a/app/assets/javascripts/repo/monaco_loader.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import monacoContext from 'monaco-editor/dev/vs/loader';
-
-monacoContext.require.config({
- paths: {
- vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase
- },
-});
-
-// eslint-disable-next-line no-underscore-dangle
-window.__monaco_context__ = monacoContext;
-export default monacoContext.require;
diff --git a/app/assets/javascripts/repo/services/repo_service.js b/app/assets/javascripts/repo/services/repo_service.js
deleted file mode 100644
index af83497fa39..00000000000
--- a/app/assets/javascripts/repo/services/repo_service.js
+++ /dev/null
@@ -1,82 +0,0 @@
-/* global Flash */
-import axios from 'axios';
-import Store from '../stores/repo_store';
-import Api from '../../api';
-import Helper from '../helpers/repo_helper';
-
-const RepoService = {
- url: '',
- options: {
- params: {
- format: 'json',
- },
- },
- richExtensionRegExp: /md/,
-
- getRaw(url) {
- return axios.get(url, {
- // Stop Axios from parsing a JSON file into a JS object
- transformResponse: [res => res],
- });
- },
-
- buildParams(url = this.url) {
- // shallow clone object without reference
- const params = Object.assign({}, this.options.params);
-
- if (this.urlIsRichBlob(url)) params.viewer = 'rich';
-
- return params;
- },
-
- urlIsRichBlob(url = this.url) {
- const extension = Helper.getFileExtension(url);
-
- return this.richExtensionRegExp.test(extension);
- },
-
- getContent(url = this.url) {
- const params = this.buildParams(url);
-
- return axios.get(url, {
- params,
- });
- },
-
- getBase64Content(url = this.url) {
- const request = axios.get(url, {
- responseType: 'arraybuffer',
- });
-
- return request.then(response => this.bufferToBase64(response.data));
- },
-
- bufferToBase64(data) {
- return new Buffer(data, 'binary').toString('base64');
- },
-
- blobURLtoParentTree(url) {
- const urlArray = url.split('/');
- urlArray.pop();
- const blobIndex = urlArray.lastIndexOf('blob');
-
- if (blobIndex > -1) urlArray[blobIndex] = 'tree';
-
- return urlArray.join('/');
- },
-
- commitFiles(payload) {
- return Api.commitMultiple(Store.projectId, payload)
- .then(this.commitFlash);
- },
-
- commitFlash(data) {
- if (data.short_id && data.stats) {
- window.Flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice');
- } else {
- window.Flash(data.message);
- }
- },
-};
-
-export default RepoService;
diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js
deleted file mode 100644
index 1c0df528aea..00000000000
--- a/app/assets/javascripts/repo/stores/repo_store.js
+++ /dev/null
@@ -1,199 +0,0 @@
-/* global Flash */
-import Helper from '../helpers/repo_helper';
-import Service from '../services/repo_service';
-
-const RepoStore = {
- monaco: {},
- monacoLoading: false,
- service: '',
- canCommit: false,
- onTopOfBranch: false,
- editMode: false,
- isTree: false,
- isRoot: false,
- prevURL: '',
- projectId: '',
- projectName: '',
- projectUrl: '',
- blobRaw: '',
- currentBlobView: 'repo-preview',
- openedFiles: [],
- submitCommitsLoading: false,
- dialog: {
- open: false,
- title: '',
- status: false,
- },
- activeFile: Helper.getDefaultActiveFile(),
- activeFileIndex: 0,
- activeLine: 0,
- activeFileLabel: 'Raw',
- files: [],
- isCommitable: false,
- binary: false,
- currentBranch: '',
- targetBranch: 'new-branch',
- commitMessage: '',
- binaryTypes: {
- png: false,
- md: false,
- svg: false,
- unknown: false,
- },
- loading: {
- tree: false,
- blob: false,
- },
-
- resetBinaryTypes() {
- Object.keys(RepoStore.binaryTypes).forEach((key) => {
- RepoStore.binaryTypes[key] = false;
- });
- },
-
- // mutations
- checkIsCommitable() {
- RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit;
- },
-
- addFilesToDirectory(inDirectory, currentList, newList) {
- RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList);
- },
-
- toggleRawPreview() {
- RepoStore.activeFile.raw = !RepoStore.activeFile.raw;
- RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source';
- },
-
- setActiveFiles(file) {
- if (RepoStore.isActiveFile(file)) return;
- RepoStore.openedFiles = RepoStore.openedFiles
- .map((openedFile, i) => RepoStore.setFileActivity(file, openedFile, i));
-
- RepoStore.setActiveToRaw();
-
- if (file.binary) {
- RepoStore.blobRaw = file.base64;
- } else if (file.newContent || file.plain) {
- RepoStore.blobRaw = file.newContent || file.plain;
- } else {
- Service.getRaw(file.raw_path)
- .then((rawResponse) => {
- RepoStore.blobRaw = rawResponse.data;
- Helper.findOpenedFileFromActive().plain = rawResponse.data;
- }).catch(Helper.loadingError);
- }
-
- if (!file.loading) Helper.updateHistoryEntry(file.url, file.name);
- RepoStore.binary = file.binary;
- },
-
- setFileActivity(file, openedFile, i) {
- const activeFile = openedFile;
- activeFile.active = file.url === activeFile.url;
-
- if (activeFile.active) RepoStore.setActiveFile(activeFile, i);
-
- return activeFile;
- },
-
- setActiveFile(activeFile, i) {
- RepoStore.activeFile = Object.assign({}, RepoStore.activeFile, activeFile);
- RepoStore.activeFileIndex = i;
- },
-
- setActiveToRaw() {
- RepoStore.activeFile.raw = false;
- // can't get vue to listen to raw for some reason so RepoStore for now.
- RepoStore.activeFileLabel = 'Display source';
- },
-
- removeChildFilesOfTree(tree) {
- let foundTree = false;
- const treeToClose = tree;
- let canStopSearching = false;
- RepoStore.files = RepoStore.files.filter((file) => {
- const isItTheTreeWeWant = file.url === treeToClose.url;
- // if it's the next tree
- if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) {
- canStopSearching = true;
- return true;
- }
- if (canStopSearching) return true;
-
- if (isItTheTreeWeWant) foundTree = true;
-
- if (foundTree) return file.level <= treeToClose.level;
- return true;
- });
-
- treeToClose.opened = false;
- treeToClose.icon = 'fa-folder';
- return treeToClose;
- },
-
- removeFromOpenedFiles(file) {
- if (file.type === 'tree') return;
- let foundIndex;
- RepoStore.openedFiles = RepoStore.openedFiles.filter((openedFile, i) => {
- if (openedFile.path === file.path) foundIndex = i;
- return openedFile.path !== file.path;
- });
-
- // now activate the right tab based on what you closed.
- if (RepoStore.openedFiles.length === 0) {
- RepoStore.activeFile = {};
- return;
- }
-
- if (RepoStore.openedFiles.length === 1 || foundIndex === 0) {
- RepoStore.setActiveFiles(RepoStore.openedFiles[0]);
- return;
- }
-
- if (foundIndex && foundIndex > 0) {
- RepoStore.setActiveFiles(RepoStore.openedFiles[foundIndex - 1]);
- }
- },
-
- addToOpenedFiles(file) {
- const openFile = file;
-
- const openedFilesAlreadyExists = RepoStore.openedFiles
- .some(openedFile => openedFile.path === openFile.path);
-
- if (openedFilesAlreadyExists) return;
-
- openFile.changed = false;
- RepoStore.openedFiles.push(openFile);
- },
-
- setActiveFileContents(contents) {
- if (!RepoStore.editMode) return;
- const currentFile = RepoStore.openedFiles[RepoStore.activeFileIndex];
- RepoStore.activeFile.newContent = contents;
- RepoStore.activeFile.changed = RepoStore.activeFile.plain !== RepoStore.activeFile.newContent;
- currentFile.changed = RepoStore.activeFile.changed;
- currentFile.newContent = contents;
- },
-
- toggleBlobView() {
- RepoStore.currentBlobView = RepoStore.isPreviewView() ? 'repo-editor' : 'repo-preview';
- },
-
- setViewToPreview() {
- RepoStore.currentBlobView = 'repo-preview';
- },
-
- // getters
-
- isActiveFile(file) {
- return file && file.url === RepoStore.activeFile.url;
- },
-
- isPreviewView() {
- return RepoStore.currentBlobView === 'repo-preview';
- },
-};
-
-export default RepoStore;
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index a4eae135403..8d3cc849f81 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -2,223 +2,220 @@
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');
-
- this.removeListeners();
- this.addEventListeners();
+ 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();
+ }
+ }
+};
+
+Sidebar.prototype.isOpen = function() {
+ return this.sidebar.is('.right-sidebar-expanded');
+};
+
+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);
-
- 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);
-
- $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;
- 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 $('.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.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 38c9a71dd20..fdfa4f28aba 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,433 +1,460 @@
-/* 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 */
+/* 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 axios from './lib/utils/axios_utils';
+import DropdownUtils from './filtered_search/dropdown_utils';
import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug } from './lib/utils/common_utils';
-((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();
- }
+/**
+ * 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;
- userId = gon.current_user_id;
- userName = gon.current_username;
- projectOptions = gl.projectOptions, groupOptions = gl.groupOptions, dashboardOptions = gl.dashboardOptions;
- if (isInGroupsPage() && groupOptions) {
- options = groupOptions[getGroupSlug()];
- } else if (isInProjectPage() && projectOptions) {
- options = projectOptions[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 class="dropdown-menu-empty-item"><a>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 8635ccece6e..d0e4f533d8a 100644
--- a/app/assets/javascripts/settings_panels.js
+++ b/app/assets/javascripts/settings_panels.js
@@ -1,34 +1,26 @@
-function expandSectionParent($section, $content) {
- $section.addClass('expanded');
- $content.off('animationend.expandSectionParent');
-}
-
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,10 +31,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) {
- expandSection($(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..db466f722c4
--- /dev/null
+++ b/app/assets/javascripts/shared/milestones/form.js
@@ -0,0 +1,9 @@
+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..1d075f7e872
--- /dev/null
+++ b/app/assets/javascripts/shared/sessions/u2f.js
@@ -0,0 +1,16 @@
+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 e754f6c4460..c5dddd001bb 100644
--- a/app/assets/javascripts/shortcuts.js
+++ b/app/assets/javascripts/shortcuts.js
@@ -1,143 +1,122 @@
-/* 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 */
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');
- const findFileURL = document.body.dataset.findFile;
+ $(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 78b257bf192..14545824e74 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -1,100 +1,72 @@
-/* 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 from 'mousetrap';
import _ from 'underscore';
-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(isMergeRequest);
- 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 Sidebar from './right_sidebar';
+import Shortcuts from './shortcuts';
+import { CopyAsGFM } from './behaviors/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();
- ShortcutsIssuable.prototype.replyWithSelectedText = function(isMergeRequest) {
- var quote, documentFragment, el, selected, separator;
- let replyField;
-
- if (isMergeRequest) {
- replyField = $('.js-main-target-form #note_note');
- } else {
- replyField = $('.js-main-target-form .js-vue-comment-form');
- }
-
- 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(a, current) {
- return current + separator + quote.join('') + "\n";
- });
-
- // Trigger autosave
- replyField.trigger('input').trigger('change');
-
- // 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 (!documentFragment) {
+ this.$replyField.focus();
return false;
- };
+ }
+
+ const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
+ const selected = CopyAsGFM.nodeToGFM(el);
+
+ 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
index 77f070d48cc..129ba2e4e89 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
@@ -39,7 +39,7 @@ export default {
class="js-sidebar-dropdown-toggle edit-link pull-right"
href="#"
>
- Edit
+ {{ __('Edit') }}
</a>
<a
v-if="showToggle"
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
index f83c3b037ed..8269fe1281d 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
@@ -1,26 +1,35 @@
-/* global Flash */
-
+import Flash from '../../../flash';
import AssigneeTitle from './assignee_title';
-import Assignees from './assignees';
-
+import Assignees from './assignees.vue';
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: '',
};
},
+ props: {
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ field: {
+ type: String,
+ required: true,
+ },
+ signedIn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
components: {
- 'assignee-title': AssigneeTitle,
- assignees: Assignees,
+ AssigneeTitle,
+ Assignees,
},
methods: {
assignSelf() {
@@ -62,10 +71,6 @@ export default {
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
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
index 8e7abdbffef..8a86c409b62 100644
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -1,81 +1,101 @@
<script>
-/* global Flash */
-import editForm from './edit_form.vue';
+ 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,
- },
- props: {
- isConfidential: {
- required: true,
- type: Boolean,
+ export default {
+ components: {
+ editForm,
+ Icon,
},
- isEditable: {
- required: true,
- type: Boolean,
+ props: {
+ isConfidential: {
+ required: true,
+ type: Boolean,
+ },
+ isEditable: {
+ required: true,
+ type: Boolean,
+ },
+ service: {
+ required: true,
+ type: Object,
+ },
},
- service: {
- required: true,
- type: Object,
- },
- },
- data() {
- return {
- edit: false,
- };
- },
- computed: {
- faEye() {
- const eye = this.isConfidential ? 'fa-eye-slash' : 'fa-eye';
+ data() {
return {
- [eye]: true,
+ edit: false,
};
},
- },
- methods: {
- toggleForm() {
- this.edit = !this.edit;
+ computed: {
+ confidentialityIcon() {
+ return this.isConfidential ? 'eye-slash' : 'eye';
+ },
},
- updateConfidentialAttribute(confidential) {
- this.service.update('issue', { confidential })
- .then(() => location.reload())
- .catch(() => new Flash('Something went wrong trying to change the confidentiality of this issue'));
+ 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 confidentiality">
+ <div class="block issuable-sidebar-item confidentiality">
<div class="sidebar-collapsed-icon">
- <i class="fa" :class="faEye" aria-hidden="true" data-hidden="true"></i>
+ <icon
+ :name="confidentialityIcon"
+ :size="16"
+ aria-hidden="true"
+ />
</div>
<div class="title hide-collapsed">
- Confidentiality
+ {{ __('Confidentiality') }}
<a
v-if="isEditable"
class="pull-right confidential-edit"
href="#"
@click.prevent="toggleForm"
>
- Edit
+ {{ __('Edit') }}
</a>
</div>
- <div class="value confidential-value hide-collapsed">
+ <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 confidential-value">
- <i class="fa fa-eye is-not-confidential"></i>
- Not confidential
+ <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 confidential-value hide-collapsed">
- <i aria-hidden="true" data-hidden="true" class="fa fa-eye-slash is-confidential"></i>
- This issue is confidential
+ <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>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
index d578b663a54..c569843b05f 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
@@ -1,40 +1,47 @@
<script>
-import editFormButtons from './edit_form_buttons.vue';
+ import editFormButtons from './edit_form_buttons.vue';
+ import { s__ } from '../../../locale';
-export default {
- components: {
- editFormButtons,
- },
- props: {
- isConfidential: {
- required: true,
- type: Boolean,
+ export default {
+ components: {
+ editFormButtons,
},
- toggleForm: {
- required: true,
- type: Function,
+ props: {
+ isConfidential: {
+ required: true,
+ type: Boolean,
+ },
+ toggleForm: {
+ required: true,
+ type: Function,
+ },
+ updateConfidentialAttribute: {
+ 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 confidential-warning-message">
+ <div class="dropdown-menu sidebar-item-warning-message">
<div>
- <p v-if="!isConfidential">
- 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.
+ <p
+ v-if="!isConfidential"
+ v-html="confidentialityOnWarning">
</p>
- <p v-else>
- 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.
+ <p
+ v-else
+ v-html="confidentialityOffWarning">
</p>
<edit-form-buttons
:is-confidential="isConfidential"
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
index 97af4a3f505..49d5dfeea1a 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
@@ -15,7 +15,7 @@ export default {
},
},
computed: {
- onOrOff() {
+ toggleButtonText() {
return this.isConfidential ? 'Turn Off' : 'Turn On';
},
updateConfidentialBool() {
@@ -26,20 +26,20 @@ export default {
</script>
<template>
- <div class="confidential-warning-message-actions">
+ <div class="sidebar-item-warning-message-actions">
<button
type="button"
class="btn btn-default append-right-10"
@click="toggleForm"
>
- Cancel
+ {{ __('Cancel') }}
</button>
<button
type="button"
class="btn btn-close"
@click.prevent="updateConfidentialAttribute(updateConfidentialBool)"
>
- {{ onOrOff }}
+ {{ 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/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
index fd0d4570d68..b5ebccd3795 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
@@ -68,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 }}
@@ -76,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 d32fe4abc7d..782e4ba4fad 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
@@ -2,7 +2,7 @@ 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';
@@ -16,7 +16,7 @@ export default {
};
},
components: {
- 'issuable-time-tracker': timeTracker,
+ IssuableTimeTracker,
},
methods: {
listenForQuickActions() {
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index ed0d71a4f79..230736a56b8 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -1,3 +1,4 @@
+<script>
import timeTrackingHelpState from './help_state';
import timeTrackingCollapsedState from './collapsed_state';
import timeTrackingSpentOnlyPane from './spent_only_pane';
@@ -8,7 +9,15 @@ import timeTrackingComparisonPane from './comparison_pane';
import eventHub from '../../event_hub';
export default {
- name: 'issuable-time-tracker',
+ name: 'IssuableTimeTracker',
+ 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,
+ },
props: {
time_estimate: {
type: Number,
@@ -38,14 +47,6 @@ export default {
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;
@@ -81,6 +82,9 @@ export default {
return !!this.showHelp;
},
},
+ created() {
+ eventHub.$on('timeTracker:updateData', this.update);
+ },
methods: {
toggleHelpState(show) {
this.showHelp = show;
@@ -92,72 +96,73 @@ export default {
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"
+};
+</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"
/>
- <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"
+ <transition name="help-state-toggle">
+ <time-tracking-help-state
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"
+ :root-path="rootPath"
/>
- <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>
+ </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
index 1c15a1b877a..b10e2cc60ef 100644
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -1,5 +1,3 @@
-/* global Flash */
-
function isValidProjectId(id) {
return id > 0;
}
@@ -38,7 +36,7 @@ class SidebarMoveIssue {
data: (searchTerm, callback) => {
this.mediator.fetchAutocompleteProjects(searchTerm)
.then(callback)
- .catch(() => new Flash('An error occured while fetching projects autocomplete.'));
+ .catch(() => new window.Flash('An error occurred while fetching projects autocomplete.'));
},
renderRow: project => `
<li>
@@ -52,7 +50,7 @@ class SidebarMoveIssue {
const selectedProjectId = options.isMarking ? project.id : 0;
this.mediator.setMoveToProjectId(selectedProjectId);
- this.$confirmButton.attr('disabled', !isValidProjectId(selectedProjectId));
+ this.$confirmButton.prop('disabled', !isValidProjectId(selectedProjectId));
},
});
}
@@ -73,7 +71,7 @@ class SidebarMoveIssue {
this.mediator.moveIssue()
.catch(() => {
- Flash('An error occured while moving the issue.');
+ window.Flash('An error occurred while moving the issue.');
this.$confirmButton
.enable()
.removeClass('is-loading');
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..56cc78ca0ca
--- /dev/null
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -0,0 +1,145 @@
+import Vue from 'vue';
+import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
+import SidebarAssignees from './components/assignees/sidebar_assignees';
+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 604648407a4..37c97225bfd 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -7,6 +7,7 @@ export default class SidebarService {
constructor(endpointMap) {
if (!SidebarService.singleton) {
this.endpoint = endpointMap.endpoint;
+ this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint;
this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
@@ -36,6 +37,10 @@ export default class SidebarService {
});
}
+ 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 3d8972050a9..377846db70e 100644
--- a/app/assets/javascripts/sidebar/sidebar_bundle.js
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -1,48 +1,9 @@
-import Vue from 'vue';
-import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
-import sidebarAssignees from './components/assignees/sidebar_assignees';
-import confidential from './components/confidential/confidential_issue_sidebar.vue';
-import SidebarMoveIssue from './lib/sidebar_move_issue';
-
import Mediator from './sidebar_mediator';
+import { mountSidebar, getSidebarOptions } from './mount_sidebar';
-function domContentLoaded() {
- const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
- const mediator = new Mediator(sidebarOptions);
+export default () => {
+ const mediator = new Mediator(getSidebarOptions());
mediator.fetch();
- const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
- const confidentialEl = document.querySelector('#js-confidential-entry-point');
- // 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);
- }
-
- if (confidentialEl) {
- const dataNode = document.getElementById('js-confidential-issue-data');
- const initialData = JSON.parse(dataNode.innerHTML);
-
- const ConfidentialComp = Vue.extend(confidential);
-
- new ConfidentialComp({
- propsData: {
- isConfidential: initialData.is_confidential,
- isEditable: initialData.is_editable,
- service: mediator.service,
- },
- }).$mount(confidentialEl);
-
- new SidebarMoveIssue(
- mediator,
- $('.js-move-issue'),
- $('.js-move-issue-confirmation-button'),
- ).init();
- }
-
- 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 e38a8db4cc5..d86557e870a 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,23 +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({
- endpoint: options.endpoint,
- moveIssueEndpoint: options.moveIssueEndpoint,
- projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
- });
- 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);
}
@@ -35,13 +39,32 @@ export default class SidebarMediator {
}
fetch() {
- this.service.get()
+ return this.service.get()
.then(response => response.json())
.then((data) => {
- this.store.setAssigneeData(data);
- this.store.setTimeTrackingData(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) {
@@ -58,7 +81,7 @@ export default class SidebarMediator {
.then(response => response.json())
.then((data) => {
if (location.pathname !== data.web_url) {
- gl.utils.visitUrl(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 cc04a2a3fcf..f20cc6d8cca 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -1,27 +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,
- };
- this.autocompleteProjects = [];
- this.moveToProjectId = 0;
-
- 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) {
@@ -36,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);
@@ -60,6 +88,10 @@ export default class SidebarStore {
this.autocompleteProjects = projects;
}
+ setSubscribedState(subscribed) {
+ this.subscribed = subscribed;
+ }
+
setMoveToProjectId(moveToProjectId) {
this.moveToProjectId = moveToProjectId;
}
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 4505a79a2df..6142ce6c6a3 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,6 +1,11 @@
/* 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 */
+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';
const WRAPPER = '<div class="diff-content"></div>';
const LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>';
@@ -13,7 +18,7 @@ export default class SingleFileDiff {
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.diffForPath = this.content.find('[data-diff-for-path]').data('diffForPath');
this.isOpen = !this.diffForPath;
if (this.diffForPath) {
this.collapsedContent = this.content;
@@ -58,26 +63,33 @@ export default class SingleFileDiff {
getContentHTML(cb) {
this.collapsedContent.hide();
this.loadingContent.show();
- $.get(this.diffForPath, (function(_this) {
- return function(data) {
- _this.loadingContent.hide();
+
+ axios.get(this.diffForPath)
+ .then(({ data }) => {
+ this.loadingContent.hide();
if (data.html) {
- _this.content = $(data.html);
- _this.content.syntaxHighlight();
+ this.content = $(data.html);
+ syntaxHighlight(this.content);
} else {
- _this.hasError = true;
- _this.content = $(ERROR_HTML);
+ this.hasError = true;
+ this.content = $(ERROR_HTML);
}
- _this.collapsedContent.after(_this.content);
+ this.collapsedContent.after(this.content);
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
}
- FilesCommentButton.init($(_this.file));
+ const $file = $(this.file);
+ FilesCommentButton.init($file);
+
+ const canCreateNote = $file.closest('.files').is('[data-can-create-note]');
+ imageDiffHelper.initImageDiff($file[0], canCreateNote);
if (cb) cb();
- };
- })(this));
+ })
+ .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 2bf7a3a5d61..8e931995fc6 100644
--- a/app/assets/javascripts/smart_interval.js
+++ b/app/assets/javascripts/smart_interval.js
@@ -3,9 +3,10 @@
* and controllable by a public API.
*/
-class SmartInterval {
+export default class SmartInterval {
/**
- * @param { function } opts.callback Function to be called on each iteration (required)
+ * @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
@@ -42,13 +43,16 @@ class SmartInterval {
const cfg = this.cfg;
const state = this.state;
- if (cfg.immediateExecution) {
+ if (cfg.immediateExecution && !this.isLoading) {
cfg.immediateExecution = false;
- cfg.callback();
+ this.triggerCallback();
}
state.intervalId = window.setInterval(() => {
- cfg.callback();
+ if (this.isLoading) {
+ return;
+ }
+ this.triggerCallback();
if (this.getCurrentInterval() === cfg.maxInterval) {
return;
@@ -76,7 +80,7 @@ class SmartInterval {
// start a timer, using the existing interval
resume() {
- this.stopTimer(); // stop exsiting timer, in case timer was not previously stopped
+ this.stopTimer(); // stop existing timer, in case timer was not previously stopped
this.start();
}
@@ -104,6 +108,18 @@ class SmartInterval {
this.initPageUnloadHandling();
}
+ triggerCallback() {
+ this.isLoading = true;
+ this.cfg.callback()
+ .then(() => {
+ this.isLoading = false;
+ })
+ .catch((err) => {
+ this.isLoading = false;
+ throw err;
+ });
+ }
+
initVisibilityChangeHandling() {
// cancel interval when tab no longer shown (prevents cached pages from polling)
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
@@ -154,4 +170,3 @@ class SmartInterval {
}
}
-window.gl.SmartInterval = SmartInterval;
diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js
index a98403f4cf2..ce0fd3f6ff8 100644
--- a/app/assets/javascripts/snippet/snippet_bundle.js
+++ b/app/assets/javascripts/snippet/snippet_bundle.js
@@ -1,12 +1,9 @@
-/* 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");
+export default () => {
+ const editor = ace.edit('editor');
- $(".snippet-form-holder form").on('submit', function() {
- $(".snippet-file-content").val(editor.getValue());
- });
+ $('.snippet-form-holder form').on('submit', () => {
+ $('.snippet-file-content').val(editor.getValue());
});
-}).call(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 3a06b477d7c..3deb629d5f2 100644
--- a/app/assets/javascripts/star.js
+++ b/app/assets/javascripts/star.js
@@ -1,28 +1,31 @@
-/* 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 Flash from './flash';
import { __, s__ } from './locale';
+import { spriteIcon } from './lib/utils/common_utils';
+import axios from './lib/utils/axios_utils';
export default class Star {
constructor() {
- $('.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) {
- $this.parent().find('.star-count').text(data.star_count);
- if (isStarred) {
- $starSpan.removeClass('starred').text(s__('StarProject|Star'));
- $starIcon.removeClass('fa-star').addClass('fa-star-o');
- } else {
- $starSpan.addClass('starred').text(__('Unstar'));
- $starIcon.removeClass('fa-star-o').addClass('fa-star');
- }
- };
- toggleStar($starSpan.hasClass('starred'));
- }).on('ajax:error', function(e, xhr, status, error) {
- new Flash('Star toggle failed. Try again later.', 'alert');
+ $('.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(s__('StarProject|Star'));
+ $startIcon.remove();
+ $this.prepend(spriteIcon('star-o'));
+ } else {
+ $starSpan.addClass('starred').text(__('Unstar'));
+ $startIcon.remove();
+ $this.prepend(spriteIcon('star'));
+ }
+ })
+ .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 bb4d68fcd49..00000000000
--- a/app/assets/javascripts/subscription.js
+++ /dev/null
@@ -1,45 +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 37e39ce5477..3ed064f87a9 100644
--- a/app/assets/javascripts/subscription_select.js
+++ b/app/assets/javascripts/subscription_select.js
@@ -1,33 +1,24 @@
-/* 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 */
+export default function subscriptionSelect() {
+ $('.js-subscription-event').each((i, element) => {
+ const fieldName = $(element).data('fieldName');
-class SubscriptionSelect {
- constructor() {
- $('.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");
+ 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');
+ },
});
- }
+ });
}
-
-window.SubscriptionSelect = SubscriptionSelect;
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index 662d6b36c16..62bdef76c55 100644
--- a/app/assets/javascripts/syntax_highlight.js
+++ b/app/assets/javascripts/syntax_highlight.js
@@ -10,17 +10,15 @@
// <div class="js-syntax-highlight"></div>
//
-$.fn.syntaxHighlight = function() {
- var $children;
-
- if ($(this).hasClass('js-syntax-highlight')) {
+export default function syntaxHighlight(el) {
+ if ($(el).hasClass('js-syntax-highlight')) {
// Given the element itself, apply highlighting
- return $(this).addClass(gon.user_color_scheme);
+ return $(el).addClass(gon.user_color_scheme);
} else {
// Given a parent element, recurse to any of its applicable children
- $children = $(this).find('.js-syntax-highlight');
+ const $children = $(el).find('.js-syntax-highlight');
if ($children.length) {
- return $children.syntaxHighlight();
+ return syntaxHighlight($children);
}
}
-};
+}
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index c39f569da5e..8fa78b636f8 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,6 +1,6 @@
-/* global Flash */
-
import 'deckar01-task_list';
+import axios from './lib/utils/axios_utils';
+import Flash from './flash';
export default class TaskList {
constructor(options = {}) {
@@ -8,11 +8,11 @@ export default class TaskList {
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,12 +39,9 @@ export default 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));
}
}
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js
index 9dd14488f22..b5b64f44a11 100644
--- a/app/assets/javascripts/templates/issuable_template_selector.js
+++ b/app/assets/javascripts/templates/issuable_template_selector.js
@@ -1,60 +1,56 @@
-/* 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 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..66d868c5839 100644
--- a/app/assets/javascripts/templates/issuable_template_selectors.js
+++ b/app/assets/javascripts/templates/issuable_template_selectors.js
@@ -1,31 +1,28 @@
-/* eslint-disable no-new, comma-dangle, class-methods-use-this, no-param-reassign */
+/* eslint-disable no-new, class-methods-use-this */
+import IssuableTemplateSelector from './issuable_template_selector';
-((global) => {
- class IssuableTemplateSelectors {
- constructor({ $dropdowns, editor } = {}) {
- this.$dropdowns = $dropdowns || $('.js-issuable-selector');
- this.editor = editor || this.initEditor();
+export default class IssuableTemplateSelectors {
+ constructor({ $dropdowns, editor } = {}) {
+ this.$dropdowns = $dropdowns || $('.js-issuable-selector');
+ this.editor = editor || this.initEditor();
- 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
- });
+ 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,
});
- }
-
- initEditor() {
- const editor = $('.markdown-area');
- // Proxy ace-editor's .setValue to jQuery's .val
- editor.setValue = editor.val;
- editor.getValue = editor.val;
- return 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/terminal_bundle.js b/app/assets/javascripts/terminal/index.js
index 134522ef961..1a75e072c4e 100644
--- a/app/assets/javascripts/terminal/terminal_bundle.js
+++ b/app/assets/javascripts/terminal/index.js
@@ -6,4 +6,4 @@ import './terminal';
window.Terminal = Terminal;
-$(() => new gl.Terminal({ selector: '#terminal' }));
+export default () => 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 8875590f0f2..a55a338eea8 100644
--- a/app/assets/javascripts/test_utils/index.js
+++ b/app/assets/javascripts/test_utils/index.js
@@ -1,6 +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/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 7777ed1c3dc..1a0b2c0415b 100644
--- a/app/assets/javascripts/tree.js
+++ b/app/assets/javascripts/tree.js
@@ -1,4 +1,5 @@
/* 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 */
+import { visitUrl } from './lib/utils/url_utility';
export default class TreeView {
constructor() {
@@ -14,7 +15,7 @@ export default class TreeView {
e.preventDefault();
return window.open(path, '_blank');
} else {
- return gl.utils.visitUrl(path);
+ return visitUrl(path);
}
}
});
@@ -56,7 +57,7 @@ export default class TreeView {
} else if (e.which === 13) {
path = $('.tree-item.selected .tree-item-file-name a').attr('href');
if (path) {
- return gl.utils.visitUrl(path);
+ return visitUrl(path);
}
}
});
diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js
index 8821b22477f..fd42f9c3baa 100644
--- a/app/assets/javascripts/u2f/authenticate.js
+++ b/app/assets/javascripts/u2f/authenticate.js
@@ -1,118 +1,108 @@
-/* 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 '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 3a2534d553b..869fac658e8 100644
--- a/app/assets/javascripts/u2f/register.js
+++ b/app/assets/javascripts/u2f/register.js
@@ -1,98 +1,88 @@
-/* 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 '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'
- };
-
- U2FRegister.prototype.renderTemplate = function(name, params) {
- var template, templateString;
- templateString = $(this.templates[name]).html();
- template = _.template(templateString);
- return this.container.html(template(params));
- };
+ 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.renderSetup = function() {
- this.renderTemplate('setup');
- return this.container.find('#js-setup-u2f-device').on('click', this.renderInProgress);
- };
+ renderTemplate(name, params) {
+ const templateString = $(this.templates[name]).html();
+ const template = _.template(templateString);
+ return this.container.html(template(params));
+ }
- U2FRegister.prototype.renderInProgress = function() {
- this.renderTemplate('inProgress');
- return this.register();
- };
+ renderSetup() {
+ this.renderTemplate('setup');
+ return this.container.find('#js-setup-u2f-device').on('click', this.renderInProgress);
+ }
- 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);
- };
+ renderInProgress() {
+ this.renderTemplate('inProgress');
+ return this.register();
+ }
- 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
index f503076715c..78dda172ee6 100644
--- a/app/assets/javascripts/ui_development_kit.js
+++ b/app/assets/javascripts/ui_development_kit.js
@@ -1,6 +1,6 @@
import Api from './api';
-document.addEventListener('DOMContentLoaded', () => {
+export default () => {
$('#js-project-dropdown').glDropdown({
data: (term, callback) => {
Api.projects(term, {
@@ -19,4 +19,4 @@ document.addEventListener('DOMContentLoaded', () => {
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 2389056bd02..00000000000
--- a/app/assets/javascripts/usage_ping.js
+++ /dev/null
@@ -1,12 +0,0 @@
-export default function UsagePing() {
- const usageDataUrl = $('.usage-data').data('endpoint');
-
- $.ajax({
- type: 'GET',
- url: usageDataUrl,
- dataType: 'html',
- success(html) {
- $('.usage-data').html(html);
- },
- });
-}
diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js
index a45b22f3084..a783122d500 100644
--- a/app/assets/javascripts/user_callout.js
+++ b/app/assets/javascripts/user_callout.js
@@ -22,7 +22,7 @@ export default class UserCallout {
const $currentTarget = $(e.currentTarget);
if (this.options.setCalloutPerProject) {
- Cookies.set(this.cookieName, 'true', { expires: 365, path: this.userCalloutBody.data('project-path') });
+ Cookies.set(this.cookieName, 'true', { expires: 365, path: this.userCalloutBody.data('projectPath') });
} else {
Cookies.set(this.cookieName, 'true', { expires: 365 });
}
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 73676bd6de7..3385aba0279 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -2,11 +2,12 @@
/* global Issuable */
/* global emitSidebarEvent */
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);
@@ -20,6 +21,8 @@ function UsersSelect(currentUser, els) {
}
}
+ const { handleClick } = options;
+
$els = $(els);
if (!els) {
@@ -31,23 +34,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();
@@ -60,7 +62,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();
@@ -69,7 +71,7 @@ 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');
@@ -93,7 +95,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() {
@@ -103,14 +105,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', {
@@ -155,7 +157,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');
@@ -175,32 +177,28 @@ 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', _.escape(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> <% } %>');
@@ -294,10 +292,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'),
});
}
@@ -328,7 +326,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();
@@ -363,7 +361,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
@@ -374,7 +372,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;
@@ -382,7 +380,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
@@ -401,7 +399,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();
@@ -409,7 +407,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
@@ -424,7 +422,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')) {
@@ -441,21 +439,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');
}
@@ -467,7 +468,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');
@@ -483,11 +484,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;
@@ -508,9 +509,7 @@ function UsersSelect(currentUser, els) {
if (user.beforeDivider != null) {
`<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 `
@@ -533,16 +532,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'),
@@ -658,39 +656,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/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 982b5e8e373..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import tooltip from '../../vue_shared/directives/tooltip';
-
-export default {
- name: 'MRWidgetAuthor',
- props: {
- author: { type: Object, required: true },
- showAuthorName: { type: Boolean, required: false, default: true },
- showAuthorTooltip: { type: Boolean, required: false, default: false },
- },
- directives: {
- tooltip,
- },
- template: `
- <a
- :href="author.webUrl || author.web_url"
- class="author-link inline"
- :v-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
index e98d147733c..d174a900f63 100644
--- 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
@@ -1,8 +1,8 @@
-/* global Flash */
-
-import '~/lib/utils/datetime_utility';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+import { visitUrl } from '../../lib/utils/url_utility';
+import Flash from '../../flash';
import MemoryUsage from './mr_widget_memory_usage';
-import StatusIcon from './mr_widget_status_icon';
+import StatusIcon from './mr_widget_status_icon.vue';
import MRWidgetService from '../services/mr_widget_service';
export default {
@@ -17,7 +17,7 @@ export default {
},
methods: {
formatDate(date) {
- return gl.utils.getTimeago().format(date);
+ return getTimeago().format(date);
},
hasExternalUrls(deployment = {}) {
return deployment.external_url && deployment.external_url_formatted;
@@ -34,10 +34,10 @@ export default {
if (isConfirmed) {
MRWidgetService.stopEnvironment(deployment.stop_url)
- .then(res => res.json())
- .then((res) => {
- if (res.redirect_url) {
- gl.utils.visitUrl(res.redirect_url);
+ .then(res => res.data)
+ .then((data) => {
+ if (data.redirect_url) {
+ visitUrl(data.redirect_url);
}
})
.catch(() => {
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 219ff94924e..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import tooltip from '../../vue_shared/directives/tooltip';
-import '../../lib/utils/text_utility';
-
-export default {
- name: 'MRWidgetHeader',
- props: {
- mr: { type: Object, required: true },
- },
- directives: {
- tooltip,
- },
- 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 class="normal">
- <strong>
- Request to merge
- <span
- class="label-branch"
- :class="{'label-truncated': isBranchTitleLong(mr.sourceBranch)}"
- :title="isBranchTitleLong(mr.sourceBranch) ? mr.sourceBranch : ''"
- data-placement="bottom"
- :v-tooltip="isBranchTitleLong(mr.sourceBranch)"
- v-html="mr.sourceBranchLink"></span>
- <button
- v-tooltip
- class="btn btn-transparent btn-clipboard"
- 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"
- :v-tooltip="isBranchTitleLong(mr.sourceBranch)"
- :class="{'label-truncatedtooltip': isBranchTitleLong(mr.targetBranch)}"
- :title="isBranchTitleLong(mr.targetBranch) ? mr.targetBranch : ''"
- data-placement="bottom">
- <a :href="mr.targetBranchTreePath">{{mr.targetBranch}}</a>
- </span>
- </strong>
- <span
- v-if="shouldShowCommitsBehindText"
- class="diverged-commits-count">
- (<a :href="mr.targetBranchPath">{{mr.divergedCommitsCount}} {{commitsText}} behind</a>)
- </span>
- </div>
- <div v-if="mr.isOpen">
- <a
- href="#modal_merge_info"
- data-toggle="modal"
- class="btn btn-sm inline">
- Check out branch
- </a>
- <span class="dropdown prepend-left-10">
- <a
- class="btn btn-sm inline dropdown-toggle"
- data-toggle="dropdown"
- aria-label="Download as"
- role="button">
- <i
- class="fa fa-download"
- aria-hidden="true">
- </i>
- <i
- class="fa fa-caret-down"
- aria-hidden="true">
- </i>
- </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>
- `,
-};
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..18a3787857d
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -0,0 +1,145 @@
+<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')"
+ />
+
+ {{ 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_memory_usage.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
index a8c686e5065..69e70ba1dd6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
@@ -102,11 +102,11 @@ export default {
return res;
}
- return res.json();
+ return res.data;
})
- .then((res) => {
- this.computeGraphData(res.metrics, res.deployment_time);
- return res;
+ .then((data) => {
+ this.computeGraphData(data.metrics, data.deployment_time);
+ return data;
})
.catch(() => {
this.loadFailed = true;
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 1d9f9863dd9..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 c79b5c720eb..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js
+++ /dev/null
@@ -1,90 +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: {
- hasPipeline() {
- return this.mr.pipeline && Object.keys(this.mr.pipeline).length > 0;
- },
- hasCIError() {
- const { hasCI, ciStatus } = this.mr;
-
- return hasCI && !ciStatus;
- },
- 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
- 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">
- <span
- v-html="svg"
- aria-hidden="true"></span>
- </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">
- <div class="ci-status-icon append-right-10">
- <a
- class="icon-link"
- :href="this.status.details_path">
- <ci-icon :status="status" />
- </a>
- </div>
- <div class="media-body">
- <span>
- Pipeline
- <a
- :href="mr.pipeline.path"
- class="pipeline-id">#{{mr.pipeline.id}}</a>
- </span>
- <span class="mr-widget-pipeline-graph">
- <span 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>
- </span>
- </span>
- <span>
- {{mr.pipeline.details.status.label}} 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>
- </div>
- </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 563267ad044..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js
+++ /dev/null
@@ -1,37 +0,0 @@
-export default {
- name: 'MRWidgetRelatedLinks',
- props: {
- relatedLinks: { type: Object, required: true },
- state: { type: String, required: false },
- },
- computed: {
- hasLinks() {
- const { closing, mentioned, assignToMe } = this.relatedLinks;
- return closing || mentioned || assignToMe;
- },
- closesText() {
- if (this.state === 'merged') {
- return 'Closed';
- }
- if (this.state === 'closed') {
- return 'Did not close';
- }
- return 'Closes';
- },
- },
- template: `
- <section
- v-if="hasLinks"
- class="mr-info-list mr-links">
- <p v-if="relatedLinks.closing">
- {{closesText}} <span v-html="relatedLinks.closing"></span>
- </p>
- <p v-if="relatedLinks.mentioned">
- Mentions <span v-html="relatedLinks.mentioned"></span>
- </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.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.js
deleted file mode 100644
index 703f3a56a34..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import ciIcon from '../../vue_shared/components/ci_icon.vue';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
-
-export default {
- props: {
- status: { type: String, required: true },
- showDisabledButton: { type: Boolean, required: false },
- },
- components: {
- ciIcon,
- loadingIcon,
- },
- computed: {
- statusObj() {
- return {
- group: this.status,
- icon: `icon_status_${this.status}`,
- };
- },
- },
- template: `
- <div class="space-children flex-container-block append-right-10">
- <div v-if="status === 'loading'" class="mr-widget-icon">
- <loading-icon />
- </div>
- <ci-icon v-else :status="statusObj" />
- <button
- v-if="showDisabledButton"
- type="button"
- class="btn btn-success btn-sm"
- disabled="true">
- Merge
- </button>
- </div>
- `,
-};
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/states/mr_widget_archived.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js
deleted file mode 100644
index b4e4a6aa161..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetArchived',
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <div class="space-children">
- <status-icon status="failed" />
- <button
- type="button"
- class="btn btn-success btn-sm"
- disabled="true">
- Merge
- </button>
- </div>
- <div class="media-body">
- <span class="bold">
- This project is archived, write access has been disabled
- </span>
- </div>
- </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 5648208f7b1..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import eventHub from '../../event_hub';
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetAutoMergeFailed',
- props: {
- mr: { type: Object, required: true },
- },
- data() {
- return {
- isRefreshing: false,
- };
- },
- components: {
- statusIcon,
- },
- methods: {
- refreshWidget() {
- this.isRefreshing = true;
- eventHub.$emit('MRWidgetUpdateRequested', () => {
- this.isRefreshing = false;
- });
- },
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="failed" />
- <div class="media-body space-children">
- <span class="bold">
- <template v-if="mr.mergeError">{{mr.mergeError}}.</template>
- This merge request failed to be merged automatically
- </span>
- <button
- @click="refreshWidget"
- :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>
- </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 aaf9d3304a4..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetChecking',
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="loading" showDisabledButton />
- <div class="media-body space-children">
- <span class="bold">
- Checking ability to merge automatically
- </span>
- </div>
- </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 4078aad7f83..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import mrWidgetAuthorTime from '../../components/mr_widget_author_time';
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetClosed',
- props: {
- mr: { type: Object, required: true },
- },
- components: {
- 'mr-widget-author-and-time': mrWidgetAuthorTime,
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="failed" />
- <div class="media-body">
- <mr-widget-author-and-time
- actionText="Closed by"
- :author="mr.closedBy"
- :dateTitle="mr.updatedAt"
- :dateReadable="mr.closedAt"
- />
- <section class="mr-info-list">
- <p>
- The changes were not merged into
- <a
- :href="mr.targetBranchPath"
- class="label-branch">
- {{mr.targetBranch}}</a>
- </p>
- </section>
- </div>
- </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 f9cb79a0bc1..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetConflicts',
- props: {
- mr: { type: Object, required: true },
- },
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="failed" showDisabledButton />
- <div class="media-body space-children">
- <span class="bold">
- There are merge conflicts<span v-if="!mr.canMerge">.</span>
- <span v-if="!mr.canMerge">
- 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="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 1cb24549d53..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-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();
- }
- },
- },
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <template v-if="isRefreshing">
- <status-icon status="loading" />
- <span class="media-body bold js-refresh-label">
- Refreshing now
- </span>
- </template>
- <template v-else>
- <status-icon status="failed" showDisabledButton />
- <div class="media-body space-children">
- <span class="bold">
- <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>
- </span>
- <button
- @click="refresh"
- class="btn btn-default btn-xs js-refresh-button"
- type="button">
- Refresh now
- </button>
- </div>
- </template>
- </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_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 bdfd4d9667c..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js
+++ /dev/null
@@ -1,117 +0,0 @@
-/* global Flash */
-import statusIcon from '../mr_widget_status_icon';
-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,
- statusIcon,
- },
- 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 media">
- <status-icon status="success" />
- <div class="media-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">
- <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>
- 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>
- </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..de98a77be6f
--- /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.mergeResource.save(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 e452260a4d0..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js
+++ /dev/null
@@ -1,140 +0,0 @@
-/* global Flash */
-
-import mrWidgetAuthorTime from '../../components/mr_widget_author_time';
-import tooltip from '../../../vue_shared/directives/tooltip';
-import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
-import statusIcon from '../mr_widget_status_icon';
-import eventHub from '../../event_hub';
-
-export default {
- name: 'MRWidgetMerged',
- props: {
- mr: { type: Object, required: true },
- service: { type: Object, required: true },
- },
- data() {
- return {
- isMakingRequest: false,
- };
- },
- directives: {
- tooltip,
- },
- components: {
- 'mr-widget-author-and-time': mrWidgetAuthorTime,
- loadingIcon,
- statusIcon,
- },
- 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 media">
- <status-icon status="success" />
- <div class="media-body">
- <div class="space-children">
- <mr-widget-author-and-time
- actionText="Merged by"
- :author="mr.mergedBy"
- :dateTitle="mr.updatedAt"
- :dateReadable="mr.mergedAt" />
- <a
- v-if="mr.canRevertInCurrentMR"
- v-tooltip
- class="btn btn-close btn-xs"
- 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"
- v-tooltip
- class="btn btn-close btn-xs"
- data-method="post"
- :href="mr.revertInForkPath"
- title="Revert this merge request in a new merge request">
- Revert
- </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="Cherry-pick this merge request in a new merge request">
- Cherry-pick
- </a>
- <a
- v-else-if="mr.cherryPickInForkPath"
- v-tooltip
- class="btn btn-default btn-xs"
- data-method="post"
- :href="mr.cherryPickInForkPath"
- title="Cherry-pick this merge request in a new merge request">
- Cherry-pick
- </a>
- </div>
- <section class="mr-info-list">
- <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" class="space-children">
- <span>You can remove source branch now</span>
- <button
- @click="removeSourceBranch"
- :disabled="isMakingRequest"
- type="button"
- class="btn btn-xs btn-default js-remove-branch-button">
- Remove Source Branch
- </button>
- </p>
- <p v-if="shouldShowSourceBranchRemoving">
- <loading-icon inline />
- <span>The source branch is being removed</span>
- </p>
- </section>
- </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.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.js
deleted file mode 100644
index f6d1a4feeb2..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetMerging',
- props: {
- mr: { type: Object, required: true },
- },
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body mr-state-locked media">
- <status-icon status="loading" />
- <div class="media-body">
- <h4>
- This merge request is in the process of being merged
- </h4>
- <section class="mr-info-list">
- <p>
- The changes will be merged into
- <span class="label-branch">
- <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
- </span>
- </p>
- </section>
- </div>
- </div>
- `,
-};
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 9f0a359d01a..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-import tooltip from '../../../vue_shared/directives/tooltip';
-import mrWidgetMergeHelp from '../../components/mr_widget_merge_help';
-
-export default {
- name: 'MRWidgetMissingBranch',
- props: {
- mr: { type: Object, required: true },
- },
- directives: {
- tooltip,
- },
- components: {
- 'mr-widget-merge-help': mrWidgetMergeHelp,
- statusIcon,
- },
- computed: {
- missingBranchName() {
- return this.mr.sourceBranchRemoved ? 'source' : 'target';
- },
- message() {
- return `If the ${this.missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line`;
- },
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="failed" showDisabledButton />
- <div class="media-body space-children">
- <span class="bold js-branch-text">
- <span class="capitalize">
- {{missingBranchName}}
- </span> branch does not exist.
- Please restore it or use a different {{missingBranchName}} branch
- <i
- v-tooltip
- class="fa fa-question-circle"
- :title="message"
- :aria-label="message"></i>
- </span>
- </div>
- </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 797511d4e3a..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetNotAllowed',
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="success" showDisabledButton />
- <div class="media-body space-children">
- <span class="bold">
- Ready to be merged automatically.
- Ask someone with write access to this repository to merge this request
- </span>
- </div>
- </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_pipeline_blocked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js
deleted file mode 100644
index 167a0d4613a..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetPipelineBlocked',
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="failed" showDisabledButton />
- <div class="media-body space-children">
- <span class="bold">
- Pipeline blocked. The pipeline for this merge request requires a manual action to proceed
- </span>
- </div>
- </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 c5be9a0530a..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,4 +1,4 @@
-import statusIcon from '../mr_widget_status_icon';
+import statusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetPipelineBlocked',
@@ -7,7 +7,7 @@ export default {
},
template: `
<div class="mr-widget-body media">
- <status-icon status="failed" showDisabledButton />
+ <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
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 ad709da51ee..162f048aac7 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 statusIcon from '../mr_widget_status_icon';
+import MergeRequest from '../../../merge_request';
+import Flash from '../../../flash';
+import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub';
export default {
@@ -38,24 +39,40 @@ export default {
return this.useCommitMessageWithDescription ? withoutDesc : withDesc;
},
- mergeButtonClass() {
- const defaultClass = 'btn btn-sm 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';
@@ -84,13 +101,8 @@ export default {
},
},
methods: {
- isMergeAllowed() {
- return !this.mr.onlyAllowMergeIfPipelineSucceeds ||
- this.mr.isPipelinePassing ||
- this.mr.isPipelineSkipped;
- },
shouldShowMergeControls() {
- return this.isMergeAllowed() || this.shouldShowMergeWhenPipelineSucceedsText;
+ return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText;
},
updateCommitMessage() {
const cmwd = this.mr.commitMessageWithDescription;
@@ -124,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(() => {
@@ -148,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'
@@ -187,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
@@ -208,7 +219,7 @@ export default {
},
template: `
<div class="mr-widget-body media">
- <status-icon status="success" />
+ <status-icon :status="iconClass" />
<div class="media-body">
<div class="mr-widget-body-controls media space-children">
<span class="btn-group append-bottom-5">
@@ -216,7 +227,8 @@ export default {
@click="handleMergeButtonClick()"
:disabled="isMergeButtonDisabled"
:class="mergeButtonClass"
- type="button">
+ type="button"
+ class="qa-merge-button">
<i
v-if="isMakingRequest"
class="fa fa-spinner fa-spin"
@@ -274,6 +286,7 @@ export default {
<input
id="remove-source-branch-input"
v-model="removeSourceBranch"
+ class="js-remove-source-branch-checkbox"
:disabled="isRemoveSourceBranchButtonDisabled"
type="checkbox"/> Remove source branch
</label>
@@ -284,17 +297,23 @@ export default {
:mr="mr"
:is-merge-button-disabled="isMergeButtonDisabled" />
+ <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="btn btn-default btn-xs"
+ class="js-modify-commit-message-button btn btn-default btn-xs"
type="button">
Modify commit message
</button>
</template>
<template v-else>
- <span class="bold">
- The pipeline for this merge request has not succeeded yet
+ <span class="bold js-resolve-mr-widget-items-message">
+ You can only merge once the items above are resolved
</span>
</template>
</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
index 89f38e5bd2a..142ddf477f1 100644
--- 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
@@ -1,4 +1,4 @@
-import statusIcon from '../mr_widget_status_icon';
+import statusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetSHAMismatch',
@@ -7,7 +7,7 @@ export default {
},
template: `
<div class="mr-widget-body media">
- <status-icon status="failed" showDisabledButton />
+ <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
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
index d762ca6e640..67b271c69ca 100644
--- 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
@@ -1,4 +1,4 @@
-import statusIcon from '../mr_widget_status_icon';
+import statusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetUnresolvedDiscussions',
@@ -10,7 +10,7 @@ export default {
},
template: `
<div class="mr-widget-body media">
- <status-icon status="failed" showDisabledButton />
+ <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
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 b11a06899cf..bbca641f65e 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,5 +1,4 @@
-/* global Flash */
-import statusIcon from '../mr_widget_status_icon';
+import statusIcon from '../mr_widget_status_icon.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
@@ -24,21 +23,21 @@ export default {
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 media">
- <status-icon status="failed" :showDisabledButton="Boolean(mr.removeWIPPath)" />
+ <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
diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
index 49340c232c8..edb3baa39e4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js
+++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
@@ -11,29 +11,30 @@
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 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 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 MergingState } from './components/states/mr_widget_merging';
+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 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/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 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 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';
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 044b664484b..797f0f6ec0f 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,5 +1,6 @@
-/* global Flash */
-
+import Project from '~/pages/projects/project';
+import SmartInterval from '~/smart_interval';
+import Flash from '../flash';
import {
WidgetHeader,
WidgetMergeHelp,
@@ -9,6 +10,7 @@ import {
MergedState,
ClosedState,
MergingState,
+ RebaseState,
WipState,
ArchivedState,
ConflictsState,
@@ -61,7 +63,7 @@ export default {
return this.mr.hasCI;
},
shouldRenderRelatedLinks() {
- return this.mr.relatedLinks;
+ return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState;
},
shouldRenderDeployments() {
return this.mr.deployments.length;
@@ -78,27 +80,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);
+ 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,
@@ -107,7 +108,7 @@ export default {
});
},
initDeploymentsPolling() {
- this.deploymentsInterval = new gl.SmartInterval({
+ this.deploymentsInterval = new SmartInterval({
callback: this.fetchDeployments,
startingInterval: 30000,
maxInterval: 120000,
@@ -122,11 +123,11 @@ export default {
}
},
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(() => {
@@ -136,18 +137,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}`;
@@ -230,13 +231,17 @@ export default {
'mr-widget-pipeline-failed': PipelineFailedState,
'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState,
'mr-widget-auto-merge-failed': AutoMergeFailed,
+ 'mr-widget-rebase': RebaseState,
},
template: `
<div class="mr-state-widget prepend-top-default">
<mr-widget-header :mr="mr" />
<mr-widget-pipeline
v-if="shouldRenderPipelines"
- :mr="mr" />
+ :pipeline="mr.pipeline"
+ :ci-status="mr.ciStatus"
+ :has-ci="mr.hasCI"
+ />
<mr-widget-deployment
v-if="shouldRenderDeployments"
:mr="mr"
@@ -249,7 +254,8 @@ export default {
<mr-widget-related-links
v-if="shouldRenderRelatedLinks"
:state="mr.state"
- :related-links="mr.relatedLinks" />
+ :related-links="mr.relatedLinks"
+ />
</div>
<div
class="mr-widget-footer"
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 29464662578..9a750ce42bd 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,11 +39,8 @@ 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;
@@ -57,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;
@@ -73,6 +74,7 @@ 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;
// Cherry-pick and Revert actions related
@@ -118,27 +120,52 @@ export default class MergeRequestStore {
}
}
- static getAuthorObject(event) {
- if (!event) {
+ get isNothingToMergeState() {
+ return this.state === stateKey.nothingToMerge;
+ }
+
+ 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 {};
+ }
- if (!event) {
+ return {
+ name: user.name || '',
+ username: user.username || '',
+ webUrl: user.web_url || '',
+ avatarUrl: user.avatar_url || '',
+ };
+ }
+
+ 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 9074a064a6d..29d5bd4a1da 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
@@ -17,6 +17,7 @@ const stateToComponentMap = {
failedToMerge: 'mr-widget-failed-to-merge',
autoMergeFailed: 'mr-widget-auto-merge-failed',
shaMismatch: 'mr-widget-sha-mismatch',
+ rebase: 'mr-widget-rebase',
};
const statesToShowHelpWidget = [
@@ -29,8 +30,27 @@ 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',
+};
+
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/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..3b6c2da1664
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -0,0 +1,56 @@
+<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 btn-default btn-transparent btn-clipboard',
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ type="button"
+ :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 50d14282cad..97789636787 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -2,13 +2,21 @@
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,
@@ -63,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() {
@@ -80,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() {
@@ -101,12 +110,6 @@
this.author.username ? `${this.author.username}'s avatar` : null;
},
},
- directives: {
- tooltip,
- },
- components: {
- userAvatarLink,
- },
created() {
this.commitIconSvg = commitIconSvg;
},
@@ -114,46 +117,48 @@
</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"
- v-tooltip
- data-container="body"
- :title="commitRef.name">
- {{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"
@@ -164,8 +169,9 @@
/>
<a
class="commit-row-message"
- :href="commitUrl">
- {{title}}
+ :href="commitUrl"
+ >
+ {{ title }}
</a>
</span>
<span v-else>
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..c9d7c0f4999
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -0,0 +1,92 @@
+<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() {
+ // We don't have a open folder icon yet
+ return this.opened ? 'folder' : '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 d305bd6acdc..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,30 +101,35 @@ 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
@@ -124,8 +137,10 @@ export default {
:href="action.path"
data-method="post"
rel="nofollow"
- :class="action.cssClass">
- {{action.label}}
+ :class="action.cssClass"
+ :key="i"
+ >
+ {{ action.label }}
</a>
<button
@@ -133,25 +148,31 @@ export default {
@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
index 7cf2e029cf6..0a30f467b08 100644
--- a/app/assets/javascripts/vue_shared/components/identicon.vue
+++ b/app/assets/javascripts/vue_shared/components/identicon.vue
@@ -46,6 +46,6 @@ export default {
class="avatar identicon"
:class="sizeClass"
:style="identiconStyles">
- {{identiconTitle}}
+ {{ identiconTitle }}
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue
deleted file mode 100644
index 397d16331d5..00000000000
--- a/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue
+++ /dev/null
@@ -1,16 +0,0 @@
-<script>
- export default {
- name: 'confidentialIssueWarning',
- };
-</script>
-<template>
- <div class="confidential-issue-warning">
- <i
- aria-hidden="true"
- class="fa fa-eye-slash">
- </i>
- <span>
- This is a confidential issue. Your comment will not be visible to the public.
- </span>
- </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 15581d5c2a0..12a75e016d7 100644
--- a/app/assets/javascripts/vue_shared/components/loading_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue
@@ -32,13 +32,14 @@
</script>
<template>
<component
- :is="this.rootElementType"
- class="text-center">
+ :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>
</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 759d30c9c7c..d2e968a8419 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,9 +1,16 @@
<script>
- /* global Flash */
+ 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: {
markdownPreviewPath: {
type: String,
@@ -22,6 +29,17 @@
quickActionsDocsPath: {
type: String,
required: false,
+ default: '',
+ },
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
},
},
data() {
@@ -33,19 +51,29 @@
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;
+
+ this.previewMarkdown = true;
/*
Can't use `$refs` as the component is technically in the parent component
@@ -53,20 +81,22 @@
*/
const text = this.$slots.textarea[0].elm.value;
- if (!this.previewMarkdown) {
- this.markdownPreview = '';
- } else if (text) {
+ if (text) {
this.markdownPreviewLoading = true;
this.$http.post(this.markdownPreviewPath, { text })
.then(resp => resp.json())
- .then((data) => {
- this.renderMarkdown(data);
- })
+ .then(data => this.renderMarkdown(data))
.catch(() => new Flash('Error loading markdown preview'));
} else {
this.renderMarkdown();
}
},
+
+ showWriteTab() {
+ this.markdownPreview = '';
+ this.previewMarkdown = false;
+ },
+
renderMarkdown(data = {}) {
this.markdownPreviewLoading = false;
this.markdownPreview = data.body || 'Nothing to preview.';
@@ -81,18 +111,6 @@
});
},
},
- mounted() {
- /*
- GLForm class handles all the toolbar buttons
- */
- return new gl.GLForm($(this.$refs['gl-form']), true);
- },
- beforeDestroy() {
- const glForm = $(this.$refs['gl-form']).data('gl-form');
- if (glForm) {
- glForm.destroy();
- }
- },
};
</script>
@@ -103,33 +121,40 @@
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-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...
@@ -139,23 +164,27 @@
<div
v-if="referencedCommands"
v-html="referencedCommands"
- class="referenced-commands"></div>
+ 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>
+ 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..177d2cfc8da 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,38 +1,48 @@
<script>
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 +50,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 65fe7bbd94e..c0ee88bbf72 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -8,6 +8,17 @@
quickActionsDocsPath: {
type: String,
required: false,
+ default: '',
+ },
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ computed: {
+ hasQuickActionsDocsPath() {
+ return this.quickActionsDocsPath !== '';
},
},
};
@@ -16,75 +27,93 @@
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
- <template v-if="!quickActionsDocsPath && markdownDocsPath">
+ <template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
<a
:href="markdownDocsPath"
target="_blank"
- tabindex="-1">
+ tabindex="-1"
+ >
Markdown is supported
</a>
</template>
- <template v-if="quickActionsDocsPath && markdownDocsPath">
- <a
+ <template v-if="hasQuickActionsDocsPath && markdownDocsPath">
+ <a
:href="markdownDocsPath"
target="_blank"
- tabindex="-1">
+ tabindex="-1"
+ >
Markdown
</a>
and
- <a
+ <a
:href="quickActionsDocsPath"
target="_blank"
- tabindex="-1">
+ tabindex="-1"
+ >
quick actions
</a>
are supported
</template>
</div>
- <span class="uploading-container">
+ <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>
+ 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>
+ 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>
+ aria-hidden="true"
+ >
+ </i>
</span>
<span class="uploading-error-message"></span>
<button
class="retry-uploading-link"
- type="button">
- Try again
+ type="button"
+ >
+ Try again
</button>
or
<button
class="attach-new-file markdown-selector"
- type="button">
+ type="button"
+ >
attach a new file
</button>
</span>
<button
class="markdown-selector button-attach-file"
tabindex="-1"
- type="button">
+ type="button"
+ >
<i
class="fa fa-file-image-o toolbar-button-icon"
- aria-hidden="true"></i>
+ aria-hidden="true"
+ >
+ </i>
Attach a file
</button>
<button
class="btn btn-default btn-xs hide button-cancel-uploading-files"
- type="button">
+ type="button"
+ >
Cancel
</button>
</span>
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
index 643b77e04c7..f37ef1a5ca3 100644
--- a/app/assets/javascripts/vue_shared/components/memory_graph.js
+++ b/app/assets/javascripts/vue_shared/components/memory_graph.js
@@ -1,3 +1,5 @@
+import { getTimeago } from '../../lib/utils/datetime_utility';
+
export default {
name: 'MemoryGraph',
props: {
@@ -16,7 +18,7 @@ export default {
},
computed: {
getFormattedMedian() {
- const deployedSince = gl.utils.getTimeago().format(this.deploymentTime * 1000);
+ const deployedSince = getTimeago().format(this.deploymentTime * 1000);
return `Deployed ${deployedSince}`;
},
},
diff --git a/app/assets/javascripts/vue_shared/components/modal.vue b/app/assets/javascripts/vue_shared/components/modal.vue
new file mode 100644
index 00000000000..5f1364421aa
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/modal.vue
@@ -0,0 +1,173 @@
+<script>
+ /* eslint-disable vue/require-default-prop */
+ export default {
+ name: 'Modal',
+
+ 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/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
new file mode 100644
index 00000000000..63d8329e495
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
@@ -0,0 +1,75 @@
+<script>
+ /**
+ * 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/notes/components/issue_placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index 6921d91372f..50b1508691b 100644
--- a/app/assets/javascripts/notes/components/issue_placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -1,18 +1,35 @@
<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 '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+ import userAvatarLink from '../user_avatar/user_avatar_link.vue';
export default {
- name: 'issuePlaceholderNote',
+ name: 'PlaceholderNote',
+ components: {
+ userAvatarLink,
+ },
props: {
note: {
type: Object,
required: true,
},
},
- components: {
- userAvatarLink,
- },
computed: {
...mapGetters([
'getUserData',
@@ -29,7 +46,7 @@
:link-href="getUserData.path"
:img-src="getUserData.avatar_url"
:img-size="40"
- />
+ />
</div>
<div
:class="{ discussion: !note.individual_note }"
@@ -37,14 +54,14 @@
<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>
+ <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>
+ <p>{{ note.body }}</p>
</div>
</div>
</div>
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/notes/components/issue_system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 0cfb6522e77..aac10f84087 100644
--- a/app/assets/javascripts/notes/components/issue_system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -1,18 +1,36 @@
<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 issueNoteHeader from './issue_note_header.vue';
+ import noteHeader from '~/notes/components/note_header.vue';
+ import { spriteIcon } from '../../../lib/utils/common_utils';
export default {
- name: 'systemNote',
+ name: 'SystemNote',
+ components: {
+ noteHeader,
+ },
props: {
note: {
type: Object,
required: true,
},
},
- components: {
- issueNoteHeader,
- },
computed: {
...mapGetters([
'targetNoteHash',
@@ -24,7 +42,7 @@
return this.targetNoteHash === this.noteAnchorId;
},
iconHtml() {
- return gl.utils.spriteIcon(this.note.system_note_icon_name);
+ return spriteIcon(this.note.system_note_icon_name);
},
},
};
@@ -42,11 +60,12 @@
</div>
<div class="timeline-content">
<div class="note-header">
- <issue-note-header
+ <note-header
:author="note.author"
:created-at="note.created_at"
:note-id="note.id"
- :action-text-html="note.note_html" />
+ :action-text-html="note.note_html"
+ />
</div>
</div>
</div>
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/popup_dialog.vue b/app/assets/javascripts/vue_shared/components/popup_dialog.vue
deleted file mode 100644
index 994b33bc1c9..00000000000
--- a/app/assets/javascripts/vue_shared/components/popup_dialog.vue
+++ /dev/null
@@ -1,85 +0,0 @@
-<script>
-export default {
- name: 'popup-dialog',
-
- props: {
- title: {
- type: String,
- required: true,
- },
- body: {
- type: String,
- required: true,
- },
- kind: {
- type: String,
- required: false,
- default: 'primary',
- },
- closeButtonLabel: {
- type: String,
- required: false,
- default: 'Cancel',
- },
- primaryButtonLabel: {
- type: String,
- required: true,
- },
- },
-
- computed: {
- btnKindClass() {
- return {
- [`btn-${this.kind}`]: true,
- };
- },
- },
-
- methods: {
- close() {
- this.$emit('toggle', false);
- },
- emitSubmit(status) {
- this.$emit('submit', status);
- },
- },
-};
-</script>
-
-<template>
-<div
- class="modal popup-dialog"
- role="dialog"
- tabindex="-1">
- <div class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <button type="button"
- class="close"
- @click="close"
- aria-label="Close">
- <span aria-hidden="true">&times;</span>
- </button>
- <h4 class="modal-title">{{this.title}}</h4>
- </div>
- <div class="modal-body">
- <p>{{this.body}}</p>
- </div>
- <div class="modal-footer">
- <button
- type="button"
- class="btn btn-default"
- @click="emitSubmit(false)">
- {{closeButtonLabel}}
- </button>
- <button type="button"
- class="btn"
- :class="btnKindClass"
- @click="emitSubmit(true)">
- {{primaryButtonLabel}}
- </button>
- </div>
- </div>
- </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..c35621c9ef3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
@@ -0,0 +1,86 @@
+<script>
+ import modal from './modal.vue';
+
+ export default {
+ name: 'RecaptchaModal',
+
+ components: {
+ modal,
+ },
+
+ 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>
+ <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>
+ </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..c1dd4d42d9d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
@@ -0,0 +1,149 @@
+<script>
+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,
+ },
+ 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[]';
+ },
+ },
+ 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"
+ />
+ </div>
+ <dropdown-create-label
+ v-if="showCreate"
+ />
+ </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..4200d1e8473
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue
@@ -0,0 +1,84 @@
+<script>
+export default {
+ 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>
+ {{ __('Create new label') }}
+ <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..e951a863811
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue
@@ -0,0 +1,34 @@
+<script>
+export default {
+ props: {
+ labelsWebUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown-footer">
+ <ul class="dropdown-footer-list">
+ <li>
+ <a
+ href="#"
+ class="dropdown-toggle-page"
+ >
+ {{ __('Create new label') }}
+ </a>
+ </li>
+ <li>
+ <a
+ data-is-link="true"
+ class="dropdown-external-link"
+ :href="labelsWebUrl"
+ >
+ {{ __('Manage labels') }}
+ </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 c9dbc048345..22fc5757447 100644
--- a/app/assets/javascripts/vue_shared/components/table_pagination.vue
+++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue
@@ -1,133 +1,141 @@
<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) {
- if (e.target.parentElement.classList.contains('disabled')) return;
-
- 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, 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;
+ 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,
'js-previous-button': item.prev,
@@ -137,8 +145,11 @@ export default {
separator: item.separator,
active: item.active,
disabled: item.disabled
- }">
- <a @click.prevent="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/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/boards/models/label.js b/app/assets/javascripts/vue_shared/models/label.js
index 98c1ec014c4..70b9efe0c68 100644
--- a/app/assets/javascripts/boards/models/label.js
+++ b/app/assets/javascripts/vue_shared/models/label.js
@@ -1,7 +1,5 @@
-/* eslint-disable no-unused-vars, space-before-function-paren */
-
class ListLabel {
- constructor (obj) {
+ constructor(obj) {
this.id = obj.id;
this.title = obj.title;
this.type = obj.type;
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/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 99c7644e4d9..4592003f57e 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,5 +1,4 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, consistent-return, camelcase, comma-dangle, max-len, class-methods-use-this */
-/* global Mousetrap */
// Zen Mode (full screen) textarea
//
@@ -8,10 +7,10 @@
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
@@ -73,7 +72,7 @@ export default class ZenMode {
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();
+ this.active_textarea.focus();
}
exit() {
@@ -83,7 +82,11 @@ export default class ZenMode {
this.scrollTo(this.active_textarea);
this.active_textarea = null;
this.active_backdrop = null;
- return Dropzone.forElement('.div-dropzone').enable();
+
+ const $dropzone = $('.div-dropzone');
+ if ($dropzone && !$dropzone.hasClass('js-invalid-dropzone')) {
+ Dropzone.forElement('.div-dropzone').enable();
+ }
}
}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 923d14f2c3d..2fccfa4011c 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -5,8 +5,10 @@
@import "framework/layout";
@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";
@@ -19,7 +21,7 @@
@import "framework/flash";
@import "framework/forms";
@import "framework/gfm";
-@import "framework/gitlab-theme";
+@import "framework/gitlab_theme";
@import "framework/header";
@import "framework/highlight";
@import "framework/issue_box";
@@ -30,25 +32,33 @@
@import "framework/media_object";
@import "framework/mobile";
@import "framework/modal";
-@import "framework/nav";
@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";
-@import "framework/page-header";
+@import "framework/page_header";
@import "framework/awards";
@import "framework/images";
-@import "framework/broadcast-messages";
+@import "framework/broadcast_messages";
@import "framework/emojis";
-@import "framework/emoji-sprites";
+@import "framework/emoji_sprites";
@import "framework/icons";
@import "framework/snippets";
@import "framework/memory_graph";
-@import "framework/responsive-tables";
+@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 667b73e150d..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);
@@ -199,6 +198,13 @@ a {
height: 12px;
}
+ &.animation-container-right {
+ .skeleton-line-2 {
+ left: 0;
+ right: 150px;
+ }
+ }
+
&::before {
animation-duration: 1s;
animation-fill-mode: forwards;
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
index bdcbd4021b3..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); }
@@ -41,8 +42,7 @@
&.avatar-inline {
float: none;
display: inline-block;
- margin-left: 4px;
- margin-bottom: 2px;
+ margin-left: 2px;
flex-shrink: 0;
-webkit-flex-shrink: 0;
@@ -58,7 +58,7 @@
&.avatar-tile {
border-radius: 0;
- border: none;
+ border: 0;
}
&:not([href]):hover {
@@ -71,13 +71,14 @@
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; }
+ &.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; }
@@ -94,7 +95,7 @@
.avatar {
border-radius: 0;
- border: none;
+ border: 0;
height: auto;
width: 100%;
margin: 0;
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index bb30da4f4b2..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;
@@ -27,6 +28,10 @@
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%);
}
}
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 6bb096fc5bd..9982a5779af 100644
--- a/app/assets/stylesheets/framework/blank.scss
+++ b/app/assets/stylesheets/framework/blank.scss
@@ -7,29 +7,76 @@
width: 100%;
height: 100%;
padding-bottom: 25px;
- border: 1px solid $border-color;
border-radius: $border-radius-default;
}
}
-.blank-state {
- padding-top: 20px;
- padding-bottom: 20px;
+.blank-state-row {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-around;
+ height: 100%;
+}
+
+.blank-state-welcome {
text-align: center;
+ padding: 20px 0 40px;
+
+ .blank-state-welcome-title {
+ font-size: 24px;
+ }
+
+ .blank-state-text {
+ margin-bottom: 0;
+ }
+}
+
+.blank-state-link {
+ display: block;
+ color: $gl-text-color;
+ flex: 0 0 100%;
+ margin-bottom: 15px;
- &.blank-state-welcome {
- .blank-state-welcome-title {
- font-size: 24px;
+ @media (min-width: $screen-sm-min) {
+ flex: 0 0 49%;
+
+ &:nth-child(odd) {
+ margin-right: 5px;
}
- .blank-state-text {
- margin-bottom: 0;
+ &:nth-child(even) {
+ margin-left: 5px;
}
}
- .blank-state-icon {
- padding-bottom: 20px;
+ &:hover {
+ background-color: $gray-light;
+ text-decoration: none;
+ color: $gl-text-color;
+ }
+}
+.blank-state-center {
+ padding-top: 20px;
+ padding-bottom: 20px;
+ text-align: center;
+}
+
+.blank-state {
+ padding: 20px;
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
+
+ @media (min-width: $screen-sm-min) {
+ display: flex;
+ align-items: center;
+ padding: 50px 30px;
+ }
+}
+
+.blank-state,
+.blank-state-center {
+ .blank-state-icon {
svg {
display: block;
margin: auto;
@@ -38,13 +85,17 @@
.blank-state-title {
margin-top: 0;
- margin-bottom: 10px;
font-size: 18px;
}
- .blank-state-text {
- max-width: $container-text-max-width;
- margin: 0 auto $gl-padding;
- font-size: 14px;
+ .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 5c68059f485..c5c7afe25be 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -10,7 +10,6 @@
color: $gl-text-color;
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,6 +206,23 @@
&.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 {
@@ -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;
}
}
@@ -336,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 d178bc17462..6b89387ab5f 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -1,3 +1,25 @@
+@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;
@@ -66,17 +88,6 @@
border-color: $border-dark;
color: $color;
}
-
- svg {
-
- path {
- fill: $color;
- }
-
- use {
- stroke: $color;
- }
- }
}
@mixin btn-green {
@@ -120,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;
@@ -158,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 {
@@ -202,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;
@@ -221,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;
@@ -270,6 +295,12 @@
}
}
+.btn-align-content {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
.btn-group {
&.btn-grouped {
@include btn-with-margin;
@@ -277,7 +308,7 @@
}
.btn-clipboard {
- border: none;
+ border: 0;
padding: 0 5px;
}
@@ -380,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/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 706a9cffe87..ae517c41cb2 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -5,32 +5,40 @@
.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-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; }
+.text-plain,
+.text-plain:hover {
+ color: $gl-text-color;
+}
+
+.text-secondary {
+ color: $gl-text-color-secondary;
+}
+
+.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; }
@@ -53,7 +61,7 @@
pre {
&.clean {
background: none;
- border: none;
+ border: 0;
margin: 0;
padding: 0;
}
@@ -78,6 +86,14 @@ hr {
.str-truncated {
@include str-truncated;
+
+ &-60 {
+ @include str-truncated(60%);
+ }
+
+ &-100 {
+ @include str-truncated(100%);
+ }
}
.block-truncated {
@@ -103,10 +119,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 {
@@ -129,11 +152,6 @@ span.update-author {
}
}
-.user-mention {
- color: $user-mention-color;
- font-weight: $gl-font-weight-bold;
-}
-
.field_with_errors {
display: inline;
}
@@ -157,7 +175,7 @@ li.note {
img { max-width: 100%; }
.note-title {
li {
- border-bottom: none !important;
+ border-bottom: 0 !important;
}
}
}
@@ -202,7 +220,7 @@ li.note {
pre {
background: $white-light;
- border: none;
+ border: 0;
font-size: 12px;
}
}
@@ -219,19 +237,6 @@ li.note {
}
}
-.browser-alert {
- padding: 10px;
- text-align: center;
- background: $error-bg;
- color: $white-light;
- font-weight: $gl-font-weight-bold;
-
- a {
- color: $white-light;
- text-decoration: underline;
- }
-}
-
.warning_message {
border-left: 4px solid $warning-message-border;
color: $warning-message-color;
@@ -296,13 +301,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;
@@ -368,10 +366,11 @@ table {
.dropzone .dz-preview .dz-progress {
border-color: $border-color !important;
-}
-.dropzone .dz-preview .dz-progress .dz-upload {
- background: $gl-success !important;
+ .dz-upload {
+ background: $gl-success !important;
+ }
+
}
.dz-message {
@@ -407,7 +406,7 @@ table {
}
.hide-bottom-border {
- border-bottom: none !important;
+ border-bottom: 0 !important;
}
.gl-accessibility {
@@ -432,16 +431,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;
@@ -452,3 +441,33 @@ table {
pointer-events: none;
opacity: .5;
}
+
+/** 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/new_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 9c404b7e542..1acde98c3ae 100644
--- a/app/assets/stylesheets/new_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -1,30 +1,12 @@
-@import "framework/variables";
-@import 'framework/tw_bootstrap_variables';
-@import "bootstrap/variables";
-
-$active-background: rgba(0, 0, 0, .04);
-$active-hover-background: $active-background;
-$active-hover-color: $gl-text-color;
-$inactive-badge-background: rgba(0, 0, 0, .08);
-$hover-background: $white-light;
-$hover-color: $gl-text-color;
-$inactive-color: $gl-text-color-secondary;
-$new-sidebar-width: 220px;
-$new-sidebar-collapsed-width: 50px;
-
-.page-with-new-sidebar {
+.page-with-contextual-sidebar {
+ transition: padding-left $sidebar-transition-duration;
+
@media (min-width: $screen-md-min) {
- padding-left: $new-sidebar-collapsed-width;
+ padding-left: $contextual-sidebar-collapsed-width;
}
@media (min-width: $screen-lg-min) {
- padding-left: $new-sidebar-width;
- }
-
- // Override position: absolute
- .right-sidebar {
- position: fixed;
- height: calc(100% - #{$new-navbar-height});
+ padding-left: $contextual-sidebar-width;
}
.issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header {
@@ -34,15 +16,17 @@ $new-sidebar-collapsed-width: 50px;
.page-with-icon-sidebar {
@media (min-width: $screen-sm-min) {
- padding-left: $new-sidebar-collapsed-width;
+ 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;
@@ -52,14 +36,8 @@ $new-sidebar-collapsed-width: 50px;
&:hover,
a:hover {
- background-color: $hover-background;
- color: $hover-color;
-
- .settings-avatar {
- i {
- color: $hover-color;
- }
- }
+ background-color: $link-hover-background;
+ color: $gl-text-color;
}
.avatar-container {
@@ -76,37 +54,33 @@ $new-sidebar-collapsed-width: 50px;
.settings-avatar {
background-color: $white-light;
- i {
- font-size: 20px;
- width: 100%;
- color: $gl-text-color-secondary;
- text-align: center;
- align-self: center;
+ 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: $new-sidebar-width;
- transition: left $sidebar-transition-duration;
- top: $new-navbar-height;
+ width: $contextual-sidebar-width;
+ top: $header-height;
bottom: 0;
left: 0;
- background-color: $gray-normal;
+ background-color: $gray-light;
box-shadow: inset -2px 0 0 $border-color;
transform: translate3d(0, 0, 0);
- &:not(.sidebar-icons-only) {
+ &: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-icons-only {
- width: auto;
- min-width: $new-sidebar-collapsed-width;
+ &.sidebar-collapsed-desktop {
+ width: $contextual-sidebar-collapsed-width;
.nav-sidebar-inner-scroll {
overflow-x: hidden;
@@ -131,12 +105,11 @@ $new-sidebar-collapsed-width: 50px;
}
}
- &.nav-sidebar-expanded {
+ &.sidebar-expanded-mobile {
left: 0;
}
a {
- transition: none;
text-decoration: none;
}
@@ -149,44 +122,41 @@ $new-sidebar-collapsed-width: 50px;
white-space: nowrap;
a {
+ transition: padding $sidebar-transition-duration;
display: flex;
align-items: center;
- padding: 12px 16px;
- color: $inactive-color;
+ padding: 12px 15px;
+ color: $gl-text-color-secondary;
}
- svg {
- fill: $inactive-color;
+ .nav-item-name {
+ flex: 1;
}
- }
-
- .nav-item-name {
- flex: 1;
- }
- li.active {
- > a {
- font-weight: $gl-font-weight-bold;
+ &.active {
+ > a {
+ font-weight: $gl-font-weight-bold;
+ }
}
}
@media (max-width: $screen-xs-max) {
- left: (-$new-sidebar-width);
+ left: (-$contextual-sidebar-width);
}
.nav-icon-container {
display: flex;
margin-right: 8px;
-
- svg {
- height: 16px;
- width: 16px;
- }
}
.fly-out-top-item {
display: none;
}
+
+ svg {
+ height: 16px;
+ width: 16px;
+ }
}
.nav-sidebar-inner-scroll {
@@ -200,7 +170,7 @@ $new-sidebar-collapsed-width: 50px;
}
.with-performance-bar .nav-sidebar {
- top: $new-navbar-height + $performance-bar-height;
+ top: $header-height + $performance-bar-height;
}
.sidebar-sub-level-items {
@@ -213,8 +183,8 @@ $new-sidebar-collapsed-width: 50px;
&:hover,
&:focus {
- background: $active-hover-background;
- color: $active-hover-color;
+ background: $link-active-background;
+ color: $gl-text-color;
}
}
@@ -223,7 +193,7 @@ $new-sidebar-collapsed-width: 50px;
&,
&:hover,
&:focus {
- background: $active-background;
+ background: $link-active-background;
}
}
}
@@ -241,10 +211,6 @@ $new-sidebar-collapsed-width: 50px;
&:hover {
color: $gl-text-color;
-
- svg {
- fill: $gl-text-color;
- }
}
}
@@ -311,15 +277,16 @@ $new-sidebar-collapsed-width: 50px;
.badge {
background-color: $inactive-badge-background;
- color: $inactive-color;
+ color: $gl-text-color-secondary;
}
&.active {
- background: $active-background;
+ background: $link-active-background;
> a {
margin-left: 4px;
- padding-left: 12px;
+ // Subtract width of left border on active element
+ padding-left: 11px;
}
.badge {
@@ -333,7 +300,7 @@ $new-sidebar-collapsed-width: 50px;
&.active > a:hover,
&.is-over > a {
- background-color: $white-light;
+ background-color: $link-hover-background;
}
}
}
@@ -343,23 +310,24 @@ $new-sidebar-collapsed-width: 50px;
.toggle-sidebar-button,
.close-nav-button {
- width: $new-sidebar-width - 2px;
+ width: $contextual-sidebar-width - 2px;
+ transition: width $sidebar-transition-duration;
position: fixed;
bottom: 0;
- padding: 16px;
- background-color: $gray-normal;
+ 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;
- i {
- font-size: 20px;
+ svg {
margin-right: 8px;
}
- .fa-angle-double-right {
+ .icon-angle-double-right {
display: none;
}
@@ -375,25 +343,22 @@ $new-sidebar-collapsed-width: 50px;
}
}
+.collapse-text {
+ white-space: nowrap;
+ overflow: hidden;
+}
-.sidebar-icons-only {
+.sidebar-collapsed-desktop {
.context-header {
- height: 61px;
+ height: 60px;
+ width: $contextual-sidebar-collapsed-width;
a {
padding: 10px 4px;
}
}
- li a {
- padding: 12px 15px;
- }
-
.sidebar-top-level-items > li {
- &.active a {
- padding-left: 12px;
- }
-
.sidebar-sub-level-items {
&:not(.flyout-list) {
display: none;
@@ -406,16 +371,17 @@ $new-sidebar-collapsed-width: 50px;
}
.toggle-sidebar-button {
- width: $new-sidebar-collapsed-width - 2px;
- padding: 16px 18px;
+ padding: 16px;
+ width: $contextual-sidebar-collapsed-width - 2px;
.collapse-text,
- .fa-angle-double-left {
+ .icon-angle-double-left {
display: none;
}
- .fa-angle-double-right {
+ .icon-angle-double-right {
display: block;
+ margin: 0;
}
}
}
@@ -461,6 +427,13 @@ $new-sidebar-collapsed-width: 50px;
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) {
@@ -486,16 +459,13 @@ $new-sidebar-collapsed-width: 50px;
// Make issue boards full-height now that sub-nav is gone
.boards-list {
- height: calc(100vh - #{$new-navbar-height});
+ height: calc(100vh - #{$header-height});
@media (min-width: $screen-sm-min) {
- height: 475px; // Needed for PhantomJS
- // scss-lint:disable DuplicateProperty
height: calc(100vh - 180px);
- // scss-lint:enable DuplicateProperty
}
}
.with-performance-bar .boards-list {
- height: calc(100vh - #{$new-navbar-height} - #{$performance-bar-height});
+ height: calc(100vh - #{$header-height} - #{$performance-bar-height});
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 2bcd23a15e6..1d7b0b602cc 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 {
@include set-visible;
- display: block;
+ 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,20 +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;
- 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 {
@@ -165,6 +187,13 @@
&.dropdown-menu-user-link {
line-height: 16px;
+ padding-top: 10px;
+ padding-bottom: 7px;
+ white-space: nowrap;
+
+ .dropdown-menu-user-username {
+ display: block;
+ }
}
.icon-play {
@@ -178,7 +207,6 @@
.dropdown-menu,
.dropdown-menu-nav {
@include set-invisible;
- display: block;
position: absolute;
width: auto;
top: 100%;
@@ -186,8 +214,8 @@
z-index: 300;
min-width: 240px;
max-width: 500px;
- margin-top: 2px;
- margin-bottom: 2px;
+ margin-top: $dropdown-vertical-offset;
+ margin-bottom: 24px;
font-size: 14px;
font-weight: $gl-font-weight-normal;
padding: 8px 0;
@@ -196,6 +224,10 @@
border-radius: $border-radius-base;
box-shadow: 0 2px 4px $dropdown-shadow-color;
+ &.dropdown-open-top {
+ margin-bottom: $dropdown-vertical-offset;
+ }
+
&.dropdown-open-left {
right: 0;
left: auto;
@@ -226,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: 6px 0;
padding: 0;
background-color: $dropdown-divider-color;
+
+ &:hover {
+ background-color: $dropdown-divider-color;
+ }
}
.separator {
@@ -246,10 +289,6 @@
background-color: $dropdown-divider-color;
}
- a {
- @include dropdown-link;
- }
-
.dropdown-menu-empty-item a {
&:hover,
&:focus {
@@ -261,7 +300,7 @@
color: $gl-text-color-secondary;
font-size: 13px;
line-height: 22px;
- padding: 0 16px;
+ padding: 8px 16px;
}
&.capitalize-header .dropdown-header {
@@ -276,7 +315,7 @@
.separator + .dropdown-header,
.separator + .dropdown-bold-header {
- padding-top: 2px;
+ padding-top: 10px;
}
.unclickable {
@@ -297,48 +336,28 @@
}
.dropdown-menu li {
- padding: $gl-btn-padding;
cursor: pointer;
+ &.droplab-item-active button {
+ @include dropdown-item-hover;
+ }
+
> a,
> button {
display: flex;
margin: 0;
- padding: 0;
- border-radius: 0;
text-overflow: inherit;
- background-color: inherit;
- color: inherit;
- border: inherit;
text-align: left;
- &:hover,
- &:focus {
- background-color: inherit;
- color: inherit;
- }
-
&.btn .fa:not(:last-child) {
margin-left: 5px;
}
}
- &:hover,
- &:focus {
- background-color: $dropdown-hover-color;
- color: $white-light;
- }
-
&.droplab-item-selected i {
visibility: visible;
}
- &.divider {
- margin: 0 8px;
- padding: 0;
- border-top: $gray-darkest;
- }
-
.icon {
visibility: hidden;
}
@@ -430,11 +449,6 @@
}
}
-.dropdown-menu-user-link {
- padding-top: 10px;
- padding-bottom: 7px;
-}
-
.dropdown-menu-user-full-name {
display: block;
font-weight: $gl-font-weight-normal;
@@ -463,41 +477,44 @@
.dropdown-menu-align-right {
left: auto;
right: 0;
- margin-top: -5px;
}
.dropdown-menu-selectable {
- a {
- padding-left: 26px;
- position: relative;
+ 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;
+ }
- &.is-indeterminate,
- &.is-active {
- font-weight: $gl-font-weight-bold;
- 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;
+ &.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";
+ }
}
}
}
@@ -607,7 +624,7 @@
}
.dropdown-content {
- max-height: 215px;
+ max-height: $dropdown-max-height;
overflow-y: auto;
}
@@ -644,6 +661,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;
@@ -709,10 +736,6 @@
}
}
-.droplab-item-ignore {
- pointer-events: none;
-}
-
.pika-single.animate-picker.is-bound,
.pika-single.animate-picker.is-bound.is-hidden {
/*
@@ -727,124 +750,10 @@
.pika-single.animate-picker.is-bound {
@include set-visible;
-}
-
-.pika-single.animate-picker.is-bound.is-hidden {
- @include set-invisible;
- overflow: hidden;
-}
-
-@mixin dropdown-item-hover {
- background-color: $dropdown-item-hover-bg;
- color: $gl-text-color;
-}
-
-// TODO: change global style and remove mixin
-@mixin new-style-dropdown($selector: '') {
- #{$selector}.dropdown-menu,
- #{$selector}.dropdown-menu-nav {
- margin-bottom: 24px;
-
- li {
- display: block;
- padding: 0 1px;
-
- &:hover {
- background-color: transparent;
- }
-
- &.divider {
- margin: 6px 0;
-
- &:hover {
- background-color: $dropdown-divider-color;
- }
- }
-
- &.dropdown-header {
- padding: 8px 16px;
- }
-
- &.droplab-item-active button {
- @include dropdown-item-hover;
- }
-
- a,
- button,
- .menu-item {
- border-radius: 0;
- box-shadow: none;
- padding: 8px 16px;
- text-align: left;
- white-space: normal;
- width: 100%;
-
- // make sure the text color is not overriden
- &.text-danger {
- color: $brand-danger;
- }
-
- &.is-focused,
- &:hover,
- &:active,
- &:focus {
- @include dropdown-item-hover;
-
- background-color: $dropdown-item-hover-bg;
- color: $gl-text-color;
-
- // make sure the text color is not overriden
- &.text-danger {
- color: $brand-danger;
- }
- }
-
- &.is-active {
- font-weight: inherit;
-
- &::before {
- top: 16px;
- }
-
- &.dropdown-menu-user-link::before {
- top: 50%;
- transform: translateY(-50%);
- }
- }
- }
-
- &.dropdown-menu-empty-item a {
- &:hover,
- &:focus {
- background-color: transparent;
- }
- }
- }
- &.dropdown-menu-selectable {
- li {
- a {
- padding: 8px 40px;
-
- &.is-active::before {
- left: 16px;
- }
- }
- }
- }
- }
-
- #{$selector}.dropdown-menu-align-right {
- margin-top: 2px;
- }
-
- .open {
- #{$selector}.dropdown-menu,
- #{$selector}.dropdown-menu-nav {
- @media (max-width: $screen-xs-max) {
- max-width: 100%;
- }
- }
+ &.is-hidden {
+ @include set-invisible;
+ overflow: hidden;
}
}
@@ -865,12 +774,16 @@
min-width: 100%;
}
}
-}
-@include new-style-dropdown('.breadcrumbs-list .dropdown ');
-@include new-style-dropdown('.js-namespace-select + ');
+ header.navbar-gitlab-new .header-content .dropdown {
+ .dropdown-menu {
+ left: 0;
+ min-width: 100%;
+ }
+ }
+}
-header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu {
+header.header-content .dropdown-menu.projects-dropdown-menu {
padding: 0;
}
@@ -915,9 +828,7 @@ header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu {
border-right: 0;
}
}
-}
-.projects-dropdown-container {
.projects-list-frequent-container,
.projects-list-search-container, {
padding: 8px 0;
@@ -928,11 +839,6 @@ header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu {
.projects-list-frequent-container li.section-empty,
.projects-list-search-container li.section-empty {
padding: 0 15px;
- }
-
- .section-header,
- .projects-list-frequent-container li.section-empty,
- .projects-list-search-container li.section-empty {
color: $gl-text-color-secondary;
font-size: $gl-font-size;
}
@@ -985,6 +891,7 @@ header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
+ white-space: nowrap;
}
&:hover {
@@ -1012,3 +919,28 @@ header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu {
}
}
}
+
+.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/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 588ec1ff3bc..d835d49d8b2 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -10,18 +10,17 @@
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;
-
- @media (min-width: $screen-md-min) {
- padding-top: 64px;
- padding-bottom: 64px;
- }
}
}
@@ -97,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%);
}
}
}
@@ -124,7 +123,11 @@
}
&.wiki {
- padding: 30px $gl-padding;
+ padding: $gl-padding;
+
+ @media (min-width: $screen-md-min) {
+ padding: $gl-padding * 2;
+ }
}
&.blob-no-preview {
@@ -138,7 +141,7 @@
*/
&.blame {
table {
- border: none;
+ border: 0;
margin: 0;
}
@@ -146,65 +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;
- max-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;
+ .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;
+ }
}
- .item-title {
- flex: 1;
- margin-right: 0.5em;
+ &.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 {
@@ -360,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 {
@@ -380,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 b2847c348eb..621a4adc0cb 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -50,8 +50,6 @@
}
.filtered-search-wrapper {
- @include new-style-dropdown;
-
display: -webkit-flex;
display: flex;
@@ -65,7 +63,7 @@
display: flex;
flex: 1;
-webkit-flex: 1;
- padding-left: 30px;
+ padding-left: 12px;
position: relative;
margin-bottom: 0;
}
@@ -165,16 +163,6 @@
}
}
-.droplab-dropdown li.filtered-search-token {
- padding: 0;
-
- &:hover,
- &:focus {
- background-color: inherit;
- color: inherit;
- }
-}
-
.filtered-search-term {
.name {
background-color: inherit;
@@ -221,10 +209,6 @@
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;
@@ -251,13 +235,6 @@
}
}
- .fa-filter {
- position: absolute;
- top: 10px;
- left: 10px;
- color: $gray-darkest;
- }
-
.fa-times {
right: 10px;
color: $gray-darkest;
@@ -266,7 +243,7 @@
.clear-search {
width: 35px;
background-color: $white-light;
- border: none;
+ border: 0;
outline: none;
z-index: 1;
@@ -279,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: 260px;
+ max-height: $dropdown-max-height;
max-width: 280px;
overflow: auto;
@@ -322,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;
}
@@ -358,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 {
@@ -424,24 +381,9 @@
}
}
-%filter-dropdown-item-btn-hover {
- text-decoration: none;
- outline: 0;
-
- .avatar {
- border-color: $white-light;
- }
-}
-
.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;
@@ -454,11 +396,7 @@
border-width: 1px;
width: 17px;
height: 17px;
- }
-
- &:hover,
- &:focus {
- @extend %filter-dropdown-item-btn-hover;
+ top: 0;
}
}
@@ -482,15 +420,7 @@
}
}
-.filter-dropdown-item.droplab-item-active .btn {
- @extend %filter-dropdown-item-btn-hover;
-}
-
.filter-dropdown-loading {
padding: 8px 16px;
text-align: center;
}
-
-.issues-details-filters {
- @include new-style-dropdown;
-}
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index e1b086ebb2b..88ce119ee3a 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -34,8 +34,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 +55,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 be96c8ee964..2c30311b1c1 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -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
index f844d6f1d5a..db36e27fa74 100644
--- a/app/assets/stylesheets/framework/gitlab-theme.scss
+++ b/app/assets/stylesheets/framework/gitlab_theme.scss
@@ -5,8 +5,8 @@
@mixin gitlab-theme($color-100, $color-200, $color-500, $color-700, $color-800, $color-900, $color-alternate) {
// Header
- header.navbar-gitlab-new {
- background: linear-gradient(to right, $color-900, $color-800);
+ .navbar-gitlab {
+ background-color: $color-900;
.navbar-collapse {
color: $color-200;
@@ -30,10 +30,6 @@
&.dropdown.open > a {
color: $color-900;
background-color: $color-alternate;
-
- svg {
- fill: currentColor;
- }
}
&.line-separator {
@@ -51,10 +47,6 @@
color: $color-200;
> a {
- svg {
- fill: $color-200;
- }
-
&.header-user-dropdown-toggle {
.header-user-avatar {
border-color: $color-200;
@@ -95,7 +87,7 @@
}
}
- .title {
+ .navbar .title {
> a {
&:hover,
&:focus {
@@ -126,7 +118,7 @@
.search-input-wrap {
.search-icon,
.clear-icon {
- color: rgba($color-200, .8);
+ fill: rgba($color-200, .8);
}
}
@@ -141,7 +133,7 @@
.search-input-wrap {
.search-icon {
- color: rgba($color-200, .8);
+ fill: rgba($color-200, .8);
}
}
}
@@ -200,9 +192,9 @@ body {
&.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);
- header.navbar-gitlab-new {
- background: $theme-gray-100;
- box-shadow: 0 2px 0 0 $border-color;
+ .navbar-gitlab {
+ background-color: $theme-gray-100;
+ box-shadow: 0 1px 0 0 $border-color;
.logo-text svg {
fill: $theme-gray-900;
@@ -216,12 +208,9 @@ body {
color: $theme-gray-900;
}
- &.active > a {
+ &.active > a,
+ &.active > a:hover {
color: $white-light;
-
- &:hover {
- color: $white-light;
- }
}
}
}
@@ -242,17 +231,21 @@ body {
&:hover {
background-color: $white-light;
- box-shadow: inset 0 0 0 1px $blue-100;
+ box-shadow: inset 0 0 0 1px $blue-200;
.location-badge {
- box-shadow: inset 0 0 0 1px $blue-100;
+ box-shadow: inset 0 0 0 1px $blue-200;
}
}
}
.search-input-wrap {
.search-icon {
- color: $theme-gray-200;
+ fill: $theme-gray-200;
+ }
+
+ .search-input {
+ color: $gl-text-color;
}
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index ab3c34df1fb..634593aefd0 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -1,195 +1,152 @@
-/*
- * Application Header
- *
- */
-
-header {
- @include new-style-dropdown;
-
- transition: padding $sidebar-transition-duration;
-
- &.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;
- }
- }
- }
-
+.navbar-gitlab {
&.navbar-gitlab {
padding: 0 16px;
z-index: 1000;
margin-bottom: 0;
min-height: $header-height;
- background-color: $gray-light;
- border: none;
+ border: 0;
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;
- }
+ .logo-text {
+ line-height: initial;
- &.with-horizontal-nav {
- border-bottom: 0;
-
- .navbar-border {
- height: 1px;
- position: absolute;
- right: 0;
- left: 0;
- bottom: -1px;
- background-color: $border-color;
- opacity: 0;
+ svg {
+ width: 55px;
+ height: 14px;
+ margin: 0;
+ fill: $white-light;
}
}
.container-fluid {
- width: 100% !important;
- filter: none;
padding: 0;
- .nav > li > a {
- color: currentColor;
- font-size: 18px;
- padding: 0;
- margin: (($header-height - 28) / 2) 3px;
- margin-left: 8px;
- 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);
- }
- }
- }
-
- &:hover,
- &:focus,
- &:active {
- background-color: transparent;
- color: $gl-text-color;
-
- svg {
- fill: $gl-text-color;
- }
- }
-
- .fa-caret-down {
- font-size: 14px;
- }
-
- .fa-chevron-down {
- position: relative;
- top: -3px;
- font-size: 10px;
- }
- }
-
.user-counter {
svg {
- height: 16px;
- width: 23px;
+ margin-right: 3px;
}
}
.navbar-toggle {
- color: $nav-toggle-gray;
- margin: 5px 0;
- border-radius: 0;
right: -10px;
- padding: 6px 10px;
+ border-radius: 0;
+ min-width: 45px;
+ padding: 0;
+ margin-right: -7px;
+ font-size: 14px;
+ text-align: center;
+ color: currentColor;
- &:hover {
- background-color: $white-normal;
+ &:hover,
+ &:focus,
+ &.active {
+ color: currentColor;
+ background-color: transparent;
}
- &.active {
- color: $gl-text-color-secondary;
+ .more-icon,
+ .close-icon {
+ fill: $white-light;
+ margin: auto;
}
}
}
}
- &.navbar-gitlab-new {
- .fa-times {
+ .close-icon {
+ display: none;
+ }
+
+ .menu-expanded {
+ .more-icon {
display: none;
}
- .menu-expanded {
- .fa-ellipsis-v {
- display: none;
- }
-
- .fa-times {
- display: block;
- }
+ .close-icon {
+ display: block;
}
}
- .global-dropdown {
- position: absolute;
- left: -10px;
+ .header-content {
+ display: -webkit-flex;
+ display: flex;
+ justify-content: space-between;
+ position: relative;
+ min-height: $header-height;
+ padding-left: 0;
- .badge {
- font-size: 11px;
+ .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;
}
- li {
- &.active a {
- font-weight: $gl-font-weight-bold;
+ .title {
+ padding-right: 0;
+ color: currentColor;
+ display: -webkit-flex;
+ display: flex;
+ position: relative;
+ margin: 0;
+ font-size: 18px;
+ vertical-align: top;
+ white-space: nowrap;
+
+ img {
+ height: 28px;
+
+ + .logo-text {
+ margin-left: 8px;
+ }
+ }
+
+ &.wrap {
+ white-space: normal;
}
- }
- }
- .global-dropdown-toggle {
- margin: 7px 0;
- font-size: 18px;
- padding: 6px 10px;
- border: none;
- background-color: $gray-light;
+ &.initializing {
+ opacity: 0;
+ }
- &:hover {
- background-color: $white-normal;
- }
+ 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;
+ }
+ }
+ }
- &:focus {
- outline: none;
- background-color: $white-normal;
+ .project-item-select {
+ right: auto;
+ left: 0;
+ }
}
- }
- .header-content {
- display: flex;
- justify-content: space-between;
- position: relative;
- min-height: $header-height;
- padding-left: 30px;
+ .dropdown.open {
+ > a {
+ border-bottom-color: $white-light;
+ }
+ }
&.menu-expanded {
@media (max-width: $screen-xs-max) {
- .header-logo,
.title-container {
display: none;
}
@@ -199,112 +156,162 @@ header {
}
}
}
+ }
- .dropdown-menu {
- margin-top: -5px;
- }
+ li.dropdown-bold-header {
+ color: $gl-text-color-secondary;
+ font-size: 12px;
+ padding: 0 16px;
+ }
- .header-logo {
- display: inline-block;
- margin: 0 12px 0 2px;
- position: relative;
- top: 10px;
- transition-duration: .3s;
+ .navbar-collapse {
+ flex: 0 0 auto;
+ border-top: 0;
+ padding: 0;
- svg,
- img {
- height: 28px;
- }
+ @media (max-width: $screen-xs-max) {
+ flex: 1 1 auto;
+ }
- &:hover {
- cursor: pointer;
+ .nav {
+ > li:not(.hidden-xs) a {
+ @media (max-width: $screen-xs-max) {
+ margin-left: 0;
+ min-width: 100%;
+ }
}
}
+ }
- .group-name-toggle {
- margin: 3px 5px;
- }
+ .container-fluid {
- .group-title {
- &.is-hidden {
- .hidable:not(:last-of-type) {
- display: none;
+ .navbar-nav {
+ @media (max-width: $screen-xs-max) {
+ display: -webkit-flex;
+ display: flex;
+ padding-right: 10px;
+ }
+
+ 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: $gl-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;
+ .header-new-dropdown-toggle {
+ margin-right: 0;
+ }
- &:hover {
- color: $gl-header-nav-hover-color;
- }
+ .impersonated-user,
+ .impersonated-user:hover {
+ margin-right: 1px;
+ background-color: $white-light;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
}
- .project-item-select {
- right: auto;
- left: 0;
+ .impersonation-btn,
+ .impersonation-btn:hover {
+ background-color: $white-light;
+ margin-left: 0;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+
+ i {
+ color: $orange-500;
+ font-size: 20px;
+ }
}
}
+ }
+}
- .navbar-collapse {
- flex: 0 0 auto;
- border-top: none;
- padding: 0;
+.navbar-sub-nav,
+.navbar-nav {
+ > li {
+ > a:hover,
+ > a:focus {
+ text-decoration: none;
+ outline: 0;
+ color: $white-light;
+ }
- @media (max-width: $screen-xs-max) {
- flex: 1 1 auto;
- }
+ > 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;
@@ -315,8 +322,137 @@ header {
}
}
-.with-performance-bar header.navbar-gitlab {
- top: $performance-bar-height;
+.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: 48px;
+ 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 {
@@ -348,11 +484,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;
@@ -360,7 +495,8 @@ header {
}
.navbar-collapse {
- padding-left: 5px;
+ margin-left: -8px;
+ margin-right: -10px;
.nav > li:not(.hidden-xs) {
display: table-cell !important;
@@ -385,12 +521,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 {
@@ -406,3 +542,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 c63114f85b4..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;
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 60d61c68d63..2d015ef086b 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -19,6 +19,16 @@
max-width: 425px;
width: 100%;
}
+
+ $image-widths: 250 306 394 430;
+ @each $width in $image-widths {
+ &.svg-#{$width} {
+ img,
+ svg {
+ width: #{$width + 'px'};
+ }
+ }
+ }
}
@mixin svg-size($size) {
@@ -27,7 +37,12 @@
}
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); }
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 bd521028c44..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,34 +89,30 @@ 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;
+ }
+ }
+ }
}
}
-.page-with-sidebar > .content-wrapper {
- min-height: calc(100vh - #{$header-height});
-}
-
-.with-performance-bar .page-with-sidebar {
+.with-performance-bar .layout-page {
margin-top: $header-height + $performance-bar-height;
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 0fb19344510..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
@@ -125,15 +126,11 @@ ul.content-list {
}
.description {
- p {
- @include str-truncated;
- margin-bottom: 0;
- }
+ @include str-truncated;
+ color: $gl-text-color-secondary;
}
.controls {
- @include new-style-dropdown;
-
float: right;
> .control-text {
@@ -178,7 +175,7 @@ ul.content-list {
// When dragging a list item
&.ui-sortable-helper {
- border-bottom: none;
+ border-bottom: 0;
}
&.list-placeholder {
@@ -229,6 +226,10 @@ ul.content-list {
.label-default {
color: $gl-text-color-secondary;
}
+
+ .avatar-cell {
+ align-self: flex-start;
+ }
}
.panel > .content-list > li {
@@ -254,8 +255,6 @@ ul.controls {
}
.author_link {
- display: inline-block;
-
.avatar-inline {
margin-left: 0;
margin-right: 0;
@@ -277,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;
@@ -289,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;
}
}
@@ -347,12 +402,23 @@ 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) {
@@ -374,6 +440,78 @@ ul.indent-list {
.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 e3920b5d3d9..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;
@@ -171,23 +220,11 @@
}
ul > li {
+ @include clearfix;
white-space: nowrap;
}
-}
-
-@media(max-width: $screen-xs-max) {
- .atwho-view-ul {
- width: 350px;
- }
-
- .atwho-view ul li {
- overflow: hidden;
- text-overflow: ellipsis;
- }
-}
-// TODO: fallback to global style
-.atwho-view {
+ // TODO: fallback to global style
.atwho-view-ul {
padding: 8px 1px;
@@ -220,3 +257,14 @@
}
}
}
+
+@media (max-width: $screen-xs-max) {
+ .atwho-view-ul {
+ width: 350px;
+ }
+
+ .atwho-view ul li {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index d40b65bb2cc..ddd9dbb2be4 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -17,6 +17,8 @@
*/
@mixin markdown-table {
width: auto;
+ display: block;
+ overflow-x: auto;
}
/*
@@ -36,7 +38,7 @@
margin: 0;
&:last-child {
- border-bottom: none;
+ border-bottom: 0;
}
&.active {
@@ -130,17 +132,73 @@
background-color: $color-light;
color: $color-dark;
border-color: $color-dark;
+ }
+}
- svg {
- fill: $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;
}
- svg {
- fill: $color-main;
+ .fa {
+ position: relative;
+ top: 5px;
+ font-size: 18px;
}
}
-@mixin green-status-color {
- @include status-color($green-50, $green-500, $green-700);
+@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 5b581780447..a6b1bf9b099 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -1,10 +1,27 @@
+.modal-header {
+ background-color: $modal-body-bg;
+ padding: #{3 * $grid-size} #{2 * $grid-size};
+
+ .page-title {
+ margin-top: 0;
+
+ .color-label {
+ font-size: $gl-font-size;
+ padding: $gl-vert-padding $label-padding-modal;
+ }
+ }
+}
+
.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 {
@@ -12,6 +29,30 @@
}
}
+.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;
+ }
+ }
+}
+
body.modal-open {
overflow: hidden;
}
@@ -24,14 +65,27 @@ body.modal-open {
}
}
-@media (min-width: $screen-md-min) {
- .modal-dialog {
- width: 860px;
- }
-}
-
@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/page-header.scss b/app/assets/stylesheets/framework/page_header.scss
index 0c879f40930..0c879f40930 100644
--- a/app/assets/stylesheets/framework/page-header.scss
+++ b/app/assets/stylesheets/framework/page_header.scss
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
index 8e653c443cf..7829d722560 100644
--- a/app/assets/stylesheets/framework/responsive-tables.scss
+++ b/app/assets/stylesheets/framework/responsive_tables.scss
@@ -3,57 +3,77 @@
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) {
- padding: 15px 0;
margin: 0;
- display: flex;
- align-items: center;
- border: none;
- border-bottom: 1px solid $white-normal;
+ padding: $gl-padding 0;
+ border: 0;
+
+ &:not(:last-child) {
+ border-bottom: 1px solid $white-normal;
+ }
}
+}
- .table-section {
- white-space: nowrap;
+.gl-responsive-table-row-col-span {
+ flex-wrap: wrap;
+}
+
+.table-section {
+ white-space: nowrap;
- $section-widths: 10 15 20 25 30 40;
- @each $width in $section-widths {
- &.section-#{$width} {
- flex: 0 0 #{$width + '%'};
+ $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 (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;
+ @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;
- }
- }
+ &:not(:first-child) {
+ border-top: 1px solid $white-normal;
}
+ }
- &.section-wrap {
- white-space: normal;
+ &.section-wrap {
+ white-space: normal;
- @media (max-width: $screen-sm-max) {
- flex-wrap: wrap;
- }
+ @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) {
@@ -61,12 +81,13 @@
}
@media (max-width: $screen-sm-max) {
- background-color: $gray-normal;
+ display: block;
align-self: stretch;
+ min-height: 0;
+ background-color: $gray-normal;
border-top: 1px solid $border-color;
.table-action-buttons {
- padding: 10px 5px;
display: flex;
.btn {
@@ -77,7 +98,14 @@
> .external-url,
> .btn {
flex: 1 1 28px;
- margin: 0 5px;
+
+ &:not(:first-child) {
+ margin-left: 5px;
+ }
+
+ &:not(:last-child) {
+ margin-right: 5px;
+ }
}
.dropdown-new {
diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 2f7717760ec..17c31d6b184 100644
--- a/app/assets/stylesheets/framework/nav.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -1,39 +1,5 @@
-@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;
- }
-}
-
+// For tabbed navigation links, scrolling tabs, etc. For all top/main navigation,
+// please check nav.scss
.nav-links {
display: flex;
padding: 0;
@@ -58,8 +24,8 @@
&:active,
&:focus {
text-decoration: none;
- border-bottom: 2px solid $gray-darkest;
color: $black;
+ border-bottom: 2px solid $gray-darkest;
.badge {
color: $black;
@@ -68,7 +34,6 @@
}
&.active a {
- border-bottom: 2px solid $link-underline-blue;
color: $black;
font-weight: $gl-font-weight-bold;
@@ -77,35 +42,6 @@
}
}
}
-
- &.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 {
@@ -125,20 +61,9 @@
}
}
- .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;
+ border-bottom: 0;
float: left;
&.wide {
@@ -157,12 +82,14 @@
/* 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 {
- @include new-style-dropdown;
-
display: inline-block;
float: right;
text-align: right;
@@ -184,12 +111,6 @@
}
}
- &.nav-controls-new-nav {
- > .dropdown {
- margin-right: 0;
- }
- }
-
> .btn-grouped {
float: none;
}
@@ -251,9 +172,9 @@
display: inline-block;
}
- // Applies on /dashboard/issues
.project-item-select-holder {
margin: 0;
+ width: 100%;
}
}
}
@@ -282,114 +203,43 @@
pre {
width: 100%;
}
-}
-
-.project-item-select-holder.btn-group {
- display: flex;
- max-width: 350px;
- 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;
-}
-.layout-nav {
- width: 100%;
- background: $gray-light;
- border-bottom: 1px solid $border-color;
- transition: padding $sidebar-transition-duration;
- text-align: center;
- margin-top: $new-navbar-height;
+ @media (max-width: $screen-xs-max) {
+ flex-flow: row wrap;
- .container-fluid {
- position: relative;
+ .nav-controls {
+ $controls-margin: $btn-xs-side-margin - 2px;
+ flex: 0 0 100%;
- .nav-control {
- @media (max-width: $screen-sm-max) {
- margin-right: 2px;
+ &.controls-flex {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: center;
+ padding: 0 0 $gl-padding-top;
}
- }
- }
-
- .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: 300;
+ .controls-item,
+ .controls-item-full,
+ .controls-item:last-child {
+ flex: 1 1 35%;
+ display: block;
+ width: 100%;
+ margin: $controls-margin;
- li.active {
- font-weight: $gl-font-weight-bold;
+ .btn,
+ .dropdown {
+ margin: 0;
+ }
}
- }
- }
- .nav-links {
- border-bottom: none;
- height: 51px;
-
- @media (min-width: $screen-sm-min) {
- justify-content: center;
- }
-
- li {
- a {
- padding-top: 10px;
+ .controls-item-full {
+ flex: 1 1 100%;
}
}
}
}
-.with-performance-bar .layout-nav {
- margin-top: $header-height + $performance-bar-height;
-}
-
.scrolling-tabs-container {
position: relative;
@@ -419,25 +269,41 @@
left: -7px;
}
}
+}
- &.sub-nav-scroll {
+.inner-page-scroll-tabs {
+ position: relative;
- .fade-right {
- @include fade(left, $gray-normal);
- right: 0;
+ .fade-right {
+ @include fade(left, $white-light);
+ right: 0;
+ text-align: right;
- .fa {
- right: -23px;
- }
+ .fa {
+ right: 5px;
}
+ }
- .fade-left {
- @include fade(right, $gray-normal);
- left: 0;
+ .fade-left {
+ @include fade(right, $white-light);
+ left: 0;
+ text-align: left;
- .fa {
- left: 10px;
- }
+ .fa {
+ left: 5px;
+ }
+ }
+
+ .fade-right,
+ .fade-left {
+ top: 16px;
+ bottom: auto;
+ }
+
+ &.is-smaller {
+ .fade-right,
+ .fade-left {
+ top: 11px;
}
}
}
@@ -466,120 +332,48 @@
}
}
}
-}
-
-.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;
- }
- }
- }
-}
-
-.with-performance-bar .page-with-layout-nav {
- .right-sidebar {
- top: ($header-height + 1) * 2 + $performance-bar-height;
- }
-
- &.page-with-sub-nav {
- .right-sidebar {
- top: ($header-height + 1) * 3 + $performance-bar-height;
-
- &.affix {
- top: $header-height + $performance-bar-height;
- }
- }
- }
-}
-
-.nav-block {
&.activities {
border-bottom: 1px solid $border-color;
.nav-links {
- border-bottom: none;
+ border-bottom: 0;
}
}
}
-@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;
+.project-item-select-holder.btn-group {
+ display: flex;
+ overflow: hidden;
+ float: right;
- .btn,
- .dropdown {
- margin: 0;
- }
- }
+ .new-project-item-link {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
- .controls-item-full {
- flex: 1 1 100%;
- }
- }
+ .new-project-item-select-button {
+ width: 32px;
}
}
-.inner-page-scroll-tabs {
- position: relative;
-
- .fade-right {
- @include fade(left, $white-light);
- right: 0;
- text-align: right;
-
- .fa {
- right: 5px;
- }
- }
+.empty-state .project-item-select-holder.btn-group {
+ float: none;
+ display: inline-block;
- .fade-left {
- @include fade(right, $white-light);
- left: 0;
- text-align: left;
+ .btn {
+ // overrides styles applied to plain `.empty-state .btn`
+ margin: 10px 0;
+ max-width: 300px;
+ width: auto;
- .fa {
- left: 5px;
+ @media(max-width: $screen-xs-max) {
+ max-width: 250px;
}
}
+}
- .fade-right,
- .fade-left {
- top: 16px;
- bottom: auto;
- }
-
- &.is-smaller {
- .fade-right,
- .fade-left {
- top: 11px;
- }
- }
+.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 6c14e8b97e0..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: $gl-font-weight-bold;
- 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 {
@@ -265,56 +271,10 @@
min-width: 250px !important;
}
-// TODO: change global style
-.ajax-project-dropdown,
-.ajax-users-dropdown,
-body[data-page="projects:edit"] #select2-drop,
-body[data-page="projects:new"] #select2-drop,
-body[data-page="projects:merge_requests:edit"] #select2-drop,
-body[data-page="projects:blob:new"] #select2-drop,
-body[data-page="profiles:show"] #select2-drop,
-body[data-page="admin:groups:show"] #select2-drop,
-body[data-page="projects:issues:show"] #select2-drop,
-body[data-page="projects:blob:edit"] #select2-drop {
- &.select2-drop {
- border: 1px solid $dropdown-border-color;
- border-radius: $border-radius-base;
- color: $gl-text-color;
- }
-
- &.select2-drop-above {
- border-top: none;
- margin-top: -4px;
- }
-
- .select2-results {
- .select2-no-results,
- .select2-searching,
- .select2-ajax-error,
- .select2-selection-limit {
- background: transparent;
- }
-
- .select2-result {
- padding: 0 1px;
-
- .select2-match {
- font-weight: $gl-font-weight-bold;
- text-decoration: none;
- }
-
- .select2-result-label {
- padding: #{$gl-padding / 2} $gl-padding;
- }
-
- &.select2-highlighted {
- background-color: transparent !important;
- color: $gl-text-color;
-
- .select2-result-label {
- background-color: $dropdown-item-hover-bg;
- }
- }
- }
+.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 48dc25d343b..d1d98270ad9 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,16 +88,11 @@
.right-sidebar {
border-left: 1px solid $border-color;
- height: calc(100% - #{$new-navbar-height});
-
- &.affix {
- position: fixed;
- top: $new-navbar-height;
- }
+ height: calc(100% - #{$header-height});
}
.with-performance-bar .right-sidebar.affix {
- top: $new-navbar-height + $performance-bar-height;
+ top: $header-height + $performance-bar-height;
}
@mixin maintain-sidebar-dimensions {
@@ -111,7 +116,7 @@
}
.block:last-of-type {
- border: none;
+ border: 0;
}
}
@@ -133,6 +138,17 @@
}
}
-.issuable-sidebar {
- @include new-style-dropdown;
+.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/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 4dd31bf28cd..5bde96caf42 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -33,7 +33,7 @@ table {
th {
background-color: $gray-light;
font-weight: $gl-font-weight-normal;
- border-bottom: none;
+ 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 d5c6ddbb4a5..1c6e2bf3074 100644
--- a/app/assets/stylesheets/framework/tw_bootstrap.scss
+++ b/app/assets/stylesheets/framework/tw_bootstrap.scss
@@ -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 4c35e3a9c3c..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;
@@ -164,3 +164,36 @@ $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 3c0b4c82d19..294c59f037f 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -167,7 +167,7 @@
&.plain-readme {
background: none;
- border: none;
+ border: 0;
padding: 0;
margin: 0;
font-size: 14px;
@@ -178,6 +178,10 @@
font-weight: inherit;
}
+ dd {
+ margin-left: $gl-padding;
+ }
+
ul,
ol {
padding: 0;
@@ -292,7 +296,7 @@ body {
line-height: 1.3;
font-size: 1.25em;
font-weight: $gl-font-weight-bold;
- margin: 12px 7px;
+ margin: 12px 0;
}
h1,
@@ -329,6 +333,10 @@ a > code {
font-family: $monospace_font;
}
+.weight-normal {
+ font-weight: $gl-font-weight-normal;
+}
+
.commit-sha,
.ref-name {
@extend .monospace;
@@ -343,8 +351,6 @@ a > code {
@extend .ref-name;
}
-@include new-style-dropdown('.git-revision-dropdown');
-
/**
* Apply Markdown typography
*
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index a3da9fd44e8..a5a8f6d2206 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: .3s;
$sidebar-breakpoint: 1024px;
+$default-transition-duration: .15s;
+$contextual-sidebar-width: 220px;
+$contextual-sidebar-collapsed-width: 50px;
/*
* Color schema
@@ -27,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;
@@ -74,6 +77,7 @@ $red-600: #c0341d;
$red-700: #a62d19;
$red-800: #8b2615;
$red-900: #711e11;
+$red-950: #4b140b;
// GitLab themes
@@ -158,8 +162,9 @@ $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.0);
+$gl-text-color-inverted: rgba(255, 255, 255, 1);
$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85);
+$gl-text-color-disabled: #919191;
$gl-text-green: $green-600;
$gl-text-green-hover: $green-700;
$gl-text-red: $red-500;
@@ -184,8 +189,8 @@ $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;
/*
@@ -201,24 +206,30 @@ $code_font_size: 12px;
$code_line_height: 1.6;
/*
+ * Tooltips
+ */
+$tooltip-font-size: 12px;
+
+/*
* Padding
*/
$gl-padding: 16px;
+$gl-padding-8: 8px;
+$gl-padding-4: 4px;
$gl-col-padding: 15px;
-$gl-btn-padding: 10px;
$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;
-$new-navbar-height: 40px;
+$header-height: 40px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
$limited-layout-width-sm: 790px;
@@ -226,6 +237,7 @@ $container-text-max-width: 540px;
$gl-avatar-size: 40px;
$error-exclamation-point: $red-500;
$border-radius-default: 4px;
+$border-radius-small: 2px;
$settings-icon-size: 18px;
$provider-btn-not-active-color: $blue-500;
$link-underline-blue: $blue-500;
@@ -236,9 +248,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;
@@ -251,6 +260,8 @@ $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;
/*
* Common component specific colors
@@ -260,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;
@@ -314,6 +326,7 @@ $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
@@ -325,6 +338,9 @@ $regular_font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-San
* 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);
@@ -340,6 +356,7 @@ $dropdown-loading-bg: rgba(#fff, .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
@@ -348,11 +365,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, .04);
+$link-hover-background: rgba(0, 0, 0, .06);
+$inactive-badge-background: rgba(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
@@ -386,14 +414,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
*/
@@ -451,24 +477,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
@@ -536,6 +562,9 @@ $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);
@@ -545,6 +574,8 @@ $label-border-radius: 100px;
* Animation
*/
$fade-in-duration: 200ms;
+$fade-mask-transition-duration: .1s;
+$fade-mask-transition-curve: ease-in-out;
/*
* Lint
@@ -644,9 +675,9 @@ $pipeline-dropdown-line-height: 20px;
$pipeline-dropdown-status-icon-size: 18px;
/*
-Pipeline Schedules
+CI variable lists
*/
-$pipeline-variable-remove-button-width: calc(1em + #{2 * $gl-padding});
+$ci-variable-remove-button-width: calc(1em + #{2 * $gl-padding});
/*
@@ -689,10 +720,31 @@ $perf-bar-bucket-color: #ccc;
$perf-bar-bucket-box-shadow-from: rgba($white-light, .2);
$perf-bar-bucket-box-shadow-to: rgba($black, .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;
/*
-Project Templates Icons
+Modals
*/
-$rails: #c00;
-$node: #353535;
-$java: #70ad51;
+$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 5f9756bf58a..2f3a80daa90 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -16,10 +16,19 @@
.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;
+ }
}
}
@@ -52,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 {
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/white.scss b/app/assets/stylesheets/highlight/white.scss
index 65b140cd7f8..c3d8f0c61a2 100644
--- a/app/assets/stylesheets/highlight/white.scss
+++ b/app/assets/stylesheets/highlight/white.scss
@@ -217,13 +217,31 @@ $white-gc-bg: #eaf2f5;
.cp { color: $white-cp; font-weight: $gl-font-weight-bold; }
.c1 { color: $white-c1; font-style: italic; }
.cs { color: $white-cs; font-weight: $gl-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; }
+
+ .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: $gl-font-weight-bold; }
diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
index fbe538ad1d7..658ac26fca9 100644
--- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss
+++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
@@ -158,13 +158,31 @@ span.highlight_word {
.cp { color: $highlighted-cp; font-weight: $gl-font-weight-bold; }
.c1 { color: $highlighted-c1; font-style: italic; }
.cs { color: $highlighted-cs; font-weight: $gl-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; }
+
+.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: $gl-font-weight-bold; }
diff --git a/app/assets/stylesheets/new_nav.scss b/app/assets/stylesheets/new_nav.scss
deleted file mode 100644
index 8c5bafac637..00000000000
--- a/app/assets/stylesheets/new_nav.scss
+++ /dev/null
@@ -1,472 +0,0 @@
-@import "framework/variables";
-@import 'framework/tw_bootstrap_variables';
-@import "bootstrap/variables";
-@import "framework/mixins";
-
-.content-wrapper.page-with-new-nav {
- margin-top: $new-navbar-height;
-}
-
-header.navbar-gitlab-new {
- color: $white-light;
- border-bottom: 0;
- min-height: $new-navbar-height;
-
- .logo-text {
- line-height: initial;
-
- svg {
- width: 55px;
- height: 14px;
- margin: 0;
- fill: $white-light;
- }
- }
-
- .header-content {
- display: -webkit-flex;
- display: flex;
- padding-left: 0;
- min-height: $new-navbar-height;
-
- .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;
- }
-
- .title {
- display: -webkit-flex;
- display: flex;
- padding-right: 0;
- color: currentColor;
-
- img {
- height: 28px;
- margin-right: 8px;
- }
-
- a {
- display: -webkit-flex;
- display: flex;
- align-items: center;
- padding: 2px 8px;
- margin: 5px 2px 5px -8px;
- border-radius: $border-radius-default;
-
- svg {
- @media (min-width: $screen-sm-min) {
- margin-right: 8px;
- }
- }
- }
- }
-
- .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;
- }
- }
-
- &.menu-expanded {
- @media (max-width: $screen-xs-max) {
- .title-container,
- .header-logo, {
- display: none;
- }
- }
- }
- }
-
- .dropdown-bold-header {
- color: $gl-text-color-secondary;
- font-size: 12px;
- }
-
- .navbar-collapse {
- padding-left: 0;
- box-shadow: 0;
-
- @media (max-width: $screen-xs-max) {
- margin-left: -8px;
- margin-right: -10px;
- }
-
- .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: 4px $gl-padding;
- margin-right: -7px;
- font-size: 14px;
- text-align: center;
- color: currentColor;
-
- &: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;
- font-weight: $gl-font-weight-bold;
- }
- }
- }
-
- .nav > li {
- &.header-user {
- @media (max-width: $screen-xs-max) {
- padding-left: 10px;
- }
- }
-
- > a {
- will-change: color;
- margin: 4px 2px;
- padding: 6px 8px;
- height: 32px;
-
- @media (max-width: $screen-xs-max) {
- padding: 0;
- }
-
- &.header-user-dropdown-toggle {
- margin-left: 2px;
-
- .header-user-avatar {
- margin-right: 0;
- }
- }
-
- &:hover,
- &:focus {
- text-decoration: none;
- outline: 0;
- opacity: 1;
- color: $white-light;
-
- svg {
- fill: currentColor;
- }
-
- &.header-user-dropdown-toggle {
- .header-user-avatar {
- border-color: $white-light;
- }
- }
- }
- }
-
- .header-new-dropdown-toggle {
- margin-right: 0;
- }
-
- .impersonated-user,
- .impersonated-user:hover {
- margin-right: 1px;
- background-color: $white-light;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- }
-
- .impersonation-btn,
- .impersonation-btn:hover {
- background-color: $white-light;
- margin-left: 0;
- border-top-left-radius: 0;
- border-bottom-left-radius: 0;
-
- i {
- color: $orange-500;
- font-size: 20px;
- }
- }
-
- &.active > a,
- &.dropdown.open > a {
-
- svg {
- fill: currentColor;
- }
- }
- }
- }
-}
-
-.navbar-sub-nav {
- display: -webkit-flex;
- display: flex;
- margin: 0 0 0 6px;
-
- .dropdown-chevron {
- position: relative;
- top: -1px;
- font-size: 10px;
- }
-}
-
-.navbar-gitlab-new {
- .navbar-sub-nav,
- .navbar-nav {
- > li {
- > a:hover,
- > a:focus {
- text-decoration: none;
- outline: 0;
- color: $white-light;
-
- svg {
- fill: currentColor;
- }
- }
-
- > a {
- 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;
-
- svg {
- fill: currentColor;
- }
- }
-
- &.line-separator {
- margin: 8px;
- }
- }
- }
-}
-
-.admin-icon i {
- font-size: 18px;
-}
-
-.caret-down {
- height: 11px;
- width: 11px;
- margin-left: 4px;
- fill: currentColor;
-}
-
-.header-user .dropdown-menu-nav,
-.header-new .dropdown-menu-nav {
- margin-top: 4px;
-}
-
-.search {
- margin: 4px 8px 0;
-
- form {
- height: 32px;
- border: 0;
- border-radius: $border-radius-default;
- transition: border-color ease-in-out 0.15s, background-color ease-in-out 0.15s;
-
- &:hover {
- box-shadow: none;
- }
- }
-
- &.search-active form {
- box-shadow: none;
-
- .search-input {
- color: $gl-text-color;
- transition: color ease-in-out 0.15s;
- }
-
- .search-input::placeholder {
- color: $gl-text-color-tertiary;
- }
-
- .search-input-wrap {
- .search-icon,
- .clear-icon {
- color: $gl-text-color-tertiary;
- transition: color ease-in-out 0.15s;
- }
- }
- }
-
- .search-input {
- color: $white-light;
- background: none;
- transition: color ease-in-out 0.15s;
- }
-
- .search-input::placeholder {
- transition: color ease-in-out 0.15s;
- }
-
- .location-badge {
- font-size: 12px;
- margin: -4px 4px -4px -4px;
- line-height: 25px;
- padding: 4px 8px;
- border-radius: 2px 0 0 2px;
- height: 32px;
- transition: border-color ease-in-out 0.15s;
- }
-
- &.search-active {
- .location-badge {
- background-color: $nav-badge-bg;
- border-color: $border-color;
- }
-
- .search-input-wrap {
- .clear-icon {
- color: $white-light;
- }
- }
- }
-}
-
-.breadcrumbs {
- display: flex;
- min-height: 48px;
- color: $gl-text-color;
-}
-
-.breadcrumbs-container {
- display: -webkit-flex;
- display: flex;
- width: 100%;
- position: relative;
- 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;
-
- @media (max-width: $screen-xs-max) {
- padding-left: 17px;
- border-left: 1px solid $gl-text-color-quaternary;
- }
-
- .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;
- flex-wrap: wrap;
- margin-bottom: 0;
- line-height: 16px;
-
- > li {
- display: flex;
- align-items: center;
- position: relative;
-
- &:not(:last-child) {
- margin-right: 20px;
- }
-
- > a {
- font-size: 12px;
- color: currentColor;
- }
- }
-}
-
-.breadcrumb-item-text {
- @include str-truncated(128px);
- text-decoration: inherit;
-}
-
-.breadcrumbs-list-angle {
- position: absolute;
- right: -12px;
- top: 50%;
- color: $gl-text-color-tertiary;
- transform: translateY(-50%);
-}
-
-.breadcrumbs-extra {
- display: flex;
- flex: 0 0 auto;
- margin-left: auto;
-}
-
-.breadcrumbs-sub-title {
- margin: 0;
- font-size: 12px;
- font-weight: 600;
- line-height: 1;
-
- a {
- color: $gl-text-color;
- }
-}
-
-.btn-sign-in {
- margin-top: 3px;
- font-weight: $gl-font-weight-bold;
-
- &:hover {
- background-color: $white-light;
- }
-}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 700be173039..2803144ef1d 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -55,6 +55,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,7 +72,7 @@
}
.boards-list {
- height: calc(100vh - 152px);
+ height: calc(100vh - 105px);
width: 100%;
padding-top: 25px;
padding-bottom: 25px;
@@ -72,17 +81,13 @@
overflow-x: scroll;
white-space: nowrap;
- @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 - 90px);
+ }
- &.is-compact {
- width: calc(100% - 290px);
- }
+ @media (min-width: $screen-md-min) {
+ height: calc(100vh - 160px);
+ min-height: 475px;
}
}
@@ -410,16 +415,7 @@
margin: 5px;
}
-.page-with-layout-nav.page-with-sub-nav .issue-boards-sidebar,
-.page-with-new-sidebar.page-with-sidebar .issue-boards-sidebar {
- position: absolute;
-
- &.right-sidebar {
- top: 0;
- bottom: 0;
- height: 100%;
- }
-
+.page-with-contextual-sidebar.layout-page .issue-boards-sidebar {
.issuable-sidebar-header {
position: relative;
}
@@ -457,8 +453,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,
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 359dd388d05..98d460339cd 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -48,7 +48,8 @@
overflow-x: auto;
font-size: 12px;
border-radius: 0;
- border: none;
+ border: 0;
+ padding: $grid-size;
.bash {
display: block;
@@ -57,29 +58,28 @@
.top-bar {
height: 35px;
- display: flex;
- justify-content: flex-end;
background: $gray-light;
border: 1px solid $border-color;
color: $gl-text-color;
position: sticky;
position: -webkit-sticky;
- top: $new-navbar-height;
+ top: $header-height;
+ padding: $grid-size;
&.affix {
- top: $new-navbar-height;
- }
+ top: $header-height;
- // with sidebar
- &.affix.sidebar-expanded {
- right: 306px;
- left: 16px;
- }
+ // with sidebar
+ &.sidebar-expanded {
+ right: 306px;
+ left: 16px;
+ }
- // without sidebar
- &.affix.sidebar-collapsed {
- right: 16px;
- left: 16px;
+ // without sidebar
+ &.sidebar-collapsed {
+ right: 16px;
+ left: 16px;
+ }
}
&.affix-top {
@@ -90,9 +90,6 @@
}
.truncated-info {
- margin: 0 auto;
- align-self: center;
-
.truncated-info-size {
margin: 0 5px;
}
@@ -118,7 +115,11 @@
.controllers-buttons {
color: $gl-text-color;
- margin: 0 10px;
+ margin: 0 $grid-size;
+
+ &:last-child {
+ margin-right: 0;
+ }
}
.btn-scroll.animate {
@@ -174,10 +175,10 @@
.with-performance-bar .build-page {
.top-bar {
- top: $new-navbar-height + $performance-bar-height;
+ top: $header-height + $performance-bar-height;
&.affix {
- top: $new-navbar-height + $performance-bar-height;
+ top: $header-height + $performance-bar-height;
}
}
}
@@ -322,8 +323,6 @@
}
.build-dropdown {
- @include new-style-dropdown;
-
margin: $gl-padding 0;
padding: 0;
@@ -333,8 +332,10 @@
svg {
position: relative;
- top: 2px;
+ top: 3px;
margin-right: 3px;
+ width: 14px;
+ height: 14px;
}
}
@@ -348,9 +349,10 @@
svg {
position: relative;
- top: 2px;
+ top: 3px;
margin-right: 3px;
- height: 13px;
+ height: 14px;
+ width: 14px;
}
a {
@@ -369,7 +371,7 @@
.build-job {
position: relative;
- .fa-arrow-right {
+ .icon-arrow-right {
position: absolute;
left: 15px;
top: 20px;
@@ -379,7 +381,7 @@
&.active {
font-weight: $gl-font-weight-bold;
- .fa-arrow-right {
+ .icon-arrow-right {
display: block;
}
}
@@ -392,8 +394,7 @@
background-color: $row-hover;
}
- .fa-refresh {
- font-size: 13px;
+ .icon-retry {
margin-left: 3px;
}
}
diff --git a/app/assets/stylesheets/pages/ci_projects.scss b/app/assets/stylesheets/pages/ci_projects.scss
index bf6a48889bf..fbe1f3081a0 100644
--- a/app/assets/stylesheets/pages/ci_projects.scss
+++ b/app/assets/stylesheets/pages/ci_projects.scss
@@ -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 994707422bb..8b680c2dc52 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,12 +54,15 @@
.mr-widget-pipeline-graph {
display: inline-block;
vertical-align: middle;
- margin-right: 4px;
.stage-cell .stage-container {
margin: 3px 3px 3px 0;
}
+ .stage-container:last-child {
+ margin-right: 0;
+ }
+
.dropdown-menu {
margin-top: 11px;
}
@@ -186,11 +189,16 @@
.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 {
@@ -215,6 +223,11 @@
font-size: 14px;
font-weight: $gl-font-weight-bold;
}
+
+ .ci-status-icon {
+ position: relative;
+ top: 1px;
+ }
}
.commit,
@@ -244,7 +257,7 @@
word-break: normal;
pre {
- border: none;
+ border: 0;
background: inherit;
padding: 0;
margin: 0;
@@ -306,8 +319,8 @@
}
&.invalid {
- @include status-color($gray-dark, $gray, $common-gray-dark);
- border-color: $common-gray-light;
+ @include status-color($gray-dark, $gray, $gray-darkest);
+ border-color: $gray-darkest;
}
}
@@ -331,8 +344,8 @@
&.invalid {
svg {
- border: 1px solid $common-gray-light;
- fill: $common-gray-light;
+ border: 1px solid $gray-darkest;
+ fill: $gray-darkest;
}
}
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 16702442f50..fb1899284fd 100644
--- a/app/assets/stylesheets/pages/convdev_index.scss
+++ b/app/assets/stylesheets/pages/convdev_index.scss
@@ -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;
}
}
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index 2a92673d9fa..cfef6476d4d 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -1,6 +1,4 @@
#cycle-analytics {
- @include new-style-dropdown;
-
max-width: 1000px;
margin: 24px auto 0;
position: relative;
@@ -22,6 +20,11 @@
}
}
}
+
+ svg {
+ width: 136px;
+ height: 136px;
+ }
}
.col-headers {
@@ -75,7 +78,7 @@
.panel {
.content-block {
padding: 24px 0;
- border-bottom: none;
+ border-bottom: 0;
position: relative;
@media (max-width: $screen-xs-max) {
@@ -114,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: $gl-font-weight-bold;
- 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;
@@ -222,11 +179,11 @@
}
&:first-child {
- border-top: none;
+ border-top: 0;
}
&:last-child {
- border-bottom: none;
+ border-bottom: 0;
}
.stage-nav-item-cell {
@@ -290,7 +247,7 @@
border-bottom: 1px solid $gray-darker;
&:last-child {
- border-bottom: none;
+ border-bottom: 0;
margin-bottom: 0;
}
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 951580ea1fe..7f037582ca0 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -47,7 +47,7 @@
table {
width: 100%;
font-family: $monospace_font;
- border: none;
+ border: 0;
border-collapse: separate;
margin: 0;
padding: 0;
@@ -77,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,7 +105,7 @@
.new_line {
@include user-select(none);
margin: 0;
- border: none;
+ border: 0;
padding: 0 5px;
border-right: 1px solid;
text-align: right;
@@ -121,7 +133,7 @@
display: block;
margin: 0;
padding: 0 1.5em;
- border: none;
+ border: 0;
position: relative;
&.parallel {
@@ -285,6 +297,7 @@
.drag-track {
display: block;
position: absolute;
+ top: 0;
left: 12px;
height: 10px;
width: 276px;
@@ -346,7 +359,7 @@
cursor: pointer;
&:first-child {
- border-left: none;
+ border-left: 0;
}
&:hover {
@@ -367,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 {
@@ -387,7 +400,7 @@
}
.files-changed {
- border-bottom: none;
+ border-bottom: 0;
}
.diff-stats-summary-toggler {
@@ -451,7 +464,7 @@
}
.files {
- margin-top: -1px;
+ margin-top: 1px;
.diff-file:last-child {
margin-bottom: 0;
@@ -535,21 +548,23 @@
}
.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;
}
@@ -566,8 +581,6 @@
}
.commit-stat-summary {
- @include new-style-dropdown;
-
@media (min-width: $screen-sm-min) {
margin-left: -$gl-padding;
padding-left: $gl-padding;
@@ -586,11 +599,6 @@
top: 76px;
}
- + .files,
- + .alert {
- margin-top: 1px;
- }
-
&:not(.is-stuck) .diff-stats-additions-deletions-collapsed {
display: none;
}
@@ -605,11 +613,6 @@
.inline-parallel-buttons {
display: none;
}
-
- + .files,
- + .alert {
- margin-top: 32px;
- }
}
}
}
@@ -623,21 +626,50 @@
}
.diff-file-changes {
- width: 450px;
+ max-width: 560px;
+ width: 100%;
z-index: 150;
@media (min-width: $screen-sm-min) {
left: $gl-padding;
}
- a {
+ .diff-changed-file {
+ display: flex;
padding-top: 8px;
padding-bottom: 8px;
+ min-width: 0;
}
- .diff-changed-file {
+ .diff-file-changed-icon {
+ margin-top: 2px;
+ }
+
+ .diff-changed-file-content {
display: flex;
- align-items: center;
+ 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;
}
}
@@ -647,3 +679,162 @@
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: 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 d3cd4d507de..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;
}
}
@@ -204,8 +204,6 @@
.gitlab-ci-yml-selector,
.dockerfile-selector,
.template-type-selector {
- @include new-style-dropdown;
-
display: inline-block;
vertical-align: top;
font-family: $regular_font;
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 9362d80d4e6..58700661142 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -12,8 +12,6 @@
.environments-container {
.ci-table {
- @include new-style-dropdown;
-
.deployment-column {
> span {
word-break: break-all;
@@ -117,12 +115,16 @@
}
.no-btn {
- border: none;
+ border: 0;
background: none;
outline: none;
width: 100%;
text-align: left;
}
+
+ .environment-child-row {
+ padding-left: 20px;
+ }
}
}
@@ -133,12 +135,11 @@
}
.folder-row {
- padding: 15px 0;
- border-bottom: 1px solid $white-normal;
+ border-left: 0;
+ border-right: 0;
- @media (max-width: $screen-sm-max) {
- border-top: 1px solid $white-normal;
- margin-top: 10px;
+ @media (min-width: $screen-sm-max) {
+ border-top: 0;
}
}
@@ -174,7 +175,7 @@
.prometheus-graph-overlay {
fill: none;
- opacity: 0.0;
+ opacity: 0;
pointer-events: all;
}
@@ -202,15 +203,23 @@
stroke-width: 1;
}
-.deploy-info-text {
- dominant-baseline: text-before-edge;
+.divider-line {
+ stroke-width: 1;
+ stroke: $gray-darkest;
}
.prometheus-state {
- margin-top: 10px;
+ max-width: 460px;
+ margin: 10px auto;
+ text-align: center;
+
+ .state-svg {
+ max-width: 80vw;
+ margin: 0 auto;
+ }
- .state-button-section {
- margin-top: 10px;
+ .state-button {
+ padding: $gl-padding / 2;
}
}
@@ -247,36 +256,80 @@
}
}
-.prometheus-svg-container {
- position: relative;
- height: 0;
- width: 100%;
- padding: 0;
- padding-bottom: 100%;
+.prometheus-graph-cursor {
+ position: absolute;
+ background: $theme-gray-600;
+ width: 1px;
}
-.prometheus-svg-container > svg {
- position: absolute;
- height: 100%;
- width: 100%;
- left: 0;
- top: 0;
+.prometheus-graph-flag {
+ display: block;
+ min-width: 160px;
- text {
- fill: $gl-text-color;
- stroke-width: 0;
+ h5 {
+ padding: 0;
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.2;
}
- .text-metric-bold {
- font-weight: $gl-font-weight-bold;
+ table {
+ border-collapse: collapse;
+ padding: 0;
+ margin: 0;
}
- .label-axis-text {
- fill: $black;
- font-weight: $gl-font-weight-normal;
- font-size: 10px;
+ 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;
@@ -284,36 +337,80 @@
font-size: 12px;
}
- .legend-axis-text {
- fill: $black;
- }
-
- .tick > text {
- font-size: 12px;
- }
+ > svg {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ left: 0;
+ top: 0;
- .text-metric-title {
- font-size: 12px;
- }
+ text {
+ fill: $gl-text-color;
+ stroke-width: 0;
+ }
- .y-label-text,
- .x-label-text {
- fill: $gray-darkest;
- }
+ .text-metric-bold {
+ font-weight: $gl-font-weight-bold;
+ }
- .axis-tick {
- stroke: $gray-darker;
- }
+ .label-axis-text {
+ fill: $black;
+ font-weight: $gl-font-weight-normal;
+ font-size: 10px;
+ }
- @media (max-width: $screen-sm-max) {
- .label-axis-text,
- .text-metric-usage,
.legend-axis-text {
- font-size: 8px;
+ 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;
}
- .tick > text {
- font-size: 8px;
+ .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 1723d716805..8871a069d5d 100644
--- a/app/assets/stylesheets/pages/events.scss
+++ b/app/assets/stylesheets/pages/events.scss
@@ -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,7 +158,6 @@
}
}
-
/*
* Last push widget
*/
@@ -182,6 +180,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 d01ee4b033c..4c9732c26d9 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -5,27 +5,21 @@
margin-right: auto;
}
-.is-confidential {
- color: $orange-600;
- background-color: $orange-50;
+.issuable-warning-icon {
+ background-color: $orange-100;
border-radius: $border-radius-default;
- padding: 5px;
- margin: 0 3px 0 -4px;
-}
-
-.is-not-confidential {
- border-radius: $border-radius-default;
- padding: 5px;
- margin: 0 3px 0 -4px;
-}
+ margin: 0 $btn-side-margin 0 0;
+ width: $issuable-warning-size;
+ height: $issuable-warning-size;
+ text-align: center;
-.confidentiality {
- .is-not-confidential {
- margin: auto;
+ .icon {
+ fill: $orange-600;
+ vertical-align: text-bottom;
}
- .is-confidential {
- margin: auto;
+ &:first-of-type {
+ margin-right: $issuable-warning-icon-margin;
}
}
@@ -70,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.
@@ -99,23 +102,42 @@
.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,
+ 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-retry),
.btn-link {
color: inherit;
}
@@ -143,11 +165,7 @@
}
&:last-child {
- border: none;
- }
-
- span {
- display: inline-block;
+ border: 0;
}
.select2-container span {
@@ -162,6 +180,14 @@
color: $gray-darkest;
}
}
+
+ &.assignee {
+ .author_link:hover {
+ .author {
+ text-decoration: underline;
+ }
+ }
+ }
}
.block-first {
@@ -177,11 +203,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: $md-link-color;
}
}
}
@@ -216,21 +249,10 @@
.btn-clipboard:hover {
color: $gl-text-color;
}
-}
-
-.right-sidebar {
- position: absolute;
- top: $new-navbar-height;
- bottom: 0;
- right: 0;
- transition: width .3s;
- background: $gray-light;
- z-index: 200;
- overflow: hidden;
.issuable-sidebar {
width: calc(100% + 100px);
- height: calc(100% - #{$new-navbar-height});
+ height: 100%;
overflow-y: scroll;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
@@ -274,10 +296,15 @@
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;
}
@@ -285,6 +312,7 @@
.gutter-toggle {
margin-top: 7px;
border-left: 1px solid $border-gray-normal;
+ text-align: center;
}
.title .gutter-toggle {
@@ -328,7 +356,7 @@
.block {
width: $gutter_collapsed_width - 2px;
padding: 15px 0 0;
- border-bottom: none;
+ border-bottom: 0;
overflow: hidden;
}
@@ -357,7 +385,7 @@
fill: $issuable-sidebar-color;
}
- &:hover,
+ &:hover:not(.disabled),
&:hover .todo-undone {
color: $gl-text-color;
@@ -389,7 +417,7 @@
}
.btn-clipboard {
- border: none;
+ border: 0;
color: $issuable-sidebar-color;
&:hover {
@@ -450,17 +478,17 @@
}
}
- .milestone-title span {
+ .milestone-title span,
+ .collapse-truncated-title {
@include str-truncated(100%);
display: block;
margin: 0 4px;
}
}
- a {
+ a:not(.btn-retry) {
&:hover {
color: $md-link-color;
- text-decoration: none;
.avatar {
border-color: rgba($avatar-border, .2);
@@ -468,12 +496,6 @@
}
}
- .dropdown-content {
- a:hover {
- color: inherit;
- }
- }
-
.dropdown-menu-toggle {
width: 100%;
padding-top: 6px;
@@ -485,17 +507,13 @@
}
.with-performance-bar .right-sidebar {
- top: $new-navbar-height + $performance-bar-height;
+ top: $header-height + $performance-bar-height;
.issuable-sidebar {
- height: calc(100% - #{$new-navbar-height} - #{$performance-bar-height});
+ height: calc(100% - #{$performance-bar-height});
}
}
-.sidebar-move-issue-dropdown {
- @include new-style-dropdown;
-}
-
.sidebar-move-issue-confirmation-button {
width: 100%;
@@ -530,7 +548,9 @@
}
.participants-list {
- margin: -5px;
+ display: flex;
+ flex-wrap: wrap;
+ margin: -7px;
}
@@ -541,7 +561,7 @@
.participants-author {
display: inline-block;
- padding: 5px;
+ padding: 7px;
&:nth-of-type(7n) {
padding-right: 0;
@@ -598,53 +618,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 {
- @include new-style-dropdown;
-
- 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 {
@@ -890,3 +890,21 @@
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 e8ca5cedaee..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;
- }
+ }
}
}
@@ -134,29 +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 {
- @include new-style-dropdown;
-
.select2-container {
width: 250px !important;
}
}
-.issues-footer {
- padding-top: $gl-padding;
- padding-bottom: 37px;
-}
-
-.issue-email-modal-btn {
+.issuable-email-modal-btn {
padding: 0;
color: $gl-link-color;
background-color: transparent;
@@ -190,7 +191,19 @@ ul.related-merge-requests > li {
}
.create-mr-dropdown-wrap {
- @include new-style-dropdown;
+ .ref::selection {
+ color: $placeholder-text-color;
+ }
+
+ .dropdown {
+ .dropdown-menu-toggle {
+ min-width: 285px;
+ }
+
+ .dropdown-select {
+ width: 285px;
+ }
+ }
.btn-group:not(.hide) {
display: flex;
@@ -201,50 +214,33 @@ 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) {
- &.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: $gl-font-weight-bold;
- }
- }
- }
}
.discussion-reply-holder .note-edit-form {
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 443f5500684..0f49d15203b 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -58,13 +58,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 +79,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,7 +126,11 @@
}
.manage-labels-list {
- @include new-style-dropdown;
+ @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;
@@ -135,8 +149,6 @@
}
.btn-action {
- color: $gl-text-color;
-
.fa {
font-size: 18px;
vertical-align: middle;
@@ -157,10 +169,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;
@@ -190,7 +210,7 @@
.toggle-priority {
display: inline-block;
- vertical-align: middle;
+ vertical-align: top;
button {
border-color: transparent;
@@ -257,6 +277,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;
@@ -280,10 +305,11 @@
}
.label-link {
- display: inline-block;
+ 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 cf5f933a762..b2250a1ce2f 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -109,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;
}
@@ -141,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;
@@ -154,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 {
@@ -231,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 b3bab082a35..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) {
@@ -78,6 +49,12 @@
width: auto;
}
}
+
+ &.existing-title {
+ @media (min-width: $screen-sm-min) {
+ float: left;
+ }
+ }
}
.member-form-control {
@@ -93,15 +70,7 @@
line-height: 43px;
}
-.member.existing-title {
- @media (min-width: $screen-sm-min) {
- float: left;
- }
-}
-
.member-search-form {
- @include new-style-dropdown;
-
position: relative;
@media (min-width: $screen-sm-min) {
@@ -310,7 +279,3 @@
}
}
}
-
-.member-form-control {
- @include new-style-dropdown;
-}
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 09a14578dd3..f887a11004f 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -150,14 +150,6 @@
display: block;
}
- .mr-widget-body {
- @include clearfix;
-
- &.media > *:first-child {
- margin-right: 10px;
- }
- }
-
.mr-widget-pipeline-graph {
padding: 0 4px;
@@ -209,12 +201,17 @@
}
}
- .mr-widget-help {
- padding: 10px 16px 10px 48px;
- font-style: italic;
- }
-
.mr-widget-body {
+ @include clearfix;
+
+ &.media > *:first-child {
+ margin-right: 10px;
+ }
+
+ .approve-btn {
+ margin-right: 5px;
+ }
+
h4 {
float: left;
font-weight: $gl-font-weight-bold;
@@ -336,6 +333,11 @@
}
}
+ .mr-widget-help {
+ padding: 10px 16px 10px 48px;
+ font-style: italic;
+ }
+
.ci-coverage {
float: right;
}
@@ -350,12 +352,6 @@
}
}
-.mr-state-widget .mr-widget-body {
- .approve-btn {
- margin-right: 5px;
- }
-}
-
.mr-widget-body-controls {
flex-wrap: wrap;
}
@@ -388,6 +384,12 @@
}
}
+.nothing-here-block {
+ img {
+ width: 230px;
+ }
+}
+
.mr-list {
.merge-request {
padding: 10px 0 10px 15px;
@@ -469,22 +471,20 @@
padding-bottom: 0;
}
}
-}
-.mr-info-list.mr-memory-usage {
- p {
- float: left;
- }
+ &.mr-memory-usage {
+ p {
+ float: left;
+ }
- .memory-graph-container {
- float: left;
- margin-left: 5px;
+ .memory-graph-container {
+ float: left;
+ margin-left: 5px;
+ }
}
}
.mr-source-target {
- @include new-style-dropdown;
-
display: flex;
flex-wrap: wrap;
justify-content: space-between;
@@ -606,8 +606,6 @@
}
.mr-version-controls {
- @include new-style-dropdown;
-
position: relative;
background: $gray-light;
color: $gl-text-color;
@@ -649,7 +647,7 @@
}
.merge-request-tabs-holder {
- top: $new-navbar-height;
+ top: $header-height;
z-index: 200;
background-color: $white-light;
border-bottom: 1px solid $border-color;
@@ -679,7 +677,7 @@
}
.with-performance-bar .merge-request-tabs-holder {
- top: $new-navbar-height + $performance-bar-height;
+ top: $header-height + $performance-bar-height;
}
.merge-request-tabs {
@@ -726,6 +724,6 @@
}
}
-.merge-request-form {
- @include new-style-dropdown;
+.fork-sprite {
+ margin-right: -5px;
}
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss
index 32039936be7..e5afa8fffcb 100644
--- a/app/assets/stylesheets/pages/milestone.scss
+++ b/app/assets/stylesheets/pages/milestone.scss
@@ -66,6 +66,15 @@
height: 6px;
margin: 0;
}
+
+ .sidebar-collapsed-icon {
+ clear: both;
+ padding: 15px 5px 5px;
+
+ .progress {
+ margin: 5px 0;
+ }
+ }
}
.collapsed-milestone-date {
@@ -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 5d7c85b16ef..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,15 +16,13 @@
.discussion {
.new-note {
margin: 0;
- border: none;
+ border: 0;
}
}
.new-note,
.note-edit-form {
.note-form-actions {
- @include new-style-dropdown;
-
position: relative;
margin: $gl-padding 0 0;
}
@@ -101,44 +99,85 @@
}
}
-.confidential-issue-warning {
+.issuable-note-warning {
color: $orange-600;
- background-color: $orange-50;
+ background-color: $orange-100;
border-radius: $border-radius-default $border-radius-default 0 0;
border: 1px solid $border-gray-normal;
- border-bottom: none;
+ border-bottom: 0;
padding: 3px 12px;
margin: auto;
align-items: center;
+
+ .icon {
+ margin-right: $issuable-warning-icon-margin;
+ vertical-align: text-bottom;
+ fill: $orange-600;
+ }
+
+ + .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;
+ }
+ }
}
-.confidential-value {
- .fa {
- background-color: inherit;
+.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;
}
}
-.confidential-warning-message {
+.sidebar-item-warning-message {
line-height: 1.5;
padding: 16px;
- .confidential-warning-message-actions {
+ .text {
+ color: $text-color;
+ }
+
+ .sidebar-item-warning-message-actions {
display: flex;
- button {
+ .btn {
flex-grow: 1;
}
}
}
-.confidential-issue-warning + .md-area {
- border-top-left-radius: 0;
- border-top-right-radius: 0;
+.discussion-form {
+ background-color: $white-light;
}
-.discussion-form {
+.discussion-form-container {
padding: $gl-padding-top $gl-padding $gl-padding;
- background-color: $white-light;
}
.discussion-notes .disabled-comment {
@@ -222,13 +261,12 @@
width: 100%;
padding-right: 5px;
}
-
}
.discussion-actions {
display: table;
- .new-issue-for-discussion path {
+ .btn-default path {
fill: $gray-darkest;
}
@@ -353,20 +391,25 @@
.dropdown-toggle {
float: right;
- .toggle-icon {
+ i {
color: $white-light;
padding-right: 2px;
margin-top: 2px;
}
+
+ &[disabled] {
+ i {
+ color: $gl-text-color-disabled;
+ }
+ }
}
.dropdown-menu {
top: initial;
- bottom: 40px;
+ bottom: 100%;
width: 298px;
}
-
@media (max-width: $screen-xs-max) {
display: flex;
width: 100%;
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 46d31e41ada..3c565837383 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -208,7 +208,6 @@ ul.notes {
a {
color: $gl-link-color;
- text-decoration: none;
}
p {
@@ -269,7 +268,7 @@ ul.notes {
display: none;
}
- &.system-note-commit-list {
+ &.system-note-commit-list:not(.hide-shade) {
max-height: 70px;
overflow: hidden;
display: block;
@@ -291,16 +290,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;
- }
- }
}
}
}
@@ -322,57 +311,72 @@ 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;
+ }
}
}
}
@@ -390,6 +394,10 @@ ul.notes {
&:focus,
&:hover {
text-decoration: none;
+
+ .note-header-author-name {
+ text-decoration: underline;
+ }
}
}
@@ -456,6 +464,10 @@ ul.notes {
.system-note-message {
white-space: normal;
}
+
+ a:hover {
+ text-decoration: underline;
+ }
}
/**
@@ -466,17 +478,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 {
- @include new-style-dropdown;
-
align-self: flex-start;
flex-shrink: 0;
display: inline-flex;
align-items: center;
- // For PhantomJS that does not support flex
- float: right;
margin-left: 10px;
color: $gray-darkest;
@@ -487,7 +504,6 @@ ul.notes {
}
.more-actions {
- float: right; // phantomjs fallback
display: flex;
align-items: flex-end;
@@ -508,13 +524,6 @@ ul.notes {
min-width: 180px;
}
-.discussion-actions {
- @include notes-media('max', $screen-md-max) {
- float: none;
- margin-left: 0;
- }
-}
-
.note-actions-item {
margin-left: 12px;
display: flex;
@@ -531,23 +540,15 @@ ul.notes {
padding: 0;
min-width: 16px;
color: $gray-darkest;
+ fill: $gray-darkest;
.fa {
position: relative;
font-size: 16px;
}
-
-
svg {
- height: 16px;
- width: 16px;
- top: 0;
- vertical-align: text-top;
-
- path {
- fill: currentColor;
- }
+ @include btn-svg;
}
.award-control-icon-positive,
@@ -566,10 +567,7 @@ ul.notes {
.link-highlight {
color: $gl-link-color;
-
- svg {
- fill: $gl-link-color;
- }
+ fill: $gl-link-color;
}
.award-control-icon-neutral {
@@ -619,26 +617,17 @@ ul.notes {
}
.note-role {
+ margin: 0 3px;
+}
+
+.note-role-special {
position: relative;
display: inline-block;
- color: $notes-role-color;
+ color: $gl-text-color-secondary;
font-size: 12px;
- line-height: 20px;
- margin: 0 3px;
-
- &.note-role-access {
- padding: 0 7px;
- border: 1px solid $border-color;
- border-radius: $label-border-radius;
- }
-
- &.note-role-special {
- text-shadow: 0 0 15px $gl-text-color-inverted;
- }
+ text-shadow: 0 0 15px $gl-text-color-inverted;
}
-
-
/**
* Line note button on the side of diffs
*/
@@ -650,29 +639,12 @@ ul.notes {
}
.add-diff-note {
+ @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;
-
- &:hover {
- background: $blue-500;
- border-color: $blue-600;
- color: $white-light;
- }
-
- &:active {
- outline: 0;
- }
}
.discussion-body,
@@ -683,15 +655,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;
}
}
}
@@ -703,6 +667,12 @@ ul.notes {
color: $note-disabled-comment-color;
padding: 90px 0;
+ &.discussion-locked {
+ border: 0;
+ background-color: $white-light;
+ }
+
+
a {
color: $gl-link-color;
}
@@ -731,20 +701,20 @@ ul.notes {
svg path {
fill: $gray-darkest;
}
- }
- .btn.discussion-create-issue-btn {
- margin-left: -4px;
- border-radius: 0;
- border-right: 0;
+ &.discussion-create-issue-btn {
+ margin-left: -4px;
+ border-radius: 0;
+ border-right: 0;
- a {
- padding: 0;
- line-height: 0;
+ a {
+ padding: 0;
+ line-height: 0;
- &:hover {
- text-decoration: none;
- border: 0;
+ &:hover {
+ text-decoration: none;
+ border: 0;
+ }
}
}
}
@@ -753,7 +723,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;
@@ -778,7 +748,7 @@ 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;
@@ -803,12 +773,6 @@ ul.notes {
}
}
- svg {
- fill: currentColor;
- height: 16px;
- width: 16px;
- }
-
.loading {
margin: 0;
height: auto;
@@ -818,12 +782,3 @@ ul.notes {
.line-resolve-text {
vertical-align: middle;
}
-
-// 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/notifications.scss b/app/assets/stylesheets/pages/notifications.scss
index c28b1e68008..bdf07a99daf 100644
--- a/app/assets/stylesheets/pages/notifications.scss
+++ b/app/assets/stylesheets/pages/notifications.scss
@@ -14,7 +14,3 @@
font-size: 18px;
}
}
-
-.notification-form {
- @include new-style-dropdown;
-}
diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss
index 7e2297c283f..bc7fa8a26d9 100644
--- a/app/assets/stylesheets/pages/pipeline_schedules.scss
+++ b/app/assets/stylesheets/pages/pipeline_schedules.scss
@@ -39,6 +39,10 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+
+ svg {
+ vertical-align: middle;
+ }
}
.next-run-cell {
@@ -74,84 +78,3 @@
margin-right: 3px;
}
}
-
-.pipeline-variable-list {
- margin-left: 0;
- margin-bottom: 0;
- padding-left: 0;
- list-style: none;
- clear: both;
-}
-
-.pipeline-variable-row {
- display: flex;
- align-items: flex-end;
-
- &:not(:last-child) {
- margin-bottom: $gl-btn-padding;
- }
-
- @media (max-width: $screen-sm-max) {
- padding-right: $gl-col-padding;
- }
-
- &:last-child {
- .pipeline-variable-row-remove-button {
- display: none;
- }
-
- @media (max-width: $screen-sm-max) {
- .pipeline-variable-value-input {
- margin-right: $pipeline-variable-remove-button-width;
- }
- }
-
- @media (max-width: $screen-xs-max) {
- .pipeline-variable-row-body {
- margin-right: $pipeline-variable-remove-button-width;
- }
- }
- }
-}
-
-.pipeline-variable-row-body {
- display: flex;
- width: calc(75% - #{$gl-col-padding});
- padding-left: $gl-col-padding;
-
- @media (max-width: $screen-sm-max) {
- width: 100%;
- }
-
- @media (max-width: $screen-xs-max) {
- display: block;
- }
-}
-
-.pipeline-variable-key-input {
- margin-right: $gl-btn-padding;
-
- @media (max-width: $screen-xs-max) {
- margin-bottom: $gl-btn-padding;
- }
-}
-
-.pipeline-variable-row-remove-button {
- @include transition(color);
- flex-shrink: 0;
- display: flex;
- justify-content: center;
- align-items: center;
- width: $pipeline-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;
- }
-}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 9d03a042aa3..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 {
@@ -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 {
@@ -129,7 +121,7 @@
.ref-name {
font-weight: $gl-font-weight-bold;
- max-width: 120px;
+ 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;
@@ -209,9 +226,11 @@
}
.stage-cell {
- @media (min-width: $screen-md-min) {
- min-width: 148px;
- margin-right: -4px;
+ &.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 {
@@ -266,9 +285,7 @@
// Pipeline visualization
.pipeline-actions {
- @include new-style-dropdown;
-
- border-bottom: none;
+ border-bottom: 0;
}
.tab-pane {
@@ -298,19 +315,22 @@
}
.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 {
@@ -366,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
@@ -389,7 +409,7 @@
// Remove left curved connectors from all builds in first stage
&:not(:first-child) {
&::before {
- border: none;
+ border: 0;
}
}
// Remove opposite curve
@@ -449,36 +469,46 @@
@extend .build-content:hover;
}
- // 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 $dropdown-toggle-active-border-color;
- }
-
- svg {
- fill: $gl-text-color-secondary;
- position: relative;
- left: -1px;
- top: -1px;
- }
-
- &:hover svg {
- fill: $gl-text-color;
- }
- }
-
.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 {
@@ -488,7 +518,7 @@
.dropdown-menu-toggle {
background-color: transparent;
- border: none;
+ border: 0;
padding: 0;
&:focus {
@@ -644,20 +674,20 @@ button.mini-pipeline-graph-dropdown-toggle {
// 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,
@@ -673,9 +703,6 @@ button.mini-pipeline-graph-dropdown-toggle {
}
}
-@include new-style-dropdown('.big-pipeline-graph-dropdown-menu');
-@include new-style-dropdown('.mini-pipeline-graph-dropdown-menu');
-
// dropdown content for big and mini pipeline
.big-pipeline-graph-dropdown-menu,
.mini-pipeline-graph-dropdown-menu {
@@ -719,29 +746,60 @@ button.mini-pipeline-graph-dropdown-toggle {
svg {
fill: $gl-text-color-secondary;
- width: $ci-action-icon-size;
- height: $ci-action-icon-size;
- left: -6px;
+ width: #{$ci-action-icon-size - 6};
+ height: #{$ci-action-icon-size - 6};
+ left: -3px;
position: relative;
- top: -3px;
+ top: -2px;
+
+ &.icon-action-stop,
+ &.icon-action-cancel {
+ width: 12px;
+ height: 12px;
+ top: 1px;
+ left: -1px;
+ }
+
+ &.icon-action-play {
+ width: 11px;
+ height: 11px;
+ top: 1px;
+ left: 1px;
+ }
+
+ &.icon-action-retry {
+ width: 16px;
+ height: 16px;
+ top: 0;
+ left: -3px;
+ }
}
&:hover svg,
&:focus svg {
fill: $gl-text-color;
}
+
+ &.icon-action-retry,
+ &.icon-action-play {
+ svg {
+ width: #{$ci-action-icon-size - 6};
+ height: #{$ci-action-icon-size - 6};
+ left: 8px;
+ }
+ }
+
+
}
// link to the build
.mini-pipeline-graph-dropdown-item {
- padding: 3px 7px 4px;
align-items: center;
clear: both;
display: flex;
font-weight: normal;
line-height: $line-height-base;
white-space: nowrap;
- border-radius: 3px;
.ci-job-name-component {
align-items: center;
@@ -760,6 +818,11 @@ button.mini-pipeline-graph-dropdown-toggle {
margin-left: 2px;
display: inline-block;
+ &::after {
+ content: '';
+ display: block;
+ }
+
@media (max-width: $screen-xs-max) {
max-width: 60%;
}
@@ -797,13 +860,10 @@ button.mini-pipeline-graph-dropdown-toggle {
left: 100%;
top: -10px;
box-shadow: 0 1px 5px $black-transparent;
-}
-
-/**
- * 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: '';
@@ -865,22 +925,23 @@ button.mini-pipeline-graph-dropdown-toggle {
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
*/
@@ -890,7 +951,7 @@ button.mini-pipeline-graph-dropdown-toggle {
.terminal-container {
.content-block {
- border-bottom: none;
+ border-bottom: 0;
}
#terminal {
@@ -904,25 +965,6 @@ button.mini-pipeline-graph-dropdown-toggle {
}
}
-/**
- * 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;
@@ -946,3 +988,11 @@ button.mini-pipeline-graph-dropdown-toggle {
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 c5d6ff66dd6..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;
}
}
@@ -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) {
@@ -291,7 +300,7 @@ table.u2f-registrations {
.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;
@@ -379,7 +388,7 @@ table.u2f-registrations {
.nav-wip {
border: 1px solid $blue-500;
- background: $blue-25;
+ background: $blue-50;
padding: $gl-padding;
margin-bottom: $gl-padding;
@@ -392,11 +401,11 @@ table.u2f-registrations {
}
}
-.gpg-email-badge {
+.email-badge {
display: inline;
margin-right: $gl-padding / 2;
- .gpg-email-badge-email {
+ .email-badge-email {
display: inline;
margin-right: $gl-padding / 4;
}
diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss
index c197494b152..68d40b56133 100644
--- a/app/assets/stylesheets/pages/profiles/preferences.scss
+++ b/app/assets/stylesheets/pages/profiles/preferences.scss
@@ -20,6 +20,22 @@
}
}
+.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;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 6400b72742c..85de0d8e70f 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -48,7 +48,8 @@
border: 1px solid $border-color;
}
- + .select2 a {
+ + .select2 a,
+ + .btn-default {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
@@ -79,7 +80,7 @@
.project-feature-settings {
background: $gray-lighter;
- border-top: none;
+ border-top: 0;
margin-bottom: 16px;
}
@@ -87,7 +88,8 @@
transition: background 2s ease-out;
&:disabled {
- opacity: 0.75;
+ opacity: 0.5;
+ pointer-events: none;
}
.highlight-changes & {
@@ -124,93 +126,6 @@
}
}
-.project-feature-toggle {
- position: relative;
- border: none;
- outline: 0;
- display: block;
- width: 100px;
- 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;
- }
-
- &::before {
- color: $feature-toggle-text-color;
- font-size: 12px;
- line-height: 24px;
- position: absolute;
- top: 0;
- left: 25px;
- right: 5px;
- text-align: center;
- overflow: hidden;
- text-overflow: ellipsis;
- animation: animate-disabled .2s ease-in;
- content: attr(data-disabled-text);
- }
-
- &::after {
- position: relative;
- display: block;
- content: "";
- width: 22px;
- height: 18px;
- left: 0;
- border-radius: 9px;
- background: $feature-toggle-color;
- transition: all .2s ease;
- }
-
- &.checked {
- background: $feature-toggle-color-enabled;
-
- &::before {
- left: 5px;
- right: 25px;
- animation: animate-enabled .2s ease-in;
- content: attr(data-enabled-text);
- }
-
- &::after {
- left: calc(100% - 22px);
- }
- }
-
- &.disabled {
- opacity: 0.4;
- cursor: not-allowed;
- }
-
- @media (max-width: $screen-xs-min) {
- width: 50px;
-
- &::before,
- &.checked::before {
- display: none;
- }
- }
-
- @keyframes animate-enabled {
- 0%, 35% { opacity: 0; }
- 100% { opacity: 1; }
- }
-
- @keyframes animate-disabled {
- 0%, 35% { opacity: 0; }
- 100% { opacity: 1; }
- }
-}
-
.project-home-panel,
.group-home-panel {
padding-top: 24px;
@@ -289,14 +204,7 @@
}
svg {
-
- path {
- fill: $layout-link-gray;
- }
-
- use {
- stroke: $layout-link-gray;
- }
+ fill: $layout-link-gray;
}
.fa-caret-down {
@@ -400,14 +308,17 @@
}
}
}
-}
-.project-repo-buttons {
- @include new-style-dropdown;
+ .clone-dropdown-btn {
+ background-color: $white-light;
+ }
+
+ .clone-options-dropdown {
+ min-width: 240px;
- .project-action-button .dropdown-menu {
- max-height: 250px;
- overflow-y: auto;
+ .dropdown-menu-inner-content {
+ min-width: 320px;
+ }
}
}
@@ -481,7 +392,7 @@ a.deploy-project-label {
flex: 1;
padding: 0;
background: transparent;
- border: none;
+ border: 0;
line-height: 34px;
margin: 0;
@@ -499,68 +410,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;
+
+ &.disabled {
+ opacity: .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;
+ }
- img {
- border-radius: 50%;
- max-width: 100px;
+ .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;
+ }
}
}
}
}
-.project-template,
+.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 {
@@ -575,10 +564,6 @@ a.deploy-project-label {
margin-right: 10px;
}
- .blank-option {
- min-width: 70px;
- }
-
.btn-template-icon {
height: 24px;
width: inherit;
@@ -600,18 +585,6 @@ a.deploy-project-label {
}
}
- .icon-rails path {
- fill: $rails;
- }
-
- .icon-node-express path {
- fill: $node;
- }
-
- .icon-java-spring path {
- fill: $java;
- }
-
> div {
margin-bottom: 10px;
padding-left: 0;
@@ -619,10 +592,6 @@ a.deploy-project-label {
}
}
-.project-templates-buttons .btn:last-child {
- margin-right: 0;
-}
-
.create-project-options {
display: flex;
@@ -709,6 +678,9 @@ a.deploy-project-label {
}
}
+.project-empty-note-panel {
+ border-bottom: 1px solid $border-color;
+}
.project-stats {
font-size: 0;
@@ -717,53 +689,52 @@ a.deploy-project-label {
border-bottom: 1px solid $border-color;
.nav {
- padding-top: 12px;
- padding-bottom: 12px;
- }
+ margin-top: $gl-padding-8;
+ margin-bottom: $gl-padding-8;
- .nav > li {
- display: inline-block;
+ > li {
+ display: inline-block;
+ margin-top: $gl-padding-4;
+ margin-bottom: $gl-padding-4;
- &:not(:last-child) {
- margin-right: $gl-padding;
- }
+ &:not(:last-child) {
+ margin-right: $gl-padding;
+ }
- &.right {
- vertical-align: top;
- margin-top: 0;
+ &.right {
+ vertical-align: top;
+ margin-top: 0;
- @media (min-width: $screen-lg-min) {
- float: right;
+ @media (min-width: $screen-lg-min) {
+ float: right;
+ }
}
}
- }
- .nav > li > a {
- padding: 0;
- background-color: transparent;
- font-size: 14px;
- line-height: 29px;
- color: $notes-light-color;
-
- &:hover,
- &:focus {
- color: $gl-text-color;
+ .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;
}
- }
- li.missing {
- border: 1px dashed $border-gray-normal-dashed;
- border-radius: $border-radius-default;
+ .stat-link {
+ &:hover,
+ &:focus {
+ color: $gl-text-color;
+ text-decoration: underline;
+ }
+ }
- a {
- padding-left: 10px;
- padding-right: 10px;
- color: $notes-light-color;
- display: block;
+ .btn {
+ padding: $gl-btn-vert-padding $gl-btn-horz-padding;
+ line-height: $gl-btn-line-height;
}
- &:hover {
- background-color: $gray-normal;
+ .btn-missing {
+ @extend .btn-missing;
}
}
}
@@ -773,7 +744,7 @@ pre.light-well {
}
.git-empty {
- margin: 0 7px 7px;
+ margin-bottom: 7px;
h5 {
color: $gl-text-color;
@@ -826,10 +797,6 @@ pre.light-well {
font-size: $gl-font-size;
}
- a {
- color: $gl-text-color;
- }
-
.avatar-container,
.controls {
flex: 0 0 auto;
@@ -923,36 +890,27 @@ pre.light-well {
.new-protected-branch,
.new-protected-tag {
- @include new-style-dropdown;
-
label {
margin-top: 6px;
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;
- text-overflow: ellipsis;
+.project-tip-command {
+ > .input-group-btn:first-child {
+ width: auto;
+ }
}
.protected-branches-list,
.protected-tags-list {
- @include new-style-dropdown;
-
margin-bottom: 30px;
.settings-message {
margin: 0;
border-radius: 0 0 1px 1px;
padding: 20px 0;
- border: none;
+ border: 0;
}
.table-bordered {
@@ -1061,6 +1019,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;
@@ -1095,18 +1059,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 {
@@ -1149,3 +1106,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
index 4d4d92f9494..8265b8370f7 100644
--- a/app/assets/stylesheets/pages/repo.scss
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -1,30 +1,3 @@
-.fade-enter-active,
-.fade-leave-active {
- transition: opacity $sidebar-transition-duration;
-}
-
-.monaco-loader {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: $black-transparent;
-}
-
-.modal.popup-dialog {
- display: block;
- background-color: $black-transparent;
- z-index: 2100;
-
- @media (min-width: $screen-md-min) {
- .modal-dialog {
- width: 600px;
- margin: 30px auto;
- }
- }
-}
-
.project-refs-form,
.project-refs-target-form {
display: inline-block;
@@ -43,321 +16,574 @@
display: inline-block;
}
-@media (min-width: $screen-md-min) {
- .blob-viewer[data-type="rich"] {
- margin: 20px;
+.ide-view {
+ display: flex;
+ height: calc(100vh - #{$header-height});
+ color: $almost-black;
+ border-top: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+
+ &.is-collapsed {
+ .ide-file-list {
+ max-width: 250px;
+ }
}
}
-.repository-view {
- border: 1px solid $border-color;
- border-radius: $border-radius-default;
- color: $almost-black;
+.ide-file-list {
+ flex: 1;
- .tree-content-holder {
- display: flex;
- min-height: 300px;
- }
+ .file {
+ cursor: pointer;
- .tree-content-holder-mini {
- height: 100vh;
- }
+ &.file-open {
+ background: $white-normal;
+ }
- .panel-right {
- display: flex;
- flex-direction: column;
- width: 80%;
- height: 100%;
+ .repo-file-name {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
- .monaco-editor.vs {
- .current-line {
- border: none;
- background: $well-light-border;
- }
+ .unsaved-icon {
+ color: $indigo-700;
+ float: right;
+ font-size: smaller;
+ line-height: 20px;
+ }
- .line-numbers {
- cursor: pointer;
+ .repo-new-btn {
+ display: none;
+ margin-top: -4px;
+ margin-bottom: -4px;
+ }
- &:hover {
- text-decoration: underline;
- }
+ &:hover {
+ .repo-new-btn {
+ display: block;
}
- .cursor {
- display: none !important;
+ .unsaved-icon {
+ display: none;
}
}
+ }
- .blob-no-preview {
- .vertical-center {
- justify-content: center;
- width: 100%;
- }
- }
+ a {
+ color: $gl-text-color;
+ }
- &.edit-mode {
- .blob-viewer-container {
- overflow: hidden;
- }
+ th {
+ position: sticky;
+ top: 0;
+ }
+}
- .monaco-editor.vs {
- .cursor {
- background: $black;
- border-color: $black;
- display: block !important;
- }
- }
+.multi-file-table-name,
+.multi-file-table-col-commit-message {
+ overflow: visible;
+ max-width: 0;
+ padding: 6px 12px;
+}
+
+.multi-file-loading-container {
+ margin-top: 10px;
+ padding: 10px;
+
+ .animation-container {
+ background: $gray-light;
+
+ div {
+ background: $gray-light;
}
+ }
+}
- .blob-viewer-container {
- flex: 1;
- overflow: auto;
+table.table tr td.multi-file-table-name {
+ width: 350px;
+ padding: 6px 12px;
- > div,
- .file-content:not(.wiki) {
- display: flex;
- }
+ svg {
+ vertical-align: middle;
+ margin-right: 2px;
+ }
- > div,
- .file-content,
- .blob-viewer,
- .line-number,
- .blob-content,
- .code {
- min-height: 100%;
- width: 100%;
- }
+ .loading-container {
+ margin-right: 4px;
+ display: inline-block;
+ }
+}
- .line-numbers {
- min-width: 44px;
- }
+.multi-file-table-col-commit-message {
+ white-space: nowrap;
+ width: 50%;
+}
- .blob-content {
- flex: 1;
- overflow-x: auto;
- }
+.multi-file-edit-pane {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ border-left: 1px solid $white-dark;
+ overflow: hidden;
+}
+
+.multi-file-tabs {
+ display: flex;
+ overflow-x: auto;
+ background-color: $white-normal;
+ box-shadow: inset 0 -1px $white-dark;
+
+ > li {
+ position: relative;
+ }
+}
+
+.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%;
+ padding: 0;
+ background: none;
+ border: 0;
+ font-size: $gl-font-size;
+ color: $gray-darkest;
+ transform: translateY(-50%);
+
+ &:not(.modified):hover,
+ &:not(.modified):focus {
+ color: $hint-color;
+ }
+
+ &.modified {
+ color: $indigo-700;
+ }
+}
+
+.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;
+ }
+}
+
+.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;
}
+ }
- #tabs {
- flex-shrink: 0;
- display: flex;
- width: 100%;
- padding-left: 0;
- margin-bottom: 0;
- white-space: nowrap;
- overflow-y: hidden;
- overflow-x: auto;
-
- li {
- animation: swipeRightAppear ease-in 0.1s;
- animation-iteration-count: 1;
- transform-origin: 0% 50%;
- list-style-type: none;
- background: $gray-normal;
- display: inline-block;
- padding: #{$gl-padding / 2} $gl-padding;
- border-right: 1px solid $white-dark;
- border-bottom: 1px solid $white-dark;
- white-space: nowrap;
- cursor: pointer;
-
- &.remove {
- animation: swipeRightDissapear ease-in 0.1s;
- animation-iteration-count: 1;
- transform-origin: 0% 50%;
-
- a {
- width: 0;
- }
- }
-
- &.active {
- background: $white-light;
- border-bottom: none;
- }
-
- a {
- @include str-truncated(100px);
- color: $black;
- vertical-align: middle;
- text-decoration: none;
- margin-right: 12px;
-
- &.close {
- width: auto;
- font-size: 15px;
- opacity: 1;
- margin-right: -6px;
- }
- }
-
- .close-icon:hover {
- color: $hint-color;
- }
-
- .close-icon,
- .unsaved-icon {
- float: right;
- margin-top: 3px;
- margin-left: 15px;
- color: $gray-darkest;
- }
-
- .unsaved-icon {
- color: $brand-success;
- }
-
- &.tabs-divider {
- width: 100%;
- background-color: $white-light;
- border-right: none;
- border-top-right-radius: 2px;
- }
- }
+ .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: 290px;
+ 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;
- #repo-file-buttons {
- background-color: $white-light;
- border-bottom: 1px solid $white-normal;
- padding: 5px 10px;
- position: relative;
- border-top: 1px solid $white-normal;
+ .multi-file-commit-list {
+ padding-top: $gl-padding;
+ overflow: hidden;
}
- #binary-viewer {
- height: 80vh;
- overflow: auto;
- margin: 0;
+ .multi-file-context-bar-icon {
+ align-items: center;
- .blob-viewer {
- padding-top: 20px;
- padding-left: 20px;
+ svg {
+ float: none;
+ margin: 0;
}
+ }
+ }
- .binary-unknown {
- text-align: center;
- padding-top: 100px;
- background: $gray-light;
- height: 100%;
- font-size: 17px;
+ .branch-container {
+ border-left: 4px solid $indigo-700;
+ margin-bottom: $gl-bar-padding;
+ }
- span {
- display: block;
- }
- }
+ .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;
}
}
- #commit-area {
+ .branch-header-btns {
+ padding: $gl-vert-padding $gl-padding;
+ }
+
+ .left-collapse-btn {
+ display: none;
background: $gray-light;
- padding: 20px;
+ text-align: left;
+ border-top: 1px solid $white-dark;
- .help-block {
- padding-top: 7px;
- margin-top: 0;
+ svg {
+ vertical-align: middle;
}
}
+}
- #view-toggler {
- height: 41px;
- position: relative;
- display: block;
- border-bottom: 1px solid $white-normal;
- background: $white-light;
- margin-top: -5px;
+.multi-file-context-bar-icon {
+ padding: 10px;
+
+ svg {
+ margin-right: 10px;
+ float: left;
}
+}
- #binary-viewer {
- img {
- max-width: 100%;
+.multi-file-commit-panel-section {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.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: $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;
+}
+
+.multi-file-commit-list-item {
+ display: flex;
+ align-items: center;
+}
+
+.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;
}
+}
- #sidebar {
+.multi-file-commit-list-path {
+ @include str-truncated(100%);
+}
+
+.multi-file-commit-form {
+ padding: $gl-padding;
+ border-top: 1px solid $white-dark;
+}
+
+.multi-file-commit-fieldset {
+ display: flex;
+ align-items: center;
+ padding-bottom: 12px;
+
+ .btn {
flex: 1;
- height: 100%;
+ }
+}
+
+.multi-file-commit-message.form-control {
+ height: 80px;
+ 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;
+ }
- &.sidebar-mini {
- width: 20%;
- border-right: 1px solid $white-normal;
- overflow: auto;
+ &-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, .5);
}
+ }
+}
+
+.ide-loading {
+ display: flex;
+ height: 100vh;
+ align-items: center;
+ justify-content: center;
+}
- table {
+.ide-empty-state {
+ display: flex;
+ height: 100vh;
+ align-items: center;
+ justify-content: center;
+}
+
+.repo-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;
}
+ }
+}
- tr {
- animation: fadein 0.5s;
- cursor: pointer;
-
- &.repo-file-options td {
- padding: 0;
- border-top: none;
- background: $gray-light;
- width: 100%;
- display: inline-block;
-
- &:first-child {
- border-top-left-radius: 2px;
- }
-
- .title {
- display: inline-block;
- font-size: 10px;
- text-transform: uppercase;
- font-weight: $gl-font-weight-bold;
- color: $gray-darkest;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- vertical-align: middle;
- padding: 2px 16px;
- }
- }
+.ide.nav-only {
+ .flash-container {
+ margin-top: $header-height;
+ margin-bottom: 0;
+ }
- .file-icon {
- margin-right: 5px;
- }
+ .alert-wrapper .flash-container .flash-alert:last-child,
+ .alert-wrapper .flash-container .flash-notice:last-child {
+ margin-bottom: 0;
+ }
- td {
- white-space: nowrap;
- }
+ .content {
+ margin-top: $header-height;
+ }
+
+ .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
+ max-height: calc(100vh - #{$header-height + $context-header-height});
+ }
+
+ &.flash-shown {
+ .content {
+ margin-top: 0;
}
- a {
- @include str-truncated(250px);
- color: $almost-black;
- display: inline-block;
- vertical-align: middle;
+ .ide-view {
+ height: calc(100vh - #{$header-height + $flash-height});
+ }
+
+ .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
+ max-height: calc(100vh - #{$header-height + $flash-height + $context-header-height});
}
}
}
-.render-error {
- min-height: calc(100vh - 62px);
+.with-performance-bar .ide.nav-only {
+ .flash-container {
+ margin-top: #{$header-height + $performance-bar-height};
+ }
- p {
- width: 100%;
+ .content {
+ margin-top: #{$header-height + $performance-bar-height};
}
-}
-@keyframes swipeRightAppear {
- 0% {
- transform: scaleX(0.00);
+ .ide-view {
+ height: calc(100vh - #{$header-height + $performance-bar-height});
}
- 100% {
- transform: scaleX(1.00);
+ .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
+ max-height: calc(100vh - #{$header-height + $performance-bar-height + 60});
+ }
+
+ &.flash-shown {
+ .content {
+ margin-top: 0;
+ }
+
+ .ide-view {
+ height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height});
+ }
+
+ .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
+ max-height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height + $context-header-height});
+ }
}
}
-@keyframes swipeRightDissapear {
- 0% {
- transform: scaleX(1.00);
+
+.dragHandle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background-color: $white-dark;
+
+ &.dragright {
+ right: 0;
}
- 100% {
- transform: scaleX(0.00);
+ &.dragleft {
+ left: 0;
}
}
diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss
index 6cac37a4e28..5fb97b13470 100644
--- a/app/assets/stylesheets/pages/runners.scss
+++ b/app/assets/stylesheets/pages/runners.scss
@@ -50,3 +50,10 @@
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 13dd7b5a780..c9363188505 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: $gl-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,19 +108,7 @@ 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 {
@@ -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(7px);
- opacity: 1;
+ .location-badge {
+ transition: all $default-transition-duration;
+ background-color: $nav-badge-bg;
+ border-color: $border-color;
}
}
@@ -190,8 +166,6 @@ input[type="checkbox"]:hover {
}
.search-holder {
- @include new-style-dropdown;
-
@media (min-width: $screen-sm-min) {
display: -webkit-flex;
display: flex;
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 41a6ba2023a..47672783d5a 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;
}
}
@@ -56,14 +55,18 @@
overflow-y: scroll;
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;
}
@@ -132,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%;
@@ -238,11 +252,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 bfe065dbbaf..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: $gl-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 36f622db136..ade5ddd147b 100644
--- a/app/assets/stylesheets/pages/status.scss
+++ b/app/assets/stylesheets/pages/status.scss
@@ -18,7 +18,7 @@
}
&.ci-failed {
- @include status-color($red-50, $red-500, $red-600);
+ @include status-color($red-100, $red-500, $red-600);
}
&.ci-success {
@@ -39,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,
@@ -55,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 6c8d87185e9..4b9824fab0c 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -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;
@@ -265,7 +264,3 @@
font-weight: $gl-font-weight-bold;
}
}
-
-.todos-filters {
- @include new-style-dropdown;
-}
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index 224eee90a3f..e0ee7e9aa3d 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -1,9 +1,12 @@
.tree-holder {
- @include new-style-dropdown;
-
.nav-block {
margin: 10px 0;
+ .btn .fa,
+ .btn svg {
+ color: $gl-text-color-secondary;
+ }
+
@media (min-width: $screen-sm-min) {
display: flex;
@@ -91,8 +94,12 @@
}
.add-to-tree {
- vertical-align: middle;
- padding: 6px 10px;
+ vertical-align: top;
+ padding: 8px;
+
+ svg {
+ top: 0;
+ }
}
.tree-table {
@@ -125,7 +132,7 @@
color: $white-normal;
}
- &:hover {
+ &:hover:not(.tree-truncated-warning) {
td {
background-color: $row-hover;
border-top: 1px solid $row-hover-border;
@@ -169,6 +176,14 @@
}
}
+ .tree-item-file-external-link {
+ margin-right: 4px;
+
+ span {
+ text-decoration: inherit;
+ }
+ }
+
.tree_commit {
max-width: 320px;
@@ -190,6 +205,11 @@
}
}
+ .tree-truncated-warning {
+ color: $orange-600;
+ background-color: $orange-100;
+ }
+
.tree-time-ago {
min-width: 135px;
color: $gl-text-color-secondary;
@@ -244,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/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index b7d4e7bf582..e70a57c2a67 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;
@@ -124,7 +132,11 @@
&:hover,
&.active {
- color: $black;
+ text-decoration: none;
+
+ span {
+ text-decoration: underline;
+ }
}
}
@@ -161,10 +173,10 @@ ul.wiki-pages-list.content-list {
list-style: none;
margin-left: 0;
padding-left: 15px;
- }
- ul li {
- padding: 5px 0;
+ li {
+ padding: 5px 0;
+ }
}
}
diff --git a/app/assets/stylesheets/pages/xterm.scss b/app/assets/stylesheets/pages/xterm.scss
index c7297a34ad8..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
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/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb
index 92df1c8dff0..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
@@ -52,7 +52,7 @@ class Admin::AppearancesController < Admin::ApplicationController
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_controller.rb b/app/controllers/admin/application_controller.rb
index a4648b33cfa..c27f2ee3c09 100644
--- a/app/controllers/admin/application_controller.rb
+++ b/app/controllers/admin/application_controller.rb
@@ -3,9 +3,23 @@
# Automatically sets the layout and ensures an administrator is logged in
class Admin::ApplicationController < ApplicationController
before_action :authenticate_admin!
+ before_action :display_read_only_information
layout 'admin'
def authenticate_admin!
render_404 unless current_user.admin?
end
+
+ def display_read_only_information
+ return unless Gitlab::Database.read_only?
+
+ flash.now[:notice] = read_only_message
+ end
+
+ private
+
+ # Overridden in EE
+ def read_only_message
+ _('You are on a read-only GitLab instance.')
+ end
end
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 16590e66d61..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
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index c49b6459452..a9109a1d4d0 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -1,4 +1,6 @@
class Admin::BroadcastMessagesController < Admin::ApplicationController
+ include BroadcastMessagesHelper
+
before_action :finder, only: [:edit, :update, :destroy]
def index
@@ -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/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb
index a7ab481519d..b0c4c31cffc 100644
--- a/app/controllers/admin/deploy_keys_controller.rb
+++ b/app/controllers/admin/deploy_keys_controller.rb
@@ -50,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 65a17828feb..61247b280b3 100644
--- a/app/controllers/admin/health_check_controller.rb
+++ b/app/controllers/admin/health_check_controller.rb
@@ -5,7 +5,7 @@ class Admin::HealthCheckController < Admin::ApplicationController
end
def reset_storage_health
- Gitlab::Git::Storage::CircuitBreaker.reset_all!
+ Gitlab::Git::Storage::FailureInfo.reset_all!
redirect_to admin_health_check_path,
notice: _('Git storage health information has been reset')
end
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index 77e3c95d197..2b47819303e 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -59,11 +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
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/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index 50cf2643390..3afe66c3566 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -1,4 +1,6 @@
class Admin::ProjectsController < Admin::ApplicationController
+ include MembersPresentation
+
before_action :project, only: [:show, :transfer, :repository_check]
before_action :group, only: [:show, :transfer]
@@ -19,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
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 cbcef70e957..156a8e2c515 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -128,7 +128,7 @@ class Admin::UsersController < Admin::ApplicationController
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
@@ -155,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
@@ -219,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 97922e39ba8..7f83bd10e93 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -2,7 +2,6 @@ require 'gon'
require 'fogbugz'
class ApplicationController < ActionController::Base
- include Gitlab::CurrentSettings
include Gitlab::GonHelper
include GitlabRoutingHelper
include PageLayoutHelper
@@ -11,8 +10,7 @@ class ApplicationController < ActionController::Base
include EnforcesTwoFactorAuthentication
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|
@@ -83,34 +83,27 @@ class ApplicationController < ActionController::Base
super
payload[:remote_ip] = request.remote_ip
- if current_user.present?
- payload[:user_id] = current_user.id
- payload[:username] = current_user.username
+ logged_user = auth_user
+
+ if logged_user.present?
+ payload[:user_id] = logged_user.try(:id)
+ payload[:username] = logged_user.try(:username)
end
end
- # 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
-
- return unless token.present?
-
- user = User.find_by_authentication_token(token) || User.find_by_personal_access_token(token)
+ # 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?
- sessionless_sign_in(user)
+ return try(:authenticated_user)
end
- # This filter handles authentication for atom request with an rss_token
- def authenticate_user_from_rss_token!
- return unless request.format.atom?
-
- token = params[:rss_token].presence
-
- return unless token.present?
-
- user = User.find_by_rss_token(token)
+ # 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)
@@ -126,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
@@ -153,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
@@ -191,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
@@ -202,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
@@ -211,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
@@ -226,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
@@ -268,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
@@ -335,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 10e8e54f402..86bade49ec9 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -8,12 +8,12 @@ class AutocompleteController < ApplicationController
def users
@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
@@ -44,6 +44,7 @@ class AutocompleteController < ApplicationController
if @project.blank? && params[:group_id].present?
group = Group.find(params[:group_id])
return render_404 unless can?(current_user, :read_group, group)
+
group
end
end
@@ -54,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/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index 0d74078645a..19dbee84c11 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -1,7 +1,11 @@
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]
@@ -10,7 +14,7 @@ module Boards
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)
+ make_sure_position_is_set(issues) if Gitlab::Database.read_write?
issues = issues.preload(:project,
:milestone,
:assignees,
@@ -54,7 +58,7 @@ module Boards
end
def issue
- @issue ||= issues_finder.execute.find(params[:id])
+ @issue ||= issues_finder.find(params[:id])
end
def filter_params
@@ -63,11 +67,19 @@ module Boards
end
def issues_finder
- IssuesFinder.new(current_user, project_id: board_parent.id)
+ 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
- board_parent
+ @project ||= if board.group_board?
+ Project.find(issue_params[:project_id])
+ else
+ board_parent
+ end
end
def move_params
@@ -84,6 +96,7 @@ module Boards
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] },
@@ -91,5 +104,10 @@ module Boards
}
)
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/ci/lints_controller.rb b/app/controllers/ci/lints_controller.rb
index be667687c18..e9bd1689a1e 100644
--- a/app/controllers/ci/lints_controller.rb
+++ b/app/controllers/ci/lints_controller.rb
@@ -16,10 +16,7 @@ module Ci
@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 b75e401a8df..db8c362f125 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -59,6 +59,7 @@ module AuthenticatesWithTwoFactor
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
@@ -75,6 +76,7 @@ module AuthenticatesWithTwoFactor
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
index 2c9c095a5d7..da830ec2cb1 100644
--- a/app/controllers/concerns/boards_responses.rb
+++ b/app/controllers/concerns/boards_responses.rb
@@ -1,10 +1,46 @@
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
- authorize_action_for!(board.parent, :read_list)
+ ability = board.group_board? ? :read_group : :read_list
+
+ authorize_action_for!(board.parent, ability)
end
def authorize_read_issue
- authorize_action_for!(board.parent, :read_issue)
+ ability = board.group_board? ? :read_group : :read_issue
+
+ authorize_action_for!(board.parent, ability)
end
def authorize_update_issue
@@ -24,11 +60,15 @@ module BoardsResponses
end
def respond_with_boards
- respond_with(@boards)
+ respond_with(@boards) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def respond_with_board
- 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)
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 782f0be9c4a..6f4fdcdaa4f 100644
--- a/app/controllers/concerns/creates_commit.rb
+++ b/app/controllers/concerns/creates_commit.rb
@@ -1,6 +1,8 @@
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)
@project_to_commit_into = @project
@@ -45,6 +47,7 @@ module CreatesCommit
end
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def authorize_edit_tree!
return if can_collaborate_with_project?
@@ -77,6 +80,7 @@ module CreatesCommit
end
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def new_merge_request_path
project_new_merge_request_path(
@project_to_commit_into,
@@ -88,20 +92,28 @@ module CreatesCommit
}
)
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def existing_merge_request_path
- project_merge_request_path(@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?
@@ -109,6 +121,6 @@ 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
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/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 4079072a930..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) # rubocop:disable GitlabSecurity/PublicSend
+ 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 0d0e53d4b76..4114ca6bf7c 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -2,60 +2,59 @@ 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 set_issues_index
- @collection_type = "Issue"
- @issues = issues_collection
- @issues = @issues.page(params[:page])
- @issuable_meta_data = issuable_meta_data(@issues, @collection_type)
- @total_pages = issues_page_count(@issues)
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def set_issuables_index
+ @issuables = issuables_collection
- return if redirect_out_of_range(@issues, @total_pages)
+ set_pagination
+ return if redirect_out_of_range(@total_pages)
- if params[:label_name].present?
- @labels = LabelsFinder.new(current_user, project_id: @project.id, title: params[:label_name]).execute
+ 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 = []
- end
+ if params[:assignee_id].present?
+ assignee = User.find_by_id(params[:assignee_id])
+ @users.push(assignee) if assignee
+ end
- def issues_collection
- issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace)
+ if params[:author_id].present?
+ author = User.find_by_id(params[:author_id])
+ @users.push(author) if author
+ end
end
- def merge_requests_collection
- merge_requests_finder.execute.preload(
- :source_project,
- :target_project,
- :author,
- :assignee,
- :labels,
- :milestone,
- head_pipeline: :project,
- target_project: :namespace,
- merge_request_diff: :merge_request_diff_commits
- )
+ 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_finder
- @issues_finder ||= issuable_finder_for(IssuesFinder)
+ def pagination_disabled?
+ false
end
- def merge_requests_finder
- @merge_requests_finder ||= issuable_finder_for(MergeRequestsFinder)
+ def issuables_collection
+ finder.execute.preload(preload_for_collection)
end
- def redirect_out_of_range(relation, total_pages)
- return false if total_pages.zero?
+ def redirect_out_of_range(total_pages)
+ return false if total_pages.nil? || total_pages.zero?
- out_of_range = relation.current_page > total_pages
+ 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)))
@@ -64,12 +63,8 @@ module IssuableCollections
out_of_range
end
- def issues_page_count(relation)
- page_count_for_relation(relation, issues_finder.row_count)
- end
-
- def merge_requests_page_count(relation)
- page_count_for_relation(relation, merge_requests_finder.row_count)
+ 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)
@@ -84,6 +79,7 @@ module IssuableCollections
finder_class.new(current_user, filter_params)
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def filter_params
set_sort_order_from_cookie
set_default_state
@@ -98,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
@@ -106,8 +103,9 @@ module IssuableCollections
# @filter_params[:authorized_only] = true
end
- @filter_params
+ @filter_params.permit(finder_type.valid_params)
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def set_default_state
params[:state] = 'opened' if params[:state].blank?
@@ -117,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 c6b1e443de6..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 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
- if membershipable.is_a?(Project)
+ case membershipable
+ when Namespace
+ polymorphic_url([membershipable, :members])
+ when Project
project_project_members_path(membershipable)
else
- polymorphic_url([membershipable, :members])
+ 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 081f3336780..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,6 +43,7 @@ module MilestoneActions
}
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def milestone_redirect_path
if @project
project_milestone_path(@project, @milestone)
@@ -52,4 +53,5 @@ module MilestoneActions
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 18fd8eb114d..03ed5b5310b 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -1,9 +1,11 @@
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
@@ -15,12 +17,12 @@ module NotesActions
notes = notes_finder.execute
.inc_relations_for_view
- .reject { |n| n.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] =
- if noteable.discussions_rendered_on_frontend?
+ if use_note_serializer?
note_serializer.represent(notes)
else
notes.map { |note| note_json(note) }
@@ -29,6 +31,7 @@ module NotesActions
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],
@@ -38,7 +41,7 @@ module NotesActions
@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|
@@ -46,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|
@@ -59,6 +64,7 @@ module NotesActions
format.html { redirect_back_or_default }
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def destroy
if note.editable?
@@ -89,14 +95,15 @@ module NotesActions
if note.persisted?
attrs[:valid] = true
- if noteable.nil? || noteable.discussions_rendered_on_frontend?
+ if use_note_serializer?
attrs.merge!(note_serializer.represent(note))
else
attrs.merge!(
id: note.id,
discussion_id: note.discussion_id(noteable),
html: note_html(note),
- note: note.note
+ note: note.note,
+ on_image: note.try(:on_image?)
)
discussion = note.to_discussion(noteable)
@@ -107,6 +114,8 @@ module NotesActions
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
@@ -122,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'
@@ -132,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(
@@ -183,7 +196,11 @@ module NotesActions
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
@@ -199,20 +216,31 @@ module NotesActions
end
def note_project
- return @note_project if defined?(@note_project)
- return nil unless project
+ strong_memoize(:note_project) do
+ return nil unless project
- note_project_id = params[:note_project_id]
+ note_project_id = params[:note_project_id]
- @note_project =
- if note_project_id.present?
- Project.find(note_project_id)
- else
- project
- end
+ 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)
- return access_denied! unless can?(current_user, :create_note, @note_project)
+ the_project
+ end
+ end
- @note_project
+ 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_commits.rb b/app/controllers/concerns/renders_commits.rb
index bb2c1dfa00a..fb41dc1e8a8 100644
--- a/app/controllers/concerns/renders_commits.rb
+++ b/app/controllers/concerns/renders_commits.rb
@@ -1,6 +1,6 @@
module RendersCommits
def prepare_commits_for_rendering(commits)
- Banzai::CommitRenderer.render(commits, @project, current_user)
+ Banzai::CommitRenderer.render(commits, @project, current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables
commits
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 4791bc561a4..e7ef297879f 100644
--- a/app/controllers/concerns/renders_notes.rb
+++ b/app/controllers/concerns/renders_notes.rb
@@ -1,12 +1,14 @@
module RendersNotes
+ # 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)
preload_first_time_contribution_for_authors(noteable, notes)
- Banzai::NoteRenderer.render(notes, @project, current_user)
+ Notes::RenderService.new(current_user).execute(notes, @project)
notes
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
private
diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
index 0218ac83441..88d1b34bb06 100644
--- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
+++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
@@ -1,8 +1,6 @@
module RequiresWhitelistedMonitoringClient
extend ActiveSupport::Concern
- include Gitlab::CurrentSettings
-
included do
before_action :validate_ip_whitelisted_or_valid_token!
end
@@ -26,7 +24,7 @@ module RequiresWhitelistedMonitoringClient
token.present? &&
ActiveSupport::SecurityUtils.variable_size_secure_compare(
token,
- current_application_settings.health_check_access_token
+ Gitlab::CurrentSettings.health_check_access_token
)
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 ada0dde87fb..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
@@ -18,13 +19,13 @@ module SpammableActions
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?
+ 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
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
index ed253042701..6a8b1a4de7b 100644
--- a/app/controllers/concerns/with_performance_bar.rb
+++ b/app/controllers/concerns/with_performance_bar.rb
@@ -9,9 +9,19 @@ module WithPerformanceBar
return false unless Gitlab::PerformanceBar.enabled?(current_user)
if RequestStore.active?
- RequestStore.fetch(:peek_enabled) { cookies[:perf_bar_enabled].present? }
+ RequestStore.fetch(:peek_enabled) { cookie_or_default_value }
else
- cookies[:perf_bar_enabled].present?
+ 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 8057a0b455c..79f563bef86 100644
--- a/app/controllers/dashboard/groups_controller.rb
+++ b/app/controllers/dashboard/groups_controller.rb
@@ -1,33 +1,10 @@
class Dashboard::GroupsController < Dashboard::ApplicationController
- def index
- @sort = params[:sort] || 'id_desc'
-
- @groups =
- if params[:parent_id] && Group.supports_nested_groups?
- parent = Group.find_by(id: params[:parent_id])
+ include GroupTree
- if can?(current_user, :read_group, parent)
- GroupsFinder.new(current_user, parent: parent).execute
- else
- Group.none
- end
- else
- current_user.groups
- end
+ skip_cross_project_access_check :index
- @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
- @groups = @groups.includes(:route)
- @groups = @groups.sort(@sort)
- @groups = @groups.page(params[:page])
-
- 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/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index cd94a36a6e7..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,10 +47,12 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
def load_projects(finder_params)
- ProjectsFinder
- .new(params: finder_params, current_user: current_user)
- .execute
- .includes(:route, :creator, namespace: [:route, :owner])
+ 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
@@ -57,5 +61,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
@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 a8b2b93b458..e89eaf7edda 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -7,9 +7,8 @@ class Dashboard::TodosController < Dashboard::ApplicationController
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
@@ -60,7 +59,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
def find_todos
- @todos ||= TodosFinder.new(current_user, params).execute
+ @todos ||= TodosFinder.new(current_user, todo_params).execute
end
def todos_counts
@@ -69,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 19a5db6fd17..280ed93faf8 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -32,6 +32,8 @@ class DashboardController < Dashboard::ApplicationController
@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 762c6ebf3a3..c7273606a85 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -1,5 +1,6 @@
class Explore::ProjectsController < Explore::ApplicationController
include ParamsBackwardCompatibility
+ include RendersMemberAccess
before_action :set_non_archived_param
@@ -49,10 +50,12 @@ class Explore::ProjectsController < Explore::ApplicationController
private
def load_projects
- ProjectsFinder.new(current_user: current_user, params: params)
- .execute
- .includes(:route, namespace: :route)
- .page(params[:page])
- .without_count
+ 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 96ce686c989..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
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 7a7bcb1a3d2..acf6aaf57f4 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -75,12 +75,11 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def milestones
- search_params = params.merge(group_ids: group.id)
-
milestones = MilestonesFinder.new(search_params).execute
legacy_milestones = GroupMilestone.build_collection(group, group_projects, params)
- milestones + legacy_milestones
+ @sort = params[:sort] || 'due_date_asc'
+ MilestoneArray.sort(milestones + legacy_milestones, @sort)
end
def milestone
@@ -93,4 +92,8 @@ class Groups::MilestonesController < Groups::ApplicationController
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
index 0142ad8278c..4bf6a2a3ad1 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -1,6 +1,7 @@
module Groups
module Settings
class CiCdController < Groups::ApplicationController
+ skip_cross_project_access_check :show
before_action :authorize_admin_pipeline!
def show
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
index 10038ff3ad9..cb8771bc97e 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -1,60 +1,45 @@
module Groups
class VariablesController < Groups::ApplicationController
- before_action :variable, only: [:show, :update, :destroy]
before_action :authorize_admin_build!
- def index
- redirect_to group_settings_ci_cd_path(group)
- end
+ 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 variable.update(variable_params)
- redirect_to group_variables_path(group),
- notice: 'Variable was successfully updated.'
+ if @group.update(group_variables_params)
+ respond_to do |format|
+ format.json { return render_group_variables }
+ end
else
- render "show"
+ respond_to do |format|
+ format.json { render_error }
+ end
end
end
- def create
- @variable = group.variables.create(variable_params)
- .present(current_user: current_user)
+ private
- if @variable.persisted?
- redirect_to group_settings_ci_cd_path(group),
- notice: 'Variable was successfully created.'
- else
- render "show"
- end
+ def render_group_variables
+ render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) }
end
- def destroy
- if variable.destroy
- redirect_to group_settings_ci_cd_path(group),
- status: 302,
- notice: 'Variable was successfully removed.'
- else
- redirect_to group_settings_ci_cd_path(group),
- status: 302,
- notice: 'Failed to remove the variable.'
- end
+ def render_error
+ render status: :bad_request, json: @group.errors.full_messages
end
- private
-
- def variable_params
- params.require(:variable).permit(*variable_params_attributes)
+ def group_variables_params
+ params.permit(variables_attributes: [*variable_params_attributes])
end
def variable_params_attributes
- %i[key value protected]
- end
-
- def variable
- @variable ||= group.variables.find(params[:id]).present(current_user: current_user)
+ %i[id key value protected _destroy]
end
def authorize_admin_build!
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 3769a2cde33..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_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
@@ -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,22 +99,21 @@ 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]
+ def transfer
+ parent_group = Group.find_by(id: params[:new_parent_group_id])
+ service = ::Groups::TransferService.new(@group, current_user)
- 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
- @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!
allowed = if params[:parent_id].present?
parent = Group.find_by(id: params[:parent_id])
@@ -142,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,
@@ -165,9 +159,21 @@ class GroupsController < Groups::ApplicationController
end
def load_events
+ 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_controller.rb b/app/controllers/health_controller.rb
index 98c2aaa3526..16abf7bab7e 100644
--- a/app/controllers/health_controller.rb
+++ b/app/controllers/health_controller.rb
@@ -1,5 +1,5 @@
class HealthController < ActionController::Base
- protect_from_forgery with: :exception
+ protect_from_forgery with: :exception, except: :storage_check
include RequiresWhitelistedMonitoringClient
CHECKS = [
@@ -8,7 +8,8 @@ class HealthController < ActionController::Base
Gitlab::HealthChecks::Redis::CacheCheck,
Gitlab::HealthChecks::Redis::QueuesCheck,
Gitlab::HealthChecks::Redis::SharedStateCheck,
- Gitlab::HealthChecks::FsShardsCheck
+ Gitlab::HealthChecks::FsShardsCheck,
+ Gitlab::HealthChecks::GitalyCheck
].freeze
def readiness
@@ -23,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 572915a4930..a394521698c 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -5,7 +5,7 @@ class HelpController < ApplicationController
# Taken from Jekyll
# https://github.com/jekyll/jekyll/blob/3.5-stable/lib/jekyll/document.rb#L13
- YAML_FRONT_MATTER_REGEXP = %r!\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)!m
+ YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
def index
# Remove YAML frontmatter so that it doesn't look weird
@@ -57,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/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 ab18d86dcae..69fb8121ded 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -36,23 +36,28 @@ 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)
+ 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
+ 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
+
+ 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
diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb
index 407154e59a0..18f1d20f5a9 100644
--- a/app/controllers/import/gitlab_controller.rb
+++ b/app/controllers/import/gitlab_controller.rb
@@ -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 510813846a4..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
@@ -39,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 0982a61902b..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,7 +62,7 @@ class InvitesController < ApplicationController
case source
when Project
project = member.source
- label = "project #{project.name_with_namespace}"
+ label = "project #{project.full_name}"
path = project_path(project)
when Group
group = member.source
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 37587a52eaf..33b682d2859 100644
--- a/app/controllers/metrics_controller.rb
+++ b/app/controllers/metrics_controller.rb
@@ -3,10 +3,17 @@ class MetricsController < ActionController::Base
protect_from_forgery with: :exception
- before_action :validate_prometheus_metrics
-
def index
- render text: metrics_service.metrics_text, content_type: 'text/plain; version=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 9612b8d8514..8440945ab43 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -10,8 +10,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
end
- if Gitlab::LDAP::Config.enabled?
- Gitlab::LDAP::Config.available_servers.each do |server|
+ if Gitlab::Auth::LDAP::Config.enabled?
+ Gitlab::Auth::LDAP::Config.available_servers.each do |server|
define_method server['provider_name'] do
ldap
end
@@ -31,7 +31,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
@@ -54,7 +54,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'
@@ -62,13 +62,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
@@ -83,6 +83,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if ticket
handle_service_ticket oauth['provider'], ticket
end
+
handle_omniauth
end
@@ -90,6 +91,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if params['sid']
handle_service_ticket oauth['provider'], params['sid']
end
+
handle_omniauth
end
@@ -98,22 +100,26 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
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
@@ -122,6 +128,7 @@ 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)
@@ -135,10 +142,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
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
@@ -163,6 +170,13 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
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
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index fda944adecd..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_creation?
+ 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&.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 ddb67d1c4d1..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
+ @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
index 689c76059f6..38e3eacd229 100644
--- a/app/controllers/profiles/gpg_keys_controller.rb
+++ b/app/controllers/profiles/gpg_keys_controller.rb
@@ -2,7 +2,7 @@ class Profiles::GpgKeysController < Profiles::ApplicationController
before_action :set_gpg_key, only: [:destroy, :revoke]
def index
- @gpg_keys = current_user.gpg_keys
+ @gpg_keys = current_user.gpg_keys.with_subkeys
@gpg_key = GpgKey.new
end
diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb
index 89d6d7f1b52..f0e5d2aa94e 100644
--- a/app/controllers/profiles/keys_controller.rb
+++ b/app/controllers/profiles/keys_controller.rb
@@ -11,7 +11,7 @@ class Profiles::KeysController < Profiles::ApplicationController
end
def create
- @key = Keys::CreateService.new(current_user, key_params).execute
+ @key = Keys::CreateService.new(current_user, key_params.merge(ip_address: request.remote_ip)).execute
if @key.persisted?
redirect_to profile_key_path(@key)
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 7beb52dd8e8..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!
- 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 c1cc509a748..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 cce2a847b53..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.'
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 d83824fef06..dbf61a17724 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -10,7 +10,7 @@ class ProfilesController < Profiles::ApplicationController
def update
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"
@@ -24,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
@@ -61,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 d7dd8ddcb7d..6025a40348b 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -2,7 +2,6 @@ class Projects::ApplicationController < ApplicationController
include RoutableActions
skip_before_action :authenticate_user!
- before_action :redirect_git_extension
before_action :project
before_action :repository
layout 'project'
@@ -11,15 +10,6 @@ class Projects::ApplicationController < ApplicationController
private
- def redirect_git_extension
- # Redirect from
- # localhost/group/project.git
- # to
- # localhost/group/project
- #
- redirect_to url_for(params.merge(format: nil)) if params[:format] == 'git'
- end
-
def project
return @project if @project
return nil unless params[:project_id] || params[:id]
@@ -96,4 +86,8 @@ class Projects::ApplicationController < ApplicationController
def require_pages_enabled!
not_found unless @project.pages_available?
end
+
+ def check_issues_available!
+ return render_404 unless @project.feature_available?(:issues, current_user)
+ end
end
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index eb010923466..0837451cc49 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -29,13 +29,17 @@ 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
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/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 2b8f3977e6e..405726c017c 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -5,9 +5,6 @@ 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]
@@ -41,6 +38,8 @@ class Projects::BlobController < Projects::ApplicationController
end
format.json do
+ page_title @blob.path, @ref, @project.full_name
+
show_json
end
end
@@ -59,7 +58,6 @@ class Projects::BlobController < Projects::ApplicationController
create_commit(Files::UpdateService, success_path: -> { after_edit_path },
failure_view: :edit,
failure_path: project_blob_path(@project, @id))
-
rescue Files::UpdateService::FileChangedError
@conflict = true
render :edit
@@ -130,13 +128,12 @@ 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_project_merge_request_path(from_merge_request.target_project, from_merge_request) +
"##{hexdigest(@path)}"
@@ -153,6 +150,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]
@@ -203,6 +201,7 @@ class Projects::BlobController < Projects::ApplicationController
tree_path = path_segments.join('/')
render json: json.merge(
+ id: @blob.id,
path: blob.path,
name: blob.name,
extension: blob.extension,
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index d1b99ecce4a..949e54ff819 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -2,6 +2,7 @@ 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
@@ -20,7 +21,7 @@ class Projects::BoardsController < Projects::ApplicationController
private
def assign_endpoint_vars
- @boards_endpoint = project_boards_url(project)
+ @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)
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index a9cce578366..965cece600e 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -7,14 +7,22 @@ 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_recently_updated
- @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|
@@ -26,7 +34,9 @@ class Projects::BranchesController < Projects::ApplicationController
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
@@ -39,19 +49,21 @@ 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)
@@ -65,7 +77,7 @@ class Projects::BranchesController < Projects::ApplicationController
end
format.json do
- if result[:status] == :success
+ if success
render json: { name: branch_name, url: project_tree_url(@project, branch_name) }
else
render json: result[:messsage], status: :unprocessable_entity
@@ -119,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/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 a62f05db7db..effb484ef0f 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -12,20 +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 do
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37599
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- render
- end
- end
+ format.html { render }
format.diff { render text: @commit.to_diff }
format.patch { render text: @commit.to_patch }
end
@@ -55,9 +52,27 @@ class Projects::CommitController < Projects::ApplicationController
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
@@ -104,7 +119,7 @@ class Projects::CommitController < Projects::ApplicationController
end
def commit
- @noteable = @commit ||= @project.commit(params[:id])
+ @noteable = @commit ||= @project.commit_by(oid: params[:id])
end
def define_commit_vars
@@ -131,6 +146,23 @@ 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, @commit)
end
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 4a841bf2073..7b7cb52d7ed 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -4,15 +4,13 @@ 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
- @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)
@@ -48,6 +46,7 @@ class Projects::CommitsController < Projects::ApplicationController
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]
@@ -58,6 +57,11 @@ class Projects::CommitsController < Projects::ApplicationController
@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 3cb4eb23981..2b0c2ca97c0 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -17,10 +17,8 @@ class Projects::CompareController < Projects::ApplicationController
def show
apply_diff_view_cookie!
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37430
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- render
- end
+
+ render
end
def diff_for_path
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 cf8829ba95b..f43ef2e5f2f 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -24,9 +24,10 @@ class Projects::DeployKeysController < Projects::ApplicationController
def create
@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..ee507009e50 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,45 @@ 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
+
+ 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
- private
-
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 29e223a5273..52d528e816e 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -34,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
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 68978f8fdd1..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]
@@ -54,4 +55,8 @@ class Projects::ForksController < Projects::ApplicationController
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/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index f59200d3b1f..f58ee3e9109 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -13,11 +13,7 @@ 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
@@ -32,7 +28,9 @@ 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
@@ -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/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index 85d35900c71..dd7aa1a67b9 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -21,6 +21,7 @@ class Projects::HooksController < Projects::ApplicationController
@hooks = @project.hooks.select(&:persisted?)
flash[:alert] = @hook.errors.full_messages.join.html_safe
end
+
redirect_to project_settings_integrations_path(@project)
end
@@ -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/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index a3ec79a56d9..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 :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_issues_index, only: [:index]
+ 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, :move]
+ 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,15 +25,7 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to :html
def index
- 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
@@ -67,32 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController
respond_with(@issue)
end
- def show
- @noteable = @issue
- @note = @project.notes.new(noteable: @issue)
-
- respond_to do |format|
- format.html
- format.json do
- render json: serializer.represent(@issue)
- end
- end
- end
-
- def discussions
- notes = @issue.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, @issue)
-
- render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions)
- end
-
def create
create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
@@ -120,25 +87,6 @@ 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)
-
- respond_to do |format|
- format.html do
- recaptcha_check_with_fallback { render :edit }
- end
-
- format.json do
- render_issue_json
- end
- end
-
- rescue ActiveRecord::StaleObjectError
- render_conflict_response
- end
-
def move
params.require(:move_to_project_id)
@@ -160,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
@@ -196,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.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])
@@ -230,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)
@@ -246,22 +176,10 @@ class Projects::IssuesController < Projects::ApplicationController
project_issue_path(@project, @issue)
end
- def authorize_update_issue!
- render_404 unless can?(current_user, :update_issue, @issue)
- end
-
- def authorize_admin_issues!
- render_404 unless can?(current_user, :admin_issue, @project)
- end
-
def authorize_create_merge_request!
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)
- end
-
def render_issue_json
if @issue.valid?
render json: serializer.represent(@issue)
@@ -286,6 +204,7 @@ class Projects::IssuesController < Projects::ApplicationController
state_event
task_num
lock_version
+ discussion_locked
] + [{ label_ids: [], assignee_ids: [] }]
end
@@ -304,4 +223,22 @@ class Projects::IssuesController < Projects::ApplicationController
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 96abdac91b6..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
@@ -109,7 +110,7 @@ class Projects::JobsController < Projects::ApplicationController
def erase
if @build.erase(erased_by: current_user)
redirect_to project_job_path(project, @build),
- notice: "Build has been successfully erased!"
+ notice: "Job has been successfully erased!"
else
respond_422
end
@@ -131,6 +132,10 @@ 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)
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 480a2dff262..e0f4710175f 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -111,6 +111,7 @@ class Projects::LabelsController < Projects::ApplicationController
begin
return render_404 unless promote_service.execute(@label)
+
respond_to do |format|
format.html do
redirect_to(project_labels_path(@project),
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/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index 6602b204fcb..793ae03fb88 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -2,7 +2,6 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
before_action :check_merge_requests_available!
before_action :merge_request
before_action :authorize_read_merge_request!
- before_action :ensure_ref_fetched
private
@@ -10,12 +9,6 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
@issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
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_request_params
params.require(:merge_request).permit(merge_request_params_attributes)
end
@@ -34,7 +27,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
:target_project_id,
:task_num,
:title,
-
+ :discussion_locked,
label_ids: []
]
end
diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb
index 28afef101a9..366524b0783 100644
--- a/app/controllers/projects/merge_requests/conflicts_controller.rb
+++ b/app/controllers/projects/merge_requests/conflicts_controller.rb
@@ -53,7 +53,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
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::Conflict::ResolutionError => e
+ rescue Gitlab::Git::Conflict::Resolver::ResolutionError => e
render status: :bad_request, json: { message: e.message }
end
end
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index 1096afbb798..a90030a8312 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -4,13 +4,16 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
include RendersCommits
skip_before_action :merge_request
- skip_before_action :ensure_ref_fetched
+ 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
- define_new_vars
+ # 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
@@ -41,11 +44,8 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
end
def diffs
- @diffs = if @merge_request.can_be_created
- @merge_request.diffs(diff_options)
- else
- []
- end
+ @diffs = @merge_request.diffs(diff_options) if @merge_request.can_be_created
+
@diff_notes_disabled = true
@environment = @merge_request.environments_for(current_user).last
@@ -66,7 +66,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
if params[:ref].present?
@ref = params[:ref]
- @commit = @repository.commit("refs/heads/#{@ref}")
+ @commit = @repository.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref)
end
render layout: false
@@ -75,9 +75,9 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
def branch_to
@target_project = selected_target_project
- if params[:ref].present?
+ if @target_project && params[:ref].present?
@ref = params[:ref]
- @commit = @target_project.commit("refs/heads/#{@ref}")
+ @commit = @target_project.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref)
end
render layout: false
@@ -85,7 +85,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
def update_branches
@target_project = selected_target_project
- @target_branches = @target_project.repository.branch_names
+ @target_branches = @target_project ? @target_project.repository.branch_names : []
render layout: false
end
@@ -111,19 +111,23 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
@commits = prepare_commits_for_rendering(@merge_request.commits)
@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
set_pipeline_variables
end
def selected_target_project
- if @project.id.to_s == params[:target_project_id] || @project.forked_project_link.nil?
+ 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_project_link.forked_from_project
+ @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
index 7d16e77ef66..fe8525a488c 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -4,16 +4,14 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
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
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37431
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- render json: { html: view_to_html_string("projects/merge_requests/diffs/_diffs") }
- end
+ render json: { html: view_to_html_string("projects/merge_requests/diffs/_diffs") }
end
def diff_for_path
@@ -23,18 +21,33 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
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 params[:diff_id]
- @merge_request.merge_request_diffs.viewable.find(params[:diff_id])
+ 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
- @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff.order_id_desc
+ return unless @merge_request_diff
+
@comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id }
- if params[:start_sha].present?
- @start_sha = params[:start_sha]
+ if @start_sha = params[:start_sha].presence
@start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha }
unless @start_version
@@ -43,20 +56,18 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
end
- @compare =
- if @start_sha
- @merge_request_diff.compare_with(@start_sha)
- else
- @merge_request_diff
- end
-
- @diffs = @compare.diffs(diff_options)
+ 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
+ noteable_id: @merge_request.id,
+ commit_id: @commit&.id
}
@diff_notes_disabled = false
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index c5204080333..a1af125547c 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -7,37 +7,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
include IssuableCollections
skip_before_action :merge_request, only: [:index, :bulk_update]
- skip_before_action :ensure_ref_fetched, only: [:index, :bulk_update]
-
- before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort]
-
+ 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 :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)
- @total_pages = merge_requests_page_count(@merge_requests)
-
- return if redirect_out_of_range(@merge_requests, @total_pages)
-
- 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
@@ -52,7 +29,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def show
validates_merge_request
- ensure_ref_fetched
close_merge_request_without_source_project
check_if_can_be_merged
@@ -74,16 +50,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
set_pipeline_variables
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37432
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- render
- end
+ 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
@@ -103,9 +76,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def commits
# Get commits from repository
# or from cache if already merged
- @commits = prepare_commits_for_rendering(@merge_request.commits)
- @note_counts = Note.where(commit_id: @commits.map(&:id))
- .group(:commit_id).count
+ @commits =
+ prepare_commits_for_rendering(@merge_request.commits.with_pipeline_status)
render json: { html: view_to_html_string('projects/merge_requests/_commits') }
end
@@ -158,7 +130,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
.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
@@ -174,7 +146,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
.new(@project, current_user)
.cancel(@merge_request)
- render json: serializer.represent(@merge_request)
+ render json: serialize_widget(@merge_request)
end
def merge
@@ -250,20 +222,18 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
render json: environments
end
+ def rebase
+ RebaseWorker.perform_async(@merge_request.id, current_user.id)
+
+ render nothing: true, status: 200
+ 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 validates_merge_request
# Show git not found page
# if there is no saved commits between source & target branch
@@ -315,15 +285,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@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?
+ 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
@merge_request.merge_async(current_user.id, params)
@@ -339,6 +309,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
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
@@ -348,4 +322,23 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@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 c94384d2a1a..75b17d05e22 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -2,13 +2,13 @@ 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
@@ -69,13 +69,21 @@ class Projects::MilestonesController < Projects::ApplicationController
end
end
+ def promote
+ promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone)
+ flash[:notice] = "Milestone has been promoted to group milestone."
+ redirect_to group_milestone_path(project.group, promoted_milestone.iid)
+ 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
@@ -84,12 +92,6 @@ class Projects::MilestonesController < Projects::ApplicationController
def milestones
@milestones ||= begin
- if @project.group && can?(current_user, :read_group, @project.group)
- group = @project.group
- end
-
- search_params = params.merge(project_ids: @project.id, group_ids: group&.id)
-
MilestonesFinder.new(search_params).execute
end
end
@@ -105,4 +107,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 fb68dd771a1..35fec229db7 100644
--- a/app/controllers/projects/network_controller.rb
+++ b/app/controllers/projects/network_controller.rb
@@ -2,31 +2,29 @@ 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
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37602
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- @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
- if @options[:extended_sha1] && !@commit
- flash.now[:alert] = "Git revision '#{@options[:extended_sha1]}' does not exist."
- end
- end
+ @url = project_network_path(@project, @ref, @options.merge(format: :json))
+ @commit_url = project_commit_path(@project, 'ae45ca32').gsub("ae45ca32", "%s")
- format.json do
- @graph = Network::Graph.new(project, @ref, @commit, @options[:filter_ref])
+ respond_to do |format|
+ format.html do
+ if @options[:extended_sha1] && !@commit
+ flash.now[:alert] = "Git revision '#{@options[:extended_sha1]}' does not exist."
end
end
- render
+ format.json do
+ @graph = Network::Graph.new(project, @ref, @commit, @options[:filter_ref])
+ end
end
+
+ render
end
def assign_commit
@@ -35,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_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index 15e77d854dc..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,16 +12,41 @@ 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 project_pages_path(@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
@@ -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 ec7c645df5a..b478e7b5e05 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -1,9 +1,11 @@
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]
- before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create]
+ before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play]
before_action :authorize_admin_pipeline_schedule!, only: [:destroy]
def index
@@ -40,6 +42,18 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
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)
@@ -60,6 +74,17 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
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
@@ -70,6 +95,10 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
variables_attributes: [:id, :key, :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
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 7ad7b3003af..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
@@ -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 abab2e2f0c9..06ce7328fb5 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -6,11 +6,19 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
end
def update
- if @project.update(update_params)
- flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
- redirect_to project_settings_ci_cd_path(@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."
+
+ if service.run_auto_devops_pipeline?
+ 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
+
+ redirect_to project_settings_ci_cd_path(@project)
+ else
+ render 'show'
+ end
end
end
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index d925dcd21ff..e9b4679f94c 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -1,5 +1,6 @@
class Projects::ProjectMembersController < Projects::ApplicationController
include MembershipActions
+ include MembersPresentation
include SortingHelper
# Authorize
@@ -20,33 +21,11 @@ class Projects::ProjectMembersController < Projects::ApplicationController
@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_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
- def update
- @project_member = @project.project_members.find(params[:id])
-
- return render_403 unless can?(current_user, :update_project_member, @project_member)
-
- @project_member.update_attributes(member_params)
- end
-
- def resend_invite
- redirect_path = project_project_members_path(@project)
-
- @project_member = @project.project_members.find(params[:id])
-
- if @project_member.invite?
- @project_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
-
def import
@projects = current_user.authorized_projects.order_id_desc
end
@@ -65,12 +44,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController
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 2fd015df688..2376f469213 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -56,9 +56,12 @@ class Projects::RefsController < Projects::ApplicationController
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
+ commit: last_commit,
+ type: content.type,
+ commit_path: commit_path
}
end
end
@@ -70,6 +73,11 @@ class Projects::RefsController < Projects::ApplicationController
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 71e7dc70a4d..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_index_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_index_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 ae72bd03cfb..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_index_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_index_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/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 9f9773575a5..c950d0f7001 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -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
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index b029b31f9af..86717bb7242 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -11,6 +11,16 @@ module Projects
define_auto_devops_variables
end
+ def reset_cache
+ if ResetProjectCacheService.new(@project, current_user).execute
+ flash[:notice] = _("Project cache successfully reset.")
+ else
+ flash[:error] = _("Unable to reset project cache.")
+ end
+
+ redirect_to project_pipelines_path(@project)
+ end
+
private
def define_runners_variables
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/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 1fc276b8c03..ee9b5458282 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -26,6 +26,7 @@ class Projects::TreeController < Projects::ApplicationController
respond_to do |format|
format.html do
+ lfs_blob_ids
@last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit
end
@@ -35,7 +36,12 @@ class Projects::TreeController < Projects::ApplicationController
end
format.json do
- render json: TreeSerializer.new(project: @project, repository: @repository, ref: @ref).represent(@tree)
+ 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
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index 4d2fb17a19b..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.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 6a825137564..7eb509e2e64 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -1,60 +1,41 @@
class Projects::VariablesController < Projects::ApplicationController
- before_action :variable, only: [:show, :update, :destroy]
before_action :authorize_admin_build!
- layout 'project_settings'
-
- def index
- redirect_to project_settings_ci_cd_path(@project)
- end
-
def show
+ respond_to do |format|
+ format.json do
+ render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) }
+ end
+ end
end
def update
- if variable.update(variable_params)
- redirect_to project_variables_path(project),
- notice: 'Variable was successfully updated.'
+ if @project.update(variables_params)
+ respond_to do |format|
+ format.json { return render_variables }
+ end
else
- render "show"
+ respond_to do |format|
+ format.json { render_error }
+ end
end
end
- def create
- @variable = project.variables.create(variable_params)
- .present(current_user: current_user)
+ private
- if @variable.persisted?
- redirect_to project_settings_ci_cd_path(project),
- notice: 'Variable was successfully created.'
- else
- render "show"
- end
+ def render_variables
+ render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) }
end
- def destroy
- if variable.destroy
- redirect_to project_settings_ci_cd_path(project),
- status: 302,
- notice: 'Variable was successfully removed.'
- else
- redirect_to project_settings_ci_cd_path(project),
- status: 302,
- notice: 'Failed to remove the variable.'
- end
+ def render_error
+ render status: :bad_request, json: @project.errors.full_messages
end
- private
-
- def variable_params
- params.require(:variable).permit(*variable_params_attributes)
+ def variables_params
+ params.permit(variables_attributes: [*variable_params_attributes])
end
def variable_params_attributes
%i[id key value protected _destroy]
end
-
- def variable
- @variable ||= project.variables.find(params[:id]).present(current_user: current_user)
- end
end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 968d880886c..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]
@@ -55,8 +54,8 @@ class Projects::WikisController < Projects::ApplicationController
else
render 'edit'
end
- rescue WikiPage::PageChangedError
- @conflict = true
+ rescue WikiPage::PageChangedError, WikiPage::PageRenameError => e
+ @error = e
render 'edit'
end
@@ -76,7 +75,11 @@ 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(
project_wiki_path(@project, :home),
notice: "Page not found"
@@ -96,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
@@ -114,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)
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index b13034d3333..ee197c75764 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -1,12 +1,16 @@
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
@@ -37,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
@@ -99,7 +103,7 @@ class ProjectsController < Projects::ApplicationController
def show
if @project.import_in_progress?
- redirect_to project_import_path(@project)
+ redirect_to project_import_path(@project, custom_import_params)
return
end
@@ -110,6 +114,8 @@ class ProjectsController < Projects::ApplicationController
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
@@ -124,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
@@ -200,6 +206,7 @@ class ProjectsController < Projects::ApplicationController
else
flash[:alert] = _("Project export could not be deleted.")
end
+
redirect_to(edit_project_path(@project))
end
@@ -258,18 +265,6 @@ class ProjectsController < Projects::ApplicationController
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
@@ -279,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
@@ -294,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'
@@ -310,6 +310,8 @@ class ProjectsController < Projects::ApplicationController
@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
@@ -344,6 +346,7 @@ class ProjectsController < Projects::ApplicationController
:tag_list,
:visibility_level,
:template_name,
+ :merge_method,
project_feature_attributes: %i[
builds_access_level
@@ -356,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
@@ -397,6 +408,19 @@ class ProjectsController < Projects::ApplicationController
end
def project_export_enabled
- render_404 unless current_application_settings.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 19e38993038..8acefd58e77 100644
--- a/app/controllers/root_controller.rb
+++ b/app/controllers/root_controller.rb
@@ -23,7 +23,7 @@ class RootController < Dashboard::ProjectsController
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
@@ -48,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 fbad9ba7db8..983f888b8ec 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -1,9 +1,14 @@
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'
def show
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index be6491d042c..f3a4aa849c7 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -8,14 +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 = Gitlab::LDAP::Config.available_servers
+ @ldap_servers = Gitlab::Auth::LDAP::Config.available_servers
super
end
@@ -27,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
@@ -42,6 +45,14 @@ 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_total, 'User sign in count')
end
@@ -53,9 +64,9 @@ class SessionsController < Devise::SessionsController
user = User.admins.last
- return unless user && user.require_password_creation?
+ 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
@@ -75,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
@@ -123,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 c1cdc7c9831..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]
@@ -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
diff --git a/app/controllers/unicorn_test_controller.rb b/app/controllers/unicorn_test_controller.rb
deleted file mode 100644
index ed04bd1f77d..00000000000
--- a/app/controllers/unicorn_test_controller.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# :nocov:
-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
-# :nocov:
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 16a74f82d3f..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.public_send(upload_mount) # rubocop:disable GitlabSecurity/PublicSend
-
- 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 4ee855806ab..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]
@@ -102,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
@@ -132,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/autocomplete_users_finder.rb b/app/finders/autocomplete_users_finder.rb
index b8f52e31926..e8a03947f59 100644
--- a/app/finders/autocomplete_users_finder.rb
+++ b/app/finders/autocomplete_users_finder.rb
@@ -1,6 +1,12 @@
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,
- :page, :per_page, :author_id, :params
+ :author_id, :params
def initialize(params:, current_user:, project:, group:)
@current_user = current_user
@@ -8,8 +14,6 @@ class AutocompleteUsersFinder
@group = group
@search = params[:search]
@skip_users = params[:skip_users]
- @page = params[:page]
- @per_page = params[:per_page]
@author_id = params[:author_id]
@params = params
end
@@ -20,7 +24,7 @@ class AutocompleteUsersFinder
items = items.reorder(:name)
items = items.search(search) if search.present?
items = items.where.not(id: skip_users) if skip_users.present?
- items = items.page(page).per(per_page)
+ items = items.limit(LIMIT)
if params[:todo_filter].present? && current_user
items = items.todo_authors(current_user.id, params[:todo_state_filter])
@@ -45,16 +49,20 @@ class AutocompleteUsersFinder
def find_users
return users_from_project if project
- return group.users if group
+ return group.users_with_parents if group
return User.all if current_user
User.none
end
def users_from_project
- user_ids = project.team.users.pluck(:id)
- user_ids << author_id if author_id.present?
+ if author_id.present?
+ union = Gitlab::SQL::Union
+ .new([project.authorized_users, User.where(id: author_id)])
- User.where(id: user_ids)
+ 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/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/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 0c4c4b10fb6..0282b378d88 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -15,6 +15,8 @@
# 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
@@ -22,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
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 0a2e3c709d9..b2d4f9938ff 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -19,14 +19,52 @@
# 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
@@ -34,31 +72,28 @@ 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_my_reaction_emoji(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)
- end
-
- def find_by(*params)
- execute.find_by(*params)
+ by_my_reaction_emoji(items)
end
def row_count
@@ -90,10 +125,6 @@ class IssuableFinder
counts
end
- def find_by!(*params)
- execute.find_by!(*params)
- end
-
def group
return @group if defined?(@group)
@@ -125,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
@@ -256,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'
@@ -351,19 +390,14 @@ 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
@@ -376,42 +410,6 @@ class IssuableFinder
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
- 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]
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index d2275139c42..2a27ff0e386 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -15,12 +15,22 @@
# 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
@@ -40,7 +50,55 @@ class IssuesFinder < IssuableFinder
private
def init_collection
- with_confidentiality_access_check
+ 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?
diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb
index ce432ddbfe6..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
@@ -96,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 d0687d28c21..64dc1e6af0f 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -17,14 +17,46 @@
# 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 0a5a0ea2f35..f5d2b9f253a 100644
--- a/app/finders/milestones_finder.rb
+++ b/app/finders/milestones_finder.rb
@@ -8,6 +8,8 @@
# state - filters by state.
class MilestonesFinder
+ include FinderMethods
+
attr_reader :params, :project_ids, :group_ids
def initialize(params = {})
@@ -46,11 +48,7 @@ class MilestonesFinder
end
def order(items)
- if params.has_key?(:order)
- items.reorder(params[:order])
- else
- order_statement = Gitlab::Database.nulls_last_order('due_date', 'ASC')
- items.reorder(order_statement)
- end
+ order_statement = Gitlab::Database.nulls_last_order('due_date', 'ASC')
+ items.reorder(order_statement).order('title ASC')
end
end
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 eac6095d8dc..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
@@ -44,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
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 3502bf08971..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
@@ -105,10 +110,6 @@ class TodosFinder
ids
end
- def projects(items)
- ProjectsFinder.new(current_user: current_user, project_ids_relation: project_ids(items)).execute
- end
-
def type?
type.present? && %w(Issue MergeRequest).include?(type)
end
@@ -147,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..6f7f7c30d92
--- /dev/null
+++ b/app/finders/user_recent_events_finder.rb
@@ -0,0 +1,33 @@
+# 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
+
+ def initialize(current_user, target_user, params = {})
+ @current_user = current_user
+ @target_user = target_user
+ @params = params
+ end
+
+ def execute
+ target_user
+ .recent_events
+ .merge(projects_for_current_user)
+ .references(:project)
+ .with_associations
+ .limit_recent(20, params[:offset])
+ end
+
+ def projects_for_current_user
+ ProjectsFinder.new(current_user: current_user).execute
+ end
+end
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
index 33f7ae90598..edde8022ec9 100644
--- a/app/finders/users_finder.rb
+++ b/app/finders/users_finder.rb
@@ -15,6 +15,7 @@
#
class UsersFinder
include CreatedAtFilter
+ include CustomAttributesFilter
attr_accessor :current_user, :params
@@ -24,7 +25,7 @@ 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)
@@ -32,6 +33,7 @@ class UsersFinder
users = by_external_identity(users)
users = by_external(users)
users = by_created_at(users)
+ users = by_custom_attributes(users)
users
end
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index 8ad94d3f723..c037de33c22 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -1,33 +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.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
+
+ # 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 8d02d5de5c3..af9c8bf1bd3 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)
@@ -306,7 +320,7 @@ module ApplicationHelper
cookies["sidebar_collapsed"] == "true"
end
- def show_new_repo?
- cookies["new_repo"] == "true" && body_data_page != 'projects:show'
+ def locale_path
+ asset_path("locale/#{Gitlab::I18n.locale}/app.js")
end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 7bd34df5c95..4c4d7cca8a5 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -1,25 +1,23 @@
module ApplicationSettingsHelper
extend self
- include Gitlab::CurrentSettings
-
- delegate :gravatar_enabled?,
- :signup_enabled?,
- :password_authentication_enabled?,
+ 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'
@@ -30,9 +28,9 @@ 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
@@ -57,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][]'
@@ -72,14 +70,14 @@ 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
@@ -96,18 +94,49 @@ module ApplicationSettingsHelper
]
end
- def repository_storages_options_for_select
+ def repository_storages_options_for_select(selected)
options = Gitlab.config.repositories.storages.map do |name, storage|
["#{name} - #{storage['path']}", 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,
@@ -115,7 +144,14 @@ module ApplicationSettingsHelper
: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,
@@ -134,6 +170,9 @@ module ApplicationSettingsHelper
: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,
@@ -160,7 +199,9 @@ module ApplicationSettingsHelper
:metrics_port,
:metrics_sample_interval,
:metrics_timeout,
- :password_authentication_enabled,
+ :pages_domain_verification_enabled,
+ :password_authentication_enabled_for_web,
+ :password_authentication_enabled_for_git,
:performance_bar_allowed_group_id,
:performance_bar_enabled,
:plantuml_enabled,
@@ -188,6 +229,15 @@ module ApplicationSettingsHelper
: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,
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 66dc0b1e6f7..c109954f3a3 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -1,11 +1,9 @@
module AuthHelper
- include Gitlab::CurrentSettings
-
PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze
FORM_BASED_PROVIDERS = [/\Aldap/, 'crowd'].freeze
def ldap_enabled?
- Gitlab::LDAP::Config.enabled?
+ Gitlab::Auth::LDAP::Config.enabled?
end
def omniauth_enabled?
@@ -17,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)
@@ -41,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
index 483b957decb..16451993e93 100644
--- a/app/helpers/auto_devops_helper.rb
+++ b/app/helpers/auto_devops_helper.rb
@@ -9,21 +9,28 @@ module AutoDevopsHelper
end
def auto_devops_warning_message(project)
- missing_domain = !project.auto_devops&.has_domain?
- missing_service = !project.kubernetes_service&.active?
-
- if missing_service
+ if missing_auto_devops_service?(project)
params = {
- kubernetes: link_to('Kubernetes service', edit_project_service_path(project, 'kubernetes'))
+ kubernetes: link_to('Kubernetes cluster', project_clusters_path(project))
}
- if missing_domain
- _('Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly.') % params
+ 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 the %{kubernetes} to work correctly.') % params
+ _('Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly.') % params
end
- elsif missing_domain
+ 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 a4c226a6aad..21b6c0a8ad5 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -8,27 +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]
- avatar_url = options[:url] || avatar_icon(options[:user] || options[:user_email], avatar_size)
+
+ avatar_url = user_avatar_url_for(options.merge(size: avatar_size))
+
has_tooltip = options[:has_tooltip].nil? ? true : options[:has_tooltip]
- data_attributes = {}
+ 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' }
+ data_attributes[:container] = 'body'
+ end
+
+ if options[:lazy]
+ css_class << 'lazy'
+ data_attributes[:src] = avatar_url
+ avatar_url = LazyImageTagHelper.placeholder_image
end
- image_tag(
- avatar_url,
+ image_options = {
+ alt: "#{user_name}'s avatar",
+ src: avatar_url,
+ data: data_attributes,
class: css_class,
- alt: "#{user_name}'s avatar",
- title: user_name,
- data: data_attributes,
- lazy: true
- )
+ title: user_name
+ }
+
+ tag(:img, image_options)
end
def user_avatar(options = {})
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 18075ee8be7..5ff09b23a78 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,30 @@ module BlobHelper
%w(credits changelog news copying copyright license authors)
end
- def edit_path(project = @project, ref = @ref, path = @path, options = {})
+ def edit_blob_path(project = @project, ref = @ref, path = @path, options = {})
project_edit_blob_path(project,
- tree_join(ref, path),
- options[:link_opts])
+ 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 = project_forks_path(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 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 +49,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 = project_forks_path(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 +66,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 +101,24 @@ module BlobHelper
icon("#{file_type_icon_class('file', mode, name)} fw")
end
- def blob_raw_path
+ def blob_raw_url(only_path: false)
if @build && @entry
- raw_project_job_artifacts_path(@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_project_snippet_path(@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
- project_raw_path(@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,7 +215,7 @@ 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')
@@ -289,4 +276,55 @@ module BlobHelper
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 62ac208f16a..275e892b2e6 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -6,7 +6,7 @@ module BoardsHelper
def board_data
{
boards_endpoint: @boards_endpoint,
- lists_endpoint: board_lists_url(board),
+ lists_endpoint: board_lists_path(board),
board_id: board.id,
disabled: "#{!can?(current_user, :admin_list, current_board_parent)}",
issue_link_base: build_issue_link_base,
@@ -17,34 +17,35 @@ module BoardsHelper
end
def build_issue_link_base
- project_issues_path(@project)
- end
-
- def current_board_json
- board = @board || @boards.first
-
- board.to_json(
- only: [:id, :name, :milestone_id],
- include: {
- milestone: { only: [:title] }
- }
- )
+ if board.group_board?
+ "#{group_path(@board.group)}/:project_path/issues"
+ else
+ project_issues_path(@project)
+ end
end
def board_base_url
- project_boards_path(@project)
+ 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?(current_user)
+ current_board_parent.multiple_issue_boards_available?
end
def current_board_path(board)
- @current_board_path ||= project_board_path(current_board_parent, 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 ||= @project
+ @current_board_parent ||= @group || @project
end
def can_admin_issue?
@@ -58,7 +59,8 @@ module BoardsHelper
labels: labels_filter_path(true),
labels_endpoint: @labels_endpoint,
namespace_path: @namespace_path,
- project_path: @project&.try(:path)
+ project_path: @project&.path,
+ group_path: @group&.path
}
end
@@ -70,7 +72,8 @@ module BoardsHelper
field_name: 'issue[assignee_ids][]',
first_user: current_user&.username,
current_user: 'true',
- project_id: @project&.try(:id),
+ project_id: @project&.id,
+ group_id: @group&.id,
null_user: 'true',
multi_select: 'true',
'dropdown-header': dropdown_options[:data][:'dropdown-header'],
@@ -79,6 +82,6 @@ module BoardsHelper
end
def boards_link_text
- _("Board")
+ s_("IssueBoards|Board")
end
end
diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb
index 686437fc99a..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)
-
- project_branches_path(@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
index ee1b7ed083e..e88fe6bcd7e 100644
--- a/app/helpers/breadcrumbs_helper.rb
+++ b/app/helpers/breadcrumbs_helper.rb
@@ -10,11 +10,7 @@ module BreadcrumbsHelper
def breadcrumb_title_link
return @breadcrumb_link if @breadcrumb_link
- if controller.available_action?(:index)
- url_for(action: "index")
- else
- request.path
- end
+ request.path
end
def breadcrumb_title(title)
@@ -25,7 +21,7 @@ module BreadcrumbsHelper
def breadcrumb_list_item(link)
content_tag "li" do
- link + icon("angle-right", class: "breadcrumbs-list-angle")
+ link + sprite_icon("angle-right", size: 8, css_class: "breadcrumbs-list-angle")
end
end
diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb
index aa3a9a055a0..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: project_job_url(@project, @build),
- build_url: project_job_url(@project, @build, :json),
+ page_path: project_job_path(@project, @build),
build_status: @build.status,
build_stage: @build.stage,
log_state: ''
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 48cf30a48ab..3605d6a3c95 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -56,42 +56,36 @@ module ButtonHelper
end
end
- def http_clone_button(project, placement = 'right', append_link: true)
- klass = 'http-selector'
- klass << ' has-tooltip' if current_user.try(:require_password_creation?) || current_user.try(:require_personal_access_token_creation_for_git_auth?)
-
+ 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_creation?)
- _("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 8022547a6ad..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
- project_pipeline_path(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)
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 ef22cafc2e2..0333c29e2fd 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -60,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)
@@ -218,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 2c28dd81c87..8bf96c0905f 100644
--- a/app/helpers/compare_helper.rb
+++ b/app/helpers/compare_helper.rb
@@ -4,8 +4,8 @@ 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)
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/diff_helper.rb b/app/helpers/diff_helper.rb
index 28f591a4e22..b5ca39711bc 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -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
@@ -102,14 +104,23 @@ module DiffHelper
].join(' ').html_safe
end
- def diff_file_blob_raw_path(diff_file)
- project_raw_path(@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
- project_raw_path(@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)
@@ -149,12 +160,12 @@ module DiffHelper
end
def diff_file_changed_icon(diff_file)
- if diff_file.deleted_file? || diff_file.renamed_file?
- "minus"
+ if diff_file.deleted_file?
+ "file-deletion"
elsif diff_file.new_file?
- "plus"
+ "file-addition"
else
- "adjust"
+ "file-modified"
end
end
@@ -215,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/emails_helper.rb b/app/helpers/emails_helper.rb
index 5f11fe62030..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}"
@@ -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/events_helper.rb b/app/helpers/events_helper.rb
index b331693c789..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)
@@ -170,16 +172,6 @@ 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-src', 'data-name', 'data-unicode-version']
- )
- end
-
def event_commit_title(message)
message ||= ''
(message.split("\n").first || "").truncate(70)
@@ -197,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/form_helper.rb b/app/helpers/form_helper.rb
index b5dece38de1..905e2002592 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -27,7 +27,7 @@ module FormHelper
first_user: current_user&.username,
null_user: true,
current_user: true,
- project_id: @project.id,
+ project_id: @project&.id,
field_name: 'issue[assignee_ids][]',
default_label: 'Unassigned',
'max-select': 1,
@@ -35,7 +35,7 @@ module FormHelper
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)
}
}
end
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index d4a91e533c1..7f3c118c7ab 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -71,11 +71,13 @@ module GitlabRoutingHelper
project_commit_url(entity.project, entity.sha, *args)
end
- def preview_markdown_path(project, *args)
+ def preview_markdown_path(parent, *args)
+ return group_preview_markdown_path(parent) if parent.is_a?(Group)
+
if @snippet.is_a?(PersonalSnippet)
preview_markdown_snippets_path
else
- preview_markdown_project_path(project, *args)
+ preview_markdown_project_path(parent, *args)
end
end
@@ -180,6 +182,11 @@ module GitlabRoutingHelper
edit_project_pipeline_schedule_path(project, schedule)
end
+ def play_pipeline_schedule_path(schedule, *args)
+ project = schedule.project
+ play_project_pipeline_schedule_path(project, schedule, *args)
+ end
+
def take_ownership_pipeline_schedule_path(schedule, *args)
project = schedule.project
take_ownership_project_pipeline_schedule_path(project, schedule, *args)
diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb
index f7e17f5cc01..1022070ab6f 100644
--- a/app/helpers/graph_helper.rb
+++ b/app/helpers/graph_helper.rb
@@ -1,14 +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
notes_count = @graph.notes[commit.id]
- refs << "[#{notes_count} #{pluralize(notes_count, 'note')}]" if notes_count > 0
+ 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 e8efe8fab27..16eceb3f48f 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -1,4 +1,16 @@
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
@@ -7,7 +19,26 @@ module GroupsHelper
can?(current_user, :change_share_with_group_lock, group)
end
- def group_icon(group)
+ 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
@@ -60,10 +91,6 @@ module GroupsHelper
end
end
- def group_issues(group)
- IssuesFinder.new(current_user, group_id: group.id).execute
- end
-
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 }
@@ -83,13 +110,40 @@ module GroupsHelper
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?
- image_tag(group_icon(group), class: "avatar-tile", width: 15, height: 15)
+ group_icon(group, class: "avatar-tile", width: 15, height: 15)
else
""
end
@@ -125,7 +179,7 @@ module GroupsHelper
end
def default_help
- s_("GroupSettings|This setting will be applied to all subgroups unless overridden by a group owner.")
+ 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)
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 08e6443bd0f..c5522ff7a69 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -23,10 +23,24 @@ module IconsHelper
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}" : nil
+ css_classes = size ? "s#{size}" : ""
css_classes << " #{css_class}" unless css_class.blank?
- content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{image_path('icons.svg')}##{icon_name}" } ), class: css_classes)
+ 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 = {})
diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb
index a18ebfb6030..b484a868f92 100644
--- a/app/helpers/import_helper.rb
+++ b/app/helpers/import_helper.rb
@@ -1,19 +1,45 @@
module ImportHelper
+ 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) # rubocop:disable GitlabSecurity/PublicSend
+ 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
- link_to path_with_namespace, url, target: '_blank', rel: 'noopener noreferrer'
+ def import_wait_and_refresh_message
+ _('Please wait while we import the repository for you. Refresh at will.')
end
private
- def github_project_url(path_with_namespace)
- "#{github_root_url}/#{path_with_namespace}"
+ def github_project_url(full_path)
+ "#{github_root_url}/#{full_path}"
end
def github_root_url
@@ -23,7 +49,7 @@ module ImportHelper
@github_url = provider.fetch('url', 'https://github.com') if provider
end
- def gitea_project_url(path_with_namespace)
- "#{@gitea_host_url.sub(%r{/+\z}, '')}/#{path_with_namespace}"
+ def gitea_project_url(full_path)
+ "#{@gitea_host_url.sub(%r{/+\z}, '')}/#{full_path}"
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 df390dd5aab..f6ddb6d4cfe 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -32,16 +32,18 @@ module IssuablesHelper
end
end
- def serialize_issuable(issuable)
- case issuable
- when Issue
- IssueSerializer.new(current_user: current_user, project: issuable.project).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
@@ -209,15 +211,14 @@ module IssuablesHelper
def issuable_initial_data(issuable)
data = {
- endpoint: project_issue_path(@project, issuable),
- canUpdate: can?(current_user, :update_issue, issuable),
- canDestroy: can?(current_user, :destroy_issue, issuable),
+ 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,
- markdownPreviewPath: preview_markdown_path(@project),
+ 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),
@@ -225,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.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)
@@ -243,21 +250,23 @@ module IssuablesHelper
end
def issuables_count_for_state(issuable_type, state)
- finder = public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend
-
Gitlab::IssuablesCountForState.new(finder)[state]
end
- def close_issuable_url(issuable)
- issuable_url(issuable, close_reopen_params(issuable, :close))
+ def close_issuable_path(issuable)
+ issuable_path(issuable, close_reopen_params(issuable, :close))
+ end
+
+ def reopen_issuable_path(issuable)
+ issuable_path(issuable, close_reopen_params(issuable, :reopen))
end
- def reopen_issuable_url(issuable)
- issuable_url(issuable, close_reopen_params(issuable, :reopen))
+ def close_reopen_issuable_path(issuable, should_inverse = false)
+ issuable.closed? ^ should_inverse ? reopen_issuable_path(issuable) : close_issuable_path(issuable)
end
- def close_reopen_issuable_url(issuable, should_inverse = false)
- issuable.closed? ^ should_inverse ? reopen_issuable_url(issuable) : close_issuable_url(issuable)
+ def issuable_path(issuable, *options)
+ polymorphic_path(issuable, *options)
end
def issuable_url(issuable, *options)
@@ -295,6 +304,12 @@ module IssuablesHelper
issuable.model_name.human.downcase
end
+ def selected_labels
+ Array(params[:label_name]).map do |label_name|
+ Label.new(title: label_name)
+ end
+ end
+
private
def sidebar_gutter_collapsed?
@@ -305,20 +320,12 @@ module IssuablesHelper
@issuable_templates ||=
case issuable
when Issue
- issue_template_names
+ ref_project.repository.issue_template_names
when MergeRequest
- merge_request_template_names
+ 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] }
end
@@ -356,13 +363,18 @@ module IssuablesHelper
def issuable_sidebar_options(issuable, can_edit_issuable)
{
- endpoint: "#{issuable_json_path(issuable)}?basic=true",
+ 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: current_user.as_json(only: [:username, :id, :name], methods: :avatar_url),
+ currentUser: UserSerializer.new.represent(current_user),
rootPath: root_path,
fullPath: @project.full_path
}
end
+
+ def parent
+ @project || @group
+ end
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 212cdbb8157..0f25d401406 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -47,34 +47,13 @@ module IssuesHelper
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)
-
- 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.order_id_desc
- projects = projects.select do |project|
- current_user.can?(ability, project)
- 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
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index e1ba7898ee6..b2c641a5dbd 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
diff --git a/app/helpers/lazy_image_tag_helper.rb b/app/helpers/lazy_image_tag_helper.rb
index 2c5619ac41b..603b9438e35 100644
--- a/app/helpers/lazy_image_tag_helper.rb
+++ b/app/helpers/lazy_image_tag_helper.rb
@@ -10,6 +10,7 @@ module LazyImageTagHelper
unless options.delete(:lazy) == false
options[:data] ||= {}
options[:data][:src] = path_to_image(source)
+
options[:class] ||= ""
options[:class] << " lazy"
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 46bced00c72..2fe1927a189 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -53,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
@@ -69,29 +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 = {})
@@ -104,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
@@ -186,6 +203,7 @@ module MarkupHelper
node.content = node.content.truncate(num_remaining)
truncated = true
end
+
content_length += node.content.length
end
@@ -213,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 c31023f2d9a..ce57422f45d 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -73,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)
@@ -100,6 +101,30 @@ 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 merge_params_ee(merge_request)
{}
end
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index d7df9bb06d2..40ca666f1bf 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -4,8 +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
- users = [current_user.namespace]
+ 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'
@@ -30,7 +33,7 @@ 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
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index a23a43c9f43..56c88e6eab0 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -1,7 +1,15 @@
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-new-sidebar' if defined?(@left_sidebar) && @left_sidebar
+ 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
@@ -12,6 +20,7 @@ module NavHelper
current_path?('projects/merge_requests/conflicts#show') ||
current_path?('issues#show') ||
current_path?('milestones#show')
+
if cookies[:collapsed_gutter] == 'true'
%w[page-gutter right-sidebar-collapsed]
else
@@ -19,11 +28,7 @@ module NavHelper
end
elsif current_path?('jobs#show')
%w[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')
+ elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access')
%w[page-gutter wiki-sidebar right-sidebar-expanded]
else
[]
@@ -41,4 +46,28 @@ module NavHelper
class_names
end
+
+ 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 ce028195e51..a70e73a6da9 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)
@@ -130,8 +130,12 @@ 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
@@ -147,7 +151,38 @@ module NotesHelper
}
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_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 5946c475835..18b9bf214a3 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -9,7 +9,7 @@ module PageLayoutHelper
end
# Segments are seperated by middot
- @page_title.join(" \u00b7 ")
+ @page_title.join(" · ")
end
# Define or get a description for the current page
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 0d7347ed30d..373dfd457f7 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -36,7 +36,8 @@ module PreferencesHelper
def project_view_choices
[
['Files and Readme (default)', :files],
- ['Activity', :activity]
+ ['Activity', :activity],
+ ['Readme', :readme]
]
end
@@ -47,30 +48,4 @@ module PreferencesHelper
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
- end
-
- def anonymous_project_view
- if !@project.empty_repo? && can?(current_user, :download_code, @project)
- 'files'
- else
- 'activity'
- end
- end
end
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 5a4fda0724c..e7aa92e6e5c 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -3,7 +3,7 @@ module ProfilesHelper
user_synced_attributes_metadata = current_user.user_synced_attributes_metadata
if user_synced_attributes_metadata&.synced?(attribute)
if user_synced_attributes_metadata.provider
- Gitlab::OAuth::Provider.label_for(user_synced_attributes_metadata.provider)
+ Gitlab::Auth::OAuth::Provider.label_for(user_synced_attributes_metadata.provider)
else
'LDAP'
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index ddeff490d3a..da9fe734f1c 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -1,6 +1,4 @@
module ProjectsHelper
- include Gitlab::CurrentSettings
-
def link_to_project(project)
link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do
title = content_tag(:span, project.name, class: 'project-name')
@@ -15,17 +13,38 @@ module ProjectsHelper
end
def link_to_member_avatar(author, opts = {})
- default_opts = { size: 16 }
+ default_opts = { size: 16, lazy_load: false }
opts = default_opts.merge(opts)
classes = %W[avatar avatar-inline s#{opts[:size]}]
classes << opts[:avatar_class] if opts[:avatar_class]
- image_tag(avatar_icon(author, opts[:size]), width: opts[:size], class: classes, alt: '')
+ 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
@@ -36,12 +55,7 @@ module ProjectsHelper
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
@@ -83,18 +97,26 @@ module ProjectsHelper
end
def remove_project_message(project)
- _("You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are 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
@@ -124,18 +146,13 @@ 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
current_user&.recent_push(@project)
end
@@ -190,7 +207,8 @@ module ProjectsHelper
project.cache_key,
controller.controller_name,
controller.action_name,
- current_application_settings.cache_key,
+ Gitlab::CurrentSettings.cache_key,
+ "cross-project:#{can?(current_user, :read_cross_project)}",
'v2.5'
]
@@ -210,11 +228,11 @@ module ProjectsHelper
def show_no_password_message?
cookies[:hide_no_password_message].blank? && !current_user.hide_no_password &&
- ( current_user.require_password_creation? || current_user.require_personal_access_token_creation_for_git_auth? )
+ current_user.require_extra_setup_for_git_auth?
end
def link_to_set_password
- if current_user.require_password_creation?
+ 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
@@ -239,8 +257,19 @@ module ProjectsHelper
end
end
- def has_projects_or_name?(projects, params)
- !!(params[:name] || any_projects?(projects))
+ 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
@@ -274,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
@@ -290,6 +323,7 @@ module ProjectsHelper
snippets: :read_project_snippet,
settings: :admin_project,
builds: :read_build,
+ clusters: :read_cluster,
labels: :read_label,
issues: :read_issue,
project_members: :read_project_member,
@@ -363,55 +397,6 @@ 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 }
- project_new_blob_path(
- 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)
- project_new_blob_path(
- 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"
@@ -422,40 +407,10 @@ module ProjectsHelper
path = "#{import_path}?repo=#{repo}&branch=#{branch}&sha=#{sha}"
- return URI.join(current_application_settings.koding_url, path).to_s
+ return URI.join(Gitlab::CurrentSettings.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
- project_blob_path(
- project,
- tree_join(project.default_branch,
- contribution_guide.name)
- )
- 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)
@@ -483,15 +438,6 @@ module ProjectsHelper
@ref || @repository.try(:root_ref)
end
- def filename_path(project, filename)
- if project && blob = project.repository.public_send(filename) # rubocop:disable GitlabSecurity/PublicSend
- project_blob_path(
- project,
- tree_join(project.default_branch, blob.name)
- )
- end
- end
-
def sanitize_repo_path(project, message)
return '' unless message.present?
@@ -534,7 +480,7 @@ 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)
@@ -581,4 +527,8 @@ module ProjectsHelper
project_find_file_path(@project, ref)
end
+
+ def can_show_last_commit_in_list?(project)
+ can?(current_user, :read_cross_project) && project.commit
+ end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index cf28a917fd1..761c1252fc8 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -110,7 +110,7 @@ module SearchHelper
category: "Projects",
id: p.id,
value: "#{search_result_sanitize(p.name)}",
- label: "#{search_result_sanitize(p.name_with_namespace)}",
+ label: "#{search_result_sanitize(p.full_name)}",
url: project_path(p)
}
end
@@ -139,8 +139,9 @@ module SearchHelper
id: "filtered-search-#{type}",
placeholder: 'Search or filter results...',
data: {
- 'username-params' => @users.to_json(only: [:id, :username])
- }
+ 'username-params' => UserSerializer.new.represent(@users)
+ },
+ autocomplete: 'off'
}
if @project.present?
@@ -169,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..240783bc7fd 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -27,5 +27,16 @@ module ServicesHelper
"#{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 b447d4952e7..00e7e4230b9 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -89,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
index 544c9efb845..b76c1228220 100644
--- a/app/helpers/storage_health_helper.rb
+++ b/app/helpers/storage_health_helper.rb
@@ -16,19 +16,14 @@ module StorageHealthHelper
def message_for_circuit_breaker(circuit_breaker)
maximum_failures = circuit_breaker.failure_count_threshold
current_failures = circuit_breaker.failure_count
- permanently_broken = circuit_breaker.circuit_broken? && current_failures >= maximum_failures
translation_params = { number_of_failures: current_failures,
- maximum_failures: maximum_failures,
- number_of_seconds: circuit_breaker.failure_wait_time }
+ maximum_failures: maximum_failures }
- if permanently_broken
+ 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
- elsif circuit_breaker.circuit_broken?
- _("%{number_of_failures} of %{maximum_failures} failures. GitLab will "\
- "block access for %{number_of_seconds} seconds.") % translation_params
else
_("%{number_of_failures} of %{maximum_failures} failures. GitLab will "\
"allow access on the next attempt.") % translation_params
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index 40d69e30188..9151543dfdc 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -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.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)
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index d7eaf6ce24d..00fe67d6ffb 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -19,7 +19,9 @@ module SystemNoteHelper
'discussion' => 'comment',
'moved' => 'arrow-right',
'outdated' => 'pencil',
- 'duplicate' => 'issue-duplicate'
+ 'duplicate' => 'issue-duplicate',
+ 'locked' => 'lock',
+ 'unlocked' => 'lock-open'
}.freeze
def system_note_icon_name(note)
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 2a7aa299e83..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
@@ -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 c4ea0f5ac53..f6a6d9bebde 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
@@ -46,7 +55,9 @@ module TreeHelper
def tree_edit_branch(project = @project, ref = @ref)
return unless can_edit_tree?(project, ref)
- if can_push_branch?(project, ref)
+ project = project.present(current_user: current_user)
+
+ if project.can_current_user_push_to_branch?(ref)
ref
else
project = tree_edit_project(project)
@@ -72,6 +83,10 @@ 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."
end
@@ -88,6 +103,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
@@ -100,7 +116,7 @@ module TreeHelper
# returns the relative path of the first subdir that doesn't have only one directory descendant
def flatten_tree(root_path, tree)
- return tree.flat_path.sub(/\A#{root_path}\//, '') if tree.flat_path.present?
+ 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?
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 46867d2d974..e395cda03d3 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -150,15 +150,17 @@ 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
diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb
index 33453dd178f..8bcced70d63 100644
--- a/app/helpers/webpack_helper.rb
+++ b/app/helpers/webpack_helper.rb
@@ -1,12 +1,37 @@
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)
@@ -14,9 +39,11 @@ module WebpackHelper
paths.select! { |p| p.ends_with? ".#{extension}" }
end
- force_host = webpack_public_host
- if force_host
- paths.map! { |p| "#{force_host}#{p}" }
+ unless force_same_domain
+ force_host = webpack_public_host
+ if force_host
+ paths.map! { |p| "#{force_host}#{p}" }
+ end
end
paths
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 815fab9e061..41f9eedd4bd 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -21,4 +21,22 @@ module WikiHelper
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/base_mailer.rb b/app/mailers/base_mailer.rb
index 8e99db444d6..654468bc7fe 100644
--- a/app/mailers/base_mailer.rb
+++ b/app/mailers/base_mailer.rb
@@ -1,13 +1,11 @@
class BaseMailer < ActionMailer::Base
- include Gitlab::CurrentSettings
-
around_action :render_with_default_locale
helper ApplicationHelper
helper MarkupHelper
attr_accessor :current_user
- helper_method :current_user, :can?, :current_application_settings
+ helper_method :current_user, :can?
default from: proc { default_sender_address.format }
default reply_to: proc { default_reply_to_address.format }
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 64ca2d2eacf..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 = project_labels_url(@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
- 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
@@ -61,11 +61,12 @@ module Emails
@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 d76c61c369f..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.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.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 3626f8ce416..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 = project_labels_url(@project)
- 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 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
@@ -64,11 +64,12 @@ module Emails
@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 77a82b895ce..50e17fe7717 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -5,7 +5,7 @@ module Emails
@commit = @note.noteable
@target_url = project_commit_url(*note_target_url_options)
- mail_answer_thread(@commit, note_thread_options(recipient_id))
+ mail_answer_note_thread(@commit, @note, note_thread_options(recipient_id))
end
def note_issue_email(recipient_id, note_id)
@@ -13,7 +13,7 @@ module Emails
@issue = @note.noteable
@target_url = project_issue_url(*note_target_url_options)
- mail_answer_thread(@issue, note_thread_options(recipient_id))
+ mail_answer_note_thread(@issue, @note, note_thread_options(recipient_id))
end
def note_merge_request_email(recipient_id, note_id)
@@ -21,7 +21,7 @@ module Emails
@merge_request = @note.noteable
@target_url = project_merge_request_url(*note_target_url_options)
- mail_answer_thread(@merge_request, note_thread_options(recipient_id))
+ mail_answer_note_thread(@merge_request, @note, note_thread_options(recipient_id))
end
def note_snippet_email(recipient_id, note_id)
@@ -29,7 +29,7 @@ module Emails
@snippet = @note.noteable
@target_url = project_snippet_url(*note_target_url_options)
- mail_answer_thread(@snippet, note_thread_options(recipient_id))
+ mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id))
end
def note_personal_snippet_email(recipient_id, note_id)
@@ -37,7 +37,7 @@ 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
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 c401030e34a..4f5edeb9bda 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -7,12 +7,6 @@ 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)
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 9efabe3f44e..45d4fb451d8 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -5,6 +5,7 @@ class Notify < BaseMailer
include Emails::Issues
include Emails::MergeRequests
include Emails::Notes
+ include Emails::PagesDomains
include Emails::Projects
include Emails::Profile
include Emails::Pipelines
@@ -112,6 +113,8 @@ 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
@@ -119,8 +122,8 @@ class Notify < BaseMailer
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
@@ -156,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
diff --git a/app/models/ability.rb b/app/models/ability.rb
index 0b6bcbde5d9..6dae49f38dc 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -22,12 +22,30 @@ 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)
+ # 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)
+
DeclarativePolicy.user_scope do
issues.select { |issue| issue.visible_to_user?(user) }
end
end
+ # 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)
+
+ DeclarativePolicy.user_scope do
+ merge_requests.select { |mr| allowed?(user, :read_merge_request, mr) }
+ end
+ end
+
def can_edit_note?(user, note)
allowed?(user, :edit_note, note)
end
@@ -53,5 +71,15 @@ class Ability
cache = RequestStore.active? ? RequestStore : {}
DeclarativePolicy.policy_for(user, subject, cache: cache)
end
+
+ private
+
+ 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 ff15689ecac..dcd14c08f3c 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -2,9 +2,8 @@ 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 }
@@ -12,6 +11,7 @@ class Appearance < ActiveRecord::Base
mount_uploader :logo, AttachmentUploader
mount_uploader :header_logo, AttachmentUploader
+
has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
CACHE_KEY = 'current_appearance'.freeze
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index c0cc60d5ebf..0dee6df525d 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -33,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,
@@ -115,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 }
@@ -151,6 +158,38 @@ 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
@@ -194,7 +233,10 @@ 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)
@@ -224,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'],
@@ -252,7 +295,8 @@ class ApplicationSetting < ActiveRecord::Base
koding_url: nil,
max_artifacts_size: Settings.artifacts['max_size'],
max_attachment_size: Settings.gitlab['max_attachment_size'],
- password_authentication_enabled: Settings.gitlab['password_authentication_enabled'],
+ 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,
@@ -271,10 +315,22 @@ class ApplicationSetting < ActiveRecord::Base
sign_in_text: nil,
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
}
end
@@ -367,6 +423,7 @@ class ApplicationSetting < ActiveRecord::Base
super(group_full_path)
Gitlab::PerformanceBar.expire_allowed_user_ids_cache
end
+
return
end
@@ -396,7 +453,7 @@ class ApplicationSetting < ActiveRecord::Base
# 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 enable
+ return if Gitlab::Utils.to_boolean(enable)
self.performance_bar_allowed_group_id = nil
end
@@ -441,6 +498,14 @@ class ApplicationSetting < ActiveRecord::Base
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/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/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/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/board.rb b/app/models/board.rb
index 5bb7d3d3722..3cede6fc99a 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -1,20 +1,22 @@
class Board < ActiveRecord::Base
+ belongs_to :group
belongs_to :project
has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
validates :project, presence: true, if: :project_needed?
+ validates :group, presence: true, unless: :project
def project_needed?
- true
+ !group
end
def parent
- project
+ @parent ||= group || project
end
def group_board?
- false
+ group_id.present?
end
def backlog_list
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/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 ee544d8ac56..b230b7f47ef 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -1,16 +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
@@ -30,15 +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, ->() 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'))
+ end
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 :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) }
- mount_uploader :artifacts_file, ArtifactUploader
- mount_uploader :artifacts_metadata, ArtifactUploader
+ 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
+
+ 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
@@ -48,7 +81,7 @@ module Ci
before_save :ensure_token
before_destroy { unscoped_project }
- after_create do |build|
+ after_create unless: :importing? do |build|
run_after_commit { BuildHooksWorker.perform_async(build.id) }
end
@@ -103,12 +136,17 @@ module Ci
end
before_transition any => [:failed] do |build|
+ next unless build.project
next if build.retries_max.zero?
if build.retries_count < build.retries_max
Ci::Build.retry(build, build.user)
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)
@@ -191,6 +229,10 @@ 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:
#
@@ -229,12 +271,16 @@ module Ci
variables
end
+ def features
+ { trace_sections: true }
+ end
+
def merge_request
return @merge_request if defined?(@merge_request)
@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)
@@ -247,7 +293,7 @@ module Ci
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
@@ -261,6 +307,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
@@ -304,6 +354,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)
@@ -311,14 +362,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,
@@ -371,6 +414,7 @@ module Ci
def keep_artifacts!
self.update(artifacts_expire_at: nil)
+ self.job_artifacts.update_all(expire_at: nil)
end
def coverage_regex
@@ -419,7 +463,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
@@ -442,6 +493,19 @@ 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
@@ -458,11 +522,7 @@ module Ci
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!
@@ -483,6 +543,7 @@ module Ci
variables = [
{ key: 'CI', value: 'true', public: true },
{ key: 'GITLAB_CI', value: 'true', public: true },
+ { key: 'GITLAB_FEATURES', value: project.namespace.features.join(','), 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 },
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
index afeae69ba39..1dd0e050ba9 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -6,7 +6,10 @@ module Ci
belongs_to :group
- validates :key, uniqueness: { scope: :group_id }
+ validates :key, uniqueness: {
+ scope: :group_id,
+ message: "(%{value}) has already been taken"
+ }
scope :unprotected, -> { where(protected: false) }
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 acaa028eaa2..a72a815bfe8 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -5,14 +5,15 @@ module Ci
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 :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent
has_many :variables, class_name: 'Ci::PipelineVariable'
@@ -39,7 +40,6 @@ module Ci
validates :status, presence: { unless: :importing? }
validate :valid_commit_sha, unless: :importing?
- after_initialize :set_config_source, if: :new_record?
after_create :keep_around_commits, unless: :importing?
enum source: {
@@ -58,10 +58,15 @@ module Ci
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
@@ -109,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
@@ -137,34 +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
- scope :internal, -> { where(source: internal_sources) }
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
@@ -181,6 +228,10 @@ module Ci
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)
@@ -236,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?
@@ -263,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
@@ -289,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
@@ -312,8 +364,12 @@ 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.kubernetes_service&.active?
+ project.deployment_platform&.active?
end
def has_stage_seeds?
@@ -338,7 +394,7 @@ module Ci
@config_processor ||= begin
Gitlab::Ci::YamlProcessor.new(ci_yaml_file)
- rescue Gitlab::Ci::YamlProcessor::ValidationError, Psych::SyntaxError => e
+ rescue Gitlab::Ci::YamlProcessor::ValidationError => e
self.yaml_errors = e.message
nil
rescue
@@ -395,7 +451,7 @@ module Ci
end
def notes
- Note.for_commit_id(sha)
+ project.notes.for_commit_id(sha)
end
def process!
@@ -403,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
@@ -434,7 +490,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
@@ -455,7 +511,10 @@ module Ci
end
def latest_builds_with_artifacts
- @latest_builds_with_artifacts ||= builds.latest.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.to_a
end
private
@@ -465,7 +524,7 @@ module Ci
return unless sha
project.repository.gitlab_ci_yml_for(sha, ci_yaml_file_path)
- rescue GRPC::NotFound, Rugged::ReferenceError, GRPC::Internal
+ rescue GRPC::NotFound, GRPC::Internal
nil
end
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index 10ead6b6d3b..b6abc3d7681 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -2,8 +2,9 @@ module Ci
class PipelineSchedule < ActiveRecord::Base
extend Gitlab::Ci::Model
include Importable
+ include IgnorableColumn
- acts_as_paranoid
+ ignore_column :deleted_at
belongs_to :project
belongs_to :owner, class_name: 'User'
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index a0d07902ba2..609620a62bb 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -1,9 +1,12 @@
module Ci
class Runner < ActiveRecord::Base
extend Gitlab::Ci::Model
+ include Gitlab::SQL::Pattern
+ include RedisCacheable
RUNNER_QUEUE_EXPIRY_TIME = 60.minutes
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 access_level].freeze
@@ -46,6 +49,8 @@ module Ci
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.
@@ -59,10 +64,7 @@ module Ci
#
# Returns an ActiveRecord::Relation.
def self.search(query)
- t = arel_table
- pattern = "%#{query}%"
-
- where(t[:token].matches(pattern).or(t[:description].matches(pattern)))
+ fuzzy_search(query, [:token, :description])
end
def self.contact_time_deadline
@@ -114,7 +116,7 @@ module Ci
def can_pick?(build)
return false if self.ref_protected? && !build.protected?
- assignable_for?(build.project) && accepting_tags?(build)
+ assignable_for?(build.project_id) && accepting_tags?(build)
end
def only_for?(project)
@@ -154,6 +156,18 @@ module Ci
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
@@ -166,6 +180,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,
@@ -173,8 +198,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/trigger.rb b/app/models/ci/trigger.rb
index b5290bcaf53..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 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/variable.rb b/app/models/ci/variable.rb
index 67d3ec81b6f..7c71291de84 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -6,7 +6,10 @@ module Ci
belongs_to :project
- validates :key, uniqueness: { scope: [:project_id, :environment_scope] }
+ validates :key, uniqueness: {
+ scope: [:project_id, :environment_scope],
+ message: "(%{value}) has already been taken"
+ }
scope :unprotected, -> { where(protected: false) }
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..7ce8befeeeb
--- /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)
+
+ variables = [
+ { key: 'KUBE_URL', value: api_url, public: true },
+ { key: 'KUBE_TOKEN', value: token, public: false },
+ { key: 'KUBE_NAMESPACE', value: actual_namespace, public: true },
+ { key: 'KUBECONFIG', value: config, public: false, file: 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 }
+ end
+
+ variables
+ 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 KubeException => 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 2ae8890c1b3..cceae5efb72 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -1,3 +1,4 @@
+# coding: utf-8
class Commit
extend ActiveModel::Naming
extend Gitlab::Cache::RequestCache
@@ -8,6 +9,7 @@ class Commit
include Mentionable
include Referable
include StaticModel
+ include ::Gitlab::Utils::StrongMemoize
attr_mentionable :safe_message, pipeline: :single_line
@@ -18,6 +20,7 @@ class Commit
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]
@@ -25,8 +28,8 @@ 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 }
@@ -51,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
@@ -71,6 +88,20 @@ class Commit
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
@@ -80,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
@@ -100,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
@@ -108,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
@@ -189,11 +226,13 @@ class Commit
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
@@ -208,17 +247,20 @@ class Commit
notes.includes(:author)
end
- def method_missing(m, *args, &block)
- @raw.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
+ 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
@@ -237,11 +279,13 @@ 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
@@ -311,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
@@ -336,19 +381,19 @@ 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)
@@ -359,7 +404,7 @@ class Commit
@deltas ||= raw.deltas
end
- def diffs(diff_options = nil)
+ def diffs(diff_options = {})
Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options)
end
@@ -377,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}"
@@ -405,15 +454,7 @@ 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
-
- def gpg_commit
- @gpg_commit ||= Gitlab::Gpg::Commit.new(self)
- end
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 f3888528940..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])
@@ -43,9 +43,21 @@ class CommitStatus < ActiveRecord::Base
script_failure: 1,
api_failure: 2,
stuck_or_timeout_failure: 3,
- runner_system_failure: 4
+ 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
@@ -93,26 +105,29 @@ class CommitStatus < ActiveRecord::Base
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
- StageUpdateWorker.perform_async(commit_status.stage_id)
- 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
@@ -126,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/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/avatarable.rb b/app/models/concerns/avatarable.rb
index 8fbfed11bdf..d35e37935fb 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?
+ self.errors.add :avatar, "only images allowed"
+ 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 9adc309a22b..d8394415362 100644
--- a/app/models/concerns/awardable.rb
+++ b/app/models/concerns/awardable.rb
@@ -98,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 193e459977a..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 = 2
+ 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).present? && __send__(markdown_field).present? # rubocop:disable GitlabSecurity/PublicSend
- 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
@@ -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/deployment_platform.rb b/app/models/concerns/deployment_platform.rb
new file mode 100644
index 00000000000..89d0474a596
--- /dev/null
+++ b/app/models/concerns/deployment_platform.rb
@@ -0,0 +1,48 @@
+module DeploymentPlatform
+ def deployment_platform
+ @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/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 3803e18a96e..7c3ed96bc28 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -81,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
index 9585b5583dc..8a241e4374a 100644
--- a/app/models/concerns/has_variable.rb
+++ b/app/models/concerns/has_variable.rb
@@ -16,6 +16,10 @@ module HasVariable
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
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
index a3d0ac8d862..01079fb8bd6 100644
--- a/app/models/concerns/internal_id.rb
+++ b/app/models/concerns/internal_id.rb
@@ -10,7 +10,6 @@ module InternalId
if iid.blank?
parent = project || group
records = parent.public_send(self.class.name.tableize) # rubocop:disable GitlabSecurity/PublicSend
- records = records.with_deleted if self.paranoid?
max_iid = records.maximum(:iid)
self.iid = max_iid.to_i + 1
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 265f6e48540..4560bc23193 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -14,10 +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
@@ -95,9 +97,7 @@ 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
@@ -123,9 +123,7 @@ module Issuable
#
# Returns an ActiveRecord::Relation.
def search(query)
- title = to_fuzzy_arel(:title, query)
-
- where(title)
+ fuzzy_search(query, [:title])
end
# Searches for records with a matching title or description.
@@ -136,23 +134,22 @@ module Issuable
#
# Returns an ActiveRecord::Relation.
def full_search(query)
- title = to_fuzzy_arel(:title, query)
- description = to_fuzzy_arel(:description, query)
-
- where(title&.or(description))
+ 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)
@@ -214,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]
@@ -254,23 +251,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
- hook_data
+ 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
+
+ Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes)
end
def labels_array
@@ -309,6 +315,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
@@ -330,15 +337,21 @@ module Issuable
false
end
- def record_metrics
- metrics = self.metrics || create_metrics
- metrics.record!
- end
-
##
# 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 1db6b2d2fa2..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,13 +44,11 @@ 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
@@ -69,6 +67,10 @@ module Mentionable
extractor
end
+ def extractors
+ @extractors ||= {}
+ end
+
def mentioned_users(current_user = nil)
all_references(current_user).users
end
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index 710fc1ed647..caf8afa97f9 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -86,6 +86,22 @@ module Milestoneish
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)
@@ -95,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/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 1c4ddabcad5..86f28f30032 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -46,6 +46,7 @@ module Noteable
notes.inc_relations_for_view.grouped_diff_discussions(*args)
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def resolvable_discussions
@resolvable_discussions ||=
if defined?(@discussions)
@@ -54,6 +55,7 @@ module Noteable
discussion_notes.resolvable.discussions(self)
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
def discussions_resolvable?
resolvable_discussions.any?(&:resolvable?)
@@ -74,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 ce69fd34ac5..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)
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index cb59b4da3d7..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
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 fde1cc44afa..e62f42e8e70 100644
--- a/app/models/concerns/protected_branch_access.rb
+++ b/app/models/concerns/protected_branch_access.rb
@@ -1,12 +1,6 @@
module ProtectedBranchAccess
extend ActiveSupport::Concern
- ALLOWED_ACCESS_LEVELS ||= [
- Gitlab::Access::MASTER,
- Gitlab::Access::DEVELOPER,
- Gitlab::Access::NO_ACCESS
- ].freeze
-
included do
include ProtectedRefAccess
@@ -14,18 +8,6 @@ module ProtectedBranchAccess
delegate :project, to: :protected_branch
- validates :access_level, presence: true, inclusion: {
- in: ALLOWED_ACCESS_LEVELS
- }
-
- 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_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 78ac4f324e7..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,12 @@ 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
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index e961c97e337..afacdb8cb12 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -10,12 +10,12 @@ module RelativePositioning
after_save :save_positionable_neighbours
end
- def project_ids
- [project.id]
+ def min_relative_position
+ self.class.in_parents(parent_ids).minimum(:relative_position)
end
def max_relative_position
- self.class.in_projects(project_ids).maximum(:relative_position)
+ self.class.in_parents(parent_ids).maximum(:relative_position)
end
def prev_relative_position
@@ -23,7 +23,7 @@ module RelativePositioning
if self.relative_position
prev_pos = self.class
- .in_projects(project_ids)
+ .in_parents(parent_ids)
.where('relative_position < ?', self.relative_position)
.maximum(:relative_position)
end
@@ -36,7 +36,7 @@ module RelativePositioning
if self.relative_position
next_pos = self.class
- .in_projects(project_ids)
+ .in_parents(parent_ids)
.where('relative_position > ?', self.relative_position)
.minimum(:relative_position)
end
@@ -52,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)
@@ -63,9 +63,9 @@ module RelativePositioning
pos_after = before.next_relative_position
if before.shift_after?
- issue_to_move = self.class.in_projects(project_ids).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
@@ -78,9 +78,9 @@ module RelativePositioning
pos_before = after.prev_relative_position
if after.shift_before?
- issue_to_move = self.class.in_projects(project_ids).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
@@ -92,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
@@ -132,6 +136,7 @@ module RelativePositioning
end
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def save_positionable_neighbours
return unless @positionable_neighbours
@@ -140,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 f006a271327..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.
@@ -31,31 +32,37 @@ module ResolvableDiscussion
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
@@ -95,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/routable.rb b/app/models/concerns/routable.rb
index f5048d17d80..dfd7d94450b 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -7,11 +7,12 @@ module Routable
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?
@@ -106,9 +107,13 @@ module Routable
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
+ @full_path = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def build_full_path
@@ -121,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?
@@ -152,6 +162,8 @@ module Routable
end
def update_route
+ return if Gitlab::Database.read_only?
+
prepare_route
route.save
end
@@ -160,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
index 67ecf470f7e..703a72c355c 100644
--- a/app/models/concerns/sha_attribute.rb
+++ b/app/models/concerns/sha_attribute.rb
@@ -3,6 +3,7 @@ module ShaAttribute
module ClassMethods
def sha_attribute(name)
+ return if ENV['STATIC_VERIFICATION']
return unless table_exists?
column = columns.find { |c| c.name == name.to_s }
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index db3cd257584..cefa5c13c5f 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -19,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 731d9b9a745..5e4274619c4 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -12,6 +12,7 @@ module Spammable
attr_accessor :spam
attr_accessor :spam_log
+ alias_method :spam?, :spam
after_validation :check_for_spam, on: [:create, :update]
@@ -34,10 +35,6 @@ module Spammable
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. "\
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index 5ab5c80a2f5..67a988addbe 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -7,12 +7,18 @@ module Storage
raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry')
end
+ expires_full_path_cache
+
# 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
@@ -32,6 +38,8 @@ module Storage
# 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
@@ -83,20 +91,10 @@ module Storage
remove_exports!
end
- def remove_exports!
- Gitlab::Popen.popen(%W(find #{export_path} -not -path #{export_path} -delete))
- end
-
- def export_path
- File.join(Gitlab::ImportExport.storage_path, full_path_was)
- end
+ def remove_legacy_exports!
+ legacy_export_path = File.join(Gitlab::ImportExport.storage_path, full_path_was)
- def full_path_was
- if parent
- parent.full_path + '/' + path_was
- else
- path_was
- end
+ FileUtils.rm_rf(legacy_export_path)
end
end
end
diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb
index 274b38a7708..f478c8ede18 100644
--- a/app/models/concerns/subscribable.rb
+++ b/app/models/concerns/subscribable.rb
@@ -13,6 +13,8 @@ module Subscribable
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 b517ddaebd7..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
@@ -21,9 +21,11 @@ module TimeTrackable
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 a7d5de48c66..ec3543f7053 100644
--- a/app/models/concerns/token_authenticatable.rb
+++ b/app/models/concerns/token_authenticatable.rb
@@ -43,15 +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? # 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/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/deploy_key.rb b/app/models/deploy_key.rb
index eae5eee4fee..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 # rubocop:disable Cop/ActiveRecordDependent
+ 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,10 +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
+
+ def deploy_keys_project_for(project)
+ deploy_keys_projects.find_by(project: project)
+ end
+
+ 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 b37b9bfbdac..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
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 7bcded5b5e1..66e61c06765 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -1,8 +1,8 @@
class Deployment < ActiveRecord::Base
include InternalId
- 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.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 e9a60e6ce09..15122cbc693 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -12,14 +12,16 @@ class DiffNote < Note
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 b80da7b246a..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 &&
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 b6868ccbe8f..24d4f1d8761 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -4,7 +4,7 @@ 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
@@ -30,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
@@ -110,13 +109,13 @@ class Environment < ActiveRecord::Base
end
def ref_path
- "refs/#{Repository::REF_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?
@@ -139,29 +138,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
@@ -223,6 +224,10 @@ class Environment < ActiveRecord::Base
self.environment_type || self.name
end
+ def deployment_platform
+ project.deployment_platform
+ 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 0997b056c6a..be0fc7efa9a 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -48,7 +48,18 @@ class Event < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :project
- belongs_to :target, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
+
+ 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.
+ if reflections['events'].active_record.reflect_on_association(:author)
+ includes(:author)
+ else
+ self
+ end
+ }, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
+
has_one :push_event_payload
# Callbacks
@@ -85,10 +96,6 @@ class Event < ActiveRecord::Base
self.inheritance_column = 'action'
- # "data" will be removed in 10.0 but it may be possible that JOINs happen that
- # include this column, hence we're ignoring it as well.
- ignore_column :data
-
class << self
def model_name
ActiveModel::Name.new(self, nil, 'event')
@@ -151,7 +158,7 @@ class Event < ActiveRecord::Base
def project_name
if project
- project.name_with_namespace
+ project.full_name
else
"(deleted project)"
end
diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb
index 0bf18e529f0..282fd7edcb7 100644
--- a/app/models/external_issue.rb
+++ b/app/models/external_issue.rb
@@ -38,13 +38,17 @@ class ExternalIssue
@project.id
end
- def to_reference(_from_project = nil, full: nil)
- id
+ def to_reference(_from = nil, full: nil)
+ reference_link_text
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/global_milestone.rb b/app/models/global_milestone.rb
index c0864769314..dc2f6817190 100644
--- a/app/models/global_milestone.rb
+++ b/app/models/global_milestone.rb
@@ -44,10 +44,10 @@ class GlobalMilestone
def self.group_milestones_states_count(group)
return STATE_COUNT_HASH unless group
- params = { group_ids: [group.id], state: 'all', order: nil }
+ params = { group_ids: [group.id], state: 'all' }
relation = MilestonesFinder.new(params).execute
- grouped_by_state = relation.group(:state).count
+ grouped_by_state = relation.reorder(nil).group(:state).count
{
opened: grouped_by_state['active'] || 0,
@@ -60,10 +60,10 @@ class GlobalMilestone
def self.legacy_group_milestone_states_count(projects)
return STATE_COUNT_HASH unless projects
- params = { project_ids: projects.map(&:id), state: 'all', order: nil }
+ params = { project_ids: projects.map(&:id), state: 'all' }
relation = MilestonesFinder.new(params).execute
- project_milestones_by_state_and_title = relation.group(:state, :title).count
+ project_milestones_by_state_and_title = relation.reorder(nil).group(:state, :title).count
opened = count_by_state(project_milestones_by_state_and_title, 'active')
closed = count_by_state(project_milestones_by_state_and_title, 'closed')
diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb
index 44deae4234b..44eda741679 100644
--- a/app/models/gpg_key.rb
+++ b/app/models/gpg_key.rb
@@ -9,6 +9,9 @@ class GpgKey < ActiveRecord::Base
belongs_to :user
has_many :gpg_signatures
+ has_many :subkeys, class_name: 'GpgKeySubkey'
+
+ scope :with_subkeys, -> { includes(:subkeys) }
validates :user, presence: true
@@ -36,10 +39,12 @@ class GpgKey < ActiveRecord::Base
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
@@ -49,6 +54,10 @@ class GpgKey < ActiveRecord::Base
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
@@ -73,7 +82,7 @@ class GpgKey < ActiveRecord::Base
end
def verified_and_belongs_to_email?(email)
- emails_with_verified_status.fetch(email, false)
+ emails_with_verified_status.fetch(email.downcase, false)
end
def update_invalid_gpg_signatures
@@ -82,10 +91,11 @@ class GpgKey < ActiveRecord::Base
def revoke
GpgSignature
- .where(gpg_key: self)
+ .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
)
@@ -106,4 +116,12 @@ class GpgKey < ActiveRecord::Base
# 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
index 1f047a32c84..bf88d75246f 100644
--- a/app/models/gpg_signature.rb
+++ b/app/models/gpg_signature.rb
@@ -15,11 +15,42 @@ class GpgSignature < ActiveRecord::Base
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
@@ -29,6 +60,8 @@ class GpgSignature < ActiveRecord::Base
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 e746e4a12c9..8d183006c65 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -2,10 +2,13 @@ require 'carrierwave/orm/activerecord'
class Group < Namespace
include Gitlab::ConfigHelper
+ 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 # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members
@@ -24,42 +27,32 @@ class Group < Namespace
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'
+
+ has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+
+ has_many :boards
+ has_many :badges, class_name: 'GroupBadge'
+
+ accepts_nested_attributes_for :variables, allow_destroy: true
- validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
validate :visibility_level_allowed_by_projects
validate :visibility_level_allowed_by_sub_groups
validate :visibility_level_allowed_by_parent
-
- validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
+ validates :variables, variable_duplicates: true
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
- mount_uploader :avatar, AvatarUploader
- has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
-
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
@@ -93,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
@@ -125,12 +118,6 @@ class Group < Namespace
visibility_level_allowed_by_sub_groups?(level)
end
- def avatar_url(**args)
- # We use avatar_path instead of overriding avatar_url because of carrierwave.
- # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864
- avatar_path(args)
- end
-
def lfs_enabled?
return false unless Gitlab.config.lfs.enabled
return Gitlab.config.lfs.enabled if self[:lfs_enabled].nil?
@@ -178,6 +165,12 @@ 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
@@ -287,6 +280,18 @@ class Group < Namespace
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
@@ -295,6 +300,10 @@ class Group < Namespace
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?
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/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index a8c424a6614..b6dd39b860b 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -1,19 +1,17 @@
class ProjectHook < WebHook
- TRIGGERS = {
- 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
+ include TriggerableHooks
- TRIGGERS.each do |trigger, event|
- scope trigger, -> { where(event => true) }
- end
+ triggerable_hooks [
+ :push_hooks,
+ :tag_push_hooks,
+ :issue_hooks,
+ :confidential_issue_hooks,
+ :note_hooks,
+ :merge_request_hooks,
+ :job_hooks,
+ :pipeline_hooks,
+ :wiki_page_hooks
+ ]
belongs_to :project
validates :project, presence: true
diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb
index 180c479c41b..0528266e5b3 100644
--- a/app/models/hooks/system_hook.rb
+++ b/app/models/hooks/system_hook.rb
@@ -1,14 +1,14 @@
class SystemHook < WebHook
- TRIGGERS = {
- repository_update_hooks: :repository_update_events,
- push_hooks: :push_events,
- tag_push_hooks: :tag_push_events
- }.freeze
+ include TriggerableHooks
- TRIGGERS.each do |trigger, event|
- scope trigger, -> { where(event => true) }
- end
+ 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 5a70e114f56..27729deeac9 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -4,6 +4,7 @@ class WebHook < ActiveRecord::Base
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/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/issue.rb b/app/models/issue.rb
index 92a454300af..c81f7e52bb1 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -5,11 +5,14 @@ class Issue < ActiveRecord::Base
include Issuable
include Noteable
include Referable
- include Sortable
include Spammable
include FasterCacheKeys
include RelativePositioning
- include CreatedAtFilterable
+ include TimeTrackable
+ include ThrottledTouch
+ include IgnorableColumn
+
+ ignore_column :assignee_id, :branch_name, :deleted_at
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -32,6 +35,8 @@ class Issue < ActiveRecord::Base
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)') }
@@ -50,7 +55,6 @@ class Issue < ActiveRecord::Base
scope :public_only, -> { where(confidential: false) }
after_save :expire_etag_cache
- after_commit :update_project_counter_caches, on: :destroy
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
@@ -74,18 +78,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 +110,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 +125,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 +159,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 +264,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(
@@ -270,16 +285,17 @@ class Issue < ActiveRecord::Base
true
end
- def update_project_counter_caches?
- state_changed? || confidential_changed?
- 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
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 0c41e34d969..ae5769c0627 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -1,7 +1,7 @@
require 'digest/md5'
class Key < ActiveRecord::Base
- include Gitlab::CurrentSettings
+ include AfterCommitQueue
include Sortable
belongs_to :user
@@ -27,13 +27,15 @@ class Key < ActiveRecord::Base
after_commit :add_to_shell, 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&.delete!("\n\r")
- 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
@@ -75,6 +77,12 @@ 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
@@ -88,13 +96,13 @@ class Key < ActiveRecord::Base
def generate_fingerprint
self.fingerprint = nil
- return unless self.key.present?
+ return unless public_key.valid?
self.fingerprint = public_key.fingerprint
end
def key_meets_restrictions
- restriction = current_application_settings.key_restriction_for(public_key.type)
+ restriction = Gitlab::CurrentSettings.key_restriction_for(public_key.type)
if restriction == ApplicationSetting::FORBIDDEN_KEY_VALUE
errors.add(:key, forbidden_key_type_message)
@@ -105,7 +113,7 @@ class Key < ActiveRecord::Base
def forbidden_key_type_message
allowed_types =
- current_application_settings
+ Gitlab::CurrentSettings
.allowed_key_types
.map(&:upcase)
.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ')
diff --git a/app/models/label.rb b/app/models/label.rb
index 899028a01a0..de7f1d56c64 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -35,6 +35,7 @@ class Label < ActiveRecord::Base
scope :templates, -> { where(template: true) }
scope :with_title, ->(title) { where(title: title) }
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)
@@ -132,6 +133,7 @@ class Label < ActiveRecord::Base
else
priorities.find_by(project: project)
end
+
priority.try(:priority)
end
@@ -165,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
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 c36be956ff0..d90cafd14b4 100644
--- a/app/models/legacy_diff_note.rb
+++ b/app/models/legacy_diff_note.rb
@@ -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 b7cf96abe83..b444812a4cf 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -6,16 +6,8 @@ class LfsObject < ActiveRecord::Base
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 cbbd58f2eaf..408e8b2d704 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
@@ -126,7 +128,7 @@ class Member < ActiveRecord::Base
find_by(invite_token: invite_token)
end
- def add_user(source, user, access_level, existing_members: nil, current_user: nil, expires_at: nil)
+ 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)
@@ -141,11 +143,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
@@ -312,7 +316,7 @@ class Member < ActiveRecord::Base
end
def notification_setting
- @notification_setting ||= user.notification_settings_for(source)
+ @notification_setting ||= user&.notification_settings_for(source)
end
def notifiable?(type, opts = {})
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 8d9a30397a9..5bec68ce4f6 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -3,20 +3,44 @@ class MergeRequest < ActiveRecord::Base
include Issuable
include Noteable
include Referable
- include Sortable
include IgnorableColumn
- include CreatedAtFilterable
+ include TimeTrackable
+ include ManualInverseAssociation
+ include EachBatch
+ include ThrottledTouch
+ include Gitlab::Utils::StrongMemoize
- ignore_column :locked_at
+ 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
+
has_one :merge_request_diff,
-> { 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 # rubocop:disable Cop/ActiveRecordDependent
@@ -30,8 +54,8 @@ class MergeRequest < ActiveRecord::Base
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
- after_commit :update_project_counter_caches, on: :destroy
# When this attribute is true some MR validation is ignored
# It allows us to close or modify broken merge requests
@@ -62,6 +86,14 @@ class MergeRequest < ActiveRecord::Base
transition locked: :opened
end
+ before_transition any => :opened do |merge_request|
+ merge_request.merge_jid = nil
+
+ merge_request.run_after_commit do
+ UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
+ end
+ end
+
state :opened
state :closed
state :merged
@@ -108,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") }
@@ -123,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.
@@ -165,6 +215,22 @@ class MergeRequest < ActiveRecord::Base
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
def self.work_in_progress?(title)
@@ -179,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
{
@@ -235,9 +311,9 @@ class MergeRequest < ActiveRecord::Base
if persisted?
merge_request_diff.commit_shas
elsif compare_commits
- compare_commits.reverse.map(&:sha)
+ compare_commits.to_a.reverse.map(&:sha)
else
- []
+ Array(diff_head_sha)
end
end
@@ -316,16 +392,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
- source_branch_ref = @source_branch_sha || source_branch
- source_project.repository.commit(source_branch_ref) if source_branch_ref
+ def target_branch_ref
+ return @target_branch_sha if @target_branch_sha
+ return unless target_branch
+
+ 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
@@ -392,7 +484,11 @@ class MergeRequest < ActiveRecord::Base
end
def merge_ongoing?
- !!merge_jid && !merged?
+ # 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?
@@ -403,7 +499,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?
@@ -415,6 +511,8 @@ class MergeRequest < ActiveRecord::Base
end
def create_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
@@ -428,7 +526,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)
@@ -451,6 +549,13 @@ 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?) &&
(source_branch_head && target_branch_head)
@@ -462,6 +567,7 @@ class MergeRequest < ActiveRecord::Base
return unless open?
old_diff_refs = self.diff_refs
+
create_merge_request_diff
MergeRequests::MergeRequestDiffCacheService.new.execute(self)
new_diff_refs = self.diff_refs
@@ -474,7 +580,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)
@@ -511,19 +617,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
@@ -552,14 +666,21 @@ class MergeRequest < ActiveRecord::Base
commits_for_notes_limit = 100
commit_ids = commit_shas.take(commits_for_notes_limit)
- 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_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
@@ -570,24 +691,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
@@ -672,13 +775,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)
@@ -694,6 +797,7 @@ class MergeRequest < ActiveRecord::Base
if !include_description && closes_issues_references.present?
message << "Closes #{closes_issues_references.to_sentence}"
end
+
message << "#{description}" if include_description && description.present?
message << "See merge request #{to_reference(full: true)}"
@@ -734,10 +838,9 @@ class MergeRequest < ActiveRecord::Base
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?
@@ -760,8 +863,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)
@@ -794,37 +898,22 @@ 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
- write_ref
- 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/#{Repository::REF_MERGE_REQUEST}/#{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?
- end
-
def in_locked_state
begin
lock_mr
@@ -852,7 +941,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
@@ -868,18 +958,26 @@ class MergeRequest < ActiveRecord::Base
.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_commit_shas
- if persisted?
- column_shas = MergeRequestDiffCommit.where(merge_request_diff: merge_request_diffs).pluck('DISTINCT(sha)')
- serialised_shas = merge_request_diffs.where.not(st_commits: nil).flat_map(&:commit_shas)
+ @all_commit_shas ||= begin
+ return commit_shas unless persisted?
- (column_shas + serialised_shas).uniq
- elsif compare_commits
- compare_commits.to_a.reverse.map(&:id)
- else
- [diff_head_sha]
+ all_commits.pluck(:sha).uniq
end
end
@@ -888,7 +986,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?
@@ -947,16 +1060,12 @@ 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?
- state_changed?
- end
-
def update_project_counter_caches
Projects::OpenMergeRequestsCountService.new(target_project).refresh_cache
end
@@ -966,10 +1075,4 @@ class MergeRequest < ActiveRecord::Base
project.merge_requests.merged.where(author_id: author_id).empty?
end
-
- private
-
- def write_ref
- target_project.repository.fetch_source_branch(source_project.repository, source_branch, ref_path)
- 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 58050e1f438..06aa67c600f 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -1,21 +1,21 @@
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
+ manual_inverse_association :merge_request, :merge_request_diff
+
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) }
- serialize :st_commits # rubocop:disable Cop/ActiveRecordSerialize
- serialize :st_diffs # rubocop:disable Cop/ActiveRecordSerialize
-
state_machine :state, initial: :empty do
state :collected
state :overflow
@@ -28,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.
@@ -37,29 +42,24 @@ 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
+ 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
save_diffs
+ save
keep_around_commits
end
def ensure_commit_shas
- merge_request.fetch_ref
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
@@ -107,27 +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 commit_shas
- if st_commits.present?
- st_commits.map { |commit| commit[:id] }
- else
- merge_request_diff_commits.map(&:sha)
- end
+ merge_request_diff_commits.map(&:sha)
end
def diff_refs=(new_diff_refs)
@@ -191,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)
@@ -201,35 +197,8 @@ class MergeRequestDiff < ActiveRecord::Base
CompareService.new(project, head_commit_sha).execute(project, sha, straight: true)
end
- def commits_count
- if st_commits.present?
- st_commits.size
- else
- merge_request_diff_commits.size
- end
- 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
- 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 create_merge_request_diff_files(diffs)
rows = diffs.map.with_index do |diff, index|
diff_hash = diff.to_hash.merge(
@@ -253,9 +222,7 @@ class MergeRequestDiff < ActiveRecord::Base
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|
@@ -266,23 +233,11 @@ class MergeRequestDiff < ActiveRecord::Base
Gitlab::Git::DiffCollection.new(raw, options)
end
- def diffs_from_database
- return @diffs_from_database if defined?(@diffs_from_database)
-
- @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.map(&:to_hash)
- end
- end
-
def load_commits
- commits = st_commits.presence || merge_request_diff_commits
+ commits = merge_request_diff_commits.map { |commit| Commit.from_hash(commit.to_hash, project) }
- commits.map { |commit| Commit.from_hash(commit.to_hash, project) }
+ CommitCollection
+ .new(merge_request.source_project, commits, merge_request.source_branch)
end
def save_diffs
@@ -308,13 +263,16 @@ class MergeRequestDiff < ActiveRecord::Base
new_attributes[:state] = :overflow if diff_collection.overflow?
end
- update(new_attributes)
+ assign_attributes(new_attributes)
end
def save_commits
MergeRequestDiffCommit.create_bulk(self.id, compare.commits.reverse)
- merge_request_diff_commits.reload
+ # 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
@@ -328,7 +286,7 @@ class MergeRequestDiff < ActiveRecord::Base
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
index 670b26d4ca3..b75387e236e 100644
--- a/app/models/merge_request_diff_commit.rb
+++ b/app/models/merge_request_diff_commit.rb
@@ -17,7 +17,9 @@ class MergeRequestDiffCommit < ActiveRecord::Base
commit_hash.merge(
merge_request_diff_id: merge_request_diff_id,
relative_order: index,
- sha: sha_attribute.type_cast_for_database(sha)
+ 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
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 47e6b785c39..77c19380e66 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -13,6 +13,7 @@ class Milestone < ActiveRecord::Base
include Referable
include StripAttribute
include Milestoneish
+ include Gitlab::SQL::Pattern
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
@@ -73,10 +74,7 @@ class Milestone < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
- t = arel_table
- pattern = "%#{query}%"
-
- where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
+ fuzzy_search(query, [:title, :description])
end
def filter_by_state(milestones, state)
@@ -86,6 +84,13 @@ class Milestone < ActiveRecord::Base
else milestones.active
end
end
+
+ def predefined?(milestone)
+ milestone == Any ||
+ milestone == None ||
+ milestone == Upcoming ||
+ milestone == Started
+ end
end
def self.reference_prefix
@@ -162,18 +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: :name, full: false)
+ def to_reference(from = nil, format: :name, full: false)
format_reference = milestone_format_reference(format)
reference = "#{self.class.reference_prefix}#{format_reference}"
if project
- "#{project.to_reference(from_project, full: full)}#{reference}"
+ "#{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
@@ -256,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 e279d8dd8c5..e350b675639 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -1,14 +1,15 @@
class Namespace < ActiveRecord::Base
- acts_as_paranoid without_default_scope: true
-
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
@@ -19,6 +20,9 @@ class Namespace < ActiveRecord::Base
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"
@@ -28,7 +32,6 @@ class Namespace < ActiveRecord::Base
validates :owner, presence: true, unless: ->(n) { n.type == "Group" }
validates :name,
presence: true,
- uniqueness: { scope: :parent_id },
length: { maximum: 255 },
namespace_name: true
@@ -36,7 +39,7 @@ class Namespace < ActiveRecord::Base
validates :path,
presence: true,
length: { maximum: 255 },
- dynamic_path: true
+ namespace_path: true
validate :nesting_level_allowed
@@ -50,7 +53,7 @@ class Namespace < ActiveRecord::Base
# Legacy Storage specific hooks
- after_update :move_dir, if: :path_changed?
+ after_update :move_dir, if: :path_or_parent_changed?
before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir
@@ -86,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)
@@ -139,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?
@@ -160,6 +172,13 @@ 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
@@ -203,14 +222,42 @@ class Namespace < ActiveRecord::Base
has_parent?
end
- def soft_delete_without_removing_associations
- # We can't use paranoia's `#destroy` since this will hard-delete projects.
- # Project uses `pending_delete` instead of the acts_as_paranoia gem.
- self.deleted_at = Time.now
+ # Overridden on EE module
+ def multiple_issue_boards_available?
+ false
+ 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
+
+ # 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
Group
.joins(project_group_links: :project)
@@ -240,4 +287,11 @@ class Namespace < ActiveRecord::Base
Namespace.where(id: descendants.select(:id))
.update_all(share_with_group_lock: true)
end
+
+ 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 9357e55b419..22d48c9e661 100644
--- a/app/models/network/commit.rb
+++ b/app/models/network/commit.rb
@@ -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 aec7b01e23a..1e0d1f9edcb 100644
--- a/app/models/network/graph.rb
+++ b/app/models/network/graph.rb
@@ -61,11 +61,8 @@ module Network
@reserved[i] = []
end
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37436
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- commits_sort_by_ref.each do |commit|
- place_chain(commit)
- end
+ commits_sort_by_ref.each do |commit|
+ place_chain(commit)
end
# find parent spaces for not overlap lines
@@ -224,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 f44590e2144..d7a67ec277c 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,8 @@ class Note < ActiveRecord::Base
include ResolvableNote
include IgnorableColumn
include Editable
+ include Gitlab::SQL::Pattern
+ include ThrottledTouch
module SpecialRole
FIRST_TIME_CONTRIBUTOR = :first_time_contributor
@@ -54,12 +55,12 @@ class Note < ActiveRecord::Base
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 # rubocop:disable Cop/ActiveRecordDependent
+ has_many :todos
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :system_note_metadata
@@ -69,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 }
@@ -86,6 +87,7 @@ class Note < ActiveRecord::Base
end
end
+ # @deprecated attachments are handler by the MarkdownUploader
mount_uploader :attachment, AttachmentUploader
# Scopes
@@ -110,12 +112,14 @@ class Note < ActiveRecord::Base
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
@@ -129,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
@@ -158,10 +171,20 @@ class Note < ActiveRecord::Base
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?
@@ -173,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
@@ -200,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?
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?
- @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
@@ -332,6 +361,16 @@ 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?
@@ -343,6 +382,45 @@ class Note < ActiveRecord::Base
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
@@ -370,4 +448,10 @@ class Note < ActiveRecord::Base
def set_discussion_id
self.discussion_id ||= discussion_class.discussion_id(self)
end
+
+ def force_cross_reference_regex_check?
+ return unless system?
+
+ 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
index 183e098d819..fd70e920c7e 100644
--- a/app/models/notification_recipient.rb
+++ b/app/models/notification_recipient.rb
@@ -1,26 +1,19 @@
class NotificationRecipient
- attr_reader :user, :type
- def initialize(
- user, type,
- custom_action: nil,
- target: nil,
- acting_user: nil,
- project: nil,
- group: nil,
- skip_read_ability: false
- )
+ 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 = custom_action
- @acting_user = acting_user
- @target = target
- @project = project || default_project
- @group = group || @project&.group
+ @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
- @skip_read_ability = skip_read_ability
+ @reason = opts[:reason]
+ @skip_read_ability = opts[:skip_read_ability]
end
def notification_setting
@@ -76,9 +69,15 @@ class NotificationRecipient
def own_activity?
return false unless @acting_user
- return false if @acting_user.notified_of_own_activity?
- user == @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?
@@ -86,6 +85,7 @@ class NotificationRecipient
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
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..588bd50ed77 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -1,10 +1,14 @@
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 :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 +20,31 @@ 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 to_param
domain
@@ -27,7 +53,7 @@ class PagesDomain < ActiveRecord::Base
def url
return unless domain
- if certificate
+ if certificate.present?
"https://#{domain}"
else
"http://#{domain}"
@@ -65,12 +91,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 +110,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 +167,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 +175,7 @@ class PagesDomain < ActiveRecord::Base
def x509
return unless certificate
+
@x509 ||= OpenSSL::X509::Certificate.new(certificate)
rescue OpenSSL::X509::CertificateError
nil
@@ -112,6 +183,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 1f9d712ef84..063dc521324 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -3,6 +3,8 @@ class PersonalAccessToken < ActiveRecord::Base
include TokenAuthenticatable
add_authentication_token_field :token
+ 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,6 +29,21 @@ 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
@@ -32,4 +51,12 @@ class PersonalAccessToken < ActiveRecord::Base
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 f7221e4f3b2..5cd1da43645 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,16 +15,28 @@ 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
- extend Gitlab::CurrentSettings
BoardLimitExceeded = Class.new(StandardError)
NUMBER_OF_PERMITTED_BOARDS = 1
UNKNOWN_IMPORT_URL = 'http://unknown.git'.freeze
- LATEST_STORAGE_VERSION = 1
+ # 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
cache_markdown_field :description, pipeline: :description
@@ -39,8 +50,8 @@ class Project < ActiveRecord::Base
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
@@ -59,11 +70,13 @@ class Project < ActiveRecord::Base
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?
@@ -72,6 +85,7 @@ class Project < ActiveRecord::Base
attr_accessor :old_path_with_namespace
attr_accessor :template_name
attr_writer :pipeline_status
+ attr_accessor :skip_disk_validation
alias_attribute :title, :name
@@ -79,6 +93,8 @@ 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
@@ -115,12 +131,22 @@ class Project < ActiveRecord::Base
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, foreign_key: 'target_project_id'
@@ -154,6 +180,7 @@ class Project < ActiveRecord::Base
has_many :releases
has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :lfs_objects, through: :lfs_objects_projects
+ has_many :lfs_file_locks
has_many :project_group_links
has_many :invited_groups, through: :project_group_links, source: :group
has_many :pages_domains
@@ -164,19 +191,23 @@ class Project < ActiveRecord::Base
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'
+ 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', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ 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'
@@ -188,6 +219,9 @@ class Project < ActiveRecord::Base
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, update_only: true
@@ -197,15 +231,14 @@ class Project < ActiveRecord::Base
delegate :name, to: :owner, allow_nil: true, 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: /\.{2}/,
- message: 'cannot include directory traversal.' },
+ format: { without: %r{(\.{2}|\A/)},
+ message: 'cannot include leading slash or directory traversal.' },
length: { maximum: 255 },
allow_blank: true
validates :name,
@@ -215,11 +248,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 }
@@ -227,25 +257,27 @@ 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 :can_create_repository?, on: [:create, :update], if: ->(project) { !project.persisted? || project.renamed? }
- 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
validates :repository_storage,
presence: true,
inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } }
+ validates :variables, variable_duplicates: { scope: :environment_scope }
- mount_uploader :avatar, AvatarUploader
has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# Scopes
scope :pending_delete, -> { where(pending_delete: true) }
scope :without_deleted, -> { where(pending_delete: false) }
- scope :sorted_by_activity, -> { reorder(last_activity_at: :desc) }
+ 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) }
+
+ # 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) }
@@ -257,7 +289,6 @@ class Project < ActiveRecord::Base
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) }
@@ -282,6 +313,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 }
@@ -290,14 +322,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
@@ -318,14 +345,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
@@ -335,6 +359,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
@@ -393,32 +418,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})") # rubocop:disable GitlabSecurity/SqlInjection
+ 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
@@ -463,6 +467,13 @@ 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?
@@ -471,14 +482,18 @@ class Project < ActiveRecord::Base
def auto_devops_enabled?
if auto_devops&.enabled.nil?
- current_application_settings.auto_devops_enabled?
+ Gitlab::CurrentSettings.auto_devops_enabled?
else
auto_devops.enabled?
end
end
def has_auto_devops_implicitly_disabled?
- auto_devops&.enabled.nil? && !current_application_settings.auto_devops_enabled?
+ auto_devops&.enabled.nil? && !Gitlab::CurrentSettings.auto_devops_enabled?
+ end
+
+ def empty_repo?
+ repository.empty?
end
def repository_storage_path
@@ -493,10 +508,13 @@ class Project < ActiveRecord::Base
@repository ||= Repository.new(full_path, self, disk_path: disk_path)
end
- def reload_repository!
+ 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}/#{full_path.downcase}"
@@ -514,6 +532,10 @@ 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)
@@ -527,7 +549,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?
@@ -539,8 +561,10 @@ class Project < ActiveRecord::Base
if forked?
RepositoryForkWorker.perform_async(id,
forked_from_project.repository_storage_path,
- forked_from_project.full_path,
- self.namespace.full_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
@@ -576,7 +600,7 @@ class Project < ActiveRecord::Base
def ci_config_path=(value)
# Strip all leading slashes so that //foo -> foo
- super(value&.sub(%r{\A/+}, '')&.delete("\0"))
+ super(value&.delete("\0"))
end
def import_url=(value)
@@ -608,6 +632,7 @@ 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)
@@ -615,7 +640,7 @@ class Project < ActiveRecord::Base
end
def import?
- external_import? || forked? || gitlab_project_import?
+ external_import? || forked? || gitlab_project_import? || bare_repository_import?
end
def no_import?
@@ -635,7 +660,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?
@@ -654,6 +680,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
@@ -662,10 +692,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
@@ -722,10 +748,10 @@ class Project < ActiveRecord::Base
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
@@ -734,13 +760,14 @@ class Project < ActiveRecord::Base
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(
- "#{full_path}+#{author.incoming_email_token}")
+ "#{full_path}#{suffix}+#{author.incoming_email_token}")
end
def build_commit_note(commit)
@@ -752,7 +779,7 @@ class Project < ActiveRecord::Base
end
def last_activity_date
- last_repository_updated_at || last_activity_at || updated_at
+ [last_activity_at, last_repository_updated_at, updated_at].compact.max
end
def project_id
@@ -808,7 +835,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?
@@ -828,7 +855,7 @@ 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(exceptions: [])
@@ -878,14 +905,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
@@ -898,20 +917,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.project_avatar_url(self) if avatar_in_git)
+ Gitlab::Routing.url_helpers.project_avatar_url(self) if avatar_in_git
end
# For compatibility with old code
@@ -931,7 +942,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
@@ -943,15 +956,21 @@ class Project < ActiveRecord::Base
end
def execute_hooks(data, hooks_scope = :push_hooks)
- hooks.public_send(hooks_scope).each do |hook| # rubocop:disable GitlabSecurity/PublicSend
- 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.public_send(hooks_scope).each do |service| # rubocop:disable GitlabSecurity/PublicSend
- 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
@@ -962,18 +981,18 @@ class Project < ActiveRecord::Base
false
end
- def repo
- repository.rugged
- end
-
def url_to_repo
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)
@@ -989,13 +1008,39 @@ 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?
+ return true if fork_network && fork_network.root_project != self
+
+ # 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 fork_source
+ return nil unless forked?
+
+ forked_from_project || fork_network&.root_project
+ end
+
+ def lfs_storage_project
+ @lfs_storage_project ||= begin
+ result = self
+
+ # 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?
+
+ result || self
+ end
+ end
+
def personal?
!group
end
@@ -1015,24 +1060,29 @@ class Project < ActiveRecord::Base
end
# Check if repository already exists on disk
- def can_create_repository?
+ 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
- if gitlab_shell.exists?(repository_storage_path, "#{disk_path}.git")
+ # 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.add_repository(repository_storage_path, disk_path)
+ if gitlab_shell.add_repository(repository_storage, disk_path)
repository.after_create
true
else
@@ -1043,6 +1093,7 @@ class Project < ActiveRecord::Base
def hook_attrs(backward: true)
attrs = {
+ id: id,
name: name,
description: description,
web_url: web_url,
@@ -1070,7 +1121,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
@@ -1097,7 +1152,7 @@ class Project < ActiveRecord::Base
def change_head(branch)
if repository.branch_exists?(branch)
repository.before_change_head
- repository.write_ref('HEAD', "refs/heads/#{branch}")
+ repository.raw_repository.write_ref('HEAD', "refs/heads/#{branch}", shell: false)
repository.copy_gitattributes(branch)
repository.after_change_head
reload_default_branch
@@ -1107,8 +1162,19 @@ class Project < ActiveRecord::Base
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
@@ -1123,6 +1189,10 @@ class Project < ActiveRecord::Base
!!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
@@ -1225,7 +1295,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
@@ -1257,7 +1327,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
@@ -1343,6 +1413,31 @@ class Project < ActiveRecord::Base
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
@@ -1353,11 +1448,51 @@ class Project < ActiveRecord::Base
reload_repository!
end
- def after_rename_repo
- path_before_change = previous_changes['path'].first
+ def after_import
+ repository.after_import
+ import_finish
+ remove_import_jid
+ update_project_counter_caches
+ after_create_default_branch
+ end
- Gitlab::UploadsTransfer.new.rename_project(path_before_change, self.path, namespace.full_path)
- Gitlab::PagesTransfer.new.rename_project(path_before_change, self.path, namespace.full_path)
+ 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)
@@ -1393,17 +1528,38 @@ 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, disk_path)
+ 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 full_path_slug
@@ -1421,7 +1577,8 @@ class Project < ActiveRecord::Base
{ key: 'CI_PROJECT_PATH', value: full_path, public: true },
{ key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug, public: true },
{ key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path, public: true },
- { key: 'CI_PROJECT_URL', value: web_url, public: true }
+ { key: 'CI_PROJECT_URL', value: web_url, public: true },
+ { key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level), public: true }
]
end
@@ -1449,20 +1606,23 @@ 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
+ return [] unless deployment_platform
- deployment_service.predefined_variables
+ deployment_platform.predefined_variables
end
def auto_devops_variables
return [] unless auto_devops_enabled?
- auto_devops&.variables || []
+ (auto_devops || build_auto_devops)&.variables
end
def append_or_update_attribute(name, value)
@@ -1512,10 +1672,6 @@ class Project < ActiveRecord::Base
map.public_path_for_source_path(path)
end
- def parent
- namespace
- end
-
def parent_changed?
namespace_id_changed?
end
@@ -1528,8 +1684,9 @@ class Project < ActiveRecord::Base
end
end
- def multiple_issue_boards_available?(user)
- feature_available?(:multiple_issue_boards, user)
+ # Overridden on EE module
+ def multiple_issue_boards_available?
+ false
end
def issue_board_milestone_available?(user = nil)
@@ -1550,18 +1707,103 @@ class Project < ActiveRecord::Base
end
def legacy_storage?
- self.storage_version.nil?
+ [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
+
private
def storage
@storage ||=
- if self.storage_version && self.storage_version >= 1
+ if hashed_storage?(:repository)
Storage::HashedProject.new(self)
else
Storage::LegacyProject.new(self)
@@ -1569,11 +1811,32 @@ class Project < ActiveRecord::Base
end
def use_hashed_storage
- if self.new_record? && current_application_settings.hashed_storage_enabled
+ 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)
diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb
index 9a52edbff8e..112ed7ed434 100644
--- a/app/models/project_auto_devops.rb
+++ b/app/models/project_auto_devops.rb
@@ -6,13 +6,17 @@ class ProjectAutoDevops < ActiveRecord::Base
validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true }
+ def instance_domain
+ Gitlab::CurrentSettings.auto_devops_domain
+ end
+
def has_domain?
- domain.present?
+ domain.present? || instance_domain.present?
end
def variables
variables = []
- variables << { key: 'AUTO_DEVOPS_DOMAIN', value: domain, public: true } if domain.present?
+ variables << { key: 'AUTO_DEVOPS_DOMAIN', value: domain.presence || instance_domain, public: true } if has_domain?
variables
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_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/campfire_service.rb b/app/models/project_services/campfire_service.rb
index c3f5b310619..8d7a4fceb08 100644
--- a/app/models/project_services/campfire_service.rb
+++ b/app/models/project_services/campfire_service.rb
@@ -86,7 +86,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 818cfb01b14..dab0ea1a681 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -99,7 +99,7 @@ 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 update?(data)
when "merge_request"
@@ -129,7 +129,7 @@ class ChatNotificationService < Service
end
def project_name
- project.name_with_namespace.gsub(/\s/, '')
+ project.full_name.gsub(/\s/, '')
end
def project_url
@@ -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/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/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
index 976d85246a8..f31c3f02af2 100644
--- a/app/models/project_services/hipchat_service.rb
+++ b/app/models/project_services/hipchat_service.rb
@@ -51,8 +51,10 @@ 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)) # rubocop:disable GitlabSecurity/PublicSend
end
@@ -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
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 31984c5d7ed..5fb15c383ca 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -10,9 +10,9 @@ class IssueTrackerService < Service
# overriden patterns. See ReferenceRegexes::EXTERNAL_PATTERN
def self.reference_pattern(only_long: false)
if only_long
- %r{(\b[A-Z][A-Z0-9_]+-)(?<issue>\d+)}
+ /(\b[A-Z][A-Z0-9_]+-)(?<issue>\d+)/
else
- %r{(\b[A-Z][A-Z0-9_]+-|#{Issue.reference_prefix})(?<issue>\d+)}
+ /(\b[A-Z][A-Z0-9_]+-|#{Issue.reference_prefix})(?<issue>\d+)/
end
end
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 9ee3a533c1e..e5035c81df0 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -1,13 +1,19 @@
class JiraService < IssueTrackerService
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 :username, presence: true, if: :activated?
+ validates :password, presence: true, if: :activated?
prop_accessor :username, :password, :url, :api_url, :jira_issue_transition_id, :title, :description
before_update :reset_password
+ alias_method :project_url, :url
+
# 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.
@@ -17,7 +23,7 @@ class JiraService < IssueTrackerService
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
def self.reference_pattern(only_long: true)
- @reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
+ @reference_pattern ||= /(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)/
end
def initialize_properties
@@ -41,9 +47,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
@@ -174,6 +182,7 @@ class JiraService < IssueTrackerService
def test_settings
return unless client_url.present?
+
# Test settings by getting the project
jira_request { client.ServerInfo.all.attrs }
end
@@ -261,7 +270,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
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb
index 5c1a1063baa..c1c68be5ed9 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
@@ -26,6 +30,7 @@ class KubernetesService < DeploymentService
before_validation :enforce_namespace_to_lower_case
+ validate :deprecation_validation, unless: :template?
validates :namespace,
allow_blank: true,
length: 1..63,
@@ -134,6 +139,22 @@ 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
@@ -151,7 +172,10 @@ class KubernetesService < DeploymentService
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')
@@ -173,6 +197,7 @@ class KubernetesService < DeploymentService
kubeclient.get_pods(namespace: actual_namespace).as_json
rescue KubeException => err
raise err unless err.error_code == 404
+
[]
end
@@ -204,11 +229,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/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..f68a0c1a3c3
--- /dev/null
+++ b/app/models/project_services/packagist_service.rb
@@ -0,0 +1,65 @@
+class PackagistService < Service
+ include HTTParty
+
+ 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 6a3118a11b8..9c7b58dead5 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 }
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..e3a1ca2d45f 100644
--- a/app/models/project_services/pushover_service.rb
+++ b/app/models/project_services/pushover_service.rb
@@ -88,10 +88,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
diff --git a/app/models/project_services/slash_commands_service.rb b/app/models/project_services/slash_commands_service.rb
index eb4da68bb7e..37ea45109ae 100644
--- a/app/models/project_services/slash_commands_service.rb
+++ b/app/models/project_services/slash_commands_service.rb
@@ -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_statistics.rb b/app/models/project_statistics.rb
index 715b215d1db..87a4350f022 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -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 1d35426050e..a9e5cfb8240 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)
@@ -157,39 +147,16 @@ class ProjectTeam
#
# 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 698fdf7a20c..f6041da986c 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -21,7 +21,7 @@ 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'
@@ -54,12 +54,15 @@ class ProjectWiki
[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
@@ -73,8 +76,8 @@ class ProjectWiki
# 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
@@ -86,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)
@@ -108,7 +105,7 @@ 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
@@ -116,18 +113,28 @@ class ProjectWiki
def update_page(page, content:, title: nil, format: :markdown, message: nil)
commit = commit_details(:updated, message, page.title)
- wiki.update_page(page, title || 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("/")]
@@ -138,27 +145,15 @@ class ProjectWiki
end
def repository
- @repository ||= Repository.new(full_path, @project, disk_path: disk_path)
+ @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(disk_path)
- wiki = Gollum::Wiki.new(path_to_repo)
- else
- raise CouldNotCreateWikiError
- end
-
- repository.after_create
-
- wiki
- end
-
def ensure_repository
- create_repo! unless repository_exists?
+ raise CouldNotCreateWikiError unless wiki.repository_exists?
end
def hook_attrs
@@ -173,24 +168,24 @@ class ProjectWiki
private
- def init_repo(disk_path)
- gitlab_shell.add_repository(project.repository_storage_path, disk_path)
+ def create_repo!(raw_repository)
+ gitlab_shell.add_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, "#{disk_path}.git")
- end
-
def update_project_activity
@project.touch(:last_activity_at, :last_repository_updated_at)
end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 89bfc5f9a9c..609780c5587 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -2,19 +2,19 @@ class ProtectedBranch < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
- extend Gitlab::CurrentSettings
-
protected_ref_access_levels :merge, :push
# Check if branch name is marked as protected in the system
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
index 83ce9014094..90c085c888e 100644
--- a/app/models/push_event.rb
+++ b/app/models/push_event.rb
@@ -46,10 +46,11 @@ class PushEvent < Event
# Returns PushEvent instances for which no merge requests have been created.
def self.without_existing_merge_requests
- existing_mrs = MergeRequest.except(:order)
+ 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
diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb
index 31de204d824..20532527346 100644
--- a/app/models/redirect_route.rb
+++ b/app/models/redirect_route.rb
@@ -17,4 +17,32 @@ class RedirectRoute < ActiveRecord::Base
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 f11cf1b065d..e6b88320110 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -4,21 +4,23 @@ 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
- attr_accessor :full_path, :disk_path, :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
CreateTreeError = Class.new(StandardError)
@@ -33,7 +35,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? empty? 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
@@ -46,7 +52,9 @@ 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
@@ -65,10 +73,12 @@ class Repository
end
end
- def initialize(full_path, project, disk_path: nil)
+ 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 ==(other)
@@ -83,6 +93,10 @@ class Repository
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(
@@ -90,33 +104,42 @@ class Repository
)
end
- # we need to have this method here because it is not cached in ::Git and
- # the method is called multiple times for every request
- def has_visible_content?
- branch_count > 0
- end
-
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_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, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil)
+ 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,
@@ -126,12 +149,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)
@@ -140,31 +165,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
- raw_repository.gitaly_migrate(:commits_by_message) do |is_enabled|
- if is_enabled
- find_commits_by_message_by_gitaly(query, ref, path, limit, offset)
- else
- find_commits_by_message_by_shelling_out(query, ref, path, limit, offset)
- end
+ 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)
@@ -212,11 +233,13 @@ class Repository
def branch_exists?(branch_name)
return false unless raw_repository
- @branch_exists_memo ||= Hash.new do |hash, key|
- hash[key] = raw_repository.branch_exists?(key)
- end
+ branch_names.include?(branch_name)
+ end
- @branch_exists_memo[branch_name]
+ def tag_exists?(tag_name)
+ return false unless raw_repository
+
+ tag_names.include?(tag_name)
end
def ref_exists?(ref)
@@ -230,39 +253,31 @@ 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
- write_ref(keep_around_ref_name(sha), sha)
- rescue Rugged::ReferenceError => ex
- Rails.logger.error "Unable to create #{REF_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 #{REF_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)
ref_exists?(keep_around_ref_name(sha))
end
- def write_ref(ref_path, sha)
- rugged.references.create(ref_path, sha, force: true)
- 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
@@ -274,7 +289,7 @@ 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
@@ -345,7 +360,7 @@ class Repository
def expire_emptiness_caches
return unless empty?
- expire_method_caches(%i(empty?))
+ expire_method_caches(%i(empty? has_visible_content?))
end
def lookup_cache
@@ -467,20 +482,19 @@ 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
+ # 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
@@ -488,17 +502,15 @@ class Repository
def exists?
return false unless full_path
- Gitlab::GitalyClient.migrate(:repository_exists) do |enabled|
- if enabled
- raw_repository.exists?
- else
- refs_directory_exists?
- end
- end
+ raw_repository.exists?
end
cache_method :exists?
- delegate :empty?, to: :raw_repository
+ def empty?
+ return true unless exists?
+
+ !has_visible_content?
+ end
cache_method :empty?
# The size of this repository in megabytes.
@@ -515,11 +527,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
@@ -528,17 +536,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)
@@ -568,7 +590,7 @@ class Repository
def license_key
return unless exists?
- Licensee.license(path).try(:key)
+ raw_repository.license_short_name
end
cache_method :license_key
@@ -629,32 +651,21 @@ class Repository
end
def last_commit_for_path(sha, path)
- raw_repository.gitaly_migrate(:last_commit_for_path) do |is_enabled|
- if is_enabled
- last_commit_for_path_by_gitaly(sha, path)
- else
- last_commit_for_path_by_rugged(sha, path)
- end
- end
+ commit_by(oid: last_commit_id_for_path(sha, path))
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
- raw_repository.gitaly_migrate(:last_commit_for_path) do |is_enabled|
- if is_enabled
- last_commit_for_path_by_gitaly(sha, path).id
- else
- last_commit_id_for_path_by_shelling_out(sha, path)
- end
- end
+ raw_repository.last_commit_id_for_path(sha, path)
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
@@ -672,7 +683,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
@@ -683,10 +696,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
@@ -700,31 +717,15 @@ class Repository
contributor
end
- end
-
- def refs_contains_sha(ref_type, sha)
- args = %W(#{ref_type} --contains #{sha})
- names = run_git(args).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
@@ -738,34 +739,30 @@ 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
def with_cache_hooks
@@ -779,70 +776,14 @@ class Repository
result.newrev
end
- def with_branch(user, *args)
- with_cache_hooks do
- Gitlab::Git::OperationService.new(user, raw_repository).with_branch(*args) do |start_commit|
- yield start_commit
- end
- end
- 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)
-
- with_branch(
- user,
- branch_name,
- start_branch_name: start_branch_name,
- start_repository: start_project.repository.raw_repository) do |start_commit|
-
- index = Gitlab::Git::Index.new(raw_repository)
-
- if start_commit
- index.read_tree(start_commit.rugged_commit.tree)
- parents = [start_commit.sha]
- else
- parents = []
- end
-
- actions.each do |options|
- index.public_send(options.delete(:action), options) # rubocop:disable GitlabSecurity/PublicSend
- end
-
- options = {
- tree: index.write_tree,
- message: message,
- parents: parents
- }
- options.merge!(get_committer_and_author(user, email: author_email, name: author_name))
+ def multi_action(user, **options)
+ start_project = options.delete(:start_project)
- create_commit(options)
+ if start_project
+ options[:start_repository] = start_project.repository.raw_repository
end
- 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
- {
- author: author,
- committer: committer
- }
- 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
- end
+ with_cache_hooks { raw.multi_action(user, **options) }
end
def merge(user, source_sha, merge_request, message)
@@ -854,6 +795,15 @@ class Repository
end
end
+ 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?
+
+ merge_request&.update(in_progress_merge_commit_sha: their_commit_id)
+
+ with_cache_hooks { raw.ff_merge(user, their_commit_id, target_branch) }
+ end
+
def revert(
user, commit, branch_name, message,
start_branch_name: nil, start_project: project)
@@ -886,87 +836,54 @@ class Repository
end
end
- def resolve_conflicts(user, branch_name, params)
- with_branch(user, branch_name) do
- committer = user_to_committer(user)
-
- create_commit(params.merge(author: committer, committer: committer))
- end
- end
-
- def merged_to_root_ref?(branch_name)
- branch_commit = commit(branch_name)
- root_ref_commit = commit(root_ref)
+ def merged_to_root_ref?(branch_or_name)
+ branch = Gitlab::Git::Branch.find(self, branch_or_name)
- if branch_commit
- same_head = branch_commit.id == root_ref_commit.id
- !same_head && ancestor?(branch_commit.id, root_ref_commit.id)
+ if branch
+ same_head = branch.target == root_ref_sha
+ merged = ancestor?(branch.target, root_ref_sha)
+ !same_head && merged
else
nil
end
end
+ def root_ref_sha
+ @root_ref_sha ||= commit(root_ref).sha
+ 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 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.ancestor?(ancestor_id, descendant_id)
- else
- rugged_is_ancestor?(ancestor_id, descendant_id)
- end
- end
- end
-
- def empty_repo?
- !exists? || !has_visible_content?
- end
- cache_method :empty_repo?, memoize_only: true
-
- def search_files_by_content(query, ref)
- return [] if empty_repo? || query.blank?
-
- offset = 2
- args = %W(grep -i -I -n --before-context #{offset} --after-context #{offset} -E -e #{Regexp.escape(query)} #{ref || root_ref})
-
- run_git(args).first.scrub.split(/^--$/)
+ raw_repository.ancestor?(ancestor_id, descendant_id)
end
- def search_files_by_name(query, ref)
- return [] if empty_repo? || query.blank?
-
- args = %W(ls-tree --full-tree -r #{ref || root_ref} --name-status | #{Regexp.escape(query)})
-
- run_git(args).first.lines.map(&:strip)
- end
-
- def add_remote(name, url)
- raw_repository.remote_add(name, url)
- rescue Rugged::ConfigError
- raw_repository.remote_update(name, url: url)
- end
+ 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
- def remove_remote(name)
- raw_repository.remote_delete(name)
- true
- rescue Rugged::ConfigError
- false
+ add_remote(remote_name, url, mirror_refmap: refmap)
+ fetch_remote(remote_name, forced: forced, prune: prune)
+ ensure
+ remove_remote(remote_name) if tmp_remote_name
end
- def fetch_remote(remote, forced: false, no_tags: false)
- gitlab_shell.fetch_remote(raw_repository, remote, forced: forced, no_tags: no_tags)
+ 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 fetch_source_branch(source_repository, source_branch, local_ref)
- raw_repository.fetch_source_branch(source_repository.raw_repository, source_branch, local_ref)
+ 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 compare_source_branch(target_branch_name, source_repository, source_branch_name, straight:)
@@ -974,7 +891,7 @@ class Repository
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)
@@ -982,8 +899,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)
@@ -1013,6 +938,10 @@ class Repository
if instance_variable_defined?(ivar)
instance_variable_get(ivar)
else
+ # If the repository doesn't exist and a fallback was specified we return
+ # that value inmediately. This saves us Rugged/gRPC invocations.
+ return fallback unless fallback.nil? || exists?
+
begin
value =
if memoize_only
@@ -1020,10 +949,12 @@ class Repository
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.
+ rescue Gitlab::Git::Repository::NoRepository
+ # Even if the above `#exists?` check passes these errors might still
+ # occur (for example because of a non-existing HEAD). We want to
+ # gracefully handle this and not cache anything
fallback
end
end
@@ -1049,8 +980,35 @@ class Repository
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
@@ -1059,12 +1017,6 @@ class Repository
blob.data
end
- def refs_directory_exists?
- circuit_breaker.perform do
- File.exist?(File.join(path_to_repo, 'refs'))
- end
- end
-
def cache
# TODO: should we use UUIDs here? We could move repositories without clearing this cache
@cache ||= RepositoryCache.new(full_path, @project.id)
@@ -1093,51 +1045,7 @@ class Repository
Gitlab::Metrics.add_event(event, { path: full_path }.merge(tags))
end
- def last_commit_for_path_by_gitaly(sha, path)
- c = raw_repository.gitaly_commit_client.last_commit_for_path(sha, path)
- commit(c)
- end
-
- def last_commit_for_path_by_rugged(sha, path)
- sha = last_commit_id_for_path_by_shelling_out(sha, path)
- commit(sha)
- end
-
- def last_commit_id_for_path_by_shelling_out(sha, path)
- args = %W(rev-list --max-count=1 #{sha} -- #{path})
- run_git(args).first.strip
- end
-
- def repository_storage_path
- @project.repository_storage_path
- end
-
def initialize_raw_repository
- Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', Gitlab::GlRepository.gl_repository(project, false))
- end
-
- def circuit_breaker
- @circuit_breaker ||= Gitlab::Git::Storage::CircuitBreaker.for_storage(project.repository_storage)
- end
-
- def find_commits_by_message_by_shelling_out(query, ref, path, limit, offset)
- ref ||= root_ref
-
- args = %W(
- 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 = run_git(args).first.lines
-
- git_log_results.map { |c| commit(c.chomp) }.compact
- end
-
- def find_commits_by_message_by_gitaly(query, ref, path, limit, offset)
- raw_repository
- .gitaly_commit_client
- .commits_by_message(query, revision: ref, path: path, limit: limit, offset: offset)
- .map { |c| commit(c) }
+ 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 298569cb7a6..6e311806be1 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -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 6b64079215f..369cae2e85f 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -2,6 +2,8 @@
# and implement a set of methods
class Service < ActiveRecord::Base
include Sortable
+ include Importable
+
serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
default_value_for :active, false
@@ -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'
@@ -117,6 +120,11 @@ 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
@@ -211,7 +219,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?
@@ -238,6 +246,7 @@ class Service < ActiveRecord::Base
kubernetes
mattermost_slash_commands
mattermost
+ packagist
pipelines_email
pivotaltracker
prometheus
@@ -248,6 +257,7 @@ class Service < ActiveRecord::Base
teamcity
microsoft_teams
]
+
if Rails.env.development?
service_names += %w[mock_ci mock_deployment mock_monitoring]
end
@@ -262,6 +272,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
@@ -275,4 +297,8 @@ class Service < ActiveRecord::Base
project.cache_has_external_wiki
end
end
+
+ def valid_recipients?
+ activated? && !importing?
+ end
end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 9533aa7f555..a58c208279e 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -9,8 +9,7 @@ class Snippet < ActiveRecord::Base
include Mentionable
include Spammable
include Editable
-
- extend Gitlab::CurrentSettings
+ include Gitlab::SQL::Pattern
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
@@ -27,7 +26,7 @@ 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
@@ -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,7 @@ class Snippet < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search_code(query)
- table = Snippet.arel_table
- pattern = "%#{query}%"
-
- where(table[:content].matches(pattern))
+ fuzzy_search(query, [:content])
end
end
end
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 0b33e45473b..29035480371 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -1,8 +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 duplicate
+ 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 09c9b3250eb..9c60adf0c90 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -2,11 +2,10 @@ require 'carrierwave/orm/activerecord'
class User < ActiveRecord::Base
extend Gitlab::ConfigHelper
- extend Gitlab::CurrentSettings
include Gitlab::ConfigHelper
- include Gitlab::CurrentSettings
include Gitlab::SQL::Pattern
+ include AfterCommitQueue
include Avatarable
include Referable
include Sortable
@@ -16,18 +15,20 @@ class User < ActiveRecord::Base
include FeatureGate
include CreatedAtFilterable
include IgnorableColumn
+ include BulkMemberAccessLoad
+ include BlocksJsonSerialization
DEFAULT_NOTIFICATION_LEVEL = :participating
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
@@ -50,17 +51,22 @@ class User < ActiveRecord::Base
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
@@ -73,7 +79,7 @@ class User < ActiveRecord::Base
#
# Namespace for personal projects
- has_one :namespace, -> { where(type: nil) }, dependent: :destroy, foreign_key: :owner_id, autosave: true # rubocop:disable Cop/ActiveRecordDependent
+ 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
@@ -91,8 +97,8 @@ class User < ActiveRecord::Base
has_one :user_synced_attributes_metadata, autosave: true
# Groups
- has_many :members, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, source: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
+ 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
@@ -100,7 +106,7 @@ 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 # rubocop:disable Cop/ActiveRecordDependent
+ 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 # rubocop:disable Cop/ActiveRecordDependent
@@ -121,7 +127,7 @@ class User < ActiveRecord::Base
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, dependent: :destroy # 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
@@ -130,6 +136,10 @@ class User < ActiveRecord::Base
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # 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
#
@@ -143,33 +153,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
- validate :namespace_uniq, if: :username_changed?
+ validates :namespace, presence: true
validate :namespace_move_dir_allowed, 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?
-
- 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?
+ 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) }
- after_save :ensure_namespace_correct
+ 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_initialize :set_projects_limit
- after_destroy :post_destroy_hook
# User's Layout preference
enum layout: [:fixed, :fluid]
@@ -179,15 +188,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
@@ -216,15 +218,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 # rubocop:disable Cop/ActiveRecordDependent
-
# Scopes
scope :admins, -> { where(admin: true) }
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
@@ -232,8 +230,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")
@@ -253,7 +251,7 @@ 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
@@ -270,18 +268,22 @@ class User < ActiveRecord::Base
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
+
+ # 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])
- User.find_by_sql([sql, { email: email }]).first
+ from("(#{union.to_sql}) #{table_name}")
end
def filter(filter_name)
@@ -311,8 +313,9 @@ class User < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
- table = arel_table
- pattern = User.to_pattern(query)
+ return none if query.blank?
+
+ query = query.downcase
order = <<~SQL
CASE
@@ -324,9 +327,9 @@ class User < ActiveRecord::Base
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
@@ -335,16 +338,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
@@ -424,7 +431,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
#
@@ -435,7 +442,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
@@ -443,6 +450,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)
@@ -456,6 +467,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(
@@ -479,17 +498,10 @@ 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
@@ -499,12 +511,6 @@ class User < ActiveRecord::Base
end
end
- def avatar_type
- unless avatar.image?
- errors.add :avatar, "only images allowed"
- end
- end
-
def unique_email
if !emails.exists?(email: email) && Email.exists?(email: email)
errors.add(:email, 'has already been taken')
@@ -523,19 +529,31 @@ 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)])
@@ -543,6 +561,11 @@ class User < ActiveRecord::Base
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
# and child groups (recursively).
def all_expanded_groups
@@ -578,6 +601,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.
#
@@ -602,21 +634,39 @@ 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_creation_for_web?
+ allow_password_authentication_for_web? && password_automatically_set?
end
- def require_password_creation?
- password_automatically_set? && allow_password_authentication?
+ 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 current_application_settings.password_authentication_enabled? || ldap_user?
+ 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?
- !ldap_user? && current_application_settings.password_authentication_enabled?
+ 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?
@@ -639,6 +689,10 @@ 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
@@ -678,19 +732,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
@@ -742,12 +792,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?
@@ -787,13 +834,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
@@ -805,9 +852,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
@@ -817,6 +866,18 @@ 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,
@@ -826,24 +887,32 @@ class User < ActiveRecord::Base
end
def ensure_namespace_correct
- # Ensure user has namespace
- create_namespace!(path: username, name: username) unless namespace
-
- if username_changed?
- unless namespace.update_attributes(path: username, name: username)
- namespace.errors.each do |attribute, message|
- self.errors.add(:"namespace_#{attribute}", message)
- end
- raise ActiveRecord::RecordInvalid.new(namespace)
- end
+ 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)
@@ -879,7 +948,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
@@ -927,7 +1005,11 @@ class User < ActiveRecord::Base
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
@@ -972,13 +1054,13 @@ class User < ActiveRecord::Base
end
def todos_done_count(force: false)
- Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do
+ 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) do
+ Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: 20.minutes) do
TodosFinder.new(self, state: :pending).execute.count
end
end
@@ -1000,7 +1082,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
@@ -1041,10 +1123,6 @@ class User < ActiveRecord::Base
ensure_rss_token!
end
- def verified_email?(email)
- self.email == email
- end
-
def sync_attribute?(attribute)
return true if ldap_user? && attribute == :email
@@ -1061,11 +1139,46 @@ class User < ActiveRecord::Base
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
@@ -1083,6 +1196,7 @@ class User < ActiveRecord::Base
# Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration
def send_devise_notification(notification, *args)
return true unless can?(:receive_notifications)
+
devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend
end
@@ -1100,8 +1214,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
@@ -1109,15 +1224,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
@@ -1186,7 +1301,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_synced_attributes_metadata.rb b/app/models/user_synced_attributes_metadata.rb
index 9f374304164..688432a9d67 100644
--- a/app/models/user_synced_attributes_metadata.rb
+++ b/app/models/user_synced_attributes_metadata.rb
@@ -6,11 +6,11 @@ class UserSyncedAttributesMetadata < ActiveRecord::Base
SYNCABLE_ATTRIBUTES = %i[name email location].freeze
def read_only?(attribute)
- Gitlab.config.omniauth.sync_profile_from_provider && synced?(attribute)
+ sync_profile_from_provider? && synced?(attribute)
end
def read_only_attributes
- return [] unless Gitlab.config.omniauth.sync_profile_from_provider
+ return [] unless sync_profile_from_provider?
SYNCABLE_ATTRIBUTES.select { |key| synced?(key) }
end
@@ -22,4 +22,10 @@ class UserSyncedAttributesMetadata < ActiveRecord::Base
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 f2315bb3dbb..0f5536415f7 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -1,5 +1,6 @@
class WikiPage
PageChangedError = Class.new(StandardError)
+ PageRenameError = Class.new(StandardError)
include ActiveModel::Validations
include ActiveModel::Conversion
@@ -50,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
@@ -75,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
@@ -102,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.
@@ -127,19 +131,24 @@ class WikiPage
@version ||= @page.version
end
- # Returns an array of Gitlab Commit instances.
- def versions
+ def versions(options = {})
return [] unless persisted?
- @page.versions
+ wiki.wiki.page_versions(@page.path, options)
end
- def commit
- versions.first
+ def count_versions
+ return [] unless persisted?
+
+ wiki.wiki.count_page_versions(@page.path)
+ end
+
+ def last_version
+ @last_version ||= versions(limit: 1).first
end
def last_commit_sha
- commit&.sha
+ last_version&.sha
end
# Returns the Date that this latest version was
@@ -151,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
@@ -169,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
@@ -181,7 +190,7 @@ class WikiPage
# Returns the String SHA1 of the newly created page
# or False if the save was unsuccessful.
def create(attrs = {})
- @attributes.merge!(attrs)
+ update_attributes(attrs)
save(page_details: title) do
wiki.create_page(title, content, format, message)
@@ -196,24 +205,29 @@ class WikiPage
# 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 to replace existing title
+ # :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(attrs = {})
last_commit_sha = attrs.delete(:last_commit_sha)
+
if last_commit_sha && last_commit_sha != self.last_commit_sha
- raise PageChangedError.new("You are attempting to update a page that has changed since you started editing it.")
+ raise PageChangedError
end
- attrs.slice!(:content, :format, :message, :title)
- @attributes.merge!(attrs)
- page_details =
- if title.present? && @page.title != title
- title
- else
- @page.url_path
+ 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(
@@ -247,8 +261,44 @@ 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
@@ -264,8 +314,8 @@ class WikiPage
end
page_title, page_dir = wiki.page_title_and_dir(page_details)
- gollum_wiki = wiki.wiki
- @page = gollum_wiki.paged(page_title, page_dir)
+ gitlab_git_wiki = wiki.wiki
+ @page = gitlab_git_wiki.page(title: page_title, dir: page_dir)
set_attributes
@persisted = errors.blank?
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 8fa7b2753c7..603218aa6df 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -15,4 +15,7 @@ class BasePolicy < DeclarativePolicy::Base
condition(:restricted_public_level, scope: :global) do
Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
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 984e5482288..1ab391a5a9d 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -10,6 +10,15 @@ module Ci
end
end
- rule { protected_ref }.prevent :update_build
+ condition(:owner_of_job) do
+ can?(:developer_access) && @subject.triggered_by?(@user)
+ end
+
+ 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 4e689a9efd5..6363c382ff8 100644
--- a/app/policies/ci/pipeline_policy.rb
+++ b/app/policies/ci/pipeline_policy.rb
@@ -2,16 +2,18 @@ module Ci
class PipelinePolicy < BasePolicy
delegate { @subject.project }
- condition(:protected_ref) do
- access = ::Gitlab::UserAccess.new(@user, project: @subject.project)
+ condition(:protected_ref) { ref_protected?(@user, @subject.project, @subject.tag?, @subject.ref) }
- if @subject.tag?
- !access.can_create_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?(@subject.ref)
+ !access.can_update_branch?(ref)
end
end
-
- rule { protected_ref }.prevent :update_pipeline
end
end
diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb
index 6b7598e1821..dc7a4aed577 100644
--- a/app/policies/ci/pipeline_schedule_policy.rb
+++ b/app/policies/ci/pipeline_schedule_policy.rb
@@ -2,13 +2,31 @@ 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/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/global_policy.rb b/app/policies/global_policy.rb
index 1be7bbe9953..64e550d19d0 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -11,6 +11,8 @@ class GlobalPolicy < BasePolicy
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
@@ -40,6 +42,10 @@ class GlobalPolicy < BasePolicy
enable :create_group
end
+ rule { can_create_fork }.policy do
+ enable :create_fork
+ end
+
rule { access_locked }.policy do
prevent :log_in
end
@@ -47,4 +53,9 @@ class GlobalPolicy < BasePolicy
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_policy.rb b/app/policies/group_policy.rb
index 8af9738d75c..c9cb730c4e9 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -28,14 +28,32 @@ class GroupPolicy < BasePolicy
with_options scope: :subject, score: 0
condition(:request_access_enabled) { @subject.request_access_enabled }
- rule { public_group } .enable :read_group
+ rule { public_group }.policy do
+ enable :read_group
+ enable :read_list
+ enable :read_label
+ end
+
rule { logged_in_viewable }.enable :read_group
- rule { guest } .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 }.enable :admin_label
+
+ rule { reporter }.policy do
+ enable :admin_label
+ enable :admin_list
+ enable :admin_issue
+ end
rule { master }.policy do
enable :create_projects
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index daf6fa9e18a..3f6d7d04667 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -1,6 +1,23 @@
class IssuablePolicy < BasePolicy
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)
@@ -12,4 +29,12 @@ class IssuablePolicy < BasePolicy
enable :read_merge_request
enable :update_merge_request
end
+
+ 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 bd2d417b2a8..ed499511999 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -13,7 +13,10 @@ class IssuePolicy < IssuablePolicy
rule { confidential & ~can_read_confidential }.policy do
prevent :read_issue
+ prevent :read_issue_iid
prevent :update_issue
prevent :admin_issue
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 85b67f0a237..eb01218eb0a 100644
--- a/app/policies/namespace_policy.rb
+++ b/app/policies/namespace_policy.rb
@@ -1,10 +1,15 @@
class NamespacePolicy < BasePolicy
rule { anonymous }.prevent_all
+ 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/note_policy.rb b/app/policies/note_policy.rb
index 20cd51cfb99..d4cb5a77e63 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -1,5 +1,6 @@
class NotePolicy < BasePolicy
delegate { @subject.project }
+ delegate { @subject.noteable if @subject.noteable.lockable? }
condition(:is_author) { @user && @subject.author == @user }
condition(:for_merge_request, scope: :subject) { @subject.for_merge_request? }
@@ -8,6 +9,7 @@ class NotePolicy < BasePolicy
condition(:editable, scope: :subject) { @subject.editable? }
rule { ~editable | anonymous }.prevent :edit_note
+
rule { is_author | admin }.enable :edit_note
rule { can?(:master_access) }.enable :edit_note
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index b7b5bd34189..3b0550b4dd6 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -80,8 +80,9 @@ class ProjectPolicy < BasePolicy
rule { reporter }.enable :reporter_access
rule { developer }.enable :developer_access
rule { master }.enable :master_access
+ rule { owner | admin }.enable :owner_access
- rule { owner | admin }.policy do
+ rule { can?(:owner_access) }.policy do
enable :guest_access
enable :reporter_access
enable :developer_access
@@ -98,11 +99,6 @@ class ProjectPolicy < BasePolicy
enable :remove_pages
end
- rule { owner | reporter }.policy do
- enable :build_download_code
- enable :build_read_container_image
- end
-
rule { can?(:guest_access) }.policy do
enable :read_project
enable :read_board
@@ -119,9 +115,13 @@ class ProjectPolicy < BasePolicy
enable :create_note
enable :upload_file
enable :read_cycle_analytics
- enable :read_project_snippet
end
+ # 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
@@ -141,12 +141,19 @@ class ProjectPolicy < BasePolicy
enable :read_merge_request
end
+ # 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
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
@@ -193,14 +200,8 @@ class ProjectPolicy < BasePolicy
enable :admin_pages
enable :read_pages
enable :update_pages
- end
-
- rule { can?(:public_user_access) }.policy do
- enable :public_access
-
- enable :fork_project
- enable :build_download_code
- enable :build_read_container_image
+ enable :read_cluster
+ enable :create_cluster
end
rule { archived }.policy do
@@ -239,7 +240,6 @@ class ProjectPolicy < BasePolicy
rule { repository_disabled }.policy do
prevent :push_code
- prevent :push_code_to_protected_branches
prevent :download_code
prevent :fork_project
prevent :read_commit_status
diff --git a/app/presenters/ci/group_variable_presenter.rb b/app/presenters/ci/group_variable_presenter.rb
index 81fea106a5c..98d68bc7a83 100644
--- a/app/presenters/ci/group_variable_presenter.rb
+++ b/app/presenters/ci/group_variable_presenter.rb
@@ -7,19 +7,15 @@ module Ci
end
def form_path
- if variable.persisted?
- group_variable_path(group, variable)
- else
- group_variables_path(group)
- end
+ group_settings_ci_cd_path(group)
end
def edit_path
- group_variable_path(group, variable)
+ group_variables_path(group)
end
def delete_path
- group_variable_path(group, variable)
+ 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
index 5d7998393a6..96159f88c59 100644
--- a/app/presenters/ci/variable_presenter.rb
+++ b/app/presenters/ci/variable_presenter.rb
@@ -7,19 +7,15 @@ module Ci
end
def form_path
- if variable.persisted?
- project_variable_path(project, variable)
- else
- project_variables_path(project)
- end
+ project_settings_ci_cd_path(project)
end
def edit_path
- project_variable_path(project, variable)
+ project_variables_path(project)
end
def delete_path
- project_variable_path(project, variable)
+ 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 2df84e58575..08ae49562c7 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
@@ -31,7 +32,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
def remove_wip_path
- if can?(current_user, :update_merge_request, merge_request.project)
+ if work_in_progress? && can?(current_user, :update_merge_request, merge_request.project)
remove_wip_project_merge_request_path(project, merge_request)
end
end
@@ -43,7 +44,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
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.",
@@ -76,6 +77,12 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
end
+ def rebase_path
+ if !rebase_in_progress? && should_be_rebased? && user_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)
@@ -145,15 +152,25 @@ 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?
+ source_branch_exists? && user_can_push_to_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
@@ -163,7 +180,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
def pipeline
- @pipeline ||= head_pipeline
+ @pipeline ||= actual_head_pipeline
end
def issues_sentence(project, issues)
@@ -174,6 +191,14 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end.sort.to_sentence
end
+ def user_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
+
def user_can_collaborate_with_project?
can?(current_user, :push_code, project) ||
(current_user && current_user.already_forked?(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_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/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
index 56f173e5a27..ad039a2623d 100644
--- a/app/serializers/blob_entity.rb
+++ b/app/serializers/blob_entity.rb
@@ -3,10 +3,6 @@ class BlobEntity < Grape::Entity
expose :id, :path, :name, :mode
- expose :last_commit do |blob|
- request.project.repository.last_commit_for_path(blob.commit_id, blob.path)
- end
-
expose :icon do |blob|
IconsHelper.file_type_icon_class('file', blob.mode, blob.name)
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 8c89eea607f..69d46f5ec14 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -6,7 +6,7 @@ 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|
+ expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :erase_build, build) } do |build|
erase_project_job_path(project, build)
end
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 e4e9d8ef90a..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
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 c75431a79ae..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.without_deleted.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/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
index 0a92e3f8167..bbbcf6a97c1 100644
--- a/app/serializers/discussion_entity.rb
+++ b/app/serializers/discussion_entity.rb
@@ -7,4 +7,42 @@ class DiscussionEntity < Grape::Entity
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/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 88842a9aa75..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
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 61c7a428745..6f31fbd6b7c 100644
--- a/app/serializers/issuable_entity.rb
+++ b/app/serializers/issuable_entity.rb
@@ -1,20 +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
- expose :milestone, using: API::Entities::Milestone
- expose :labels, using: LabelEntity
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 0d6feb78173..b5e2334b6e3 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -1,8 +1,17 @@
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
@@ -14,7 +23,7 @@ class IssueEntity < IssuableEntity
expose :current_user do
expose :can_create_note do |issue|
- can?(request.current_user, :create_note, issue.project)
+ can?(request.current_user, :create_note, issue)
end
expose :can_update do |issue|
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 72e56a2c77f..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
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_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_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 07650ce6f20..4e8ef320af2 100644
--- a/app/serializers/merge_request_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -1,6 +1,5 @@
-class MergeRequestEntity < IssuableEntity
- include RequestAwareEntity
-
+class MergeRequestWidgetEntity < IssuableEntity
+ expose :state
expose :in_progress_merge_commit_sha
expose :merge_commit_sha
expose :merge_error
@@ -13,12 +12,28 @@ class MergeRequestEntity < IssuableEntity
expose :target_branch
expose :target_project_id
- # Events
- expose :merge_event, using: EventEntity
- expose :closed_event, using: EventEntity
+ 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 :author, using: UserEntity
expose :merge_user, using: UserEntity
# Diff sha's
@@ -26,19 +41,30 @@ class MergeRequestEntity < IssuableEntity
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
+ 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
+
+ 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|
@@ -89,6 +115,14 @@ class MergeRequestEntity < IssuableEntity
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
@@ -163,6 +197,10 @@ class MergeRequestEntity < IssuableEntity
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
@@ -175,4 +213,27 @@ class MergeRequestEntity < IssuableEntity
@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_entity.rb b/app/serializers/note_entity.rb
index 7d50e0ff10d..4ccf0bca476 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -23,6 +23,10 @@ class NoteEntity < API::Entities::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
@@ -53,6 +57,14 @@ class NoteEntity < API::Entities::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)
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index 357fc71f877..6457294b285 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -20,6 +20,7 @@ class PipelineEntity < Grape::Entity
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
@@ -44,6 +45,11 @@ 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_project_pipeline_path(pipeline.project, pipeline)
@@ -53,8 +59,6 @@ class PipelineEntity < Grape::Entity
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_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/submodule_entity.rb b/app/serializers/submodule_entity.rb
index 9a7eb5e7880..ed1f1ae0ef0 100644
--- a/app/serializers/submodule_entity.rb
+++ b/app/serializers/submodule_entity.rb
@@ -7,7 +7,7 @@ class SubmoduleEntity < Grape::Entity
'archive'
end
- expose :project_url do |blob|
+ expose :url do |blob|
submodule_links(blob, request).first
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
index 555e5cf83bd..9f1b485347f 100644
--- a/app/serializers/tree_entity.rb
+++ b/app/serializers/tree_entity.rb
@@ -3,10 +3,6 @@ class TreeEntity < Grape::Entity
expose :id, :path, :name, :mode
- expose :last_commit do |tree|
- request.project.repository.last_commit_for_path(tree.commit_id, tree.path)
- end
-
expose :icon do |tree|
IconsHelper.file_type_icon_class('folder', tree.mode, tree.name)
end
diff --git a/app/serializers/tree_root_entity.rb b/app/serializers/tree_root_entity.rb
index 69702ae1493..496f070ddbd 100644
--- a/app/serializers/tree_root_entity.rb
+++ b/app/serializers/tree_root_entity.rb
@@ -18,4 +18,8 @@ class TreeRootEntity < Grape::Entity
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/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 9c00ea789ec..46e19230328 100644
--- a/app/services/access_token_validation_service.rb
+++ b/app/services/access_token_validation_service.rb
@@ -39,11 +39,8 @@ class AccessTokenValidationService
token_scopes = token.scopes.map(&:to_sym)
required_scopes.any? do |scope|
- if scope.respond_to?(:sufficient?)
- scope.sufficient?(token_scopes, request)
- else
- API::Scope.new(scope).sufficient?(token_scopes, request)
- end
+ scope = API::Scope.new(scope) unless scope.is_a?(API::Scope)
+ scope.sufficient?(token_scopes, request)
end
end
end
diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb
index aa6f0e841c9..0521393dd27 100644
--- a/app/services/akismet_service.rb
+++ b/app/services/akismet_service.rb
@@ -1,6 +1,4 @@
class AkismetService
- include Gitlab::CurrentSettings
-
attr_accessor :owner, :text, :options
def initialize(owner, text, options = {})
@@ -41,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)
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 9a636346899..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
- extend 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)
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/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index d85d93e251b..ecd74b74f8a 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -40,7 +40,11 @@ module Boards
end
def set_parent
- params[:project_id] = parent.id
+ if parent.is_a?(Group)
+ params[:group_id] = parent.id
+ else
+ params[:project_id] = parent.id
+ end
end
def set_state
@@ -54,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 797d6df7c1a..15fed7d17c1 100644
--- a/app/services/boards/issues/move_service.rb
+++ b/app/services/boards/issues/move_service.rb
@@ -60,8 +60,10 @@ 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(parent.id).pluck(:label_id)
+ ::Label.on_project_boards(parent.id).pluck(:label_id)
end
Array(label_ids).compact
diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb
index 183556a1d6b..bebc90c7a8d 100644
--- a/app/services/boards/lists/create_service.rb
+++ b/app/services/boards/lists/create_service.rb
@@ -12,7 +12,11 @@ module Boards
private
def available_labels_for(board)
- LabelsFinder.new(current_user, project_id: parent.id).execute
+ 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/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 d20de9b16a4..c8b112132b3 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -2,122 +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(
- source: source,
- project: project,
- ref: ref,
- sha: sha,
- before_sha: before_sha,
- tag: tag?,
- trigger_requests: Array(trigger_request),
- user: current_user,
- pipeline_schedule: schedule,
- protected: project.protected_for?(ref)
- )
-
- result = validate_project_and_git_items ||
- validate_pipeline(ignore_skip_ci: ignore_skip_ci,
- save_on_errors: save_on_errors)
-
- return result if result
-
- begin
- Ci::Pipeline.transaction do
- pipeline.save!
-
- yield(pipeline) if block_given?
-
- Ci::CreatePipelineStagesService
- .new(project, current_user)
- .execute(pipeline)
- end
- rescue ActiveRecord::RecordInvalid => e
- return error("Failed to persist the pipeline: #{e}")
- end
+ 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
- update_merge_requests_head_pipeline
-
- cancel_pending_pipelines if project.auto_cancel_pending_pipelines?
-
- pipeline_created_counter.increment(source: source)
-
- pipeline.tap(&:process!)
- end
+ def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, &block)
+ @pipeline = Ci::Pipeline.new
- private
-
- def validate_project_and_git_items
- unless project.builds_enabled?
- return error('Pipeline is disabled')
- end
+ 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,
+ current_user: current_user)
- unless allowed_to_trigger_pipeline?
- if can?(current_user, :create_pipeline, project)
- return error("Insufficient permissions for protected ref '#{ref}'")
- else
- return error('Insufficient permissions to create a new pipeline')
- end
- 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
- end
+ if sequence.complete?
+ cancel_pending_pipelines if project.auto_cancel_pending_pipelines?
+ pipeline_created_counter.increment(source: source)
- def validate_pipeline(ignore_skip_ci:, save_on_errors:)
- unless pipeline.config_processor
- unless pipeline.ci_yaml_file
- return error("Missing #{pipeline.ci_yaml_file_path} 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
+ pipeline
end
- def allowed_to_trigger_pipeline?
- if current_user
- allowed_to_create?
- else # legacy triggers don't have a corresponding user
- !project.protected_for?(ref)
- end
- end
-
- def allowed_to_create?
- return unless can?(current_user, :create_pipeline, project)
-
- access = Gitlab::UserAccess.new(current_user, project: project)
- if branch?
- access.can_update_branch?(ref)
- elsif tag?
- access.can_create_tag?(ref)
- else
- true # Allow it for now and we'll reject when we check ref existence
- end
- end
-
- def update_merge_requests_head_pipeline
- return unless pipeline.latest?
+ private
- 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
@@ -136,61 +69,19 @@ module Ci
.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?
- return @is_branch if defined?(@is_branch)
-
- @is_branch =
- project.repository.ref_exists?(Gitlab::Git::BRANCH_REF_PREFIX + ref)
- end
-
- def tag?
- return @is_tag if defined?(@is_tag)
-
- @is_tag =
- project.repository.ref_exists?(Gitlab::Git::TAG_REF_PREFIX + ref)
- end
-
- def ref
- @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.tap do
- pipeline.errors.add(:base, message)
-
- if save
- pipeline.drop
- update_merge_requests_head_pipeline
- end
+ 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_total, "Counter of pipelines created")
+ def related_merge_requests
+ MergeRequest.opened.where(source_project: pipeline.project, source_branch: pipeline.ref)
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..e73c6ad6780
--- /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 KubeException => 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
index 120af8c1e61..a9813d774bb 100644
--- a/app/services/ci/pipeline_trigger_service.rb
+++ b/app/services/ci/pipeline_trigger_service.rb
@@ -1,5 +1,7 @@
module Ci
class PipelineTriggerService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
def execute
if trigger_from_token
create_pipeline_from_trigger(trigger_from_token)
@@ -26,9 +28,9 @@ module Ci
end
def trigger_from_token
- return @trigger if defined?(@trigger)
-
- @trigger = Ci::Trigger.find_by_token(params[:token].to_s)
+ strong_memoize(:trigger) do
+ Ci::Trigger.find_by_token(params[:token].to_s)
+ end
end
def create_pipeline_variables!(pipeline)
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index b8db709211a..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,17 +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!
- register_success(build)
-
- 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.
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index d67b9f5cc56..6128b2a8fbb 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -1,7 +1,7 @@
module Ci
class RetryBuildService < ::BaseService
CLONE_ACCESSORS = %i[pipeline project ref tag options commands name
- allow_failure stage_id stage stage_idx trigger_request
+ allow_failure stage stage_id stage_idx trigger_request
yaml_variables when environment coverage_regex
description tag_list protected].freeze
@@ -28,6 +28,8 @@ module Ci
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..bde090eaeec
--- /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 KubeException => 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..8ceeec687cd
--- /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 KubeException => 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/compare_service.rb b/app/services/compare_service.rb
index 53f16a236d2..1db91c3c90c 100644
--- a/app/services/compare_service.rb
+++ b/app/services/compare_service.rb
@@ -1,17 +1,17 @@
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)
- raw_compare = target_project.repository.compare_source_branch(target_branch, start_project.repository, start_branch_name, straight: straight)
+ def execute(target_project, target_ref, straight: false)
+ raw_compare = target_project.repository.compare_source_branch(target_ref, start_project.repository, start_ref_name, straight: straight)
Compare.new(raw_compare, target_project, straight: straight) if raw_compare
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/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_merged_branches_service.rb b/app/services/delete_merged_branches_service.rb
index 077268b2388..c98d1e3c540 100644
--- a/app/services/delete_merged_branches_service.rb
+++ b/app/services/delete_merged_branches_service.rb
@@ -6,18 +6,14 @@ class DeleteMergedBranchesService < BaseService
def execute
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :push_code, project)
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37438
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- branches = project.repository.branch_names
- branches = branches.select { |branch| project.repository.merged_to_root_ref?(branch) }
- # Prevent deletion of branches relevant to open merge requests
- branches -= merge_request_branch_names
- # Prevent deletion of protected branches
- branches = branches.reject { |branch| project.protected_for?(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)
- end
+ branches.each do |branch|
+ DeleteBranchService.new(project, current_user).execute(branch)
end
end
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 6328d567a07..44dc90b3462 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -103,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..46acdc5406c 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!
+ handler = Lfs::FileModificationHandler.new(project, @branch_name)
+
+ handler.new_file(@file_path, @file_content) do |content_or_lfs_pointer|
+ create_transformed_commit(content_or_lfs_pointer)
+ end
+ 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..a03c59f569d 100644
--- a/app/services/files/multi_service.rb
+++ b/app/services/files/multi_service.rb
@@ -1,8 +1,10 @@
module Files
class MultiService < Files::BaseService
+ UPDATE_FILE_ACTIONS = %w(update move delete).freeze
+
def create_commit!
repository.multi_action(
- user: current_user,
+ current_user,
message: @commit_message,
branch_name: @branch_name,
actions: params[:actions],
@@ -11,6 +13,8 @@ module Files
start_project: @start_project,
start_branch_name: @start_branch
)
+ rescue ArgumentError => e
+ raise_error(e)
end
private
@@ -18,14 +22,16 @@ module Files
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_push_service.rb b/app/services/git_push_service.rb
index bb61136e33b..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.
@@ -154,24 +153,7 @@ class GitPushService < BaseService
offset = [@push_commits_count - PROCESS_COMMIT_LIMIT, 0].max
@push_commits = project.repository.commits(params[:newrev], offset: offset, limit: PROCESS_COMMIT_LIMIT)
- # Ensure HEAD points to the default branch in case it is not master
- project.change_head(branch_name)
-
- # Set protection on the default branch if configured
- if current_application_settings.default_branch_protection != PROTECTION_NONE && !ProtectedBranch.protected?(@project, @project.default_branch)
-
- 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
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/destroy_service.rb b/app/services/groups/destroy_service.rb
index e3f9d9ee95d..58e88688dfa 100644
--- a/app/services/groups/destroy_service.rb
+++ b/app/services/groups/destroy_service.rb
@@ -1,7 +1,6 @@
module Groups
class DestroyService < Groups::BaseService
def async_execute
- group.soft_delete_without_removing_associations
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
@@ -23,7 +22,7 @@ module Groups
group.chat_team&.remove_mattermost_team(current_user)
- group.really_destroy!
+ group.destroy
end
end
end
diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb
index d6f08fc3cce..5c337a9faa5 100644
--- a/app/services/groups/nested_create_service.rb
+++ b/app/services/groups/nested_create_service.rb
@@ -11,8 +11,8 @@ module Groups
def execute
return nil unless group_path
- if group = Group.find_by_full_path(group_path)
- return group
+ if namespace = namespace_or_group(group_path)
+ return namespace
end
if group_path.include?('/') && !Group.supports_nested_groups?
@@ -40,10 +40,14 @@ module Groups
)
new_params[:visibility_level] ||= Gitlab::CurrentSettings.current_application_settings.default_group_visibility
- last_group = Group.find_by_full_path(partial_path) || Groups::CreateService.new(current_user, new_params).execute
+ 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/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 12604e7eb5d..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)
@@ -57,6 +15,7 @@ class IssuableBaseService < BaseService
params.delete(:due_date)
params.delete(:canonical_issue_id)
params.delete(:project)
+ params.delete(:discussion_locked)
end
filter_assignee(issuable)
@@ -118,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
@@ -143,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
@@ -166,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)
@@ -210,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)
@@ -228,28 +195,27 @@ class IssuableBaseService < BaseService
# We have to perform this check before saving the issuable as Rails resets
# the changed fields upon calling #save.
- update_project_counters = issuable.update_project_counter_caches?
+ 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)
new_assignees = issuable.assignees.to_a
- affected_assignees = (old_assignees + new_assignees) - (old_assignees & new_assignees)
+ 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
@@ -289,7 +255,7 @@ 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 = TodosFinder.new(current_user).find_by(target: issuable)
todo_service.mark_todos_as_done_by_ids(todo, current_user) if todo
end
end
@@ -302,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]
@@ -316,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(issuable, users: [])
- users.each do |user|
- user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
- end
+ def parent
+ project
end
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 4c198fc96ea..9f6cfc0f6d3 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -1,10 +1,10 @@
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
@@ -22,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)
@@ -45,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/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 29def25719d..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
@@ -76,7 +80,7 @@ module Issues
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),
@@ -84,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 35de4337b15..62b4b4b6a1e 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -9,6 +9,7 @@ module Issues
notification_service.reopen_issue(issue, current_user)
execute_hooks(issue, 'reopen')
invalidate_cache_counts(issue, users: issue.assignees)
+ issue.update_project_counter_caches
end
issue
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index b4ca3966505..d7aa7e2347e 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -14,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)
@@ -27,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')
diff --git a/app/services/keys/base_service.rb b/app/services/keys/base_service.rb
index 545832d0bd4..f78791932a7 100644
--- a/app/services/keys/base_service.rb
+++ b/app/services/keys/base_service.rb
@@ -4,6 +4,7 @@ module Keys
def initialize(user, params)
@user, @params = user, params
+ @ip_address = @params.delete(:ip_address)
end
def notification_service
diff --git a/app/services/keys/last_used_service.rb b/app/services/keys/last_used_service.rb
index 066f3246158..dbd79f7da55 100644
--- a/app/services/keys/last_used_service.rb
+++ b/app/services/keys/last_used_service.rb
@@ -16,6 +16,8 @@ module Keys
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
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/lfs/file_modification_handler.rb b/app/services/lfs/file_modification_handler.rb
new file mode 100644
index 00000000000..fe9091a6e5d
--- /dev/null
+++ b/app/services/lfs/file_modification_handler.rb
@@ -0,0 +1,42 @@
+module Lfs
+ class FileModificationHandler
+ 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)
+ if project.lfs_enabled? && lfs_file?(file_path)
+ lfs_pointer_file = Gitlab::Git::LfsPointerFile.new(file_content)
+ lfs_object = create_lfs_object!(lfs_pointer_file, file_content)
+ content = lfs_pointer_file.pointer
+
+ success = yield(content)
+
+ link_lfs_object!(lfs_object) if success
+ else
+ yield(file_content)
+ end
+ end
+
+ private
+
+ def lfs_file?(file_path)
+ repository.attributes_at(branch_name, file_path)['filter'] == 'lfs'
+ 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 46c505baf8b..b141bfd5fbc 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -1,42 +1,74 @@
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.transaction do
+ unassign_issues_and_merge_requests(member) unless member.invite?
+ member.notification_setting&.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.destroy
+ end
- 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)
+ def can_destroy_member?(member)
+ can?(current_user, destroy_member_permission(member), member)
+ end
+
+ def destroy_member_permission(member)
+ case member
+ when GroupMember
+ :destroy_group_member
+ when ProjectMember
+ :destroy_project_member
else
- source.public_send(scope).find_by!(condition) # rubocop:disable GitlabSecurity/PublicSend
+ raise "Unknown member type: #{member}!"
end
end
- def can_destroy_member?(member)
- member && can?(current_user, action_member_permission(:destroy, member), member)
+ 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(current_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/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 35ccff26262..23262b62615 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -4,33 +4,19 @@ module MergeRequests
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,6 +24,21 @@ 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 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)
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index bc0e7ad4e39..4b186d93772 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -1,6 +1,10 @@
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.compare_commits = []
merge_request.source_project = find_source_project
@@ -8,8 +12,12 @@ module MergeRequests
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 +26,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 +46,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 +72,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 +124,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 40213c99014..f727ec002e7 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -8,7 +8,7 @@ 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)
@@ -19,5 +19,16 @@ module MergeRequests
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..ca9a33678e4 100644
--- a/app/services/merge_requests/conflicts/list_service.rb
+++ b/app/services/merge_requests/conflicts/list_service.rb
@@ -23,13 +23,13 @@ module MergeRequests
# 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
+ rescue Gitlab::Git::CommandError, Gitlab::Git::Conflict::Parser::UnresolvableError, Gitlab::Git::Conflict::Resolver::ConflictSideMissing
@conflicts_can_be_resolved_in_ui = false
end
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 da39a380451..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,18 @@
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?
@@ -21,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
@@ -43,9 +50,11 @@ 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_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 820709583fa..c57a2445341 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -1,22 +1,15 @@
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]
+ set_projects!
merge_request = MergeRequest.new
merge_request.target_project = @project
- merge_request.source_project = source_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)
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37439
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- create(merge_request)
- end
+ create(merge_request)
end
def before_create(merge_request)
@@ -35,6 +28,18 @@ module MergeRequests
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)
@@ -52,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/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 07cbd8f92a9..cedfcb50e09 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -10,18 +10,17 @@ 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
@@ -30,17 +29,32 @@ module MergeRequests
success
end
end
+ log_info("Merge process finished on JID #{merge_jid} with state #{state}")
rescue MergeError => e
- clean_merge_jid
- 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 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
+
+ raise MergeError, error if error
+ end
+
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
@@ -54,15 +68,13 @@ 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?
- # Verify again that the source branch can be removed, since branch may be protected,
- # or the source branch may have been updated.
- if @merge_request.can_remove_source_branch?(branch_deletion_user)
- DeleteBranchService.new(@merge_request.source_project, branch_deletion_user)
- .execute(merge_request.source_branch)
- end
+ if delete_source_branch?
+ DeleteBranchService.new(@merge_request.source_project, branch_deletion_user)
+ .execute(merge_request.source_branch)
end
end
@@ -74,18 +86,30 @@ module MergeRequests
@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/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index 261a8bfa200..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, 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 bc4a13cf4bc..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,13 +30,24 @@ 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.preload(:merge_request_diff).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|
@@ -76,7 +88,12 @@ module MergeRequests
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
@@ -166,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
@@ -182,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 b9c65be36ec..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_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, 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 2832d893e95..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)
@@ -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 a02eee4961b..236e9fe8c44 100644
--- a/app/services/metrics_service.rb
+++ b/app/services/metrics_service.rb
@@ -6,8 +6,7 @@ class MetricsService
Gitlab::HealthChecks::Redis::RedisCheck,
Gitlab::HealthChecks::Redis::CacheCheck,
Gitlab::HealthChecks::Redis::QueuesCheck,
- Gitlab::HealthChecks::Redis::SharedStateCheck,
- Gitlab::HealthChecks::FsShardsCheck
+ Gitlab::HealthChecks::Redis::SharedStateCheck
].freeze
def prometheus_metrics_text
@@ -21,7 +20,7 @@ class MetricsService
end
def metrics_text
- "#{health_metrics_text}#{prometheus_metrics_text}"
+ prometheus_metrics_text.concat(health_metrics_text)
end
private
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/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/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 c9f07c140f7..6835b14648b 100644
--- a/app/services/notification_recipient_service.rb
+++ b/app/services/notification_recipient_service.rb
@@ -11,11 +11,11 @@ module NotificationRecipientService
end
def self.build_recipients(*a)
- Builder::Default.new(*a).recipient_users
+ Builder::Default.new(*a).notification_recipients
end
def self.build_new_note_recipients(*a)
- Builder::NewNote.new(*a).recipient_users
+ Builder::NewNote.new(*a).notification_recipients
end
module Builder
@@ -49,25 +49,24 @@ module NotificationRecipientService
@recipients ||= []
end
- def <<(pair)
- users, type = pair
-
+ def add_recipients(users, type, reason)
if users.is_a?(ActiveRecord::Relation)
users = users.includes(:notification_settings)
end
users = Array(users)
users.compact!
- recipients.concat(users.map { |u| make_recipient(u, type) })
+ recipients.concat(users.map { |u| make_recipient(u, type, reason) })
end
def user_scope
User.includes(:notification_settings)
end
- def make_recipient(user, type)
+ def make_recipient(user, type, reason)
NotificationRecipient.new(
user, type,
+ reason: reason,
project: project,
custom_action: custom_action,
target: target,
@@ -75,14 +74,13 @@ module NotificationRecipientService
)
end
- def recipient_users
- @recipient_users ||=
+ def notification_recipients
+ @notification_recipients ||=
begin
build!
filter!
- users = recipients.map(&:user)
- users.uniq!
- users.freeze
+ recipients = self.recipients.sort_by { |r| NotificationReason.priority(r.reason) }.uniq(&:user)
+ recipients.freeze
end
end
@@ -95,7 +93,13 @@ module NotificationRecipientService
def add_participants(user)
return unless target.respond_to?(:participants)
- self << [target.participants(user), :participating]
+ add_recipients(target.participants(user), :participating, nil)
+ end
+
+ def add_mentions(user, target:)
+ return unless target.respond_to?(:mentioned_users)
+
+ add_recipients(target.mentioned_users(user), :mention, NotificationReason::MENTIONED)
end
# Get project/group users with CUSTOM notification level
@@ -113,11 +117,11 @@ module NotificationRecipientService
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)
- self << [user_scope.where(id: user_ids), :watch]
+ add_recipients(user_scope.where(id: user_ids), :watch, nil)
end
def add_project_watchers
- self << [project_watchers, :watch]
+ add_recipients(project_watchers, :watch, nil)
end
# Get project users with WATCH notification level
@@ -138,7 +142,7 @@ module NotificationRecipientService
def add_subscribed_users
return unless target.respond_to? :subscribers
- self << [target.subscribers(project), :subscription]
+ add_recipients(target.subscribers(project), :subscription, nil)
end
def user_ids_notifiable_on(resource, notification_level = nil)
@@ -189,7 +193,7 @@ module NotificationRecipientService
return unless target.respond_to? :labels
(labels || target.labels).each do |label|
- self << [label.subscribers(project), :subscription]
+ add_recipients(label.subscribers(project), :subscription, nil)
end
end
end
@@ -216,17 +220,28 @@ module NotificationRecipientService
# Re-assign is considered as a mention of the new assignee
case custom_action
when :reassign_merge_request
- self << [previous_assignee, :mention]
- self << [target.assignee, :mention]
+ add_recipients(previous_assignee, :mention, nil)
+ add_recipients(target.assignee, :mention, NotificationReason::ASSIGNED)
when :reassign_issue
previous_assignees = Array(previous_assignee)
- self << [previous_assignees, :mention]
- self << [target.assignees, :mention]
+ 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
@@ -263,7 +278,7 @@ module NotificationRecipientService
def build!
# Add all users participating in the thread (author, assignee, comment authors)
add_participants(note.author)
- self << [note.mentioned_users, :mention]
+ add_mentions(note.author, target: note)
unless note.for_personal_snippet?
# Merge project watchers
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index e2a80db06a6..e07ecda27b5 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -31,13 +31,6 @@ class NotificationService
end
end
- # Always notify user about email added to profile
- def new_email(email)
- if email.user&.can?(:receive_notifications)
- mailer.new_email_email(email.id).deliver_later
- end
- end
-
# When create an issue we should send an email to:
#
# * issue assignee if their notification level is not Disabled
@@ -92,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
@@ -183,7 +177,7 @@ class NotificationService
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
@@ -206,7 +200,7 @@ class NotificationService
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
@@ -214,7 +208,12 @@ class NotificationService
def new_access_request(member)
return true unless member.notifiable?(:subscription)
- mailer.member_access_requested_email(member.real_source_type, member.id).deliver_later
+ recipients = member.source.members.owners_and_masters
+ if fallback_to_group_owners_masters?(recipients, member)
+ recipients = member.source.group.members.owners_and_masters
+ end
+
+ recipients.each { |recipient| deliver_access_request_email(recipient, member) }
end
def decline_access_request(member)
@@ -306,7 +305,7 @@ class NotificationService
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
@@ -340,22 +339,46 @@ 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, 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, new_mentioned_users, current_user, method)
recipients = NotificationRecipientService.build_recipients(target, current_user, action: "new")
- recipients = recipients & new_mentioned_users
+ 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
@@ -370,7 +393,7 @@ 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
@@ -388,16 +411,17 @@ 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, labels, current_user, method)
- recipients = labels.flat_map { |l| l.subscribers(target.project) }
+ recipients = labels.flat_map { |l| l.subscribers(target.project) }.uniq
recipients = notifiable_users(
recipients, :subscription,
target: target,
@@ -415,7 +439,7 @@ class NotificationService
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
@@ -433,6 +457,14 @@ class NotificationService
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
@@ -440,4 +472,14 @@ class NotificationService
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/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb
index 724a77c873a..e61ecb696d0 100644
--- a/app/services/projects/autocomplete_service.rb
+++ b/app/services/projects/autocomplete_service.rb
@@ -20,8 +20,23 @@ module Projects
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)
@@ -33,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
index aa034315280..933829b557b 100644
--- a/app/services/projects/count_service.rb
+++ b/app/services/projects/count_service.rb
@@ -1,7 +1,7 @@
module Projects
# Base class for the various service classes that count project data (e.g.
# issues or forks).
- class CountService
+ 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.
@@ -12,26 +12,7 @@ module Projects
end
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) { uncached_count }
- end
-
- def refresh_cache
- Rails.cache.write(cache_key, uncached_count)
- end
-
- def uncached_count
- relation_for_count.count
- end
-
- def delete_cache
- Rails.cache.delete(cache_key)
+ self.class.query(@project.id)
end
def cache_key_name
@@ -44,5 +25,12 @@ module Projects
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
index 87d9ed7a0e6..a549cfbabea 100644
--- a/app/services/projects/create_from_template_service.rb
+++ b/app/services/projects/create_from_template_service.rb
@@ -5,11 +5,15 @@ module Projects
end
def execute
- params[:file] = Gitlab::ProjectTemplate.find(params[:template_name]).file
+ template_name = params.delete(:template_name)
+ file = Gitlab::ProjectTemplate.find(template_name).file
+
+ params[:file] = file
+
+ GitlabProjectsImportService.new(current_user, params).execute
- GitlabProjectsImportService.new(@current_user, @params).execute
ensure
- params[:file]&.close
+ file&.close
end
end
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 71533da31b1..01838ec6b5d 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -56,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
@@ -92,6 +88,7 @@ module Projects
log_info("#{@project.owner.name} created a new project \"#{@project.name_with_namespace}\"")
unless @project.gitlab_project_import?
+ @project.write_repository_config
@project.create_wiki unless skip_wiki?
create_services_from_active_templates(@project)
@@ -164,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 54eb75ab9bf..81972df9b3c 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -22,6 +22,13 @@ module Projects
Projects::UnlinkForkService.new(project, current_user).execute
+ # 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
+
attempt_destroy_transaction(project)
system_hook_service.execute_hooks_for(project, :destroy)
@@ -44,7 +51,7 @@ module Projects
end
def wiki_path
- repo_path + '.wiki'
+ project.wiki.disk_path
end
def trash_repositories!
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index ad67e68a86a..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,25 +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)
- refresh_forks_count
+ 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 link_fork_network(fork_to_project)
+ fork_network.fork_network_members.create(project: fork_to_project,
+ forked_from_project: @project)
+
+ # 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
- project_level = @project.visibility_level
+ target_level = [@project.visibility_level, target_namespace.visibility_level].min
- if Gitlab::VisibilityLevel.non_restricted_level?(project_level)
- project_level
- else
- Gitlab::VisibilityLevel.highest_allowed_level
- end
+ 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
index 3a0fa84b868..dc6eb19affd 100644
--- a/app/services/projects/forks_count_service.rb
+++ b/app/services/projects/forks_count_service.rb
@@ -1,12 +1,15 @@
module Projects
# Service class for getting and caching the number of forks of a project.
- class ForksCountService < CountService
- def relation_for_count
- @project.forks
- end
-
+ 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
index 4ca6414b73b..a68ecb4abe1 100644
--- a/app/services/projects/gitlab_projects_import_service.rb
+++ b/app/services/projects/gitlab_projects_import_service.rb
@@ -11,12 +11,14 @@ module Projects
def execute
FileUtils.mkdir_p(File.dirname(import_upload_path))
+
+ file = params.delete(:file)
FileUtils.copy_entry(file.path, import_upload_path)
- Gitlab::ImportExport::ProjectCreator.new(params[:namespace_id],
- current_user,
- import_upload_path,
- params[:path]).execute
+ params[:import_type] = 'gitlab_project'
+ params[:import_source] = import_upload_path
+
+ ::Projects::CreateService.new(current_user, params).execute
end
private
@@ -26,11 +28,7 @@ module Projects
end
def tmp_filename
- "#{SecureRandom.hex}_#{params[:path]}"
- end
-
- def file
- params[:file]
+ 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 dcef8b66215..120d57a188d 100644
--- a/app/services/projects/housekeeping_service.rb
+++ b/app/services/projects/housekeeping_service.rb
@@ -7,8 +7,6 @@
#
module Projects
class HousekeepingService < BaseService
- include Gitlab::CurrentSettings
-
# Timeout set to 24h
LEASE_TIMEOUT = 86400
@@ -83,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 fe4e8ea10bf..af41ce82f65 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.disk_path, 'work'))
+ @shared = project.import_export_shared
save_all
end
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index c3bf0031409..f2d676af5c3 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -4,8 +4,18 @@ 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
@@ -17,6 +27,14 @@ module Projects
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)
+ 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,19 +50,16 @@ module Projects
end
def import_repository
- raise Error, 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url)
-
- # We should return early for a GitHub import because the new GitHub
- # importer fetch the project repositories for us.
- return if project.github_import?
-
begin
- if 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
@@ -54,17 +69,6 @@ module Projects
end
end
- def clone_repository
- gitlab_shell.import_repository(project.repository_storage_path, project.disk_path, project.import_url)
- end
-
- def fetch_repository
- project.ensure_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?
@@ -75,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
index 3c0d186a73c..a975a06a05c 100644
--- a/app/services/projects/open_issues_count_service.rb
+++ b/app/services/projects/open_issues_count_service.rb
@@ -1,15 +1,15 @@
module Projects
# Service class for counting and caching the number of open issues of a
# project.
- class OpenIssuesCountService < CountService
- def relation_for_count
- # We don't include confidential issues in this number since this would
- # expose the number of confidential issues to non project members.
- @project.issues.opened.public_only
- end
-
+ 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
index 2a90f78b90d..77e6448fd5e 100644
--- a/app/services/projects/open_merge_requests_count_service.rb
+++ b/app/services/projects/open_merge_requests_count_service.rb
@@ -1,7 +1,7 @@
module Projects
# Service class for counting and caching the number of open merge requests of
# a project.
- class OpenMergeRequestsCountService < CountService
+ class OpenMergeRequestsCountService < Projects::CountService
def relation_for_count
@project.merge_requests.opened
end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 5957f612e84..26765e5c3f3 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -60,21 +60,14 @@ module Projects
# Notifications
project.send_move_instructions(@old_path)
- # Move main repository
- # TODO: check storage type and NOOP when not using Legacy
- unless move_repo_folder(@old_path, @new_path)
- raise TransferError.new('Cannot move project')
- end
-
- # Move wiki repo also if present
- # TODO: check storage type and NOOP when not using Legacy
- 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)
@@ -82,6 +75,8 @@ module Projects
project.old_path_with_namespace = @old_path
project.expires_full_path_cache
+ write_repository_config(@new_path)
+
execute_system_hooks
end
rescue Exception # rubocop:disable Lint/RescueException
@@ -105,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.
@@ -117,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
@@ -131,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 f30b40423c8..842fe4e09c4 100644
--- a/app/services/projects/unlink_fork_service.rb
+++ b/app/services/projects/unlink_fork_service.rb
@@ -3,18 +3,25 @@ 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)
end
- refresh_forks_count(@project.forked_from_project)
-
+ @project.fork_network_member.destroy
@project.forked_project_link.destroy
end
diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb
index cacb74b1205..52ff64cc938 100644
--- a/app/services/projects/update_pages_configuration_service.rb
+++ b/app/services/projects/update_pages_configuration_service.rb
@@ -23,7 +23,7 @@ module Projects
end
def pages_domains_config
- project.pages_domains.map do |domain|
+ enabled_pages_domains.map do |domain|
{
domain: domain.domain,
certificate: domain.certificate,
@@ -32,6 +32,14 @@ module Projects
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 d34903c9989..00fdd047208 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -1,6 +1,7 @@
module Projects
class UpdatePagesService < BaseService
- include Gitlab::CurrentSettings
+ InvaildStateError = Class.new(StandardError)
+ FailedToExtractError = Class.new(StandardError)
BLOCK_SIZE = 32.kilobytes
MAX_SIZE = 1.terabyte
@@ -13,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)
@@ -28,24 +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
- register_attempt
- build.erase_artifacts! unless build.has_expiring_artifacts?
end
private
def success
@status.success
+ delete_artifact!
super
end
@@ -54,6 +55,7 @@ module Projects
@status.allow_failure = !latest?
@status.description = message
@status.drop(:script_failure)
+ delete_artifact!
super
end
@@ -74,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
@@ -83,17 +85,17 @@ 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.
@@ -102,7 +104,7 @@ module Projects
# 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 -qq -n #{artifacts} #{site_path} -d #{temp_path}))
- raise 'pages failed to extract'
+ raise FailedToExtractError, 'pages failed to extract'
end
end
@@ -134,7 +136,7 @@ module Projects
end
def max_size
- max_pages_size = current_application_settings.max_pages_size.megabytes
+ max_pages_size = Gitlab::CurrentSettings.max_pages_size.megabytes
return MAX_SIZE if max_pages_size.zero?
@@ -165,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
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 13e292a18bf..379a8068023 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -15,6 +15,8 @@ module Projects
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
@@ -31,6 +33,12 @@ module Projects
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?
@@ -46,5 +54,18 @@ module Projects
project.repository.exists? &&
new_branch && new_branch != project.default_branch
end
+
+ def enabling_wiki?
+ return false if @project.wiki_enabled?
+
+ params[: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
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_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/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/api_create_service.rb b/app/services/protected_branches/legacy_api_create_service.rb
index f2040dfa03a..e358fd0374e 100644
--- a/app/services/protected_branches/api_create_service.rb
+++ b/app/services/protected_branches/legacy_api_create_service.rb
@@ -1,9 +1,9 @@
-# The protected branches API still uses the `developers_can_push` and `developers_can_merge`
+# 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 ApiCreateService < BaseService
+ class LegacyApiCreateService < BaseService
def execute
push_access_level =
if params.delete(:developers_can_push)
diff --git a/app/services/protected_branches/api_update_service.rb b/app/services/protected_branches/legacy_api_update_service.rb
index bdb0e0cc8bf..33176253ca2 100644
--- a/app/services/protected_branches/api_update_service.rb
+++ b/app/services/protected_branches/legacy_api_update_service.rb
@@ -1,9 +1,9 @@
-# The protected branches API still uses the `developers_can_push` and `developers_can_merge`
+# 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 ApiUpdateService < BaseService
+ class LegacyApiUpdateService < BaseService
def execute(protected_branch)
@developers_can_push = params.delete(:developers_can_push)
@developers_can_merge = params.delete(:developers_can_merge)
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index a077b3584b0..cba49faac31 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -7,6 +7,18 @@ module QuickActions
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)
@@ -15,8 +27,8 @@ 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
@@ -28,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
@@ -157,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
@@ -335,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'
@@ -381,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'
@@ -394,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
@@ -424,7 +440,7 @@ 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}"
@@ -458,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'
@@ -540,6 +556,10 @@ module QuickActions
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
@@ -553,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
@@ -577,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/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
index 14171bce782..2623f253d98 100644
--- a/app/services/submit_usage_ping_service.rb
+++ b/app/services/submit_usage_ping_service.rb
@@ -11,10 +11,8 @@ class SubmitUsagePingService
percentage_projects_prometheus_active leader_service_desk_issues instance_service_desk_issues
percentage_service_desk_issues].freeze
- include Gitlab::CurrentSettings
-
def execute
- return false unless current_application_settings.usage_ping_enabled?
+ return false unless Gitlab::CurrentSettings.usage_ping_enabled?
response = HTTParty.post(
URL,
diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb
index a1c2f8d0180..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.public_send(hooks_scope).find_each do |hook| # rubocop:disable GitlabSecurity/PublicSend
+ 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
@@ -35,24 +41,25 @@ 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
@@ -83,7 +90,7 @@ class SystemHooksService
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
@@ -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 1f66a2668f9..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
@@ -162,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
@@ -188,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'))
@@ -241,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**'
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
- end
+ def handle_merge_request_wip(noteable, project, author)
+ prefix = noteable.work_in_progress? ? "marked" : "unmarked"
- 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
@@ -451,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
@@ -484,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
@@ -507,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
@@ -591,6 +564,17 @@ module SystemNoteService
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)
@@ -619,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
#
@@ -655,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
@@ -676,4 +667,8 @@ module SystemNoteService
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 b3f4a72d6fe..cc76d0df3a1 100644
--- a/app/services/tags/create_service.rb
+++ b/app/services/tags/create_service.rb
@@ -11,7 +11,7 @@ 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 Gitlab::Git::HooksService::PreReceiveError => ex
return error(ex.message)
diff --git a/app/services/test_hooks/base_service.rb b/app/services/test_hooks/base_service.rb
index 20d90504bd2..e9aefb1fb75 100644
--- a/app/services/test_hooks/base_service.rb
+++ b/app/services/test_hooks/base_service.rb
@@ -9,7 +9,7 @@ module TestHooks
end
def execute
- trigger_key = hook.class::TRIGGERS.key(trigger.to_sym)
+ 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)
diff --git a/app/services/test_hooks/system_service.rb b/app/services/test_hooks/system_service.rb
index 67552edefc9..9016c77b7f0 100644
--- a/app/services/test_hooks/system_service.rb
+++ b/app/services/test_hooks/system_service.rb
@@ -13,5 +13,12 @@ module TestHooks
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 6ee96d6a0f8..c2ca404b179 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
@@ -216,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
@@ -234,10 +235,6 @@ 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?
@@ -254,10 +251,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 76700dfcdee..d5a9b344905 100644
--- a/app/services/upload_service.rb
+++ b/app/services/upload_service.rb
@@ -1,6 +1,4 @@
class UploadService
- include Gitlab::CurrentSettings
-
def initialize(model, file, uploader_class = FileUploader)
@model, @file, @uploader_class = model, file, uploader_class
end
@@ -17,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/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 6f05500adea..4fb6d221909 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -1,7 +1,5 @@
module Users
class BuildService < BaseService
- include Gitlab::CurrentSettings
-
def initialize(current_user, params = {})
@current_user = current_user
@params = params.dup
@@ -34,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)
@@ -102,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/destroy_service.rb b/app/services/users/destroy_service.rb
index 8e20de8dfa5..b71002433d6 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -31,6 +31,11 @@ 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
@@ -48,7 +53,7 @@ module Users
# Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
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
index f2bfb60604f..57e446d7f30 100644
--- a/app/services/users/last_push_event_service.rb
+++ b/app/services/users/last_push_event_service.rb
@@ -16,8 +16,8 @@ module Users
user_cache_key
]
- if event.project.forked?
- keys << project_cache_key(event.project.forked_from_project)
+ if forked_from = event.project.forked_from_project
+ keys << project_cache_key(forked_from)
end
keys.each { |key| set_key(key, event.id) }
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
index 3a9c151cf9b..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,18 +36,22 @@ 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)
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
index 6188b8a4349..15ca1a55a5b 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -2,22 +2,21 @@ module Users
class UpdateService < BaseService
include NewUserNotifier
- def initialize(user, params = {})
- @user = user
+ 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?
- assign_attributes(&block)
-
user_exists = @user.persisted?
- if @user.save(validate: validate)
- notify_new_user(@user, nil) unless user_exists
+ assign_attributes(&block)
- success
+ if @user.save(validate: validate)
+ notify_success(user_exists)
else
error(@user.errors.full_messages.uniq.join('. '))
end
@@ -33,6 +32,12 @@ 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)
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 cd99e0b90f9..36e589d5aa8 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -63,7 +63,7 @@ class WebHookService
end
def async_execute
- Sidekiq::Client.enqueue(WebHookWorker, hook.id, data, hook_name)
+ WebHookWorker.perform_async(hook.id, data, hook_name)
end
private
@@ -113,7 +113,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
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 cbb79376d5f..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}"
- end
-
def exists?
model.avatar.file && model.avatar.file.present?
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
+ def move_to_cache
false
end
- def move_to_cache
+ def move_to_store
false
end
+
+ 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 7027ac4b5db..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.full_path)
+ 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 7f72b3ce471..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
- 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}/", '')
+ file_storage?
end
def exists?
file.present?
end
- # Override this if you don't want to save files by default to the Rails.root directory
+ def store_dir
+ File.join(base_dir, dynamic_segment)
+ end
+
+ def cache_dir
+ File.join(root, base_dir, 'tmp/cache')
+ end
+
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 3298ad104ec..f2ad0badd53 100644
--- a/app/uploaders/personal_file_uploader.rb
+++ b/app/uploaders/personal_file_uploader.rb
@@ -1,23 +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
- def self.base_dir
- File.join(root_dir, '-', 'system')
+ def self.base_dir(model)
+ File.join(options.base_dir, model_path_segment(model))
end
- private
+ def self.model_path_segment(model)
+ return 'temp/' unless model
- def secure_url
- File.join(self.class.model_path(model), secret, file.filename)
+ File.join(model.class.to_s.underscore, model.id.to_s)
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
+ # 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
+
+ # 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..5239e70a326 100644
--- a/app/validators/certificate_validator.rb
+++ b/app/validators/certificate_validator.rb
@@ -17,6 +17,7 @@ class CertificateValidator < ActiveModel::EachValidator
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/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
index 8a9d8892e9b..72660be6c43 100644
--- a/app/validators/variable_duplicates_validator.rb
+++ b/app/validators/variable_duplicates_validator.rb
@@ -1,13 +1,30 @@
# VariableDuplicatesValidator
#
-# This validtor is designed for especially the following condition
+# 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)
- duplicates = value.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first)
+ 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?
- record.errors.add(attribute, "Duplicate variables: #{duplicates.join(", ")}")
+ 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 935787d1a4a..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:
@@ -28,27 +45,22 @@
.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-sm 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 dbaed1d09fb..68788134b8e 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -160,9 +160,22 @@
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
- = f.label :password_authentication_enabled do
- = f.check_box :password_authentication_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'
@@ -224,6 +237,17 @@
.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 and Deployment
@@ -236,7 +260,12 @@
.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
@@ -524,12 +553,45 @@
.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
@@ -604,15 +666,15 @@
.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
@@ -681,6 +743,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'
@@ -705,5 +791,72 @@
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'
+
.form-actions
= f.submit 'Save', class: 'btn btn-save'
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/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/index.html.haml b/app/views/admin/cohorts/index.html.haml
index bff53da1d9a..5e9a8c083af 100644
--- a/app/views/admin/cohorts/index.html.haml
+++ b/app/views/admin/cohorts/index.html.haml
@@ -1,6 +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 c2151710884..00000000000
--- a/app/views/admin/dashboard/_head.html.haml
+++ /dev/null
@@ -1,37 +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
- = nav_link(controller: :conversational_development_index) do
- = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do
- %span
- ConvDev Index
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index d212c7ca965..05c41082882 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -1,10 +1,37 @@
- @no_container = true
- breadcrumb_title "Dashboard"
-= render "admin/dashboard/head"
%div{ class: container_class }
.admin-dashboard.prepend-top-default
.row
+ .col-sm-4
+ .info-well.dark-well
+ .well-segment.well-centered
+ = link_to admin_projects_path do
+ %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
+ .info-well.dark-well
+ .well-segment.well-centered
+ = link_to admin_users_path do
+ %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
+ .info-well.dark-well
+ .well-segment.well-centered
+ = link_to admin_groups_path do
+ %h3.text-center
+ Groups:
+ = number_with_delimiter(Group.count)
+ %hr
+ = link_to 'New group', new_admin_group_path, class: "btn btn-new"
+ .row
.col-md-4
.info-well
.well-segment.admin-well.admin-well-statistics
@@ -46,10 +73,10 @@
.well-segment.admin-well.admin-well-features
%h4 Features
- sign_up = "Sign up"
- %p{ "aria-label" => "#{sign_up}: status " + (signup_enabled? ? "on" : "off") }
+ %p{ "aria-label" => "#{sign_up}: status " + (allow_signup? ? "on" : "off") }
= sign_up
%span.light.pull-right
- = boolean_to_icon signup_enabled?
+ = boolean_to_icon allow_signup?
- ldap = "LDAP"
%p{ "aria-label" => "#{ldap}: status " + (Gitlab.config.ldap.enabled ? "on" : "off") }
= ldap
@@ -92,7 +119,7 @@
.well-segment.admin-well
%h4
Components
- - if current_application_settings.version_check_enabled
+ - if Gitlab::CurrentSettings.version_check_enabled
.pull-right
= version_status_badge
%p
@@ -111,20 +138,12 @@
GitLab API
%span.pull-right
= API::API::version
- %p
- Gitaly
- %span.pull-right
- = Gitlab::GitalyClient.expected_server_version
- if Gitlab.config.pages.enabled
%p
GitLab Pages
%span.pull-right
= Gitlab::Pages::VERSION
%p
- Git
- %span.pull-right
- = Gitlab::Git.version
- %p
Ruby
%span.pull-right
#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}
@@ -136,34 +155,8 @@
= Gitlab::Database.adapter_name
%span.pull-right
= Gitlab::Database.version
- .row
- .col-sm-4
- .info-well.dark-well
- .well-segment.well-centered
- = link_to admin_projects_path do
- %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
- .info-well.dark-well
- .well-segment.well-centered
- = link_to admin_users_path do
- %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
- .info-well.dark-well
- .well-segment.well-centered
- = link_to admin_groups_path do
- %h3.text-center
- Groups:
- = number_with_delimiter(Group.count)
- %hr
- = link_to 'New group', new_admin_group_path, class: "btn btn-new"
+ %p
+ = link_to "Gitaly Servers", admin_gitaly_servers_path
.row
.col-md-4
.info-well
@@ -171,7 +164,7 @@
%h4 Latest projects
- @projects.each do |project|
%p
- = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated-60'
+ = 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
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 3e02f7b1e16..324f3c0a22f 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -16,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
@@ -68,7 +68,7 @@
- @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
@@ -86,7 +86,7 @@
- @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
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 517db50b97f..e31fb58b205 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -1,7 +1,6 @@
- @no_container = true
- page_title _('Health Check')
- no_errors = @errors.blank? && @failing_storage_statuses.blank?
-= render 'admin/monitoring/head'
%div{ class: container_class }
%h3.page-title= page_title
@@ -9,7 +8,7 @@
.pull-left
%p
#{ s_('HealthCheck|Access token is') }
- %code#health-check-token= current_application_settings.health_check_access_token
+ %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,
method: :put, class: 'btn btn-default',
@@ -19,11 +18,11 @@
= 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
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..d8f96ed5b0d 100644
--- a/app/views/admin/hooks/_form.html.haml
+++ b/app/views/admin/hooks/_form.html.haml
@@ -38,6 +38,13 @@
%strong Tag push events
%p.light
This URL will be triggered when a new tag is pushed to the repository
+ %div
+ = 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
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index efb15ccc8df..629b1a9940f 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -13,7 +13,7 @@
= render partial: 'form', locals: { form: f, hook: @hook }
.form-actions
= f.submit 'Save changes', class: 'btn btn-create'
- = render 'shared/web_hooks/test_button', triggers: SystemHook::TRIGGERS, hook: @hook
+ = 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 b6e1df5f3ac..bc02d9969d6 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -22,12 +22,12 @@
- @hooks.each do |hook|
%li
.controls
- = render 'shared/web_hooks/test_button', triggers: SystemHook::TRIGGERS, hook: hook, button_class: 'btn-sm'
+ = 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|
+ - 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'}
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 aa6e9db3900..4e3e2f7a475 100644
--- a/app/views/admin/jobs/index.html.haml
+++ b/app/views/admin/jobs/index.html.haml
@@ -1,16 +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/logs/show.html.haml b/app/views/admin/logs/show.html.haml
index ee87f25a225..78757b6384f 100644
--- a/app/views/admin/logs/show.html.haml
+++ b/app/views/admin/logs/show.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title "Logs"
-= render 'admin/monitoring/head'
%div{ class: container_class }
%ul.nav-links.log-tabs
diff --git a/app/views/admin/monitoring/_head.html.haml b/app/views/admin/monitoring/_head.html.haml
deleted file mode 100644
index b3530915068..00000000000
--- a/app/views/admin/monitoring/_head.html.haml
+++ /dev/null
@@ -1,25 +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: :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 c69c2761189..b5d7b889504 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -5,7 +5,12 @@
%li.project-row{ class: ('no-description' if project.description.blank?) }
.controls
= link_to 'Edit', edit_project_path(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"
+ %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 ab4165c0bf2..c02ddafe108 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -1,8 +1,8 @@
- add_to_breadcrumbs "Projects", admin_projects_path
-- breadcrumb_title @project.name_with_namespace
-- page_title @project.name_with_namespace, "Projects"
+- 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
@@ -115,7 +115,7 @@
= 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")
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 140688b52d3..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
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 6793ce557c4..9f13dbbbd82 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title "Runners"
- @no_container = true
-= render "admin/dashboard/head"
%div{ class: container_class }
.bs-callout
@@ -36,9 +35,8 @@
method: :put, class: 'btn btn-default',
data: { confirm: _("Are you sure you want to reset registration token?") }
- = render partial: 'ci/runner/how_to_setup_runner',
- locals: { registration_token: current_application_settings.runners_registration_token,
- type: 'shared' }
+ = render partial: 'ci/runner/how_to_setup_shared_runner',
+ locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token }
.append-bottom-20.clearfix
.pull-left
@@ -53,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 df2bf27be9d..185e9d7b35d 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -39,7 +39,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 +61,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|
@@ -95,11 +95,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/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 ca6e43e091c..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,12 +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?" },
- class: 'text-danger',
- 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: 'text-danger',
- 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 4a440f3f6d4..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_project_path(project), class: dom_class(project) do
- = project.name_with_namespace
+ = project.full_name
- if member
.pull-right
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 98ff592eb64..ec3be869797 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -10,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
@@ -157,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
@@ -173,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
@@ -193,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
@@ -205,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/show.html.haml b/app/views/ci/lints/show.html.haml
index e6408f35201..3c0881caa06 100644
--- a/app/views/ci/lints/show.html.haml
+++ b/app/views/ci/lints/show.html.haml
@@ -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
index b75dab0acc5..37fb8fbab26 100644
--- a/app/views/ci/runner/_how_to_setup_runner.html.haml
+++ b/app/views/ci/runner/_how_to_setup_runner.html.haml
@@ -1,16 +1,16 @@
- link = link_to _("GitLab Runner section"), 'https://about.gitlab.com/gitlab-ci/#gitlab-runner', target: '_blank'
-.bs-callout.help-callout
- %h4= _("How to setup a #{type} Runner for a new project")
+.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= root_url(only_path: false)
- %li
- = _("Use the following registration token during setup:")
- %code#registration_token= registration_token
- %li
- = _("Start the Runner!")
+%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 dcfb7f0c32d..c5b4439e273 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -7,13 +7,13 @@
- if status.has_details?
= link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
- %span{ class: klass }= custom_icon(status.icon)
+ %span{ class: klass }= sprite_icon(status.icon)
%span.ci-build-text= subject.name
- else
.menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
- %span{ class: klass }= custom_icon(status.icon)
+ %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, container: 'body' } 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
index fbfe3e56588..d355e7799df 100644
--- a/app/views/ci/variables/_content.html.haml
+++ b/app/views/ci/variables/_content.html.haml
@@ -1,3 +1 @@
-%p.append-bottom-default
- 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.
+= _('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/_form.html.haml b/app/views/ci/variables/_form.html.haml
deleted file mode 100644
index eebd0955c80..00000000000
--- a/app/views/ci/variables/_form.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-= form_for @variable, as: :variable, url: @variable.form_path do |f|
- = form_errors(@variable)
-
- .form-group
- = f.label :key, "Key", class: "label-light"
- = f.text_field :key, class: "form-control", placeholder: @variable.placeholder, required: true
- .form-group
- = f.label :value, "Value", class: "label-light"
- = f.text_area :value, class: "form-control", placeholder: @variable.placeholder
- .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/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 2bac69bc536..e402801a776 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -1,14 +1,20 @@
-.row.prepend-top-default.append-bottom-default
- .col-lg-12
- %h5.prepend-top-0
- Add a variable
- = render "ci/variables/form", btn_text: "Add new variable"
- %hr
- %h5.prepend-top-0
- Your variables (#{@variables.size})
- - if @variables.empty?
- %p.settings-message.text-center.append-bottom-0
- No variables found, add one with the form above.
- - else
- = render "ci/variables/table"
- %button.btn.btn-info.js-btn-toggle-reveal-values{ "data-status" => 'hidden' } Reveal Values
+- 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/_show.html.haml b/app/views/ci/variables/_show.html.haml
deleted file mode 100644
index 6d75ae96124..00000000000
--- a/app/views/ci/variables/_show.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- page_title "Variables"
-
-.row.prepend-top-default.append-bottom-default
- .col-lg-3
- = render "ci/variables/content"
- .col-lg-9
- %h4.prepend-top-0
- Update variable
- = render "ci/variables/form", btn_text: "Save variable"
diff --git a/app/views/ci/variables/_table.html.haml b/app/views/ci/variables/_table.html.haml
deleted file mode 100644
index 71a0b56c4f4..00000000000
--- a/app/views/ci/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
- - @variables.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 variable.edit_path, class: "btn btn-transparent btn-variable-edit" do
- %span.sr-only
- Update
- = icon("pencil")
- = link_to variable.delete_path, 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/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
new file mode 100644
index 00000000000..15201780451
--- /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][][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 7981daa0705..617c20b9635 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -1,13 +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", new_group_path, class: "btn btn-new"
+ = 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 fd2ba9ac1ca..449a2ce625e 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -4,15 +4,15 @@
.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
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index 7330f4cb523..a9488df07bd 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -2,10 +2,10 @@
%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
diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml
index ad35d05c29a..31d4b3da4f1 100644
--- a/app/views/dashboard/activity.html.haml
+++ b/app/views/dashboard/activity.html.haml
@@ -7,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 1cea8182733..50f39f93283 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -3,10 +3,7 @@
- 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 42941acc508..3e85535dae0 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -7,7 +7,7 @@
.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', type: :issues
diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
index 57544559824..c50b20a83dc 100644
--- a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
@@ -1,33 +1,41 @@
-.blank-state
- .blank-state-icon
- = custom_icon("add_new_user", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Add user
- %p.blank-state-text
- Add your team members and others to GitLab.
- = link_to new_admin_user_path, class: "btn btn-new" do
- New user
+.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.
-.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.
- = link_to admin_root_path, class: "btn btn-new" do
- Configure
+ - 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.
-- if current_user.can_create_group?
- .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_group_path, class: "btn btn-new" do
- New group
+ = 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
index a93a3415ee1..8d5bddbb288 100644
--- a/app/views/dashboard/projects/_blank_state_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml
@@ -1,48 +1,58 @@
- public_project_count = ProjectsFinder.new(current_user: current_user).execute.count
-- if current_user.can_create_group?
- .blank-state
- .blank-state-icon
- = custom_icon("add_new_group", size: 50)
- .blank-state-body
- %h3.blank-state-title
- 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-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.
-.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 current_user.can_create_project?
- You don't have access to any projects right now.
- 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 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
- .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 trending_explore_projects_path, class: "btn btn-new" do
- Browse projects
+ - 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 ad3fac6d164..18a82feb189 100644
--- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml
+++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
@@ -1,12 +1,13 @@
-.row.blank-state-parent-container
+.blank-state-parent-container
.section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" }
.container.section-body
- .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.admin?
- = render "blank_state_admin_welcome"
- - else
- = render "blank_state_welcome"
+ .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 a4dc49d2120..deed774a4a5 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -7,11 +7,11 @@
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
-= render "projects/last_push"
-
%div{ class: container_class }
- - if has_projects_or_name?(@projects, params)
+ = render "projects/last_push"
+ - if show_projects?(@projects, params)
= render 'dashboard/projects_head'
+ = 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 14f9f8cd70a..b1efe59aadc 100644
--- a/app/views/dashboard/projects/starred.html.haml
+++ b/app/views/dashboard/projects/starred.html.haml
@@ -4,9 +4,8 @@
- 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 params[:filter_projects] || any_projects?(@projects)
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 f62a0cd681e..664966989db 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -4,11 +4,11 @@
- 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') }>
@@ -83,12 +83,12 @@
You're all done!
- elsif current_user.todos.any?
.todos-all-done
- .svg-content
+ .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
@@ -104,7 +104,7 @@
= 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 dd61dcf2a7b..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 password_authentication_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 password_authentication_enabled? && signup_enabled?
+ - if allow_signup?
= render 'devise/shared/signup_box'
-# Show a message if none of the mechanisms above are enabled
- - if !password_authentication_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/_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 3b06008febe..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 password_authentication_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 password_authentication_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 298604dee8c..2554b2688bb 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -31,4 +31,4 @@
%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 6d0243a325d..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 password_authentication_enabled?
+ - if password_authentication_enabled_for_web?
%li
- = link_to 'Standard', '#ldap-standard', 'data-toggle' => 'tab'
- - if password_authentication_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 212856c0676..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 password_authentication_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..f9bfc01f213 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -1,18 +1,27 @@
- 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-file.file-holder
+.diff-file.file-holder{ class: diff_file_class }
.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
+ = 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
+ - 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..8b9fa3d6b05 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -3,7 +3,7 @@
.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
@@ -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/_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/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index 72eab964766..6364f0be4a3 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -1,3 +1,5 @@
+- add_to_breadcrumbs "Applications", oauth_applications_path
+- breadcrumb_title @application.name
- page_title @application.name, "Applications"
- @content_class = "limit-container-width" unless fluid_layout
diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml
index 8ba88906714..6d9c6b5572a 100644
--- a/app/views/doorkeeper/authorizations/new.html.haml
+++ b/app/views/doorkeeper/authorizations/new.html.haml
@@ -1,5 +1,5 @@
%main{ :role => "main" }
- .modal-no-backdrop
+ .modal-no-backdrop.modal-doorkeepr-auth
.modal-content
.modal-header
%h3.page-title
@@ -16,14 +16,26 @@
%strong= @pre_auth.client.name
will allow them to interact with GitLab as an admin as well. Proceed with caution.
%p
- You are about to authorize
+ An application called
= link_to @pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer'
- to use your account.
- - if @pre_auth.scopes
+ is requesting access to your GitLab account.
+
+ - 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= t scope, scope: [:doorkeeper, :scopes]
+ %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
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/_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/_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 53ebdd6d2ff..f85f5c5be88 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -7,7 +7,8 @@
%span.pushed #{event.action_name} #{event.ref_type}
%strong
- commits_link = project_commits_path(project, event.ref_name)
- = link_to_if project.repository.branch_exists?(event.ref_name), event.ref_name, commits_link, class: '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
@@ -19,8 +20,7 @@
- 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
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 2651ef37e67..efa8b2706da 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -17,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/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 623d233a46a..00000000000
--- a/app/views/groups/_settings_head.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#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: 'Pipelines' do
- %span
- Pipelines
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 0d3308833b7..86cd0759a2c 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -1,5 +1,4 @@
- breadcrumb_title "General Settings"
-= render "groups/settings_head"
.panel.panel-default.prepend-top-default
.panel-heading
Group settings
@@ -11,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
@@ -58,4 +57,20 @@
.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"
+
= render 'shared/confirm_modal', phrase: @group.path
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 7f411927429..36df03302e8 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -1,14 +1,10 @@
- page_title "Issues"
-- group_issues_exists = group_issues(@group).exists?
-= render "head_issues"
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@group.name} issues")
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'filtered_search'
-
-- if group_issues_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
@@ -20,13 +16,4 @@
= render 'shared/issuable/search_bar', 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/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 89165096fe2..ac7e12fcd0b 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -1,11 +1,10 @@
- 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)
@@ -19,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 ae240490bbd..538c353cf2d 100644
--- a/app/views/groups/labels/new.html.haml
+++ b/app/views/groups/labels/new.html.haml
@@ -1,6 +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 e56dc1fb9c2..4ccd16f3e11 100644
--- a/app/views/groups/merge_requests.html.haml
+++ b/app/views/groups/merge_requests.html.haml
@@ -1,10 +1,6 @@
- page_title "Merge Requests"
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'filtered_search'
-
-- 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
@@ -15,11 +11,4 @@
= render 'shared/issuable/search_bar', 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/merge_requests'
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index 7f450cd9a93..a1be0d3220a 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -10,8 +10,8 @@
.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...'
+ = 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
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/index.html.haml b/app/views/groups/milestones/index.html.haml
index ed582e521c4..f5f621507b8 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -1,11 +1,10 @@
- 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 milestone", new_group_milestone_path(@group), class: "btn btn-new"
diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml
index eca7fb9ddb1..d758e314d41 100644
--- a/app/views/groups/milestones/new.html.haml
+++ b/app/views/groups/milestones/new.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title "Milestones"
- page_title "Milestones"
-- header_title group_title(@group, "Milestones", group_milestones_path(@group))
%h3.page-title
New Milestone
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index 7f3f2f707f7..ef181b425bc 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -1,5 +1,4 @@
- breadcrumb_title "Projects"
-= render "groups/settings_head"
.panel.panel-default.prepend-top-default
.panel-heading
@@ -15,7 +14,7 @@
.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
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 9f9ae01e7c5..dd82922ec55 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -1,5 +1,11 @@
- breadcrumb_title "CI / CD Settings"
- page_title "CI / CD"
-= render "groups/settings_head"
-= render 'ci/variables/index'
+%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 f4f76887422..7f9486d08d9 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,20 +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 7abc84412c6..00000000000
--- a/app/views/groups/subgroups.html.haml
+++ /dev/null
@@ -1,22 +0,0 @@
-- breadcrumb_title "Details"
-- @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/groups/variables/show.html.haml b/app/views/groups/variables/show.html.haml
deleted file mode 100644
index df533952b76..00000000000
--- a/app/views/groups/variables/show.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'ci/variables/show'
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index b18b3dd5766..29b23ae2e52 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -17,10 +17,6 @@
%th Global Shortcuts
%tr
%td.shortcut
- .key n
- %td Main Navigation
- %tr
- %td.shortcut
.key s
%td Focus Search
%tr
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/show.html.haml b/app/views/help/show.html.haml
index d6789baea28..c07c148a12a 100644
--- a/app/views/help/show.html.haml
+++ b/app/views/help/show.html.haml
@@ -1,5 +1,3 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'help'
- page_title @path.split("/").reverse.map(&:humanize)
.documentation.wiki.prepend-top-default
= markdown @markdown
diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml
index 445f0dffbcc..ce09b44fbb2 100644
--- a/app/views/help/ui.html.haml
+++ b/app/views/help/ui.html.haml
@@ -1,7 +1,5 @@
- page_title "UI Development Kit", "Help"
- lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed fermentum nisi sapien, non consequat lectus aliquam ultrices. Suspendisse sodales est euismod nunc condimentum, a consectetur diam ornare."
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('ui_development_kit')
.gitlab-ui-dev-kit
%h1 GitLab UI development kit
@@ -68,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
diff --git a/app/views/import/base/create.js.haml b/app/views/import/base/create.js.haml
deleted file mode 100644
index 4dc3a4a0acf..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.full_path, project_path(@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/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml
index 5d68e1e2156..df5841d1911 100644
--- a/app/views/import/gitlab_projects/new.html.haml
+++ b/app/views/import/gitlab_projects/new.html.haml
@@ -1,7 +1,5 @@
- page_title "GitLab Import"
- header_title "Projects", root_path
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'project_import_gl'
%h3.page-title
= icon('gitlab')
diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml
index ad6213b4efd..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, project_url(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 0c113c08526..21cf6d0dd65 100644
--- a/app/views/issues/_issue.atom.builder
+++ b/app/views/issues/_issue.atom.builder
@@ -3,7 +3,7 @@ xml.entry do
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/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/_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/_head.html.haml b/app/views/layouts/_head.html.haml
index e6a10e500a4..b981b5fdafa 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -32,26 +32,22 @@
= stylesheet_link_tag "test", media: "all" if Rails.env.test?
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
- // TODO: Combine these 2 stylesheets into application.scss
- = stylesheet_link_tag "new_nav", media: "all"
- = stylesheet_link_tag "new_sidebar", media: "all"
-
= Gon::Base.render_data
- 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 'performance_bar' if performance_bar_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
diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml
index fe0ec35d003..4276e6ee4bb 100644
--- a/app/views/layouts/_init_auto_complete.html.haml
+++ b/app/views/layouts/_init_auto_complete.html.haml
@@ -10,7 +10,7 @@
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)}",
+ 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..b50537438a9 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] {
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 1fd301d6850..f0963cf9da8 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -1,7 +1,8 @@
-.page-with-sidebar{ class: page_with_sidebar_class }
+.layout-page{ class: page_with_sidebar_class }
- if defined?(nav) && nav
= render "layouts/nav/sidebar/#{nav}"
- .content-wrapper.page-with-new-nav
+ .content-wrapper{ class: "#{@content_wrapper_class}" }
+ = render 'shared/outdated_browser'
.mobile-overlay
.alert-wrapper
= render "layouts/broadcast"
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 cd7a47da4a1..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: project_issues_path(@project), mr_path: project_merge_requests_path(@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,7 +13,15 @@
.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
@@ -21,8 +29,8 @@
%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/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/group.html.haml b/app/views/layouts/group.html.haml
index 08bd6fc311e..bfbfeee7c4b 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -4,4 +4,10 @@
- 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 d8fc371497d..e6238c0dddb 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -1,4 +1,4 @@
-%header.navbar.navbar-gitlab.navbar-gitlab-new
+%header.navbar.navbar-gitlab.qa-navbar
%a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
.container-fluid
.header-content
@@ -6,8 +6,10 @@
%h1.title
= link_to root_path, title: 'Dashboard', id: 'logo' do
= brand_header_logo
- %span.logo-text.hidden-xs
- = render 'shared/logo_type.svg'
+ - logo_text = brand_header_logo_type
+ - if logo_text.present?
+ %span.logo-text.hidden-xs
+ = logo_text
- if current_user
= render "layouts/nav/dashboard"
@@ -18,33 +20,38 @@
%ul.nav.navbar-nav
- if current_user
= render 'layouts/header/new_dropdown'
- %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
- %li.user-counter
+ - 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
- = custom_icon('issues')
+ = 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.user-counter
+ - 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
- = custom_icon('mr_bold')
+ = 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.user-counter
+ - 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
- = custom_icon('todo_done')
+ = 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: user_dropdown_class, data: { toggle: "dropdown" } do
- = image_tag avatar_icon(current_user, 23), width: 23, height: 23, class: "header-user-avatar"
- = custom_icon('caret_down')
+ = 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
@@ -61,19 +68,17 @@
= link_to "Help", help_path
%li.divider
%li
- = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
- - if session[:impersonator_id]
- %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')
- - 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 / Register", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in'
%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')
-
-= render 'shared/outdated_browser'
+ = 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_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index 63d1c077ecd..eb32f393310 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -1,7 +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
- = custom_icon('plus_square')
- = custom_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?
diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml
index 7bd3f5306a2..002922e13f1 100644
--- a/app/views/layouts/nav/_breadcrumbs.html.haml
+++ b/app/views/layouts/nav/_breadcrumbs.html.haml
@@ -16,5 +16,5 @@
= breadcrumb_list_item link_to(extra[:text], extra[:link])
= render "layouts/nav/breadcrumbs/collapsed_dropdown", location: :after
%li
- %h2.breadcrumbs-sub-title= @breadcrumb_title
+ %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 c254ee02dd8..f773bd0832d 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -1,60 +1,77 @@
%ul.list-unstyled.navbar-sub-nav
- = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects" }) do
- %a{ href: "#", data: { toggle: "dropdown" } }
- Projects
- = custom_icon('caret_down')
- .dropdown-menu.projects-dropdown-menu
- = render "layouts/nav/projects_dropdown/show"
-
- = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "hidden-xs" }) do
- = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
- Groups
-
- = nav_link(path: 'dashboard#activity', html_options: { class: "visible-lg" }) do
- = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
- Activity
-
- = 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(controller: 'dashboard/snippets', html_options: { class: "visible-lg" }) do
- = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
- Snippets
-
- %li.header-more.dropdown.hidden-lg
- %a{ href: "#", data: { toggle: "dropdown" } }
- More
- = custom_icon('caret_down')
- .dropdown-menu
- %ul
- = 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
-
- = nav_link(path: 'dashboard#activity') 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
+ - 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
+ = 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
+
+ - 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
+
+ - 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
+
+ - 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
- %li.hidden
- = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
- 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', title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('wrench fw')
+ = 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',
diff --git a/app/views/layouts/nav/_explore.html.haml b/app/views/layouts/nav/_explore.html.haml
index cd1c39f3226..50bde9d1754 100644
--- a/app/views/layouts/nav/_explore.html.haml
+++ b/app/views/layouts/nav/_explore.html.haml
@@ -1,12 +1,15 @@
%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
- = nav_link(controller: :snippets) do
- = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
- Snippets
+ - 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
+ - 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
+ - if explore_nav_link?(:snippets)
+ = nav_link(controller: :snippets) do
+ = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
+ Snippets
%li
= link_to "Help", help_path, title: 'About GitLab CE'
diff --git a/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml b/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml
index 28022eebb19..ad0d51d28f9 100644
--- a/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml
+++ b/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml
@@ -4,7 +4,7 @@
%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")
- = icon("angle-right", class: "breadcrumbs-list-angle")
+ = 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|
diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml
index a7370180bf6..5809d6f7fea 100644
--- a/app/views/layouts/nav/projects_dropdown/_show.html.haml
+++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml
@@ -1,9 +1,9 @@
-- project_meta = { id: @project.id, name: @project.name, namespace: @project.name_with_namespace, web_url: @project.web_url, avatar_url: @project.avatar_url } if @project&.persisted?
+- 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
+ .project-dropdown-sidebar.qa-projects-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/projects#index') do
- = link_to dashboard_projects_path 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
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 9eef006b6a8..dd086f70641 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -1,9 +1,9 @@
-.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) }
+.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
- = icon('wrench')
+ = 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
@@ -12,7 +12,6 @@
= 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
@@ -131,7 +130,7 @@
%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_broadcast_messages_path 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))
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 8cba495f7e4..5ea19c9882d 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -1,94 +1,111 @@
-- issues = IssuesFinder.new(current_user, group_id: @group.id, state: 'opened').execute
-- merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id, state: 'opened', non_archived: true).execute
+- 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-icons-only" if collapsed_sidebar?) }
+.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
- = image_tag group_icon(@group), class: "avatar s40 avatar-tile"
+ = group_icon(@group, class: "avatar s40 avatar-tile")
.sidebar-context-title
= @group.name
%ul.sidebar-top-level-items
- = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: 'home' }) do
- = link_to group_path(@group) do
- .nav-icon-container
- = sprite_icon('project')
- %span.nav-item-name
- Overview
+ - 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#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
+ %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)
- = nav_link(path: 'groups#activity') do
- = link_to activity_group_path(@group), title: 'Activity' do
- %span
- Activity
+ %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
- = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index']) 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)
+ - 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
- %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?(:labels)
+ = nav_link(path: 'labels#index') do
+ = link_to group_labels_path(@group), title: 'Labels' do
+ %span
+ 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
- = 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') }
- = 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)
- = 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 current_user && can?(current_user, :admin_group, @group)
- = nav_link(path: %w[groups#projects groups#edit ci_cd#show]) do
+ - 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')
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index a015c94c60e..c878fcf2808 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -1,9 +1,9 @@
-.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) }
+.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
- = icon('user')
+ = 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
@@ -28,7 +28,7 @@
= link_to profile_account_path do
%strong.fly-out-top-item-name
#{ _('Account') }
- - if current_application_settings.user_oauth_applications?
+ - if Gitlab::CurrentSettings.user_oauth_applications?
= nav_link(controller: 'oauth/applications') do
= link_to applications_profile_path do
.nav-icon-container
@@ -73,7 +73,7 @@
= link_to profile_emails_path do
%strong.fly-out-top-item-name
#{ _('Emails') }
- - unless current_user.ldap_user?
+ - if current_user.allow_password_authentication?
= nav_link(controller: :passwords) do
= link_to edit_profile_password_path do
.nav-icon-container
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 8765b814405..059571f795f 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -1,4 +1,4 @@
-.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) }
+.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll
- can_edit = can?(current_user, :admin_project, @project)
.context-header
@@ -127,6 +127,19 @@
= 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
@@ -146,7 +159,7 @@
= number_with_delimiter(@project.open_merge_requests_count)
- if project_nav_tab? :pipelines
- = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts]) do
+ = 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')
@@ -154,7 +167,7 @@
CI / CD
%ul.sidebar-sub-level-items
- = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts], html_options: { class: "fly-out-top-item" } ) do
+ = 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') }
@@ -183,6 +196,35 @@
%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
@@ -220,7 +262,7 @@
= link_to edit_project_path(@project), class: 'shortcuts-tree' do
.nav-icon-container
= sprite_icon('settings')
- %span.nav-item-name
+ %span.nav-item-name.qa-settings-item
Settings
%ul.sidebar-sub-level-items
@@ -260,12 +302,17 @@
Pages
- else
- = nav_link(path: %w[members#show]) do
+ = 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'
@@ -288,9 +335,10 @@
Charts
-# Shortcut to Issues > New Issue
- %li.hidden
- = link_to new_project_issue_path(@project), class: 'shortcuts-new-issue' do
- Create a 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
@@ -305,5 +353,6 @@
Commits
-# Shortcut to issue boards
- %li.hidden
- = link_to 'Issue Boards', project_boards_path(@project), title: 'Issue Boards', class: 'shortcuts-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/project.html.haml b/app/views/layouts/project.html.haml
index 6b847fb4b7c..6b51483810e 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -1,4 +1,4 @@
-- 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"
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/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_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_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_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 f0ba7827cef..71c62f6be4e 100644
--- a/app/views/notify/project_was_exported_email.html.haml
+++ b/app/views/notify/project_was_exported_email.html.haml
@@ -3,6 +3,6 @@
%p
The project export can be downloaded from:
= link_to download_export_project_url(@project), rel: 'nofollow', download: '' do
- = @project.name_with_namespace + " export"
+ = @project.full_name + " export"
%p
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 c476a39b661..1b6b1a81665 100644
--- a/app/views/notify/project_was_moved_email.html.haml
+++ b/app/views/notify/project_was_moved_email.html.haml
@@ -3,7 +3,7 @@
%p
The project is now located under
= link_to project_url(@project) do
- = @project.name_with_namespace
+ = @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/peek/views/_gitaly.html.haml b/app/views/peek/views/_gitaly.html.haml
new file mode 100644
index 00000000000..a7d040d6821
--- /dev/null
+++ b/app/views/peek/views/_gitaly.html.haml
@@ -0,0 +1,7 @@
+- local_assigns.fetch(:view)
+
+%strong
+ %span{ data: { defer_to: "#{view.defer_key}-duration" } } ...
+ \/
+ %span{ data: { defer_to: "#{view.defer_key}-calls" } } ...
+ Gitaly
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 8abbd828032..02263095599 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -1,6 +1,5 @@
- page_title "Account"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
- if current_user.ldap_user?
.alert.alert-info
@@ -9,22 +8,6 @@
.row.prepend-top-default
.col-lg-4.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-8.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-4.profile-settings-sidebar
- %h4.prepend-top-0
Two-Factor Authentication
%p
Increase your account's security by enabling Two-Factor Authentication (2FA).
@@ -33,10 +16,6 @@
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'
@@ -97,21 +76,28 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0.danger-title
- Remove account
+ = s_('Profiles|Delete account')
.col-lg-8
- if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
%p
- Deleting an account has the following effects:
+ = s_('Profiles|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"
+
+ %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
- Your account is currently an owner in these groups:
+ = s_('Profiles|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.
+ = s_('Profiles|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.
+ = 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 1a392e29e2a..a924369050b 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -1,10 +1,9 @@
- page_title "Authentication log"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h3.prepend-top-0
+ %h4.prepend-top-0
= page_title
%p
This is a security log of important events involving your account.
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index fe1cf802971..c7094800fb2 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -4,7 +4,7 @@
%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
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
index 8f7121afe02..4b6e419af50 100644
--- a/app/views/profiles/chat_names/index.html.haml
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -1,6 +1,5 @@
- page_title 'Chat'
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index 612ecbbb96a..e3c2bd1150e 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -1,6 +1,5 @@
- page_title "Emails"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
@@ -32,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/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml
index b04981f90e3..5ed517c1ef6 100644
--- a/app/views/profiles/gpg_keys/_key.html.haml
+++ b/app/views/profiles/gpg_keys/_key.html.haml
@@ -3,10 +3,17 @@
= icon 'key', class: "settings-list-icon hidden-xs"
.key-list-item-info
- key.emails_with_verified_status.map do |email, verified|
- = render partial: 'email_with_badge', locals: { email: email, verified: 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)}
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
index 8dbb8aef31b..1d2e41cb437 100644
--- a/app/views/profiles/gpg_keys/index.html.haml
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -1,13 +1,13 @@
- page_title "GPG 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
GPG keys allow you to verify signed commits.
- .col-lg-9
+ .col-lg-8
%h5.prepend-top-0
Add a GPG key
%p.profile-settings-content
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 5f7b41cf30e..1e206def7ee 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -1,6 +1,5 @@
- page_title "SSH Keys"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
@@ -13,7 +12,9 @@
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 172c0450381..28be6172219 100644
--- a/app/views/profiles/keys/show.html.haml
+++ b/app/views/profiles/keys/show.html.haml
@@ -1,4 +1,5 @@
+- add_to_breadcrumbs "SSH Keys", profile_keys_path
+- breadcrumb_title @key.title
- page_title @key.title, "SSH Keys"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
= render "key_details"
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index e98fdfc7a3d..8f099aa6dd7 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -1,6 +1,5 @@
- page_title "Notifications"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
%div
- if @user.errors.any?
@@ -10,16 +9,16 @@
%li= msg
= hidden_field_tag :notification_type, 'global'
- .row
+ .row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4
+ %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-8
- %h5
+ %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/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 06bb72b9f0d..78848542810 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -2,7 +2,6 @@
- page_title "Personal Access Tokens"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
@@ -15,14 +14,13 @@
They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.
.col-lg-8
-
- - if flash[:personal_access_token]
+ - 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 js-select-on-focus", '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
@@ -30,3 +28,40 @@
= render "shared/personal_access_tokens_form", path: profile_personal_access_tokens_path, impersonation: false, token: @personal_access_token, scopes: @scopes
= render "shared/personal_access_tokens_table", impersonation: false, active_tokens: @active_personal_access_tokens, inactive_tokens: @inactive_personal_access_tokens
+
+%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 66d1d1e8d44..6aefd97bb96 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,6 +1,5 @@
- page_title 'Preferences'
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f|
.col-lg-4.application-theme
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 79f334176a5..e497eab32e0 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title "Edit Profile"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
= 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)
@@ -13,27 +12,24 @@
- 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}
+ 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-4.profile-settings-sidebar
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 0b03276efcc..1bd10018b40 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -1,14 +1,7 @@
- page_title 'Two-Factor Authentication', 'Account'
-- add_to_breadcrumbs("Account", profile_account_path)
+- add_to_breadcrumbs("Two-Factor Authentication", profile_account_path)
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
-
-- content_for :page_specific_javascripts do
- - if inject_u2f_api?
- = page_specific_javascript_bundle_tag('u2f')
- = page_specific_javascript_bundle_tag('two_factor_auth')
-
.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
@@ -18,7 +11,12 @@
Use an app on your mobile device to enable two-factor authentication (2FA).
.col-lg-8
- 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."
+ %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.
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
index 623d3bc91c6..5dfe973f33c 100644
--- a/app/views/projects/_export.html.haml
+++ b/app/views/projects/_export.html.haml
@@ -1,9 +1,9 @@
-- return unless current_application_settings.project_export_enabled?
+- return unless Gitlab::CurrentSettings.project_export_enabled?
- project = local_assigns.fetch(:project)
- expanded = Rails.env.test?
-%section.settings
+%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Export project
@@ -11,7 +11,7 @@
= 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.no-animate{ class: ('expanded' if expanded) }
+ .settings-content
.bs-callout.bs-callout-info
%p.append-bottom-0
%p
@@ -30,7 +30,7 @@
%li CI variables
%li Any encrypted tokens
%p
- Once the exported file is ready, you will receive a notification email with a download link.
+ 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"
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 3a7a99462a6..79530e78154 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -7,7 +7,7 @@
.nav-block
= render 'projects/tree/tree_header', tree: @tree
- - if !show_new_repo? && commit
+ - if commit
= render 'shared/commit_well', commit: commit, ref: ref, project: project
= render 'projects/tree/tree_content', tree: @tree, content_url: content_url
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 56eecece54c..6f5eb828902 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -14,5 +14,5 @@
#{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
+ = 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 71424593f2e..8212ab9a31e 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/projects/_md_preview.html.haml
@@ -1,27 +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
- %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 1dd8778f800..f6e5712ce81 100644
--- a/app/views/projects/_merge_request_merge_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_settings.html.haml
@@ -8,7 +8,7 @@
%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
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 cc5afa943cf..fd0c419cdac 100644
--- a/app/views/projects/_merge_request_settings.html.haml
+++ b/app/views/projects/_merge_request_settings.html.haml
@@ -1,3 +1,18 @@
- form = local_assigns.fetch(:form)
+.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 '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..f4b5ef1555e
--- /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
+ = 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
+ #{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
index 5638b7da1b0..d50175727be 100644
--- a/app/views/projects/_project_templates.html.haml
+++ b/app/views/projects/_project_templates.html.haml
@@ -1,10 +1,24 @@
-.project-templates-buttons.import-buttons{ data: { toggle: "buttons" } }
- .btn.blank-option.active
- %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: "blank", checked: "true", value: "" }
- = icon('file-o', class: 'btn-template-icon')
- Blank
+.project-templates-buttons.import-buttons
- Gitlab::ProjectTemplate.all.each do |template|
- .btn
- %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name }
+ .template-option
= custom_icon(template.logo)
- = template.title
+ .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/activity.html.haml b/app/views/projects/activity.html.haml
index f80dadb8037..b28a375e956 100644
--- a/app/views/projects/activity.html.haml
+++ b/app/views/projects/activity.html.haml
@@ -2,8 +2,7 @@
- page_title _("Activity")
-= render "projects/head"
-
-= 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 03be6f15313..1a9ce8d0508 100644
--- a/app/views/projects/artifacts/_tree_directory.html.haml
+++ b/app/views/projects/artifacts/_tree_directory.html.haml
@@ -3,6 +3,6 @@
%tr.tree-item{ 'data-link' => path_to_directory }
%td.tree-item-file-name
= tree_icon('folder', '755', directory.name)
- = 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 8edb9be049a..cfb91568061 100644
--- a/app/views/projects/artifacts/_tree_file.html.haml
+++ b/app/views/projects/artifacts/_tree_file.html.haml
@@ -1,10 +1,17 @@
+- 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 4cc3218d967..7ff7466e561 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -1,10 +1,9 @@
- 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(_('Jobs'), project_jobs_path(@project))
+- add_to_breadcrumbs(s_('CICD|Jobs'), project_jobs_path(@project))
- add_to_breadcrumbs("##{@build.id}", project_jobs_path(@project))
.tree-holder
@@ -19,7 +18,7 @@
.tree-controls
= link_to download_project_job_artifacts_path(@project, @build),
rel: 'nofollow', download: '', class: 'btn btn-default download' do
- = icon('download')
+ = sprite_icon('download')
Download artifacts archive
.tree-content-holder
diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml
index b85bbcb980e..2942d618a42 100644
--- a/app/views/projects/artifacts/file.html.haml
+++ b/app/views/projects/artifacts/file.html.haml
@@ -1,5 +1,4 @@
- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
-= render "projects/pipelines/head"
= render "projects/jobs/header", show_controls: false
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index 60ac202bde0..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
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..f93bb02acb9 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,7 @@
= view_on_environment_button(@commit.sha, @path, @environment) if @environment
.btn-group{ role: "group" }<
- = edit_blob_link
+ = edit_blob_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 03ab1bb59e4..5d48a35dc4c 100644
--- a/app/views/projects/blob/_new_dir.html.haml
+++ b/app/views/projects/blob/_new_dir.html.haml
@@ -1,5 +1,5 @@
#modal-create-new-dir.modal
- .modal-dialog
+ .modal-dialog.modal-lg
.modal-content
.modal-header
%a.close{ href: "#", "data-dismiss" => "modal" } ×
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 05b7dfe2872..f1324c61500 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
@@ -27,6 +27,3 @@
- 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')
diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml
index cc85e5de40f..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
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 992fe7f717f..9d90251ab66 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -3,8 +3,6 @@
- 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
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index a4263774dfd..fa091d8f6ef 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -2,7 +2,6 @@
- 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
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index 240e62d5ac5..efb8175398b 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -2,26 +2,15 @@
- @no_container = true
- page_title @blob.path, @ref
-= render "projects/commits/head"
-
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'blob'
-
- - if show_new_repo?
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'repo'
-
-= render 'projects/last_push'
%div{ class: container_class }
- - if show_new_repo?
- = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_blob_path(@project, @id)
- - else
- #tree-holder.tree-holder
- = render 'blob', blob: @blob
+ = render 'projects/last_push'
+
+ #tree-holder.tree-holder
+ = render 'blob', blob: @blob
- 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: project_update_blob_path(@project, @id), method: :put
+ - title = "Replace #{@blob.name}"
+ = 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 1e7c461f02e..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_path } }
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 253566c43be..f9b1da05a00 100644
--- a/app/views/projects/blob/viewers/_download.html.haml
+++ b/app/views/projects/blob/viewers/_download.html.haml
@@ -2,6 +2,6 @@
.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 26ea028c5d7..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
- = image_tag(blob_raw_path, 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 8a41bc53004..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_path } }
diff --git a/app/views/projects/blob/viewers/_pdf.html.haml b/app/views/projects/blob/viewers/_pdf.html.haml
index ec2b18bd4ab..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_path } }
diff --git a/app/views/projects/blob/viewers/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml
index 775e4584f77..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_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 6578d826ace..55dd8cba7fe 100644
--- a/app/views/projects/blob/viewers/_stl.html.haml
+++ b/app/views/projects/blob/viewers/_stl.html.haml
@@ -1,6 +1,3 @@
-- 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_path } }
= icon('spinner spin 2x', class: 'prepend-top-default append-bottom-default', 'aria-hidden' => 'true', 'aria-label' => 'Loading')
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 05c1d2b383c..1da0e865a41 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -1,3 +1,4 @@
+- 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)
@@ -7,26 +8,29 @@
%li{ class: "js-branch-#{branch.name}" }
%div
= link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated ref-name' do
- = icon('code-fork')
+ = sprite_icon('fork', size: 12)
= 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
+ - 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
+ = s_('Branches|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
+ = _('Merge request')
- if branch.name != @repository.root_ref
- = 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: "Compare" do
- Compare
+ = 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]
@@ -34,46 +38,48 @@
- 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" }
+ 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: "Delete protected branch",
+ 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 @repository.merged_to_root_ref?(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: "Only a project master or owner can delete a protected branch" }
+ 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: "Delete branch",
+ title: s_('Branches|Delete branch'),
method: :delete,
- data: { confirm: "Deleting the '#{branch.name}' branch cannot be undone. Are you sure?" },
+ data: { confirm: s_("Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?") % { branch_name: branch.name } },
remote: true,
- "aria-label" => "Delete branch" do
+ 'aria-label' => s_('Branches|Delete branch') do
= icon("trash-o")
- if branch.name != @repository.root_ref
- .divergence-graph{ title: "#{number_commits_behind} commits behind #{@repository.root_ref}, #{number_commits_ahead} commits ahead" }
+ .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= number_commits_behind
+ %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= number_commits_ahead
+ %span.count.count-ahead= diverging_count_label(number_commits_ahead)
- if commit
= render 'projects/branches/commit', commit: commit, project: @project
- else
%p
- Cant find HEAD commit for this branch
+ = s_('Branches|Cant find HEAD commit for this branch')
diff --git a/app/views/projects/branches/_delete_protected_modal.html.haml b/app/views/projects/branches/_delete_protected_modal.html.haml
index f00a0ee6925..e0008e322a0 100644
--- a/app/views/projects/branches/_delete_protected_modal.html.haml
+++ b/app/views/projects/branches/_delete_protected_modal.html.haml
@@ -4,36 +4,38 @@
.modal-header
%button.close{ data: { dismiss: 'modal' } } ×
%h3.page-title
- Delete protected branch
- = surround "'", "'?" do
+ - 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.ref-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.")
+ = 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 73583c6bbc2..5dcc72d8263 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -1,42 +1,66 @@
- @no_container = true
-- page_title "Branches"
-= render "projects/commits/head"
+- page_title _('Branches')
%div{ class: container_class }
.top-area.adjust
- - if can?(current_user, :admin_project, @project)
- .nav-text
- Protected branches can be managed in
- = link_to 'project settings', project_protected_branches_path(@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 project_merged_branches_path(@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 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
- New branch
+ = 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 2baaaf6ac5b..c7fc5a98ca8 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -20,7 +20,7 @@
.col-sm-10.create-from
.dropdown
= hidden_field_tag :ref, default_ref
- = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select 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'
@@ -28,4 +28,5 @@
.form-actions
= button_tag 'Create branch', class: 'btn btn-create', tabindex: 3
= 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 9d85e027ac9..fa9a9bfc8f7 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -3,7 +3,7 @@
- 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' }
@@ -11,19 +11,15 @@
#{ _('Source code') }
%li
= link_to archive_project_repository_path(project, ref: ref, format: 'zip'), rel: 'nofollow', download: '' do
- %i.fa.fa-download
%span= _('Download zip')
%li
= link_to archive_project_repository_path(project, ref: ref, format: 'tar.gz'), rel: 'nofollow', download: '' do
- %i.fa.fa-download
%span= _('Download tar.gz')
%li
= link_to archive_project_repository_path(project, ref: ref, format: 'tar.bz2'), rel: 'nofollow', download: '' do
- %i.fa.fa-download
%span= _('Download tar.bz2')
%li
= link_to archive_project_repository_path(project, ref: ref, format: 'tar'), rel: 'nofollow', download: '' do
- %i.fa.fa-download
%span= _('Download tar')
- if pipeline && pipeline.latest_builds_with_artifacts.any?
@@ -36,6 +32,5 @@
- 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
- %i.fa.fa-download
%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 b04d6a1fa5e..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_project_issue_path(@project) do
- = icon('exclamation-circle fw')
- #{ _('New issue') }
+ %li= link_to _('New issue'), new_project_issue_path(@project)
- if merge_project
- %li
- = link_to project_new_merge_request_path(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_project_snippet_path(@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 project_new_blob_path(@project, @project.default_branch || 'master') do
- = icon('file fw')
- #{ _('New file') }
- %li
- = link_to new_project_branch_path(@project) do
- = icon('code-fork fw')
- #{ _('New branch') }
- %li
- = link_to new_project_tag_path(@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 project_new_blob_path(@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: 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)
- = 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 f45cc7f0f45..f880556a9f7 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -4,12 +4,11 @@
= 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_project_fork_path(@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_project_fork_path(@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
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 c82ae35a685..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_project_path(@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')
+ = 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 c1842527480..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 project_job_url(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
@@ -63,7 +63,7 @@
- if admin
%td
- if job.project
- = link_to job.project.name_with_namespace, admin_project_path(job.project)
+ = link_to job.project.full_name, admin_project_path(job.project)
%td
- if job.try(:runner)
= runner_link(job.runner)
@@ -96,7 +96,7 @@
.pull-right
- if can?(current_user, :read_build, job) && job.artifacts?
= link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
- = icon('download')
+ = sprite_icon('download')
- if can?(current_user, :update_build, job)
- if job.active?
= link_to cancel_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do
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..d4c0cd82ce3
--- /dev/null
+++ b/app/views/projects/clusters/_integration_form.html.haml
@@ -0,0 +1,32 @@
+= 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|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
index 83821326aec..36b28c731a1 100644
--- a/app/views/projects/commit/_ajax_signature.html.haml
+++ b/app/views/projects/commit/_ajax_signature.html.haml
@@ -1,2 +1,2 @@
- if commit.has_signature?
- %button{ class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'auto top', title: 'GPG signature (loading...)', 'commit-sha' => commit.sha } }
+ %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 d0a380516f9..93407956f56 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,6 +18,8 @@
%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'
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 09bcd187e59..461129a3e0e 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -47,7 +47,7 @@
%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?
@@ -61,14 +61,20 @@
%span.cgray= n_('parent', 'parents', @commit.parents.count)
- @commit.parents.each do |parent|
= link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha"
- %span.commit-info.branches
+ .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 project_pipeline_path(@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}", project_pipeline_path(@project, last_pipeline.id)
@@ -77,5 +83,16 @@
#{ 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)
+
+ 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
index 80eca96f7ce..d7bf2dc0cb6 100644
--- a/app/views/projects/commit/_other_user_signature_badge.html.haml
+++ b/app/views/projects/commit/_other_user_signature_badge.html.haml
@@ -1,6 +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: 'icon_status_notfound_borderless', show_user: true }
+- 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 49b0b314e1d..68b35072f26 100644
--- a/app/views/projects/commit/_pipelines_list.haml
+++ b/app/views/projects/commit/_pipelines_list.haml
@@ -6,7 +6,3 @@
"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
index e737de48e22..22ffd66ff8e 100644
--- 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
@@ -2,6 +2,6 @@
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: 'icon_status_notfound_borderless', show_user: true }
+- 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_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml
index edff018ba6d..aac020b42c5 100644
--- a/app/views/projects/commit/_signature_badge.html.haml
+++ b/app/views/projects/commit/_signature_badge.html.haml
@@ -10,7 +10,7 @@
- title = capture do
.gpg-popover-status
.gpg-popover-icon{ class: css_class }
- = render "shared/icons/#{icon}.svg"
+ = sprite_icon(icon)
%div
= title
@@ -24,5 +24,5 @@
= link_to('Learn more about signing commits', help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link')
-%button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto top', title: title, content: content } }
+%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/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml
index 1af58027b83..00e1efe0582 100644
--- a/app/views/projects/commit/_unverified_signature_badge.html.haml
+++ b/app/views/projects/commit/_unverified_signature_badge.html.haml
@@ -1,6 +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: 'icon_status_notfound_borderless' }
+- 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
index 423beba2120..31408806be7 100644
--- a/app/views/projects/commit/_verified_signature_badge.html.haml
+++ b/app/views/projects/commit/_verified_signature_badge.html.haml
@@ -2,6 +2,6 @@
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: 'icon_status_success_borderless', show_user: true }
+- 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/show.html.haml b/app/views/projects/commit/show.html.haml
index 717de85c5d2..abb292f8f27 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -6,7 +6,6 @@
- @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 d806acdda13..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 project_commit_url(@project, id: commit.id)
xml.link href: project_commit_url(@project, id: commit.id)
- xml.title truncate(commit.title, length: 80)
+ 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 a16ffb433a5..078bd0eee63 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -1,12 +1,18 @@
-- 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
-
-- cache_key = [project.full_path, commit.id, current_application_settings, note_count, @path.presence, current_controller?(:commits), I18n.locale]
-- cache_key.push(commit.status(ref)) if commit.status(ref)
+- 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 }
+
+- 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-row.js-toggle-container{ id: "commit-#{commit.short_id}" }
@@ -14,9 +20,9 @@
.avatar-cell.hidden-xs
= author_avatar(commit, size: 36)
- .commit-detail
+ .commit-detail.flex-list
.commit-content
- = link_to_markdown_field(commit, :title, project_commit_path(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
@@ -32,12 +38,11 @@
.commiter
- commit_author_link = commit_author_link(commit, avatar: false, size: 24)
- - commit_timeago = time_ago_with_tooltip(commit.committed_date, placement: 'bottom')
- - 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.hidden-xs
+ .commit-actions.flex-row.hidden-xs
- if request.xhr?
= render partial: 'projects/commit/signature', object: commit.signature
- else
@@ -46,6 +51,10 @@
- if commit.status(ref)
= render_commit_status(commit, ref: ref)
- = link_to commit.short_id, project_commit_path(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 d14897428d0..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|
@@ -8,7 +11,7 @@
%li.commits-row{ data: { day: day } }
%ul.content-list.commit-list.flex-list
- = render partial: 'projects/commits/commit', collection: commits, locals: { project: project, ref: ref }
+ = render partial: 'projects/commits/commit', collection: commits, locals: { project: project, ref: ref, merge_request: merge_request }
- if hidden > 0
%li.alert.alert-warning
diff --git a/app/views/projects/commits/_head.html.haml b/app/views/projects/commits/_head.html.haml
deleted file mode 100644
index e1549baef89..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_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') }
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index e873b931683..ab371521840 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -3,10 +3,7 @@
- page_title _("Commits"), @ref
= content_for :meta_tags do
- = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
-
-= content_for :sub_nav do
- = render "head"
+ = 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 }
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index a518fced2b4..d0c8a699608 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -5,22 +5,24 @@
= 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 Source
+ %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'
+ .dropdown-toggle-text.str-truncated= params[:to] || _("Select branch/tag")
= render 'shared/ref_dropdown'
.compare-ellipsis.inline ...
.form-group.dropdown.compare-form-group.from.js-compare-from-dropdown
.input-group.inline-input-group
- %span.input-group-addon Target
+ %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'
+ .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", project_merge_request_path(@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 1ce3ad0c0fd..14c64b3534a 100644
--- a/app/views/projects/compare/index.html.haml
+++ b/app/views/projects/compare/index.html.haml
@@ -1,25 +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.
- %br
- Choose a branch/tag (e.g.
- = succeed ')' do
+ - example_master = capture do
%code.ref-name master
- or enter a commit SHA (e.g.
- = succeed ')' do
+ - example_sha = capture do
%code.ref-name 4eedf23
- to see what's changed or to create a merge request.
+ = (_("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
- Changes are shown as if the
- %b source
- revision was being merged into the
- %b target
- revision.
+ = (_("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 7cc42455394..8da55664878 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -1,7 +1,6 @@
- @no_container = true
-- add_to_breadcrumbs "Compare Revisions", project_compare_index_path(@project)
+- 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
@@ -14,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 8d008be5aae..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', "@click" => "dismissOverviewDialog()" }
- = icon("times")
- .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
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 45985a5ecef..75dd4c9ae15 100644
--- a/app/views/projects/deploy_keys/_index.html.haml
+++ b/app/views/projects/deploy_keys/_index.html.haml
@@ -1,13 +1,13 @@
- 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
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 014486be868..c7ac687e4a6 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -2,7 +2,7 @@
.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")
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index adc4dcbed33..47bfcb21cf4 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -11,13 +11,13 @@
- 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
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/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml
index 56d63250714..1f0ca211074 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -14,20 +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 }= 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
@@ -35,20 +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 }= 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 2de2cf9e38c..b082ad0ef0e 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -22,9 +22,16 @@
- diff_files.each do |diff_file|
%li
%a.diff-changed-file{ href: "##{hexdigest(diff_file.file_path)}", title: diff_file.new_path }
- = icon("#{diff_file_changed_icon(diff_file)} fw", class: "#{diff_file_changed_icon_color(diff_file)} append-right-5")
- %span.diff-file-changes-path.append-right-5= diff_file.new_path
- .pull-right
+ = 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 --git a/app/views/projects/diffs/viewers/_image.html.haml b/app/views/projects/diffs/viewers/_image.html.haml
index 6b5233833c6..f190073c2fc 100644
--- a/app/views/projects/diffs/viewers/_image.html.haml
+++ b/app/views/projects/diffs/viewers/_image.html.haml
@@ -1,67 +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') }
- = image_tag(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
- = image_tag(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
- = image_tag(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
- = image_tag(old_blob_raw_path, alt: diff_file.old_path, lazy: false)
- .swipe-wrap
- .frame.added
- = image_tag(blob_raw_path, alt: diff_file.new_path, lazy: false)
- %span.swipe-bar
- %span.top-handle
- %span.bottom-handle
-
- .onion-skin.view.hide
- .onion-skin-frame
- .frame.deleted
- = image_tag(old_blob_raw_path, alt: diff_file.old_path, lazy: false)
- .frame.added
- = image_tag(blob_raw_path, alt: diff_file.new_path, lazy: false)
- .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 0a3045604f4..a96485ab155 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -3,10 +3,8 @@
- @content_class = "limit-container-width" unless fluid_layout
- expanded = Rails.env.test?
-= render "projects/settings/head"
-
.project-edit-container
- %section.settings.general-settings
+ %section.settings.general-settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
General project settings
@@ -14,7 +12,7 @@
= expanded ? 'Collapse' : 'Expand'
%p
Update your project name, description, avatar, and other general settings.
- .settings-content.no-animate{ class: ('expanded' if expanded) }
+ .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
@@ -44,26 +42,25 @@
= f.text_field :tag_list, value: @project.tag_list.sort.join(', '), maxlength: 2000, class: "form-control"
%p.help-block Separate tags with commas.
%fieldset.features
- %h5.prepend-top-0
- Project avatar
+ %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', project_avatar_path(@project), data: { confirm: "Project avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar"
- = f.submit 'Save changes', class: "btn btn-save"
+ = 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
+ %section.settings.sharing-permissions.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Permissions
@@ -71,13 +68,14 @@
= expanded ? 'Collapse' : 'Expand'
%p
Enable or disable certain project features and choose access levels.
- .settings-content.no-animate{ class: ('expanded' if expanded) }
+ .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"
- %section.settings.merge-requests-feature{ class: ("hidden" if @project.project_feature.send(:merge_requests_access_level) == 0) }
+ %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
@@ -85,22 +83,22 @@
= expanded ? 'Collapse' : 'Expand'
%p
Customize your merge request restrictions.
- .settings-content.no-animate{ class: ('expanded' if expanded) }
+ .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"
+ = f.submit 'Save changes', class: "btn btn-save qa-save-merge-request-changes"
= render 'export', project: @project
- %section.settings.advanced-settings
+ %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, exporting, archiving, renaming, transferring, or removing your project.
- .settings-content.no-animate{ class: ('expanded' if expanded) }
+ Perform advanced options such as housekeeping, archiving, renaming, transferring, or removing your project.
+ .settings-content
.sub-section
%h4 Housekeeping
%p
@@ -149,7 +147,7 @@
%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?
+ - 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)
@@ -175,7 +173,10 @@
%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)
+ - 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.
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index d5b83b53ebb..8a36fada389 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -3,35 +3,43 @@
= 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 }
- - if show_auto_devops_callout?(@project)
- = render 'shared/auto_devops_callout'
+ %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/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 acc80b49dd0..7ebe617766f 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -1,18 +1,11 @@
- @no_container = true
- page_title "Environments"
- add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
-= render "projects/pipelines/head"
-
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag("environments")
#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_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 4a65b46f029..c151b5acdf7 100644
--- a/app/views/projects/environments/metrics.html.haml
+++ b/app/views/projects/environments/metrics.html.haml
@@ -1,10 +1,5 @@
- @no_container = true
- page_title "Metrics for environment", @environment.name
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'common_d3'
- = webpack_bundle_tag 'monitoring'
-= render "projects/pipelines/head"
.prometheus-container{ class: container_class }
.top-area
@@ -15,10 +10,13 @@
= link_to @environment.name, environment_path(@environment)
#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'),
- "empty-loading-svg-path": image_path('illustrations/monitoring/loading'),
- "empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect'),
- "additional-metrics": additional_metrics_project_environment_path(@project, @environment, format: :json),
- "has-metrics": "#{@environment.has_metrics?}", deployment_endpoint: project_environment_deployments_path(@project, @environment, format: :json) } }
-
+ "empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'),
+ "empty-loading-svg-path": image_path('illustrations/monitoring/loading.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 88f43a1e7e4..62b08e85e22 100644
--- a/app/views/projects/environments/new.html.haml
+++ b/app/views/projects/environments/new.html.haml
@@ -1,7 +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 c35d1b5aaee..add394a6356 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -2,7 +2,6 @@
- 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
@@ -20,14 +19,15 @@
.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 464135b5ac7..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
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index 021575160ea..a3467eb6f05 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -1,5 +1,4 @@
- page_title "Find File", @ref
-= render "projects/commits/head"
.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
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 d365bcd4ecc..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
@@ -21,5 +21,4 @@
%p
= link_to new_project_fork_path(@project), title: "Fork", class: "btn" do
- %i.fa.fa-code-fork
Try to fork again
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index 111cbcda266..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_project_fork_path(@project), title: "Fork project", class: 'btn btn-new' do
- = custom_icon('icon_fork')
+ = 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 0f36e1a7353..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 project_forks_path(@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 b98dc09534f..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,7 +19,7 @@
- if ref
- if generic_commit_status.ref
.icon-container
- = generic_commit_status.tags.any? ? icon('tag') : icon('code-fork')
+ = 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
@@ -53,7 +53,7 @@
- if admin
%td
- if generic_commit_status.project
- = link_to generic_commit_status.project.name_with_namespace, admin_project_path(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 f0ef647ddb3..14c47a5d91c 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -1,14 +1,9 @@
- @no_container = true
- page_title "Charts"
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('common_d3')
- = webpack_bundle_tag('graphs')
- = webpack_bundle_tag('graphs_charts')
-= 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
@@ -31,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
@@ -46,34 +43,38 @@
.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
+-# haml-lint:disable InlineJavaScript
%script#projectChartData{ type: "application/json" }
- projectChartData = {};
- projectChartData['hour'] = @commits_per_time
diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml
index 08b38428b50..c81ee6874e3 100644
--- a/app/views/projects/graphs/show.html.haml
+++ b/app/views/projects/graphs/show.html.haml
@@ -1,31 +1,25 @@
- @no_container = true
-- page_title "Contributors"
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('common_d3')
- = webpack_bundle_tag('graphs')
- = webpack_bundle_tag('graphs_show')
-
-= render 'projects/commits/head'
+- page_title _('Contributors')
.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
diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml
index 05b06cfc8b2..8096d9530c3 100644
--- a/app/views/projects/hook_logs/_index.html.haml
+++ b/app/views/projects/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/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
index ab5a7b117d7..1cf4105bd27 100644
--- a/app/views/projects/hook_logs/show.html.haml
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -1,5 +1,3 @@
-= render 'projects/settings/head'
-
.row.prepend-top-default.append-bottom-default
.col-lg-3
%h4.prepend-top-0
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index c8c17d2d828..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
@@ -13,10 +12,9 @@
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
= f.submit 'Save changes', class: 'btn btn-create'
- = render 'shared/web_hooks/test_button', triggers: ProjectHook::TRIGGERS, hook: @hook
+ = 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/show.html.haml b/app/views/projects/imports/show.html.haml
index 8c490773a56..3b0c828ccd1 100644
--- a/app/views/projects/imports/show.html.haml
+++ b/app/views/projects/imports/show.html.haml
@@ -1,12 +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.
+ %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 483f28c74f2..cdfc3e232c5 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -6,12 +6,6 @@
= 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'
%section.js-vue-notes-event
- #js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json),
- register_path: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
- new_session_path: new_session_path(:user, redirect_to_referer: 'yes'),
- markdown_docs_path: help_page_path('user/markdown'),
- quick_actions_docs_path: help_page_path('user/project/quick_actions'),
- notes_path: notes_url,
- last_fetched_at: Time.now.to_i,
- issue_data: serialize_issuable(@issue),
- current_user_data: UserSerializer.new.represent(current_user).to_json } }
+ #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 e9f21594a71..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 project_issues_path(@project), title: 'Issues' do
- %span
- List
-
- = nav_link(controller: :boards) do
- = link_to project_boards_path(@project), title: 'Board' do
- %span
- Board
-
- - if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests)
- = nav_link(controller: :merge_requests) do
- = link_to project_merge_requests_path(@project), title: 'Merge Requests' do
- %span
- Merge Requests
-
- - if project_nav_tab? :labels
- = nav_link(controller: :labels) do
- = link_to project_labels_path(@project), title: 'Labels' do
- %span
- Labels
-
- - if project_nav_tab? :milestones
- = nav_link(controller: :milestones) do
- = link_to project_milestones_path(@project), title: 'Milestones' do
- %span
- Milestones
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 264032a3a31..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_project_path(@project), class: 'incoming-email-token-reset'
- if that ever happens.
diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml
index 6a567487514..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, project_path(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
index 13809da6523..0d39edb7bfd 100644
--- a/app/views/projects/issues/_nav_btns.html.haml
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -3,8 +3,8 @@
- 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: issues_finder.assignee.try(:id),
- milestone_id: issues_finder.milestones.first.try(:id) }),
+ 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 e1b4a49850a..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_project_issue_path(@project, @issue), create_mr_path: create_merge_request_project_issue_path(@project, @issue), create_branch_path: project_branches_path(@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/index.html.haml b/app/views/projects/issues/index.html.haml
index e72c94695bc..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")
@@ -27,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_project_issue_path(@project)
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index fbaf88356bf..ec7e87219f5 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -5,40 +5,36 @@
- page_description @issue.description
- page_card_attributes @issue.card_attributes
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'notes'
-
- 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
- if @issue.confidential
- = icon('eye-slash', class: 'is-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.js-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
- - if can_update_issue
- %li= link_to 'Edit', edit_project_issue_path(@project, @issue)
- 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
@@ -50,9 +46,6 @@
%li.divider
%li= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link'
- - if can_update_issue
- = link_to 'Edit', edit_project_issue_path(@project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
-
= render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue
- if can_report_spam
@@ -62,7 +55,8 @@
.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?
@@ -72,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_project_issue_url(@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_project_issue_url(@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.js-issue-note-awards
+ .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?
@@ -89,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/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml
index 43e23bb2200..e779473c239 100644
--- a/app/views/projects/jobs/_sidebar.html.haml
+++ b/app/views/projects/jobs/_sidebar.html.haml
@@ -4,8 +4,10 @@
.sidebar-container
.blocks-container
.block
- %strong
+ %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')
@@ -22,7 +24,7 @@
- elsif @build.has_expiring_artifacts?
%p.build-detail-row
The artifacts will be removed in
- %span.js-artifacts-remove= @build.artifacts_expire_at
+ %span= time_ago_in_words @build.artifacts_expire_at
- if @build.artifacts?
.btn-group.btn-group-justified{ role: :group }
@@ -42,13 +44,14 @@
%h4.title
Trigger
- %p
- %span.build-light-text Token:
- #{@build.trigger_request.trigger.short_token}
+ - 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.reveal-variables Reveal Variables
+ %button.btn.group.btn-group-justified.js-reveal-variables Reveal Variables
%dl.js-build-variables.trigger-build-variables.hide
- @build.trigger_variables.each do |trigger_variable|
@@ -89,7 +92,7 @@
- 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
- = icon('arrow-right')
+ = 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
@@ -98,4 +101,5 @@
- else
= build.id
- if build.retried?
- %i.fa.fa-refresh.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was 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 8604c7d3ea4..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,7 +8,7 @@
.nav-controls
- if can?(current_user, :update_build, @project)
- - if @all_builds.running_or_pending.any?
+ - 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
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index 975c08c06e6..849c273db8c 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -2,7 +2,6 @@
- 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
@@ -55,48 +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.prepend-top-default
- .top-bar.js-top-bar
- .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_project_job_path(@project, @build) }>< Complete Raw
-
- .controllers
- - 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_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
+ .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 @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: project_job_path(@project, @build, format: :json) } }
-
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('common_vue')
- = webpack_bundle_tag('job_details')
diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml
index 84b0b65d1c0..b8ee4305142 100644
--- a/app/views/projects/labels/edit.html.haml
+++ b/app/views/projects/labels/edit.html.haml
@@ -1,6 +1,5 @@
- @no_container = true
- page_title "Edit", @label.name, "Labels"
-= render "shared/mr_head"
%div{ class: container_class }
%h3.page-title
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 10d07ce8e45..80e4dce1a80 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -3,8 +3,6 @@
- hide_class = ''
- can_admin_label = can?(current_user, :admin_label, @project)
-= render "shared/mr_head"
-
- if @labels.exists? || @prioritized_labels.exists?
%div{ class: container_class }
.top-area.adjust
diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml
index 562b6fb8d8c..02f59f30a39 100644
--- a/app/views/projects/labels/new.html.haml
+++ b/app/views/projects/labels/new.html.haml
@@ -1,7 +1,6 @@
- @no_container = true
- breadcrumb_title "Labels"
- page_title "New Label"
-= render "shared/mr_head"
%div{ class: container_class }
%h3.page-title
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
index 11793919ff7..b414518b597 100644
--- a/app/views/projects/merge_requests/_commits.html.haml
+++ b/app/views/projects/merge_requests/_commits.html.haml
@@ -5,4 +5,4 @@
= 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
+ = render "projects/commits/commits", merge_request: @merge_request
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 1e505222887..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 project_merge_requests_path(@project), title: 'Merge Requests' do
- %span
- List
-
- - if project_nav_tab? :labels
- = nav_link(controller: :labels) do
- = link_to project_labels_path(@project), title: 'Labels' do
- %span
- Labels
-
- - if project_nav_tab? :milestones
- = nav_link(controller: :milestones) do
- = link_to project_milestones_path(@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
index 917ec7fdbda..54a661040ea 100644
--- a/app/views/projects/merge_requests/_how_to_merge.html.haml
+++ b/app/views/projects/merge_requests/_how_to_merge.html.haml
@@ -1,6 +1,3 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('how_to_merge')
-
#modal_merge_info.modal
.modal-dialog
.modal-content
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 2b5e8711b0a..f45a000833b 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -30,7 +30,7 @@
%span.project-ref-path
&nbsp;
= link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do
- = icon('code-fork')
+ = sprite_icon('fork', size: 12, css_class: 'fork-sprite')
= merge_request.target_branch
- if merge_request.labels.any?
&nbsp;
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index f3c44c94a5c..22c8b6b513d 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -4,20 +4,22 @@
.alert.alert-danger
%p The source project of this merge request has been removed.
-.clearfix.detail-page-header
- .issuable-header
+.detail-page-header
+ .detail-page-header-body
.issuable-status-box.status-box{ class: status_box_class(@merge_request) }
- = icon(@merge_request.state_icon_name, class: "hidden-sm hidden-md hidden-lg")
+ = 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
- %a.btn.btn-default.pull-right.visible-xs-block.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
- = icon('angle-double-left')
-
.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")
- .issuable-actions.js-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
@@ -25,16 +27,16 @@
.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), class: 'issuable-edit'
+ %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) }
+ %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'
+ = 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 issuable-edit"
+ = 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/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml
index 454bc359b6b..a6e2565a485 100644
--- a/app/views/projects/merge_requests/conflicts.html.haml
+++ b/app/views/projects/merge_requests/conflicts.html.haml
@@ -1,7 +1,5 @@
- 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/mr_title"
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index 454bc359b6b..a6e2565a485 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -1,7 +1,5 @@
- 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/mr_title"
diff --git a/app/views/projects/merge_requests/creations/_diffs.html.haml b/app/views/projects/merge_requests/creations/_diffs.html.haml
index 627fc4e9671..5b70e894b39 100644
--- a/app/views/projects/merge_requests/creations/_diffs.html.haml
+++ b/app/views/projects/merge_requests/creations/_diffs.html.haml
@@ -1 +1,5 @@
-= render "projects/diffs/diffs", diffs: @diffs, environment: @environment, show_whitespace_toggle: false
+- 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_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
index 4b5fa28078a..376ac377562 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -15,7 +15,7 @@
= f.hidden_field :source_project_id
= f.hidden_field :source_branch
= f.hidden_field :target_project_id
- = f.hidden_field :target_branch
+ = 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?
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
index 0d30d6da68f..986ba5ae02d 100644
--- a/app/views/projects/merge_requests/diffs/_diffs.html.haml
+++ b/app/views/projects/merge_requests/diffs/_diffs.html.haml
@@ -1,5 +1,21 @@
-- if @merge_request_diff.collected? || @merge_request_diff.overflow?
- = render 'projects/merge_requests/diffs/versions'
- = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, merge_request: true
-- elsif @merge_request_diff.empty?
- .nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch}
+= 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/_versions.html.haml b/app/views/projects/merge_requests/diffs/_version_controls.html.haml
index 9f7152b9824..1c26f0405d2 100644
--- a/app/views/projects/merge_requests/diffs/_versions.html.haml
+++ b/app/views/projects/merge_requests/diffs/_version_controls.html.haml
@@ -1,4 +1,4 @@
-- if @merge_request_diffs.size > 1
+- if @merge_request_diff && @merge_request_diffs.size > 1
.mr-version-controls
.mr-version-menus-container.content-block
Changes between
@@ -71,27 +71,3 @@
(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 project_compare_path(@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_project_merge_request_path(@project, @merge_request), class: 'btn btn-sm'
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 2c53891a92d..b2c0d9e1cfa 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -4,20 +4,13 @@
- new_merge_request_path = project_new_merge_request_path(merge_project) if merge_project
- page_title "Merge Requests"
-- unless @project.issues_enabled?
- = 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 }
- - if show_auto_devops_callout?(@project)
- = render 'shared/auto_devops_callout'
.top-area
= render 'shared/issuable/nav', type: :merge_requests
.nav-controls
@@ -30,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_merge_request_path
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index d3742f3e4be..9866cc716ee 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -1,14 +1,12 @@
+- @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
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('common_vue')
- = webpack_bundle_tag('diff_notes')
-.merge-request{ 'data-mr-action': "#{j params[:tab].presence || 'show'}", 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) }
+.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 } }
@@ -20,14 +18,11 @@
-# haml-lint:disable InlineJavaScript
:javascript
window.gl = window.gl || {};
- window.gl.mrWidgetData = #{serialize_issuable(@merge_request)}
+ window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget')}
#js-vue-mr-widget.mr-widget
- - content_for :page_specific_javascripts do
- = webpack_bundle_tag 'vue_merge_request_widget'
-
- .content-block.content-block-small.emoji-list-container
+ .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') }
@@ -38,52 +33,61 @@
.nav-links.scrolling-tabs
%ul.merge-request-tabs
%li.notes-tab
- = link_to project_merge_request_path(@project, @merge_request), data: { target: 'div#notes', action: 'show', toggle: 'tab' } do
+ = 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
- = link_to commits_project_merge_request_path(@project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do
+ = tab_link_for @merge_request, :commits do
Commits
%span.badge= @commits_count
- if @pipelines.any?
%li.pipelines-tab
- = link_to pipelines_project_merge_request_path(@project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do
+ = tab_link_for @merge_request, :pipelines do
Pipelines
%span.badge.js-pipelines-mr-count= @pipelines.size
%li.diffs-tab
- = link_to diffs_project_merge_request_path(@project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do
+ = tab_link_for @merge_request, :diffs 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 }" }
- %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"
+
+ - 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
- .issuable-discussion
+ %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
+ #diffs.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked? } }
-# This tab is always loaded via AJAX
.mr-loading-status
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 f3abecdd302..6a7bc4b1888 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -1,8 +1,6 @@
- @no_container = true
- page_title 'Milestones'
-= render "shared/mr_head"
-
%div{ class: container_class }
.top-area
= render 'shared/milestones_filter', counts: milestone_counts(@project.milestones)
@@ -14,6 +12,8 @@
New milestone
.milestones
+ #delete-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 84ffbc0a926..c301f517013 100644
--- a/app/views/projects/milestones/new.html.haml
+++ b/app/views/projects/milestones/new.html.haml
@@ -1,7 +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 1f5f18801ad..de381d489c6 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -3,7 +3,6 @@
- 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
@@ -24,16 +23,30 @@
= 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
+ = link_to promote_project_milestone_path(@milestone.project, @milestone), title: "Promote to Group Milestone", class: 'btn btn-grouped', data: { confirm: "Promoting #{@milestone.title} will make it available for all projects inside #{@project.group.name}. Existing project milestones with the same name will be merged. This action cannot be reversed.", toggle: "tooltip" }, method: :post do
+ Promote
+
- if @milestone.active?
= 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', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped"
- = link_to edit_project_milestone_path(@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 project_milestone_path(@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')
@@ -41,6 +54,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 e29cb277389..4b7be9a223f 100644
--- a/app/views/projects/network/show.html.haml
+++ b/app/views/projects/network/show.html.haml
@@ -1,21 +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 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'
+ = 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 cc41b908946..1d31b58a2cc 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -4,8 +4,7 @@
- page_title 'New Project'
- header_title "Projects", dashboard_projects_path
- visibility_level = params.dig(:project, :visibility_level) || default_project_visibility
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'project_new'
+- active_tab = local_assigns.fetch(:active_tab, 'blank')
.project-edit-container
.project-edit-errors
@@ -13,115 +12,107 @@
.row.prepend-top-default
.col-lg-3.profile-settings-sidebar
%h4.prepend-top-0
- New project
- - if import_sources_enabled?
- %p
- A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), #{link_to 'among other things', help_page_path("user/project/index.md", anchor: "projects-features"), target: '_blank'}.
- %p
- All features are enabled when you create a project, but you can disable the ones you don’t need in the project settings.
+ = _('New project')
+ %p
+ - 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
- = form_for @project, html: { class: 'new_project' } do |f|
- .create-project-options
- .first-column
+ %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
- = f.label :template_project, class: 'label-light' do
- Create from template
- = link_to icon('question-circle'), help_page_path("gitlab-basics/create-project"), target: '_blank', aria: { label: "What’s included in a template?" }, title: "What’s included in a template?", class: 'has-tooltip', data: { placement: 'top'}
%div
= render 'project_templates', f: f
- - if import_sources_enabled?
- .second-column
- .project-import
- .form-group.clearfix
- = f.label :visibility_level, class: 'label-light' do #the label here seems wrong
- 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
- %div
- - if git_import_enabled?
- %button.btn.js-toggle-button.import_git{ type: "button" }
- = icon('git', text: 'Repo by URL')
- - 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')
-
- .row
- .col-lg-12
- .js-toggle-content.hide
- %hr
- = render "shared/import_form", f: f
- %hr
-
- .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
- .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
+ .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 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"
- .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' }
- = 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'
+ -# EE-specific start
+ -# EE-specific end
.save-project-loader.hide
.center
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index de76832331a..5ea653ccad5 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -1,8 +1,9 @@
+- access = note_max_access_for_user(note)
- 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. Handle with care.") }
+ %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 = note_max_access_for_user(note)
- %span.note-role.note-role-access= Gitlab::Access.human_access(access)
+- 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)
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index a85cda407af..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', 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 098b0ef56ef..04e647c0dc6 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -1,5 +1,4 @@
- page_title 'Pages'
-= render "projects/settings/head"
%h3.page_title
Pages
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 857ae00d0ab..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,14 +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
+ .form-group.js-ci-variable-list-section
.col-md-9
%label.label-light
#{ s_('PipelineSchedules|Variables') }
- %ul.js-pipeline-variable-list.pipeline-variable-list
- - @schedule.variables.each do |variable|
- = render 'variable_row', id: variable.id, key: variable.key, value: variable.value
- = render 'variable_row'
+ %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 bd8c38292d6..55d0e8bb7f9 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -3,7 +3,7 @@
%td
= pipeline_schedule.description
%td.branch-name-cell
- = icon('code-fork')
+ = 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
@@ -21,12 +21,15 @@
= 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, pipeline_schedule)
+ - 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/_variable_row.html.haml b/app/views/projects/pipeline_schedules/_variable_row.html.haml
deleted file mode 100644
index 564cb5d1ca9..00000000000
--- a/app/views/projects/pipeline_schedules/_variable_row.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-- id = local_assigns.fetch(:id, nil)
-- key = local_assigns.fetch(:key, "")
-- value = local_assigns.fetch(:value, "")
-%li.js-row.pipeline-variable-row{ data: { is_persisted: "#{!id.nil?}" } }
- .pipeline-variable-row-body
- %input{ type: "hidden", name: "schedule[variables_attributes][][id]", value: id }
- %input.js-destroy-input{ type: "hidden", name: "schedule[variables_attributes][][_destroy]" }
- %input.js-user-input.pipeline-variable-key-input.form-control{ type: "text",
- name: "schedule[variables_attributes][][key]",
- value: key,
- placeholder: s_('PipelineSchedules|Input variable key') }
- %textarea.js-user-input.pipeline-variable-value-input.form-control{ rows: 1,
- name: "schedule[variables_attributes][][value]",
- placeholder: s_('PipelineSchedules|Input variable value') }
- = value
- %button.js-row-remove-button.pipeline-variable-row-remove-button{ 'aria-label': s_('PipelineSchedules|Remove variable row') }
- %i.fa.fa-minus-circle{ 'aria-hidden': "true" }
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index 2b081786b6a..bcb6dddba1a 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -1,14 +1,8 @@
- breadcrumb_title _("Schedules")
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'schedules_index'
-
- @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') } }
.top-area
diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml
deleted file mode 100644
index ee2f236cec4..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_project_pipelines_path(@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 f5149306734..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, 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")
+ .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 ad61f033a1c..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 project_pipeline_path(@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_project_pipeline_path(@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_project_pipeline_path(@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 487ac87186d..a86cb14960a 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,10 +1,6 @@
- @no_container = true
- breadcrumb_title "CI / CD Charts"
- 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'
%div{ class: container_class }
.sub-header-block
diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml
index a5dbd1b1532..c23fe6ff170 100644
--- a/app/views/projects/pipelines/charts/_pipeline_times.haml
+++ b/app/views/projects/pipelines/charts/_pipeline_times.haml
@@ -1,10 +1,8 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('pipelines_times')
-
%div
%p.light
= _("Commit duration in minutes for last 30 commits")
%canvas#build_timesChart{ height: 200 }
+-# 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 7a100843f5e..14b3d47a9c2 100644
--- a/app/views/projects/pipelines/charts/_pipelines.haml
+++ b/app/views/projects/pipelines/charts/_pipelines.haml
@@ -1,14 +1,11 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('pipelines_charts')
-
%h4= _("Pipelines charts")
%p
&nbsp;
- %span.cgreen
+ %span.legend-success
= icon("circle")
= s_("Pipeline|success")
&nbsp;
- %span.cgray
+ %span.legend-all
= icon("circle")
= s_("Pipeline|all")
@@ -29,6 +26,7 @@
= _("Pipelines for last year")
%canvas#yearChart.padded{ height: 250 }
+-# haml-lint:disable InlineJavaScript
%script#pipelinesChartsData{ type: "application/json" }
- chartData = []
- [:week, :month, :year].each do |scope|
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index 4f53efcf791..3e6b3346787 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -1,25 +1,15 @@
- @no_container = true
- page_title "Pipelines"
-= render "projects/pipelines/head"
%div{ 'class' => container_class }
- - if show_auto_devops_callout?(@project)
- = render 'shared/auto_devops_callout'
#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'),
- "new-pipeline-path" => new_project_pipeline_path(@project),
+ "no-pipelines-svg-path" => image_path('illustrations/pipelines_pending.svg'),
"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')
+ "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 4ad37d0e882..877101b05ca 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -20,4 +20,5 @@
= f.submit 'Create pipeline', class: 'btn btn-create', tabindex: 3
= link_to 'Cancel', project_pipelines_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/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 7cc9fe79afd..a7d7c923957 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -2,7 +2,6 @@
- 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
@@ -11,7 +10,3 @@
= render "projects/pipelines/with_tabs", pipeline: @pipeline
.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } }
-
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('common_vue')
- = webpack_bundle_tag('pipelines_details')
diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml
index 21d01242c0e..646c01c0989 100644
--- a/app/views/projects/pipelines_settings/_show.html.haml
+++ b/app/views/projects/pipelines_settings/_show.html.haml
@@ -6,7 +6,6 @@
%h5 Auto DevOps (Beta)
%p
Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.
- This will happen starting with the next event (e.g.: push) that occurs to the project.
= link_to 'Learn more about Auto DevOps', help_page_path('topics/autodevops/index.md')
- message = auto_devops_warning_message(@project)
- if message
@@ -20,30 +19,35 @@
%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 Continious Integration and Delivery.
+ An explicit <code>.gitlab-ci.yml</code> needs to be specified before you can begin using Continuous Integration and Delivery.
+
.radio
- = form.label :enabled_nil do
+ = form.label :enabled_ do
= form.radio_button :enabled, ''
- %strong Instance default (#{current_application_settings.auto_devops_enabled? ? 'enabled' : 'disabled'})
+ %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>.
- %br
%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
+ .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
diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml
index e71d58ec26d..16bcf671c25 100644
--- a/app/views/projects/project_members/_team.html.haml
+++ b/app/views/projects/project_members/_team.html.haml
@@ -1,11 +1,13 @@
+- 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 project_project_members_path(@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" }
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 25153fd0b6f..d81103c3a92 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -17,14 +17,14 @@
%i Owners
.light
- if can?(current_user, :admin_project_member, @project)
- %ul.nav-links.project-member-tabs{ role: 'tablist' }
+ %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.project-member-tab-content
+ .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' }
@@ -37,5 +37,5 @@
- if @group_links.any?
= render 'projects/project_members/groups', group_links: @group_links
- = render 'projects/project_members/team', members: @project_members
+ = 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/_index.html.haml b/app/views/projects/protected_branches/_index.html.haml
index 2f30fe33a97..2b0a502fe4d 100644
--- a/app/views/projects/protected_branches/_index.html.haml
+++ b/app/views/projects/protected_branches/_index.html.haml
@@ -1,6 +1,3 @@
-- 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'
diff --git a/app/views/projects/protected_branches/shared/_dropdown.html.haml b/app/views/projects/protected_branches/shared/_dropdown.html.haml
index 6e9c473494e..74435236808 100644
--- a/app/views/projects/protected_branches/shared/_dropdown.html.haml
+++ b/app/views/projects/protected_branches/shared/_dropdown.html.haml
@@ -10,6 +10,6 @@
%ul.dropdown-footer-list
%li
- %button{ class: "create-new-protected-branch-button js-create-new-protected-branch", title: "New Protected Branch" }
+ %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
index 6a47cbdf724..e662b877fbb 100644
--- a/app/views/projects/protected_branches/shared/_index.html.haml
+++ b/app/views/projects/protected_branches/shared/_index.html.haml
@@ -1,6 +1,6 @@
- expanded = Rails.env.test?
-%section.settings
+%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Protected Branches
@@ -8,7 +8,7 @@
= expanded ? 'Collapse' : 'Expand'
%p
Keep stable branches secure and force developers to use merge requests.
- .settings-content.no-animate{ class: ('expanded' if expanded) }
+ .settings-content
%p
By default, protected branches are designed to:
%ul
@@ -16,7 +16,7 @@
%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"}.
+ %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
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 ea91e8af70e..f53b81cada6 100644
--- a/app/views/projects/protected_tags/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -2,7 +2,7 @@
.create_access_levels-container
= dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-create wide',
- dropdown_class: 'dropdown-menu-selectable',
+ 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' }})
= render 'projects/protected_tags/shared/create_protected_tag'
diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml
index 955220562a6..6b284fda35c 100644
--- a/app/views/projects/protected_tags/_index.html.haml
+++ b/app/views/projects/protected_tags/_index.html.haml
@@ -1,6 +1,3 @@
-- 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'
diff --git a/app/views/projects/protected_tags/shared/_dropdown.html.haml b/app/views/projects/protected_tags/shared/_dropdown.html.haml
index 9b6923210f7..f0d7dcccd36 100644
--- a/app/views/projects/protected_tags/shared/_dropdown.html.haml
+++ b/app/views/projects/protected_tags/shared/_dropdown.html.haml
@@ -10,6 +10,6 @@
%ul.dropdown-footer-list
%li
- %button{ class: "create-new-protected-tag-button js-create-new-protected-tag", title: "New Protected Tag" }
+ %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
index c07bd454ff6..24baf1cfc89 100644
--- a/app/views/projects/protected_tags/shared/_index.html.haml
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -1,6 +1,6 @@
- expanded = Rails.env.test?
-%section.settings
+%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Protected Tags
@@ -8,7 +8,7 @@
= expanded ? 'Collapse' : 'Expand'
%p
Limit access to creating and updating tags.
- .settings-content.no-animate{ class: ('expanded' if expanded) }
+ .settings-content
%p
By default, protected tags are designed to:
%ul
@@ -16,7 +16,7 @@
%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"), class: "underlined-link"}.
+ %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
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 a0535edafc3..00000000000
--- a/app/views/projects/registry/repositories/_image.html.haml
+++ /dev/null
@@ -1,32 +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 project_container_registry_path(@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/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 c786298e341..4d962f9433f 100644
--- a/app/views/projects/releases/edit.html.haml
+++ b/app/views/projects/releases/edit.html.haml
@@ -2,7 +2,6 @@
- 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
diff --git a/app/views/projects/repositories/_feed.html.haml b/app/views/projects/repositories/_feed.html.haml
index 170f9e259df..87895a15239 100644
--- a/app/views/projects/repositories/_feed.html.haml
+++ b/app/views/projects/repositories/_feed.html.haml
@@ -11,7 +11,7 @@
%div
= 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 e660fce652f..49c90869146 100644
--- a/app/views/projects/runners/_form.html.haml
+++ b/app/views/projects/runners/_form.html.haml
@@ -30,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
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index 25d862ab4de..6376496ee1a 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -17,6 +17,10 @@
.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
diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml
index a4e820628f3..4fd4ca355a8 100644
--- a/app/views/projects/runners/_shared_runners.html.haml
+++ b/app/views/projects/runners/_shared_runners.html.haml
@@ -1,15 +1,15 @@
%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_project_runners_path(@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_project_runners_path(@project), class: 'btn btn-success', method: :post do
@@ -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 28ccbf7eb15..f0813e56b71 100644
--- a/app/views/projects/runners/_specific_runners.html.haml
+++ b/app/views/projects/runners/_specific_runners.html.haml
@@ -1,8 +1,7 @@
%h3 Specific Runners
-= render partial: 'ci/runner/how_to_setup_runner',
- locals: { registration_token: @project.runners_token,
- type: 'specific' }
+= 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/show.html.haml b/app/views/projects/runners/show.html.haml
index dfab04aa1fb..4e57f5f844d 100644
--- a/app/views/projects/runners/show.html.haml
+++ b/app/views/projects/runners/show.html.haml
@@ -41,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 b842fd57cf3..17e804d682b 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
@@ -9,21 +6,18 @@
%p= @service.description
.col-lg-9
- = form_for(@service, as: :service, url: project_service_path(@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_project_service_path(@project, @service) } }) 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
- if @service.editable?
.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
+ = service_save_button(@service)
&nbsp;
- if @service.valid? && @service.activated?
- unless @service.can_test?
- disabled_class = 'disabled'
- disabled_title = @service.disabled_title
- = link_to 'Cancel', project_settings_integrations_path(@project), class: 'btn btn-cancel'
+ = 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/edit.html.haml b/app/views/projects/services/edit.html.haml
index 3e2a24a4c32..df1fd583670 100644
--- a/app/views/projects/services/edit.html.haml
+++ b/app/views/projects/services/edit.html.haml
@@ -2,5 +2,6 @@
- page_title @service.title, "Services"
- add_to_breadcrumbs("Settings", edit_project_path(@project))
-= render "projects/settings/head"
+= 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 5dbcbf7eba6..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')
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 d8e11500964..6dc2b85fd32 100644
--- a/app/views/projects/services/prometheus/_show.html.haml
+++ b/app/views/projects/services/prometheus/_show.html.haml
@@ -1,45 +1,39 @@
-- 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', help_page_path('user/project/integrations/prometheus')
+ = 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" => "#{project_prometheus_active_metrics_path(@project, :json)}" } }
+ .panel.panel-default.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(@project, :json) } }
.panel-heading
%h3.panel-title
- Monitored
+ = s_('PrometheusService|Monitored')
%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...
+ %p
+ = s_('PrometheusService|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
+ %p
+ = s_('PrometheusService|No metrics are being monitored. To start monitoring, deploy to an environment.')
+ = link_to s_('PrometheusService|View environments'), project_environments_path(@project), class: 'btn btn-success'
%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', help_page_path('user/project/integrations/prometheus', anchor: 'metrics-and-labels')
+ = 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 c31c95608c6..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
diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml
deleted file mode 100644
index 7d24c6a9122..00000000000
--- a/app/views/projects/settings/_head.html.haml
+++ /dev/null
@@ -1,30 +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
- - 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: 'Pipelines' do
- %span
- Pipelines
- - if @project.pages_available?
- = nav_link(controller: :pages) do
- = link_to project_pages_path(@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 47c056d097a..756f31f91d9 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -2,11 +2,9 @@
- page_title "CI / CD Settings"
- page_title "CI / CD"
-= render "projects/settings/head"
-
- expanded = Rails.env.test?
-%section.settings#js-general-pipeline-settings
+%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
General pipelines settings
@@ -14,10 +12,10 @@
= expanded ? 'Collapse' : 'Expand'
%p
Update your CI/CD configuration, like job timeout or Auto DevOps.
- .settings-content.no-animate{ class: ('expanded' if expanded) }
+ .settings-content
= render 'projects/pipelines_settings/show'
-%section.settings
+%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Runners settings
@@ -25,22 +23,22 @@
= expanded ? 'Collapse' : 'Expand'
%p
Register and see your runners for this project.
- .settings-content.no-animate{ class: ('expanded' if expanded) }
+ .settings-content
= render 'projects/runners/index'
-%section.settings
+%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'
+ = _('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
+ %p.append-bottom-0
= render "ci/variables/content"
- .settings-content.no-animate{ class: ('expanded' if expanded) }
- = render 'ci/variables/index'
+ .settings-content
+ = render 'ci/variables/index', save_endpoint: project_variables_path(@project)
-%section.settings
+%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Pipeline triggers
@@ -50,5 +48,5 @@
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.no-animate{ class: ('expanded' if expanded) }
+ .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 82516cb4bcf..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
- - ProjectHook::TRIGGERS.each_value do |event|
+ - 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_project_hook_path(@project, hook), class: 'btn btn-sm'
- = render 'shared/web_hooks/test_button', triggers: ProjectHook::TRIGGERS, hook: hook, button_class: '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 933daa7f549..2f1a548e119 100644
--- a/app/views/projects/settings/integrations/show.html.haml
+++ b/app/views/projects/settings/integrations/show.html.haml
@@ -1,6 +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 6d4af72b8ea..6bef4d19434 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -2,12 +2,6 @@
- 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
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index d8f5114f4b5..fa281327eb7 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -1,77 +1,23 @@
- @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, project_path(@project, rss_url_options), title: "#{@project.name} activity")
= render partial: 'flash_messages', locals: { project: @project }
-= render "projects/head"
-= render "projects/last_push"
+%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
+ = 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_tree_path(@project) do
- #{_('Files')} (#{storage_counter(@project.statistics.total_repository_size)})
- %li
- = link_to project_commits_path(@project, current_ref) do
- #{n_('Commit', 'Commits', @project.statistics.commit_count)} (#{number_with_delimiter(@project.statistics.commit_count)})
- %li
- = link_to project_branches_path(@project) do
- #{n_('Branch', 'Branches', @repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)})
- %li
- = link_to project_tags_path(@project) do
- #{n_('Tag', 'Tags', @repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)})
-
- - if @repository.readme
- %li
- = link_to _('Readme'),
- default_project_view != 'readme' ? readme_path(@project) : '#readme'
-
- - if @repository.changelog
- %li
- = link_to _('Changelog'), changelog_path(@project)
-
- - if @repository.license_blob
- %li
- = link_to license_short_name(@project), license_path(@project)
-
- - 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)
+ = 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 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, ("limit-container-width" unless fluid_layout)] }
- if @project.archived?
@@ -80,7 +26,7 @@
= 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'
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index fda068f08c2..7062c5b765e 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -1,5 +1,5 @@
- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout
-- add_to_breadcrumbs "Snippets", dashboard_snippets_path
+- add_to_breadcrumbs "Snippets", project_snippets_path(@project)
- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets"
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 468ab922542..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 project_tag_path(@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_project_tag_release_path(@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 project_tag_path(@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 a6fe02fcae0..10415d011d6 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -1,17 +1,15 @@
- @no_container = true
- @sort ||= sort_value_recently_updated
-- page_title "Tags"
-- add_to_breadcrumbs("Repository", project_tree_path(@project))
-= 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'} }
@@ -20,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_project_tag_path(@project), class: 'btn btn-create new-tag-btn' do
- New tag
+ = s_('TagsPage|New tag')
.tags
- if @tags.any?
@@ -37,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 521b4d927bc..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,37 +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', project_tags_path(@project), class: 'btn btn-cancel'
+ = 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 5d6eb4f4026..dfe2c37ed8e 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -1,8 +1,7 @@
- @no_container = true
-- add_to_breadcrumbs "Tags", project_tags_path(@project)
+- add_to_breadcrumbs s_('TagsPage|Tags'), project_tags_path(@project)
- breadcrumb_title @tag.name
-- page_title @tag.name, "Tags"
-= render "projects/commits/head"
+- page_title @tag.name, s_('TagsPage|Tags')
%div{ class: container_class }
.top-area.multi-line
@@ -13,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_project_tag_release_path(@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 project_tree_path(@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 project_commits_path(@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 project_tag_path(@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?
@@ -44,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/tree/_blob_item.html.haml b/app/views/projects/tree/_blob_item.html.haml
index fd8175e1e01..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 project_blob_path(@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/_old_tree_content.html.haml b/app/views/projects/tree/_old_tree_content.html.haml
deleted file mode 100644
index 820b947804e..00000000000
--- a/app/views/projects/tree/_old_tree_content.html.haml
+++ /dev/null
@@ -1,24 +0,0 @@
-.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path }
- .table-holder
- %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" }
- %thead
- %tr
- %th= s_('ProjectFileTree|Name')
- %th.hidden-xs
- .pull-left= _('Last commit')
- %th.text-right= _('Last Update')
- - if @path.present?
- %tr.tree-item
- %td.tree-item-file-name
- = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10'
- %td
- %td.hidden-xs
-
- = render_tree(tree)
-
- - if tree.readme
- = 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: project_create_blob_path(@project, @id), method: :post
- = render 'projects/blob/new_dir'
diff --git a/app/views/projects/tree/_old_tree_header.html.haml b/app/views/projects/tree/_old_tree_header.html.haml
deleted file mode 100644
index 13705ca303b..00000000000
--- a/app/views/projects/tree/_old_tree_header.html.haml
+++ /dev/null
@@ -1,70 +0,0 @@
-%ul.breadcrumb.repo-breadcrumb
- %li
- = link_to project_tree_path(@project, @ref) do
- = @project.path
- - path_breadcrumbs do |title, path|
- %li
- = 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')
- .add-to-tree-dropdown
- %ul.dropdown-menu
- - if can_edit_tree?
- %li
- = link_to project_new_blob_path(@project, @id) do
- = icon('pencil fw')
- #{ _('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: project_new_blob_path(@project, @id),
- 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)
- = 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 = 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 = 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
- = link_to new_project_branch_path(@project) do
- = icon('code-fork fw')
- #{ _('New branch') }
- %li
- = link_to new_project_tag_path(@project) do
- = icon('tags fw')
- #{ _('New tag') }
diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml
index a4bdd67209d..6ea78851b8d 100644
--- a/app/views/projects/tree/_tree_content.html.haml
+++ b/app/views/projects/tree/_tree_content.html.haml
@@ -1,5 +1,24 @@
-- content_url = local_assigns.fetch(:content_url, nil)
-- if show_new_repo?
- = render 'shared/repo/repo', project: @project, content_url: content_url
-- else
- = render 'projects/tree/old_tree_content', tree: tree
+.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path }
+ .table-holder
+ %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" }
+ %thead
+ %tr
+ %th= s_('ProjectFileTree|Name')
+ %th.hidden-xs
+ .pull-left= _('Last commit')
+ %th.text-right= _('Last update')
+ - if @path.present?
+ %tr.tree-item
+ %td.tree-item-file-name
+ = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10'
+ %td
+ %td.hidden-xs
+
+ = render_tree(tree)
+
+ - if tree.readme
+ = 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: project_create_blob_path(@project, @id), method: :post
+ = render 'projects/blob/new_dir'
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 853e2a6e7ec..06bce52e709 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -1,19 +1,78 @@
.tree-ref-container
.tree-ref-holder
- = render 'shared/ref_switcher', destination: 'tree', path: @path
- - if show_new_repo?
- .tree-ref-target-holder.js-tree-ref-target-holder
- = icon('long-arrow-right', title: 'to target branch')
- = render 'shared/target_switcher', destination: 'tree', path: @path
+ = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true
- - unless show_new_repo?
- = render 'projects/tree/old_tree_header'
+ - 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 project_tree_path(@project, @ref) do
+ = @project.path
+ - path_breadcrumbs do |title, path|
+ %li
+ = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path))
+
+ - if current_user
+ %li
+ %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 project_new_blob_path(@project, @id) do
+ #{ _('New file') }
+ %li
+ = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
+ #{ _('Upload file') }
+ %li
+ = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
+ #{ _('New directory') }
+ - elsif can?(current_user, :fork_project, @project)
+ %li
+ - 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 = project_forks_path(@project, namespace_key: current_user.namespace.id,
+ continue: continue_params)
+ = link_to fork_path, method: :post do
+ #{ _('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 = project_forks_path(@project, namespace_key: current_user.namespace.id,
+ continue: continue_params)
+ = link_to fork_path, method: :post do
+ #{ _('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 = project_forks_path(@project, namespace_key: current_user.namespace.id,
+ continue: continue_params)
+ = link_to fork_path, method: :post do
+ #{ _('New directory') }
+
+ %li.divider
+ %li.dropdown-header
+ #{ _('This repository') }
+ %li
+ = link_to new_project_branch_path(@project) do
+ #{ _('New branch') }
+ %li
+ = link_to new_project_tag_path(@project) do
+ #{ _('New tag') }
.tree-controls
- - if show_new_repo?
- = render 'shared/repo/editable_mode'
- - else
- = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
+ = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
= render 'projects/find_file_link'
diff --git a/app/views/projects/tree/_tree_item.html.haml b/app/views/projects/tree/_tree_item.html.haml
index 56197382a70..af3816fc9f4 100644
--- a/app/views/projects/tree/_tree_item.html.haml
+++ b/app/views/projects/tree/_tree_item.html.haml
@@ -2,8 +2,8 @@
%td.tree-item-file-name
= tree_icon(type, tree_item.mode, tree_item.name)
- path = flatten_tree(@path, tree_item)
- = link_to project_tree_path(@project, tree_join(@id || @commit.id, path)), title: path do
- %span.str-truncated= path
+ = 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 d84a1fd7ee1..3b4057e56d0 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -6,15 +6,6 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
-- if show_new_repo?
- - content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'repo'
-
-= render "projects/commits/head"
-
-%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- - if show_auto_devops_callout?(@project)
- = render 'shared/auto_devops_callout'
+%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/update.js.haml b/app/views/projects/update.js.haml
index 2c05ebe52ae..1a353953838 100644
--- a/app/views/projects/update.js.haml
+++ b/app/views/projects/update.js.haml
@@ -6,4 +6,4 @@
$(".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/show.html.haml b/app/views/projects/variables/show.html.haml
deleted file mode 100644
index df533952b76..00000000000
--- a/app/views/projects/variables/show.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'ci/variables/show'
diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml
index e5a1fccf9ba..d285251d06f 100644
--- a/app/views/projects/wikis/_form.html.haml
+++ b/app/views/projects/wikis/_form.html.haml
@@ -1,4 +1,5 @@
-- 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)
@@ -8,17 +9,23 @@
.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
+ .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: project_wiki_preview_markdown_path(@project, @page.slug) } do
- = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: 'Write your content or drag files here...'
+ = 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
@@ -26,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'
@@ -39,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", project_wiki_path(@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", project_wiki_path(@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 3bbd8042c3a..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
+ = s_("Wiki|New page")
= link_to project_wiki_history_path(@project, @page), class: "btn" do
- Page history
+ = s_("Wiki|Page history")
- if can?(current_user, :create_wiki, @project) && @page.latest?
= link_to project_wiki_edit_path(@project, @page), class: "btn js-wiki-edit" do
- Edit
+ = _("Edit")
diff --git a/app/views/projects/wikis/_new.html.haml b/app/views/projects/wikis/_new.html.haml
index 13dd8461433..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' => project_wikis_path(@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 7c2f562d422..efa16d38f84 100644
--- a/app/views/projects/wikis/_pages_wiki_page.html.haml
+++ b/app/views/projects/wikis/_pages_wiki_page.html.haml
@@ -2,4 +2,4 @@
= 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 f7283ae4739..2c7551c6f8c 100644
--- a/app/views/projects/wikis/_sidebar.html.haml
+++ b/app/views/projects/wikis/_sidebar.html.haml
@@ -6,9 +6,8 @@
- git_access_url = project_wikis_git_access_path(@project)
= link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '' do
- = succeed '&nbsp;' do
- = icon('cloud-download')
- Clone repository
+ = icon('cloud-download', class: 'append-right-5')
+ %span= _("Clone repository")
.blocks-container
.block.block-first
@@ -17,6 +16,6 @@
.block
= link_to project_wikis_pages_path(@project), class: 'btn btn-block' do
- More Pages
+ = s_("Wiki|More Pages")
= render 'projects/wikis/new'
diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml
index 8fd60216536..9d3d4072027 100644
--- a/app/views/projects/wikis/edit.html.haml
+++ b/app/views/projects/wikis/edit.html.haml
@@ -1,11 +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")
-- if @conflict
- .alert.alert-danger
- Someone edited the page the same time you did. Please check out
- = link_to "the page", project_wiki_path(@project, @page), target: "_blank"
- and make sure your changes will not unintentionally remove theirs.
+= wiki_page_errors(@error)
.wiki-page-header.has-sidebar-toggle
%button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
@@ -20,20 +16,20 @@
%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 project_wiki_history_path(@project, @page), class: "btn" do
- Page history
+ = s_("Wiki|Page history")
- if can?(current_user, :admin_wiki, @project)
- = link_to project_wiki_path(@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 e740fb93ea4..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
+ = _("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 306feeff259..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" }
@@ -9,19 +9,19 @@
= 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 d533c611a38..aeef64fd7eb 100644
--- a/app/views/projects/wikis/pages.html.haml
+++ b/app/views/projects/wikis/pages.html.haml
@@ -1,19 +1,19 @@
- @no_container = true
- add_to_breadcrumbs "Wiki", get_project_wiki_path(@project)
-- breadcrumb_title "Pages"
-- page_title "Pages", "Wiki"
+- 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 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 b066a812ec8..b3b83cee81a 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/projects/wikis/show.html.haml
@@ -1,8 +1,8 @@
- @content_class = "limit-container-width limit-container-width-sm" unless fluid_layout
- 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)
+- 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" }
@@ -11,18 +11,18 @@
.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", project_wiki_path(@project, @page)} or browse the #{link_to "history", project_wiki_history_path(@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/_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/_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 aef825691e0..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, project_path(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/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
index 2f09c2fec87..934d65e8b42 100644
--- a/app/views/shared/_auto_devops_callout.html.haml
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -1,15 +1,16 @@
-.user-callout{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
- .bordered-box.landing.content-block
- %button.btn.btn-default.close.js-close-callout{ type: 'button',
- 'aria-label' => 'Dismiss Auto DevOps box' }
- = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
- .svg-container
- = custom_icon('icon_autodevops')
- .user-callout-copy
- %h4= _('Auto DevOps (Beta)')
- %p= _('Auto DevOps can be activated for this project. It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
- %p
- #{s_('AutoDevOps|Learn more in the')}
- = link_to _('Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer'
+.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')
- = link_to _('Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn btn-primary js-close-callout'
+ .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 3d9c90c38fe..687cd4d1532 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -3,11 +3,11 @@
.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')
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/profiles/gpg_keys/_email_with_badge.html.haml b/app/views/shared/_email_with_badge.html.haml
index 5f7844584e1..b7bbc109238 100644
--- a/app/views/profiles/gpg_keys/_email_with_badge.html.haml
+++ b/app/views/shared/_email_with_badge.html.haml
@@ -2,7 +2,7 @@
- css_classes << (verified ? 'verified': 'unverified')
- text = verified ? 'Verified' : 'Unverified'
-.gpg-email-badge
- .gpg-email-badge-email= email
+.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 151aad306a0..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'), 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')
+.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 d0b9e891b82..cb21f90696f 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -1,5 +1,3 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('group')
- parent = @group.parent
- group_path = root_url
- group_path << parent.full_path + '/' if parent
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index 233d8c95eda..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: 'label-light' do
- %span Git repository URL
+ %span
+ = _('Git repository URL')
- = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git'
+ = 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>.
+ = _('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>.
+ = _('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
- The import will time out after 15 minutes. For repositories that take longer, use a clone/push combination.
+ = import_will_timeout_message(ci_cd_only)
%li
- To migrate an SVN repository, check out #{link_to "this document", help_page_path('user/project/import/svn')}.
+ = import_svn_message(ci_cd_only)
+
+-# EE-specific start
+-# EE-specific end
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 23a418ad640..8847d11f623 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -2,22 +2,26 @@
- 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)
@@ -35,14 +39,26 @@
%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)
+ = link_to promote_project_label_path(label.project, label), title: "Promote to Group Label", class: 'btn btn-transparent btn-action', data: {confirm: "Promoting #{label.title} will make it available for all projects inside #{label.project.group.name}. Existing project labels with the same name will be merged. This action cannot be reversed.", toggle: "tooltip"}, method: :post do
+ %span.sr-only Promote to Group
+ = 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)
@@ -65,14 +81,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_project_label_path(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')
+= 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 7f58298c60f..bd4f191502e 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -1,3 +1,7 @@
+- 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
@@ -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/_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 dff847159d3..901a177323b 100644
--- a/app/views/shared/_mini_pipeline_graph.html.haml
+++ b/app/views/shared/_mini_pipeline_graph.html.haml
@@ -7,7 +7,7 @@
.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_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } }
- = custom_icon(icon_status)
+ = 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 e7355ae2eea..00000000000
--- a/app/views/shared/_mr_head.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- if @project.issues_enabled?
- = 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/_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 e415ec64c38..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|
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 7ad743b3b81..4c8c92d722a 100644
--- a/app/views/shared/_ref_switcher.html.haml
+++ b/app/views/shared/_ref_switcher.html.haml
@@ -1,3 +1,5 @@
+- show_create = local_assigns.fetch(:show_create, false)
+
- dropdown_toggle_text = @ref || @project.default_branch
= form_tag switch_project_refs_path(@project), method: :get, class: "project-refs-form" do
= hidden_field_tag :destination, destination
@@ -6,9 +8,10 @@
- @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_project_path(@project), field_name: 'ref', submit_form_on_click: true, visit: 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 7ca14ac93cc..61b39afb5d4 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -11,7 +11,7 @@
.form-group
= form.label :active, "Active", class: "control-label"
.col-sm-10
- = form.check_box :active
+ = form.check_box :active, disabled: disable_fields_service?(@service)
- if @service.supported_events.present?
.form-group
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
index eb5ddb0dde4..2530db986e0 100644
--- a/app/views/shared/_sidebar_toggle_button.html.haml
+++ b/app/views/shared/_sidebar_toggle_button.html.haml
@@ -1,8 +1,8 @@
%a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" }
- = icon('angle-double-left')
- = icon('angle-double-right')
+ = 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
- = icon ('times')
+ = 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 785a500e44e..7ff5e679f17 100644
--- a/app/views/shared/_sort_dropdown.html.haml
+++ b/app/views/shared/_sort_dropdown.html.haml
@@ -1,36 +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 viewing_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/_target_switcher.html.haml b/app/views/shared/_target_switcher.html.haml
deleted file mode 100644
index bbe9692a7da..00000000000
--- a/app/views/shared/_target_switcher.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-- dropdown_toggle_text = @ref || @project.default_branch
-= form_tag nil, method: :get, class: "project-refs-form project-refs-target-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_project_path(@project, find: ['branches']), field_name: 'ref', input_field_name: 'new-branch', submit_form_on_click: true, visit: false }, { 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 _("Create a new branch")
- = dropdown_input _("Create a new branch")
- = dropdown_title _("Select existing branch"), options: {close: false}
- = dropdown_filter _("Search branches and tags")
- = dropdown_content
- = dropdown_loading
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index 2e5e7911981..44b09545a61 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -1,3 +1,5 @@
+- board = local_assigns.fetch(:board, nil)
+- group = local_assigns.fetch(:group, false)
- @no_breadcrumb_container = true
- @no_container = true
- @content_class = "issue-boards-content"
@@ -5,20 +7,16 @@
- page_title "Boards"
- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'filtered_search'
- = webpack_bundle_tag 'boards'
+ -# 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
-= render "projects/issues/head"
+#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
-.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-list
.boards-app-loading.text-center{ "v-if" => "loading" }
= icon("spinner spin")
%board{ "v-cloak" => true,
@@ -30,11 +28,12 @@
":root-path" => "rootPath",
":board-id" => "boardId",
":key" => "_uid" }
- = render "shared/boards/components/sidebar"
- %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.try(: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
index c687e66fd43..2e9ad380012 100644
--- a/app/views/shared/boards/components/_board.html.haml
+++ b/app/views/shared/boards/components/_board.html.haml
@@ -42,6 +42,7 @@
":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
index b3f73e96b81..8e5e32e9f16 100644
--- a/app/views/shared/boards/components/_sidebar.html.haml
+++ b/app/views/shared/boards/components/_sidebar.html.haml
@@ -1,5 +1,4 @@
-%board-sidebar{ "inline-template" => true,
- ":current-user" => "#{current_user ? current_user.to_json(only: [:username, :id, :name], methods: [:avatar_url]) : {}}" }
+%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
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
index 1f540bdaf93..dfc0f9be321 100644
--- a/app/views/shared/boards/components/sidebar/_labels.html.haml
+++ b/app/views/shared/boards/components/sidebar/_labels.html.haml
@@ -25,7 +25,7 @@
show_any: "true",
project_id: @project&.try(:id),
labels: labels_filter_path(false),
- namespace_path: @project.try(:namespace).try(:full_path),
+ namespace_path: @namespace_path,
project_path: @project.try(:path) },
":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
%span.dropdown-toggle-text
diff --git a/app/views/shared/boards/components/sidebar/_notifications.html.haml b/app/views/shared/boards/components/sidebar/_notifications.html.haml
index 9b989c23cab..333dd1a00b4 100644
--- a/app/views/shared/boards/components/sidebar/_notifications.html.haml
+++ b/app/views/shared/boards/components/sidebar/_notifications.html.haml
@@ -1,7 +1,5 @@
- if current_user
- .block.light.subscription{ ":data-url" => "'#{build_issue_link_base}/' + issue.iid + '/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'}}
+ .block.subscriptions
+ %subscriptions{ ":loading" => "issue.isFetching && issue.isFetching.subscriptions",
+ ":subscribed" => "issue.subscribed",
+ ":id" => "issue.id" }
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 de26fa8bbf3..62437f5fc9d 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -6,18 +6,22 @@
.col-xs-12
.svg-content
= image_tag 'illustrations/issues.svg'
- .col-xs-12.text-center
+ .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', type: :issues
- - else
- = 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 a65634dce53..04db9de3606 100644
--- a/app/views/shared/empty_states/_labels.html.haml
+++ b/app/views/shared/empty_states/_labels.html.haml
@@ -2,10 +2,10 @@
.col-xs-12
.svg-content
= image_tag 'illustrations/labels.svg'
- .col-xs-12.text-center
+ .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_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'
+ = 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 67f906903e9..2edf3557df4 100644
--- a/app/views/shared/empty_states/_merge_requests.html.haml
+++ b/app/views/shared/empty_states/_merge_requests.html.haml
@@ -6,17 +6,18 @@
.col-xs-12
.svg-content
= image_tag 'illustrations/merge_requests.svg'
- .col-xs-12.text-center
+ .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', type: :merge_requests
- - 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/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/_add_new_project.svg b/app/views/shared/icons/_add_new_project.svg
index 3c1e15453df..cf8762944ca 100644
--- a/app/views/shared/icons/_add_new_project.svg
+++ b/app/views/shared/icons/_add_new_project.svg
@@ -1 +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="#FEE1D3" fill-rule="nonzero" d="M30 24a4 4 0 0 0-4 4v22a4 4 0 0 0 4 4h18a4 4 0 0 0 4-4V28a4 4 0 0 0-4-4H30zm0-4h18a8 8 0 0 1 8 8v22a8 8 0 0 1-8 8H30a8 8 0 0 1-8-8V28a8 8 0 0 1 8-8z"/><path fill="#FC6D26" d="M33 30h8a2 2 0 1 1 0 4h-8a2 2 0 1 1 0-4zm0 7h12a2 2 0 1 1 0 4H33a2 2 0 1 1 0-4zm0 7h12a2 2 0 1 1 0 4H33a2 2 0 1 1 0-4z"/></g></svg> \ No newline at end of file
+<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/_express.svg b/app/views/shared/icons/_express.svg
index f2c94319f19..a51e81e5568 100644
--- a/app/views/shared/icons/_express.svg
+++ b/app/views/shared/icons/_express.svg
@@ -1,6 +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" transform="translate(-3)">
- <rect width="32" height="32"/>
- <path fill="#353535" d="M4.19170065,16.2667139 C4.23142421,18.3323387 4.47969269,20.2489714 4.93651356,22.0166696 C5.39333443,23.7843677 6.09841693,25.3236323 7.05178222,26.6345096 C8.00514751,27.9453869 9.23655921,28.9781838 10.7460543,29.7329313 C12.2555493,30.4876788 14.1026668,30.8650469 16.2874623,30.8650469 C19.5050701,30.8650469 22.1764391,30.0209341 24.3016492,28.3326831 C26.4268593,26.644432 27.7476477,24.1120935 28.2640539,20.7355914 L29.4557545,20.7355914 C29.0187954,24.3107112 27.6086304,27.0813875 25.2252172,29.0477034 C22.841804,31.0140194 19.9023051,31.9971626 16.4066324,31.9971626 C14.0232191,32.0368861 11.9874175,31.659518 10.2991665,30.8650469 C8.61091547,30.0705759 7.23054269,28.9484023 6.15800673,27.4984926 C5.08547078,26.0485829 4.29101162,24.3404957 3.77460543,22.3741798 C3.25819923,20.4078639 3,18.2926164 3,16.0283738 C3,13.4860664 3.3773681,11.2218578 4.13211562,9.23568007 C4.88686314,7.24950238 5.87993709,5.57120741 7.11136726,4.20074481 C8.34279742,2.8302822 9.77282391,1.78755456 11.4014896,1.07253059 C13.0301553,0.357506621 14.6985195,0 16.4066324,0 C18.7900456,0 20.8457087,0.456814016 22.5736832,1.37045575 C24.3016578,2.28409749 25.7118228,3.4956477 26.8042206,5.00514275 C27.8966183,6.51463779 28.6910775,8.24258646 29.1876219,10.1890406 C29.6841663,12.1354947 29.8927118,14.1613656 29.8132647,16.2667139 L4.19170065,16.2667139 Z M28.6215641,15.0750133 C28.6215641,13.2080062 28.3633648,11.4304039 27.8469586,9.74215285 C27.3305524,8.05390181 26.5658855,6.57422163 25.5529349,5.30306791 C24.5399843,4.03191419 23.2787803,3.0289095 21.7692853,2.29402376 C20.2597903,1.55913801 18.5119801,1.19170065 16.5258024,1.19170065 C14.8574132,1.19170065 13.2982871,1.50948432 11.8483774,2.14506118 C10.3984676,2.78063804 9.12733299,3.70419681 8.03493526,4.9157652 C6.94253754,6.12733359 6.05870172,7.58715229 5.38340131,9.2952651 C4.70810089,11.0033779 4.31087132,12.9299414 4.19170065,15.0750133 L28.6215641,15.0750133 Z"/>
- </g>
-</svg>
+<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/_icon_autodevops.svg b/app/views/shared/icons/_icon_autodevops.svg
index 807ff27bb67..7e47c084bde 100644
--- a/app/views/shared/icons/_icon_autodevops.svg
+++ b/app/views/shared/icons/_icon_autodevops.svg
@@ -1,4 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="189" height="179" viewBox="0 0 189 179">
+<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)"/>
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_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_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_notfound_borderless.svg b/app/views/shared/icons/_icon_status_notfound_borderless.svg
deleted file mode 100644
index e58bd264ef8..00000000000
--- a/app/views/shared/icons/_icon_status_notfound_borderless.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"><path d="M12.822 11.29c.816-.581 1.421-1.348 1.683-2.322.603-2.243-.973-4.553-3.53-4.553-1.15 0-2.085.41-2.775 1.089-.42.413-.672.835-.8 1.167a1.179 1.179 0 0 0 2.2.847c.016-.043.1-.184.252-.334.264-.259.613-.412 1.123-.412.938 0 1.47.78 1.254 1.584-.105.39-.37.726-.773 1.012a3.25 3.25 0 0 1-.945.47 1.179 1.179 0 0 0-.874 1.138v2.234a1.179 1.179 0 1 0 2.358 0V11.78a5.9 5.9 0 0 0 .827-.492z" fill-rule="nonzero"/><ellipse cx="10.825" cy="16.711" rx="1.275" ry="1.322"/></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_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/_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/_rails.svg b/app/views/shared/icons/_rails.svg
index 0bb09a705df..852bd183cc7 100644
--- a/app/views/shared/icons/_rails.svg
+++ b/app/views/shared/icons/_rails.svg
@@ -1,6 +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" transform="translate(0 -6)">
- <rect width="32" height="32"/>
- <path fill="#C00" fill-rule="nonzero" d="M0.984615385,25.636044 C0.984615385,25.636044 1.40659341,21.4725275 4.36043956,16.5494505 C7.31428571,11.6263736 12.3498901,7.8989011 16.4430769,7.53318681 C24.5872527,6.71736264 31.9015385,14.0175824 31.9015385,14.0175824 C31.9015385,14.0175824 31.6624176,14.1863736 31.4092308,14.3973626 C23.4197802,8.48967033 18.5389011,11.2747253 17.0057143,12.0202198 C9.97274725,15.9446154 12.0967033,25.636044 12.0967033,25.636044 L0.984615385,25.636044 Z M24.1371429,8.32087912 C23.687033,8.13802198 23.2369231,7.96923077 22.7727473,7.81450549 L22.829011,6.88615385 C23.7151648,7.13934066 24.0668132,7.30813187 24.1934066,7.37846154 L24.1371429,8.32087912 Z M22.8008791,11.3028571 C23.250989,11.330989 23.7151648,11.3872527 24.1934066,11.4857143 L24.1371429,12.3578022 C23.672967,12.2593407 23.2087912,12.2030769 22.7446154,12.189011 L22.8008791,11.3028571 Z M17.5964835,6.91428571 C17.1885714,6.91428571 16.7806593,6.92835165 16.3727473,6.97054945 L16.1054945,6.14065934 C16.5696703,6.0843956 17.0197802,6.05626374 17.4558242,6.05626374 L17.7371429,6.91428571 C17.6949451,6.91428571 17.6386813,6.91428571 17.5964835,6.91428571 Z M18.2716484,12.0905495 C18.6232967,11.9358242 19.0312088,11.7810989 19.5094505,11.6404396 L19.8189011,12.5687912 C19.410989,12.6953846 19.0030769,12.8641758 18.5951648,13.0610989 L18.2716484,12.0905495 Z M11.8857143,8.39120879 C11.52,8.57406593 11.1683516,8.78505495 10.8026374,9.01010989 L10.1556044,8.02549451 C10.5353846,7.80043956 10.9010989,7.60351648 11.2527473,7.42065934 L11.8857143,8.39120879 Z M14.7692308,14.7208791 C15.0224176,14.3973626 15.3178022,14.0738462 15.6413187,13.7784615 L16.2742857,14.7349451 C15.9648352,15.0584615 15.6835165,15.381978 15.4443956,15.7336264 L14.7692308,14.7208791 Z M12.7296703,19.2501099 C12.8421978,18.7437363 12.9687912,18.2232967 13.1516484,17.7028571 L14.1643956,18.5046154 C14.0237363,19.0531868 13.9252747,19.6017582 13.869011,20.1503297 L12.7296703,19.2501099 Z M6.56879121,12.5687912 C6.23120879,12.9204396 5.90769231,13.3002198 5.61230769,13.68 L4.52923077,12.7516484 C4.85274725,12.4 5.2043956,12.0483516 5.57010989,11.6967033 L6.56879121,12.5687912 Z M2.32087912,18.8562637 C2.09582418,19.3767033 1.80043956,20.0659341 1.61758242,20.5441758 L0,19.9534066 C0.140659341,19.5736264 0.436043956,18.8703297 0.703296703,18.2654945 L2.32087912,18.8562637 Z M12.5186813,22.8228571 L14.0378022,23.3714286 C14.1221978,24.0325275 14.2487912,24.6514286 14.3753846,25.2 L12.6874725,24.5951648 C12.6171429,24.1731868 12.5468132,23.5683516 12.5186813,22.8228571 Z"/>
- </g>
-</svg>
+<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/_spring.svg b/app/views/shared/icons/_spring.svg
index 508349aa456..ccf18749029 100644
--- a/app/views/shared/icons/_spring.svg
+++ b/app/views/shared/icons/_spring.svg
@@ -1,6 +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">
- <rect width="32" height="32"/>
- <path fill="#70AD51" d="M5.46647617,27.9932117 C6.0517027,28.4658996 6.91159892,28.3777063 7.38425926,27.7914452 C7.85922261,27.2048452 7.76991326,26.3449044 7.18398981,25.8699411 C6.59874295,25.3956543 5.74015536,25.4869934 5.26383884,26.0722403 C4.81393367,26.6267596 4.87238621,27.4284565 5.37913494,27.9159868 L5.11431334,27.6818383 C1.97157151,24.7616933 0,20.5966301 0,15.9782542 C0,7.16842834 7.16775175,0 15.9796074,0 C20.4586065,0 24.5113565,1.8565519 27.4145869,4.8362365 C28.0749348,3.93840692 28.6466499,2.93435335 29.115524,1.82069284 C31.1513712,7.93770658 32.3482517,13.0811131 31.909824,17.1311567 C31.3178113,25.4044499 24.4017495,31.9585382 15.9796074,31.9585382 C12.0682639,31.9585382 8.48438805,30.5444735 5.7042963,28.2034861 L5.46647617,27.9932117 Z M29.0471888,23.0106888 C33.0546075,17.6737787 30.8211972,9.04527781 28.9612624,3.529749 C27.3029502,6.98304378 23.2217836,9.62375882 19.6981239,10.4613722 C16.3950312,11.2482417 13.4715032,10.6021021 10.4153644,11.7780085 C3.44517575,14.457289 3.55613585,22.7698242 7.39373146,24.6365249 C7.39711439,24.6392312 7.62444728,24.7616933 7.62174094,24.7576338 C7.62309411,24.7562806 13.2658211,23.6358542 16.3862356,22.4843049 C20.9450718,20.7996058 25.9524846,16.6494275 27.5986182,11.8273993 C26.723116,16.8415779 22.4179995,21.6669891 18.093262,23.8828081 C15.7908399,25.0648038 14.0005934,25.3279957 10.2123886,26.6385428 C9.74892722,26.798217 9.38492397,26.9538318 9.38492397,26.9538318 C10.3463526,26.7948341 11.301692,26.7420604 11.301692,26.7420604 C16.6954354,26.4869875 25.1087819,28.2582896 29.0471888,23.0106888 Z"/>
- </g>
-</svg>
+<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/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/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
index f16bc8dd430..9ef015047c9 100644
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -3,9 +3,9 @@
- button_method = issuable_close_reopen_button_method(issuable)
- if can_update && is_current_user
- = link_to "Close #{display_issuable_type}", close_issuable_url(issuable), method: button_method,
+ = 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_url(issuable), method: button_method,
+ = 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
diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
index a38cd319e3c..39a5171c1d6 100644
--- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
+++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
@@ -7,7 +7,7 @@
- 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_url(issuable),
+ = 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",
@@ -16,7 +16,7 @@
%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_url(issuable),
+ 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')
@@ -26,7 +26,7 @@
= display_issuable_type
%li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}",
- data: { text: "Reopen #{display_issuable_type}", url: reopen_issuable_url(issuable),
+ 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')
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index d3f0aa2d339..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
@@ -23,7 +22,7 @@
= 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
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index bb02dfa0d3a..6dfabd7ba4c 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -65,11 +65,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 0a692d9653f..d5e7d3b87b7 100644
--- a/app/views/shared/issuable/_label_page_create.html.haml
+++ b/app/views/shared/issuable/_label_page_create.html.haml
@@ -2,16 +2,16 @@
= dropdown_title("Create new label", 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 ad031e6af80..6a83321abcb 100644
--- a/app/views/shared/issuable/_label_page_default.html.haml
+++ b/app/views/shared/issuable/_label_page_default.html.haml
@@ -1,4 +1,4 @@
-- 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')
@@ -8,7 +8,7 @@
- if show_boards_content
.issue-board-dropdown-content
%p
- Create lists from labels. Issues with that label appear in that list.
+ = _('Create lists from labels. Issues with that label appear in that list.')
= dropdown_filter(filter_placeholder)
= dropdown_content
- if current_board_parent && show_footer
@@ -17,11 +17,11 @@
- if can?(current_user, :admin_label, current_board_parent)
%li
%a.dropdown-toggle-page{ href: "#" }
- Create new label
+ = _('Create new label')
%li
= link_to labels_path, :"data-is-link" => true do
- if show_create && can?(current_user, :admin_label, current_board_parent)
- Manage labels
+ = _('Manage labels')
- else
- View labels
+ = _('View labels')
= dropdown_loading
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 8a71819aa8e..00000000000
--- a/app/views/shared/issuable/_participants.html.haml
+++ /dev/null
@@ -1,18 +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
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 161b1c9fd72..fc6f71ef60f 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -25,7 +25,6 @@
%ul.tokens-container.list-unstyled
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options(type) }
- = icon('filter')
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
@@ -113,6 +112,7 @@
- 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'
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 674f13ddb23..adaddda13eb 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -1,7 +1,4 @@
- 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.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)}" } }
@@ -9,7 +6,7 @@
.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
@@ -29,26 +26,27 @@
%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: 'js-sidebar-dropdown-toggle 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, 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: 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 }})
+ = 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: 'js-sidebar-dropdown-toggle 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,16 +92,17 @@
%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: 'js-sidebar-dropdown-toggle 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
@@ -116,47 +117,47 @@
= render partial: "shared/issuable/label_page_create"
- 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
- = render "shared/issuable/participants", participants: issuable.participants(current_user)
+ - 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")
+ = 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' }
+ .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
+ = _('Move issue')
.dropdown-menu.dropdown-menu-selectable
- = dropdown_title('Move issue')
- = dropdown_filter('Search project', search_id: 'sidebar-move-issue-dropdown-search')
+ = 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
+ = _('Move')
= icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon')
+ -# 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 58782fa5f58..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,35 +10,35 @@
- 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: 'js-sidebar-dropdown-toggle 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, 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'
+ - 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?
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 48d04678d47..4a3547e9e70 100644
--- a/app/views/shared/issuable/_user_dropdown_item.html.haml
+++ b/app/views/shared/issuable/_user_dropdown_item.html.haml
@@ -4,7 +4,7 @@
%li.filter-dropdown-item{ class: ('js-current-user' if user == current_user) }
%button.btn.btn-link.dropdown-user{ type: :button }
.avatar-container.s40
- = user_avatar_without_link(user: user, lazy: avatar[:lazy], url: avatar[:url], size: 40, has_tooltip: false).gsub('/images/{{avatar_url}}','{{avatar_url}}').html_safe
+ = 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/_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/_group.html.haml b/app/views/shared/members/_group.html.haml
index bcdad3c153a..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
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index 951b4dd7b36..ba57d922c6d 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -1,14 +1,14 @@
- 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: ''
+ = 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
@@ -36,7 +36,7 @@
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: ''
+ = image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40", alt: ''
.user-info
.member= member.invite_email
.cgray
@@ -50,18 +50,17 @@
.controls.member-controls
- if show_controls && member.source == current_resource
- - if member.invite? && can?(current_user, action_member_permission(:admin, member), member.source)
+ - 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 && can_admin_member
+ - 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
@@ -70,23 +69,22 @@
= 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 visible-xs-block'
-
- - elsif member.request? && can_admin_member
+ - if member.can_approve?
= link_to polymorphic_path([:approve_access_request, member]),
method: :post,
class: 'btn btn-success prepend-left-10',
@@ -96,7 +94,7 @@
- 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,
@@ -104,7 +102,6 @@
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',
diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml
index 09b9944082f..1fbd6bcc4cb 100644
--- a/app/views/shared/members/_requests.html.haml
+++ b/app/views/shared/members/_requests.html.haml
@@ -1,10 +1,13 @@
+- membership_source = local_assigns.fetch(:membership_source)
+- requesters = local_assigns.fetch(:requesters)
- force_mobile_view = local_assigns.fetch(:force_mobile_view, false)
-- if requesters.any?
- .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 }
+- 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 14395bcc661..eba64daaadc 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -12,12 +12,12 @@
- 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 [namespace, project, issuable] do
+ = link_to [namespace, project, issuable], class: 'issue-link' do
%span.issuable-number= issuable.to_reference
- labels.each do |label|
@@ -28,4 +28,4 @@
- assignees.each do |assignee|
= 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 305e2542281..da01fc02d07 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -27,7 +27,7 @@
- milestone.milestones.each do |milestone|
= link_to milestone_path(milestone) do
%span.label.label-gray
- = dashboard ? milestone.project.name_with_namespace : milestone.project.name
+ = dashboard ? milestone.project.full_name : milestone.project.name
- if @group
.col-sm-6.milestone-actions
- if can?(current_user, :admin_milestones, @group)
@@ -49,6 +49,20 @@
= link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do
Edit
\
+
+ - if @project.group
+ = link_to promote_project_milestone_path(milestone.project, milestone), title: "Promote to Group Milestone", class: 'btn btn-xs btn-grouped', data: { confirm: "Promoting #{milestone.title} will make it available for all projects inside #{@project.group.name}. Existing project milestones with the same name will be merged. This action cannot be reversed.", toggle: "tooltip" }, method: :post do
+ 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"
- = link_to project_milestone_path(milestone.project, milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-xs btn-remove btn-grouped" do
- Delete
+
+ %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 1615871385e..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)
%div
%small.cgray= user.username
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index f03e0ab154c..a942ebc328b 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -85,6 +85,13 @@
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
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index fd0760d83a5..6006ab8b43f 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -56,7 +56,7 @@
- milestone.milestones.each do |ms|
%tr
%td
- - project_name = group ? ms.project.name : ms.project.name_with_namespace
+ - 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
diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
index 1dfe380db16..4b9af78bc1a 100644
--- a/app/views/shared/notes/_comment_button.html.haml
+++ b/app/views/shared/notes/_comment_button.html.haml
@@ -7,7 +7,7 @@
= 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}" } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index 725bf916592..71c0d740bc8 100644
--- a/app/views/shared/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -24,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
-
- .note-form-actions.clearfix
- = render partial: 'shared/notes/comment_button'
-
- = yield(:note_actions)
-
- %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } }
- Discard draft
+ .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'
+
+ = yield(:note_actions)
+
+ %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 4f00a9f2759..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
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index e3e86709b8f..1db7c4e67cf 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -1,17 +1,21 @@
-%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
@@ -21,5 +25,15 @@
or
= link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link'
to comment
-
+- 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/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml
index 80432a73e4e..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
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 0bedfea3502..e1da05d8f08 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -5,18 +5,20 @@
- 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 any_projects?(projects)
+ - load_pipeline_status(projects)
+
%ul.projects-list
- projects.each_with_index do |project, i|
- 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
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 52a8fe8bb67..0687f6d961d 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -3,8 +3,10 @@
- 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_date)
@@ -15,20 +17,25 @@
.avatar-container.s40
= link_to project_path(project), class: dom_class(project) do
- if project.creator && use_creator_avatar
- = image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:''
+ = 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_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
@@ -40,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/repo/_editable_mode.html.haml b/app/views/shared/repo/_editable_mode.html.haml
deleted file mode 100644
index 73fdb8b523f..00000000000
--- a/app/views/shared/repo/_editable_mode.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-.editable-mode
- %repo-edit-button
diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml
deleted file mode 100644
index 87fa2007d16..00000000000
--- a/app/views/shared/repo/_repo.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-#repo{ data: { url: content_url,
- project_name: project.name,
- refs_url: refs_project_path(project, format: :json),
- project_url: project_path(project),
- project_id: project.id,
- can_commit: (!!can_push_branch?(project, @ref)).to_s,
- on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s } }
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 43322978749..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|
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index 119d189f21d..12df79a28c7 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -1,14 +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)
- %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 7388f20a9fd..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
@@ -31,7 +31,7 @@
%span.hidden-xs
in
= link_to project_path(snippet.project) do
- = snippet.project.name_with_namespace
+ = 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/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/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 79e8f8d0e89..cc0e93c0755 100644
--- a/app/views/u2f/_register.html.haml
+++ b/app/views/u2f/_register.html.haml
@@ -1,5 +1,6 @@
#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).
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/show.html.haml b/app/views/users/show.html.haml
index d0ffcc88d43..4bf01ecb48c 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -9,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
@@ -32,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
@@ -48,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')
@@ -82,47 +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 }
.tab-content
- #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 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
- %h4.prepend-top-20
- Most Recent Activity
- .content_list{ data: { href: user_path } }
- = spinner
+ - if can?(current_user, :read_cross_project)
+ %h4.prepend-top-20
+ Most Recent Activity
+ .content_list{ data: { href: user_path } }
+ = spinner
- #groups.tab-pane
- -# This tab is always loaded via AJAX
+ - if profile_tab?(:groups)
+ #groups.tab-pane
+ -# This tab is always loaded via AJAX
- #contributed.tab-pane
- -# This tab is always loaded via AJAX
+ - if profile_tab?(:contributed)
+ #contributed.tab-pane
+ -# This tab is always loaded via AJAX
- #projects.tab-pane
- -# This tab is always loaded via AJAX
+ - if profile_tab?(:projects)
+ #projects.tab-pane
+ -# This tab is always loaded via AJAX
- #snippets.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
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 55d8d0c69d1..d7e24491516 100644
--- a/app/workers/authorized_projects_worker.rb
+++ b/app/workers/authorized_projects_worker.rb
@@ -1,48 +1,10 @@
class AuthorizedProjectsWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
+ prepend WaitableWorker
- # Schedules multiple jobs and waits for them to be completed.
- def self.bulk_perform_and_wait(args_list)
- # 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
- end
-
- # Schedules multiple jobs to run in sidekiq without waiting for completion
- def self.bulk_perform_async(args_list)
- Sidekiq::Client.push_bulk('class' => self, 'queue' => sidekiq_options['queue'], 'args' => args_list)
- end
-
- # Performs multiple jobs directly. Failed jobs will be put into sidekiq so
- # they can benefit from retries
- def self.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
-
- def perform(user_id, notify_key = nil)
+ def perform(user_id)
user = User.find_by(id: user_id)
user&.refresh_authorized_projects
- ensure
- Gitlab::JobWaiter.notify(notify_key, jid) if notify_key
end
end
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
index 45ce49bb5c0..376703f6319 100644
--- a/app/workers/background_migration_worker.rb
+++ b/app/workers/background_migration_worker.rb
@@ -1,39 +1,53 @@
class BackgroundMigrationWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
- # Enqueues 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:
+ # 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.
#
- # [migration-class, [arg1, arg2, ...]]
- def self.perform_bulk(jobs)
- Sidekiq::Client.push_bulk('class' => self,
- 'queue' => sidekiq_options['queue'],
- 'args' => jobs)
+ # 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 = [])
+ 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
- # Schedules multiple jobs in bulk, with a delay.
- #
- def self.perform_bulk_in(delay, jobs)
- now = Time.now.to_i
- schedule = now + delay.to_i
+ 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)
- if schedule <= now
- raise ArgumentError, 'The schedule time must be in the future!'
+ [lease.try_obtain, lease.ttl]
end
+ end
- Sidekiq::Client.push_bulk('class' => self,
- 'queue' => sidekiq_options['queue'],
- 'args' => jobs,
- 'at' => schedule)
+ def lease_for(class_name)
+ Gitlab::ExclusiveLease
+ .new("#{self.class.name}:#{class_name}", timeout: MIN_INTERVAL)
end
- # Performs the background migration.
- #
- # See Gitlab::BackgroundMigration.perform for more information.
- def perform(class_name, arguments = [])
- Gitlab::BackgroundMigration.perform(class_name, arguments)
+ 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 cd4af85d047..62b212c79be 100644
--- a/app/workers/build_coverage_worker.rb
+++ b/app/workers/build_coverage_worker.rb
@@ -1,5 +1,5 @@
class BuildCoverageWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
def perform(build_id)
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index e2a1b3dcc41..46f1ac09915 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -1,13 +1,18 @@
class BuildFinishedWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
- enqueue_in group: :processing
+ 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 dedaf2835e6..cbfca8c342c 100644
--- a/app/workers/build_hooks_worker.rb
+++ b/app/workers/build_hooks_worker.rb
@@ -1,8 +1,8 @@
class BuildHooksWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
- enqueue_in group: :hooks
+ 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 e5ceb9ef715..e4f4e6c1d9e 100644
--- a/app/workers/build_queue_worker.rb
+++ b/app/workers/build_queue_worker.rb
@@ -1,8 +1,8 @@
class BuildQueueWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
- enqueue_in group: :processing
+ 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 20ec24bd18a..4b9097bc5e4 100644
--- a/app/workers/build_success_worker.rb
+++ b/app/workers/build_success_worker.rb
@@ -1,8 +1,8 @@
class BuildSuccessWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
- enqueue_in group: :processing
+ 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/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/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
index eb0d6c9c36c..526ed0bad07 100644
--- a/app/workers/concerns/new_issuable.rb
+++ b/app/workers/concerns/new_issuable.rb
@@ -9,15 +9,15 @@ module NewIssuable
end
def set_user(user_id)
- @user = User.find_by(id: user_id)
+ @user = User.find_by(id: user_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables
- log_error(User, user_id) unless @user
+ 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)
+ @issuable = issuable_class.find_by(id: issuable_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables
- log_error(issuable_class, issuable_id) unless @issuable
+ log_error(issuable_class, issuable_id) unless @issuable # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def log_error(record_class, record_id)
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 ddf45b91345..e77093a6902 100644
--- a/app/workers/concerns/pipeline_queue.rb
+++ b/app/workers/concerns/pipeline_queue.rb
@@ -5,14 +5,6 @@ module PipelineQueue
extend ActiveSupport::Concern
included do
- sidekiq_options queue: 'pipeline_default'
- end
-
- class_methods do
- def enqueue_in(group:)
- raise ArgumentError, 'Unspecified queue group!' if group.empty?
-
- sidekiq_options queue: "pipeline_#{group}"
- end
+ 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
index 9b5ff17aafa..f371731f68c 100644
--- a/app/workers/create_gpg_signature_worker.rb
+++ b/app/workers/create_gpg_signature_worker.rb
@@ -1,6 +1,5 @@
class CreateGpgSignatureWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(commit_sha, project_id)
project = Project.find_by(id: project_id)
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 1afa24c8e2a..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?
@@ -39,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..21da27973fe 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
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 98a7500bffe..7217364a9f2 100644
--- a/app/workers/expire_job_cache_worker.rb
+++ b/app/workers/expire_job_cache_worker.rb
@@ -1,8 +1,8 @@
class ExpireJobCacheWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
- enqueue_in group: :cache
+ queue_namespace :pipeline_cache
def perform(job_id)
job = CommitStatus.joins(:pipeline, :project).find_by(id: job_id)
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index 1a0e7f92875..db73d37868a 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -1,8 +1,8 @@
class ExpirePipelineCacheWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
- enqueue_in group: :cache
+ queue_namespace :pipeline_cache
def perform(pipeline_id)
pipeline = Ci::Pipeline.find_by(id: pipeline_id)
@@ -13,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)
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index ec65d3ff65e..55fb817ca6e 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -1,7 +1,5 @@
class GitGarbageCollectWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
- include Gitlab::CurrentSettings
+ include ApplicationWorker
sidekiq_options retry: false
@@ -46,6 +44,10 @@ class GitGarbageCollectWorker
# 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
cancel_lease(lease_key, lease_uuid) if lease_key.present? && lease_uuid.present?
end
@@ -103,7 +105,7 @@ class GitGarbageCollectWorker
end
def bitmaps_enabled?
- current_application_settings.housekeeping_bitmaps_enabled
+ Gitlab::CurrentSettings.housekeeping_bitmaps_enabled
end
def git(write_bitmaps:)
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 0ec871e00e1..a0028e41332 100644
--- a/app/workers/gitlab_shell_worker.rb
+++ b/app/workers/gitlab_shell_worker.rb
@@ -1,7 +1,6 @@
class GitlabShellWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include Gitlab::ShellAdapter
- include DedicatedSidekiqQueue
def perform(action, *arg)
gitlab_shell.__send__(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
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 bd8e212e928..509bd09dc2e 100644
--- a/app/workers/group_destroy_worker.rb
+++ b/app/workers/group_destroy_worker.rb
@@ -1,11 +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
index db6b1ea8e8d..6774ab307c6 100644
--- a/app/workers/invalid_gpg_signature_update_worker.rb
+++ b/app/workers/invalid_gpg_signature_update_worker.rb
@@ -1,6 +1,5 @@
class InvalidGpgSignatureUpdateWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(gpg_key_id)
gpg_key = GpgKey.find_by(id: gpg_key_id)
diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb
index 3dd14466994..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)
@@ -104,6 +103,7 @@ 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
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 f1cd1769421..adb25c2a170 100644
--- a/app/workers/namespaceless_project_destroy_worker.rb
+++ b/app/workers/namespaceless_project_destroy_worker.rb
@@ -5,14 +5,9 @@
# 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
+ include ApplicationWorker
include ExceptionBacktrace
- def self.bulk_perform_async(args_list)
- Sidekiq::Client.push_bulk('class' => self, 'queue' => sidekiq_options['queue'], 'args' => args_list)
- end
-
def perform(project_id)
begin
project = Project.unscoped.find(project_id)
diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb
index d9a8e892e90..3bc030f9c62 100644
--- a/app/workers/new_issue_worker.rb
+++ b/app/workers/new_issue_worker.rb
@@ -1,6 +1,5 @@
class NewIssueWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
include NewIssuable
def perform(issue_id, user_id)
diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb
index 1910c490159..bda2a0ab59d 100644
--- a/app/workers/new_merge_request_worker.rb
+++ b/app/workers/new_merge_request_worker.rb
@@ -1,6 +1,5 @@
class NewMergeRequestWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
include NewIssuable
def perform(merge_request_id, user_id)
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 64788da7299..66a0ff83bef 100644
--- a/app/workers/pages_worker.rb
+++ b/app/workers/pages_worker.rb
@@ -1,7 +1,7 @@
class PagesWorker
- include Sidekiq::Worker
+ include ApplicationWorker
- sidekiq_options queue: :pages, retry: false
+ sidekiq_options retry: 3
def perform(action, *arg)
send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
@@ -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 30a75ec8435..c94918ff4ee 100644
--- a/app/workers/pipeline_hooks_worker.rb
+++ b/app/workers/pipeline_hooks_worker.rb
@@ -1,8 +1,8 @@
class PipelineHooksWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
- enqueue_in group: :hooks
+ queue_namespace :pipeline_hooks
def perform(pipeline_id)
Ci::Pipeline.find_by(id: pipeline_id)
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 8c067d05081..24424b3f472 100644
--- a/app/workers/pipeline_process_worker.rb
+++ b/app/workers/pipeline_process_worker.rb
@@ -1,8 +1,8 @@
class PipelineProcessWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
- enqueue_in group: :processing
+ queue_namespace :pipeline_processing
def perform(pipeline_id)
Ci::Pipeline.find_by(id: pipeline_id)
diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb
index d7087f20dfc..c49758878a4 100644
--- a/app/workers/pipeline_schedule_worker.rb
+++ b/app/workers/pipeline_schedule_worker.rb
@@ -1,5 +1,5 @@
class PipelineScheduleWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
@@ -9,7 +9,7 @@ class PipelineScheduleWorker
pipeline = Ci::CreatePipelineService.new(schedule.project,
schedule.owner,
ref: schedule.ref)
- .execute(:schedule, save_on_errors: false, schedule: schedule)
+ .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule)
schedule.deactivate! unless pipeline.persisted?
rescue => e
diff --git a/app/workers/pipeline_success_worker.rb b/app/workers/pipeline_success_worker.rb
index cb8bb2ffe75..2ab0739a17f 100644
--- a/app/workers/pipeline_success_worker.rb
+++ b/app/workers/pipeline_success_worker.rb
@@ -1,8 +1,8 @@
class PipelineSuccessWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
- enqueue_in group: :processing
+ queue_namespace :pipeline_processing
def perform(pipeline_id)
Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline|
diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb
index 5fa399dff4c..fc9da2d45b1 100644
--- a/app/workers/pipeline_update_worker.rb
+++ b/app/workers/pipeline_update_worker.rb
@@ -1,8 +1,8 @@
class PipelineUpdateWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
- enqueue_in group: :processing
+ queue_namespace :pipeline_processing
def perform(pipeline_id)
Ci::Pipeline.find_by(id: pipeline_id)
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 b8f8d3750d9..f2b2c4428d3 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -1,6 +1,5 @@
class PostReceive
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
def perform(gl_repository, identifier, changes)
project, is_wiki = Gitlab::GlRepository.parse(gl_repository)
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 3be7e686609..1ba854ca4cb 100644
--- a/app/workers/project_destroy_worker.rb
+++ b/app/workers/project_destroy_worker.rb
@@ -1,6 +1,5 @@
class ProjectDestroyWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
include ExceptionBacktrace
def perform(project_id, user_id, params)
diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb
index f13ac9e5db2..c100852374a 100644
--- a/app/workers/project_export_worker.rb
+++ b/app/workers/project_export_worker.rb
@@ -1,6 +1,5 @@
class ProjectExportWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
include ExceptionBacktrace
sidekiq_options retry: 3
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 4883d848c53..75c4b8b3663 100644
--- a/app/workers/project_service_worker.rb
+++ b/app/workers/project_service_worker.rb
@@ -1,6 +1,5 @@
class ProjectServiceWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
sidekiq_options dead: false
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 cde5b45ad41..07584fab7c8 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -1,50 +1,31 @@
class RepositoryForkWorker
- ForkError = Class.new(StandardError)
-
- include Sidekiq::Worker
+ include ApplicationWorker
include Gitlab::ShellAdapter
- include DedicatedSidekiqQueue
-
- sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION
+ include ProjectStartImport
+ include ProjectImportOptions
- def perform(project_id, forked_from_repository_storage_path, source_path, target_path)
+ def perform(project_id, forked_from_repository_storage_path, source_disk_path)
project = Project.find(project_id)
return unless start_fork(project)
Gitlab::Metrics.add_event(:fork_repository,
- source_path: source_path,
- target_path: target_path)
-
- 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
+ source_path: source_disk_path,
+ target_path: project.disk_path)
- project.repository.after_import
- raise ForkError, "Project #{project_id} had an invalid repository after fork" unless project.valid_repo?
+ 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
- project.import_finish
- rescue ForkError => ex
- fail_fork(project, ex.message)
- raise
- rescue => ex
- return unless project
-
- fail_fork(project, ex.message)
- raise ForkError, "#{ex.class} #{ex.message}"
+ project.after_import
end
private
def start_fork(project)
- return true if project.import_start
+ return true if start(project)
Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while forking.")
false
end
-
- def fail_fork(project, message)
- Rails.logger.error(message)
- project.mark_import_as_failed(message)
- end
end
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 00a021abbdc..d79b5ee5346 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -1,11 +1,8 @@
class RepositoryImportWorker
- ImportError = Class.new(StandardError)
-
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
include ExceptionBacktrace
-
- sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION
+ include ProjectStartImport
+ include ProjectImportOptions
def perform(project_id)
project = Project.find(project_id)
@@ -16,25 +13,26 @@ class RepositoryImportWorker
import_url: project.import_url,
path: project.full_path)
- result = Projects::ImportService.new(project, project.creator).execute
- raise ImportError, result[:message] if result[:status] == :error
+ service = Projects::ImportService.new(project, project.creator)
+ result = service.execute
+
+ # 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?
+
+ 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 project.import_start
+ return true if start(project)
Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while importing.")
false
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
index c301cea5ad6..e4b683fca33 100644
--- a/app/workers/stage_update_worker.rb
+++ b/app/workers/stage_update_worker.rb
@@ -1,8 +1,8 @@
class StageUpdateWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include PipelineQueue
- enqueue_in group: :processing
+ queue_namespace :pipeline_processing
def perform(stage_id)
Ci::Stage.find_by(id: stage_id).try do |stage|
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 269776a1f62..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,14 +39,23 @@ 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).merge(Project.without_deleted).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
diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb
index f850e459cd9..fbb14efc525 100644
--- a/app/workers/stuck_import_jobs_worker.rb
+++ b/app/workers/stuck_import_jobs_worker.rb
@@ -1,5 +1,5 @@
class StuckImportJobsWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
IMPORT_JOBS_EXPIRATION = 15.hours.to_i
@@ -16,43 +16,41 @@ class StuckImportJobsWorker
private
def mark_projects_without_jid_as_failed!
- started_projects_without_jid.each do |project|
+ enqueued_projects_without_jid.each do |project|
project.mark_import_as_failed(error_message)
end.count
end
def mark_projects_with_jid_as_failed!
- completed_jids_count = 0
+ jids_and_ids = enqueued_projects_with_jid.pluck(:import_jid, :id).to_h
- started_projects_with_jid.find_in_batches(batch_size: 500) do |group|
- jids = group.map(&:import_jid)
+ # 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?
- # Find the jobs that aren't currently running or that exceeded the threshold.
- completed_jids = Gitlab::SidekiqStatus.completed_jids(jids).to_set
+ completed_project_ids = jids_and_ids.values_at(*completed_jids)
- if completed_jids.any?
- completed_jids_count += completed_jids.count
- group.each do |project|
- project.mark_import_as_failed(error_message) if completed_jids.include?(project.import_jid)
- end
+ # 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_jids.to_a.join(', ')}")
- end
- end
+ Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_projects.map(&:import_jid).join(', ')}")
- completed_jids_count
+ completed_projects.each do |project|
+ project.mark_import_as_failed(error_message)
+ end.count
end
- def started_projects
- Project.with_import_status(:started)
+ def enqueued_projects
+ Project.with_import_status(:scheduled, :started)
end
- def started_projects_with_jid
- started_projects.where.not(import_jid: nil)
+ def enqueued_projects_with_jid
+ enqueued_projects.where.not(import_jid: nil)
end
- def started_projects_without_jid
- started_projects.where(import_jid: nil)
+ def enqueued_projects_without_jid
+ enqueued_projects.where(import_jid: nil)
end
def error_message
diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb
index 7843179d77c..16394293c79 100644
--- a/app/workers/stuck_merge_jobs_worker.rb
+++ b/app/workers/stuck_merge_jobs_worker.rb
@@ -1,5 +1,5 @@
class StuckMergeJobsWorker
- include Sidekiq::Worker
+ include ApplicationWorker
include CronjobQueue
def perform
@@ -23,7 +23,12 @@ class StuckMergeJobsWorker
merge_requests = MergeRequest.where(id: completed_ids)
merge_requests.where.not(merge_commit_sha: nil).update_all(state: :merged)
- merge_requests.where(merge_commit_sha: nil).update_all(state: :opened)
+
+ 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
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/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 713c0228040..dfc3f33ad9d 100644
--- a/app/workers/web_hook_worker.rb
+++ b/app/workers/web_hook_worker.rb
@@ -1,6 +1,5 @@
class WebHookWorker
- include Sidekiq::Worker
- include DedicatedSidekiqQueue
+ include ApplicationWorker
sidekiq_options retry: 4, dead: false