summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorFilipa Lacerda <filipa@gitlab.com>2018-05-04 14:58:47 +0100
committerFilipa Lacerda <filipa@gitlab.com>2018-05-04 14:58:47 +0100
commit1983356d647290fe38ca21bbbca43fe2d6292913 (patch)
treed07fba5693e239993dfc6d1f724b2103f90a3fa6 /app
parent703f45632292e7fc45359d0144cd616725bf9b0d (diff)
parent4bf47cd76fd69a26b7b2b4ac029f088ec5493712 (diff)
downloadgitlab-ce-1983356d647290fe38ca21bbbca43fe2d6292913.tar.gz
Merge branch 'master' into 44427-state-management-with-vuext
* master: (1063 commits) Replace commits spinach tests with RSpec analog Update repository.rb Add note about rebase/squash duplication in Gitaly Resolve "Reconcile project templates with Auto DevOps" Move import project pane to a separate partial Inform the user when there are no project import options available Clarify location of Vue templates Make add_index_to_namespaces_runners_token migration reversible Fix lambda arguments in Grape entities Update grape-entity 0.6.0 -> 0.7.1 Fix constants in backfill_runner_type_for_ci_runners_post_migrate.rb Use limited_counter_with_delimiter in the admin user list tabs Remove a warning from spec/features/admin/admin_users_spec.rb Use smallint for runner_type since its an enum Dont remove duplicates in Runner.owned_or_shared since its not necessary Change the docs license to CC BY-SA Remove unnecessary disable transaction in add_ci_runner_namespaces Split migration to add and index namespaces.runners_token Output some useful information when running the rails console Revert "Use factory in specs for ProjectCiCdSettings" ...
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/ext_snippet_icons/ext_snippet_icons.pngbin0 -> 1018 bytes
-rw-r--r--app/assets/images/ext_snippet_icons/logo.pngbin0 -> 494 bytes
-rw-r--r--app/assets/javascripts/awards_handler.js15
-rw-r--r--app/assets/javascripts/badges/components/badge.vue121
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue219
-rw-r--r--app/assets/javascripts/badges/components/badge_list.vue57
-rw-r--r--app/assets/javascripts/badges/components/badge_list_row.vue89
-rw-r--r--app/assets/javascripts/badges/components/badge_settings.vue70
-rw-r--r--app/assets/javascripts/badges/constants.js2
-rw-r--r--app/assets/javascripts/badges/empty_badge.js7
-rw-r--r--app/assets/javascripts/badges/store/actions.js167
-rw-r--r--app/assets/javascripts/badges/store/index.js13
-rw-r--r--app/assets/javascripts/badges/store/mutation_types.js21
-rw-r--r--app/assets/javascripts/badges/store/mutations.js158
-rw-r--r--app/assets/javascripts/badges/store/state.js13
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js27
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js2
-rw-r--r--app/assets/javascripts/blob/file_template_selector.js4
-rw-r--r--app/assets/javascripts/boards/components/board.js4
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.vue (renamed from app/assets/javascripts/boards/components/board_blank_state.js)71
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js6
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.js13
-rw-r--r--app/assets/javascripts/boards/components/modal/empty_state.js6
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.js6
-rw-r--r--app/assets/javascripts/boards/components/modal/header.js6
-rw-r--r--app/assets/javascripts/boards/components/modal/index.js3
-rw-r--r--app/assets/javascripts/boards/components/modal/list.js3
-rw-r--r--app/assets/javascripts/boards/components/modal/lists_dropdown.js3
-rw-r--r--app/assets/javascripts/boards/components/modal/tabs.js6
-rw-r--r--app/assets/javascripts/boards/components/sidebar/remove_issue.js6
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js1
-rw-r--r--app/assets/javascripts/boards/index.js7
-rw-r--r--app/assets/javascripts/boards/mixins/modal_mixins.js4
-rw-r--r--app/assets/javascripts/boards/models/issue.js6
-rw-r--r--app/assets/javascripts/boards/models/list.js2
-rw-r--r--app/assets/javascripts/boards/services/board_service.js2
-rw-r--r--app/assets/javascripts/boards/stores/modal_store.js5
-rw-r--r--app/assets/javascripts/branches/branches_delete_modal.js11
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue185
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue26
-rw-r--r--app/assets/javascripts/compare.js86
-rw-r--r--app/assets/javascripts/compare_autocomplete.js49
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js33
-rw-r--r--app/assets/javascripts/due_date_select.js9
-rw-r--r--app/assets/javascripts/emoji/index.js6
-rw-r--r--app/assets/javascripts/emoji/support/unicode_support_map.js40
-rw-r--r--app/assets/javascripts/environments/components/container.vue1
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue18
-rw-r--r--app/assets/javascripts/environments/components/environment_external_url.vue14
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue15
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue3
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue18
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight.js17
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_helper.js29
-rw-r--r--app/assets/javascripts/ide/components/changed_file_icon.vue75
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue58
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue77
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue153
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue121
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue58
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue130
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue88
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue59
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/success_message.vue39
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue45
-rw-r--r--app/assets/javascripts/ide/components/file_finder/index.vue245
-rw-r--r--app/assets/javascripts/ide/components/file_finder/item.vue113
-rw-r--r--app/assets/javascripts/ide/components/ide.vue119
-rw-r--r--app/assets/javascripts/ide/components/ide_context_bar.vue42
-rw-r--r--app/assets/javascripts/ide/components/ide_file_buttons.vue84
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue47
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue90
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue11
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue138
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue81
-rw-r--r--app/assets/javascripts/ide/components/repo_file.vue95
-rw-r--r--app/assets/javascripts/ide/components/repo_file_buttons.vue61
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue14
-rw-r--r--app/assets/javascripts/ide/components/resizable_panel.vue109
-rw-r--r--app/assets/javascripts/ide/constants.js8
-rw-r--r--app/assets/javascripts/ide/ide_router.js4
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js37
-rw-r--r--app/assets/javascripts/ide/lib/common/model_manager.js4
-rw-r--r--app/assets/javascripts/ide/lib/decorations/controller.js9
-rw-r--r--app/assets/javascripts/ide/lib/diff/controller.js25
-rw-r--r--app/assets/javascripts/ide/lib/editor.js57
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js2
-rw-r--r--app/assets/javascripts/ide/lib/keymap.json11
-rw-r--r--app/assets/javascripts/ide/stores/actions.js48
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js48
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js100
-rw-r--r--app/assets/javascripts/ide/stores/getters.js45
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js109
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js14
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js10
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js42
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js78
-rw-r--r--app/assets/javascripts/ide/stores/mutations/tree.js8
-rw-r--r--app/assets/javascripts/ide/stores/state.js3
-rw-r--r--app/assets/javascripts/ide/stores/utils.js26
-rw-r--r--app/assets/javascripts/ide/stores/workers/files_decorator_worker.js31
-rw-r--r--app/assets/javascripts/issuable_context.js4
-rw-r--r--app/assets/javascripts/jobs/components/header.vue150
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_details_block.vue185
-rw-r--r--app/assets/javascripts/jobs/job_details_bundle.js5
-rw-r--r--app/assets/javascripts/labels_select.js8
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js7
-rw-r--r--app/assets/javascripts/lib/utils/keycodes.js4
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js26
-rw-r--r--app/assets/javascripts/merge_request_tabs.js49
-rw-r--r--app/assets/javascripts/milestone.js22
-rw-r--r--app/assets/javascripts/milestone_select.js94
-rw-r--r--app/assets/javascripts/monitoring/components/graph.vue24
-rw-r--r--app/assets/javascripts/monitoring/components/graph/axis.vue142
-rw-r--r--app/assets/javascripts/monitoring/components/graph/flag.vue32
-rw-r--r--app/assets/javascripts/monitoring/components/graph/legend.vue228
-rw-r--r--app/assets/javascripts/monitoring/components/graph/track_info.vue29
-rw-r--r--app/assets/javascripts/monitoring/components/graph/track_line.vue36
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js2
-rw-r--r--app/assets/javascripts/monitoring/utils/multiple_time_series.js84
-rw-r--r--app/assets/javascripts/mr_notes/index.js5
-rw-r--r--app/assets/javascripts/notes.js48
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue4
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue9
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue11
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue1
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue1
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue11
-rw-r--r--app/assets/javascripts/notes/constants.js6
-rw-r--r--app/assets/javascripts/notes/mixins/noteable.js11
-rw-r--r--app/assets/javascripts/notes/stores/actions.js3
-rw-r--r--app/assets/javascripts/notes/stores/getters.js3
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue5
-rw-r--r--app/assets/javascripts/pages/dashboard/milestones/show/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/milestones/show/index.js7
-rw-r--r--app/assets/javascripts/pages/groups/settings/badges/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/compare/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js60
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js22
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/settings/badges/index/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/form.js19
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js18
-rw-r--r--app/assets/javascripts/pages/projects/shared/project_new.js152
-rw-r--r--app/assets/javascripts/pages/projects/shared/save_project_loader.js12
-rw-r--r--app/assets/javascripts/pages/projects/snippets/show/index.js6
-rw-r--r--app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js2
-rw-r--r--app/assets/javascripts/pages/shared/mount_badge_settings.js24
-rw-r--r--app/assets/javascripts/pages/snippets/show/index.js10
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js205
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue5
-rw-r--r--app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue20
-rw-r--r--app/assets/javascripts/performance_bar/index.js2
-rw-r--r--app/assets/javascripts/performance_bar/services/performance_bar_service.js30
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue109
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue53
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue128
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue83
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_component.vue171
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue79
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines.vue64
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue21
-rw-r--r--app/assets/javascripts/pipelines/constants.js2
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines.js39
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js28
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediator.js13
-rw-r--r--app/assets/javascripts/pipelines/services/pipeline_service.js13
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js42
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue121
-rw-r--r--app/assets/javascripts/profile/account/index.js15
-rw-r--r--app/assets/javascripts/registry/stores/actions.js3
-rw-r--r--app/assets/javascripts/registry/stores/getters.js3
-rw-r--r--app/assets/javascripts/right_sidebar.js12
-rw-r--r--app/assets/javascripts/search_autocomplete.js8
-rw-r--r--app/assets/javascripts/shared/popover.js33
-rw-r--r--app/assets/javascripts/shortcuts_dashboard_navigation.js11
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue12
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue19
-rw-r--r--app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue21
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue22
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue22
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue32
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js17
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.vue (renamed from app/assets/javascripts/sidebar/components/time_tracking/help_state.js)49
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js10
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue13
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue (renamed from app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js)35
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js15
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue16
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js3
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js3
-rw-r--r--app/assets/javascripts/snippet/snippet_embed.js23
-rw-r--r--app/assets/javascripts/users_select.js10
-rw-r--r--app/assets/javascripts/visibility_select.js21
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue107
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue102
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue (renamed from app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js)267
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue (renamed from app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js)79
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/dependencies.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/callout.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue88
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue75
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue76
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue217
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue52
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue68
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue90
-rw-r--r--app/assets/javascripts/vue_shared/components/expand_button.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue101
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal.vue77
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue132
-rw-r--r--app/assets/javascripts/vue_shared/components/icon.vue118
-rw-r--r--app/assets/javascripts/vue_shared/components/identicon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue39
-rw-r--r--app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue2
-rw-r--r--app/assets/javascripts/vue_shared/models/label.js2
-rw-r--r--app/assets/stylesheets/application.scss6
-rw-r--r--app/assets/stylesheets/emoji_sprites.scss5403
-rw-r--r--app/assets/stylesheets/framework.scss121
-rw-r--r--app/assets/stylesheets/framework/animations.scss80
-rw-r--r--app/assets/stylesheets/framework/banner.scss19
-rw-r--r--app/assets/stylesheets/framework/blocks.scss4
-rw-r--r--app/assets/stylesheets/framework/buttons.scss46
-rw-r--r--app/assets/stylesheets/framework/common.scss2
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss33
-rw-r--r--app/assets/stylesheets/framework/emoji_sprites.scss1813
-rw-r--r--app/assets/stylesheets/framework/images.scss37
-rw-r--r--app/assets/stylesheets/framework/lists.scss13
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss10
-rw-r--r--app/assets/stylesheets/framework/mobile.scss4
-rw-r--r--app/assets/stylesheets/framework/responsive_tables.scss2
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss4
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss9
-rw-r--r--app/assets/stylesheets/framework/snippets.scss27
-rw-r--r--app/assets/stylesheets/framework/typography.scss5
-rw-r--r--app/assets/stylesheets/framework/variables.scss23
-rw-r--r--app/assets/stylesheets/framework/wells.scss1
-rw-r--r--app/assets/stylesheets/highlight/embedded.scss3
-rw-r--r--app/assets/stylesheets/highlight/white.scss291
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss290
-rw-r--r--app/assets/stylesheets/pages/boards.scss2
-rw-r--r--app/assets/stylesheets/pages/builds.scss43
-rw-r--r--app/assets/stylesheets/pages/commits.scss71
-rw-r--r--app/assets/stylesheets/pages/diff.scss11
-rw-r--r--app/assets/stylesheets/pages/environments.scss46
-rw-r--r--app/assets/stylesheets/pages/issuable.scss28
-rw-r--r--app/assets/stylesheets/pages/labels.scss4
-rw-r--r--app/assets/stylesheets/pages/login.scss20
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss17
-rw-r--r--app/assets/stylesheets/pages/milestone.scss49
-rw-r--r--app/assets/stylesheets/pages/notes.scss4
-rw-r--r--app/assets/stylesheets/pages/pages.scss60
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss23
-rw-r--r--app/assets/stylesheets/pages/profile.scss7
-rw-r--r--app/assets/stylesheets/pages/projects.scss13
-rw-r--r--app/assets/stylesheets/pages/repo.scss303
-rw-r--r--app/assets/stylesheets/pages/repo.scss.orig786
-rw-r--r--app/assets/stylesheets/pages/settings.scss20
-rw-r--r--app/assets/stylesheets/performance_bar.scss1
-rw-r--r--app/assets/stylesheets/snippets.scss156
-rw-r--r--app/controllers/admin/application_settings_controller.rb21
-rw-r--r--app/controllers/application_controller.rb4
-rw-r--r--app/controllers/boards/issues_controller.rb3
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb5
-rw-r--r--app/controllers/concerns/checks_collaboration.rb21
-rw-r--r--app/controllers/concerns/issuable_collections.rb6
-rw-r--r--app/controllers/concerns/notes_actions.rb8
-rw-r--r--app/controllers/concerns/renders_notes.rb2
-rw-r--r--app/controllers/concerns/snippets_actions.rb4
-rw-r--r--app/controllers/dashboard/todos_controller.rb2
-rw-r--r--app/controllers/dashboard_controller.rb19
-rw-r--r--app/controllers/groups/application_controller.rb2
-rw-r--r--app/controllers/groups/milestones_controller.rb2
-rw-r--r--app/controllers/groups/settings/badges_controller.rb13
-rw-r--r--app/controllers/groups/variables_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb6
-rw-r--r--app/controllers/jwt_controller.rb3
-rw-r--r--app/controllers/ldap/omniauth_callbacks_controller.rb31
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb144
-rw-r--r--app/controllers/profiles/active_sessions_controller.rb14
-rw-r--r--app/controllers/profiles_controller.rb18
-rw-r--r--app/controllers/projects/application_controller.rb16
-rw-r--r--app/controllers/projects/commit_controller.rb1
-rw-r--r--app/controllers/projects/deploy_tokens_controller.rb10
-rw-r--r--app/controllers/projects/discussions_controller.rb4
-rw-r--r--app/controllers/projects/git_http_client_controller.rb1
-rw-r--r--app/controllers/projects/git_http_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb8
-rw-r--r--app/controllers/projects/jobs_controller.rb10
-rw-r--r--app/controllers/projects/lfs_api_controller.rb2
-rw-r--r--app/controllers/projects/lfs_storage_controller.rb16
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb9
-rw-r--r--app/controllers/projects/merge_requests_controller.rb4
-rw-r--r--app/controllers/projects/notes_controller.rb6
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb37
-rw-r--r--app/controllers/projects/refs_controller.rb2
-rw-r--r--app/controllers/projects/repositories_controller.rb23
-rw-r--r--app/controllers/projects/runners_controller.rb6
-rw-r--r--app/controllers/projects/settings/badges_controller.rb13
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb62
-rw-r--r--app/controllers/projects/settings/repository_controller.rb30
-rw-r--r--app/controllers/projects/snippets_controller.rb3
-rw-r--r--app/controllers/projects/variables_controller.rb2
-rw-r--r--app/controllers/projects/wikis_controller.rb19
-rw-r--r--app/controllers/projects_controller.rb4
-rw-r--r--app/controllers/snippets_controller.rb4
-rw-r--r--app/controllers/users_controller.rb2
-rw-r--r--app/finders/group_descendants_finder.rb6
-rw-r--r--app/finders/groups_finder.rb8
-rw-r--r--app/finders/issuable_finder.rb11
-rw-r--r--app/finders/merge_request_target_project_finder.rb1
-rw-r--r--app/finders/pipelines_finder.rb9
-rw-r--r--app/finders/users_finder.rb12
-rw-r--r--app/helpers/active_sessions_helper.rb23
-rw-r--r--app/helpers/application_helper.rb76
-rw-r--r--app/helpers/application_settings_helper.rb4
-rw-r--r--app/helpers/auth_helper.rb8
-rw-r--r--app/helpers/avatars_helper.rb74
-rw-r--r--app/helpers/blob_helper.rb10
-rw-r--r--app/helpers/ci_status_helper.rb4
-rw-r--r--app/helpers/commits_helper.rb31
-rw-r--r--app/helpers/compare_helper.rb2
-rw-r--r--app/helpers/deploy_tokens_helper.rb12
-rw-r--r--app/helpers/diff_helper.rb2
-rw-r--r--app/helpers/dropdowns_helper.rb2
-rw-r--r--app/helpers/gitlab_routing_helper.rb8
-rw-r--r--app/helpers/groups_helper.rb2
-rw-r--r--app/helpers/icons_helper.rb4
-rw-r--r--app/helpers/issuables_helper.rb62
-rw-r--r--app/helpers/issues_helper.rb15
-rw-r--r--app/helpers/markup_helper.rb2
-rw-r--r--app/helpers/merge_requests_helper.rb12
-rw-r--r--app/helpers/milestones_helper.rb91
-rw-r--r--app/helpers/nav_helper.rb2
-rw-r--r--app/helpers/notes_helper.rb4
-rw-r--r--app/helpers/projects_helper.rb71
-rw-r--r--app/helpers/safe_params_helper.rb11
-rw-r--r--app/helpers/services_helper.rb25
-rw-r--r--app/helpers/snippets_helper.rb35
-rw-r--r--app/helpers/system_note_helper.rb6
-rw-r--r--app/helpers/tree_helper.rb2
-rw-r--r--app/helpers/workhorse_helper.rb4
-rw-r--r--app/mailers/emails/issues.rb6
-rw-r--r--app/mailers/notify.rb1
-rw-r--r--app/models/ability.rb4
-rw-r--r--app/models/active_session.rb110
-rw-r--r--app/models/appearance.rb2
-rw-r--r--app/models/broadcast_message.rb6
-rw-r--r--app/models/ci/build.rb46
-rw-r--r--app/models/ci/group_variable.rb2
-rw-r--r--app/models/ci/job_artifact.rb41
-rw-r--r--app/models/ci/pipeline.rb11
-rw-r--r--app/models/ci/runner.rb70
-rw-r--r--app/models/ci/runner_namespace.rb9
-rw-r--r--app/models/ci/stage.rb21
-rw-r--r--app/models/commit.rb17
-rw-r--r--app/models/commit_status.rb9
-rw-r--r--app/models/concerns/atomic_internal_id.rb5
-rw-r--r--app/models/concerns/avatarable.rb5
-rw-r--r--app/models/concerns/awardable.rb14
-rw-r--r--app/models/concerns/cache_markdown_field.rb30
-rw-r--r--app/models/concerns/chronic_duration_attribute.rb6
-rw-r--r--app/models/concerns/group_descendant.rb15
-rw-r--r--app/models/concerns/milestoneish.rb4
-rw-r--r--app/models/concerns/nonatomic_internal_id.rb22
-rw-r--r--app/models/concerns/presentable.rb8
-rw-r--r--app/models/concerns/protected_ref.rb2
-rw-r--r--app/models/concerns/routable.rb7
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb30
-rw-r--r--app/models/concerns/uniquify.rb22
-rw-r--r--app/models/deploy_token.rb66
-rw-r--r--app/models/deployment.rb4
-rw-r--r--app/models/diff_note.rb15
-rw-r--r--app/models/environment.rb2
-rw-r--r--app/models/event.rb3
-rw-r--r--app/models/group.rb18
-rw-r--r--app/models/hooks/project_hook.rb1
-rw-r--r--app/models/identity.rb8
-rw-r--r--app/models/internal_id.rb27
-rw-r--r--app/models/issue.rb29
-rw-r--r--app/models/lfs_object.rb6
-rw-r--r--app/models/members/group_member.rb6
-rw-r--r--app/models/members/project_member.rb6
-rw-r--r--app/models/merge_request.rb6
-rw-r--r--app/models/merge_request_diff.rb4
-rw-r--r--app/models/merge_request_diff_commit.rb2
-rw-r--r--app/models/milestone.rb9
-rw-r--r--app/models/namespace.rb7
-rw-r--r--app/models/note.rb8
-rw-r--r--app/models/notification_recipient.rb12
-rw-r--r--app/models/notification_setting.rb3
-rw-r--r--app/models/project.rb93
-rw-r--r--app/models/project_ci_cd_setting.rb16
-rw-r--r--app/models/project_deploy_token.rb8
-rw-r--r--app/models/project_services/chat_notification_service.rb14
-rw-r--r--app/models/project_services/flowdock_service.rb48
-rw-r--r--app/models/project_services/hipchat_service.rb2
-rw-r--r--app/models/project_statistics.rb20
-rw-r--r--app/models/project_wiki.rb8
-rw-r--r--app/models/protected_branch.rb9
-rw-r--r--app/models/repository.rb16
-rw-r--r--app/models/service.rb14
-rw-r--r--app/models/storage/hashed_project.rb4
-rw-r--r--app/models/storage/legacy_project.rb8
-rw-r--r--app/models/user.rb48
-rw-r--r--app/models/wiki_page.rb18
-rw-r--r--app/policies/ci/build_policy.rb4
-rw-r--r--app/policies/ci/pipeline_schedule_policy.rb14
-rw-r--r--app/policies/deploy_token_policy.rb11
-rw-r--r--app/policies/group_policy.rb8
-rw-r--r--app/policies/issuable_policy.rb16
-rw-r--r--app/policies/issue_policy.rb2
-rw-r--r--app/policies/merge_request_policy.rb1
-rw-r--r--app/policies/note_policy.rb11
-rw-r--r--app/policies/personal_snippet_policy.rb2
-rw-r--r--app/policies/project_policy.rb133
-rw-r--r--app/policies/project_policy/class_methods.rb19
-rw-r--r--app/presenters/ci/build_presenter.rb37
-rw-r--r--app/presenters/merge_request_presenter.rb11
-rw-r--r--app/presenters/project_presenter.rb19
-rw-r--r--app/serializers/build_metadata_entity.rb5
-rw-r--r--app/serializers/entity_date_helper.rb27
-rw-r--r--app/serializers/issue_entity.rb4
-rw-r--r--app/serializers/job_entity.rb18
-rw-r--r--app/serializers/note_entity.rb6
-rw-r--r--app/serializers/status_entity.rb2
-rw-r--r--app/services/auth/container_registry_authentication_service.rb22
-rw-r--r--app/services/boards/issues/list_service.rb5
-rw-r--r--app/services/boards/issues/move_service.rb5
-rw-r--r--app/services/ci/ensure_stage_service.rb1
-rw-r--r--app/services/ci/register_job_service.rb50
-rw-r--r--app/services/ci/update_build_queue_service.rb16
-rw-r--r--app/services/clusters/gcp/finalize_creation_service.rb2
-rw-r--r--app/services/clusters/gcp/verify_provision_status_service.rb2
-rw-r--r--app/services/create_deployment_service.rb4
-rw-r--r--app/services/deploy_tokens/create_service.rb7
-rw-r--r--app/services/events/render_service.rb12
-rw-r--r--app/services/import_export_clean_up_service.rb2
-rw-r--r--app/services/issuable/destroy_service.rb1
-rw-r--r--app/services/issuable_base_service.rb3
-rw-r--r--app/services/issues/close_service.rb2
-rw-r--r--app/services/issues/move_service.rb2
-rw-r--r--app/services/issues/reopen_service.rb2
-rw-r--r--app/services/issues/update_service.rb23
-rw-r--r--app/services/labels/transfer_service.rb11
-rw-r--r--app/services/merge_requests/close_service.rb2
-rw-r--r--app/services/merge_requests/create_service.rb4
-rw-r--r--app/services/merge_requests/merge_service.rb29
-rw-r--r--app/services/merge_requests/reopen_service.rb2
-rw-r--r--app/services/merge_requests/resolved_discussion_notification_service.rb2
-rw-r--r--app/services/merge_requests/update_service.rb11
-rw-r--r--app/services/notes/post_process_service.rb10
-rw-r--r--app/services/notes/render_service.rb13
-rw-r--r--app/services/notes/resolve_service.rb9
-rw-r--r--app/services/notification_recipient_service.rb14
-rw-r--r--app/services/notification_service.rb89
-rw-r--r--app/services/projects/base_move_relations_service.rb22
-rw-r--r--app/services/projects/create_from_template_service.rb3
-rw-r--r--app/services/projects/create_service.rb2
-rw-r--r--app/services/projects/destroy_service.rb32
-rw-r--r--app/services/projects/gitlab_projects_import_service.rb26
-rw-r--r--app/services/projects/hashed_storage/migrate_repository_service.rb6
-rw-r--r--app/services/projects/import_export/export_service.rb6
-rw-r--r--app/services/projects/move_access_service.rb25
-rw-r--r--app/services/projects/move_deploy_keys_projects_service.rb31
-rw-r--r--app/services/projects/move_forks_service.rb42
-rw-r--r--app/services/projects/move_lfs_objects_projects_service.rb29
-rw-r--r--app/services/projects/move_notification_settings_service.rb38
-rw-r--r--app/services/projects/move_project_authorizations_service.rb40
-rw-r--r--app/services/projects/move_project_group_links_service.rb40
-rw-r--r--app/services/projects/move_project_members_service.rb40
-rw-r--r--app/services/projects/move_users_star_projects_service.rb20
-rw-r--r--app/services/projects/overwrite_project_service.rb69
-rw-r--r--app/services/projects/transfer_service.rb4
-rw-r--r--app/services/projects/update_pages_service.rb43
-rw-r--r--app/services/quick_actions/interpret_service.rb26
-rw-r--r--app/services/repository_archive_clean_up_service.rb7
-rw-r--r--app/services/system_note_service.rb4
-rw-r--r--app/services/test_hooks/base_service.rb2
-rw-r--r--app/uploaders/gitlab_uploader.rb4
-rw-r--r--app/uploaders/job_artifact_uploader.rb12
-rw-r--r--app/uploaders/legacy_artifact_uploader.rb4
-rw-r--r--app/uploaders/object_storage.rb66
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml2
-rw-r--r--app/views/admin/application_settings/_signin.html.haml1
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml1
-rw-r--r--app/views/admin/application_settings/show.html.haml2
-rw-r--r--app/views/admin/dashboard/index.html.haml1
-rw-r--r--app/views/admin/projects/show.html.haml4
-rw-r--r--app/views/admin/runners/_runner.html.haml6
-rw-r--r--app/views/admin/runners/index.html.haml3
-rw-r--r--app/views/admin/runners/show.html.haml5
-rw-r--r--app/views/admin/services/index.html.haml3
-rw-r--r--app/views/admin/users/_user.html.haml6
-rw-r--r--app/views/admin/users/index.html.haml14
-rw-r--r--app/views/admin/users/show.html.haml4
-rw-r--r--app/views/award_emoji/_awards_block.html.haml4
-rw-r--r--app/views/ci/status/_badge.html.haml4
-rw-r--r--app/views/ci/status/_dropdown_graph_badge.html.haml7
-rw-r--r--app/views/ci/variables/_variable_row.html.haml6
-rw-r--r--app/views/dashboard/issues.atom.builder2
-rw-r--r--app/views/dashboard/issues.html.haml16
-rw-r--r--app/views/dashboard/merge_requests.html.haml12
-rw-r--r--app/views/devise/mailer/unlock_instructions.html.haml2
-rw-r--r--app/views/devise/mailer/unlock_instructions.text.erb2
-rw-r--r--app/views/devise/shared/_tab_single.html.haml2
-rw-r--r--app/views/devise/shared/_tabs_ldap.html.haml2
-rw-r--r--app/views/devise/shared/_tabs_normal.html.haml2
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml7
-rw-r--r--app/views/email_rejection_mailer/rejection.text.haml3
-rw-r--r--app/views/groups/_group_admin_settings.html.haml52
-rw-r--r--app/views/groups/edit.html.haml2
-rw-r--r--app/views/groups/issues.atom.builder2
-rw-r--r--app/views/groups/issues.html.haml2
-rw-r--r--app/views/groups/settings/badges/index.html.haml4
-rw-r--r--app/views/layouts/header/_new_dropdown.haml4
-rw-r--r--app/views/layouts/mailer.text.erb2
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml8
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml11
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml11
-rw-r--r--app/views/layouts/notify.text.erb2
-rw-r--r--app/views/notify/issue_due_email.html.haml12
-rw-r--r--app/views/notify/issue_due_email.text.erb7
-rw-r--r--app/views/notify/push_to_merge_request_email.html.haml2
-rw-r--r--app/views/notify/push_to_merge_request_email.text.haml2
-rw-r--r--app/views/peek/_bar.html.haml7
-rw-r--r--app/views/profiles/accounts/show.html.haml16
-rw-r--r--app/views/profiles/active_sessions/_active_session.html.haml31
-rw-r--r--app/views/profiles/active_sessions/index.html.haml14
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml7
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml2
-rw-r--r--app/views/projects/_export.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml11
-rw-r--r--app/views/projects/_import_project_pane.html.haml51
-rw-r--r--app/views/projects/_last_push.html.haml7
-rw-r--r--app/views/projects/_visibility_select.html.haml9
-rw-r--r--app/views/projects/blob/_viewer.html.haml5
-rw-r--r--app/views/projects/blob/viewers/_highlight_embed.html.haml7
-rw-r--r--app/views/projects/branches/_branch.html.haml17
-rw-r--r--app/views/projects/buttons/_download.html.haml9
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml22
-rw-r--r--app/views/projects/clusters/_empty_state.html.haml5
-rw-r--r--app/views/projects/clusters/new.html.haml2
-rw-r--r--app/views/projects/commit/_commit_box.html.haml11
-rw-r--r--app/views/projects/commit/show.html.haml2
-rw-r--r--app/views/projects/commits/_commit.html.haml20
-rw-r--r--app/views/projects/deploy_tokens/_form.html.haml29
-rw-r--r--app/views/projects/deploy_tokens/_index.html.haml18
-rw-r--r--app/views/projects/deploy_tokens/_new_deploy_token.html.haml14
-rw-r--r--app/views/projects/deploy_tokens/_revoke_modal.html.haml17
-rw-r--r--app/views/projects/deploy_tokens/_table.html.haml31
-rw-r--r--app/views/projects/diffs/_collapsed.html.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml2
-rw-r--r--app/views/projects/edit.html.haml11
-rw-r--r--app/views/projects/empty.html.haml16
-rwxr-xr-x[-rw-r--r--]app/views/projects/forks/new.html.haml2
-rw-r--r--app/views/projects/issues/_discussion.html.haml1
-rw-r--r--app/views/projects/issues/_issue.html.haml2
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml15
-rw-r--r--app/views/projects/issues/_new_branch.html.haml8
-rw-r--r--app/views/projects/issues/index.atom.builder2
-rw-r--r--app/views/projects/issues/index.html.haml2
-rw-r--r--app/views/projects/issues/show.html.haml13
-rw-r--r--app/views/projects/jobs/_empty_state.html.haml5
-rw-r--r--app/views/projects/jobs/_empty_states.html.haml9
-rw-r--r--app/views/projects/jobs/_sidebar.html.haml19
-rw-r--r--app/views/projects/jobs/index.html.haml2
-rw-r--r--app/views/projects/jobs/show.html.haml25
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml34
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml8
-rw-r--r--app/views/projects/merge_requests/dropdowns/_project.html.haml2
-rw-r--r--app/views/projects/merge_requests/index.html.haml2
-rw-r--r--app/views/projects/merge_requests/show.html.haml1
-rw-r--r--app/views/projects/new.html.haml53
-rw-r--r--app/views/projects/notes/_actions.html.haml2
-rw-r--r--app/views/projects/pages/_list.html.haml37
-rw-r--r--app/views/projects/pages/show.html.haml3
-rw-r--r--app/views/projects/pages_domains/edit.html.haml2
-rw-r--r--app/views/projects/pages_domains/new.html.haml2
-rw-r--r--app/views/projects/pages_domains/show.html.haml54
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/projects/pipelines/new.html.haml15
-rw-r--r--app/views/projects/protected_branches/_branches_list.html.haml2
-rw-r--r--app/views/projects/protected_branches/_create_protected_branch.html.haml8
-rw-r--r--app/views/projects/protected_branches/_update_protected_branch.html.haml4
-rw-r--r--app/views/projects/protected_branches/shared/_branches_list.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_dropdown.html.haml4
-rw-r--r--app/views/projects/protected_branches/shared/_index.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_protected_branch.html.haml4
-rw-r--r--app/views/projects/protected_tags/shared/_tags_list.html.haml2
-rw-r--r--app/views/projects/registry/repositories/_tag.html.haml2
-rw-r--r--app/views/projects/registry/repositories/index.html.haml4
-rw-r--r--app/views/projects/runners/_group_runners.html.haml32
-rw-r--r--app/views/projects/runners/_index.html.haml4
-rw-r--r--app/views/projects/runners/_runner.html.haml2
-rw-r--r--app/views/projects/runners/show.html.haml2
-rw-r--r--app/views/projects/services/_index.html.haml3
-rw-r--r--app/views/projects/settings/badges/index.html.haml4
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml41
-rw-r--r--app/views/projects/settings/ci_cd/_badge.html.haml (renamed from app/views/projects/pipelines_settings/_badge.html.haml)0
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml (renamed from app/views/projects/pipelines_settings/_show.html.haml)52
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml19
-rw-r--r--app/views/projects/settings/repository/show.html.haml1
-rw-r--r--app/views/projects/show.html.haml2
-rw-r--r--app/views/projects/tags/_tag.html.haml6
-rw-r--r--app/views/projects/tags/show.html.haml2
-rw-r--r--app/views/projects/tree/_tree_header.html.haml33
-rw-r--r--app/views/projects/triggers/_trigger.html.haml2
-rw-r--r--app/views/shared/_auto_devops_callout.html.haml10
-rw-r--r--app/views/shared/_group_form.html.haml7
-rw-r--r--app/views/shared/_label.html.haml26
-rw-r--r--app/views/shared/_recaptcha_form.html.haml2
-rw-r--r--app/views/shared/_ref_switcher.html.haml4
-rw-r--r--app/views/shared/badges/_badge_settings.html.haml4
-rw-r--r--app/views/shared/boards/components/_sidebar.html.haml2
-rw-r--r--app/views/shared/boards/components/sidebar/_assignee.html.haml3
-rw-r--r--app/views/shared/boards/components/sidebar/_due_date.html.haml3
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml5
-rw-r--r--app/views/shared/boards/components/sidebar/_milestone.html.haml3
-rw-r--r--app/views/shared/dashboard/_no_filter_selected.html.haml8
-rw-r--r--app/views/shared/issuable/_filter.html.haml9
-rw-r--r--app/views/shared/issuable/_nav.html.haml11
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml13
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar_todo.html.haml6
-rw-r--r--app/views/shared/issuable/form/_merge_request_assignee.html.haml2
-rw-r--r--app/views/shared/members/_group.html.haml2
-rw-r--r--app/views/shared/members/_member.html.haml2
-rw-r--r--app/views/shared/milestones/_deprecation_message.html.haml14
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml37
-rw-r--r--app/views/shared/milestones/_top.html.haml13
-rw-r--r--app/views/shared/notes/_note.html.haml2
-rw-r--r--app/views/shared/snippets/_embed.html.haml24
-rw-r--r--app/views/shared/snippets/_header.html.haml25
-rw-r--r--app/views/shared/snippets/show.js.haml2
-rw-r--r--app/views/shared/web_hooks/_form.html.haml7
-rw-r--r--app/views/sherlock/transactions/_general.html.haml3
-rw-r--r--app/views/sherlock/transactions/index.html.haml3
-rw-r--r--app/views/users/show.html.haml2
-rw-r--r--app/workers/all_queues.yml4
-rw-r--r--app/workers/authorized_projects_worker.rb8
-rw-r--r--app/workers/concerns/mail_scheduler_queue.rb11
-rw-r--r--app/workers/issue_due_scheduler_worker.rb10
-rw-r--r--app/workers/mail_scheduler/issue_due_worker.rb12
-rw-r--r--app/workers/mail_scheduler/notification_service_worker.rb19
-rw-r--r--app/workers/new_note_worker.rb2
-rw-r--r--app/workers/post_receive.rb2
-rw-r--r--app/workers/repository_fork_worker.rb4
-rw-r--r--app/workers/stuck_ci_jobs_worker.rb2
668 files changed, 16926 insertions, 8131 deletions
diff --git a/app/assets/images/ext_snippet_icons/ext_snippet_icons.png b/app/assets/images/ext_snippet_icons/ext_snippet_icons.png
new file mode 100644
index 00000000000..20380adc4e5
--- /dev/null
+++ b/app/assets/images/ext_snippet_icons/ext_snippet_icons.png
Binary files differ
diff --git a/app/assets/images/ext_snippet_icons/logo.png b/app/assets/images/ext_snippet_icons/logo.png
new file mode 100644
index 00000000000..794c9cc2dbc
--- /dev/null
+++ b/app/assets/images/ext_snippet_icons/logo.png
Binary files differ
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 0e1ca7fe883..976d32abe9b 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -4,7 +4,8 @@ import $ from 'jquery';
import _ from 'underscore';
import Cookies from 'js-cookie';
import { __ } from './locale';
-import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils';
+import { updateTooltipTitle } from './lib/utils/common_utils';
+import { isInVueNoteablePage } from './lib/utils/dom_utils';
import flash from './flash';
import axios from './lib/utils/axios_utils';
@@ -243,7 +244,7 @@ class AwardsHandler {
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length;
- if (this.isInVueNoteablePage() && !isMainAwardsBlock) {
+ if (isInVueNoteablePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', '');
this.hideMenuElement($('.emoji-menu'));
@@ -295,16 +296,8 @@ class AwardsHandler {
}
}
- isVueMRDiscussions() {
- return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
- }
-
- isInVueNoteablePage() {
- return isInIssuePage() || isInEpicPage() || this.isVueMRDiscussions();
- }
-
getVotesBlock() {
- if (this.isInVueNoteablePage()) {
+ if (isInVueNoteablePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) {
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
new file mode 100644
index 00000000000..6e6cb31e3ac
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -0,0 +1,121 @@
+<script>
+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 {
+ name: 'Badge',
+ components: {
+ Icon,
+ LoadingIcon,
+ Tooltip,
+ },
+ directives: {
+ Tooltip,
+ },
+ props: {
+ imageUrl: {
+ type: String,
+ required: true,
+ },
+ linkUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ hasError: false,
+ isLoading: true,
+ numRetries: 0,
+ };
+ },
+ computed: {
+ imageUrlWithRetries() {
+ if (this.numRetries === 0) {
+ return this.imageUrl;
+ }
+
+ return `${this.imageUrl}#retries=${this.numRetries}`;
+ },
+ },
+ watch: {
+ imageUrl() {
+ this.hasError = false;
+ this.isLoading = true;
+ this.numRetries = 0;
+ },
+ },
+ methods: {
+ onError() {
+ this.isLoading = false;
+ this.hasError = true;
+ },
+ onLoad() {
+ this.isLoading = false;
+ },
+ reloadImage() {
+ this.hasError = false;
+ this.isLoading = true;
+ this.numRetries += 1;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <a
+ v-show="!isLoading && !hasError"
+ :href="linkUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <img
+ class="project-badge"
+ :src="imageUrlWithRetries"
+ @load="onLoad"
+ @error="onError"
+ aria-hidden="true"
+ />
+ </a>
+
+ <loading-icon
+ v-show="isLoading"
+ :inline="true"
+ />
+
+ <div
+ v-show="hasError"
+ class="btn-group"
+ >
+ <div class="btn btn-default btn-xs disabled">
+ <icon
+ class="prepend-left-8 append-right-8"
+ name="doc_image"
+ :size="16"
+ aria-hidden="true"
+ />
+ </div>
+ <div
+ class="btn btn-default btn-xs disabled"
+ >
+ <span class="prepend-left-8 append-right-8">{{ s__('Badges|No badge image') }}</span>
+ </div>
+ </div>
+
+ <button
+ v-show="hasError"
+ class="btn btn-transparent btn-xs text-primary"
+ type="button"
+ v-tooltip
+ :title="s__('Badges|Reload badge image')"
+ @click="reloadImage"
+ >
+ <icon
+ name="retry"
+ :size="16"
+ />
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
new file mode 100644
index 00000000000..ae942b2c1a7
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -0,0 +1,219 @@
+<script>
+import _ from 'underscore';
+import { mapActions, mapState } from 'vuex';
+import createFlash from '~/flash';
+import { s__, sprintf } from '~/locale';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import createEmptyBadge from '../empty_badge';
+import Badge from './badge.vue';
+
+const badgePreviewDelayInMilliseconds = 1500;
+
+export default {
+ name: 'BadgeForm',
+ components: {
+ Badge,
+ LoadingButton,
+ LoadingIcon,
+ },
+ props: {
+ isEditing: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'badgeInAddForm',
+ 'badgeInEditForm',
+ 'docsUrl',
+ 'isRendering',
+ 'isSaving',
+ 'renderedBadge',
+ ]),
+ badge() {
+ if (this.isEditing) {
+ return this.badgeInEditForm;
+ }
+
+ return this.badgeInAddForm;
+ },
+ canSubmit() {
+ return (
+ this.badge !== null &&
+ this.badge.imageUrl &&
+ this.badge.imageUrl.trim() !== '' &&
+ this.badge.linkUrl &&
+ this.badge.linkUrl.trim() !== '' &&
+ !this.isSaving
+ );
+ },
+ helpText() {
+ const placeholders = ['project_path', 'project_id', 'default_branch', 'commit_sha']
+ .map(placeholder => `<code>%{${placeholder}}</code>`)
+ .join(', ');
+ return sprintf(
+ s__('Badges|The %{docsLinkStart}variables%{docsLinkEnd} GitLab supports: %{placeholders}'),
+ {
+ docsLinkEnd: '</a>',
+ docsLinkStart: `<a href="${_.escape(this.docsUrl)}">`,
+ placeholders,
+ },
+ false,
+ );
+ },
+ renderedImageUrl() {
+ return this.renderedBadge ? this.renderedBadge.renderedImageUrl : '';
+ },
+ renderedLinkUrl() {
+ return this.renderedBadge ? this.renderedBadge.renderedLinkUrl : '';
+ },
+ imageUrl: {
+ get() {
+ return this.badge ? this.badge.imageUrl : '';
+ },
+ set(imageUrl) {
+ const badge = this.badge || createEmptyBadge();
+ this.updateBadgeInForm({
+ ...badge,
+ imageUrl,
+ });
+ },
+ },
+ linkUrl: {
+ get() {
+ return this.badge ? this.badge.linkUrl : '';
+ },
+ set(linkUrl) {
+ const badge = this.badge || createEmptyBadge();
+ this.updateBadgeInForm({
+ ...badge,
+ linkUrl,
+ });
+ },
+ },
+ submitButtonLabel() {
+ if (this.isEditing) {
+ return s__('Badges|Save changes');
+ }
+ return s__('Badges|Add badge');
+ },
+ },
+ methods: {
+ ...mapActions(['addBadge', 'renderBadge', 'saveBadge', 'stopEditing', 'updateBadgeInForm']),
+ debouncedPreview: _.debounce(function preview() {
+ this.renderBadge();
+ }, badgePreviewDelayInMilliseconds),
+ onCancel() {
+ this.stopEditing();
+ },
+ onSubmit() {
+ if (!this.canSubmit) {
+ return Promise.resolve();
+ }
+
+ if (this.isEditing) {
+ return this.saveBadge()
+ .then(() => {
+ createFlash(s__('Badges|The badge was saved.'), 'notice');
+ })
+ .catch(error => {
+ createFlash(
+ s__('Badges|Saving the badge failed, please check the entered URLs and try again.'),
+ );
+ throw error;
+ });
+ }
+
+ return this.addBadge()
+ .then(() => {
+ createFlash(s__('Badges|A new badge was added.'), 'notice');
+ })
+ .catch(error => {
+ createFlash(
+ s__('Badges|Adding the badge failed, please check the entered URLs and try again.'),
+ );
+ throw error;
+ });
+ },
+ },
+ badgeImageUrlPlaceholder:
+ 'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/<badge>.svg',
+ badgeLinkUrlPlaceholder: 'https://example.gitlab.com/%{project_path}',
+};
+</script>
+
+<template>
+ <form
+ class="prepend-top-default append-bottom-default"
+ @submit.prevent.stop="onSubmit"
+ >
+ <div class="form-group">
+ <label for="badge-link-url">{{ s__('Badges|Link') }}</label>
+ <input
+ id="badge-link-url"
+ type="text"
+ class="form-control"
+ v-model="linkUrl"
+ :placeholder="$options.badgeLinkUrlPlaceholder"
+ @input="debouncedPreview"
+ />
+ <span
+ class="help-block"
+ v-html="helpText"
+ ></span>
+ </div>
+
+ <div class="form-group">
+ <label for="badge-image-url">{{ s__('Badges|Badge image URL') }}</label>
+ <input
+ id="badge-image-url"
+ type="text"
+ class="form-control"
+ v-model="imageUrl"
+ :placeholder="$options.badgeImageUrlPlaceholder"
+ @input="debouncedPreview"
+ />
+ <span
+ class="help-block"
+ v-html="helpText"
+ ></span>
+ </div>
+
+ <div class="form-group">
+ <label for="badge-preview">{{ s__('Badges|Badge image preview') }}</label>
+ <badge
+ id="badge-preview"
+ v-show="renderedBadge && !isRendering"
+ :image-url="renderedImageUrl"
+ :link-url="renderedLinkUrl"
+ />
+ <p v-show="isRendering">
+ <loading-icon
+ :inline="true"
+ />
+ </p>
+ <p
+ v-show="!renderedBadge && !isRendering"
+ class="disabled-content"
+ >{{ s__('Badges|No image to preview') }}</p>
+ </div>
+
+ <div class="row-content-block">
+ <loading-button
+ type="submit"
+ container-class="btn btn-success"
+ :disabled="!canSubmit"
+ :loading="isSaving"
+ :label="submitButtonLabel"
+ />
+ <button
+ class="btn btn-cancel"
+ type="button"
+ v-if="isEditing"
+ @click="onCancel"
+ >{{ __('Cancel') }}</button>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue
new file mode 100644
index 00000000000..ca7197e1e0f
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_list.vue
@@ -0,0 +1,57 @@
+<script>
+import { mapState } from 'vuex';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import BadgeListRow from './badge_list_row.vue';
+import { GROUP_BADGE } from '../constants';
+
+export default {
+ name: 'BadgeList',
+ components: {
+ BadgeListRow,
+ LoadingIcon,
+ },
+ computed: {
+ ...mapState(['badges', 'isLoading', 'kind']),
+ hasNoBadges() {
+ return !this.isLoading && (!this.badges || !this.badges.length);
+ },
+ isGroupBadge() {
+ return this.kind === GROUP_BADGE;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="panel panel-default">
+ <div class="panel-heading">
+ {{ s__('Badges|Your badges') }}
+ <span
+ v-show="!isLoading"
+ class="badge"
+ >{{ badges.length }}</span>
+ </div>
+ <loading-icon
+ v-show="isLoading"
+ class="panel-body"
+ size="2"
+ />
+ <div
+ v-if="hasNoBadges"
+ class="panel-body"
+ >
+ <span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span>
+ <span v-else>{{ s__('Badges|This project has no badges') }}</span>
+ </div>
+ <div
+ v-else
+ class="panel-body"
+ >
+ <badge-list-row
+ v-for="badge in badges"
+ :key="badge.id"
+ :badge="badge"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
new file mode 100644
index 00000000000..af062bdf8c6
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_list_row.vue
@@ -0,0 +1,89 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { s__ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import { PROJECT_BADGE } from '../constants';
+import Badge from './badge.vue';
+
+export default {
+ name: 'BadgeListRow',
+ components: {
+ Badge,
+ Icon,
+ LoadingIcon,
+ },
+ props: {
+ badge: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['kind']),
+ badgeKindText() {
+ if (this.badge.kind === PROJECT_BADGE) {
+ return s__('Badges|Project Badge');
+ }
+
+ return s__('Badges|Group Badge');
+ },
+ canEditBadge() {
+ return this.badge.kind === this.kind;
+ },
+ },
+ methods: {
+ ...mapActions(['editBadge', 'updateBadgeInModal']),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-responsive-table-row-layout gl-responsive-table-row">
+ <badge
+ class="table-section section-30"
+ :image-url="badge.renderedImageUrl"
+ :link-url="badge.renderedLinkUrl"
+ />
+ <span class="table-section section-50 str-truncated">{{ badge.linkUrl }}</span>
+ <div class="table-section section-10">
+ <span class="badge">{{ badgeKindText }}</span>
+ </div>
+ <div class="table-section section-10 table-button-footer">
+ <div
+ v-if="canEditBadge"
+ class="table-action-buttons">
+ <button
+ class="btn btn-default append-right-8"
+ type="button"
+ :disabled="badge.isDeleting"
+ @click="editBadge(badge)"
+ >
+ <icon
+ name="pencil"
+ :size="16"
+ :aria-label="__('Edit')"
+ />
+ </button>
+ <button
+ class="btn btn-danger"
+ type="button"
+ data-toggle="modal"
+ data-target="#delete-badge-modal"
+ :disabled="badge.isDeleting"
+ @click="updateBadgeInModal(badge)"
+ >
+ <icon
+ name="remove"
+ :size="16"
+ :aria-label="__('Delete')"
+ />
+ </button>
+ <loading-icon
+ v-show="badge.isDeleting"
+ :inline="true"
+ />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue
new file mode 100644
index 00000000000..83f78394238
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_settings.vue
@@ -0,0 +1,70 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import GlModal from '~/vue_shared/components/gl_modal.vue';
+import Badge from './badge.vue';
+import BadgeForm from './badge_form.vue';
+import BadgeList from './badge_list.vue';
+
+export default {
+ name: 'BadgeSettings',
+ components: {
+ Badge,
+ BadgeForm,
+ BadgeList,
+ GlModal,
+ },
+ computed: {
+ ...mapState(['badgeInModal', 'isEditing']),
+ deleteModalText() {
+ return s__(
+ 'Badges|You are going to delete this badge. Deleted badges <strong>cannot</strong> be restored.',
+ );
+ },
+ },
+ methods: {
+ ...mapActions(['deleteBadge']),
+ onSubmitModal() {
+ this.deleteBadge(this.badgeInModal)
+ .then(() => {
+ createFlash(s__('Badges|The badge was deleted.'), 'notice');
+ })
+ .catch(error => {
+ createFlash(s__('Badges|Deleting the badge failed, please try again.'));
+ throw error;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="badge-settings">
+ <gl-modal
+ id="delete-badge-modal"
+ :header-title-text="s__('Badges|Delete badge?')"
+ footer-primary-button-variant="danger"
+ :footer-primary-button-text="s__('Badges|Delete badge')"
+ @submit="onSubmitModal">
+ <div class="well">
+ <badge
+ :image-url="badgeInModal ? badgeInModal.renderedImageUrl : ''"
+ :link-url="badgeInModal ? badgeInModal.renderedLinkUrl : ''"
+ />
+ </div>
+ <p v-html="deleteModalText"></p>
+ </gl-modal>
+
+ <badge-form
+ v-show="isEditing"
+ :is-editing="true"
+ />
+
+ <badge-form
+ v-show="!isEditing"
+ :is-editing="false"
+ />
+ <badge-list v-show="!isEditing" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/badges/constants.js b/app/assets/javascripts/badges/constants.js
new file mode 100644
index 00000000000..8fbe3db5ef1
--- /dev/null
+++ b/app/assets/javascripts/badges/constants.js
@@ -0,0 +1,2 @@
+export const GROUP_BADGE = 'group';
+export const PROJECT_BADGE = 'project';
diff --git a/app/assets/javascripts/badges/empty_badge.js b/app/assets/javascripts/badges/empty_badge.js
new file mode 100644
index 00000000000..49a9b5e1be8
--- /dev/null
+++ b/app/assets/javascripts/badges/empty_badge.js
@@ -0,0 +1,7 @@
+export default () => ({
+ imageUrl: '',
+ isDeleting: false,
+ linkUrl: '',
+ renderedImageUrl: '',
+ renderedLinkUrl: '',
+});
diff --git a/app/assets/javascripts/badges/store/actions.js b/app/assets/javascripts/badges/store/actions.js
new file mode 100644
index 00000000000..5542278b3e0
--- /dev/null
+++ b/app/assets/javascripts/badges/store/actions.js
@@ -0,0 +1,167 @@
+import axios from '~/lib/utils/axios_utils';
+import types from './mutation_types';
+
+export const transformBackendBadge = badge => ({
+ id: badge.id,
+ imageUrl: badge.image_url,
+ kind: badge.kind,
+ linkUrl: badge.link_url,
+ renderedImageUrl: badge.rendered_image_url,
+ renderedLinkUrl: badge.rendered_link_url,
+ isDeleting: false,
+});
+
+export default {
+ requestNewBadge({ commit }) {
+ commit(types.REQUEST_NEW_BADGE);
+ },
+ receiveNewBadge({ commit }, newBadge) {
+ commit(types.RECEIVE_NEW_BADGE, newBadge);
+ },
+ receiveNewBadgeError({ commit }) {
+ commit(types.RECEIVE_NEW_BADGE_ERROR);
+ },
+ addBadge({ dispatch, state }) {
+ const newBadge = state.badgeInAddForm;
+ const endpoint = state.apiEndpointUrl;
+ dispatch('requestNewBadge');
+ return axios
+ .post(endpoint, {
+ image_url: newBadge.imageUrl,
+ link_url: newBadge.linkUrl,
+ })
+ .catch(error => {
+ dispatch('receiveNewBadgeError');
+ throw error;
+ })
+ .then(res => {
+ dispatch('receiveNewBadge', transformBackendBadge(res.data));
+ });
+ },
+ requestDeleteBadge({ commit }, badgeId) {
+ commit(types.REQUEST_DELETE_BADGE, badgeId);
+ },
+ receiveDeleteBadge({ commit }, badgeId) {
+ commit(types.RECEIVE_DELETE_BADGE, badgeId);
+ },
+ receiveDeleteBadgeError({ commit }, badgeId) {
+ commit(types.RECEIVE_DELETE_BADGE_ERROR, badgeId);
+ },
+ deleteBadge({ dispatch, state }, badge) {
+ const badgeId = badge.id;
+ dispatch('requestDeleteBadge', badgeId);
+ const endpoint = `${state.apiEndpointUrl}/${badgeId}`;
+ return axios
+ .delete(endpoint)
+ .catch(error => {
+ dispatch('receiveDeleteBadgeError', badgeId);
+ throw error;
+ })
+ .then(() => {
+ dispatch('receiveDeleteBadge', badgeId);
+ });
+ },
+
+ editBadge({ commit }, badge) {
+ commit(types.START_EDITING, badge);
+ },
+
+ requestLoadBadges({ commit }, data) {
+ commit(types.REQUEST_LOAD_BADGES, data);
+ },
+ receiveLoadBadges({ commit }, badges) {
+ commit(types.RECEIVE_LOAD_BADGES, badges);
+ },
+ receiveLoadBadgesError({ commit }) {
+ commit(types.RECEIVE_LOAD_BADGES_ERROR);
+ },
+
+ loadBadges({ dispatch, state }, data) {
+ dispatch('requestLoadBadges', data);
+ const endpoint = state.apiEndpointUrl;
+ return axios
+ .get(endpoint)
+ .catch(error => {
+ dispatch('receiveLoadBadgesError');
+ throw error;
+ })
+ .then(res => {
+ dispatch('receiveLoadBadges', res.data.map(transformBackendBadge));
+ });
+ },
+
+ requestRenderedBadge({ commit }) {
+ commit(types.REQUEST_RENDERED_BADGE);
+ },
+ receiveRenderedBadge({ commit }, renderedBadge) {
+ commit(types.RECEIVE_RENDERED_BADGE, renderedBadge);
+ },
+ receiveRenderedBadgeError({ commit }) {
+ commit(types.RECEIVE_RENDERED_BADGE_ERROR);
+ },
+
+ renderBadge({ dispatch, state }) {
+ const badge = state.isEditing ? state.badgeInEditForm : state.badgeInAddForm;
+ const { linkUrl, imageUrl } = badge;
+ if (!linkUrl || linkUrl.trim() === '' || !imageUrl || imageUrl.trim() === '') {
+ return Promise.resolve(badge);
+ }
+
+ dispatch('requestRenderedBadge');
+
+ const parameters = [
+ `link_url=${encodeURIComponent(linkUrl)}`,
+ `image_url=${encodeURIComponent(imageUrl)}`,
+ ].join('&');
+ const renderEndpoint = `${state.apiEndpointUrl}/render?${parameters}`;
+ return axios
+ .get(renderEndpoint)
+ .catch(error => {
+ dispatch('receiveRenderedBadgeError');
+ throw error;
+ })
+ .then(res => {
+ dispatch('receiveRenderedBadge', transformBackendBadge(res.data));
+ });
+ },
+
+ requestUpdatedBadge({ commit }) {
+ commit(types.REQUEST_UPDATED_BADGE);
+ },
+ receiveUpdatedBadge({ commit }, updatedBadge) {
+ commit(types.RECEIVE_UPDATED_BADGE, updatedBadge);
+ },
+ receiveUpdatedBadgeError({ commit }) {
+ commit(types.RECEIVE_UPDATED_BADGE_ERROR);
+ },
+
+ saveBadge({ dispatch, state }) {
+ const badge = state.badgeInEditForm;
+ const endpoint = `${state.apiEndpointUrl}/${badge.id}`;
+ dispatch('requestUpdatedBadge');
+ return axios
+ .put(endpoint, {
+ image_url: badge.imageUrl,
+ link_url: badge.linkUrl,
+ })
+ .catch(error => {
+ dispatch('receiveUpdatedBadgeError');
+ throw error;
+ })
+ .then(res => {
+ dispatch('receiveUpdatedBadge', transformBackendBadge(res.data));
+ });
+ },
+
+ stopEditing({ commit }) {
+ commit(types.STOP_EDITING);
+ },
+
+ updateBadgeInForm({ commit }, badge) {
+ commit(types.UPDATE_BADGE_IN_FORM, badge);
+ },
+
+ updateBadgeInModal({ commit }, badge) {
+ commit(types.UPDATE_BADGE_IN_MODAL, badge);
+ },
+};
diff --git a/app/assets/javascripts/badges/store/index.js b/app/assets/javascripts/badges/store/index.js
new file mode 100644
index 00000000000..7a5df403a0e
--- /dev/null
+++ b/app/assets/javascripts/badges/store/index.js
@@ -0,0 +1,13 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ state: createState(),
+ actions,
+ mutations,
+});
diff --git a/app/assets/javascripts/badges/store/mutation_types.js b/app/assets/javascripts/badges/store/mutation_types.js
new file mode 100644
index 00000000000..d73f91b6005
--- /dev/null
+++ b/app/assets/javascripts/badges/store/mutation_types.js
@@ -0,0 +1,21 @@
+export default {
+ RECEIVE_DELETE_BADGE: 'RECEIVE_DELETE_BADGE',
+ RECEIVE_DELETE_BADGE_ERROR: 'RECEIVE_DELETE_BADGE_ERROR',
+ RECEIVE_LOAD_BADGES: 'RECEIVE_LOAD_BADGES',
+ RECEIVE_LOAD_BADGES_ERROR: 'RECEIVE_LOAD_BADGES_ERROR',
+ RECEIVE_NEW_BADGE: 'RECEIVE_NEW_BADGE',
+ RECEIVE_NEW_BADGE_ERROR: 'RECEIVE_NEW_BADGE_ERROR',
+ RECEIVE_RENDERED_BADGE: 'RECEIVE_RENDERED_BADGE',
+ RECEIVE_RENDERED_BADGE_ERROR: 'RECEIVE_RENDERED_BADGE_ERROR',
+ RECEIVE_UPDATED_BADGE: 'RECEIVE_UPDATED_BADGE',
+ RECEIVE_UPDATED_BADGE_ERROR: 'RECEIVE_UPDATED_BADGE_ERROR',
+ REQUEST_DELETE_BADGE: 'REQUEST_DELETE_BADGE',
+ REQUEST_LOAD_BADGES: 'REQUEST_LOAD_BADGES',
+ REQUEST_NEW_BADGE: 'REQUEST_NEW_BADGE',
+ REQUEST_RENDERED_BADGE: 'REQUEST_RENDERED_BADGE',
+ REQUEST_UPDATED_BADGE: 'REQUEST_UPDATED_BADGE',
+ START_EDITING: 'START_EDITING',
+ STOP_EDITING: 'STOP_EDITING',
+ UPDATE_BADGE_IN_FORM: 'UPDATE_BADGE_IN_FORM',
+ UPDATE_BADGE_IN_MODAL: 'UPDATE_BADGE_IN_MODAL',
+};
diff --git a/app/assets/javascripts/badges/store/mutations.js b/app/assets/javascripts/badges/store/mutations.js
new file mode 100644
index 00000000000..bd84e68c00f
--- /dev/null
+++ b/app/assets/javascripts/badges/store/mutations.js
@@ -0,0 +1,158 @@
+import types from './mutation_types';
+import { PROJECT_BADGE } from '../constants';
+
+const reorderBadges = badges =>
+ badges.sort((a, b) => {
+ if (a.kind !== b.kind) {
+ return a.kind === PROJECT_BADGE ? 1 : -1;
+ }
+
+ return a.id - b.id;
+ });
+
+export default {
+ [types.RECEIVE_NEW_BADGE](state, newBadge) {
+ Object.assign(state, {
+ badgeInAddForm: null,
+ badges: reorderBadges(state.badges.concat(newBadge)),
+ isSaving: false,
+ renderedBadge: null,
+ });
+ },
+ [types.RECEIVE_NEW_BADGE_ERROR](state) {
+ Object.assign(state, {
+ isSaving: false,
+ });
+ },
+ [types.REQUEST_NEW_BADGE](state) {
+ Object.assign(state, {
+ isSaving: true,
+ });
+ },
+
+ [types.RECEIVE_UPDATED_BADGE](state, updatedBadge) {
+ const badges = state.badges.map(badge => {
+ if (badge.id === updatedBadge.id) {
+ return updatedBadge;
+ }
+ return badge;
+ });
+ Object.assign(state, {
+ badgeInEditForm: null,
+ badges,
+ isEditing: false,
+ isSaving: false,
+ renderedBadge: null,
+ });
+ },
+ [types.RECEIVE_UPDATED_BADGE_ERROR](state) {
+ Object.assign(state, {
+ isSaving: false,
+ });
+ },
+ [types.REQUEST_UPDATED_BADGE](state) {
+ Object.assign(state, {
+ isSaving: true,
+ });
+ },
+
+ [types.RECEIVE_LOAD_BADGES](state, badges) {
+ Object.assign(state, {
+ badges: reorderBadges(badges),
+ isLoading: false,
+ });
+ },
+ [types.RECEIVE_LOAD_BADGES_ERROR](state) {
+ Object.assign(state, {
+ isLoading: false,
+ });
+ },
+ [types.REQUEST_LOAD_BADGES](state, data) {
+ Object.assign(state, {
+ kind: data.kind, // project or group
+ apiEndpointUrl: data.apiEndpointUrl,
+ docsUrl: data.docsUrl,
+ isLoading: true,
+ });
+ },
+
+ [types.RECEIVE_DELETE_BADGE](state, badgeId) {
+ const badges = state.badges.filter(badge => badge.id !== badgeId);
+ Object.assign(state, {
+ badges,
+ });
+ },
+ [types.RECEIVE_DELETE_BADGE_ERROR](state, badgeId) {
+ const badges = state.badges.map(badge => {
+ if (badge.id === badgeId) {
+ return {
+ ...badge,
+ isDeleting: false,
+ };
+ }
+
+ return badge;
+ });
+ Object.assign(state, {
+ badges,
+ });
+ },
+ [types.REQUEST_DELETE_BADGE](state, badgeId) {
+ const badges = state.badges.map(badge => {
+ if (badge.id === badgeId) {
+ return {
+ ...badge,
+ isDeleting: true,
+ };
+ }
+
+ return badge;
+ });
+ Object.assign(state, {
+ badges,
+ });
+ },
+
+ [types.RECEIVE_RENDERED_BADGE](state, renderedBadge) {
+ Object.assign(state, { isRendering: false, renderedBadge });
+ },
+ [types.RECEIVE_RENDERED_BADGE_ERROR](state) {
+ Object.assign(state, { isRendering: false });
+ },
+ [types.REQUEST_RENDERED_BADGE](state) {
+ Object.assign(state, { isRendering: true });
+ },
+
+ [types.START_EDITING](state, badge) {
+ Object.assign(state, {
+ badgeInEditForm: { ...badge },
+ isEditing: true,
+ renderedBadge: { ...badge },
+ });
+ },
+ [types.STOP_EDITING](state) {
+ Object.assign(state, {
+ badgeInEditForm: null,
+ isEditing: false,
+ renderedBadge: null,
+ });
+ },
+
+ [types.UPDATE_BADGE_IN_FORM](state, badge) {
+ if (state.isEditing) {
+ Object.assign(state, {
+ badgeInEditForm: badge,
+ });
+ } else {
+ Object.assign(state, {
+ badgeInAddForm: badge,
+ });
+ }
+ },
+
+ [types.UPDATE_BADGE_IN_MODAL](state, badge) {
+ Object.assign(state, {
+ badgeInModal: badge,
+ });
+ },
+};
diff --git a/app/assets/javascripts/badges/store/state.js b/app/assets/javascripts/badges/store/state.js
new file mode 100644
index 00000000000..43413aeb5bb
--- /dev/null
+++ b/app/assets/javascripts/badges/store/state.js
@@ -0,0 +1,13 @@
+export default () => ({
+ apiEndpointUrl: null,
+ badgeInAddForm: null,
+ badgeInEditForm: null,
+ badgeInModal: null,
+ badges: [],
+ docsUrl: null,
+ renderedBadge: null,
+ isEditing: false,
+ isLoading: false,
+ isRendering: false,
+ isSaving: false,
+});
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index 7e98e04303a..56293d5f96f 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -7,27 +7,24 @@ export default function installGlEmojiElement() {
const GlEmojiElementProto = Object.create(HTMLElement.prototype);
GlEmojiElementProto.createdCallback = function createdCallback() {
const emojiUnicode = this.textContent.trim();
- const {
- name,
- unicodeVersion,
- fallbackSrc,
- fallbackSpriteClass,
- } = this.dataset;
+ const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset;
- const isEmojiUnicode = this.childNodes && Array.prototype.every.call(
- this.childNodes,
- childNode => childNode.nodeType === 3,
- );
+ const isEmojiUnicode =
+ this.childNodes &&
+ Array.prototype.every.call(this.childNodes, childNode => childNode.nodeType === 3);
const hasImageFallback = fallbackSrc && fallbackSrc.length > 0;
const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0;
- if (
- emojiUnicode &&
- isEmojiUnicode &&
- !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)
- ) {
+ if (emojiUnicode && isEmojiUnicode && !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)) {
// CSS sprite fallback takes precedence over image fallback
if (hasCssSpriteFalback) {
+ if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) {
+ const emojiSpriteLinkTag = document.createElement('link');
+ emojiSpriteLinkTag.setAttribute('rel', 'stylesheet');
+ emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path);
+ document.head.appendChild(emojiSpriteLinkTag);
+ gon.emoji_sprites_css_added = true;
+ }
// IE 11 doesn't like adding multiple at once :(
this.classList.add('emoji-icon');
this.classList.add(fallbackSpriteClass);
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 030ca1907e5..ff1cbcad145 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -94,7 +94,7 @@ export default class FileTemplateMediator {
const hash = urlPieces[1];
if (hash === 'preview') {
this.hideTemplateSelectorMenu();
- } else if (hash === 'editor') {
+ } else if (hash === 'editor' && !this.typeSelector.isHidden()) {
this.showTemplateSelectorMenu();
}
});
diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js
index e52cf249f3a..02228434a29 100644
--- a/app/assets/javascripts/blob/file_template_selector.js
+++ b/app/assets/javascripts/blob/file_template_selector.js
@@ -32,6 +32,10 @@ export default class FileTemplateSelector {
}
}
+ isHidden() {
+ return this.$wrapper.hasClass('hidden');
+ }
+
getToggleText() {
return this.$dropdownToggleText.text();
}
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index 3cffd91716a..bea818010a4 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -5,7 +5,7 @@ import Sortable from 'vendor/Sortable';
import Vue from 'vue';
import AccessorUtilities from '../../lib/utils/accessor';
import boardList from './board_list.vue';
-import boardBlankState from './board_blank_state';
+import BoardBlankState from './board_blank_state.vue';
import './board_delete';
const Store = gl.issueBoards.BoardsStore;
@@ -18,7 +18,7 @@ gl.issueBoards.Board = Vue.extend({
components: {
boardList,
'board-delete': gl.issueBoards.BoardDelete,
- boardBlankState,
+ BoardBlankState,
},
props: {
list: Object,
diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.vue
index 72db626d3c7..2049eeb9c30 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.js
+++ b/app/assets/javascripts/boards/components/board_blank_state.vue
@@ -1,42 +1,11 @@
+<script>
/* global ListLabel */
-
import _ from 'underscore';
import Cookies from 'js-cookie';
const Store = gl.issueBoards.BoardsStore;
export default {
- template: `
- <div class="board-blank-state">
- <p>
- Add the following default lists to your Issue Board with one click:
- </p>
- <ul class="board-blank-state-list">
- <li v-for="label in predefinedLabels">
- <span
- class="label-color"
- :style="{ backgroundColor: label.color }">
- </span>
- {{ label.title }}
- </li>
- </ul>
- <p>
- Starting out with the default set of lists will get you right on the way to making the most of your board.
- </p>
- <button
- class="btn btn-create btn-inverted btn-block"
- type="button"
- @click.stop="addDefaultLists">
- Add default lists
- </button>
- <button
- class="btn btn-default btn-block"
- type="button"
- @click.stop="clearBlankState">
- Nevermind, I'll use my own
- </button>
- </div>
- `,
data() {
return {
predefinedLabels: [
@@ -89,3 +58,41 @@ export default {
clearBlankState: Store.removeBlankState.bind(Store),
},
};
+
+</script>
+
+<template>
+ <div class="board-blank-state">
+ <p>
+ Add the following default lists to your Issue Board with one click:
+ </p>
+ <ul class="board-blank-state-list">
+ <li
+ v-for="(label, index) in predefinedLabels"
+ :key="index"
+ >
+ <span
+ class="label-color"
+ :style="{ backgroundColor: label.color }">
+ </span>
+ {{ label.title }}
+ </li>
+ </ul>
+ <p>
+ Starting out with the default set of lists will get you
+ right on the way to making the most of your board.
+ </p>
+ <button
+ class="btn btn-create btn-inverted btn-block"
+ type="button"
+ @click.stop="addDefaultLists">
+ Add default lists
+ </button>
+ <button
+ class="btn btn-default btn-block"
+ type="button"
+ @click.stop="clearBlankState">
+ Nevermind, I'll use my own
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index a44969272a1..c4ee4f6c855 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -60,10 +60,6 @@ gl.issueBoards.BoardSidebar = Vue.extend({
this.issue = this.detail.issue;
this.list = this.detail.list;
-
- this.$nextTick(() => {
- this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate;
- });
},
deep: true
},
@@ -91,7 +87,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
saveAssignees () {
this.loadingAssignees = true;
- gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint)
+ gl.issueBoards.BoardsStore.detail.issue.update()
.then(() => {
this.loadingAssignees = false;
})
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js
index 8aee5b23c76..84fe9b1288a 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -68,15 +68,6 @@ gl.issueBoards.IssueCardInner = Vue.extend({
return this.issue.assignees.length > this.numberOverLimit;
},
- cardUrl() {
- 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) {
return `#${this.issue.iid}`;
@@ -153,13 +144,13 @@ gl.issueBoards.IssueCardInner = Vue.extend({
/>
<a
class="js-no-trigger"
- :href="cardUrl"
+ :href="issue.path"
:title="issue.title">{{ issue.title }}</a>
<span
class="card-number"
v-if="issueId"
>
- <template v-if="groupId && issue.project">{{issue.project.path}}</template>{{ issueId }}
+ {{ issue.referencePath }}
</span>
</h4>
<div class="card-assignee">
diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js b/app/assets/javascripts/boards/components/modal/empty_state.js
index e571b11a83d..9e37f95cdd6 100644
--- a/app/assets/javascripts/boards/components/modal/empty_state.js
+++ b/app/assets/javascripts/boards/components/modal/empty_state.js
@@ -1,9 +1,9 @@
import Vue from 'vue';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
+import modalMixin from '../../mixins/modal_mixins';
gl.issueBoards.ModalEmptyState = Vue.extend({
- mixins: [gl.issueBoards.ModalMixins],
+ mixins: [modalMixin],
data() {
return ModalStore.store;
},
diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js
index 03cd7ef65cb..9735e0ddacc 100644
--- a/app/assets/javascripts/boards/components/modal/footer.js
+++ b/app/assets/javascripts/boards/components/modal/footer.js
@@ -3,11 +3,11 @@ import Flash from '../../../flash';
import { __ } from '../../../locale';
import './lists_dropdown';
import { pluralize } from '../../../lib/utils/text_utility';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
+import modalMixin from '../../mixins/modal_mixins';
gl.issueBoards.ModalFooter = Vue.extend({
- mixins: [gl.issueBoards.ModalMixins],
+ mixins: [modalMixin],
data() {
return {
modal: ModalStore.store,
diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js
index 31f59d295bf..67c29ebca72 100644
--- a/app/assets/javascripts/boards/components/modal/header.js
+++ b/app/assets/javascripts/boards/components/modal/header.js
@@ -1,11 +1,11 @@
import Vue from 'vue';
import modalFilters from './filters';
import './tabs';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
+import modalMixin from '../../mixins/modal_mixins';
gl.issueBoards.ModalHeader = Vue.extend({
- mixins: [gl.issueBoards.ModalMixins],
+ mixins: [modalMixin],
props: {
projectId: {
type: Number,
diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js
index d825ff38587..3083b3e4405 100644
--- a/app/assets/javascripts/boards/components/modal/index.js
+++ b/app/assets/javascripts/boards/components/modal/index.js
@@ -7,8 +7,7 @@ import './header';
import './list';
import './footer';
import './empty_state';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
gl.issueBoards.IssuesModal = Vue.extend({
props: {
diff --git a/app/assets/javascripts/boards/components/modal/list.js b/app/assets/javascripts/boards/components/modal/list.js
index 7c62134b3a3..6b04a6c7a6c 100644
--- a/app/assets/javascripts/boards/components/modal/list.js
+++ b/app/assets/javascripts/boards/components/modal/list.js
@@ -2,8 +2,7 @@
import Vue from 'vue';
import bp from '../../../breakpoints';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
gl.issueBoards.ModalList = Vue.extend({
props: {
diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js
index 4684ea76647..e644de2d4fc 100644
--- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js
+++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
gl.issueBoards.ModalFooterListsDropdown = Vue.extend({
data() {
diff --git a/app/assets/javascripts/boards/components/modal/tabs.js b/app/assets/javascripts/boards/components/modal/tabs.js
index 3e5d08e3d75..b6465a88e5e 100644
--- a/app/assets/javascripts/boards/components/modal/tabs.js
+++ b/app/assets/javascripts/boards/components/modal/tabs.js
@@ -1,9 +1,9 @@
import Vue from 'vue';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
+import modalMixin from '../../mixins/modal_mixins';
gl.issueBoards.ModalTabs = Vue.extend({
- mixins: [gl.issueBoards.ModalMixins],
+ mixins: [modalMixin],
data() {
return ModalStore.store;
},
diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js
index 09c683ff621..0a0820ec5fd 100644
--- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js
+++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js
@@ -17,14 +17,10 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
type: Object,
required: true,
},
- issueUpdate: {
- type: String,
- required: true,
- },
},
computed: {
updateUrl() {
- return this.issueUpdate.replace(':project_path', this.issue.project.path);
+ return this.issue.path;
},
},
methods: {
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index fb40b9f5565..70367c4f711 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -6,6 +6,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) {
super({
page: 'boards',
+ isGroupDecendent: true,
stateFiltersSelector: '.issues-state-filters',
});
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 8b1c14c04ff..a6f8681cfac 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -17,9 +17,9 @@ import './models/milestone';
import './models/project';
import './models/assignee';
import './stores/boards_store';
-import './stores/modal_store';
+import ModalStore from './stores/modal_store';
import BoardService from './services/board_service';
-import './mixins/modal_mixins';
+import modalMixin from './mixins/modal_mixins';
import './mixins/sortable_default_options';
import './filters/due_date_filters';
import './components/board';
@@ -31,7 +31,6 @@ import '~/vue_shared/vue_resource_interceptor'; // eslint-disable-line import/fi
export default () => {
const $boardApp = document.getElementById('board-app');
const Store = gl.issueBoards.BoardsStore;
- const ModalStore = gl.issueBoards.ModalStore;
window.gl = window.gl || {};
@@ -176,7 +175,7 @@ export default () => {
gl.IssueBoardsModalAddBtn = new Vue({
el: document.getElementById('js-add-issues-btn'),
- mixins: [gl.issueBoards.ModalMixins],
+ mixins: [modalMixin],
data() {
return {
modal: ModalStore.store,
diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js b/app/assets/javascripts/boards/mixins/modal_mixins.js
index 2b0a1aaa89f..6c97e1629bf 100644
--- a/app/assets/javascripts/boards/mixins/modal_mixins.js
+++ b/app/assets/javascripts/boards/mixins/modal_mixins.js
@@ -1,6 +1,6 @@
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../stores/modal_store';
-gl.issueBoards.ModalMixins = {
+export default {
methods: {
toggleModal(toggle) {
ModalStore.store.showAddIssuesModal = toggle;
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index 4c5079efc8b..b381d48d625 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -23,6 +23,8 @@ class ListIssue {
};
this.isLoading = {};
this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint;
+ this.referencePath = obj.reference_path;
+ this.path = obj.real_path;
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id;
@@ -98,7 +100,7 @@ class ListIssue {
this.isLoading[key] = value;
}
- update (url) {
+ update () {
const data = {
issue: {
milestone_id: this.milestone ? this.milestone.id : null,
@@ -113,7 +115,7 @@ class ListIssue {
}
const projectPath = this.project ? this.project.path : '';
- return Vue.http.patch(url.replace(':project_path', projectPath), data);
+ return Vue.http.patch(`${this.path}.json`, data);
}
}
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index e210d69895e..7144f4190e7 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -113,6 +113,8 @@ class List {
issue.id = data.id;
issue.iid = data.iid;
issue.project = data.project;
+ issue.path = data.real_path;
+ issue.referencePath = data.reference_path;
if (this.issuesSize > 1) {
const moveBeforeId = this.issues[1].id;
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index d78d4701974..7c90597f77c 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -19,7 +19,7 @@ export default class BoardService {
}
static generateIssuePath(boardId, id) {
- return `${gon.relative_url_root}/-/boards/${boardId ? `/${boardId}` : ''}/issues${id ? `/${id}` : ''}`;
+ return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues${id ? `/${id}` : ''}`;
}
all() {
diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js
index 4fdc925c825..a4220cd840d 100644
--- a/app/assets/javascripts/boards/stores/modal_store.js
+++ b/app/assets/javascripts/boards/stores/modal_store.js
@@ -1,6 +1,3 @@
-window.gl = window.gl || {};
-window.gl.issueBoards = window.gl.issueBoards || {};
-
class ModalStore {
constructor() {
this.store = {
@@ -95,4 +92,4 @@ class ModalStore {
}
}
-gl.issueBoards.ModalStore = new ModalStore();
+export default new ModalStore();
diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js
index 839e369eaf6..f34496f84c6 100644
--- a/app/assets/javascripts/branches/branches_delete_modal.js
+++ b/app/assets/javascripts/branches/branches_delete_modal.js
@@ -16,6 +16,7 @@ class DeleteModal {
bindEvents() {
this.$toggleBtns.on('click', this.setModalData.bind(this));
this.$confirmInput.on('input', this.setDeleteDisabled.bind(this));
+ this.$deleteBtn.on('click', this.setDisableDeleteButton.bind(this));
}
setModalData(e) {
@@ -30,6 +31,16 @@ class DeleteModal {
this.$deleteBtn.attr('disabled', e.currentTarget.value !== this.branchName);
}
+ setDisableDeleteButton(e) {
+ if (this.$deleteBtn.is('[disabled]')) {
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+ }
+
+ return true;
+ }
+
updateModal() {
this.$branchName.text(this.branchName);
this.$confirmInput.val('');
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index f8dcdf3f60a..9c12b89240c 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -1,96 +1,102 @@
<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';
+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,
+export default {
+ components: {
+ applicationRow,
+ clipboardButton,
+ },
+ props: {
+ applications: {
+ type: Object,
+ required: false,
+ default: () => ({}),
},
- 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: '',
- },
+ helpPath: {
+ type: String,
+ required: false,
+ default: '',
},
- computed: {
- generalApplicationDescription() {
- return sprintf(
- _.escape(s__(
+ 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}">
+ ),
+ ),
+ {
+ 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__(
+ },
+ 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">
+ the hosting provider your Kubernetes cluster is installed on. If you are using
+ Google Kubernetes Engine, 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,
- );
+ },
+ false,
+ );
- const externalIpParagraph = sprintf(
- _.escape(s__(
+ 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}">
+ ),
+ ),
+ {
+ ingressHelpLink: `<a href="${this.ingressHelpPath}">
${_.escape(s__('ClusterIntegration|More information'))}
</a>`,
- },
- false,
- );
+ },
+ false,
+ );
- return `
+ return `
<p>
${extraCostParagraph}
</p>
@@ -98,22 +104,25 @@
${externalIpParagraph}
</p>
`;
- },
- prometheusDescription() {
- return sprintf(
- _.escape(s__(
+ },
+ 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"
+ ),
+ ),
+ {
+ 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,
- );
- },
+ },
+ false,
+ );
},
- };
+ },
+};
</script>
<template>
@@ -205,7 +214,7 @@
>
{{ 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.`) }}
+ cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }}
<a
:href="ingressHelpPath"
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 466a5b5d635..24d63b99a29 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -55,22 +55,20 @@
},
methods: {
successCallback(resp) {
- return resp.json().then((response) => {
- // depending of the endpoint the response can either bring a `pipelines` key or not.
- const pipelines = response.pipelines || response;
- this.setCommonData(pipelines);
+ // depending of the endpoint the response can either bring a `pipelines` key or not.
+ const pipelines = resp.data.pipelines || resp.data;
+ this.setCommonData(pipelines);
- const updatePipelinesEvent = new CustomEvent('update-pipelines-count', {
- detail: {
- pipelines: response,
- },
- });
-
- // notifiy to update the count in tabs
- if (this.$el.parentElement) {
- this.$el.parentElement.dispatchEvent(updatePipelinesEvent);
- }
+ const updatePipelinesEvent = new CustomEvent('update-pipelines-count', {
+ detail: {
+ pipelines: resp.data,
+ },
});
+
+ // notifiy to update the count in tabs
+ if (this.$el.parentElement) {
+ this.$el.parentElement.dispatchEvent(updatePipelinesEvent);
+ }
},
},
};
diff --git a/app/assets/javascripts/compare.js b/app/assets/javascripts/compare.js
deleted file mode 100644
index 303a5bf4a53..00000000000
--- a/app/assets/javascripts/compare.js
+++ /dev/null
@@ -1,86 +0,0 @@
-/* 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 $ from 'jquery';
-import { localTimeAgo } from './lib/utils/datetime_utility';
-import axios from './lib/utils/axios_utils';
-
-export default class Compare {
- constructor(opts) {
- this.opts = opts;
- this.source_loading = $(".js-source-loading");
- this.target_loading = $(".js-target-loading");
- $('.js-compare-dropdown').each((function(_this) {
- return function(i, dropdown) {
- var $dropdown;
- $dropdown = $(dropdown);
- return $dropdown.glDropdown({
- selectable: true,
- fieldName: $dropdown.data('fieldName'),
- filterable: true,
- id: function(obj, $el) {
- return $el.data('id');
- },
- toggleLabel: function(obj, $el) {
- return $el.text().trim();
- },
- clicked: function(e, el) {
- if ($dropdown.is('.js-target-branch')) {
- return _this.getTargetHtml();
- } else if ($dropdown.is('.js-source-branch')) {
- return _this.getSourceHtml();
- } else if ($dropdown.is('.js-target-project')) {
- return _this.getTargetProject();
- }
- }
- });
- };
- })(this));
- this.initialState();
- }
-
- initialState() {
- this.getSourceHtml();
- this.getTargetHtml();
- }
-
- getTargetProject() {
- $('.mr_target_commit').empty();
-
- return axios.get(this.opts.targetProjectUrl, {
- params: {
- target_project_id: $("input[name='merge_request[target_project_id]']").val(),
- },
- }).then(({ data }) => {
- $('.js-target-branch-dropdown .dropdown-content').html(data);
- });
- }
-
- getSourceHtml() {
- return this.constructor.sendAjax(this.opts.sourceBranchUrl, this.source_loading, '.mr_source_commit', {
- ref: $("input[name='merge_request[source_branch]']").val()
- });
- }
-
- 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()
- });
- }
-
- static sendAjax(url, loading, target, params) {
- const $target = $(target);
-
- loading.show();
- $target.empty();
-
- return axios.get(url, {
- params,
- }).then(({ data }) => {
- loading.hide();
- $target.html(data);
- const className = '.' + $target[0].className.replace(' ', '.');
- localTimeAgo($('.js-timeago', className));
- });
- }
-}
diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js
index 260c91cac24..9c88466e576 100644
--- a/app/assets/javascripts/compare_autocomplete.js
+++ b/app/assets/javascripts/compare_autocomplete.js
@@ -4,8 +4,9 @@ import $ from 'jquery';
import { __ } from './locale';
import axios from './lib/utils/axios_utils';
import flash from './flash';
+import { capitalizeFirstCharacter } from './lib/utils/text_utility';
-export default function initCompareAutocomplete() {
+export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) {
$('.js-compare-dropdown').each(function() {
var $dropdown, selected;
$dropdown = $(this);
@@ -15,14 +16,27 @@ export default function initCompareAutocomplete() {
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')));
+ const params = {
+ ref: $dropdown.data('ref'),
+ search: term,
+ };
+
+ if (limitTo) {
+ params.find = limitTo;
+ }
+
+ axios
+ .get($dropdown.data('refsUrl'), {
+ params,
+ })
+ .then(({ data }) => {
+ if (limitTo) {
+ callback(data[capitalizeFirstCharacter(limitTo)] || []);
+ } else {
+ callback(data);
+ }
+ })
+ .catch(() => flash(__('Error fetching refs')));
},
selectable: true,
filterable: true,
@@ -32,9 +46,15 @@ export default function initCompareAutocomplete() {
renderRow: function(ref) {
var link;
if (ref.header != null) {
- return $('<li />').addClass('dropdown-header').text(ref.header);
+ 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));
+ link = $('<a />')
+ .attr('href', '#')
+ .addClass(ref === selected ? 'is-active' : '')
+ .text(ref)
+ .attr('data-ref', escape(ref));
return $('<li />').append(link);
}
},
@@ -43,9 +63,10 @@ export default function initCompareAutocomplete() {
},
toggleLabel: function(obj, $el) {
return $el.text().trim();
- }
+ },
+ clicked: () => clickHandler($dropdown),
});
- $filterInput.on('keyup', (e) => {
+ $filterInput.on('keyup', e => {
const keyCode = e.keyCode || e.which;
if (keyCode !== 13) return;
const text = $filterInput.val();
@@ -54,7 +75,7 @@ export default function initCompareAutocomplete() {
$dropdownContainer.removeClass('open');
});
- $dropdownContainer.on('click', '.dropdown-content a', (e) => {
+ $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/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index fb1fc9cd32e..a88b6971f90 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -84,20 +84,21 @@ export default class CreateMergeRequestDropdown {
if (data.can_create_branch) {
this.available();
this.enable();
+ this.updateBranchName(data.suggested_branch_name);
if (!this.droplabInitialized) {
this.droplabInitialized = true;
this.initDroplab();
this.bindEvents();
}
- } else if (data.has_related_branch) {
+ } else {
this.hide();
}
})
.catch(() => {
this.unavailable();
this.disable();
- Flash('Failed to check if a new branch can be created.');
+ Flash(__('Failed to check related branches.'));
});
}
@@ -409,13 +410,16 @@ export default class CreateMergeRequestDropdown {
this.unavailableButton.classList.remove('hide');
}
+ updateBranchName(suggestedBranchName) {
+ this.branchInput.value = suggestedBranchName;
+ this.updateCreatePaths('branch', suggestedBranchName);
+ }
+
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) {
@@ -426,18 +430,12 @@ export default class CreateMergeRequestDropdown {
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);
+ this.updateCreatePaths(target, ref);
}
} 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);
+ this.updateCreatePaths(target, ref);
} else {
this.refIsValid = false;
this.refInput.dataset.value = ref;
@@ -457,4 +455,15 @@ export default class CreateMergeRequestDropdown {
this.disableCreateAction();
}
}
+
+ // target - 'branch' or 'ref'
+ // ref - string - the new value to use as branch or ref
+ updateCreatePaths(target, ref) {
+ const pathReplacement = `$1${ref}`;
+
+ this.createBranchPath = this.createBranchPath.replace(this.regexps[target].createBranchPath,
+ pathReplacement);
+ this.createMrPath = this.createMrPath.replace(this.regexps[target].createMrPath,
+ pathReplacement);
+ }
}
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index 842a4255f08..4164149dd06 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -2,7 +2,9 @@
import $ from 'jquery';
import Pikaday from 'pikaday';
+import { __ } from '~/locale';
import axios from './lib/utils/axios_utils';
+import { timeFor } from './lib/utils/datetime_utility';
import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
class DueDateSelect {
@@ -14,6 +16,7 @@ class DueDateSelect {
this.$dropdownParent = $dropdownParent;
this.$datePicker = $dropdownParent.find('.js-due-date-calendar');
this.$block = $block;
+ this.$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon');
this.$selectbox = $dropdown.closest('.selectbox');
this.$value = $block.find('.value');
this.$valueContent = $block.find('.value-content');
@@ -128,7 +131,8 @@ class DueDateSelect {
submitSelectedDate(isDropdown) {
const selectedDateValue = this.datePayload[this.abilityName].due_date;
- const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value';
+ const hasDueDate = this.displayedDate !== 'No due date';
+ const displayedDateStyle = hasDueDate ? 'bold' : 'no-value';
this.$loading.removeClass('hidden').fadeIn();
@@ -145,10 +149,13 @@ class DueDateSelect {
return axios.put(this.issueUpdateURL, this.datePayload)
.then(() => {
+ const tooltipText = hasDueDate ? `${__('Due date')}<br />${selectedDateValue} (${timeFor(selectedDateValue)})` : __('Due date');
if (isDropdown) {
this.$dropdown.trigger('loaded.gl.dropdown');
this.$dropdown.dropdown('toggle');
}
+ this.$sidebarCollapsedValue.attr('data-original-title', tooltipText);
+
return this.$loading.fadeOut();
});
}
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index dc7672560ea..cd8dff40b88 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -34,7 +34,7 @@ export function getEmojiCategoryMap() {
symbols: [],
flags: [],
};
- Object.keys(emojiMap).forEach((name) => {
+ Object.keys(emojiMap).forEach(name => {
const emoji = emojiMap[name];
if (emojiCategoryMap[emoji.category]) {
emojiCategoryMap[emoji.category].push(name);
@@ -79,7 +79,9 @@ export function glEmojiTag(inputName, options) {
classList.push(fallbackSpriteClass);
}
const classAttribute = classList.length > 0 ? `class="${classList.join(' ')}"` : '';
- const fallbackSpriteAttribute = opts.sprite ? `data-fallback-sprite-class="${fallbackSpriteClass}"` : '';
+ const fallbackSpriteAttribute = opts.sprite
+ ? `data-fallback-sprite-class="${fallbackSpriteClass}"`
+ : '';
let contents = emojiInfo.moji;
if (opts.forceFallback && !opts.sprite) {
contents = emojiImageTag(name, fallbackImageSrc);
diff --git a/app/assets/javascripts/emoji/support/unicode_support_map.js b/app/assets/javascripts/emoji/support/unicode_support_map.js
index c18d07dad43..8c1861c56db 100644
--- a/app/assets/javascripts/emoji/support/unicode_support_map.js
+++ b/app/assets/javascripts/emoji/support/unicode_support_map.js
@@ -54,7 +54,8 @@ const unicodeSupportTestMap = {
function checkPixelInImageDataArray(pixelOffset, imageDataArray) {
// `4 *` because RGBA
const indexOffset = 4 * pixelOffset;
- const hasColor = imageDataArray[indexOffset + 0] ||
+ const hasColor =
+ imageDataArray[indexOffset + 0] ||
imageDataArray[indexOffset + 1] ||
imageDataArray[indexOffset + 2];
const isVisible = imageDataArray[indexOffset + 3];
@@ -75,23 +76,23 @@ const chromeVersion = chromeMatches && chromeMatches[1] && parseInt(chromeMatche
const fontSize = 16;
function generateUnicodeSupportMap(testMap) {
const testMapKeys = Object.keys(testMap);
- const numTestEntries = testMapKeys
- .reduce((list, testKey) => list.concat(testMap[testKey]), []).length;
+ const numTestEntries = testMapKeys.reduce((list, testKey) => list.concat(testMap[testKey]), [])
+ .length;
const canvas = document.createElement('canvas');
(window.gl || window).testEmojiUnicodeSupportMapCanvas = canvas;
const ctx = canvas.getContext('2d');
- canvas.width = (2 * fontSize);
- canvas.height = (numTestEntries * fontSize);
+ canvas.width = 2 * fontSize;
+ canvas.height = numTestEntries * fontSize;
ctx.fillStyle = '#000000';
ctx.textBaseline = 'middle';
ctx.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`;
// Write each emoji to the canvas vertically
let writeIndex = 0;
- testMapKeys.forEach((testKey) => {
+ testMapKeys.forEach(testKey => {
const testEntry = testMap[testKey];
- [].concat(testEntry).forEach((emojiUnicode) => {
- ctx.fillText(emojiUnicode, 0, (writeIndex * fontSize) + (fontSize / 2));
+ [].concat(testEntry).forEach(emojiUnicode => {
+ ctx.fillText(emojiUnicode, 0, writeIndex * fontSize + fontSize / 2);
writeIndex += 1;
});
});
@@ -99,29 +100,25 @@ function generateUnicodeSupportMap(testMap) {
// Read from the canvas
const resultMap = {};
let readIndex = 0;
- testMapKeys.forEach((testKey) => {
+ testMapKeys.forEach(testKey => {
const testEntry = testMap[testKey];
// This needs to be a `reduce` instead of `every` because we need to
// keep the `readIndex` in sync from the writes by running all entries
- const isTestSatisfied = [].concat(testEntry).reduce((isSatisfied) => {
+ const isTestSatisfied = [].concat(testEntry).reduce(isSatisfied => {
// Sample along the vertical-middle for a couple of characters
- const imageData = ctx.getImageData(
- 0,
- (readIndex * fontSize) + (fontSize / 2),
- 2 * fontSize,
- 1,
- ).data;
+ const imageData = ctx.getImageData(0, readIndex * fontSize + fontSize / 2, 2 * fontSize, 1)
+ .data;
let isValidEmoji = false;
for (let currentPixel = 0; currentPixel < 64; currentPixel += 1) {
const isLookingAtFirstChar = currentPixel < fontSize;
- const isLookingAtSecondChar = currentPixel >= (fontSize + (fontSize / 2));
+ const isLookingAtSecondChar = currentPixel >= fontSize + fontSize / 2;
// Check for the emoji somewhere along the row
if (isLookingAtFirstChar && checkPixelInImageDataArray(currentPixel, imageData)) {
isValidEmoji = true;
- // Check to see that nothing is rendered next to the first character
- // to ensure that the ZWJ sequence rendered as one piece
+ // Check to see that nothing is rendered next to the first character
+ // to ensure that the ZWJ sequence rendered as one piece
} else if (isLookingAtSecondChar && checkPixelInImageDataArray(currentPixel, imageData)) {
isValidEmoji = false;
break;
@@ -170,7 +167,10 @@ export default function getUnicodeSupportMap() {
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));
+ 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
index dbee81fa320..6bd7c6b49cb 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -43,6 +43,7 @@
<div class="environments-container">
<loading-icon
+ class="prepend-top-default"
label="Loading environments"
v-if="isLoading"
size="3"
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 16bd2f5feb3..ab9e22037d0 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,5 +1,5 @@
<script>
- import playIconSvg from 'icons/_icon_play.svg';
+ import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
@@ -8,9 +8,9 @@
directives: {
tooltip,
},
-
components: {
loadingIcon,
+ Icon,
},
props: {
actions: {
@@ -19,20 +19,16 @@
default: () => [],
},
},
-
data() {
return {
- playIconSvg,
isLoading: false,
};
},
-
computed: {
title() {
return 'Deploy to...';
},
},
-
methods: {
onClickAction(endpoint) {
this.isLoading = true;
@@ -65,7 +61,10 @@
:disabled="isLoading"
>
<span>
- <span v-html="playIconSvg"></span>
+ <icon
+ name="play"
+ :size="12"
+ />
<i
class="fa fa-caret-down"
aria-hidden="true"
@@ -86,7 +85,10 @@
:class="{ disabled: isActionDisabled(action) }"
:disabled="isActionDisabled(action)"
>
- <span v-html="playIconSvg"></span>
+ <icon
+ name="play"
+ :size="12"
+ />
<span>
{{ action.name }}
</span>
diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue
index c9a68cface6..ea6f1168c68 100644
--- a/app/assets/javascripts/environments/components/environment_external_url.vue
+++ b/app/assets/javascripts/environments/components/environment_external_url.vue
@@ -1,4 +1,5 @@
<script>
+ import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import { s__ } from '../../locale';
@@ -6,6 +7,9 @@
* Renders the external url link in environments table.
*/
export default {
+ components: {
+ Icon,
+ },
directives: {
tooltip,
},
@@ -15,7 +19,6 @@
required: true,
},
},
-
computed: {
title() {
return s__('Environments|Open');
@@ -34,10 +37,9 @@
:aria-label="title"
:href="externalUrl"
>
- <i
- class="fa fa-external-link"
- aria-hidden="true"
- >
- </i>
+ <icon
+ name="external-link"
+ :size="12"
+ />
</a>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index 081537cf218..deada134b27 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -2,20 +2,22 @@
/**
* Renders the Monitoring (Metrics) link in environments table.
*/
+ import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
+ components: {
+ Icon,
+ },
directives: {
tooltip,
},
-
props: {
monitoringUrl: {
type: String,
required: true,
},
},
-
computed: {
title() {
return 'Monitoring';
@@ -33,10 +35,9 @@
:title="title"
:aria-label="title"
>
- <i
- class="fa fa-area-chart"
- aria-hidden="true"
- >
- </i>
+ <icon
+ name="chart"
+ :size="12"
+ />
</a>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 605a88e997e..c822fb1574c 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -12,7 +12,6 @@
components: {
loadingIcon,
},
-
props: {
retryUrl: {
type: String,
@@ -24,13 +23,11 @@
default: true,
},
},
-
data() {
return {
isLoading: false,
};
},
-
methods: {
onClick() {
this.isLoading = true;
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index 407d5333c0e..e8469d088ef 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -3,14 +3,16 @@
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
- import terminalIconSvg from 'icons/_icon_terminal.svg';
+ import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
+ components: {
+ Icon,
+ },
directives: {
tooltip,
},
-
props: {
terminalPath: {
type: String,
@@ -18,13 +20,6 @@
default: '',
},
},
-
- data() {
- return {
- terminalIconSvg,
- };
- },
-
computed: {
title() {
return 'Terminal';
@@ -40,7 +35,10 @@
:title="title"
:aria-label="title"
:href="terminalPath"
- v-html="terminalIconSvg"
>
+ <icon
+ name="terminal"
+ :size="12"
+ />
</a>
</template>
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js
index c50ac667c20..2d5bae9a9c4 100644
--- a/app/assets/javascripts/feature_highlight/feature_highlight.js
+++ b/app/assets/javascripts/feature_highlight/feature_highlight.js
@@ -1,19 +1,19 @@
import $ from 'jquery';
-import _ from 'underscore';
import {
getSelector,
- togglePopover,
inserted,
- mouseenter,
- mouseleave,
} from './feature_highlight_helper';
+import {
+ togglePopover,
+ mouseenter,
+ debouncedMouseleave,
+} from '../shared/popover';
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
@@ -29,13 +29,10 @@ export function setupFeatureHighlightPopover(id, debounceTimeout = 300) {
`,
})
.on('mouseenter', mouseenter)
- .on('mouseleave', debouncedMouseleave)
+ .on('mouseleave', debouncedMouseleave(debounceTimeout))
.on('inserted.bs.popover', inserted)
.on('show.bs.popover', () => {
- window.addEventListener('scroll', hideOnScroll);
- })
- .on('hide.bs.popover', () => {
- window.removeEventListener('scroll', hideOnScroll);
+ window.addEventListener('scroll', hideOnScroll, { once: true });
})
// Display feature highlight
.removeAttr('disabled');
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
index f480e72961c..d5b97ebb264 100644
--- a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
@@ -3,20 +3,10 @@ import axios from '../lib/utils/axios_utils';
import { __ } from '../locale';
import Flash from '../flash';
import LazyLoader from '../lazy_loader';
+import { togglePopover } from '../shared/popover';
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,
@@ -27,23 +17,6 @@ export function dismiss(highlightId) {
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;
diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue
index 037e3efb4ce..1cec84706fc 100644
--- a/app/assets/javascripts/ide/components/changed_file_icon.vue
+++ b/app/assets/javascripts/ide/components/changed_file_icon.vue
@@ -1,31 +1,88 @@
<script>
-import icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+import Icon from '~/vue_shared/components/icon.vue';
+import { pluralize } from '~/lib/utils/text_utility';
+import { __, sprintf } from '~/locale';
export default {
components: {
- icon,
+ Icon,
+ },
+ directives: {
+ tooltip,
},
props: {
file: {
type: Object,
required: true,
},
+ showTooltip: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showStagedIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ forceModifiedIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
changedIcon() {
- return this.file.tempFile ? 'file-addition' : 'file-modified';
+ const suffix = this.file.staged && !this.showStagedIcon ? '-solid' : '';
+ return this.file.tempFile && !this.forceModifiedIcon
+ ? `file-addition${suffix}`
+ : `file-modified${suffix}`;
+ },
+ stagedIcon() {
+ return `${this.changedIcon}-solid`;
},
changedIconClass() {
- return `multi-${this.changedIcon}`;
+ return `multi-${this.changedIcon} pull-left`;
+ },
+ tooltipTitle() {
+ if (!this.showTooltip) return undefined;
+
+ const type = this.file.tempFile ? 'addition' : 'modification';
+
+ if (this.file.changed && !this.file.staged) {
+ return sprintf(__('Unstaged %{type}'), {
+ type,
+ });
+ } else if (!this.file.changed && this.file.staged) {
+ return sprintf(__('Staged %{type}'), {
+ type,
+ });
+ } else if (this.file.changed && this.file.staged) {
+ return sprintf(__('Unstaged and staged %{type}'), {
+ type: pluralize(type),
+ });
+ }
+
+ return undefined;
},
},
};
</script>
<template>
- <icon
- :name="changedIcon"
- :size="12"
- :css-classes="`ide-file-changed-icon ${changedIconClass}`"
- />
+ <span
+ v-tooltip
+ :title="tooltipTitle"
+ data-container="body"
+ data-placement="right"
+ class="ide-file-changed-icon"
+ >
+ <icon
+ v-if="file.changed || file.tempFile || file.staged"
+ :name="changedIcon"
+ :size="12"
+ :css-classes="changedIconClass"
+ />
+ </span>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index 2cbd982af19..45321df191c 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -1,41 +1,27 @@
<script>
- import { mapState } from 'vuex';
- import { sprintf, __ } from '~/locale';
- import * as consts from '../../stores/modules/commit/constants';
- import RadioGroup from './radio_group.vue';
+import { mapState } from 'vuex';
+import { sprintf, __ } from '~/locale';
+import * as consts from '../../stores/modules/commit/constants';
+import RadioGroup from './radio_group.vue';
- export default {
- components: {
- RadioGroup,
+export default {
+ components: {
+ RadioGroup,
+ },
+ computed: {
+ ...mapState(['currentBranchId']),
+ commitToCurrentBranchText() {
+ return sprintf(
+ __('Commit to %{branchName} branch'),
+ { branchName: `<strong class="monospace">${this.currentBranchId}</strong>` },
+ false,
+ );
},
- computed: {
- ...mapState([
- 'currentBranchId',
- ]),
- newMergeRequestHelpText() {
- return sprintf(
- __('Creates a new branch from %{branchName} and re-directs to create a new merge request'),
- { branchName: this.currentBranchId },
- );
- },
- commitToCurrentBranchText() {
- return sprintf(
- __('Commit to %{branchName} branch'),
- { branchName: `<strong>${this.currentBranchId}</strong>` },
- false,
- );
- },
- commitToNewBranchText() {
- return sprintf(
- __('Creates a new branch from %{branchName}'),
- { branchName: this.currentBranchId },
- );
- },
- },
- commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
- commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
- commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR,
- };
+ },
+ commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
+ commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
+ commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR,
+};
</script>
<template>
@@ -53,13 +39,11 @@
:value="$options.commitToNewBranch"
:label="__('Create a new branch')"
:show-input="true"
- :help-text="commitToNewBranchText"
/>
<radio-group
:value="$options.commitToNewBranchMR"
:label="__('Create a new branch and merge request')"
:show-input="true"
- :help-text="newMergeRequestHelpText"
/>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
new file mode 100644
index 00000000000..1f6bbca13b5
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
@@ -0,0 +1,77 @@
+<script>
+import { mapActions, mapState, mapGetters } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ components: {
+ Icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['lastCommitMsg', 'rightPanelCollapsed', 'changedFiles', 'stagedFiles']),
+ ...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']),
+ },
+ methods: {
+ ...mapActions(['toggleRightPanelCollapsed']),
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="!lastCommitMsg"
+ class="multi-file-commit-panel-section ide-commit-empty-state js-empty-state"
+ >
+ <header
+ class="multi-file-commit-panel-header"
+ :class="{
+ 'is-collapsed': rightPanelCollapsed,
+ }"
+ >
+ <button
+ v-tooltip
+ :title="collapseButtonTooltip"
+ data-container="body"
+ data-placement="left"
+ type="button"
+ class="btn btn-transparent multi-file-commit-panel-collapse-btn"
+ :aria-label="__('Toggle sidebar')"
+ @click.stop="toggleRightPanelCollapsed"
+ >
+ <icon
+ :name="collapseButtonIcon"
+ :size="18"
+ />
+ </button>
+ </header>
+ <div
+ class="ide-commit-empty-state-container"
+ v-if="!rightPanelCollapsed"
+ >
+ <div class="svg-content svg-80">
+ <img :src="noChangesStateSvgPath" />
+ </div>
+ <div class="append-right-default prepend-left-default">
+ <div
+ class="text-content text-center"
+ >
+ <h4>
+ {{ __('No changes') }}
+ </h4>
+ <p>
+ {{ __('Edit files in the editor and commit changes here') }}
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index 453208f3f19..ff05ee8682a 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -1,56 +1,132 @@
<script>
- import { mapState } from 'vuex';
- import icon from '~/vue_shared/components/icon.vue';
- import listItem from './list_item.vue';
- import listCollapsed from './list_collapsed.vue';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import { __, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+import ListItem from './list_item.vue';
+import ListCollapsed from './list_collapsed.vue';
- export default {
- components: {
- icon,
- listItem,
- listCollapsed,
+export default {
+ components: {
+ Icon,
+ ListItem,
+ ListCollapsed,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
},
- props: {
- title: {
- type: String,
- required: true,
- },
- fileList: {
- type: Array,
- required: true,
- },
+ fileList: {
+ type: Array,
+ required: true,
},
- computed: {
- ...mapState([
- 'currentProjectId',
- 'currentBranchId',
- 'rightPanelCollapsed',
- ]),
- isCommitInfoShown() {
- return this.rightPanelCollapsed || this.fileList.length;
- },
+ showToggle: {
+ type: Boolean,
+ required: false,
+ default: true,
},
- methods: {
- toggleCollapsed() {
- this.$emit('toggleCollapsed');
- },
+ iconName: {
+ type: String,
+ required: true,
},
- };
+ action: {
+ type: String,
+ required: true,
+ },
+ actionBtnText: {
+ type: String,
+ required: true,
+ },
+ itemActionComponent: {
+ type: String,
+ required: true,
+ },
+ stagedList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapState(['rightPanelCollapsed']),
+ ...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']),
+ titleText() {
+ return sprintf(__('%{title} changes'), {
+ title: this.title,
+ });
+ },
+ },
+ methods: {
+ ...mapActions(['toggleRightPanelCollapsed', 'stageAllChanges', 'unstageAllChanges']),
+ actionBtnClicked() {
+ this[this.action]();
+ },
+ },
+};
</script>
<template>
<div
+ class="ide-commit-list-container"
:class="{
- 'multi-file-commit-list': isCommitInfoShown
+ 'is-collapsed': rightPanelCollapsed,
}"
>
+ <header
+ class="multi-file-commit-panel-header"
+ >
+ <div
+ v-if="!rightPanelCollapsed"
+ class="multi-file-commit-panel-header-title"
+ :class="{
+ 'append-right-10': showToggle,
+ }"
+ >
+ <icon
+ v-once
+ :name="iconName"
+ :size="18"
+ />
+ {{ titleText }}
+ <button
+ type="button"
+ class="btn btn-blank btn-link ide-staged-action-btn"
+ @click="actionBtnClicked"
+ >
+ {{ actionBtnText }}
+ </button>
+ </div>
+ <button
+ v-if="showToggle"
+ v-tooltip
+ :title="collapseButtonTooltip"
+ data-container="body"
+ data-placement="left"
+ type="button"
+ class="btn btn-transparent multi-file-commit-panel-collapse-btn"
+ :aria-label="__('Toggle sidebar')"
+ @click.stop="toggleRightPanelCollapsed"
+ >
+ <icon
+ :name="collapseButtonIcon"
+ :size="18"
+ />
+ </button>
+ </header>
<list-collapsed
v-if="rightPanelCollapsed"
+ :files="fileList"
+ :icon-name="iconName"
+ :title="title"
/>
<template v-else>
<ul
v-if="fileList.length"
- class="list-unstyled append-bottom-0"
+ class="multi-file-commit-list list-unstyled append-bottom-0"
>
<li
v-for="file in fileList"
@@ -58,9 +134,18 @@
>
<list-item
:file="file"
+ :action-component="itemActionComponent"
+ :key-prefix="title"
+ :staged-list="stagedList"
/>
</li>
</ul>
+ <p
+ v-else
+ class="multi-file-commit-list help-block"
+ >
+ {{ __('No changes') }}
+ </p>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
index 15918ac9631..2254271c679 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
@@ -1,35 +1,110 @@
<script>
- import { mapGetters } from 'vuex';
- import icon from '~/vue_shared/components/icon.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+import { sprintf, n__, __ } from '~/locale';
- export default {
- components: {
- icon,
+export default {
+ components: {
+ Icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ files: {
+ type: Array,
+ required: true,
},
- computed: {
- ...mapGetters([
- 'addedFiles',
- 'modifiedFiles',
- ]),
+ iconName: {
+ type: String,
+ required: true,
},
- };
+ title: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ addedFilesLength() {
+ return this.files.filter(f => f.tempFile).length;
+ },
+ modifiedFilesLength() {
+ return this.files.filter(f => !f.tempFile).length;
+ },
+ addedFilesIconClass() {
+ return this.addedFilesLength ? 'multi-file-addition' : '';
+ },
+ modifiedFilesClass() {
+ return this.modifiedFilesLength ? 'multi-file-modified' : '';
+ },
+ additionsTooltip() {
+ return sprintf(n__('1 %{type} addition', '%d %{type} additions', this.addedFilesLength), {
+ type: this.title.toLowerCase(),
+ });
+ },
+ modifiedTooltip() {
+ return sprintf(
+ n__('1 %{type} modification', '%d %{type} modifications', this.modifiedFilesLength),
+ { type: this.title.toLowerCase() },
+ );
+ },
+ titleTooltip() {
+ return sprintf(__('%{title} changes'), { title: this.title });
+ },
+ additionIconName() {
+ return this.title.toLowerCase() === 'staged' ? 'file-addition-solid' : 'file-addition';
+ },
+ modifiedIconName() {
+ return this.title.toLowerCase() === 'staged' ? 'file-modified-solid' : 'file-modified';
+ },
+ },
+};
</script>
<template>
<div
class="multi-file-commit-list-collapsed text-center"
>
- <icon
- name="file-addition"
- :size="18"
- css-classes="multi-file-addition append-bottom-10"
- />
- {{ addedFiles.length }}
- <icon
- name="file-modified"
- :size="18"
- css-classes="multi-file-modified prepend-top-10 append-bottom-10"
- />
- {{ modifiedFiles.length }}
+ <div
+ v-tooltip
+ :title="titleTooltip"
+ data-container="body"
+ data-placement="left"
+ class="append-bottom-15"
+ >
+ <icon
+ v-once
+ :name="iconName"
+ :size="18"
+ />
+ </div>
+ <div
+ v-tooltip
+ :title="additionsTooltip"
+ data-container="body"
+ data-placement="left"
+ class="append-bottom-10"
+ >
+ <icon
+ :name="additionIconName"
+ :size="18"
+ :css-classes="addedFilesIconClass"
+ />
+ </div>
+ {{ addedFilesLength }}
+ <div
+ v-tooltip
+ :title="modifiedTooltip"
+ data-container="body"
+ data-placement="left"
+ class="prepend-top-10 append-bottom-10"
+ >
+ <icon
+ :name="modifiedIconName"
+ :size="18"
+ :css-classes="modifiedFilesClass"
+ />
+ </div>
+ {{ modifiedFilesLength }}
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index 560cdd941cd..872302840e2 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -1,34 +1,69 @@
<script>
import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
+import StageButton from './stage_button.vue';
+import UnstageButton from './unstage_button.vue';
export default {
components: {
Icon,
+ StageButton,
+ UnstageButton,
},
props: {
file: {
type: Object,
required: true,
},
+ actionComponent: {
+ type: String,
+ required: true,
+ },
+ keyPrefix: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ stagedList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
iconName() {
- return this.file.tempFile ? 'file-addition' : 'file-modified';
+ const prefix = this.stagedList ? '-solid' : '';
+ return this.file.tempFile ? `file-addition${prefix}` : `file-modified${prefix}`;
},
iconClass() {
return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
},
},
methods: {
- ...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']),
- openFileInEditor(file) {
- return this.openPendingTab(file).then(changeViewer => {
+ ...mapActions([
+ 'discardFileChanges',
+ 'updateViewer',
+ 'openPendingTab',
+ 'unstageChange',
+ 'stageChange',
+ ]),
+ openFileInEditor() {
+ return this.openPendingTab({
+ file: this.file,
+ keyPrefix: this.keyPrefix.toLowerCase(),
+ }).then(changeViewer => {
if (changeViewer) {
this.updateViewer('diff');
}
});
},
+ fileAction() {
+ if (this.file.staged) {
+ this.unstageChange(this.file.path);
+ } else {
+ this.stageChange(this.file.path);
+ }
+ },
},
};
</script>
@@ -38,7 +73,9 @@ export default {
<button
type="button"
class="multi-file-commit-list-path"
- @click="openFileInEditor(file)">
+ @dblclick="fileAction"
+ @click="openFileInEditor"
+ >
<span class="multi-file-commit-list-file-path">
<icon
:name="iconName"
@@ -47,12 +84,9 @@ export default {
/>{{ file.path }}
</span>
</button>
- <button
- type="button"
- class="btn btn-blank multi-file-discard-btn"
- @click="discardFileChanges(file.path)"
- >
- Discard
- </button>
+ <component
+ :is="actionComponent"
+ :path="file.path"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
new file mode 100644
index 00000000000..dcd934f76b7
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -0,0 +1,130 @@
+<script>
+import { __, sprintf } from '../../../locale';
+import Icon from '../../../vue_shared/components/icon.vue';
+import popover from '../../../vue_shared/directives/popover';
+import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants';
+
+export default {
+ directives: {
+ popover,
+ },
+ components: {
+ Icon,
+ },
+ props: {
+ text: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ scrollTop: 0,
+ isFocused: false,
+ };
+ },
+ computed: {
+ allLines() {
+ return this.text.split('\n').map((line, i) => ({
+ text: line.substr(0, this.getLineLength(i)) || ' ',
+ highlightedText: line.substr(this.getLineLength(i)),
+ }));
+ },
+ },
+ methods: {
+ handleScroll() {
+ if (this.$refs.textarea) {
+ this.$nextTick(() => {
+ this.scrollTop = this.$refs.textarea.scrollTop;
+ });
+ }
+ },
+ getLineLength(i) {
+ return i === 0 ? MAX_TITLE_LENGTH : MAX_BODY_LENGTH;
+ },
+ onInput(e) {
+ this.$emit('input', e.target.value);
+ },
+ updateIsFocused(isFocused) {
+ this.isFocused = isFocused;
+ },
+ },
+ popoverOptions: {
+ trigger: 'hover',
+ placement: 'top',
+ content: sprintf(
+ __(`
+ The character highligher helps you keep the subject line to %{titleLength} characters
+ and wrap the body at %{bodyLength} so they are readable in git.
+ `),
+ { titleLength: MAX_TITLE_LENGTH, bodyLength: MAX_BODY_LENGTH },
+ ),
+ },
+};
+</script>
+
+<template>
+ <fieldset class="common-note-form ide-commit-message-field">
+ <div
+ class="md-area"
+ :class="{
+ 'is-focused': isFocused
+ }"
+ >
+ <div
+ v-once
+ class="md-header"
+ >
+ <ul class="nav-links">
+ <li>
+ {{ __('Commit Message') }}
+ <span
+ v-popover="$options.popoverOptions"
+ class="help-block prepend-left-10"
+ >
+ <icon
+ name="question"
+ />
+ </span>
+ </li>
+ </ul>
+ </div>
+ <div class="ide-commit-message-textarea-container">
+ <div class="ide-commit-message-highlights-container">
+ <div
+ class="note-textarea highlights monospace"
+ :style="{
+ transform: `translate3d(0, ${-scrollTop}px, 0)`
+ }"
+ >
+ <div
+ v-for="(line, index) in allLines"
+ :key="index"
+ >
+ <span
+ v-text="line.text"
+ >
+ </span><mark
+ v-show="line.highlightedText"
+ v-text="line.highlightedText"
+ >
+ </mark>
+ </div>
+ </div>
+ </div>
+ <textarea
+ class="note-textarea ide-commit-message-textarea"
+ name="commit-message"
+ :placeholder="__('Write a commit message...')"
+ :value="text"
+ @scroll="handleScroll"
+ @input="onInput"
+ @focus="updateIsFocused(true)"
+ @blur="updateIsFocused(false)"
+ ref="textarea"
+ >
+ </textarea>
+ </div>
+ </div>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
index 4310d762c78..b660a2961cb 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -1,52 +1,40 @@
<script>
- import { mapActions, mapState, mapGetters } from 'vuex';
- import tooltip from '~/vue_shared/directives/tooltip';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import tooltip from '~/vue_shared/directives/tooltip';
- export default {
- directives: {
- tooltip,
+export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
},
- props: {
- value: {
- type: String,
- required: true,
- },
- label: {
- type: String,
- required: false,
- default: null,
- },
- checked: {
- type: Boolean,
- required: false,
- default: false,
- },
- showInput: {
- type: Boolean,
- required: false,
- default: false,
- },
- helpText: {
- type: String,
- required: false,
- default: null,
- },
+ label: {
+ type: String,
+ required: false,
+ default: null,
},
- computed: {
- ...mapState('commit', [
- 'commitAction',
- ]),
- ...mapGetters('commit', [
- 'newBranchName',
- ]),
+ checked: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- methods: {
- ...mapActions('commit', [
- 'updateCommitAction',
- 'updateBranchName',
- ]),
+ showInput: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- };
+ },
+ computed: {
+ ...mapState('commit', ['commitAction']),
+ ...mapGetters('commit', ['newBranchName']),
+ },
+ methods: {
+ ...mapActions('commit', ['updateCommitAction', 'updateBranchName']),
+ },
+};
</script>
<template>
@@ -65,18 +53,6 @@
{{ label }}
</template>
<slot v-else></slot>
- <span
- v-if="helpText"
- v-tooltip
- class="help-block inline"
- :title="helpText"
- >
- <i
- class="fa fa-question-circle"
- aria-hidden="true"
- >
- </i>
- </span>
</span>
</label>
<div
@@ -85,7 +61,7 @@
>
<input
type="text"
- class="form-control"
+ class="form-control monospace"
:placeholder="newBranchName"
@input="updateBranchName($event.target.value)"
/>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
new file mode 100644
index 00000000000..52dce8412ab
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
@@ -0,0 +1,59 @@
+<script>
+import { mapActions } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ components: {
+ Icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ ...mapActions(['stageChange', 'discardFileChanges']),
+ },
+};
+</script>
+
+<template>
+ <div
+ v-once
+ class="multi-file-discard-btn"
+ >
+ <button
+ v-tooltip
+ type="button"
+ class="btn btn-blank append-right-5"
+ :aria-label="__('Stage changes')"
+ :title="__('Stage changes')"
+ data-container="body"
+ @click.stop="stageChange(path)"
+ >
+ <icon
+ name="mobile-issue-close"
+ :size="12"
+ />
+ </button>
+ <button
+ v-tooltip
+ type="button"
+ class="btn btn-blank"
+ :aria-label="__('Discard changes')"
+ :title="__('Discard changes')"
+ data-container="body"
+ @click.stop="discardFileChanges(path)"
+ >
+ <icon
+ name="remove"
+ :size="12"
+ />
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
new file mode 100644
index 00000000000..628a17eddca
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
@@ -0,0 +1,39 @@
+<script>
+import { mapState } from 'vuex';
+
+export default {
+ props: {
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['lastCommitMsg']),
+ },
+};
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-panel-success-message"
+ aria-live="assertive"
+ >
+ <div class="svg-content svg-80">
+ <img
+ :src="committedStateSvgPath"
+ alt=""
+ />
+ </div>
+ <div class="append-right-default prepend-left-default">
+ <div
+ class="text-content text-center"
+ >
+ <h4>
+ {{ __('All changes are committed') }}
+ </h4>
+ <p v-html="lastCommitMsg"></p>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue
new file mode 100644
index 00000000000..123d60da47e
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue
@@ -0,0 +1,45 @@
+<script>
+import { mapActions } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ components: {
+ Icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ ...mapActions(['unstageChange']),
+ },
+};
+</script>
+
+<template>
+ <div
+ v-once
+ class="multi-file-discard-btn"
+ >
+ <button
+ v-tooltip
+ type="button"
+ class="btn btn-blank"
+ :aria-label="__('Unstage changes')"
+ :title="__('Unstage changes')"
+ data-container="body"
+ @click="unstageChange(path)"
+ >
+ <icon
+ name="history"
+ :size="12"
+ />
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/file_finder/index.vue b/app/assets/javascripts/ide/components/file_finder/index.vue
new file mode 100644
index 00000000000..ea2b13a8b21
--- /dev/null
+++ b/app/assets/javascripts/ide/components/file_finder/index.vue
@@ -0,0 +1,245 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import VirtualList from 'vue-virtual-scroll-list';
+import Item from './item.vue';
+import router from '../../ide_router';
+import {
+ MAX_FILE_FINDER_RESULTS,
+ FILE_FINDER_ROW_HEIGHT,
+ FILE_FINDER_EMPTY_ROW_HEIGHT,
+} from '../../constants';
+import {
+ UP_KEY_CODE,
+ DOWN_KEY_CODE,
+ ENTER_KEY_CODE,
+ ESC_KEY_CODE,
+} from '../../../lib/utils/keycodes';
+
+export default {
+ components: {
+ Item,
+ VirtualList,
+ },
+ data() {
+ return {
+ focusedIndex: 0,
+ searchText: '',
+ mouseOver: false,
+ cancelMouseOver: false,
+ };
+ },
+ computed: {
+ ...mapGetters(['allBlobs']),
+ ...mapState(['fileFindVisible', 'loading']),
+ filteredBlobs() {
+ const searchText = this.searchText.trim();
+
+ if (searchText === '') {
+ return this.allBlobs.slice(0, MAX_FILE_FINDER_RESULTS);
+ }
+
+ return fuzzaldrinPlus
+ .filter(this.allBlobs, searchText, {
+ key: 'path',
+ maxResults: MAX_FILE_FINDER_RESULTS,
+ })
+ .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
+ },
+ filteredBlobsLength() {
+ return this.filteredBlobs.length;
+ },
+ listShowCount() {
+ return this.filteredBlobsLength ? Math.min(this.filteredBlobsLength, 5) : 1;
+ },
+ listHeight() {
+ return this.filteredBlobsLength ? FILE_FINDER_ROW_HEIGHT : FILE_FINDER_EMPTY_ROW_HEIGHT;
+ },
+ showClearInputButton() {
+ return this.searchText.trim() !== '';
+ },
+ },
+ watch: {
+ fileFindVisible() {
+ this.$nextTick(() => {
+ if (!this.fileFindVisible) {
+ this.searchText = '';
+ } else {
+ this.focusedIndex = 0;
+
+ if (this.$refs.searchInput) {
+ this.$refs.searchInput.focus();
+ }
+ }
+ });
+ },
+ searchText() {
+ this.focusedIndex = 0;
+ },
+ focusedIndex() {
+ if (!this.mouseOver) {
+ this.$nextTick(() => {
+ const el = this.$refs.virtualScrollList.$el;
+ const scrollTop = this.focusedIndex * FILE_FINDER_ROW_HEIGHT;
+ const bottom = this.listShowCount * FILE_FINDER_ROW_HEIGHT;
+
+ if (this.focusedIndex === 0) {
+ // if index is the first index, scroll straight to start
+ el.scrollTop = 0;
+ } else if (this.focusedIndex === this.filteredBlobsLength - 1) {
+ // if index is the last index, scroll to the end
+ el.scrollTop = this.filteredBlobsLength * FILE_FINDER_ROW_HEIGHT;
+ } else if (scrollTop >= bottom + el.scrollTop) {
+ // if element is off the bottom of the scroll list, scroll down one item
+ el.scrollTop = scrollTop - bottom + FILE_FINDER_ROW_HEIGHT;
+ } else if (scrollTop < el.scrollTop) {
+ // if element is off the top of the scroll list, scroll up one item
+ el.scrollTop = scrollTop;
+ }
+ });
+ }
+ },
+ },
+ methods: {
+ ...mapActions(['toggleFileFinder']),
+ clearSearchInput() {
+ this.searchText = '';
+
+ this.$nextTick(() => {
+ this.$refs.searchInput.focus();
+ });
+ },
+ onKeydown(e) {
+ switch (e.keyCode) {
+ case UP_KEY_CODE:
+ e.preventDefault();
+ this.mouseOver = false;
+ this.cancelMouseOver = true;
+ if (this.focusedIndex > 0) {
+ this.focusedIndex -= 1;
+ } else {
+ this.focusedIndex = this.filteredBlobsLength - 1;
+ }
+ break;
+ case DOWN_KEY_CODE:
+ e.preventDefault();
+ this.mouseOver = false;
+ this.cancelMouseOver = true;
+ if (this.focusedIndex < this.filteredBlobsLength - 1) {
+ this.focusedIndex += 1;
+ } else {
+ this.focusedIndex = 0;
+ }
+ break;
+ default:
+ break;
+ }
+ },
+ onKeyup(e) {
+ switch (e.keyCode) {
+ case ENTER_KEY_CODE:
+ this.openFile(this.filteredBlobs[this.focusedIndex]);
+ break;
+ case ESC_KEY_CODE:
+ this.toggleFileFinder(false);
+ break;
+ default:
+ break;
+ }
+ },
+ openFile(file) {
+ this.toggleFileFinder(false);
+ router.push(`/project${file.url}`);
+ },
+ onMouseOver(index) {
+ if (!this.cancelMouseOver) {
+ this.mouseOver = true;
+ this.focusedIndex = index;
+ }
+ },
+ onMouseMove(index) {
+ this.cancelMouseOver = false;
+ this.onMouseOver(index);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="ide-file-finder-overlay"
+ @mousedown.self="toggleFileFinder(false)"
+ >
+ <div
+ class="dropdown-menu diff-file-changes ide-file-finder show"
+ >
+ <div class="dropdown-input">
+ <input
+ type="search"
+ class="dropdown-input-field"
+ :placeholder="__('Search files')"
+ autocomplete="off"
+ v-model="searchText"
+ ref="searchInput"
+ @keydown="onKeydown($event)"
+ @keyup="onKeyup($event)"
+ />
+ <i
+ aria-hidden="true"
+ class="fa fa-search dropdown-input-search"
+ :class="{
+ hidden: showClearInputButton
+ }"
+ ></i>
+ <i
+ role="button"
+ :aria-label="__('Clear search input')"
+ class="fa fa-times dropdown-input-clear"
+ :class="{
+ show: showClearInputButton
+ }"
+ @click="clearSearchInput"
+ ></i>
+ </div>
+ <div>
+ <virtual-list
+ :size="listHeight"
+ :remain="listShowCount"
+ wtag="ul"
+ ref="virtualScrollList"
+ >
+ <template v-if="filteredBlobsLength">
+ <li
+ v-for="(file, index) in filteredBlobs"
+ :key="file.key"
+ >
+ <item
+ class="disable-hover"
+ :file="file"
+ :search-text="searchText"
+ :focused="index === focusedIndex"
+ :index="index"
+ @click="openFile"
+ @mouseover="onMouseOver"
+ @mousemove="onMouseMove"
+ />
+ </li>
+ </template>
+ <li
+ v-else
+ class="dropdown-menu-empty-item"
+ >
+ <div class="append-right-default prepend-left-default prepend-top-8 append-bottom-8">
+ <template v-if="loading">
+ {{ __('Loading...') }}
+ </template>
+ <template v-else>
+ {{ __('No files found.') }}
+ </template>
+ </div>
+ </li>
+ </virtual-list>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/file_finder/item.vue b/app/assets/javascripts/ide/components/file_finder/item.vue
new file mode 100644
index 00000000000..d4427420207
--- /dev/null
+++ b/app/assets/javascripts/ide/components/file_finder/item.vue
@@ -0,0 +1,113 @@
+<script>
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import FileIcon from '../../../vue_shared/components/file_icon.vue';
+import ChangedFileIcon from '../changed_file_icon.vue';
+
+const MAX_PATH_LENGTH = 60;
+
+export default {
+ components: {
+ ChangedFileIcon,
+ FileIcon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ focused: {
+ type: Boolean,
+ required: true,
+ },
+ searchText: {
+ type: String,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ pathWithEllipsis() {
+ const path = this.file.path;
+
+ return path.length < MAX_PATH_LENGTH
+ ? path
+ : `...${path.substr(path.length - MAX_PATH_LENGTH)}`;
+ },
+ nameSearchTextOccurences() {
+ return fuzzaldrinPlus.match(this.file.name, this.searchText);
+ },
+ pathSearchTextOccurences() {
+ return fuzzaldrinPlus.match(this.pathWithEllipsis, this.searchText);
+ },
+ },
+ methods: {
+ clickRow() {
+ this.$emit('click', this.file);
+ },
+ mouseOverRow() {
+ this.$emit('mouseover', this.index);
+ },
+ mouseMove() {
+ this.$emit('mousemove', this.index);
+ },
+ },
+};
+</script>
+
+<template>
+ <button
+ type="button"
+ class="diff-changed-file"
+ :class="{
+ 'is-focused': focused,
+ }"
+ @click.prevent="clickRow"
+ @mouseover="mouseOverRow"
+ @mousemove="mouseMove"
+ >
+ <file-icon
+ :file-name="file.name"
+ :size="16"
+ css-classes="diff-file-changed-icon append-right-8"
+ />
+ <span class="diff-changed-file-content append-right-8">
+ <strong
+ class="diff-changed-file-name"
+ >
+ <span
+ v-for="(char, index) in file.name.split('')"
+ :key="index + char"
+ :class="{
+ highlighted: nameSearchTextOccurences.indexOf(index) >= 0,
+ }"
+ v-text="char"
+ >
+ </span>
+ </strong>
+ <span
+ class="diff-changed-file-path prepend-top-5"
+ >
+ <span
+ v-for="(char, index) in pathWithEllipsis.split('')"
+ :key="index + char"
+ :class="{
+ highlighted: pathSearchTextOccurences.indexOf(index) >= 0,
+ }"
+ v-text="char"
+ >
+ </span>
+ </span>
+ </span>
+ <span
+ v-if="file.changed || file.tempFile"
+ class="diff-changed-stats"
+ >
+ <changed-file-icon
+ :file="file"
+ />
+ </span>
+ </button>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index d22869466c9..0274fc7d299 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,57 +1,91 @@
<script>
-import { mapState, mapGetters } from 'vuex';
-import ideSidebar from './ide_side_bar.vue';
-import ideContextbar from './ide_context_bar.vue';
-import repoTabs from './repo_tabs.vue';
-import repoFileButtons from './repo_file_buttons.vue';
-import ideStatusBar from './ide_status_bar.vue';
-import repoEditor from './repo_editor.vue';
+ import { mapActions, mapState, mapGetters } from 'vuex';
+ import Mousetrap from 'mousetrap';
+ import ideSidebar from './ide_side_bar.vue';
+ import ideContextbar from './ide_context_bar.vue';
+ import repoTabs from './repo_tabs.vue';
+ import ideStatusBar from './ide_status_bar.vue';
+ import repoEditor from './repo_editor.vue';
+ import FindFile from './file_finder/index.vue';
-export default {
- components: {
- ideSidebar,
- ideContextbar,
- repoTabs,
- repoFileButtons,
- ideStatusBar,
- repoEditor,
- },
- props: {
- emptyStateSvgPath: {
- type: String,
- required: true,
+ const originalStopCallback = Mousetrap.stopCallback;
+
+ export default {
+ components: {
+ ideSidebar,
+ ideContextbar,
+ repoTabs,
+ ideStatusBar,
+ repoEditor,
+ FindFile,
},
- noChangesStateSvgPath: {
- type: String,
- required: true,
+ props: {
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
},
- committedStateSvgPath: {
- type: String,
- required: true,
+ computed: {
+ ...mapState([
+ 'changedFiles',
+ 'openFiles',
+ 'viewer',
+ 'currentMergeRequestId',
+ 'fileFindVisible',
+ ]),
+ ...mapGetters(['activeFile', 'hasChanges']),
},
- },
- computed: {
- ...mapState(['changedFiles', 'openFiles', 'viewer', 'currentMergeRequestId']),
- ...mapGetters(['activeFile', 'hasChanges']),
- },
- mounted() {
- const returnValue = 'Are you sure you want to lose unsaved changes?';
- window.onbeforeunload = e => {
- if (!this.changedFiles.length) return undefined;
+ mounted() {
+ const returnValue = 'Are you sure you want to lose unsaved changes?';
+ window.onbeforeunload = e => {
+ if (!this.changedFiles.length) return undefined;
+
+ Object.assign(e, {
+ returnValue,
+ });
+ return returnValue;
+ };
+
+ Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
+ if (e.preventDefault) {
+ e.preventDefault();
+ }
- Object.assign(e, {
- returnValue,
+ this.toggleFileFinder(!this.fileFindVisible);
});
- return returnValue;
- };
- },
-};
+
+ Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
+ },
+ methods: {
+ ...mapActions(['toggleFileFinder']),
+ mousetrapStopCallback(e, el, combo) {
+ if (combo === 't' && el.classList.contains('dropdown-input-field')) {
+ return true;
+ } else if (combo === 'command+p' || combo === 'ctrl+p') {
+ return false;
+ }
+
+ return originalStopCallback(e, el, combo);
+ },
+ },
+ };
</script>
<template>
<div
class="ide-view"
>
+ <find-file
+ v-show="fileFindVisible"
+ />
<ide-sidebar />
<div
class="multi-file-edit-pane"
@@ -70,9 +104,6 @@ export default {
class="multi-file-edit-pane-content"
:file="activeFile"
/>
- <repo-file-buttons
- :file="activeFile"
- />
<ide-status-bar
:file="activeFile"
/>
diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue
index 79a83b47994..627fbeb9adf 100644
--- a/app/assets/javascripts/ide/components/ide_context_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_context_bar.vue
@@ -1,5 +1,4 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
import icon from '~/vue_shared/components/icon.vue';
import panelResizer from '~/vue_shared/components/panel_resizer.vue';
import repoCommitSection from './repo_commit_section.vue';
@@ -22,13 +21,6 @@ export default {
required: true,
},
},
- computed: {
- ...mapState(['changedFiles', 'rightPanelCollapsed']),
- ...mapGetters(['currentIcon']),
- },
- methods: {
- ...mapActions(['setPanelCollapsedStatus']),
- },
};
</script>
@@ -41,40 +33,6 @@ export default {
<div
class="multi-file-commit-panel-section"
>
- <header
- class="multi-file-commit-panel-header"
- :class="{
- 'is-collapsed': rightPanelCollapsed,
- }"
- >
- <div
- class="multi-file-commit-panel-header-title"
- v-if="!rightPanelCollapsed"
- >
- <div
- v-if="changedFiles.length"
- >
- <icon
- name="list-bulleted"
- :size="18"
- />
- Staged
- </div>
- </div>
- <button
- type="button"
- class="btn btn-transparent multi-file-commit-panel-collapse-btn"
- @click.stop="setPanelCollapsedStatus({
- side: 'right',
- collapsed: !rightPanelCollapsed,
- })"
- >
- <icon
- :name="currentIcon"
- :size="18"
- />
- </button>
- </header>
<repo-commit-section
:no-changes-state-svg-path="noChangesStateSvgPath"
:committed-state-svg-path="committedStateSvgPath"
diff --git a/app/assets/javascripts/ide/components/ide_file_buttons.vue b/app/assets/javascripts/ide/components/ide_file_buttons.vue
new file mode 100644
index 00000000000..a6c6f46a144
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_file_buttons.vue
@@ -0,0 +1,84 @@
+<script>
+import { __ } from '~/locale';
+import tooltip from '~/vue_shared/directives/tooltip';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ showButtons() {
+ return (
+ this.file.rawPath || this.file.blamePath || this.file.commitsPath || this.file.permalink
+ );
+ },
+ rawDownloadButtonLabel() {
+ return this.file.binary ? __('Download') : __('Raw');
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="showButtons"
+ class="pull-right ide-btn-group"
+ >
+ <a
+ v-tooltip
+ v-if="!file.binary"
+ :href="file.blamePath"
+ :title="__('Blame')"
+ class="btn btn-xs btn-transparent blame"
+ >
+ <icon
+ name="blame"
+ :size="16"
+ />
+ </a>
+ <a
+ v-tooltip
+ :href="file.commitsPath"
+ :title="__('History')"
+ class="btn btn-xs btn-transparent history"
+ >
+ <icon
+ name="history"
+ :size="16"
+ />
+ </a>
+ <a
+ v-tooltip
+ :href="file.permalink"
+ :title="__('Permalink')"
+ class="btn btn-xs btn-transparent permalink"
+ >
+ <icon
+ name="link"
+ :size="16"
+ />
+ </a>
+ <a
+ v-tooltip
+ :href="file.rawPath"
+ target="_blank"
+ class="btn btn-xs btn-transparent prepend-left-10 raw"
+ rel="noopener noreferrer"
+ :title="rawDownloadButtonLabel">
+ <icon
+ name="download"
+ :size="16"
+ />
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index 9c386896448..c13eeeace3f 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -1,36 +1,27 @@
<script>
- import icon from '~/vue_shared/components/icon.vue';
- import tooltip from '~/vue_shared/directives/tooltip';
- import timeAgoMixin from '~/vue_shared/mixins/timeago';
+import icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+import timeAgoMixin from '~/vue_shared/mixins/timeago';
- export default {
- components: {
- icon,
+export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ mixins: [timeAgoMixin],
+ props: {
+ file: {
+ type: Object,
+ required: true,
},
- directives: {
- tooltip,
- },
- mixins: [
- timeAgoMixin,
- ],
- props: {
- file: {
- type: Object,
- required: true,
- },
- },
- };
+ },
+};
</script>
<template>
<div class="ide-status-bar">
- <div class="ref-name">
- <icon
- name="branch"
- :size="12"
- />
- {{ file.branchId }}
- </div>
<div>
<div v-if="file.lastCommit && file.lastCommit.id">
Last commit:
@@ -50,7 +41,9 @@
<div class="text-right">
{{ file.eol }}
</div>
- <div class="text-right">
+ <div
+ class="text-right"
+ v-if="!file.binary">
{{ file.editorRow }}:{{ file.editorColumn }}
</div>
<div class="text-right">
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index 769e9b79cad..b1b5c0d4a28 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -1,49 +1,54 @@
<script>
- import { mapActions } from 'vuex';
- import icon from '~/vue_shared/components/icon.vue';
- import newModal from './modal.vue';
- import upload from './upload.vue';
+import { mapActions } from 'vuex';
+import icon from '~/vue_shared/components/icon.vue';
+import newModal from './modal.vue';
+import upload from './upload.vue';
- export default {
- components: {
- icon,
- newModal,
- upload,
+export default {
+ components: {
+ icon,
+ newModal,
+ upload,
+ },
+ props: {
+ branch: {
+ type: String,
+ required: true,
},
- props: {
- branch: {
- type: String,
- required: true,
- },
- path: {
- type: String,
- required: true,
- },
+ path: {
+ type: String,
+ required: true,
},
- data() {
- return {
- openModal: false,
- modalType: '',
- dropdownOpen: false,
- };
+ },
+ data() {
+ return {
+ openModal: false,
+ modalType: '',
+ dropdownOpen: false,
+ };
+ },
+ watch: {
+ dropdownOpen() {
+ this.$nextTick(() => {
+ this.$refs.dropdownMenu.scrollIntoView();
+ });
},
- methods: {
- ...mapActions([
- 'createTempEntry',
- ]),
- createNewItem(type) {
- this.modalType = type;
- this.openModal = true;
- this.dropdownOpen = false;
- },
- hideModal() {
- this.openModal = false;
- },
- openDropdown() {
- this.dropdownOpen = !this.dropdownOpen;
- },
+ },
+ methods: {
+ ...mapActions(['createTempEntry']),
+ createNewItem(type) {
+ this.modalType = type;
+ this.openModal = true;
+ this.dropdownOpen = false;
},
- };
+ hideModal() {
+ this.openModal = false;
+ },
+ openDropdown() {
+ this.dropdownOpen = !this.dropdownOpen;
+ },
+ },
+};
</script>
<template>
@@ -71,7 +76,10 @@
css-classes="pull-left"
/>
</button>
- <ul class="dropdown-menu dropdown-menu-right">
+ <ul
+ class="dropdown-menu dropdown-menu-right"
+ ref="dropdownMenu"
+ >
<li>
<a
href="#"
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index 4b5a50785b6..a95a0225950 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -40,13 +40,6 @@ export default {
return __('Create file');
},
- formLabelName() {
- if (this.type === 'tree') {
- return __('Directory name');
- }
-
- return __('File name');
- },
},
mounted() {
this.$refs.fieldName.focus();
@@ -82,8 +75,8 @@ export default {
@submit.prevent="createEntryInStore"
>
<fieldset class="form-group append-bottom-0">
- <label class="label-light col-sm-3">
- {{ formLabelName }}
+ <label class="label-light col-sm-3 ide-new-modal-label">
+ {{ __('Name') }}
</label>
<div class="col-sm-9">
<input
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index d885ed5e301..fa929381744 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -1,20 +1,26 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip';
-import icon from '~/vue_shared/components/icon.vue';
+import Icon from '~/vue_shared/components/icon.vue';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
-import commitFilesList from './commit_sidebar/list.vue';
+import CommitFilesList from './commit_sidebar/list.vue';
+import EmptyState from './commit_sidebar/empty_state.vue';
+import CommitMessageField from './commit_sidebar/message_field.vue';
+import SuccessMessage from './commit_sidebar/success_message.vue';
import * as consts from '../stores/modules/commit/constants';
import Actions from './commit_sidebar/actions.vue';
export default {
components: {
DeprecatedModal,
- icon,
- commitFilesList,
+ Icon,
+ CommitFilesList,
+ EmptyState,
+ SuccessMessage,
Actions,
LoadingButton,
+ CommitMessageField,
},
directives: {
tooltip,
@@ -30,43 +36,25 @@ export default {
},
},
computed: {
- ...mapState([
- 'currentProjectId',
- 'currentBranchId',
- 'rightPanelCollapsed',
- 'lastCommitMsg',
- 'changedFiles',
- ]),
- ...mapState('commit', ['commitMessage', 'submitCommitLoading']),
- ...mapGetters('commit', [
- 'commitButtonDisabled',
- 'discardDraftButtonDisabled',
- 'branchName',
- ]),
- statusSvg() {
- return this.lastCommitMsg
- ? this.committedStateSvgPath
- : this.noChangesStateSvgPath;
+ showStageUnstageArea() {
+ return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal);
},
+ someUncommitedChanges() {
+ return !!(this.changedFiles.length || this.stagedFiles.length);
+ },
+ ...mapState(['changedFiles', 'stagedFiles', 'rightPanelCollapsed', 'lastCommitMsg', 'unusedSeal']),
+ ...mapState('commit', ['commitMessage', 'submitCommitLoading']),
+ ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']),
},
methods: {
- ...mapActions(['setPanelCollapsedStatus']),
...mapActions('commit', [
'updateCommitMessage',
'discardDraft',
'commitChanges',
'updateCommitAction',
]),
- toggleCollapsed() {
- this.setPanelCollapsedStatus({
- side: 'right',
- collapsed: !this.rightPanelCollapsed,
- });
- },
forceCreateNewBranch() {
- return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() =>
- this.commitChanges(),
- );
+ return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges());
},
},
};
@@ -75,9 +63,6 @@ export default {
<template>
<div
class="multi-file-commit-panel-section"
- :class="{
- 'multi-file-commit-empty-state-container': !changedFiles.length
- }"
>
<deprecated-modal
id="ide-create-branch-modal"
@@ -91,30 +76,48 @@ export default {
Would you like to create a new branch?`) }}
</template>
</deprecated-modal>
- <commit-files-list
- title="Staged"
- :file-list="changedFiles"
- :collapsed="rightPanelCollapsed"
- @toggleCollapsed="toggleCollapsed"
- />
<template
- v-if="changedFiles.length"
+ v-if="showStageUnstageArea"
+ >
+ <commit-files-list
+ icon-name="unstaged"
+ :title="__('Unstaged')"
+ :file-list="changedFiles"
+ action="stageAllChanges"
+ :action-btn-text="__('Stage all')"
+ item-action-component="stage-button"
+ />
+ <commit-files-list
+ icon-name="staged"
+ :title="__('Staged')"
+ :file-list="stagedFiles"
+ action="unstageAllChanges"
+ :action-btn-text="__('Unstage all')"
+ item-action-component="unstage-button"
+ :show-toggle="false"
+ :staged-list="true"
+ />
+ </template>
+ <empty-state
+ v-if="unusedSeal"
+ :no-changes-state-svg-path="noChangesStateSvgPath"
+ />
+ <div
+ class="multi-file-commit-panel-bottom"
>
<form
class="form-horizontal multi-file-commit-form"
@submit.prevent.stop="commitChanges"
v-if="!rightPanelCollapsed"
>
- <div class="multi-file-commit-fieldset">
- <textarea
- class="form-control multi-file-commit-message"
- name="commit-message"
- :value="commitMessage"
- :placeholder="__('Write a commit message...')"
- @input="updateCommitMessage($event.target.value)"
- >
- </textarea>
- </div>
+ <success-message
+ v-if="lastCommitMsg && !someUncommitedChanges"
+ :committed-state-svg-path="committedStateSvgPath"
+ />
+ <commit-message-field
+ :text="commitMessage"
+ @input="updateCommitMessage"
+ />
<div class="clearfix prepend-top-15">
<actions />
<loading-button
@@ -134,39 +137,6 @@ export default {
</button>
</div>
</form>
- </template>
- <div
- v-else-if="!rightPanelCollapsed"
- class="row js-empty-state"
- >
- <div class="col-xs-10 col-xs-offset-1">
- <div class="svg-content svg-80">
- <img :src="statusSvg" />
- </div>
- </div>
- <div class="col-xs-10 col-xs-offset-1">
- <div
- class="text-content text-center"
- v-if="!lastCommitMsg"
- >
- <h4>
- {{ __('No changes') }}
- </h4>
- <p>
- {{ __('Edit files in the editor and commit changes here') }}
- </p>
- </div>
- <div
- class="text-content text-center"
- v-else
- >
- <h4>
- {{ __('All changes are committed') }}
- </h4>
- <p v-html="lastCommitMsg">
- </p>
- </div>
- </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index b1a16350c19..3a04cdd8e46 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -2,10 +2,16 @@
/* global monaco */
import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '~/flash';
+import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import monacoLoader from '../monaco_loader';
import Editor from '../lib/editor';
+import IdeFileButtons from './ide_file_buttons.vue';
export default {
+ components: {
+ ContentViewer,
+ IdeFileButtons,
+ },
props: {
file: {
type: Object,
@@ -13,10 +19,20 @@ export default {
},
},
computed: {
- ...mapState(['leftPanelCollapsed', 'rightPanelCollapsed', 'viewer', 'delayViewerUpdated']),
- ...mapGetters(['currentMergeRequest']),
+ ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']),
+ ...mapGetters(['currentMergeRequest', 'getStagedFile']),
shouldHideEditor() {
- return this.file && this.file.binary && !this.file.raw;
+ return this.file && this.file.binary && !this.file.content;
+ },
+ editTabCSS() {
+ return {
+ active: this.file.viewMode === 'edit',
+ };
+ },
+ previewTabCSS() {
+ return {
+ active: this.file.viewMode === 'preview',
+ };
},
},
watch: {
@@ -26,15 +42,17 @@ export default {
this.initMonaco();
}
},
- leftPanelCollapsed() {
- this.editor.updateDimensions();
- },
rightPanelCollapsed() {
this.editor.updateDimensions();
},
viewer() {
this.createEditorInstance();
},
+ panelResizing() {
+ if (!this.panelResizing) {
+ this.editor.updateDimensions();
+ }
+ },
},
beforeDestroy() {
this.editor.dispose();
@@ -56,6 +74,7 @@ export default {
'changeFileContent',
'setFileLanguage',
'setEditorPosition',
+ 'setFileViewMode',
'setFileEOL',
'updateViewer',
'updateDelayViewerUpdated',
@@ -101,7 +120,12 @@ export default {
setupEditor() {
if (!this.file || !this.editor.instance) return;
- this.model = this.editor.createModel(this.file);
+ const head = this.getStagedFile(this.file.path);
+
+ this.model = this.editor.createModel(
+ this.file,
+ this.file.staged && this.file.key.indexOf('unstaged-') === 0 ? head : null,
+ );
if (this.viewer === 'mrdiff') {
this.editor.attachMergeRequestModel(this.model);
@@ -152,16 +176,49 @@ export default {
id="ide"
class="blob-viewer-container blob-editor-container"
>
- <div
- v-if="shouldHideEditor"
- v-html="file.html"
- >
+ <div class="ide-mode-tabs clearfix">
+ <ul
+ class="nav-links pull-left"
+ v-if="!shouldHideEditor">
+ <li :class="editTabCSS">
+ <a
+ href="javascript:void(0);"
+ role="button"
+ @click.prevent="setFileViewMode({ file, viewMode: 'edit' })">
+ <template v-if="viewer === 'editor'">
+ {{ __('Edit') }}
+ </template>
+ <template v-else>
+ {{ __('Review') }}
+ </template>
+ </a>
+ </li>
+ <li
+ v-if="file.previewMode"
+ :class="previewTabCSS">
+ <a
+ href="javascript:void(0);"
+ role="button"
+ @click.prevent="setFileViewMode({ file, viewMode:'preview' })">
+ {{ file.previewMode.previewTitle }}
+ </a>
+ </li>
+ </ul>
+ <ide-file-buttons
+ :file="file"
+ />
</div>
<div
- v-show="!shouldHideEditor"
+ v-show="!shouldHideEditor && file.viewMode === 'edit'"
ref="editor"
class="multi-file-editor-holder"
>
</div>
+ <content-viewer
+ v-if="shouldHideEditor || file.viewMode === 'preview'"
+ :content="file.content || file.raw"
+ :path="file.rawPath || file.path"
+ :file-size="file.size"
+ :project-path="file.projectId"/>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue
index 3b5068d4910..89c5ce70dd3 100644
--- a/app/assets/javascripts/ide/components/repo_file.vue
+++ b/app/assets/javascripts/ide/components/repo_file.vue
@@ -1,22 +1,29 @@
<script>
-import { mapActions } from 'vuex';
-import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
-import fileIcon from '~/vue_shared/components/file_icon.vue';
+import { mapActions, mapGetters } from 'vuex';
+import { n__, __, sprintf } from '~/locale';
+import tooltip from '~/vue_shared/directives/tooltip';
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
import router from '../ide_router';
-import newDropdown from './new_dropdown/index.vue';
-import fileStatusIcon from './repo_file_status_icon.vue';
-import changedFileIcon from './changed_file_icon.vue';
-import mrFileIcon from './mr_file_icon.vue';
+import NewDropdown from './new_dropdown/index.vue';
+import FileStatusIcon from './repo_file_status_icon.vue';
+import ChangedFileIcon from './changed_file_icon.vue';
+import MrFileIcon from './mr_file_icon.vue';
export default {
name: 'RepoFile',
+ directives: {
+ tooltip,
+ },
components: {
- skeletonLoadingContainer,
- newDropdown,
- fileStatusIcon,
- fileIcon,
- changedFileIcon,
- mrFileIcon,
+ SkeletonLoadingContainer,
+ NewDropdown,
+ FileStatusIcon,
+ FileIcon,
+ ChangedFileIcon,
+ MrFileIcon,
+ Icon,
},
props: {
file: {
@@ -29,6 +36,34 @@ export default {
},
},
computed: {
+ ...mapGetters([
+ 'getChangesInFolder',
+ 'getUnstagedFilesCountForPath',
+ 'getStagedFilesCountForPath',
+ ]),
+ folderUnstagedCount() {
+ return this.getUnstagedFilesCountForPath(this.file.path);
+ },
+ folderStagedCount() {
+ return this.getStagedFilesCountForPath(this.file.path);
+ },
+ changesCount() {
+ return this.getChangesInFolder(this.file.path);
+ },
+ folderChangesTooltip() {
+ if (this.changesCount === 0) return undefined;
+
+ if (this.folderUnstagedCount > 0 && this.folderStagedCount === 0) {
+ return n__('%d unstaged change', '%d unstaged changes', this.folderUnstagedCount);
+ } else if (this.folderUnstagedCount === 0 && this.folderStagedCount > 0) {
+ return n__('%d staged change', '%d staged changes', this.folderStagedCount);
+ }
+
+ return sprintf(__('%{unstaged} unstaged and %{staged} staged changes'), {
+ unstaged: this.folderUnstagedCount,
+ staged: this.folderStagedCount,
+ });
+ },
isTree() {
return this.file.type === 'tree';
},
@@ -48,10 +83,19 @@ export default {
'is-open': this.file.opened,
};
},
+ showTreeChangesCount() {
+ return this.isTree && this.changesCount > 0 && !this.file.opened;
+ },
+ showChangedFileIcon() {
+ return this.file.changed || this.file.tempFile || this.file.staged;
+ },
},
updated() {
if (this.file.type === 'blob' && this.file.active) {
- this.$el.scrollIntoView();
+ this.$el.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest',
+ });
}
},
methods: {
@@ -97,13 +141,32 @@ export default {
:file="file"
/>
</span>
- <span class="pull-right">
+ <span class="pull-right ide-file-icon-holder">
<mr-file-icon
v-if="file.mrChange"
/>
+ <span
+ v-if="showTreeChangesCount"
+ class="ide-tree-changes"
+ >
+ {{ changesCount }}
+ <icon
+ v-tooltip
+ :title="folderChangesTooltip"
+ data-container="body"
+ data-placement="right"
+ name="file-modified"
+ :size="12"
+ css-classes="prepend-left-5 multi-file-modified"
+ />
+ </span>
<changed-file-icon
+ v-else-if="showChangedFileIcon"
:file="file"
- v-if="file.changed || file.tempFile"
+ :show-tooltip="true"
+ :show-staged-icon="true"
+ :force-modified-icon="true"
+ class="pull-right"
/>
</span>
<new-dropdown
diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue
deleted file mode 100644
index 4ea8cf7504b..00000000000
--- a/app/assets/javascripts/ide/components/repo_file_buttons.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<script>
-export default {
- props: {
- file: {
- type: Object,
- required: true,
- },
- },
- computed: {
- showButtons() {
- return this.file.rawPath ||
- this.file.blamePath ||
- this.file.commitsPath ||
- this.file.permalink;
- },
- rawDownloadButtonLabel() {
- return this.file.binary ? 'Download' : 'Raw';
- },
- },
-};
-</script>
-
-<template>
- <div
- v-if="showButtons"
- class="multi-file-editor-btn-group"
- >
- <a
- :href="file.rawPath"
- target="_blank"
- class="btn btn-default btn-sm raw"
- rel="noopener noreferrer">
- {{ rawDownloadButtonLabel }}
- </a>
-
- <div
- class="btn-group"
- role="group"
- aria-label="File actions"
- >
- <a
- :href="file.blamePath"
- class="btn btn-default btn-sm blame"
- >
- Blame
- </a>
- <a
- :href="file.commitsPath"
- class="btn btn-default btn-sm history"
- >
- History
- </a>
- <a
- :href="file.permalink"
- class="btn btn-default btn-sm permalink"
- >
- Permalink
- </a>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index 304a73ed1ad..a3ee3184c19 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -26,13 +26,16 @@ export default {
},
computed: {
closeLabel() {
- if (this.tab.changed || this.tab.tempFile) {
+ if (this.fileHasChanged) {
return `${this.tab.name} changed`;
}
return `Close ${this.tab.name}`;
},
showChangedIcon() {
- return this.tab.changed ? !this.tabMouseOver : false;
+ return this.fileHasChanged ? !this.tabMouseOver : false;
+ },
+ fileHasChanged() {
+ return this.tab.changed || this.tab.tempFile || this.tab.staged;
},
},
@@ -42,18 +45,18 @@ export default {
this.updateDelayViewerUpdated(true);
if (tab.pending) {
- this.openPendingTab(tab);
+ this.openPendingTab({ file: tab, keyPrefix: tab.staged ? 'staged' : 'unstaged' });
} else {
this.$router.push(`/project${tab.url}`);
}
},
mouseOverTab() {
- if (this.tab.changed) {
+ if (this.fileHasChanged) {
this.tabMouseOver = true;
}
},
mouseOutTab() {
- if (this.tab.changed) {
+ if (this.fileHasChanged) {
this.tabMouseOver = false;
}
},
@@ -81,6 +84,7 @@ export default {
<changed-file-icon
v-else
:file="tab"
+ :force-modified-icon="true"
/>
</button>
diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue
index faa690ecba0..5ea2a2f6825 100644
--- a/app/assets/javascripts/ide/components/resizable_panel.vue
+++ b/app/assets/javascripts/ide/components/resizable_panel.vue
@@ -1,67 +1,64 @@
<script>
- import { mapActions, mapState } from 'vuex';
- import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+import { mapActions, mapState } from 'vuex';
+import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
- export default {
- components: {
- PanelResizer,
+export default {
+ components: {
+ PanelResizer,
+ },
+ props: {
+ collapsible: {
+ type: Boolean,
+ required: true,
},
- props: {
- collapsible: {
- type: Boolean,
- required: true,
- },
- initialWidth: {
- type: Number,
- required: true,
- },
- minSize: {
- type: Number,
- required: false,
- default: 200,
- },
- side: {
- type: String,
- required: true,
- },
+ initialWidth: {
+ type: Number,
+ required: true,
},
- data() {
- return {
- width: this.initialWidth,
- };
+ minSize: {
+ type: Number,
+ required: false,
+ default: 340,
},
- computed: {
- ...mapState({
- collapsed(state) {
- return state[`${this.side}PanelCollapsed`];
- },
- }),
- panelStyle() {
- if (!this.collapsed) {
- return {
- width: `${this.width}px`,
- };
- }
-
- return {};
- },
+ side: {
+ type: String,
+ required: true,
},
- methods: {
- ...mapActions([
- 'setPanelCollapsedStatus',
- 'setResizingStatus',
- ]),
- toggleFullbarCollapsed() {
- if (this.collapsed && this.collapsible) {
- this.setPanelCollapsedStatus({
- side: this.side,
- collapsed: !this.collapsed,
- });
- }
+ },
+ data() {
+ return {
+ width: this.initialWidth,
+ };
+ },
+ computed: {
+ ...mapState({
+ collapsed(state) {
+ return state[`${this.side}PanelCollapsed`];
},
+ }),
+ panelStyle() {
+ if (!this.collapsed) {
+ return {
+ width: `${this.width}px`,
+ };
+ }
+
+ return {};
+ },
+ },
+ methods: {
+ ...mapActions(['setPanelCollapsedStatus', 'setResizingStatus']),
+ toggleFullbarCollapsed() {
+ if (this.collapsed && this.collapsible) {
+ this.setPanelCollapsedStatus({
+ side: this.side,
+ collapsed: !this.collapsed,
+ });
+ }
},
- maxSize: (window.innerWidth / 2),
- };
+ },
+ maxSize: window.innerWidth / 2,
+};
</script>
<template>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
new file mode 100644
index 00000000000..b06da9f95d1
--- /dev/null
+++ b/app/assets/javascripts/ide/constants.js
@@ -0,0 +1,8 @@
+// Fuzzy file finder
+export const MAX_FILE_FINDER_RESULTS = 40;
+export const FILE_FINDER_ROW_HEIGHT = 55;
+export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33;
+
+// Commit message textarea
+export const MAX_TITLE_LENGTH = 50;
+export const MAX_BODY_LENGTH = 72;
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
index 20983666b4a..4a0a303d5a6 100644
--- a/app/assets/javascripts/ide/ide_router.js
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -36,11 +36,11 @@ const router = new VueRouter({
base: `${gon.relative_url_root}/-/ide/`,
routes: [
{
- path: '/project/:namespace/:project',
+ path: '/project/:namespace/:project+',
component: EmptyRouterComponent,
children: [
{
- path: ':targetmode/:branch/*',
+ path: ':targetmode(edit|tree|blob)/:branch/*',
component: EmptyRouterComponent,
},
{
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
index e47adae99ed..016dcda1fa1 100644
--- a/app/assets/javascripts/ide/lib/common/model.js
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -3,15 +3,16 @@ import Disposable from './disposable';
import eventHub from '../../eventhub';
export default class Model {
- constructor(monaco, file) {
+ constructor(monaco, file, head = null) {
this.monaco = monaco;
this.disposable = new Disposable();
this.file = file;
+ this.head = head;
this.content = file.content !== '' ? file.content : file.raw;
this.disposable.add(
(this.originalModel = this.monaco.editor.createModel(
- this.file.raw,
+ head ? head.content : this.file.raw,
undefined,
new this.monaco.Uri(null, null, `original/${this.file.key}`),
)),
@@ -31,13 +32,15 @@ export default class Model {
);
}
- this.events = new Map();
+ this.events = new Set();
this.updateContent = this.updateContent.bind(this);
+ this.updateNewContent = this.updateNewContent.bind(this);
this.dispose = this.dispose.bind(this);
eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose);
- eventHub.$on(`editor.update.model.content.${this.file.path}`, this.updateContent);
+ eventHub.$on(`editor.update.model.content.${this.file.key}`, this.updateContent);
+ eventHub.$on(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
}
get url() {
@@ -73,22 +76,36 @@ export default class Model {
}
onChange(cb) {
- this.events.set(
- this.path,
- this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))),
- );
+ this.events.add(this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))));
+ }
+
+ onDispose(cb) {
+ this.events.add(cb);
}
- updateContent(content) {
+ updateContent({ content, changed }) {
this.getOriginalModel().setValue(content);
+
+ if (!changed) {
+ this.getModel().setValue(content);
+ }
+ }
+
+ updateNewContent(content) {
this.getModel().setValue(content);
}
dispose() {
this.disposable.dispose();
+
+ this.events.forEach(cb => {
+ if (typeof cb === 'function') cb();
+ });
+
this.events.clear();
eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose);
- eventHub.$off(`editor.update.model.content.${this.file.path}`, this.updateContent);
+ eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent);
+ eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
}
}
diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js
index 0e7b563b5d6..7f643969480 100644
--- a/app/assets/javascripts/ide/lib/common/model_manager.js
+++ b/app/assets/javascripts/ide/lib/common/model_manager.js
@@ -17,12 +17,12 @@ export default class ModelManager {
return this.models.get(key);
}
- addModel(file) {
+ addModel(file, head = null) {
if (this.hasCachedModel(file.key)) {
return this.getModel(file.key);
}
- const model = new Model(this.monaco, file);
+ const model = new Model(this.monaco, file, head);
this.models.set(model.path, model);
this.disposable.add(model);
diff --git a/app/assets/javascripts/ide/lib/decorations/controller.js b/app/assets/javascripts/ide/lib/decorations/controller.js
index 42904774747..13d477bb2cf 100644
--- a/app/assets/javascripts/ide/lib/decorations/controller.js
+++ b/app/assets/javascripts/ide/lib/decorations/controller.js
@@ -38,6 +38,15 @@ export default class DecorationsController {
);
}
+ hasDecorations(model) {
+ return this.decorations.has(model.url);
+ }
+
+ removeDecorations(model) {
+ this.decorations.delete(model.url);
+ this.editorDecorations.delete(model.url);
+ }
+
dispose() {
this.decorations.clear();
this.editorDecorations.clear();
diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js
index b136545ad11..f579424cf33 100644
--- a/app/assets/javascripts/ide/lib/diff/controller.js
+++ b/app/assets/javascripts/ide/lib/diff/controller.js
@@ -3,7 +3,7 @@ import { throttle } from 'underscore';
import DirtyDiffWorker from './diff_worker';
import Disposable from '../common/disposable';
-export const getDiffChangeType = (change) => {
+export const getDiffChangeType = change => {
if (change.modified) {
return 'modified';
} else if (change.added) {
@@ -16,12 +16,7 @@ export const getDiffChangeType = (change) => {
};
export const getDecorator = change => ({
- range: new monaco.Range(
- change.lineNumber,
- 1,
- change.endLineNumber,
- 1,
- ),
+ range: new monaco.Range(change.lineNumber, 1, change.endLineNumber, 1),
options: {
isWholeLine: true,
linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`,
@@ -31,6 +26,7 @@ export const getDecorator = change => ({
export default class DirtyDiffController {
constructor(modelManager, decorationsController) {
this.disposable = new Disposable();
+ this.models = new Map();
this.editorSimpleWorker = null;
this.modelManager = modelManager;
this.decorationsController = decorationsController;
@@ -42,7 +38,15 @@ export default class DirtyDiffController {
}
attachModel(model) {
+ if (this.models.has(model.url)) return;
+
model.onChange(() => this.throttledComputeDiff(model));
+ model.onDispose(() => {
+ this.decorationsController.removeDecorations(model);
+ this.models.delete(model.url);
+ });
+
+ this.models.set(model.url, model);
}
computeDiff(model) {
@@ -54,7 +58,11 @@ export default class DirtyDiffController {
}
reDecorate(model) {
- this.decorationsController.decorate(model);
+ if (this.decorationsController.hasDecorations(model)) {
+ this.decorationsController.decorate(model);
+ } else {
+ this.computeDiff(model);
+ }
}
decorate({ data }) {
@@ -65,6 +73,7 @@ export default class DirtyDiffController {
dispose() {
this.disposable.dispose();
+ this.models.clear();
this.dirtyDiffWorker.removeEventListener('message', this.decorate);
this.dirtyDiffWorker.terminate();
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index 6b4ba30e086..b65d9c68a0b 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -1,10 +1,12 @@
import _ from 'underscore';
+import store from '../stores';
import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
import editorOptions, { defaultEditorOptions } from './editor_options';
import gitlabTheme from './themes/gl_theme';
+import keymap from './keymap.json';
export const clearDomElement = el => {
if (!el || !el.firstChild) return;
@@ -53,6 +55,8 @@ export default class Editor {
)),
);
+ this.addCommands();
+
window.addEventListener('resize', this.debouncedUpdate, false);
}
}
@@ -69,19 +73,22 @@ export default class Editor {
occurrencesHighlight: false,
renderLineHighlight: 'none',
hideCursorInOverviewRuler: true,
+ renderSideBySide: Editor.renderSideBySide(domElement),
})),
);
+ this.addCommands();
+
window.addEventListener('resize', this.debouncedUpdate, false);
}
}
- createModel(file) {
- return this.modelManager.addModel(file);
+ createModel(file, head = null) {
+ return this.modelManager.addModel(file, head);
}
attachModel(model) {
- if (this.instance.getEditorType() === 'vs.editor.IDiffEditor') {
+ if (this.isDiffEditorType) {
this.instance.setModel({
original: model.getOriginalModel(),
modified: model.getModel(),
@@ -153,6 +160,7 @@ export default class Editor {
updateDimensions() {
this.instance.layout();
+ this.updateDiffView();
}
setPosition({ lineNumber, column }) {
@@ -171,4 +179,47 @@ export default class Editor {
this.disposable.add(this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)));
}
+
+ updateDiffView() {
+ if (!this.isDiffEditorType) return;
+
+ this.instance.updateOptions({
+ renderSideBySide: Editor.renderSideBySide(this.instance.getDomNode()),
+ });
+ }
+
+ get isDiffEditorType() {
+ return this.instance.getEditorType() === 'vs.editor.IDiffEditor';
+ }
+
+ static renderSideBySide(domElement) {
+ return domElement.offsetWidth >= 700;
+ }
+
+ addCommands() {
+ const getKeyCode = key => {
+ const monacoKeyMod = key.indexOf('KEY_') === 0;
+
+ return monacoKeyMod ? this.monaco.KeyCode[key] : this.monaco.KeyMod[key];
+ };
+
+ keymap.forEach(command => {
+ const keybindings = command.bindings.map(binding => {
+ const keys = binding.split('+');
+
+ // eslint-disable-next-line no-bitwise
+ return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
+ });
+
+ this.instance.addAction({
+ id: command.id,
+ label: command.label,
+ keybindings,
+ run() {
+ store.dispatch(command.action.name, command.action.params);
+ return null;
+ },
+ });
+ });
+ }
}
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
index a213862f9b3..9f895d49f2e 100644
--- a/app/assets/javascripts/ide/lib/editor_options.js
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -6,7 +6,7 @@ export const defaultEditorOptions = {
minimap: {
enabled: false,
},
- wordWrap: 'bounded',
+ wordWrap: 'on',
};
export default [
diff --git a/app/assets/javascripts/ide/lib/keymap.json b/app/assets/javascripts/ide/lib/keymap.json
new file mode 100644
index 00000000000..131abfebbed
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/keymap.json
@@ -0,0 +1,11 @@
+[
+ {
+ "id": "file-finder",
+ "label": "File finder",
+ "bindings": ["CtrlCmd+KEY_P"],
+ "action": {
+ "name": "toggleFileFinder",
+ "params": true
+ }
+ }
+]
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index c6ba679d99c..7358dd9ef92 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Vue from 'vue';
import { visitUrl } from '~/lib/utils/url_utility';
import flash from '~/flash';
@@ -32,6 +33,19 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
}
};
+export const toggleRightPanelCollapsed = ({ dispatch, state }, e = undefined) => {
+ if (e) {
+ $(e.currentTarget)
+ .tooltip('hide')
+ .blur();
+ }
+
+ dispatch('setPanelCollapsedStatus', {
+ side: 'right',
+ collapsed: !state.rightPanelCollapsed,
+ });
+};
+
export const setResizingStatus = ({ commit }, resizing) => {
commit(types.SET_RESIZING_STATUS, resizing);
};
@@ -60,7 +74,7 @@ export const createTempEntry = (
}
worker.addEventListener('message', ({ data }) => {
- const { file } = data;
+ const { file, parentPath } = data;
worker.terminate();
@@ -76,6 +90,10 @@ export const createTempEntry = (
dispatch('setFileActive', file.path);
}
+ if (parentPath && !state.entries[parentPath].opened) {
+ commit(types.TOGGLE_TREE_OPEN, parentPath);
+ }
+
resolve(file);
});
@@ -104,6 +122,14 @@ export const scrollToTab = () => {
});
};
+export const stageAllChanges = ({ state, commit }) => {
+ state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path));
+};
+
+export const unstageAllChanges = ({ state, commit }) => {
+ state.stagedFiles.forEach(file => commit(types.UNSTAGE_CHANGE, file.path));
+};
+
export const updateViewer = ({ commit }, viewer) => {
commit(types.UPDATE_VIEWER, viewer);
};
@@ -112,7 +138,27 @@ export const updateDelayViewerUpdated = ({ commit }, delay) => {
commit(types.UPDATE_DELAY_VIEWER_CHANGE, delay);
};
+export const updateTempFlagForEntry = ({ commit, dispatch, state }, { file, tempFile }) => {
+ commit(types.UPDATE_TEMP_FLAG, { path: file.path, tempFile });
+
+ if (file.parentPath) {
+ dispatch('updateTempFlagForEntry', { file: state.entries[file.parentPath], tempFile });
+ }
+};
+
+export const toggleFileFinder = ({ commit }, fileFindVisible) =>
+ commit(types.TOGGLE_FILE_FINDER, fileFindVisible);
+
+export const burstUnusedSeal = ({ state, commit }) => {
+ if (state.unusedSeal) {
+ commit(types.BURST_UNUSED_SEAL);
+ }
+};
+
export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
export * from './actions/merge_request';
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 6b034ea1e82..861830badee 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -24,7 +24,10 @@ export const closeFile = ({ commit, state, dispatch }, file) => {
if (nextFileToOpen.pending) {
dispatch('updateViewer', 'diff');
- dispatch('openPendingTab', nextFileToOpen);
+ dispatch('openPendingTab', {
+ file: nextFileToOpen,
+ keyPrefix: nextFileToOpen.staged ? 'staged' : 'unstaged',
+ });
} else {
dispatch('updateDelayViewerUpdated', true);
router.push(`/project${nextFileToOpen.url}`);
@@ -60,7 +63,7 @@ export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive
const file = state.entries[path];
commit(types.TOGGLE_LOADING, { entry: file });
return service
- .getFileData(file.url)
+ .getFileData(`${gon.relative_url_root ? gon.relative_url_root : ''}${file.url}`)
.then(res => {
const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']);
setPageTitle(pageTitle);
@@ -114,7 +117,7 @@ export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) =
});
};
-export const changeFileContent = ({ state, commit }, { path, content }) => {
+export const changeFileContent = ({ commit, dispatch, state }, { path, content }) => {
const file = state.entries[path];
commit(types.UPDATE_FILE_CONTENT, { path, content });
@@ -125,6 +128,8 @@ export const changeFileContent = ({ state, commit }, { path, content }) => {
} else if (!file.changed && indexOfChangedFile !== -1) {
commit(types.REMOVE_FILE_FROM_CHANGED, path);
}
+
+ dispatch('burstUnusedSeal', {}, { root: true });
};
export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => {
@@ -149,7 +154,11 @@ export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn
}
};
-export const discardFileChanges = ({ state, commit }, path) => {
+export const setFileViewMode = ({ state, commit }, { file, viewMode }) => {
+ commit(types.SET_FILE_VIEWMODE, { file, viewMode });
+};
+
+export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => {
const file = state.entries[path];
commit(types.DISCARD_FILE_CHANGES, path);
@@ -157,17 +166,40 @@ export const discardFileChanges = ({ state, commit }, path) => {
if (file.tempFile && file.opened) {
commit(types.TOGGLE_FILE_OPEN, path);
+ } else if (getters.activeFile && file.path === getters.activeFile.path) {
+ dispatch('updateDelayViewerUpdated', true)
+ .then(() => {
+ router.push(`/project${file.url}`);
+ })
+ .catch(e => {
+ throw e;
+ });
}
- eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw);
+ eventHub.$emit(`editor.update.model.new.content.${file.key}`, file.content);
+ eventHub.$emit(`editor.update.model.dispose.unstaged-${file.key}`, file.content);
+};
+
+export const stageChange = ({ commit, state }, path) => {
+ const stagedFile = state.stagedFiles.find(f => f.path === path);
+
+ commit(types.STAGE_CHANGE, path);
+
+ if (stagedFile) {
+ eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content);
+ }
+};
+
+export const unstageChange = ({ commit }, path) => {
+ commit(types.UNSTAGE_CHANGE, path);
};
-export const openPendingTab = ({ commit, getters, dispatch, state }, file) => {
- if (getters.activeFile && getters.activeFile.path === file.path && state.viewer === 'diff') {
+export const openPendingTab = ({ commit, getters, dispatch, state }, { file, keyPrefix }) => {
+ if (getters.activeFile && getters.activeFile === file && state.viewer === 'diff') {
return false;
}
- commit(types.ADD_PENDING_TAB, { file });
+ commit(types.ADD_PENDING_TAB, { file, keyPrefix });
dispatch('scrollToTab');
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index b3882cb8d21..4eb23b2ee0f 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -5,45 +5,71 @@ import * as types from '../mutation_types';
export const getProjectData = (
{ commit, state, dispatch },
{ namespace, projectId, force = false } = {},
-) => new Promise((resolve, reject) => {
- if (!state.projects[`${namespace}/${projectId}`] || force) {
- commit(types.TOGGLE_LOADING, { entry: state });
- service.getProjectData(namespace, projectId)
- .then(res => res.data)
- .then((data) => {
+) =>
+ new Promise((resolve, reject) => {
+ if (!state.projects[`${namespace}/${projectId}`] || force) {
commit(types.TOGGLE_LOADING, { entry: state });
- commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data });
- if (!state.currentProjectId) commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`);
- resolve(data);
- })
- .catch(() => {
- flash('Error loading project data. Please try again.', 'alert', document, null, false, true);
- reject(new Error(`Project not loaded ${namespace}/${projectId}`));
- });
- } else {
- resolve(state.projects[`${namespace}/${projectId}`]);
- }
-});
+ service
+ .getProjectData(namespace, projectId)
+ .then(res => res.data)
+ .then(data => {
+ commit(types.TOGGLE_LOADING, { entry: state });
+ commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data });
+ if (!state.currentProjectId)
+ commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`);
+ resolve(data);
+ })
+ .catch(() => {
+ flash(
+ 'Error loading project data. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ reject(new Error(`Project not loaded ${namespace}/${projectId}`));
+ });
+ } else {
+ resolve(state.projects[`${namespace}/${projectId}`]);
+ }
+ });
export const getBranchData = (
{ commit, state, dispatch },
{ projectId, branchId, force = false } = {},
-) => new Promise((resolve, reject) => {
- if ((typeof state.projects[`${projectId}`] === 'undefined' ||
- !state.projects[`${projectId}`].branches[branchId])
- || force) {
- service.getBranchData(`${projectId}`, branchId)
- .then(({ data }) => {
- const { id } = data.commit;
- commit(types.SET_BRANCH, { projectPath: `${projectId}`, branchName: branchId, branch: data });
- commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id });
- resolve(data);
- })
- .catch(() => {
- flash('Error loading branch data. Please try again.', 'alert', document, null, false, true);
- reject(new Error(`Branch not loaded - ${projectId}/${branchId}`));
- });
- } else {
- resolve(state.projects[`${projectId}`].branches[branchId]);
- }
-});
+) =>
+ new Promise((resolve, reject) => {
+ if (
+ typeof state.projects[`${projectId}`] === 'undefined' ||
+ !state.projects[`${projectId}`].branches[branchId] ||
+ force
+ ) {
+ service
+ .getBranchData(`${projectId}`, branchId)
+ .then(({ data }) => {
+ const { id } = data.commit;
+ commit(types.SET_BRANCH, {
+ projectPath: `${projectId}`,
+ branchName: branchId,
+ branch: data,
+ });
+ commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id });
+ commit(types.SET_CURRENT_BRANCH, branchId);
+ resolve(data);
+ })
+ .catch(() => {
+ flash(
+ 'Error loading branch data. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ reject(new Error(`Branch not loaded - ${projectId}/${branchId}`));
+ });
+ } else {
+ resolve(state.projects[`${projectId}`].branches[branchId]);
+ }
+ });
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index a77cdbc13c8..a93d29fd865 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -1,3 +1,6 @@
+import { __ } from '~/locale';
+import { getChangesCountForFiles, filePathMatches } from './utils';
+
export const activeFile = state => state.openFiles.find(file => file.active) || null;
export const addedFiles = state => state.changedFiles.filter(f => f.tempFile);
@@ -29,9 +32,47 @@ export const currentMergeRequest = state => {
};
// eslint-disable-next-line no-confusing-arrow
-export const currentIcon = state =>
+export const collapseButtonIcon = state =>
state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right';
-export const hasChanges = state => !!state.changedFiles.length;
+export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length;
+
+// eslint-disable-next-line no-confusing-arrow
+export const collapseButtonTooltip = state =>
+ state.rightPanelCollapsed ? __('Expand sidebar') : __('Collapse sidebar');
export const hasMergeRequest = state => !!state.currentMergeRequestId;
+
+export const allBlobs = state =>
+ Object.keys(state.entries)
+ .reduce((acc, key) => {
+ const entry = state.entries[key];
+
+ if (entry.type === 'blob') {
+ acc.push(entry);
+ }
+
+ return acc;
+ }, [])
+ .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
+
+export const getChangedFile = state => path => state.changedFiles.find(f => f.path === path);
+export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path);
+
+export const getChangesInFolder = state => path => {
+ const changedFilesCount = state.changedFiles.filter(f => filePathMatches(f, path)).length;
+ const stagedFilesCount = state.stagedFiles.filter(
+ f => filePathMatches(f, path) && !getChangedFile(state)(f.path),
+ ).length;
+
+ return changedFilesCount + stagedFilesCount;
+};
+
+export const getUnstagedFilesCountForPath = state => path =>
+ getChangesCountForFiles(state.changedFiles, path);
+
+export const getStagedFilesCountForPath = state => path =>
+ getChangesCountForFiles(state.stagedFiles, path);
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index f536ce6344b..4fbc97d053e 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -37,9 +37,9 @@ export const setLastCommitMessage = ({ rootState, commit }, data) => {
const commitMsg = sprintf(
__('Your changes have been committed. Commit %{commitId} %{commitStats}'),
{
- commitId: `<a href="${currentProject.web_url}/commit/${
+ commitId: `<a href="${currentProject.web_url}/commit/${data.short_id}" class="commit-sha">${
data.short_id
- }" class="commit-sha">${data.short_id}</a>`,
+ }</a>`,
commitStats,
},
false,
@@ -54,9 +54,7 @@ export const checkCommitStatus = ({ rootState }) =>
.then(({ data }) => {
const { id } = data.commit;
const selectedBranch =
- rootState.projects[rootState.currentProjectId].branches[
- rootState.currentBranchId
- ];
+ rootState.projects[rootState.currentProjectId].branches[rootState.currentBranchId];
if (selectedBranch.workingReference !== id) {
return true;
@@ -100,23 +98,14 @@ export const updateFilesAfterCommit = (
{ root: true },
);
- rootState.changedFiles.forEach(entry => {
- commit(
- rootTypes.SET_LAST_COMMIT_DATA,
- {
- entry,
- lastCommit,
- },
- { root: true },
- );
-
- eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content);
+ rootState.stagedFiles.forEach(file => {
+ const changedFile = rootState.changedFiles.find(f => f.path === file.path);
commit(
- rootTypes.SET_FILE_RAW_DATA,
+ rootTypes.UPDATE_FILE_AFTER_COMMIT,
{
- file: entry,
- raw: entry.content,
+ file,
+ lastCommit,
},
{ root: true },
);
@@ -124,43 +113,31 @@ export const updateFilesAfterCommit = (
commit(
rootTypes.TOGGLE_FILE_CHANGED,
{
- file: entry,
+ file,
changed: false,
},
{ root: true },
);
- });
- commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true });
+ dispatch('updateTempFlagForEntry', { file, tempFile: false }, { root: true });
- if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) {
+ eventHub.$emit(`editor.update.model.content.${file.key}`, {
+ content: file.content,
+ changed: !!changedFile,
+ });
+ });
+
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH && rootGetters.activeFile) {
router.push(
- `/project/${rootState.currentProjectId}/blob/${branch}/${
- rootGetters.activeFile.path
- }`,
+ `/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`,
);
}
-
- dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH);
};
-export const commitChanges = ({
- commit,
- state,
- getters,
- dispatch,
- rootState,
-}) => {
+export const commitChanges = ({ commit, state, getters, dispatch, rootState }) => {
const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH;
- const payload = createCommitPayload(
- getters.branchName,
- newBranch,
- state,
- rootState,
- );
- const getCommitStatus = newBranch
- ? Promise.resolve(false)
- : dispatch('checkCommitStatus');
+ const payload = createCommitPayload(getters.branchName, newBranch, state, rootState);
+ const getCommitStatus = newBranch ? Promise.resolve(false) : dispatch('checkCommitStatus');
commit(types.UPDATE_LOADING, true);
@@ -182,28 +159,35 @@ export const commitChanges = ({
if (!data.short_id) {
flash(data.message, 'alert', document, null, false, true);
- return;
+ return null;
}
dispatch('setLastCommitMessage', data);
dispatch('updateCommitMessage', '');
+ return dispatch('updateFilesAfterCommit', {
+ data,
+ branch: getters.branchName,
+ })
+ .then(() => {
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) {
+ dispatch(
+ 'redirectToUrl',
+ createNewMergeRequestUrl(
+ rootState.projects[rootState.currentProjectId].web_url,
+ getters.branchName,
+ rootState.currentBranchId,
+ ),
+ { root: true },
+ );
+ }
- if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) {
- dispatch(
- 'redirectToUrl',
- createNewMergeRequestUrl(
- rootState.projects[rootState.currentProjectId].web_url,
- getters.branchName,
- rootState.currentBranchId,
- ),
- { root: true },
- );
- } else {
- dispatch('updateFilesAfterCommit', {
- data,
- branch: getters.branchName,
- });
- }
+ commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true });
+
+ setTimeout(() => {
+ commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true });
+ }, 5000);
+ })
+ .then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH));
})
.catch(err => {
let errMsg = __('Error committing changes. Please try again.');
@@ -216,3 +200,6 @@ export const commitChanges = ({
commit(types.UPDATE_LOADING, false);
});
};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
index f7cdd6adb0c..d01060201f2 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -1,12 +1,17 @@
import * as consts from './constants';
-export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading;
+const BRANCH_SUFFIX_COUNT = 5;
+
+export const discardDraftButtonDisabled = state =>
+ state.commitMessage === '' || state.submitCommitLoading;
export const commitButtonDisabled = (state, getters, rootState) =>
- getters.discardDraftButtonDisabled || !rootState.changedFiles.length;
+ getters.discardDraftButtonDisabled || !rootState.stagedFiles.length;
export const newBranchName = (state, _, rootState) =>
- `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`;
+ `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(
+ -BRANCH_SUFFIX_COUNT,
+ )}`;
export const branchName = (state, getters, rootState) => {
if (
@@ -22,3 +27,6 @@ export const branchName = (state, getters, rootState) => {
return rootState.currentBranchId;
};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index ee759bff516..87b39379338 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -38,6 +38,7 @@ export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA';
export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE';
export const SET_FILE_POSITION = 'SET_FILE_POSITION';
+export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE';
export const SET_FILE_EOL = 'SET_FILE_EOL';
export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
@@ -50,5 +51,14 @@ export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE';
export const UPDATE_VIEWER = 'UPDATE_VIEWER';
export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE';
+export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES';
+export const STAGE_CHANGE = 'STAGE_CHANGE';
+export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE';
+
+export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT';
export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB';
+
+export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG';
+export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
+export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index 5e5eb831662..539a07116b3 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -4,6 +4,7 @@ import mergeRequestMutation from './mutations/merge_request';
import fileMutations from './mutations/file';
import treeMutations from './mutations/tree';
import branchMutations from './mutations/branch';
+import { sortTree } from './utils';
export default {
[types.SET_INITIAL_DATA](state, data) {
@@ -49,6 +50,11 @@ export default {
lastCommitMsg,
});
},
+ [types.CLEAR_STAGED_CHANGES](state) {
+ Object.assign(state, {
+ stagedFiles: [],
+ });
+ },
[types.SET_ENTRIES](state, entries) {
Object.assign(state, {
entries,
@@ -68,7 +74,7 @@ export default {
f => foundEntry.tree.find(e => e.path === f.path) === undefined,
);
Object.assign(foundEntry, {
- tree: foundEntry.tree.concat(tree),
+ tree: sortTree(foundEntry.tree.concat(tree)),
});
}
@@ -81,10 +87,16 @@ export default {
if (!foundEntry) {
Object.assign(state.trees[`${projectId}/${branchId}`], {
- tree: state.trees[`${projectId}/${branchId}`].tree.concat(data.treeList),
+ tree: sortTree(state.trees[`${projectId}/${branchId}`].tree.concat(data.treeList)),
});
}
},
+ [types.UPDATE_TEMP_FLAG](state, { path, tempFile }) {
+ Object.assign(state.entries[path], {
+ tempFile,
+ changed: tempFile,
+ });
+ },
[types.UPDATE_VIEWER](state, viewer) {
Object.assign(state, {
viewer,
@@ -95,6 +107,32 @@ export default {
delayViewerUpdated,
});
},
+ [types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
+ Object.assign(state, {
+ fileFindVisible,
+ });
+ },
+ [types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) {
+ const changedFile = state.changedFiles.find(f => f.path === file.path);
+
+ Object.assign(state.entries[file.path], {
+ raw: file.content,
+ changed: !!changedFile,
+ staged: false,
+ lastCommit: Object.assign(state.entries[file.path].lastCommit, {
+ id: lastCommit.commit.id,
+ url: lastCommit.commit_path,
+ message: lastCommit.commit.message,
+ author: lastCommit.commit.author_name,
+ updatedAt: lastCommit.commit.authored_date,
+ }),
+ });
+ },
+ [types.BURST_UNUSED_SEAL](state) {
+ Object.assign(state, {
+ unusedSeal: false,
+ });
+ },
...projectMutations,
...mergeRequestMutation,
...fileMutations,
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index 926b6f66d78..c3041c77199 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -4,6 +4,7 @@ export default {
[types.SET_FILE_ACTIVE](state, { path, active }) {
Object.assign(state.entries[path], {
active,
+ lastOpenedAt: new Date().getTime(),
});
if (active && !state.entries[path].pending) {
@@ -42,6 +43,8 @@ export default {
renderError: data.render_error,
raw: null,
baseRaw: null,
+ html: data.html,
+ size: data.size,
});
},
[types.SET_FILE_RAW_DATA](state, { file, raw }) {
@@ -55,7 +58,9 @@ export default {
});
},
[types.UPDATE_FILE_CONTENT](state, { path, content }) {
- const changed = content !== state.entries[path].raw;
+ const stagedFile = state.stagedFiles.find(f => f.path === path);
+ const rawContent = stagedFile ? stagedFile.content : state.entries[path].raw;
+ const changed = content !== rawContent;
Object.assign(state.entries[path], {
content,
@@ -83,9 +88,16 @@ export default {
mrChange,
});
},
+ [types.SET_FILE_VIEWMODE](state, { file, viewMode }) {
+ Object.assign(state.entries[file.path], {
+ viewMode,
+ });
+ },
[types.DISCARD_FILE_CHANGES](state, path) {
+ const stagedFile = state.stagedFiles.find(f => f.path === path);
+
Object.assign(state.entries[path], {
- content: state.entries[path].raw,
+ content: stagedFile ? stagedFile.content : state.entries[path].raw,
changed: false,
});
},
@@ -99,16 +111,67 @@ export default {
changedFiles: state.changedFiles.filter(f => f.path !== path),
});
},
+ [types.STAGE_CHANGE](state, path) {
+ const stagedFile = state.stagedFiles.find(f => f.path === path);
+
+ Object.assign(state, {
+ changedFiles: state.changedFiles.filter(f => f.path !== path),
+ entries: Object.assign(state.entries, {
+ [path]: Object.assign(state.entries[path], {
+ staged: true,
+ changed: false,
+ }),
+ }),
+ });
+
+ if (stagedFile) {
+ Object.assign(stagedFile, {
+ ...state.entries[path],
+ });
+ } else {
+ Object.assign(state, {
+ stagedFiles: state.stagedFiles.concat({
+ ...state.entries[path],
+ }),
+ });
+ }
+ },
+ [types.UNSTAGE_CHANGE](state, path) {
+ const changedFile = state.changedFiles.find(f => f.path === path);
+ const stagedFile = state.stagedFiles.find(f => f.path === path);
+
+ if (!changedFile && stagedFile) {
+ Object.assign(state.entries[path], {
+ ...stagedFile,
+ key: state.entries[path].key,
+ active: state.entries[path].active,
+ opened: state.entries[path].opened,
+ changed: true,
+ });
+
+ Object.assign(state, {
+ changedFiles: state.changedFiles.concat(state.entries[path]),
+ });
+ }
+
+ Object.assign(state, {
+ stagedFiles: state.stagedFiles.filter(f => f.path !== path),
+ entries: Object.assign(state.entries, {
+ [path]: Object.assign(state.entries[path], {
+ staged: false,
+ }),
+ }),
+ });
+ },
[types.TOGGLE_FILE_CHANGED](state, { file, changed }) {
Object.assign(state.entries[file.path], {
changed,
});
},
[types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) {
- const pendingTab = state.openFiles.find(f => f.path === file.path && f.pending);
- let openFiles = state.openFiles.map(f =>
- Object.assign(f, { active: f.path === file.path, opened: false }),
- );
+ const key = `${keyPrefix}-${file.key}`;
+ const pendingTab = state.openFiles.find(f => f.key === key && f.pending);
+ let openFiles = state.openFiles.map(f => Object.assign(f, { active: false, opened: false }));
if (!pendingTab) {
const openFile = openFiles.find(f => f.path === file.path);
@@ -119,10 +182,11 @@ export default {
if (f.path === file.path) {
return acc.concat({
...f,
+ content: file.content,
active: true,
pending: true,
opened: true,
- key: `${keyPrefix}-${f.key}`,
+ key,
});
}
diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js
index 7f7e470c9bb..1176c040fb9 100644
--- a/app/assets/javascripts/ide/stores/mutations/tree.js
+++ b/app/assets/javascripts/ide/stores/mutations/tree.js
@@ -17,12 +17,8 @@ export default {
});
},
[types.SET_DIRECTORY_DATA](state, { data, treePath }) {
- Object.assign(state, {
- trees: Object.assign(state.trees, {
- [treePath]: {
- tree: data,
- },
- }),
+ Object.assign(state.trees[treePath], {
+ tree: data,
});
},
[types.SET_LAST_COMMIT_URL](state, { tree = state, url }) {
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index e5cc8814000..0976d278559 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -3,6 +3,7 @@ export default () => ({
currentBranchId: '',
currentMergeRequestId: '',
changedFiles: [],
+ stagedFiles: [],
endpoints: {},
lastCommitMsg: '',
lastCommitPath: '',
@@ -17,4 +18,6 @@ export default () => ({
entries: {},
viewer: 'editor',
delayViewerUpdated: false,
+ unusedSeal: true,
+ fileFindVisible: false,
});
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 63e4de3b17d..bc79ff4a542 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -15,6 +15,7 @@ export const dataStructure = () => ({
opened: false,
active: false,
changed: false,
+ staged: false,
lastCommitPath: '',
lastCommit: {
id: '',
@@ -38,6 +39,11 @@ export const dataStructure = () => ({
editorColumn: 1,
fileLanguage: '',
eol: '',
+ viewMode: 'edit',
+ previewMode: null,
+ size: 0,
+ parentPath: null,
+ lastOpenedAt: 0,
});
export const decorateData = entity => {
@@ -57,8 +63,10 @@ export const decorateData = entity => {
changed = false,
parentTreeUrl = '',
base64 = false,
-
+ previewMode,
file_lock,
+ html,
+ parentPath = '',
} = entity;
return {
@@ -79,8 +87,10 @@ export const decorateData = entity => {
renderError,
content,
base64,
-
+ previewMode,
file_lock,
+ html,
+ parentPath,
};
};
@@ -96,7 +106,7 @@ export const setPageTitle = title => {
export const createCommitPayload = (branch, newBranch, state, rootState) => ({
branch,
commit_message: state.commitMessage,
- actions: rootState.changedFiles.map(f => ({
+ actions: rootState.stagedFiles.map(f => ({
action: f.tempFile ? 'create' : 'update',
file_path: f.path,
content: f.content,
@@ -114,8 +124,8 @@ const sortTreesByTypeAndName = (a, b) => {
} else if (a.type === 'blob' && b.type === 'tree') {
return 1;
}
- if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
- if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
+ if (a.name < b.name) return -1;
+ if (a.name > b.name) return 1;
return 0;
};
@@ -127,3 +137,9 @@ export const sortTree = sortedTree =>
}),
)
.sort(sortTreesByTypeAndName);
+
+export const filePathMatches = (f, path) =>
+ f.path.replace(new RegExp(`${f.name}$`), '').indexOf(`${path}/`) === 0;
+
+export const getChangesCountForFiles = (files, path) =>
+ files.filter(f => filePathMatches(f, path)).length;
diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
index a4cd1ab099f..d249b05f47c 100644
--- a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
+++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
@@ -1,17 +1,12 @@
+import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import { decorateData, sortTree } from '../utils';
self.addEventListener('message', e => {
- const {
- data,
- projectId,
- branchId,
- tempFile = false,
- content = '',
- base64 = false,
- } = e.data;
+ const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data;
const treeList = [];
let file;
+ let parentPath;
const entries = data.reduce((acc, path) => {
const pathSplit = path.split('/');
const blobName = pathSplit.pop().trim();
@@ -19,12 +14,12 @@ self.addEventListener('message', e => {
if (pathSplit.length > 0) {
pathSplit.reduce((pathAcc, folderName) => {
const parentFolder = acc[pathAcc[pathAcc.length - 1]];
- const folderPath = `${
- parentFolder ? `${parentFolder.path}/` : ''
- }${folderName}`;
+ const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`;
const foundEntry = acc[folderPath];
if (!foundEntry) {
+ parentPath = parentFolder ? parentFolder.path : null;
+
const tree = decorateData({
projectId,
branchId,
@@ -33,12 +28,11 @@ self.addEventListener('message', e => {
path: folderPath,
url: `/${projectId}/tree/${branchId}/${folderPath}/`,
type: 'tree',
- parentTreeUrl: parentFolder
- ? parentFolder.url
- : `/${projectId}/tree/${branchId}/`,
+ parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
tempFile,
changed: tempFile,
opened: tempFile,
+ parentPath,
});
Object.assign(acc, {
@@ -62,6 +56,8 @@ self.addEventListener('message', e => {
if (blobName !== '') {
const fileFolder = acc[pathSplit.join('/')];
+ parentPath = fileFolder ? fileFolder.path : null;
+
file = decorateData({
projectId,
branchId,
@@ -70,13 +66,13 @@ self.addEventListener('message', e => {
path,
url: `/${projectId}/blob/${branchId}/${path}`,
type: 'blob',
- parentTreeUrl: fileFolder
- ? fileFolder.url
- : `/${projectId}/blob/${branchId}`,
+ parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
tempFile,
changed: tempFile,
content,
base64,
+ previewMode: viewerInformationForPath(blobName),
+ parentPath,
});
Object.assign(acc, {
@@ -97,5 +93,6 @@ self.addEventListener('message', e => {
entries,
treeList: sortTree(treeList),
file,
+ parentPath,
});
});
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index 7470d634b99..f3d722409b0 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -30,10 +30,10 @@ export default class IssuableContext {
const $selectbox = $block.find('.selectbox');
if ($selectbox.is(':visible')) {
$selectbox.hide();
- $block.find('.value').show();
+ $block.find('.value:not(.dont-hide)').show();
} else {
$selectbox.show();
- $block.find('.value').hide();
+ $block.find('.value:not(.dont-hide)').hide();
}
if ($selectbox.is(':visible')) {
diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue
index 357bc9aab17..21b545d6cab 100644
--- a/app/assets/javascripts/jobs/components/header.vue
+++ b/app/assets/javascripts/jobs/components/header.vue
@@ -1,82 +1,94 @@
<script>
- import ciHeader from '../../vue_shared/components/header_ci_component.vue';
- import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import ciHeader from '../../vue_shared/components/header_ci_component.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import callout from '../../vue_shared/components/callout.vue';
- export default {
- name: 'JobHeaderSection',
- components: {
- ciHeader,
- loadingIcon,
+export default {
+ name: 'JobHeaderSection',
+ components: {
+ ciHeader,
+ loadingIcon,
+ callout,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
},
- props: {
- job: {
- type: Object,
- required: true,
- },
- isLoading: {
- type: Boolean,
- required: true,
- },
+ isLoading: {
+ type: Boolean,
+ required: true,
},
- data() {
- return {
- actions: this.getActions(),
- };
+ },
+ data() {
+ return {
+ actions: this.getActions(),
+ };
+ },
+ computed: {
+ status() {
+ return this.job && this.job.status;
},
- computed: {
- status() {
- return this.job && this.job.status;
- },
- 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;
- },
+ shouldRenderContent() {
+ return !this.isLoading && Object.keys(this.job).length;
},
- watch: {
- job() {
- this.actions = this.getActions();
- },
+ shouldRenderReason() {
+ return !!(this.job.status && this.job.callout_message);
},
- methods: {
- getActions() {
- const actions = [];
+ /**
+ * 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() {
+ const actions = [];
- if (this.job.new_issue_path) {
- actions.push({
- label: 'New issue',
- path: this.job.new_issue_path,
- cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block',
- type: 'link',
- });
- }
- return actions;
- },
+ if (this.job.new_issue_path) {
+ actions.push({
+ label: 'New issue',
+ path: this.job.new_issue_path,
+ cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block',
+ type: 'link',
+ });
+ }
+ return actions;
},
- };
+ },
+};
</script>
<template>
- <div class="js-build-header build-header top-area">
- <ci-header
- v-if="shouldRenderContent"
- :status="status"
- item-name="Job"
- :item-id="job.id"
- :time="job.created_at"
- :user="job.user"
- :actions="actions"
- :has-sidebar-button="true"
- :should-render-triggered-label="jobStarted"
- />
- <loading-icon
- v-if="isLoading"
- size="2"
- class="prepend-top-default append-bottom-default"
+ <header>
+ <div class="js-build-header build-header top-area">
+ <ci-header
+ v-if="shouldRenderContent"
+ :status="status"
+ item-name="Job"
+ :item-id="job.id"
+ :time="job.created_at"
+ :user="job.user"
+ :actions="actions"
+ :has-sidebar-button="true"
+ :should-render-triggered-label="jobStarted"
+ />
+ <loading-icon
+ v-if="isLoading"
+ size="2"
+ class="prepend-top-default append-bottom-default"
+ />
+ </div>
+
+ <callout
+ v-if="shouldRenderReason"
+ :message="job.callout_message"
/>
- </div>
+ </header>
</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue
index 172de6b3679..db19dc9b238 100644
--- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue
@@ -1,80 +1,119 @@
<script>
- import detailRow from './sidebar_detail_row.vue';
- import loadingIcon from '../../vue_shared/components/loading_icon.vue';
- import timeagoMixin from '../../vue_shared/mixins/timeago';
- import { timeIntervalInWords } from '../../lib/utils/datetime_utility';
+import detailRow from './sidebar_detail_row.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import timeagoMixin from '../../vue_shared/mixins/timeago';
+import { timeIntervalInWords } from '../../lib/utils/datetime_utility';
- export default {
- name: 'SidebarDetailsBlock',
- components: {
- detailRow,
- loadingIcon,
+export default {
+ name: 'SidebarDetailsBlock',
+ components: {
+ detailRow,
+ loadingIcon,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ job: {
+ type: Object,
+ required: true,
},
- mixins: [
- timeagoMixin,
- ],
- props: {
- job: {
- type: Object,
- required: true,
- },
- isLoading: {
- type: Boolean,
- required: true,
- },
- runnerHelpUrl: {
- type: String,
- required: false,
- default: '',
- },
+ isLoading: {
+ type: Boolean,
+ required: true,
},
- computed: {
- shouldRenderContent() {
- return !this.isLoading && Object.keys(this.job).length > 0;
- },
- coverage() {
- return `${this.job.coverage}%`;
- },
- duration() {
- return timeIntervalInWords(this.job.duration);
- },
- queued() {
- return timeIntervalInWords(this.job.queued);
- },
- runnerId() {
- return `#${this.job.runner.id}`;
- },
- hasTimeout() {
- return this.job.metadata != null && this.job.metadata.timeout_human_readable !== '';
- },
- timeout() {
- if (this.job.metadata == null) {
- return '';
- }
+ canUserRetry: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ runnerHelpUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ shouldRenderContent() {
+ return !this.isLoading && Object.keys(this.job).length > 0;
+ },
+ coverage() {
+ return `${this.job.coverage}%`;
+ },
+ duration() {
+ return timeIntervalInWords(this.job.duration);
+ },
+ queued() {
+ return timeIntervalInWords(this.job.queued);
+ },
+ runnerId() {
+ return `${this.job.runner.description} (#${this.job.runner.id})`;
+ },
+ retryButtonClass() {
+ let className = 'js-retry-button pull-right btn btn-retry visible-md-block visible-lg-block';
+ className +=
+ this.job.status && this.job.recoverable
+ ? ' btn-primary'
+ : ' btn-inverted-secondary';
+ return className;
+ },
+ hasTimeout() {
+ return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null;
+ },
+ timeout() {
+ if (this.job.metadata == null) {
+ return '';
+ }
- let t = this.job.metadata.timeout_human_readable;
- if (this.job.metadata.timeout_source !== '') {
- t += ` (from ${this.job.metadata.timeout_source})`;
- }
+ let t = this.job.metadata.timeout_human_readable;
+ if (this.job.metadata.timeout_source !== '') {
+ t += ` (from ${this.job.metadata.timeout_source})`;
+ }
- return t;
- },
- renderBlock() {
- return this.job.merge_request ||
- this.job.duration ||
- this.job.finished_data ||
- this.job.erased_at ||
- this.job.queued ||
- this.job.runner ||
- this.job.coverage ||
- this.job.tags.length ||
- this.job.cancel_path;
- },
+ return t;
},
- };
+ renderBlock() {
+ return (
+ this.job.merge_request ||
+ this.job.duration ||
+ this.job.finished_data ||
+ this.job.erased_at ||
+ this.job.queued ||
+ this.job.runner ||
+ this.job.coverage ||
+ this.job.tags.length ||
+ this.job.cancel_path
+ );
+ },
+ },
+};
</script>
<template>
<div>
+ <div class="block">
+ <strong class="inline prepend-top-8">
+ {{ job.name }}
+ </strong>
+ <a
+ v-if="canUserRetry"
+ :class="retryButtonClass"
+ :href="job.retry_path"
+ data-method="post"
+ rel="nofollow"
+ >
+ {{ __('Retry') }}
+ </a>
+ <button
+ type="button"
+ :aria-label="__('Toggle Sidebar')"
+ class="btn btn-blank gutter-toggle pull-right
+ visible-xs-block visible-sm-block js-sidebar-build-toggle"
+ >
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-angle-double-right"
+ ></i>
+ </button>
+ </div>
<template v-if="shouldRenderContent">
<div
class="block retry-link"
@@ -85,16 +124,16 @@
class="js-new-issue btn btn-new btn-inverted"
:href="job.new_issue_path"
>
- New issue
+ {{ __('New issue') }}
</a>
<a
- v-if="job.retry_path"
+ v-if="canUserRetry"
class="js-retry-job btn btn-inverted-secondary"
:href="job.retry_path"
data-method="post"
rel="nofollow"
>
- Retry
+ {{ __('Retry') }}
</a>
</div>
<div :class="{block : renderBlock }">
@@ -103,7 +142,7 @@
v-if="job.merge_request"
>
<span class="build-light-text">
- Merge Request:
+ {{ __('Merge Request:') }}
</span>
<a :href="job.merge_request.path">
!{{ job.merge_request.iid }}
@@ -158,7 +197,7 @@
v-if="job.tags.length"
>
<span class="build-light-text">
- Tags:
+ {{ __('Tags:') }}
</span>
<span
v-for="(tag, i) in job.tags"
@@ -178,7 +217,7 @@
data-method="post"
rel="nofollow"
>
- Cancel
+ {{ __('Cancel') }}
</a>
</div>
</div>
diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js
index 656676ead91..f2939ad4dbe 100644
--- a/app/assets/javascripts/jobs/job_details_bundle.js
+++ b/app/assets/javascripts/jobs/job_details_bundle.js
@@ -35,9 +35,11 @@ export default () => {
});
// Sidebar information block
+ const detailsBlockElement = document.getElementById('js-details-block-vue');
+ const detailsBlockDataset = detailsBlockElement.dataset;
// eslint-disable-next-line
new Vue({
- el: '#js-details-block-vue',
+ el: detailsBlockElement,
components: {
detailsBlock,
},
@@ -50,6 +52,7 @@ export default () => {
return createElement('details-block', {
props: {
isLoading: this.mediator.state.isLoading,
+ canUserRetry: !!('canUserRetry' in detailsBlockDataset),
job: this.mediator.store.state.job,
runnerHelpUrl: dataset.runnerHelpUrl,
},
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 824d3f7ca09..9b62cfb8206 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -10,6 +10,7 @@ import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import DropdownUtils from './filtered_search/dropdown_utils';
import CreateLabelDropdown from './create_label';
import flash from './flash';
+import ModalStore from './boards/stores/modal_store';
export default class LabelsSelect {
constructor(els, options = {}) {
@@ -82,7 +83,7 @@ export default class LabelsSelect {
$dropdown.trigger('loading.gl.dropdown');
axios.put(issueUpdateURL, data)
.then(({ data }) => {
- var labelCount, template, labelTooltipTitle, labelTitles;
+ var labelCount, template, labelTooltipTitle, labelTitles, formattedLabels;
$loading.fadeOut();
$dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide();
@@ -114,8 +115,7 @@ export default class LabelsSelect {
labelTooltipTitle = labelTitles.join(', ');
}
else {
- labelTooltipTitle = '';
- $sidebarLabelTooltip.tooltip('destroy');
+ labelTooltipTitle = __('Labels');
}
$sidebarLabelTooltip
@@ -350,7 +350,7 @@ export default class LabelsSelect {
}
if ($dropdown.closest('.add-issues-modal').length) {
- boardsModel = gl.issueBoards.ModalStore.store.filter;
+ boardsModel = ModalStore.store.filter;
}
if (boardsModel) {
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index de65ea15a60..914de9de940 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -1,7 +1,12 @@
-/* eslint-disable import/prefer-default-export */
+import $ from 'jquery';
+import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie } from './common_utils';
+
+const isVueMRDiscussions = () => isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
export const addClassIfElementExists = (element, className) => {
if (element) {
element.classList.add(className);
}
};
+
+export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isVueMRDiscussions();
diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js
new file mode 100644
index 00000000000..5e0f9b612a2
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/keycodes.js
@@ -0,0 +1,4 @@
+export const UP_KEY_CODE = 38;
+export const DOWN_KEY_CODE = 40;
+export const ENTER_KEY_CODE = 13;
+export const ESC_KEY_CODE = 27;
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 94d03621bff..5e786ee6935 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -7,7 +7,8 @@
* @param {String} text
* @returns {String}
*/
-export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text);
+export const addDelimiter = text =>
+ (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text);
/**
* Returns '99+' for numbers bigger than 99.
@@ -22,7 +23,8 @@ export const highCountTrim = count => (count > 99 ? '99+' : count);
* @param {String} string
* @requires {String}
*/
-export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
+export const humanize = string =>
+ string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
/**
* Adds an 's' to the end of the string when count is bigger than 0
@@ -53,7 +55,7 @@ export const slugify = str => str.trim().toLowerCase();
* @param {Number} maxLength
* @returns {String}
*/
-export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`;
+export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`;
/**
* Capitalizes first character
@@ -72,7 +74,11 @@ export function capitalizeFirstCharacter(text) {
* @param {*} replace
* @returns {String}
*/
-export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, replace);
+export const stripHtml = (string, replace = '') => {
+ if (!string) return string;
+
+ return string.replace(/<[^>]*>/g, replace);
+};
/**
* Converts snake_case string to camelCase
@@ -80,3 +86,15 @@ export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, re
* @param {*} string
*/
export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase());
+
+/**
+ * Converts a sentence to lower case from the second word onwards
+ * e.g. Hello World => Hello world
+ *
+ * @param {*} string
+ */
+export const convertToSentenceCase = string => {
+ const splitWord = string.split(' ').map((word, index) => (index > 0 ? word.toLowerCase() : word));
+
+ return splitWord.join(' ');
+};
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index e77318fef46..3f84f4b9499 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -7,11 +7,7 @@ import flash from './flash';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import initChangesDropdown from './init_changes_dropdown';
import bp from './breakpoints';
-import {
- parseUrlPathname,
- handleLocationHash,
- isMetaClick,
-} from './lib/utils/common_utils';
+import { parseUrlPathname, handleLocationHash, isMetaClick } from './lib/utils/common_utils';
import { getLocationHash } from './lib/utils/url_utility';
import initDiscussionTab from './image_diff/init_discussion_tab';
import Diff from './diff';
@@ -69,11 +65,10 @@ import Notes from './notes';
let location = window.location;
export default class MergeRequestTabs {
-
constructor({ action, setUrl, stubLocation } = {}) {
const mergeRequestTabs = document.querySelector('.js-tabs-affix');
const navbar = document.querySelector('.navbar-gitlab');
- const peek = document.getElementById('peek');
+ const peek = document.getElementById('js-peek');
const paddingTop = 16;
this.diffsLoaded = false;
@@ -109,8 +104,7 @@ export default class MergeRequestTabs {
.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);
+ $('.merge-request-tabs a[data-toggle="tab"]').on('click', this.clickTab);
}
// Used in tests
@@ -119,8 +113,7 @@ export default class MergeRequestTabs {
.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);
+ $('.merge-request-tabs a[data-toggle="tab"]').off('click', this.clickTab);
}
destroyPipelinesView() {
@@ -183,10 +176,7 @@ export default class MergeRequestTabs {
scrollToElement(container) {
if (location.hash) {
- const offset = 0 - (
- $('.navbar-gitlab').outerHeight() +
- $('.js-tabs-affix').outerHeight()
- );
+ const offset = 0 - ($('.navbar-gitlab').outerHeight() + $('.js-tabs-affix').outerHeight());
const $el = $(`${container} ${location.hash}:not(.match)`);
if ($el.length) {
$.scrollTo($el[0], { offset });
@@ -240,9 +230,13 @@ export default class MergeRequestTabs {
// Turbolinks' history.
//
// See https://github.com/rails/turbolinks/issues/363
- window.history.replaceState({
- url: newState,
- }, document.title, newState);
+ window.history.replaceState(
+ {
+ url: newState,
+ },
+ document.title,
+ newState,
+ );
return newState;
}
@@ -258,7 +252,8 @@ export default class MergeRequestTabs {
this.toggleLoading(true);
- axios.get(`${source}.json`)
+ axios
+ .get(`${source}.json`)
.then(({ data }) => {
document.querySelector('div#commits').innerHTML = data.html;
localTimeAgo($('.js-timeago', 'div#commits'));
@@ -303,7 +298,8 @@ export default class MergeRequestTabs {
this.toggleLoading(true);
- axios.get(`${urlPathname}.json${location.search}`)
+ axios
+ .get(`${urlPathname}.json${location.search}`)
.then(({ data }) => {
const $container = $('#diffs');
$container.html(data.html);
@@ -332,8 +328,7 @@ export default class MergeRequestTabs {
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();
+ }).init();
});
// Scroll any linked note into view
@@ -388,8 +383,7 @@ export default class MergeRequestTabs {
resetViewContainer() {
if (this.fixedLayoutPref !== null) {
- $('.content-wrapper .container-fluid')
- .toggleClass('container-limited', this.fixedLayoutPref);
+ $('.content-wrapper .container-fluid').toggleClass('container-limited', this.fixedLayoutPref);
}
}
@@ -438,12 +432,11 @@ export default class MergeRequestTabs {
const $diffTabs = $('#diff-notes-app');
- $tabs.off('affix.bs.affix affix-top.bs.affix')
+ $tabs
+ .off('affix.bs.affix affix-top.bs.affix')
.affix({
offset: {
- top: () => (
- $diffTabs.offset().top - $tabs.height() - $fixedNav.height()
- ),
+ top: () => $diffTabs.offset().top - $tabs.height() - $fixedNav.height(),
},
})
.on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() }))
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index e6e3a66aa20..325fa570f37 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import flash from './flash';
+import { mouseenter, debouncedMouseleave, togglePopover } from './shared/popover';
export default class Milestone {
constructor() {
@@ -43,4 +44,25 @@ export default class Milestone {
.catch(() => flash('Error loading milestone tab'));
}
}
+
+ static initDeprecationMessage() {
+ const deprecationMesssageContainer = document.querySelector('.js-milestone-deprecation-message');
+
+ if (!deprecationMesssageContainer) return;
+
+ const deprecationMessage = deprecationMesssageContainer.querySelector('.js-milestone-deprecation-message-template').innerHTML;
+ const $popover = $('.js-popover-link', deprecationMesssageContainer);
+ const hideOnScroll = togglePopover.bind($popover, false);
+
+ $popover.popover({
+ content: deprecationMessage,
+ html: true,
+ placement: 'bottom',
+ })
+ .on('mouseenter', mouseenter)
+ .on('mouseleave', debouncedMouseleave())
+ .on('show.bs.popover', () => {
+ window.addEventListener('scroll', hideOnScroll, { once: true });
+ });
+ }
}
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index add07c156a4..f8b3d3061f0 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -4,13 +4,16 @@
import $ from 'jquery';
import _ from 'underscore';
+import { __ } from '~/locale';
import axios from './lib/utils/axios_utils';
import { timeFor } from './lib/utils/datetime_utility';
+import ModalStore from './boards/stores/modal_store';
export default class MilestoneSelect {
constructor(currentProject, els, options = {}) {
if (currentProject !== null) {
- this.currentProject = typeof currentProject === 'string' ? JSON.parse(currentProject) : currentProject;
+ this.currentProject =
+ typeof currentProject === 'string' ? JSON.parse(currentProject) : currentProject;
}
this.init(els, options);
@@ -24,7 +27,10 @@ export default class MilestoneSelect {
}
$els.each((i, dropdown) => {
- let collapsedSidebarLabelTemplate, milestoneLinkNoneTemplate, milestoneLinkTemplate, selectedMilestone, selectedMilestoneDefault;
+ let milestoneLinkNoneTemplate,
+ milestoneLinkTemplate,
+ selectedMilestone,
+ selectedMilestoneDefault;
const $dropdown = $(dropdown);
const projectId = $dropdown.data('projectId');
const milestonesUrl = $dropdown.data('milestones');
@@ -44,46 +50,47 @@ export default class MilestoneSelect {
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);
+ 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>');
+ 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 }) => {
+ data: (term, callback) =>
+ axios.get(milestonesUrl).then(({ data }) => {
const extraOptions = [];
if (showAny) {
extraOptions.push({
- id: 0,
- name: '',
- title: 'Any Milestone'
+ id: null,
+ name: null,
+ title: 'Any Milestone',
});
}
if (showNo) {
extraOptions.push({
id: -1,
name: 'No Milestone',
- title: 'No Milestone'
+ title: 'No Milestone',
});
}
if (showUpcoming) {
extraOptions.push({
id: -2,
name: '#upcoming',
- title: 'Upcoming'
+ title: 'Upcoming',
});
}
if (showStarted) {
extraOptions.push({
id: -3,
name: '#started',
- title: 'Started'
+ title: 'Started',
});
}
if (extraOptions.length) {
@@ -94,10 +101,10 @@ export default class MilestoneSelect {
if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove();
}
- $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active');
+ $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active');
}),
renderRow: milestone => `
- <li data-milestone-id="${milestone.name}">
+ <li data-milestone-id="${_.escape(milestone.name)}">
<a href='#' class='dropdown-menu-milestone-link'>
${_.escape(milestone.title)}
</a>
@@ -105,7 +112,7 @@ export default class MilestoneSelect {
`,
filterable: true,
search: {
- fields: ['title']
+ fields: ['title'],
},
selectable: true,
toggleLabel: (selected, el, e) => {
@@ -118,29 +125,28 @@ export default class MilestoneSelect {
defaultLabel: defaultLabel,
fieldName: $dropdown.data('fieldName'),
text: milestone => _.escape(milestone.title),
- id: (milestone) => {
+ 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) => {
+ 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');
+ $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active');
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: (clickEvent) => {
+ clicked: clickEvent => {
const { $el, e } = clickEvent;
let selected = clickEvent.selectedObj;
@@ -155,16 +161,20 @@ export default class MilestoneSelect {
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);
+ 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')) {
+
+ 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;
+ boardsStore = ModalStore.store.filter;
}
if (boardsStore) {
@@ -176,10 +186,13 @@ export default class MilestoneSelect {
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
- }));
+ gl.issueBoards.boardStoreIssueSet(
+ 'milestone',
+ new ListMilestone({
+ id: selected.id,
+ title: selected.name,
+ }),
+ );
} else {
gl.issueBoards.boardStoreIssueDelete('milestone');
}
@@ -187,7 +200,8 @@ export default class MilestoneSelect {
$dropdown.trigger('loading.gl.dropdown');
$loading.removeClass('hidden').fadeIn();
- gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
+ gl.issueBoards.BoardsStore.detail.issue
+ .update($dropdown.attr('data-issue-update'))
.then(() => {
$dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut();
@@ -202,7 +216,8 @@ export default class MilestoneSelect {
data[abilityName].milestone_id = selected != null ? selected : null;
$loading.removeClass('hidden').fadeIn();
$dropdown.trigger('loading.gl.dropdown');
- return axios.put(issueUpdateURL, data)
+ return axios
+ .put(issueUpdateURL, data)
.then(({ data }) => {
$dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut();
@@ -213,17 +228,26 @@ export default class MilestoneSelect {
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));
+ return $sidebarCollapsedValue
+ .attr(
+ 'data-original-title',
+ `${data.milestone.name}<br />${data.milestone.remaining}`,
+ )
+ .find('span')
+ .text(data.milestone.title);
} else {
$value.html(milestoneLinkNoneTemplate);
- return $sidebarCollapsedValue.find('span').text('No');
+ return $sidebarCollapsedValue
+ .attr('data-original-title', __('Milestone'))
+ .find('span')
+ .text(__('None'));
}
})
.catch(() => {
$loading.fadeOut();
});
}
- }
+ },
});
});
}
diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue
index 04d546fafa0..f93b1da4f58 100644
--- a/app/assets/javascripts/monitoring/components/graph.vue
+++ b/app/assets/javascripts/monitoring/components/graph.vue
@@ -1,8 +1,10 @@
<script>
import { scaleLinear, scaleTime } from 'd3-scale';
import { axisLeft, axisBottom } from 'd3-axis';
+import _ from 'underscore';
import { max, extent } from 'd3-array';
import { select } from 'd3-selection';
+import GraphAxis from './graph/axis.vue';
import GraphLegend from './graph/legend.vue';
import GraphFlag from './graph/flag.vue';
import GraphDeployment from './graph/deployment.vue';
@@ -18,10 +20,11 @@ const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select }
export default {
components: {
- GraphLegend,
+ GraphAxis,
GraphFlag,
GraphDeployment,
GraphPath,
+ GraphLegend,
},
mixins: [MonitoringMixin],
props: {
@@ -138,7 +141,7 @@ export default {
this.legendTitle = query.label || 'Average';
this.graphWidth = this.$refs.baseSvg.clientWidth - this.margin.left - this.margin.right;
this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
- this.baseGraphHeight = this.graphHeight;
+ this.baseGraphHeight = this.graphHeight - 50;
this.baseGraphWidth = this.graphWidth;
// pixel offsets inside the svg and outside are not 1:1
@@ -177,10 +180,8 @@ export default {
this.graphHeightOffset,
);
- if (!this.showLegend) {
- this.baseGraphHeight -= 50;
- } else if (this.timeSeries.length > 3) {
- this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20;
+ if (_.findWhere(this.timeSeries, { renderCanary: true })) {
+ this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true }));
}
const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]);
@@ -251,17 +252,13 @@ export default {
class="y-axis"
transform="translate(70, 20)"
/>
- <graph-legend
+ <graph-axis
:graph-width="graphWidth"
:graph-height="graphHeight"
:margin="margin"
:measurements="measurements"
- :legend-title="legendTitle"
:y-axis-label="yAxisLabel"
- :time-series="timeSeries"
:unit-of-display="unitOfDisplay"
- :current-data-index="currentDataIndex"
- :show-legend-group="showLegend"
/>
<svg
class="graph-data"
@@ -306,5 +303,10 @@ export default {
:deployment-flag-data="deploymentFlagData"
/>
</div>
+ <graph-legend
+ v-if="showLegend"
+ :legend-title="legendTitle"
+ :time-series="timeSeries"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue
new file mode 100644
index 00000000000..fc4b3689dfd
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph/axis.vue
@@ -0,0 +1,142 @@
+<script>
+import { convertToSentenceCase } from '~/lib/utils/text_utility';
+import { s__ } from '~/locale';
+
+export default {
+ props: {
+ graphWidth: {
+ type: Number,
+ required: true,
+ },
+ graphHeight: {
+ type: Number,
+ required: true,
+ },
+ margin: {
+ type: Object,
+ required: true,
+ },
+ measurements: {
+ type: Object,
+ required: true,
+ },
+ yAxisLabel: {
+ type: String,
+ required: true,
+ },
+ unitOfDisplay: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ yLabelWidth: 0,
+ yLabelHeight: 0,
+ };
+ },
+ computed: {
+ textTransform() {
+ const yCoordinate =
+ (this.graphHeight -
+ this.margin.top +
+ this.measurements.axisLabelLineOffset) /
+ 2 || 0;
+
+ return `translate(15, ${yCoordinate}) rotate(-90)`;
+ },
+
+ rectTransform() {
+ const yCoordinate =
+ (this.graphHeight -
+ this.margin.top +
+ this.measurements.axisLabelLineOffset) /
+ 2 +
+ this.yLabelWidth / 2 || 0;
+
+ return `translate(0, ${yCoordinate}) rotate(-90)`;
+ },
+
+ xPosition() {
+ return (
+ (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 -
+ this.margin.right || 0
+ );
+ },
+
+ yPosition() {
+ return (
+ this.graphHeight -
+ this.margin.top +
+ this.measurements.axisLabelLineOffset || 0
+ );
+ },
+
+ yAxisLabelSentenceCase() {
+ return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`;
+ },
+
+ timeString() {
+ return s__('PrometheusDashboard|Time');
+ },
+ },
+ mounted() {
+ this.$nextTick(() => {
+ const bbox = this.$refs.ylabel.getBBox();
+ this.yLabelWidth = bbox.width + 10; // Added some padding
+ this.yLabelHeight = bbox.height + 5;
+ });
+ },
+};
+</script>
+<template>
+ <g class="axis-label-container">
+ <line
+ class="label-x-axis-line"
+ stroke="#000000"
+ stroke-width="1"
+ x1="10"
+ :y1="yPosition"
+ :x2="graphWidth + 20"
+ :y2="yPosition"
+ />
+ <line
+ class="label-y-axis-line"
+ stroke="#000000"
+ stroke-width="1"
+ x1="10"
+ y1="0"
+ :x2="10"
+ :y2="yPosition"
+ />
+ <rect
+ class="rect-axis-text"
+ :transform="rectTransform"
+ :width="yLabelWidth"
+ :height="yLabelHeight"
+ />
+ <text
+ class="label-axis-text y-label-text"
+ text-anchor="middle"
+ :transform="textTransform"
+ ref="ylabel"
+ >
+ {{ yAxisLabelSentenceCase }}
+ </text>
+ <rect
+ class="rect-axis-text"
+ :x="xPosition + 60"
+ :y="graphHeight - 80"
+ width="35"
+ height="50"
+ />
+ <text
+ class="label-axis-text x-label-text"
+ :x="xPosition + 60"
+ :y="yPosition"
+ dy=".35em"
+ >
+ {{ timeString }}
+ </text>
+ </g>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue
index 906c7c51f52..b8202e25685 100644
--- a/app/assets/javascripts/monitoring/components/graph/flag.vue
+++ b/app/assets/javascripts/monitoring/components/graph/flag.vue
@@ -1,11 +1,13 @@
<script>
import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
import { formatRelevantDigits } from '../../../lib/utils/number_utils';
-import icon from '../../../vue_shared/components/icon.vue';
+import Icon from '../../../vue_shared/components/icon.vue';
+import TrackLine from './track_line.vue';
export default {
components: {
- icon,
+ Icon,
+ TrackLine,
},
props: {
currentXCoordinate: {
@@ -107,11 +109,6 @@ export default {
}
return `series ${index + 1}`;
},
- strokeDashArray(type) {
- if (type === 'dashed') return '6, 3';
- if (type === 'dotted') return '3, 3';
- return null;
- },
},
};
</script>
@@ -160,28 +157,13 @@ export default {
</div>
</div>
<div class="popover-content">
- <table>
+ <table class="prometheus-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>
+ <track-line :track="series"/>
+ <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td>
<td>
<strong>{{ seriesMetricValue(series) }}</strong>
</td>
diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue
index a7a058a9203..da9280cf1f1 100644
--- a/app/assets/javascripts/monitoring/components/graph/legend.vue
+++ b/app/assets/javascripts/monitoring/components/graph/legend.vue
@@ -1,204 +1,72 @@
<script>
-import { formatRelevantDigits } from '../../../lib/utils/number_utils';
+import TrackLine from './track_line.vue';
+import TrackInfo from './track_info.vue';
export default {
+ components: {
+ TrackLine,
+ TrackInfo,
+ },
props: {
- graphWidth: {
- type: Number,
- required: true,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- margin: {
- type: Object,
- required: true,
- },
- measurements: {
- type: Object,
- required: true,
- },
legendTitle: {
type: String,
required: true,
},
- yAxisLabel: {
- type: String,
- required: true,
- },
timeSeries: {
type: Array,
required: true,
},
- unitOfDisplay: {
- type: String,
- required: true,
- },
- currentDataIndex: {
- type: Number,
- required: true,
- },
- showLegendGroup: {
- type: Boolean,
- required: false,
- default: true,
- },
- },
- data() {
- return {
- yLabelWidth: 0,
- yLabelHeight: 0,
- seriesXPosition: 0,
- metricUsageXPosition: 0,
- };
- },
- computed: {
- textTransform() {
- const yCoordinate =
- (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0;
-
- return `translate(15, ${yCoordinate}) rotate(-90)`;
- },
- rectTransform() {
- const yCoordinate =
- (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 +
- this.yLabelWidth / 2 || 0;
-
- return `translate(0, ${yCoordinate}) rotate(-90)`;
- },
- xPosition() {
- return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0;
- },
- yPosition() {
- return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0;
- },
- },
- mounted() {
- this.$nextTick(() => {
- const bbox = this.$refs.ylabel.getBBox();
- this.metricUsageXPosition = 0;
- this.seriesXPosition = 0;
- if (this.$refs.legendTitleSvg != null) {
- this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width;
- }
- if (this.$refs.seriesTitleSvg != null) {
- this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width;
- }
- this.yLabelWidth = bbox.width + 10; // Added some padding
- this.yLabelHeight = bbox.height + 5;
- });
},
methods: {
- translateLegendGroup(index) {
- return `translate(0, ${12 * index})`;
- },
- formatMetricUsage(series) {
- const value =
- series.values[this.currentDataIndex] && series.values[this.currentDataIndex].value;
- if (isNaN(value)) {
- return '-';
- }
- return `${formatRelevantDigits(value)} ${this.unitOfDisplay}`;
- },
- createSeriesString(index, series) {
- if (series.metricTag) {
- return `${series.metricTag} ${this.formatMetricUsage(series)}`;
- }
- return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`;
- },
- strokeDashArray(type) {
- if (type === 'dashed') return '6, 3';
- if (type === 'dotted') return '3, 3';
- return null;
+ isStable(track) {
+ return {
+ 'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary,
+ };
},
},
};
</script>
<template>
- <g class="axis-label-container">
- <line
- class="label-x-axis-line"
- stroke="#000000"
- stroke-width="1"
- x1="10"
- :y1="yPosition"
- :x2="graphWidth + 20"
- :y2="yPosition"
- />
- <line
- class="label-y-axis-line"
- stroke="#000000"
- stroke-width="1"
- x1="10"
- y1="0"
- :x2="10"
- :y2="yPosition"
- />
- <rect
- class="rect-axis-text"
- :transform="rectTransform"
- :width="yLabelWidth"
- :height="yLabelHeight"
- />
- <text
- class="label-axis-text y-label-text"
- text-anchor="middle"
- :transform="textTransform"
- ref="ylabel"
- >
- {{ yAxisLabel }}
- </text>
- <rect
- class="rect-axis-text"
- :x="xPosition + 60"
- :y="graphHeight - 80"
- width="35"
- height="50"
- />
- <text
- class="label-axis-text x-label-text"
- :x="xPosition + 60"
- :y="yPosition"
- dy=".35em"
- >
- Time
- </text>
- <template v-if="showLegendGroup">
- <g
- class="legend-group"
+ <div class="prometheus-graph-legends prepend-left-10">
+ <table class="prometheus-table">
+ <tr
v-for="(series, index) in timeSeries"
:key="index"
- :transform="translateLegendGroup(index)"
+ v-if="series.shouldRenderLegend"
+ :class="isStable(series)"
>
- <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
+ <td>
+ <strong v-if="series.renderCanary">{{ series.trackName }}</strong>
+ </td>
+ <track-line :track="series" />
+ <td
class="legend-metric-title"
- ref="legendTitleSvg"
- x="38"
- :y="graphHeight - 30"
- >
- {{ legendTitle }} {{ formatMetricUsage(series) }}
- </text>
- </g>
- </template>
- </g>
+ v-if="timeSeries.length > 1">
+ <track-info
+ :track="series"
+ v-if="series.metricTag" />
+ <track-info
+ v-else
+ :track="series">
+ <strong>{{ legendTitle }}</strong> series {{ index + 1 }}
+ </track-info>
+ </td>
+ <td v-else>
+ <track-info :track="series">
+ <strong>{{ legendTitle }}</strong>
+ </track-info>
+ </td>
+ <template v-for="(track, trackIndex) in series.tracksLegend">
+ <track-line
+ :track="track"
+ :key="`track-line-${trackIndex}`"/>
+ <td :key="`track-info-${trackIndex}`">
+ <track-info
+ class="legend-metric-title"
+ :track="track" />
+ </td>
+ </template>
+ </tr>
+ </table>
+ </div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/track_info.vue b/app/assets/javascripts/monitoring/components/graph/track_info.vue
new file mode 100644
index 00000000000..ec1c2222af9
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph/track_info.vue
@@ -0,0 +1,29 @@
+<script>
+import { formatRelevantDigits } from '~/lib/utils/number_utils';
+
+export default {
+ name: 'TrackInfo',
+ props: {
+ track: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ summaryMetrics() {
+ return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits(
+ this.track.max,
+ )}`;
+ },
+ },
+};
+</script>
+<template>
+ <span>
+ <slot>
+ <strong> {{ track.metricTag }} </strong>
+ </slot>
+ {{ summaryMetrics }}
+ </span>
+</template>
+
diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue
new file mode 100644
index 00000000000..79b322e2e42
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph/track_line.vue
@@ -0,0 +1,36 @@
+<script>
+export default {
+ name: 'TrackLine',
+ props: {
+ track: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ stylizedLine() {
+ if (this.track.lineStyle === 'dashed') return '6, 3';
+ if (this.track.lineStyle === 'dotted') return '3, 3';
+ return null;
+ },
+ },
+};
+</script>
+<template>
+ <td>
+ <svg
+ width="15"
+ height="6">
+ <line
+ :stroke-dasharray="stylizedLine"
+ :stroke="track.lineColor"
+ stroke-width="4"
+ :x1="0"
+ :x2="15"
+ :y1="2"
+ :y2="2"
+ />
+ </svg>
+ </td>
+</template>
+
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
index 854636e9a89..535c415cd6d 100644
--- a/app/assets/javascripts/monitoring/stores/monitoring_store.js
+++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js
@@ -1,7 +1,7 @@
import _ from 'underscore';
function sortMetrics(metrics) {
- return _.chain(metrics).sortBy('weight').sortBy('title').value();
+ return _.chain(metrics).sortBy('title').sortBy('weight').value();
}
function normalizeMetrics(metrics) {
diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
index b5b8e3c255d..8a93c7e6bae 100644
--- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js
+++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
@@ -1,10 +1,21 @@
import _ from 'underscore';
import { scaleLinear, scaleTime } from 'd3-scale';
import { line, area, curveLinear } from 'd3-shape';
-import { extent, max } from 'd3-array';
+import { extent, max, sum } from 'd3-array';
import { timeMinute } from 'd3-time';
-
-const d3 = { scaleLinear, scaleTime, line, area, curveLinear, extent, max, timeMinute };
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+
+const d3 = {
+ scaleLinear,
+ scaleTime,
+ line,
+ area,
+ curveLinear,
+ extent,
+ max,
+ timeMinute,
+ sum,
+};
const defaultColorPalette = {
blue: ['#1f78d1', '#8fbce8'],
@@ -20,6 +31,8 @@ const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) {
let usedColors = [];
+ let renderCanary = false;
+ const timeSeriesParsed = [];
function pickColor(name) {
let pick;
@@ -38,16 +51,23 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
return defaultColorPalette[pick];
}
- return query.result.map((timeSeries, timeSeriesNumber) => {
+ query.result.forEach((timeSeries, timeSeriesNumber) => {
let metricTag = '';
let lineColor = '';
let areaColor = '';
+ let shouldRenderLegend = true;
+ const timeSeriesValues = timeSeries.values.map(d => d.value);
+ const maximumValue = d3.max(timeSeriesValues);
+ const accum = d3.sum(timeSeriesValues);
+ const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable');
+
+ if (trackName === 'Canary') {
+ renderCanary = true;
+ }
- const timeSeriesScaleX = d3.scaleTime()
- .range([0, graphWidth - 70]);
+ const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]);
- const timeSeriesScaleY = d3.scaleLinear()
- .range([graphHeight - graphHeightOffset, 0]);
+ const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]);
timeSeriesScaleX.domain(xDom);
timeSeriesScaleX.ticks(d3.timeMinute, 60);
@@ -55,13 +75,15 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
const defined = d => !isNaN(d.value) && d.value != null;
- const lineFunction = d3.line()
+ const lineFunction = d3
+ .line()
.defined(defined)
.curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
.x(d => timeSeriesScaleX(d.time))
.y(d => timeSeriesScaleY(d.value));
- const areaFunction = d3.area()
+ const areaFunction = d3
+ .area()
.defined(defined)
.curve(d3.curveLinear)
.x(d => timeSeriesScaleX(d.time))
@@ -69,38 +91,62 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
.y1(d => timeSeriesScaleY(d.value));
const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]];
- const seriesCustomizationData = query.series != null &&
- _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
+ const seriesCustomizationData =
+ query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
if (seriesCustomizationData) {
metricTag = seriesCustomizationData.value || timeSeriesMetricLabel;
[lineColor, areaColor] = pickColor(seriesCustomizationData.color);
+ shouldRenderLegend = false;
} else {
metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`;
[lineColor, areaColor] = pickColor();
+ if (timeSeriesParsed.length > 1) {
+ shouldRenderLegend = false;
+ }
}
- if (query.track) {
- metricTag += ` - ${query.track}`;
+ if (!shouldRenderLegend) {
+ if (!timeSeriesParsed[0].tracksLegend) {
+ timeSeriesParsed[0].tracksLegend = [];
+ }
+ timeSeriesParsed[0].tracksLegend.push({
+ max: maximumValue,
+ average: accum / timeSeries.values.length,
+ lineStyle,
+ lineColor,
+ metricTag,
+ });
}
- return {
+ timeSeriesParsed.push({
linePath: lineFunction(timeSeries.values),
areaPath: areaFunction(timeSeries.values),
timeSeriesScaleX,
values: timeSeries.values,
+ max: maximumValue,
+ average: accum / timeSeries.values.length,
lineStyle,
lineColor,
areaColor,
metricTag,
- };
+ trackName,
+ shouldRenderLegend,
+ renderCanary,
+ });
});
+
+ return timeSeriesParsed;
}
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 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))];
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index 096c4ef5f31..e3c5bf06b3d 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -13,8 +13,11 @@ export default function initMrNotes() {
data() {
const notesDataset = document.getElementById('js-vue-mr-discussions')
.dataset;
+ const noteableData = JSON.parse(notesDataset.noteableData);
+ noteableData.noteableType = notesDataset.noteableType;
+
return {
- noteableData: JSON.parse(notesDataset.noteableData),
+ noteableData,
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
};
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index b0573510ff9..96f2b3eac98 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -19,7 +19,6 @@ import AjaxCache from '~/lib/utils/ajax_cache';
import Vue from 'vue';
import syntaxHighlight from '~/syntax_highlight';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
-import { __ } from '~/locale';
import axios from './lib/utils/axios_utils';
import { getLocationHash } from './lib/utils/url_utility';
import Flash from './flash';
@@ -198,6 +197,8 @@ export default class Notes {
);
this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff);
+ this.$wrapperEl.on('click', '.js-toggle-lazy-diff-retry-button', this.onClickRetryLazyLoad.bind(this));
+
// fetch notes when tab becomes visible
this.$wrapperEl.on('visibilitychange', this.visibilityChange);
// when issue status changes, we need to refresh data
@@ -244,6 +245,7 @@ export default class Notes {
this.$wrapperEl.off('click', '.js-comment-resolve-button');
this.$wrapperEl.off('click', '.system-note-commit-list-toggler');
this.$wrapperEl.off('click', '.js-toggle-lazy-diff');
+ this.$wrapperEl.off('click', '.js-toggle-lazy-diff-retry-button');
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');
@@ -1425,22 +1427,21 @@ export default class Notes {
const { discussion_html } = data;
const lines = $(discussion_html).find('.line_holder');
lines.addClass('fade-in');
- $container.find('tbody').prepend(lines);
+ $container.find('.diff-content > table > tbody').prepend(lines);
const fileHolder = $container.find('.file-holder');
$container.find('.line-holder-placeholder').remove();
syntaxHighlight(fileHolder);
}
- static renderDiffError($container) {
- $container.find('.line_content').html(
- $(`
- <div class="nothing-here-block">
- ${__(
- 'Unable to load the diff.',
- )} <a class="js-toggle-lazy-diff" href="javascript:void(0)">Try again</a>?
- </div>
- `),
- );
+ onClickRetryLazyLoad(e) {
+ const $retryButton = $(e.currentTarget);
+
+ $retryButton.prop('disabled', true);
+
+ return this.loadLazyDiff(e)
+ .then(() => {
+ $retryButton.prop('disabled', false);
+ });
}
loadLazyDiff(e) {
@@ -1449,20 +1450,35 @@ export default class Notes {
$container.find('.js-toggle-lazy-diff').removeClass('js-toggle-lazy-diff');
- const tableEl = $container.find('tbody');
- if (tableEl.length === 0) return;
+ const $tableEl = $container.find('tbody');
+ if ($tableEl.length === 0) return;
const fileHolder = $container.find('.file-holder');
const url = fileHolder.data('linesPath');
- axios
+ const $errorContainer = $container.find('.js-error-lazy-load-diff');
+ const $successContainer = $container.find('.js-success-lazy-load');
+
+ /**
+ * We only fetch resolved discussions.
+ * Unresolved discussions don't have an endpoint being provided.
+ */
+ if (url) {
+ return axios
.get(url)
.then(({ data }) => {
+ // Reset state in case last request returned error
+ $successContainer.removeClass('hidden');
+ $errorContainer.addClass('hidden');
+
Notes.renderDiffContent($container, data);
})
.catch(() => {
- Notes.renderDiffError($container);
+ $successContainer.addClass('hidden');
+ $errorContainer.removeClass('hidden');
});
+ }
+ return Promise.resolve();
}
toggleCommitList(e) {
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 648fa6ff804..396a675b4ac 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -317,10 +317,10 @@ Please check your network connection and try again.`;
<note-signed-out-widget v-if="!isLoggedIn" />
<discussion-locked-widget
issuable-type="issue"
- v-else-if="!canCreateNote"
+ v-else-if="isLocked(getNoteableData) && !canCreateNote"
/>
<ul
- v-else
+ v-else-if="canCreateNote"
class="notes notes-form timeline">
<li class="timeline-entry">
<div class="timeline-entry-inner">
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index d492d1cd001..cbe4774a360 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -86,7 +86,7 @@ export default {
v-html="resolveSvg"
></span>
</span>
- <span class=".line-resolve-text">
+ <span class="line-resolve-text">
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
</span>
</div>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index a7e2d857013..626b0799581 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -40,6 +40,10 @@ export default {
type: Boolean,
required: true,
},
+ canAwardEmoji: {
+ type: Boolean,
+ required: true,
+ },
canDelete: {
type: Boolean,
required: true,
@@ -74,9 +78,6 @@ export default {
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
- canAddAwardEmoji() {
- return this.currentUserId;
- },
isAuthoredByCurrentUser() {
return this.authorId === this.currentUserId;
},
@@ -149,7 +150,7 @@ export default {
</button>
</div>
<div
- v-if="canAddAwardEmoji"
+ v-if="canAwardEmoji"
class="note-actions-item">
<a
v-tooltip
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 6cb8229e268..e8fd155a1ee 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -28,6 +28,10 @@ export default {
type: Number,
required: true,
},
+ canAwardEmoji: {
+ type: Boolean,
+ required: true,
+ },
},
computed: {
...mapGetters(['getUserData']),
@@ -67,9 +71,6 @@ export default {
isAuthoredByMe() {
return this.noteAuthorId === this.getUserData.id;
},
- isLoggedIn() {
- return this.getUserData.id;
- },
},
created() {
this.emojiSmiling = emojiSmiling;
@@ -156,7 +157,7 @@ export default {
return title;
},
handleAward(awardName) {
- if (!this.isLoggedIn) {
+ if (!this.canAwardEmoji) {
return;
}
@@ -208,7 +209,7 @@ export default {
</span>
</button>
<div
- v-if="isLoggedIn"
+ v-if="canAwardEmoji"
class="award-menu-holder">
<button
v-tooltip
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 069f94c5845..0cb626c14f4 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -112,6 +112,7 @@ export default {
:note-author-id="note.author.id"
:awards="note.award_emoji"
:toggle-award-path="note.toggle_award_path"
+ :can-award-emoji="note.current_user.can_award_emoji"
/>
<note-attachment
v-if="note.attachment"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 3554027d2b4..566f5c68e66 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -177,6 +177,7 @@ export default {
:note-id="note.id"
:access-level="note.human_access"
:can-edit="note.current_user.can_edit"
+ :can-award-emoji="note.current_user.can_award_emoji"
:can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse"
:report-abuse-path="note.report_abuse_path"
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 5bd81c7cad6..ebfc827ac57 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -49,16 +49,7 @@ export default {
computed: {
...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']),
noteableType() {
- // FIXME -- @fatihacet Get this from JSON data.
- const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE, EPIC_NOTEABLE_TYPE } = constants;
-
- if (this.noteableData.noteableType === EPIC_NOTEABLE_TYPE) {
- return EPIC_NOTEABLE_TYPE;
- }
-
- return this.noteableData.merge_params
- ? MERGE_REQUEST_NOTEABLE_TYPE
- : ISSUE_NOTEABLE_TYPE;
+ return this.noteableData.noteableType;
},
allNotes() {
if (this.isLoading) {
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index 68f8cb1cf1e..c4de4826eda 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -14,3 +14,9 @@ export const EPIC_NOTEABLE_TYPE = 'epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request';
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post';
+
+export const NOTEABLE_TYPE_MAPPING = {
+ Issue: ISSUE_NOTEABLE_TYPE,
+ MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE,
+ Epic: EPIC_NOTEABLE_TYPE,
+};
diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js
index 5bf8216a1f3..b68543d71c8 100644
--- a/app/assets/javascripts/notes/mixins/noteable.js
+++ b/app/assets/javascripts/notes/mixins/noteable.js
@@ -9,16 +9,7 @@ export default {
},
computed: {
noteableType() {
- switch (this.note.noteable_type) {
- case 'MergeRequest':
- return constants.MERGE_REQUEST_NOTEABLE_TYPE;
- case 'Issue':
- return constants.ISSUE_NOTEABLE_TYPE;
- case 'Epic':
- return constants.EPIC_NOTEABLE_TYPE;
- default:
- return '';
- }
+ return constants.NOTEABLE_TYPE_MAPPING[this.note.noteable_type];
},
},
};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 244a6980b5a..98ce070288e 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -315,3 +315,6 @@ export const scrollToNoteIfNeeded = (context, el) => {
scrollToElement(el);
}
};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index f89591a54d6..787be6f4c99 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -68,3 +68,6 @@ export const resolvedDiscussionCount = (state, getters) => {
return Object.keys(resolvedMap).length;
};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
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
index 0e3ac636661..9ce176744ba 100644
--- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -52,16 +52,15 @@
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.
+ 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.`);
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".
+ 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.`);
-
return sprintf(this.deleteContributions ? deleteContributionsText : keepContributionsText,
{
username: `<strong>${_.escape(this.username)}</strong>`,
diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
index 397149aaa9e..8b529585898 100644
--- a/app/assets/javascripts/pages/dashboard/milestones/show/index.js
+++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
@@ -6,4 +6,6 @@ document.addEventListener('DOMContentLoaded', () => {
new Milestone(); // eslint-disable-line no-new
new Sidebar(); // eslint-disable-line no-new
new MountMilestoneSidebar(); // eslint-disable-line no-new
+
+ Milestone.initDeprecationMessage();
});
diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js
index 88f40b5278e..74cc4ba42c1 100644
--- a/app/assets/javascripts/pages/groups/milestones/show/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/show/index.js
@@ -1,3 +1,8 @@
import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
+import Milestone from '~/milestone';
-document.addEventListener('DOMContentLoaded', initMilestonesShow);
+document.addEventListener('DOMContentLoaded', () => {
+ initMilestonesShow();
+
+ Milestone.initDeprecationMessage();
+});
diff --git a/app/assets/javascripts/pages/groups/settings/badges/index.js b/app/assets/javascripts/pages/groups/settings/badges/index.js
new file mode 100644
index 00000000000..74e96ee4a8f
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/badges/index.js
@@ -0,0 +1,10 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import { GROUP_BADGE } from '~/badges/constants';
+import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => {
+ mountBadgeSettings(GROUP_BADGE);
+});
diff --git a/app/assets/javascripts/pages/projects/compare/index.js b/app/assets/javascripts/pages/projects/compare/index.js
index d1c78bd61db..768da8fb236 100644
--- a/app/assets/javascripts/pages/projects/compare/index.js
+++ b/app/assets/javascripts/pages/projects/compare/index.js
@@ -1,3 +1,3 @@
import initCompareAutocomplete from '~/compare_autocomplete';
-document.addEventListener('DOMContentLoaded', initCompareAutocomplete);
+document.addEventListener('DOMContentLoaded', () => initCompareAutocomplete());
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
index be37df36be8..628913483c6 100644
--- a/app/assets/javascripts/pages/projects/edit/index.js
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -1,12 +1,12 @@
import initSettingsPanels from '~/settings_panels';
import setupProjectEdit from '~/project_edit';
import initConfirmDangerModal from '~/confirm_danger_modal';
-import ProjectNew from '../shared/project_new';
+import initProjectLoadingSpinner from '../shared/save_project_loader';
import projectAvatar from '../shared/project_avatar';
import initProjectPermissionsSettings from '../shared/permissions';
document.addEventListener('DOMContentLoaded', () => {
- new ProjectNew(); // eslint-disable-line no-new
+ initProjectLoadingSpinner();
setupProjectEdit();
// Initialize expandable settings panels
initSettingsPanels();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js
new file mode 100644
index 00000000000..46f3f55a400
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js
@@ -0,0 +1,60 @@
+import $ from 'jquery';
+import { localTimeAgo } from '~/lib/utils/datetime_utility';
+import axios from '~/lib/utils/axios_utils';
+import initCompareAutocomplete from '~/compare_autocomplete';
+import initTargetProjectDropdown from './target_project_dropdown';
+
+const updateCommitList = (url, $loadingIndicator, $commitList, params) => {
+ $loadingIndicator.show();
+ $commitList.empty();
+
+ return axios
+ .get(url, {
+ params,
+ })
+ .then(({ data }) => {
+ $loadingIndicator.hide();
+ $commitList.html(data);
+ localTimeAgo($('.js-timeago', $commitList));
+ });
+};
+
+export default mrNewCompareNode => {
+ const { sourceBranchUrl, targetBranchUrl } = mrNewCompareNode.dataset;
+ initTargetProjectDropdown();
+
+ const updateSourceBranchCommitList = () =>
+ updateCommitList(
+ sourceBranchUrl,
+ $(mrNewCompareNode).find('.js-source-loading'),
+ $(mrNewCompareNode).find('.mr_source_commit'),
+ {
+ ref: $(mrNewCompareNode)
+ .find("input[name='merge_request[source_branch]']")
+ .val(),
+ },
+ );
+ const updateTargetBranchCommitList = () =>
+ updateCommitList(
+ targetBranchUrl,
+ $(mrNewCompareNode).find('.js-target-loading'),
+ $(mrNewCompareNode).find('.mr_target_commit'),
+ {
+ target_project_id: $(mrNewCompareNode)
+ .find("input[name='merge_request[target_project_id]']")
+ .val(),
+ ref: $(mrNewCompareNode)
+ .find("input[name='merge_request[target_branch]']")
+ .val(),
+ },
+ );
+ initCompareAutocomplete('branches', $dropdown => {
+ if ($dropdown.is('.js-target-branch')) {
+ updateTargetBranchCommitList();
+ } else if ($dropdown.is('.js-source-branch')) {
+ updateSourceBranchCommitList();
+ }
+ });
+ updateSourceBranchCommitList();
+ updateTargetBranchCommitList();
+};
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
index 6c9afddefac..01a0b4870c1 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
@@ -1,18 +1,15 @@
-import Compare from '~/compare';
import MergeRequest from '~/merge_request';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
+import initCompare from './compare';
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,
- });
+ initCompare(mrNewCompareNode);
} else {
const mrNewSubmitNode = document.querySelector('.js-merge-request-new-submit');
- new MergeRequest({ // eslint-disable-line no-new
+ // eslint-disable-next-line no-new
+ new MergeRequest({
action: mrNewSubmitNode.dataset.mrSubmitAction,
});
initPipelines();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js
new file mode 100644
index 00000000000..b72fe6681df
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js
@@ -0,0 +1,22 @@
+import $ from 'jquery';
+
+export default () => {
+ const $targetProjectDropdown = $('.js-target-project');
+ $targetProjectDropdown.glDropdown({
+ selectable: true,
+ fieldName: $targetProjectDropdown.data('fieldName'),
+ filterable: true,
+ id(obj, $el) {
+ return $el.data('id');
+ },
+ toggleLabel(obj, $el) {
+ return $el.text().trim();
+ },
+ clicked({ $el }) {
+ $('.mr_target_commit').empty();
+ const $targetBranchDropdown = $('.js-target-branch');
+ $targetBranchDropdown.data('refsUrl', $el.data('refsUrl'));
+ $targetBranchDropdown.data('glDropdown').clearMenu();
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index ea6fd961393..7db644e2477 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -1,9 +1,9 @@
-import ProjectNew from '../shared/project_new';
+import initProjectLoadingSpinner from '../shared/save_project_loader';
import initProjectVisibilitySelector from '../../../project_visibility';
import initProjectNew from '../../../projects/project_new';
document.addEventListener('DOMContentLoaded', () => {
- new ProjectNew(); // eslint-disable-line no-new
+ initProjectLoadingSpinner();
initProjectVisibilitySelector();
initProjectNew.bindEvents();
});
diff --git a/app/assets/javascripts/pages/projects/settings/badges/index/index.js b/app/assets/javascripts/pages/projects/settings/badges/index/index.js
new file mode 100644
index 00000000000..30469550866
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/badges/index/index.js
@@ -0,0 +1,10 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import { PROJECT_BADGE } from '~/badges/constants';
+import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => {
+ mountBadgeSettings(PROJECT_BADGE);
+});
diff --git a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
new file mode 100644
index 00000000000..ffc84dc106b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
@@ -0,0 +1,3 @@
+import initForm from '../form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js
new file mode 100644
index 00000000000..a5c17ab322c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/repository/form.js
@@ -0,0 +1,19 @@
+/* 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';
+import DueDateSelectors from '~/due_date_select';
+
+export default () => {
+ new ProtectedTagCreate();
+ new ProtectedTagEditList();
+ initDeployKeys();
+ initSettingsPanels();
+ new ProtectedBranchCreate(); // eslint-disable-line no-new
+ new ProtectedBranchEditList(); // eslint-disable-line no-new
+ new DueDateSelectors();
+};
diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
index 788d86d1192..ffc84dc106b 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -1,17 +1,3 @@
-/* eslint-disable no-new */
+import initForm from '../form';
-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
-});
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/shared/project_new.js b/app/assets/javascripts/pages/projects/shared/project_new.js
deleted file mode 100644
index 56d5574aa2f..00000000000
--- a/app/assets/javascripts/pages/projects/shared/project_new.js
+++ /dev/null
@@ -1,152 +0,0 @@
-/* eslint-disable func-names, no-var, no-underscore-dangle, prefer-template, prefer-arrow-callback*/
-
-import $ from 'jquery';
-import VisibilitySelect from '../../../visibility_select';
-
-function highlightChanges($elm) {
- $elm.addClass('highlight-changes');
- setTimeout(() => $elm.removeClass('highlight-changes'), 10);
-}
-
-export default class ProjectNew {
- constructor() {
- this.toggleSettings = this.toggleSettings.bind(this);
- this.$selects = $('.features select');
- this.$repoSelects = this.$selects.filter('.js-repo-select');
- this.$projectSelects = this.$selects.not('.js-repo-select');
-
- $('.project-edit-container').on('ajax:before', () => {
- $('.project-edit-container').hide();
- return $('.save-project-loader').show();
- });
-
- this.initVisibilitySelect();
-
- this.toggleSettings();
- this.toggleSettingsOnclick();
- this.toggleRepoVisibility();
- }
-
- initVisibilitySelect() {
- const visibilityContainer = document.querySelector('.js-visibility-select');
- if (!visibilityContainer) return;
- const visibilitySelect = new VisibilitySelect(visibilityContainer);
- visibilitySelect.init();
-
- const $visibilitySelect = $(visibilityContainer).find('select');
- let projectVisibility = $visibilitySelect.val();
- const PROJECT_VISIBILITY_PRIVATE = '0';
-
- $visibilitySelect.on('change', () => {
- const newProjectVisibility = $visibilitySelect.val();
-
- if (projectVisibility !== newProjectVisibility) {
- this.$projectSelects.each((idx, select) => {
- const $select = $(select);
- const $options = $select.find('option');
- const values = $.map($options, e => e.value);
-
- // if switched to "private", limit visibility options
- if (newProjectVisibility === PROJECT_VISIBILITY_PRIVATE) {
- if ($select.val() !== values[0] && $select.val() !== values[1]) {
- $select.val(values[1]).trigger('change');
- highlightChanges($select);
- }
- $options.slice(2).disable();
- }
-
- // if switched from "private", increase visibility for non-disabled options
- if (projectVisibility === PROJECT_VISIBILITY_PRIVATE) {
- $options.enable();
- if ($select.val() !== values[0] && $select.val() !== values[values.length - 1]) {
- $select.val(values[values.length - 1]).trigger('change');
- highlightChanges($select);
- }
- }
- });
-
- projectVisibility = newProjectVisibility;
- }
- });
- }
-
- toggleSettings() {
- this.$selects.each(function () {
- var $select = $(this);
- var className = $select.data('field')
- .replace(/_/g, '-')
- .replace('access-level', 'feature');
- ProjectNew._showOrHide($select, '.' + className);
- });
- }
-
- toggleSettingsOnclick() {
- this.$selects.on('change', this.toggleSettings);
- }
-
- static _showOrHide(checkElement, container) {
- const $container = $(container);
-
- if ($(checkElement).val() !== '0') {
- return $container.show();
- }
- return $container.hide();
- }
-
- toggleRepoVisibility() {
- var $repoAccessLevel = $('.js-repo-access-level select');
- var $lfsEnabledOption = $('.js-lfs-enabled select');
- var containerRegistry = document.querySelectorAll('.js-container-registry')[0];
- var containerRegistryCheckbox = document.getElementById('project_container_registry_enabled');
- var prevSelectedVal = parseInt($repoAccessLevel.val(), 10);
-
- this.$repoSelects.find("option[value='" + $repoAccessLevel.val() + "']")
- .nextAll()
- .hide();
-
- $repoAccessLevel
- .off('change')
- .on('change', function () {
- var selectedVal = parseInt($repoAccessLevel.val(), 10);
-
- this.$repoSelects.each(function () {
- var $this = $(this);
- var repoSelectVal = parseInt($this.val(), 10);
-
- $this.find('option').enable();
-
- if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) {
- $this.val(selectedVal).trigger('change');
- highlightChanges($this);
- }
-
- $this.find("option[value='" + selectedVal + "']").nextAll().disable();
- });
-
- if (selectedVal) {
- this.$repoSelects.removeClass('disabled');
-
- if ($lfsEnabledOption.length) {
- $lfsEnabledOption.removeClass('disabled');
- highlightChanges($lfsEnabledOption);
- }
- if (containerRegistry) {
- containerRegistry.style.display = '';
- }
- } else {
- this.$repoSelects.addClass('disabled');
-
- if ($lfsEnabledOption.length) {
- $lfsEnabledOption.val('false').addClass('disabled');
- highlightChanges($lfsEnabledOption);
- }
- if (containerRegistry) {
- containerRegistry.style.display = 'none';
- containerRegistryCheckbox.checked = false;
- }
- }
-
- prevSelectedVal = selectedVal;
- }.bind(this));
- }
-}
diff --git a/app/assets/javascripts/pages/projects/shared/save_project_loader.js b/app/assets/javascripts/pages/projects/shared/save_project_loader.js
new file mode 100644
index 00000000000..aa3589ac88d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/save_project_loader.js
@@ -0,0 +1,12 @@
+import $ from 'jquery';
+
+export default function initProjectLoadingSpinner() {
+ const $formContainer = $('.project-edit-container');
+ const $loadingSpinner = $('.save-project-loader');
+
+ // show loading spinner when saving
+ $formContainer.on('ajax:before', () => {
+ $formContainer.hide();
+ $loadingSpinner.show();
+ });
+}
diff --git a/app/assets/javascripts/pages/projects/snippets/show/index.js b/app/assets/javascripts/pages/projects/snippets/show/index.js
index a134599cb04..c35b9c30058 100644
--- a/app/assets/javascripts/pages/projects/snippets/show/index.js
+++ b/app/assets/javascripts/pages/projects/snippets/show/index.js
@@ -1,11 +1,13 @@
import initNotes from '~/init_notes';
import ZenMode from '~/zen_mode';
-import LineHighlighter from '../../../../line_highlighter';
-import BlobViewer from '../../../../blob/viewer';
+import LineHighlighter from '~/line_highlighter';
+import BlobViewer from '~/blob/viewer';
+import snippetEmbed from '~/snippet/snippet_embed';
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
+ snippetEmbed();
});
diff --git a/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
index 08f0afdcce3..d321892d2d2 100644
--- a/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
+++ b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
@@ -5,7 +5,7 @@ import AccessorUtilities from '~/lib/utils/accessor';
* Does that setting the current selected tab in the localStorage
*/
export default class SigninTabsMemoizer {
- constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) {
+ constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.new-session-tabs' } = {}) {
this.currentTabKey = currentTabKey;
this.tabSelector = tabSelector;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
diff --git a/app/assets/javascripts/pages/shared/mount_badge_settings.js b/app/assets/javascripts/pages/shared/mount_badge_settings.js
new file mode 100644
index 00000000000..1397c0834ff
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/mount_badge_settings.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import BadgeSettings from '~/badges/components/badge_settings.vue';
+import store from '~/badges/store';
+
+export default kind => {
+ const badgeSettingsElement = document.getElementById('badge-settings');
+
+ store.dispatch('loadBadges', {
+ kind,
+ apiEndpointUrl: badgeSettingsElement.dataset.apiEndpointUrl,
+ docsUrl: badgeSettingsElement.dataset.docsUrl,
+ });
+
+ return new Vue({
+ el: badgeSettingsElement,
+ store,
+ components: {
+ BadgeSettings,
+ },
+ render(createElement) {
+ return createElement(BadgeSettings);
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/snippets/show/index.js b/app/assets/javascripts/pages/snippets/show/index.js
index f548b9fad65..26936110402 100644
--- a/app/assets/javascripts/pages/snippets/show/index.js
+++ b/app/assets/javascripts/pages/snippets/show/index.js
@@ -1,11 +1,13 @@
-import LineHighlighter from '../../../line_highlighter';
-import BlobViewer from '../../../blob/viewer';
-import ZenMode from '../../../zen_mode';
-import initNotes from '../../../init_notes';
+import LineHighlighter from '~/line_highlighter';
+import BlobViewer from '~/blob/viewer';
+import ZenMode from '~/zen_mode';
+import initNotes from '~/init_notes';
+import snippetEmbed from '~/snippet/snippet_embed';
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
+ snippetEmbed();
});
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 8ce938c958b..50d042fef29 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -19,7 +19,7 @@ function getSystemDate(systemUtcOffsetSeconds) {
const date = new Date();
const localUtcOffsetMinutes = 0 - date.getTimezoneOffset();
const systemUtcOffsetMinutes = systemUtcOffsetSeconds / 60;
- date.setMinutes((date.getMinutes() - localUtcOffsetMinutes) + systemUtcOffsetMinutes);
+ date.setMinutes(date.getMinutes() - localUtcOffsetMinutes + systemUtcOffsetMinutes);
return date;
}
@@ -35,18 +35,36 @@ function formatTooltipText({ date, count }) {
return `${contribText}<br />${dateDayName} ${dateText}`;
}
-const initColorKey = () => d3.scaleLinear().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) {
+ constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0, firstDayOfWeek = 0) {
this.calendarActivitiesPath = calendarActivitiesPath;
this.clickDay = this.clickDay.bind(this);
this.currentSelectedDate = '';
this.daySpace = 1;
this.daySize = 15;
- this.daySizeWithSpace = this.daySize + (this.daySpace * 2);
- this.monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ this.daySizeWithSpace = this.daySize + this.daySpace * 2;
+ this.monthNames = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
this.months = [];
+ this.firstDayOfWeek = firstDayOfWeek;
// Loop through the timestamps to create a group of objects
// The group of objects will be grouped based on the day of the week they are
@@ -70,7 +88,7 @@ export default class ActivityCalendar {
// Create a new group array if this is the first day of the week
// or if is first object
- if ((day === 0 && i !== 0) || i === 0) {
+ if ((day === this.firstDayOfWeek && i !== 0) || i === 0) {
this.timestampsTmp.push([]);
group += 1;
}
@@ -109,21 +127,30 @@ export default class ActivityCalendar {
}
renderSvg(container, group) {
- const width = ((group + 1) * this.daySizeWithSpace) + this.getExtraWidthPadding(group);
- return d3.select(container)
+ const width = (group + 1) * this.daySizeWithSpace + this.getExtraWidthPadding(group);
+ return d3
+ .select(container)
.append('svg')
- .attr('width', width)
- .attr('height', 167)
- .attr('class', 'contrib-calendar');
+ .attr('width', width)
+ .attr('height', 167)
+ .attr('class', 'contrib-calendar');
+ }
+
+ dayYPos(day) {
+ return this.daySizeWithSpace * ((day + 7 - this.firstDayOfWeek) % 7);
}
renderDays() {
- this.svg.selectAll('g').data(this.timestampsTmp).enter().append('g')
+ this.svg
+ .selectAll('g')
+ .data(this.timestampsTmp)
+ .enter()
+ .append('g')
.attr('transform', (group, i) => {
_.each(group, (stamp, a) => {
if (a === 0 && stamp.day === 0) {
const month = stamp.date.getMonth();
- const x = (this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace;
+ const x = this.daySizeWithSpace * i + 1 + this.daySizeWithSpace;
const lastMonth = _.last(this.months);
if (
lastMonth == null ||
@@ -133,86 +160,113 @@ export default class ActivityCalendar {
}
}
});
- return `translate(${(this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace}, 18)`;
+ return `translate(${this.daySizeWithSpace * i + 1 + this.daySizeWithSpace}, 18)`;
})
.selectAll('rect')
- .data(stamp => stamp)
- .enter()
- .append('rect')
- .attr('x', '0')
- .attr('y', stamp => this.daySizeWithSpace * stamp.day)
- .attr('width', this.daySize)
- .attr('height', this.daySize)
- .attr('fill', stamp => (
- stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed'
- ))
- .attr('title', stamp => formatTooltipText(stamp))
- .attr('class', 'user-contrib-cell js-tooltip')
- .attr('data-container', 'body')
- .on('click', this.clickDay);
+ .data(stamp => stamp)
+ .enter()
+ .append('rect')
+ .attr('x', '0')
+ .attr('y', stamp => this.dayYPos(stamp.day))
+ .attr('width', this.daySize)
+ .attr('height', this.daySize)
+ .attr(
+ 'fill',
+ stamp => (stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed'),
+ )
+ .attr('title', stamp => formatTooltipText(stamp))
+ .attr('class', 'user-contrib-cell js-tooltip')
+ .attr('data-container', 'body')
+ .on('click', this.clickDay);
}
renderDayTitles() {
const days = [
{
text: 'M',
- y: 29 + (this.daySizeWithSpace * 1),
- }, {
+ y: 29 + this.dayYPos(1),
+ },
+ {
text: 'W',
- y: 29 + (this.daySizeWithSpace * 3),
- }, {
+ y: 29 + this.dayYPos(3),
+ },
+ {
text: 'F',
- y: 29 + (this.daySizeWithSpace * 5),
+ y: 29 + this.dayYPos(5),
},
];
- this.svg.append('g')
+ this.svg
+ .append('g')
.selectAll('text')
- .data(days)
- .enter()
- .append('text')
- .attr('text-anchor', 'middle')
- .attr('x', 8)
- .attr('y', day => day.y)
- .text(day => day.text)
- .attr('class', 'user-contrib-text');
+ .data(days)
+ .enter()
+ .append('text')
+ .attr('text-anchor', 'middle')
+ .attr('x', 8)
+ .attr('y', day => day.y)
+ .text(day => day.text)
+ .attr('class', 'user-contrib-text');
}
renderMonths() {
- this.svg.append('g')
+ this.svg
+ .append('g')
.attr('direction', 'ltr')
.selectAll('text')
- .data(this.months)
- .enter()
- .append('text')
- .attr('x', date => date.x)
- .attr('y', 10)
- .attr('class', 'user-contrib-text')
- .text(date => this.monthNames[date.month]);
+ .data(this.months)
+ .enter()
+ .append('text')
+ .attr('x', date => date.x)
+ .attr('y', 10)
+ .attr('class', 'user-contrib-text')
+ .text(date => this.monthNames[date.month]);
}
renderKey() {
- const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions'];
- const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
+ const keyValues = [
+ 'no contributions',
+ '1-9 contributions',
+ '10-19 contributions',
+ '20-29 contributions',
+ '30+ contributions',
+ ];
+ const keyColors = [
+ '#ededed',
+ this.colorKey(0),
+ this.colorKey(1),
+ this.colorKey(2),
+ this.colorKey(3),
+ ];
- this.svg.append('g')
- .attr('transform', `translate(18, ${(this.daySizeWithSpace * 8) + 16})`)
+ this.svg
+ .append('g')
+ .attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`)
.selectAll('rect')
- .data(keyColors)
- .enter()
- .append('rect')
- .attr('width', this.daySize)
- .attr('height', this.daySize)
- .attr('x', (color, i) => this.daySizeWithSpace * i)
- .attr('y', 0)
- .attr('fill', color => color)
- .attr('class', 'js-tooltip')
- .attr('title', (color, i) => keyValues[i])
- .attr('data-container', 'body');
+ .data(keyColors)
+ .enter()
+ .append('rect')
+ .attr('width', this.daySize)
+ .attr('height', this.daySize)
+ .attr('x', (color, i) => this.daySizeWithSpace * i)
+ .attr('y', 0)
+ .attr('fill', color => color)
+ .attr('class', 'js-tooltip')
+ .attr('title', (color, i) => keyValues[i])
+ .attr('data-container', 'body');
}
initColor() {
- const colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
- return d3.scaleThreshold().domain([0, 10, 20, 30]).range(colorRange);
+ const colorRange = [
+ '#ededed',
+ this.colorKey(0),
+ this.colorKey(1),
+ this.colorKey(2),
+ this.colorKey(3),
+ ];
+ return d3
+ .scaleThreshold()
+ .domain([0, 10, 20, 30])
+ .range(colorRange);
}
clickDay(stamp) {
@@ -227,14 +281,15 @@ export default class ActivityCalendar {
$('.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')));
+ 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/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 2fd1715ee79..8ffaa52d9e8 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -5,7 +5,6 @@ import PerformanceBarService from '../services/performance_bar_service';
import detailedMetric from './detailed_metric.vue';
import requestSelector from './request_selector.vue';
import simpleMetric from './simple_metric.vue';
-import upstreamPerformanceBar from './upstream_performance_bar.vue';
import Flash from '../../flash';
@@ -14,7 +13,6 @@ export default {
detailedMetric,
requestSelector,
simpleMetric,
- upstreamPerformanceBar,
},
props: {
store: {
@@ -128,9 +126,6 @@ export default {
{{ currentRequest.details.host.hostname }}
</span>
</div>
- <upstream-performance-bar
- v-if="initialRequest && currentRequest.details"
- />
<detailed-metric
v-for="metric in $options.detailedMetrics"
:key="metric.metric"
diff --git a/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue b/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue
deleted file mode 100644
index 2b5915f381f..00000000000
--- a/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue
+++ /dev/null
@@ -1,20 +0,0 @@
-<script>
-export default {
- mounted() {
- const upstreamPerformanceBar = document
- .getElementById('peek-view-performance-bar')
- .cloneNode(true);
-
- upstreamPerformanceBar.classList.remove('hidden');
-
- this.$refs.wrapper.appendChild(upstreamPerformanceBar);
- },
-};
-</script>
-<template>
- <div
- id="peek-view-performance-bar-vue"
- class="view"
- ref="wrapper"
- ></div>
-</template>
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index a0ddf36a672..4a98aed7679 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -1,5 +1,3 @@
-import 'vendor/peek.performance_bar';
-
import Vue from 'vue';
import performanceBarApp from './components/performance_bar_app.vue';
import PerformanceBarStore from './stores/performance_bar_store';
diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
index 3ebfaa87a4e..bc71911ae35 100644
--- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js
+++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
@@ -10,29 +10,25 @@ export default class PerformanceBarService {
}
static registerInterceptor(peekUrl, callback) {
- vueResourceInterceptor = (request, next) => {
- next(response => {
- const requestId = response.headers['x-request-id'];
- const requestUrl = response.url;
-
- if (requestUrl !== peekUrl && requestId) {
- callback(requestId, requestUrl);
- }
- });
- };
-
- Vue.http.interceptors.push(vueResourceInterceptor);
-
- return axios.interceptors.response.use(response => {
+ const interceptor = response => {
const requestId = response.headers['x-request-id'];
- const requestUrl = response.config.url;
+ // Get the request URL from response.config for Axios, and response for
+ // Vue Resource.
+ const requestUrl = (response.config || response).url;
+ const cachedResponse = response.headers['x-gitlab-from-cache'] === 'true';
- if (requestUrl !== peekUrl && requestId) {
+ if (requestUrl !== peekUrl && requestId && !cachedResponse) {
callback(requestId, requestUrl);
}
return response;
- });
+ };
+
+ vueResourceInterceptor = (request, next) => next(interceptor);
+
+ Vue.http.interceptors.push(vueResourceInterceptor);
+
+ return axios.interceptors.response.use(interceptor);
}
static removeInterceptor(interceptor) {
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index d7effb27bff..29ee73a2a6f 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -1,60 +1,85 @@
<script>
- 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,
- },
+import $ from 'jquery';
+import tooltip from '../../../vue_shared/directives/tooltip';
+import Icon from '../../../vue_shared/components/icon.vue';
+import { dasherize } from '../../../lib/utils/text_utility';
+import eventHub from '../../event_hub';
+/**
+ * Renders either a cancel, retry or play icon pointing to the given path.
+ */
+export default {
+ components: {
+ Icon,
+ },
- directives: {
- tooltip,
- },
+ directives: {
+ tooltip,
+ },
- props: {
- tooltipText: {
- type: String,
- required: true,
- },
+ props: {
+ tooltipText: {
+ type: String,
+ required: true,
+ },
- link: {
- type: String,
- required: true,
- },
+ link: {
+ type: String,
+ required: true,
+ },
- actionMethod: {
- type: String,
- required: true,
- },
+ actionIcon: {
+ type: String,
+ required: true,
+ },
- actionIcon: {
- type: String,
- required: true,
- },
+ requestFinishedFor: {
+ type: String,
+ required: false,
+ default: '',
},
+ },
+ data() {
+ return {
+ isDisabled: false,
+ linkRequested: '',
+ };
+ },
- computed: {
- cssClass() {
- const actionIconDash = dasherize(this.actionIcon);
- return `${actionIconDash} js-icon-${actionIconDash}`;
- },
+ computed: {
+ cssClass() {
+ const actionIconDash = dasherize(this.actionIcon);
+ return `${actionIconDash} js-icon-${actionIconDash}`;
+ },
+ },
+ watch: {
+ requestFinishedFor() {
+ if (this.requestFinishedFor === this.linkRequested) {
+ this.isDisabled = false;
+ }
+ },
+ },
+ methods: {
+ onClickAction() {
+ $(this.$el).tooltip('hide');
+ eventHub.$emit('graphAction', this.link);
+ this.linkRequested = this.link;
+ this.isDisabled = true;
},
- };
+ },
+};
</script>
<template>
- <a
+ <button
+ type="button"
+ @click="onClickAction"
v-tooltip
- :data-method="actionMethod"
:title="tooltipText"
- :href="link"
- class="ci-action-icon-container ci-action-icon-wrapper"
+ class="js-ci-action btn btn-blank
+btn-transparent ci-action-icon-container ci-action-icon-wrapper"
:class="cssClass"
data-container="body"
+ :disabled="isDisabled"
>
<icon :name="actionIcon" />
- </a>
+ </button>
</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
deleted file mode 100644
index 7c4fd65e36f..00000000000
--- a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-<script>
- import icon from '../../../vue_shared/components/icon.vue';
- import tooltip from '../../../vue_shared/directives/tooltip';
-
- /**
- * 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,
- required: true,
- },
-
- link: {
- type: String,
- required: true,
- },
-
- actionMethod: {
- type: String,
- required: true,
- },
-
- actionIcon: {
- type: String,
- required: true,
- },
- },
- };
-</script>
-<template>
- <a
- v-tooltip
- :data-method="actionMethod"
- :title="tooltipText"
- :href="link"
- rel="nofollow"
- class="ci-action-icon-wrapper js-ci-status-icon"
- data-container="body"
- 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 be213c2ee78..43121dd38f3 100644
--- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
@@ -1,77 +1,83 @@
<script>
- import $ from 'jquery';
- import jobNameComponent from './job_name_component.vue';
- import jobComponent from './job_component.vue';
- import tooltip from '../../../vue_shared/directives/tooltip';
+import $ from 'jquery';
+import JobNameComponent from './job_name_component.vue';
+import JobComponent from './job_component.vue';
+import tooltip from '../../../vue_shared/directives/tooltip';
- /**
- * Renders the dropdown for the pipeline graph.
- *
- * The following object should be provided as `job`:
- *
- * {
- * "id": 4256,
- * "name": "test",
- * "status": {
- * "icon": "icon_status_success",
- * "text": "passed",
- * "label": "passed",
- * "group": "success",
- * "details_path": "/root/ci-mock/builds/4256",
- * "action": {
- * "icon": "retry",
- * "title": "Retry",
- * "path": "/root/ci-mock/builds/4256/retry",
- * "method": "post"
- * }
- * }
- * }
- */
- export default {
- directives: {
- tooltip,
- },
+/**
+ * Renders the dropdown for the pipeline graph.
+ *
+ * The following object should be provided as `job`:
+ *
+ * {
+ * "id": 4256,
+ * "name": "test",
+ * "status": {
+ * "icon": "icon_status_success",
+ * "text": "passed",
+ * "label": "passed",
+ * "group": "success",
+ * "details_path": "/root/ci-mock/builds/4256",
+ * "action": {
+ * "icon": "retry",
+ * "title": "Retry",
+ * "path": "/root/ci-mock/builds/4256/retry",
+ * "method": "post"
+ * }
+ * }
+ * }
+ */
+export default {
+ directives: {
+ tooltip,
+ },
- components: {
- jobComponent,
- jobNameComponent,
- },
+ components: {
+ JobComponent,
+ JobNameComponent,
+ },
- props: {
- job: {
- type: Object,
- required: true,
- },
+ props: {
+ job: {
+ type: Object,
+ required: true,
},
-
- computed: {
- tooltipText() {
- return `${this.job.name} - ${this.job.status.label}`;
- },
+ requestFinishedFor: {
+ type: String,
+ required: false,
+ default: '',
},
+ },
- mounted() {
- this.stopDropdownClickPropagation();
+ computed: {
+ tooltipText() {
+ return `${this.job.name} - ${this.job.status.label}`;
},
+ },
+
+ mounted() {
+ this.stopDropdownClickPropagation();
+ },
- methods: {
- /**
- * When the user right clicks or cmd/ctrl + click in the job name
- * the dropdown should not be closed and the link should open in another tab,
- * so we stop propagation of the click event inside the dropdown.
+ methods: {
+ /**
+ * When the user right clicks or cmd/ctrl + click in the job name or the action icon
+ * the dropdown should not be closed so we stop propagation
+ * of the click event inside the dropdown.
*
* Since this component is rendered multiple times per page we need to guarantee we only
* target the click event of this component.
*/
- stopDropdownClickPropagation() {
- $(this.$el
- .querySelectorAll('.js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item'))
- .on('click', (e) => {
- e.stopPropagation();
- });
- },
+ stopDropdownClickPropagation() {
+ $(
+ '.js-grouped-pipeline-dropdown button, .js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item',
+ this.$el,
+ ).on('click', e => {
+ e.stopPropagation();
+ });
},
- };
+ },
+};
</script>
<template>
<div class="ci-job-dropdown-container">
@@ -101,8 +107,8 @@
:key="i">
<job-component
:job="item"
- :is-dropdown="true"
css-class-job-name="mini-pipeline-graph-dropdown-item"
+ :request-finished-for="requestFinishedFor"
/>
</li>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index ab84711d4a2..7b8a5edcbff 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,54 +1,58 @@
<script>
- import loadingIcon from '~/vue_shared/components/loading_icon.vue';
- import stageColumnComponent from './stage_column_component.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import StageColumnComponent from './stage_column_component.vue';
- export default {
- components: {
- stageColumnComponent,
- loadingIcon,
+export default {
+ components: {
+ StageColumnComponent,
+ LoadingIcon,
+ },
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
},
-
- props: {
- isLoading: {
- type: Boolean,
- required: true,
- },
- pipeline: {
- type: Object,
- required: true,
- },
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ requestFinishedFor: {
+ type: String,
+ required: false,
+ default: '',
},
+ },
- computed: {
- graph() {
- return this.pipeline.details && this.pipeline.details.stages;
- },
+ computed: {
+ graph() {
+ return this.pipeline.details && this.pipeline.details.stages;
},
+ },
- methods: {
- capitalizeStageName(name) {
- return name.charAt(0).toUpperCase() + name.slice(1);
- },
+ methods: {
+ capitalizeStageName(name) {
+ return name.charAt(0).toUpperCase() + name.slice(1);
+ },
- isFirstColumn(index) {
- return index === 0;
- },
+ isFirstColumn(index) {
+ return index === 0;
+ },
- stageConnectorClass(index, stage) {
- let className;
+ stageConnectorClass(index, stage) {
+ let className;
- // If it's the first stage column and only has one job
- if (index === 0 && stage.groups.length === 1) {
- className = 'no-margin';
- } else if (index > 0) {
- // If it is not the first column
- className = 'left-margin';
- }
+ // If it's the first stage column and only has one job
+ if (index === 0 && stage.groups.length === 1) {
+ className = 'no-margin';
+ } else if (index > 0) {
+ // If it is not the first column
+ className = 'left-margin';
+ }
- return className;
- },
+ return className;
},
- };
+ },
+};
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
@@ -70,6 +74,7 @@
:key="stage.name"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
+ :request-finished-for="requestFinishedFor"
/>
</ul>
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue
index 9b136573135..4fcd4b79f4a 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue
@@ -1,95 +1,90 @@
<script>
- import actionComponent from './action_component.vue';
- import dropdownActionComponent from './dropdown_action_component.vue';
- import jobNameComponent from './job_name_component.vue';
- import tooltip from '../../../vue_shared/directives/tooltip';
-
- /**
- * Renders the badge for the pipeline graph and the job's dropdown.
- *
- * The following object should be provided as `job`:
- *
- * {
- * "id": 4256,
- * "name": "test",
- * "status": {
- * "icon": "icon_status_success",
- * "text": "passed",
- * "label": "passed",
- * "group": "success",
- * "details_path": "/root/ci-mock/builds/4256",
- * "action": {
- * "icon": "retry",
- * "title": "Retry",
- * "path": "/root/ci-mock/builds/4256/retry",
- * "method": "post"
- * }
- * }
- * }
- */
-
- export default {
- components: {
- actionComponent,
- dropdownActionComponent,
- jobNameComponent,
+import ActionComponent from './action_component.vue';
+import JobNameComponent from './job_name_component.vue';
+import tooltip from '../../../vue_shared/directives/tooltip';
+
+/**
+ * Renders the badge for the pipeline graph and the job's dropdown.
+ *
+ * The following object should be provided as `job`:
+ *
+ * {
+ * "id": 4256,
+ * "name": "test",
+ * "status": {
+ * "icon": "icon_status_success",
+ * "text": "passed",
+ * "label": "passed",
+ * "group": "success",
+ * "tooltip": "passed",
+ * "details_path": "/root/ci-mock/builds/4256",
+ * "action": {
+ * "icon": "retry",
+ * "title": "Retry",
+ * "path": "/root/ci-mock/builds/4256/retry",
+ * "method": "post"
+ * }
+ * }
+ * }
+ */
+
+export default {
+ components: {
+ ActionComponent,
+ JobNameComponent,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
},
-
- directives: {
- tooltip,
+ cssClassJobName: {
+ type: String,
+ required: false,
+ default: '',
},
- props: {
- job: {
- type: Object,
- required: true,
- },
-
- cssClassJobName: {
- type: String,
- required: false,
- default: '',
- },
-
- isDropdown: {
- type: Boolean,
- required: false,
- default: false,
- },
+ requestFinishedFor: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ status() {
+ return this.job && this.job.status ? this.job.status : {};
},
- computed: {
- status() {
- return this.job && this.job.status ? this.job.status : {};
- },
-
- tooltipText() {
- const textBuilder = [];
+ tooltipText() {
+ const textBuilder = [];
- if (this.job.name) {
- textBuilder.push(this.job.name);
- }
+ if (this.job.name) {
+ textBuilder.push(this.job.name);
+ }
- if (this.job.name && this.status.label) {
- textBuilder.push('-');
- }
+ if (this.job.name && this.status.tooltip) {
+ textBuilder.push('-');
+ }
- if (this.status.label) {
- textBuilder.push(`${this.job.status.label}`);
- }
+ if (this.status.tooltip) {
+ textBuilder.push(`${this.job.status.tooltip}`);
+ }
- return textBuilder.join(' ');
- },
+ return textBuilder.join(' ');
+ },
- /**
- * Verifies if the provided job has an action path
- *
- * @return {Boolean}
- */
- hasAction() {
- return this.job.status && this.job.status.action && this.job.status.action.path;
- },
+ /**
+ * Verifies if the provided job has an action path
+ *
+ * @return {Boolean}
+ */
+ hasAction() {
+ return this.job.status && this.job.status.action && this.job.status.action.path;
},
- };
+ },
+};
</script>
<template>
<div class="ci-job-component">
@@ -100,6 +95,7 @@
:title="tooltipText"
:class="cssClassJobName"
data-container="body"
+ data-html="true"
class="js-pipeline-graph-job-link"
>
@@ -115,6 +111,7 @@
class="js-job-component-tooltip"
:title="tooltipText"
:class="cssClassJobName"
+ data-html="true"
data-container="body"
>
@@ -125,19 +122,11 @@
</div>
<action-component
- v-if="hasAction && !isDropdown"
- :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"
+ v-if="hasAction"
:tooltip-text="status.action.title"
:link="status.action.path"
:action-icon="status.action.icon"
- :action-method="status.action.method"
+ :request-finished-for="requestFinishedFor"
/>
</div>
</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 7adcf4017b8..5461fdbbadd 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,50 +1,56 @@
<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 {
- components: {
- jobComponent,
- dropdownJobComponent,
+export default {
+ components: {
+ JobComponent,
+ DropdownJobComponent,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
},
- 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: '',
- },
+ requestFinishedFor: {
+ type: String,
+ required: false,
+ default: '',
},
+ },
- 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
@@ -74,6 +80,7 @@
<dropdown-job-component
v-if="job.size > 1"
:job="job"
+ :request-finished-for="requestFinishedFor"
/>
</li>
diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue
index e0a7284124d..497a09cec65 100644
--- a/app/assets/javascripts/pipelines/components/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines.vue
@@ -7,10 +7,7 @@
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 { getParameterByName } from '../../lib/utils/common_utils';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
export default {
@@ -19,10 +16,7 @@
NavigationTabs,
NavigationControls,
},
- mixins: [
- pipelinesMixin,
- CIPaginationMixin,
- ],
+ mixins: [pipelinesMixin, CIPaginationMixin],
props: {
store: {
type: Object,
@@ -147,25 +141,26 @@
*/
shouldRenderTabs() {
const { stateMap } = this.$options;
- return this.hasMadeRequest &&
- [
- stateMap.loading,
- stateMap.tableList,
- stateMap.error,
- stateMap.emptyTab,
- ].includes(this.stateToRender);
+ return (
+ this.hasMadeRequest &&
+ [stateMap.loading, stateMap.tableList, stateMap.error, stateMap.emptyTab].includes(
+ this.stateToRender,
+ )
+ );
},
shouldRenderButtons() {
- return (this.newPipelinePath ||
- this.resetCachePath ||
- this.ciLintPath) && this.shouldRenderTabs;
+ return (
+ (this.newPipelinePath || this.resetCachePath || this.ciLintPath) && this.shouldRenderTabs
+ );
},
shouldRenderPagination() {
- return !this.isLoading &&
+ return (
+ !this.isLoading &&
this.state.pipelines.length &&
- this.state.pageInfo.total > this.state.pageInfo.perPage;
+ this.state.pageInfo.total > this.state.pageInfo.perPage
+ );
},
emptyTabMessage() {
@@ -229,15 +224,13 @@
},
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);
- }
- });
+ // Because we are polling & the user is interacting verify if the response received
+ // matches the last request made
+ if (_.isEqual(resp.config.params, this.requestData)) {
+ this.store.storeCount(resp.data.count);
+ this.store.storePagination(resp.headers);
+ this.setCommonData(resp.data.pipelines);
+ }
},
/**
* Handles URL and query parameter changes.
@@ -251,8 +244,9 @@
this.updateInternalState(parameters);
// fetch new data
- return this.service.getPipelines(this.requestData)
- .then((response) => {
+ return this.service
+ .getPipelines(this.requestData)
+ .then(response => {
this.isLoading = false;
this.successCallback(response);
@@ -271,13 +265,11 @@
handleResetRunnersCache(endpoint) {
this.isResetCacheButtonLoading = true;
- this.service.postAction(endpoint)
+ this.service
+ .postAction(endpoint)
.then(() => {
this.isResetCacheButtonLoading = false;
- createFlash(
- s__('Pipelines|Project cache successfully reset.'),
- 'notice',
- );
+ createFlash(s__('Pipelines|Project cache successfully reset.'), 'notice');
})
.catch(() => {
this.isResetCacheButtonLoading = false;
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
index 8bc7a1f20b2..32cf3dba3c3 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -1,5 +1,4 @@
<script>
- import $ from 'jquery';
/**
* Renders each stage of the pipeline mini graph.
@@ -14,15 +13,18 @@
* 4. Commit widget
*/
+ import $ from 'jquery';
import Flash from '../../flash';
- import icon from '../../vue_shared/components/icon.vue';
- import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import axios from '../../lib/utils/axios_utils';
+ import eventHub from '../event_hub';
+ 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,
+ LoadingIcon,
+ Icon,
},
directives: {
@@ -82,15 +84,15 @@
methods: {
onClickStage() {
if (!this.isDropdownOpen()) {
+ eventHub.$emit('clickedDropdown');
this.isLoading = true;
this.fetchJobs();
}
},
fetchJobs() {
- this.$http.get(this.stage.dropdown_path)
- .then(response => response.json())
- .then((data) => {
+ axios.get(this.stage.dropdown_path)
+ .then(({ data }) => {
this.dropdownContent = data.html;
this.isLoading = false;
})
@@ -98,8 +100,7 @@
this.closeDropdown();
this.isLoading = false;
- const flash = new Flash('Something went wrong on our end.');
- return flash;
+ Flash('Something went wrong on our end.');
});
},
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
new file mode 100644
index 00000000000..b384c7500e7
--- /dev/null
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export const CANCEL_REQUEST = 'CANCEL_REQUEST';
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js
index 522a4277bd7..6d87f75ae8e 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines.js
@@ -7,6 +7,7 @@ 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';
+import { CANCEL_REQUEST } from '../constants';
export default {
components: {
@@ -52,34 +53,58 @@ export default {
});
eventHub.$on('postAction', this.postAction);
+ eventHub.$on('clickedDropdown', this.updateTable);
},
beforeDestroy() {
eventHub.$off('postAction', this.postAction);
+ eventHub.$off('clickedDropdown', this.updateTable);
},
destroyed() {
this.poll.stop();
},
methods: {
+ updateTable() {
+ // Cancel ongoing request
+ if (this.isMakingRequest) {
+ this.service.cancelationSource.cancel(CANCEL_REQUEST);
+ }
+ // Stop polling
+ this.poll.stop();
+ // Update the table
+ return this.getPipelines()
+ .then(() => this.poll.restart());
+ },
fetchPipelines() {
if (!this.isMakingRequest) {
this.isLoading = true;
- this.service.getPipelines(this.requestData)
- .then(response => this.successCallback(response))
- .catch(() => this.errorCallback());
+ this.getPipelines();
}
},
+ getPipelines() {
+ return this.service.getPipelines(this.requestData)
+ .then(response => this.successCallback(response))
+ .catch((error) => this.errorCallback(error));
+ },
setCommonData(pipelines) {
this.store.storePipelines(pipelines);
this.isLoading = false;
this.updateGraphDropdown = true;
this.hasMadeRequest = true;
+
+ // In case the previous polling request returned an error, we need to reset it
+ if (this.hasError) {
+ this.hasError = false;
+ }
},
- errorCallback() {
- this.hasError = true;
- this.isLoading = false;
- this.updateGraphDropdown = false;
+ errorCallback(error) {
this.hasMadeRequest = true;
+ this.isLoading = false;
+
+ if (error && error.message && error.message !== CANCEL_REQUEST) {
+ this.hasError = true;
+ this.updateGraphDropdown = false;
+ }
},
setIsMakingRequest(isMakingRequest) {
this.isMakingRequest = isMakingRequest;
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 6b26708148c..6584f96130b 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -25,13 +25,38 @@ export default () => {
data() {
return {
mediator,
+ requestFinishedFor: null,
};
},
+ created() {
+ eventHub.$on('graphAction', this.postAction);
+ },
+ beforeDestroy() {
+ eventHub.$off('graphAction', this.postAction);
+ },
+ methods: {
+ postAction(action) {
+ // Click was made, reset this variable
+ this.requestFinishedFor = null;
+
+ this.mediator.service
+ .postAction(action)
+ .then(() => {
+ this.mediator.refreshPipeline();
+ this.requestFinishedFor = action;
+ })
+ .catch(() => {
+ this.requestFinishedFor = action;
+ Flash(__('An error occurred while making the request.'));
+ });
+ },
+ },
render(createElement) {
return createElement('pipeline-graph', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
+ requestFinishedFor: this.requestFinishedFor,
},
});
},
@@ -56,7 +81,8 @@ export default () => {
},
methods: {
postAction(action) {
- this.mediator.service.postAction(action.path)
+ this.mediator.service
+ .postAction(action.path)
.then(() => this.mediator.refreshPipeline())
.catch(() => Flash(__('An error occurred while making the request.')));
},
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
index 10f238fe73b..5633e54b28a 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
@@ -40,10 +40,8 @@ export default class pipelinesMediator {
}
successCallback(response) {
- return response.json().then((data) => {
- this.state.isLoading = false;
- this.store.storePipeline(data);
- });
+ this.state.isLoading = false;
+ this.store.storePipeline(response.data);
}
errorCallback() {
@@ -52,8 +50,11 @@ export default class pipelinesMediator {
}
refreshPipeline() {
- this.service.getPipeline()
+ this.poll.stop();
+
+ return this.service.getPipeline()
.then(response => this.successCallback(response))
- .catch(() => this.errorCallback());
+ .catch(() => this.errorCallback())
+ .finally(() => this.poll.restart());
}
}
diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js
index 3e0c52c7726..a53a9cc8365 100644
--- a/app/assets/javascripts/pipelines/services/pipeline_service.js
+++ b/app/assets/javascripts/pipelines/services/pipeline_service.js
@@ -1,19 +1,16 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-Vue.use(VueResource);
+import axios from '../../lib/utils/axios_utils';
export default class PipelineService {
constructor(endpoint) {
- this.pipeline = Vue.resource(endpoint);
+ this.pipeline = endpoint;
}
getPipeline() {
- return this.pipeline.get();
+ return axios.get(this.pipeline);
}
- // eslint-disable-next-line
+ // eslint-disable-next-line class-methods-use-this
postAction(endpoint) {
- return Vue.http.post(`${endpoint}.json`);
+ return axios.post(`${endpoint}.json`);
}
}
diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index 47736fc5f42..59c8b9c58e5 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -1,35 +1,32 @@
-/* eslint-disable class-methods-use-this */
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-import '../../vue_shared/vue_resource_interceptor';
-
-Vue.use(VueResource);
+import axios from '../../lib/utils/axios_utils';
export default class PipelinesService {
-
/**
- * Commits and merge request endpoints need to be requested with `.json`.
- *
- * The url provided to request the pipelines in the new merge request
- * page already has `.json`.
- *
- * @param {String} root
- */
+ * Commits and merge request endpoints need to be requested with `.json`.
+ *
+ * The url provided to request the pipelines in the new merge request
+ * page already has `.json`.
+ *
+ * @param {String} root
+ */
constructor(root) {
- let endpoint;
-
if (root.indexOf('.json') === -1) {
- endpoint = `${root}.json`;
+ this.endpoint = `${root}.json`;
} else {
- endpoint = root;
+ this.endpoint = root;
}
-
- this.pipelines = Vue.resource(endpoint);
}
getPipelines(data = {}) {
const { scope, page } = data;
- return this.pipelines.get({ scope, page });
+ const CancelToken = axios.CancelToken;
+
+ this.cancelationSource = CancelToken.source();
+
+ return axios.get(this.endpoint, {
+ params: { scope, page },
+ cancelToken: this.cancelationSource.token,
+ });
}
/**
@@ -38,7 +35,8 @@ export default class PipelinesService {
* @param {String} endpoint
* @return {Promise}
*/
+ // eslint-disable-next-line class-methods-use-this
postAction(endpoint) {
- return Vue.http.post(`${endpoint}.json`);
+ return axios.post(`${endpoint}.json`);
}
}
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
new file mode 100644
index 00000000000..e5de3f69b01
--- /dev/null
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -0,0 +1,121 @@
+<script>
+import _ from 'underscore';
+import axios from '~/lib/utils/axios_utils';
+import GlModal from '~/vue_shared/components/gl_modal.vue';
+import { s__, sprintf } from '~/locale';
+import Flash from '~/flash';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ actionUrl: {
+ type: String,
+ required: true,
+ },
+ rootUrl: {
+ type: String,
+ required: true,
+ },
+ initialUsername: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isRequestPending: false,
+ username: this.initialUsername,
+ newUsername: this.initialUsername,
+ };
+ },
+ computed: {
+ path() {
+ return sprintf(s__('Profiles|Current path: %{path}'), {
+ path: `${this.rootUrl}${this.username}`,
+ });
+ },
+ modalText() {
+ return sprintf(
+ s__(`Profiles|
+You are going to change the username %{currentUsernameBold} to %{newUsernameBold}.
+Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group.
+Please update your Git repository remotes as soon as possible.`),
+ {
+ currentUsernameBold: `<strong>${_.escape(this.username)}</strong>`,
+ newUsernameBold: `<strong>${_.escape(this.newUsername)}</strong>`,
+ currentUsername: _.escape(this.username),
+ newUsername: _.escape(this.newUsername),
+ },
+ false,
+ );
+ },
+ },
+ methods: {
+ onConfirm() {
+ this.isRequestPending = true;
+ const username = this.newUsername;
+ const putData = {
+ user: {
+ username,
+ },
+ };
+
+ return axios
+ .put(this.actionUrl, putData)
+ .then(result => {
+ Flash(result.data.message, 'notice');
+ this.username = username;
+ this.isRequestPending = false;
+ })
+ .catch(error => {
+ Flash(error.response.data.message);
+ this.isRequestPending = false;
+ throw error;
+ });
+ },
+ },
+ modalId: 'username-change-confirmation-modal',
+ inputId: 'username-change-input',
+ buttonText: s__('Profiles|Update username'),
+};
+</script>
+<template>
+ <div>
+ <div class="form-group">
+ <label :for="$options.inputId">{{ s__('Profiles|Path') }}</label>
+ <div class="input-group">
+ <div class="input-group-addon">{{ rootUrl }}</div>
+ <input
+ :id="$options.inputId"
+ class="form-control"
+ required="required"
+ v-model="newUsername"
+ :disabled="isRequestPending"
+ />
+ </div>
+ <p class="help-block">
+ {{ path }}
+ </p>
+ </div>
+ <button
+ :data-target="`#${$options.modalId}`"
+ class="btn btn-warning"
+ type="button"
+ data-toggle="modal"
+ :disabled="isRequestPending || newUsername === username"
+ >
+ {{ $options.buttonText }}
+ </button>
+ <gl-modal
+ :id="$options.modalId"
+ :header-title-text="s__('Profiles|Change username') + '?'"
+ footer-primary-button-variant="warning"
+ :footer-primary-button-text="$options.buttonText"
+ @submit="onConfirm"
+ >
+ <span v-html="modalText"></span>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js
index 84049a1f0b7..59c13e1a042 100644
--- a/app/assets/javascripts/profile/account/index.js
+++ b/app/assets/javascripts/profile/account/index.js
@@ -1,10 +1,25 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
+import UpdateUsername from './components/update_username.vue';
import deleteAccountModal from './components/delete_account_modal.vue';
export default () => {
Vue.use(Translate);
+ const updateUsernameElement = document.getElementById('update-username');
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: updateUsernameElement,
+ components: {
+ UpdateUsername,
+ },
+ render(createElement) {
+ return createElement('update-username', {
+ props: { ...updateUsernameElement.dataset },
+ });
+ },
+ });
+
const deleteAccountButton = document.getElementById('delete-account-button');
const deleteAccountModalEl = document.getElementById('delete-account-modal');
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js
index 795b39bb3dc..593a43c7cc1 100644
--- a/app/assets/javascripts/registry/stores/actions.js
+++ b/app/assets/javascripts/registry/stores/actions.js
@@ -35,3 +35,6 @@ export const deleteRegistry = ({ commit }, image) => Vue.http.delete(image.destr
export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/registry/stores/getters.js b/app/assets/javascripts/registry/stores/getters.js
index 588f479c492..f4923512578 100644
--- a/app/assets/javascripts/registry/stores/getters.js
+++ b/app/assets/javascripts/registry/stores/getters.js
@@ -1,2 +1,5 @@
export const isLoading = state => state.isLoading;
export const repos = state => state.repos;
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 2088a49590a..6eb0b62fa1c 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -5,6 +5,7 @@ import _ from 'underscore';
import Cookies from 'js-cookie';
import flash from './flash';
import axios from './lib/utils/axios_utils';
+import { __ } from './locale';
function Sidebar(currentUser) {
this.toggleTodo = this.toggleTodo.bind(this);
@@ -41,12 +42,14 @@ Sidebar.prototype.addEventListeners = function() {
};
Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
- var $allGutterToggleIcons, $this, $thisIcon;
+ var $allGutterToggleIcons, $this, isExpanded, tooltipLabel;
e.preventDefault();
$this = $(this);
- $thisIcon = $this.find('i');
+ isExpanded = $this.find('i').hasClass('fa-angle-double-right');
+ tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar');
$allGutterToggleIcons = $('.js-sidebar-toggle i');
- if ($thisIcon.hasClass('fa-angle-double-right')) {
+
+ if (isExpanded) {
$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');
@@ -57,6 +60,9 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
if (gl.lazyLoader) gl.lazyLoader.loadCheck();
}
+
+ $this.attr('data-original-title', tooltipLabel);
+
if (!triggered) {
Cookies.set("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed'));
}
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 7dd3e9858c6..2da022fde63 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -233,21 +233,21 @@ export default class SearchAutocomplete {
const issueItems = [
{
text: 'Issues assigned to me',
- url: `${issuesPath}/?assignee_username=${userName}`,
+ url: `${issuesPath}/?assignee_id=${userId}`,
},
{
text: "Issues I've created",
- url: `${issuesPath}/?author_username=${userName}`,
+ url: `${issuesPath}/?author_id=${userId}`,
},
];
const mergeRequestItems = [
{
text: 'Merge requests assigned to me',
- url: `${mrPath}/?assignee_username=${userName}`,
+ url: `${mrPath}/?assignee_id=${userId}`,
},
{
text: "Merge requests I've created",
- url: `${mrPath}/?author_username=${userName}`,
+ url: `${mrPath}/?author_id=${userId}`,
},
];
diff --git a/app/assets/javascripts/shared/popover.js b/app/assets/javascripts/shared/popover.js
new file mode 100644
index 00000000000..3fc03553bdd
--- /dev/null
+++ b/app/assets/javascripts/shared/popover.js
@@ -0,0 +1,33 @@
+import $ from 'jquery';
+import _ from 'underscore';
+
+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 mouseleave() {
+ if (!$('.popover:hover').length > 0) {
+ const $popover = $(this);
+ togglePopover.call($popover, false);
+ }
+}
+
+export function mouseenter() {
+ const $popover = $(this);
+
+ const showedPopover = togglePopover.call($popover, true);
+ if (showedPopover) {
+ $('.popover').on('mouseleave', mouseleave.bind($popover));
+ }
+}
+
+export function debouncedMouseleave(debounceTimeout = 300) {
+ return _.debounce(mouseleave, debounceTimeout);
+}
diff --git a/app/assets/javascripts/shortcuts_dashboard_navigation.js b/app/assets/javascripts/shortcuts_dashboard_navigation.js
index 25f39e4fdb6..9f69f110d06 100644
--- a/app/assets/javascripts/shortcuts_dashboard_navigation.js
+++ b/app/assets/javascripts/shortcuts_dashboard_navigation.js
@@ -1,12 +1,15 @@
+import { visitUrl } from './lib/utils/url_utility';
+
/**
* Helper function that finds the href of the fiven selector and updates the location.
*
* @param {String} selector
*/
-export default (selector) => {
- const link = document.querySelector(selector).getAttribute('href');
+export default function findAndFollowLink(selector) {
+ const element = document.querySelector(selector);
+ const link = element && element.getAttribute('href');
if (link) {
- window.location = link;
+ visitUrl(link);
}
-};
+}
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index 1e7f46454bf..2d00e8ac7e0 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -1,6 +1,12 @@
<script>
+import { __ } from '~/locale';
+import tooltip from '~/vue_shared/directives/tooltip';
+
export default {
name: 'Assignees',
+ directives: {
+ tooltip,
+ },
props: {
rootPath: {
type: String,
@@ -14,6 +20,11 @@ export default {
type: Boolean,
required: true,
},
+ issuableType: {
+ type: String,
+ require: true,
+ default: 'issue',
+ },
},
data() {
return {
@@ -62,6 +73,12 @@ export default {
names.push(`+ ${this.users.length - maxRender} more`);
}
+ if (!this.users.length) {
+ const emptyTooltipLabel = this.issuableType === 'issue' ?
+ __('Assignee(s)') : __('Assignee');
+ names.push(emptyTooltipLabel);
+ }
+
return names.join(', ');
},
sidebarAvatarCounter() {
@@ -109,7 +126,8 @@ export default {
<div>
<div
class="sidebar-collapsed-icon sidebar-collapsed-user"
- :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }"
+ :class="{ 'multiple-users': hasMoreThanOneAssignee }"
+ v-tooltip
data-container="body"
data-placement="left"
:title="collapsedTooltipTitle"
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index 3c6b9c27814..b04a2eff798 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -1,9 +1,9 @@
<script>
-import Flash from '../../../flash';
+import Flash from '~/flash';
+import eventHub from '~/sidebar/event_hub';
+import Store from '~/sidebar/stores/sidebar_store';
import AssigneeTitle from './assignee_title.vue';
import Assignees from './assignees.vue';
-import Store from '../../stores/sidebar_store';
-import eventHub from '../../event_hub';
export default {
name: 'SidebarAssignees',
@@ -25,6 +25,11 @@ export default {
required: false,
default: false,
},
+ issuableType: {
+ type: String,
+ require: true,
+ default: 'issue',
+ },
},
data() {
return {
@@ -90,6 +95,7 @@ export default {
:users="store.assignees"
:editable="store.editable"
@assign-self="assignSelf"
+ :issuable-type="issuableType"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
index ceb02309959..7f0de722f61 100644
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -1,15 +1,19 @@
<script>
-import Flash from '../../../flash';
+import { __ } from '~/locale';
+import Flash from '~/flash';
+import tooltip from '~/vue_shared/directives/tooltip';
+import Icon from '~/vue_shared/components/icon.vue';
+import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue';
-import Icon from '../../../vue_shared/components/icon.vue';
-import { __ } from '../../../locale';
-import eventHub from '../../event_hub';
export default {
components: {
editForm,
Icon,
},
+ directives: {
+ tooltip,
+ },
props: {
isConfidential: {
required: true,
@@ -33,6 +37,9 @@ export default {
confidentialityIcon() {
return this.isConfidential ? 'eye-slash' : 'eye';
},
+ tooltipLabel() {
+ return this.isConfidential ? __('Confidential') : __('Not confidential');
+ },
},
created() {
eventHub.$on('closeConfidentialityForm', this.toggleForm);
@@ -65,6 +72,10 @@ export default {
<div
class="sidebar-collapsed-icon"
@click="toggleForm"
+ v-tooltip
+ data-container="body"
+ data-placement="left"
+ :title="tooltipLabel"
>
<icon
:name="confidentialityIcon"
diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
index e4893451af3..1a5e7b67eca 100644
--- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
@@ -1,15 +1,22 @@
<script>
+import { __ } from '~/locale';
import Flash from '~/flash';
+import tooltip from '~/vue_shared/directives/tooltip';
+import issuableMixin from '~/vue_shared/mixins/issuable';
+import Icon from '~/vue_shared/components/icon.vue';
+import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue';
-import issuableMixin from '../../../vue_shared/mixins/issuable';
-import Icon from '../../../vue_shared/components/icon.vue';
-import eventHub from '../../event_hub';
export default {
components: {
editForm,
Icon,
},
+
+ directives: {
+ tooltip,
+ },
+
mixins: [issuableMixin],
props: {
@@ -44,6 +51,10 @@ export default {
isLockDialogOpen() {
return this.mediator.store.isLockDialogOpen;
},
+
+ tooltipLabel() {
+ return this.isLocked ? __('Locked') : __('Unlocked');
+ },
},
created() {
@@ -85,6 +96,10 @@ export default {
<div
class="sidebar-collapsed-icon"
@click="toggleForm"
+ v-tooltip
+ data-container="body"
+ data-placement="left"
+ :title="tooltipLabel"
>
<icon
:name="lockIcon"
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index 006a6d2905d..8f9e6761d20 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -1,9 +1,13 @@
<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';
+ import { __, n__, sprintf } from '~/locale';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+ import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
export default {
+ directives: {
+ tooltip,
+ },
components: {
loadingIcon,
userAvatarImage,
@@ -66,13 +70,23 @@
toggleMoreParticipants() {
this.isShowingMoreParticipants = !this.isShowingMoreParticipants;
},
+ onClickCollapsedIcon() {
+ this.$emit('toggleSidebar');
+ },
},
};
</script>
<template>
<div>
- <div class="sidebar-collapsed-icon">
+ <div
+ class="sidebar-collapsed-icon"
+ v-tooltip
+ data-container="body"
+ data-placement="left"
+ :title="participantLabel"
+ @click="onClickCollapsedIcon"
+ >
<i
class="fa fa-users"
aria-hidden="true"
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
index 3e8cc7a6630..385717e7c1e 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
@@ -1,6 +1,5 @@
<script>
import Store from '../../stores/sidebar_store';
-import eventHub from '../../event_hub';
import Flash from '../../../flash';
import { __ } from '../../../locale';
import subscriptions from './subscriptions.vue';
@@ -20,12 +19,6 @@ export default {
store: new Store(),
};
},
- created() {
- eventHub.$on('toggleSubscription', this.onToggleSubscription);
- },
- beforeDestroy() {
- eventHub.$off('toggleSubscription', this.onToggleSubscription);
- },
methods: {
onToggleSubscription() {
this.mediator.toggleSubscription()
@@ -42,6 +35,7 @@ export default {
<subscriptions
:loading="store.isFetching.subscriptions"
:subscribed="store.subscribed"
+ @toggleSubscription="onToggleSubscription"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index d69d100a26c..f0df759ef7a 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -47,8 +47,25 @@
},
},
methods: {
+ /**
+ * We need to emit this event on both component & eventHub
+ * for 2 dependencies;
+ *
+ * 1. eventHub: This component is used in Issue Boards sidebar
+ * where component template is part of HAML
+ * and event listeners are tied to app's eventHub.
+ * 2. Component: This compone is also used in Epics in EE
+ * where listeners are tied to component event.
+ */
toggleSubscription() {
+ // App's eventHub event emission.
eventHub.$emit('toggleSubscription', this.id);
+
+ // Component event emission.
+ this.$emit('toggleSubscription', this.id);
+ },
+ onClickCollapsedIcon() {
+ this.$emit('toggleSidebar');
},
},
};
@@ -56,7 +73,10 @@
<template>
<div>
- <div class="sidebar-collapsed-icon">
+ <div
+ class="sidebar-collapsed-icon"
+ @click="onClickCollapsedIcon"
+ >
<span
v-tooltip
:title="notificationTooltip"
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
index 3b86f1145d1..9d9ee9dea4d 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
@@ -1,12 +1,17 @@
<script>
- import icon from '../../../vue_shared/components/icon.vue';
- import { abbreviateTime } from '../../../lib/utils/pretty_time';
+ import { __, sprintf } from '~/locale';
+ import { abbreviateTime } from '~/lib/utils/pretty_time';
+ import icon from '~/vue_shared/components/icon.vue';
+ import tooltip from '~/vue_shared/directives/tooltip';
export default {
name: 'TimeTrackingCollapsedState',
components: {
icon,
},
+ directives: {
+ tooltip,
+ },
props: {
showComparisonState: {
type: Boolean,
@@ -79,6 +84,21 @@
return '';
},
+ timeTrackedTooltipText() {
+ let title;
+ if (this.showComparisonState) {
+ title = __('Time remaining');
+ } else if (this.showEstimateOnlyState) {
+ title = __('Estimated');
+ } else if (this.showSpentOnlyState) {
+ title = __('Time spent');
+ }
+
+ return sprintf('%{title}: %{text}', ({ title, text: this.text }));
+ },
+ tooltipText() {
+ return this.showNoTimeTrackingState ? __('Time tracking') : this.timeTrackedTooltipText;
+ },
},
methods: {
abbreviateTime(timeStr) {
@@ -89,7 +109,13 @@
</script>
<template>
- <div class="sidebar-collapsed-icon">
+ <div
+ class="sidebar-collapsed-icon"
+ v-tooltip
+ data-container="body"
+ data-placement="left"
+ :title="tooltipText"
+ >
<icon name="timer" />
<div class="time-tracking-collapsed-summary">
<div :class="divClass">
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
deleted file mode 100644
index 2d324c71379..00000000000
--- a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
+++ /dev/null
@@ -1,17 +0,0 @@
-export default {
- name: 'time-tracking-estimate-only-pane',
- props: {
- timeEstimateHumanReadable: {
- type: String,
- required: true,
- },
- },
- template: `
- <div class="time-tracking-estimate-only-pane">
- <span class="bold">
- {{ s__('TimeTracking|Estimated:') }}
- </span>
- {{ timeEstimateHumanReadable }}
- </div>
- `,
-};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue
new file mode 100644
index 00000000000..08fce597e50
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue
@@ -0,0 +1,20 @@
+<script>
+export default {
+ name: 'TimeTrackingEstimateOnlyPane',
+ props: {
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="time-tracking-estimate-only-pane">
+ <span class="bold">
+ {{ s__('TimeTracking|Estimated:') }}
+ </span>
+ {{ timeEstimateHumanReadable }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
index 19f74ad3c6d..825063d9ba6 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
@@ -1,7 +1,8 @@
+<script>
import { sprintf, s__ } from '../../../locale';
export default {
- name: 'time-tracking-help-state',
+ name: 'TimeTrackingHelpState',
props: {
rootPath: {
type: String,
@@ -27,26 +28,28 @@ export default {
);
},
},
- template: `
- <div class="time-tracking-help-state">
- <div class="time-tracking-info">
- <h4>
- {{ __('Track time with quick actions') }}
- </h4>
- <p>
- {{ __('Quick actions can be used in the issues description and comment boxes.') }}
- </p>
- <p v-html="estimateText">
- </p>
- <p v-html="spendText">
- </p>
- <a
- class="btn btn-default learn-more-button"
- :href="href"
- >
- {{ __('Learn more') }}
- </a>
- </div>
- </div>
- `,
};
+</script>
+
+<template>
+ <div class="time-tracking-help-state">
+ <div class="time-tracking-info">
+ <h4>
+ {{ __('Track time with quick actions') }}
+ </h4>
+ <p>
+ {{ __('Quick actions can be used in the issues description and comment boxes.') }}
+ </p>
+ <p v-html="estimateText">
+ </p>
+ <p v-html="spendText">
+ </p>
+ <a
+ class="btn btn-default learn-more-button"
+ :href="href"
+ >
+ {{ __('Learn more') }}
+ </a>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index 38da76c6771..00000000000
--- a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export default {
- name: 'time-tracking-no-tracking-pane',
- template: `
- <div class="time-tracking-no-tracking-pane">
- <span class="no-value">
- {{ __('No estimate or time spent') }}
- </span>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue
new file mode 100644
index 00000000000..9228184df5b
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue
@@ -0,0 +1,13 @@
+<script>
+export default {
+ name: 'TimeTrackingNoTrackingPane',
+};
+</script>
+
+<template>
+ <div class="time-tracking-no-tracking-pane">
+ <span class="no-value">
+ {{ __('No estimate or time spent') }}
+ </span>
+ </div>
+</template>
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.vue
index 5626cccc022..2e1d6e9643a 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
@@ -1,3 +1,4 @@
+<script>
import $ from 'jquery';
import _ from 'underscore';
@@ -10,14 +11,17 @@ import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub';
export default {
+ components: {
+ IssuableTimeTracker,
+ },
data() {
return {
mediator: new Mediator(),
store: new Store(),
};
},
- components: {
- IssuableTimeTracker,
+ mounted() {
+ this.listenForQuickActions();
},
methods: {
listenForQuickActions() {
@@ -41,18 +45,17 @@ export default {
}
},
},
- mounted() {
- this.listenForQuickActions();
- },
- template: `
- <div class="block">
- <issuable-time-tracker
- :time_estimate="store.timeEstimate"
- :time_spent="store.totalTimeSpent"
- :human_time_estimate="store.humanTimeEstimate"
- :human_time_spent="store.humanTotalTimeSpent"
- :rootPath="store.rootPath"
- />
- </div>
- `,
};
+</script>
+
+<template>
+ <div class="block">
+ <issuable-time-tracker
+ :time_estimate="store.timeEstimate"
+ :time_spent="store.totalTimeSpent"
+ :human_time_estimate="store.humanTimeEstimate"
+ :human_time_spent="store.humanTotalTimeSpent"
+ :root-path="store.rootPath"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
deleted file mode 100644
index bf987562647..00000000000
--- a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
+++ /dev/null
@@ -1,15 +0,0 @@
-export default {
- name: 'time-tracking-spent-only-pane',
- props: {
- timeSpentHumanReadable: {
- type: String,
- required: true,
- },
- },
- template: `
- <div class="time-tracking-spend-only-pane">
- <span class="bold">Spent:</span>
- {{ timeSpentHumanReadable }}
- </div>
- `,
-};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
new file mode 100644
index 00000000000..59cd99f8f14
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
@@ -0,0 +1,18 @@
+<script>
+export default {
+ name: 'TimeTrackingSpentOnlyPane',
+ props: {
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="time-tracking-spend-only-pane">
+ <span class="bold">Spent:</span>
+ {{ timeSpentHumanReadable }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index 1c641c73ea3..8f5d0bee107 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -1,9 +1,9 @@
<script>
-import timeTrackingHelpState from './help_state';
+import TimeTrackingHelpState from './help_state.vue';
import TimeTrackingCollapsedState from './collapsed_state.vue';
-import timeTrackingSpentOnlyPane from './spent_only_pane';
-import timeTrackingNoTrackingPane from './no_tracking_pane';
-import timeTrackingEstimateOnlyPane from './estimate_only_pane';
+import TimeTrackingSpentOnlyPane from './spent_only_pane.vue';
+import TimeTrackingNoTrackingPane from './no_tracking_pane.vue';
+import TimeTrackingEstimateOnlyPane from './estimate_only_pane.vue';
import TimeTrackingComparisonPane from './comparison_pane.vue';
import eventHub from '../../event_hub';
@@ -12,11 +12,11 @@ export default {
name: 'IssuableTimeTracker',
components: {
TimeTrackingCollapsedState,
- 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane,
- 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
- 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane,
+ TimeTrackingEstimateOnlyPane,
+ TimeTrackingSpentOnlyPane,
+ TimeTrackingNoTrackingPane,
TimeTrackingComparisonPane,
- 'time-tracking-help-state': timeTrackingHelpState,
+ TimeTrackingHelpState,
},
props: {
time_estimate: {
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
index 1eadebc7004..b267422cd97 100644
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import _ from 'underscore';
function isValidProjectId(id) {
return id > 0;
@@ -43,7 +44,7 @@ class SidebarMoveIssue {
renderRow: project => `
<li>
<a href="#" class="js-move-issue-dropdown-item">
- ${project.name_with_namespace}
+ ${_.escape(project.name_with_namespace)}
</a>
</li>
`,
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 9f5d852260e..3086e7d0fc9 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
-import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
+import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
@@ -27,6 +27,7 @@ function mountAssigneesComponent(mediator) {
mediator,
field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'),
+ issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
},
}),
});
diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js
new file mode 100644
index 00000000000..81ec483f2d9
--- /dev/null
+++ b/app/assets/javascripts/snippet/snippet_embed.js
@@ -0,0 +1,23 @@
+export default () => {
+ const { protocol, host, pathname } = location;
+ const shareBtn = document.querySelector('.js-share-btn');
+ const embedBtn = document.querySelector('.js-embed-btn');
+ const snippetUrlArea = document.querySelector('.js-snippet-url-area');
+ const embedAction = document.querySelector('.js-embed-action');
+ const url = `${protocol}//${host + pathname}`;
+
+ shareBtn.addEventListener('click', () => {
+ shareBtn.classList.add('is-active');
+ embedBtn.classList.remove('is-active');
+ snippetUrlArea.value = url;
+ embedAction.innerText = 'Share';
+ });
+
+ embedBtn.addEventListener('click', () => {
+ embedBtn.classList.add('is-active');
+ shareBtn.classList.remove('is-active');
+ const scriptTag = `<script src="${url}.js"></script>`;
+ snippetUrlArea.value = scriptTag;
+ embedAction.innerText = 'Embed';
+ });
+};
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index f3b961eb109..8486019897d 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -5,6 +5,8 @@
import $ from 'jquery';
import _ from 'underscore';
import axios from './lib/utils/axios_utils';
+import { __ } from './locale';
+import ModalStore from './boards/stores/modal_store';
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
@@ -181,7 +183,7 @@ function UsersSelect(currentUser, els, options = {}) {
return axios.put(issueURL, data)
.then(({ data }) => {
- var user;
+ var user, tooltipTitle;
$dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut();
if (data.assignee) {
@@ -190,15 +192,17 @@ function UsersSelect(currentUser, els, options = {}) {
username: data.assignee.username,
avatar: data.assignee.avatar_url
};
+ tooltipTitle = _.escape(user.name);
} else {
user = {
name: 'Unassigned',
username: '',
avatar: ''
};
+ tooltipTitle = __('Assignee');
}
$value.html(assigneeTemplate(user));
- $collapsedSidebar.attr('title', _.escape(user.name)).tooltip('fixTitle');
+ $collapsedSidebar.attr('title', tooltipTitle).tooltip('fixTitle');
return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
});
};
@@ -441,7 +445,7 @@ function UsersSelect(currentUser, els, options = {}) {
return;
}
if ($el.closest('.add-issues-modal').length) {
- gl.issueBoards.ModalStore.store.filter[$dropdown.data('fieldName')] = user.id;
+ ModalStore.store.filter[$dropdown.data('fieldName')] = user.id;
} else if (handleClick) {
e.preventDefault();
handleClick(user, isMarking);
diff --git a/app/assets/javascripts/visibility_select.js b/app/assets/javascripts/visibility_select.js
deleted file mode 100644
index 0c928d0d5f6..00000000000
--- a/app/assets/javascripts/visibility_select.js
+++ /dev/null
@@ -1,21 +0,0 @@
-export default class VisibilitySelect {
- constructor(container) {
- if (!container) throw new Error('VisibilitySelect requires a container element as argument 1');
- this.container = container;
- this.helpBlock = this.container.querySelector('.help-block');
- this.select = this.container.querySelector('select');
- }
-
- init() {
- if (this.select) {
- this.updateHelpText();
- this.select.addEventListener('change', this.updateHelpText.bind(this));
- } else {
- this.helpBlock.textContent = this.container.querySelector('.js-locked').dataset.helpBlock;
- }
- }
-
- updateHelpText() {
- this.helpBlock.textContent = this.select.querySelector('option:checked').dataset.description;
- }
-}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue
index 95c8b0a4c55..f012f9c6772 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue
@@ -146,8 +146,8 @@ export default {
</p>
<p
v-if="shouldShowMemoryGraph"
- class="usage-info js-usage-info">
- {{ memoryChangeMessage }}
+ class="usage-info js-usage-info"
+ v-html="memoryChangeMessage">
</p>
<p
v-if="shouldShowLoadFailure"
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
index 54a98abf860..48dff8c4916 100644
--- 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
@@ -1,56 +1,61 @@
<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';
+/* 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,
+export default {
+ name: 'MRWidgetPipeline',
+ components: {
+ PipelineStage,
+ CiIcon,
+ Icon,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
},
- 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,
- },
+ // 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,
},
- 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;
- },
+ 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
+ );
+ },
+ hasCommitInfo() {
+ return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0;
+ },
+ },
+};
</script>
<template>
<div
v-if="hasPipeline || hasCIError"
- class="mr-widget-heading">
+ 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">
@@ -77,13 +82,17 @@
#{{ pipeline.id }}
</a>
- {{ pipeline.details.status.label }} for
+ {{ pipeline.details.status.label }}
- <a
- :href="pipeline.commit.commit_path"
- class="commit-sha js-commit-link"
- >
- {{ pipeline.commit.short_id }}</a>.
+ <template v-if="hasCommitInfo">
+ for
+
+ <a
+ :href="pipeline.commit.commit_path"
+ class="commit-sha js-commit-link"
+ >
+ {{ pipeline.commit.short_id }}</a>.
+ </template>
<span class="mr-widget-pipeline-graph">
<span
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
index 602b68ea572..7d366c495f0 100644
--- 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
@@ -1,66 +1,70 @@
<script>
- import { n__ } from '~/locale';
- import statusIcon from '../mr_widget_status_icon.vue';
- import eventHub from '../../event_hub';
+import { n__ } from '~/locale';
+import statusIcon from '../mr_widget_status_icon.vue';
+import eventHub from '../../event_hub';
- export default {
- name: 'MRWidgetFailedToMerge',
+export default {
+ name: 'MRWidgetFailedToMerge',
- components: {
- statusIcon,
- },
+ components: {
+ statusIcon,
+ },
- props: {
- mr: {
- type: Object,
- required: true,
- default: () => ({}),
- },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
},
+ },
- data() {
- return {
- timer: 10,
- isRefreshing: false,
- };
- },
+ data() {
+ return {
+ timer: 10,
+ isRefreshing: false,
+ intervalId: null,
+ };
+ },
- computed: {
- timerText() {
- return n__(
- 'Refreshing in a second to show the updated status...',
- 'Refreshing in %d seconds to show the updated status...',
- this.timer,
- );
- },
+ 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);
- },
+ mounted() {
+ this.intervalId = setInterval(this.updateTimer, 1000);
+ },
- created() {
- eventHub.$emit('DisablePolling');
- },
+ created() {
+ eventHub.$emit('DisablePolling');
+ },
- methods: {
- refresh() {
- this.isRefreshing = true;
- eventHub.$emit('MRWidgetUpdateRequested');
- eventHub.$emit('EnablePolling');
- },
- updateTimer() {
- this.timer = this.timer - 1;
+ beforeDestroy() {
+ if (this.intervalId) {
+ clearInterval(this.intervalId);
+ }
+ },
- if (this.timer === 0) {
- this.refresh();
- }
- },
+ 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">
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
deleted file mode 100644
index 4d9a2ca530f..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import statusIcon from '../mr_widget_status_icon.vue';
-
-export default {
- name: 'MRWidgetPipelineBlocked',
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="warning" :show-disabled-button="true" />
- <div class="media-body space-children">
- <span class="bold">
- The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure
- </span>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
new file mode 100644
index 00000000000..8d55477929f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
@@ -0,0 +1,25 @@
+<script>
+import statusIcon from '../mr_widget_status_icon.vue';
+
+export default {
+ name: 'PipelineFailed',
+ 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|The pipeline for this merge request failed.
+Please retry the job or push a new commit to fix the failure`) }}
+ </span>
+ </div>
+ </div>
+</template>
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/ready_to_merge.vue
index 3c781ccddc8..0264625a526 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/ready_to_merge.vue
@@ -1,3 +1,4 @@
+<script>
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
import simplePoll from '~/lib/utils/simple_poll';
@@ -7,7 +8,10 @@ import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub';
export default {
- name: 'MRWidgetReadyToMerge',
+ name: 'ReadyToMerge',
+ components: {
+ statusIcon,
+ },
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
@@ -26,9 +30,6 @@ export default {
warningSvg,
};
},
- components: {
- statusIcon,
- },
computed: {
shouldShowMergeWhenPipelineSucceedsText() {
return this.mr.isPipelineActive;
@@ -217,136 +218,146 @@ export default {
});
},
},
- template: `
- <div class="mr-widget-body media">
- <status-icon :status="iconClass" />
- <div class="media-body">
- <div class="mr-widget-body-controls media space-children">
- <span class="btn-group append-bottom-5">
- <button
- @click="handleMergeButtonClick()"
- :disabled="isMergeButtonDisabled"
- :class="mergeButtonClass"
- type="button"
- class="qa-merge-button">
- <i
- v-if="isMakingRequest"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- {{mergeButtonText}}
- </button>
+};
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <status-icon :status="iconClass" />
+ <div class="media-body">
+ <div class="mr-widget-body-controls media space-children">
+ <span class="btn-group append-bottom-5">
+ <button
+ @click="handleMergeButtonClick()"
+ :disabled="isMergeButtonDisabled"
+ :class="mergeButtonClass"
+ type="button"
+ class="qa-merge-button">
+ <i
+ v-if="isMakingRequest"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ ></i>
+ {{ mergeButtonText }}
+ </button>
+ <button
+ v-if="shouldShowMergeOptionsDropdown"
+ :disabled="isMergeButtonDisabled"
+ type="button"
+ class="btn btn-sm btn-info dropdown-toggle js-merge-moment"
+ data-toggle="dropdown"
+ aria-label="Select merge moment">
+ <i
+ class="fa fa-chevron-down"
+ aria-hidden="true"
+ ></i>
+ </button>
+ <ul
+ v-if="shouldShowMergeOptionsDropdown"
+ class="dropdown-menu dropdown-menu-right"
+ role="menu">
+ <li>
+ <a
+ @click.prevent="handleMergeButtonClick(true)"
+ class="merge_when_pipeline_succeeds"
+ href="#">
+ <span class="media">
+ <span
+ v-html="successSvg"
+ class="merge-opt-icon"
+ aria-hidden="true"></span>
+ <span class="media-body merge-opt-title">Merge when pipeline succeeds</span>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a
+ @click.prevent="handleMergeButtonClick(false, true)"
+ class="accept-merge-request"
+ href="#">
+ <span class="media">
+ <span
+ v-html="warningSvg"
+ class="merge-opt-icon"
+ aria-hidden="true"></span>
+ <span class="media-body merge-opt-title">Merge immediately</span>
+ </span>
+ </a>
+ </li>
+ </ul>
+ </span>
+ <div class="media-body-wrap space-children">
+ <template v-if="shouldShowMergeControls()">
+ <label v-if="mr.canRemoveSourceBranch">
+ <input
+ id="remove-source-branch-input"
+ v-model="removeSourceBranch"
+ class="js-remove-source-branch-checkbox"
+ :disabled="isRemoveSourceBranchButtonDisabled"
+ type="checkbox"/> Remove source branch
+ </label>
+
+ <!-- Placeholder for EE extension of this component -->
+ <squash-before-merge
+ v-if="shouldShowSquashBeforeMerge"
+ :mr="mr"
+ :is-merge-button-disabled="isMergeButtonDisabled" />
+
+ <span
+ v-if="mr.ffOnlyEnabled"
+ class="js-fast-forward-message">
+ Fast-forward merge without a merge commit
+ </span>
<button
- v-if="shouldShowMergeOptionsDropdown"
+ v-else
+ @click="toggleCommitMessageEditor"
:disabled="isMergeButtonDisabled"
- type="button"
- class="btn btn-sm btn-info dropdown-toggle js-merge-moment"
- data-toggle="dropdown"
- aria-label="Select merge moment">
- <i
- class="fa fa-chevron-down"
- aria-hidden="true" />
+ class="js-modify-commit-message-button btn btn-default btn-xs"
+ type="button">
+ Modify commit message
</button>
- <ul
- v-if="shouldShowMergeOptionsDropdown"
- class="dropdown-menu dropdown-menu-right"
- role="menu">
- <li>
- <a
- @click.prevent="handleMergeButtonClick(true)"
- class="merge_when_pipeline_succeeds"
- href="#">
- <span class="media">
- <span
- v-html="successSvg"
- class="merge-opt-icon"
- aria-hidden="true"></span>
- <span class="media-body merge-opt-title">Merge when pipeline succeeds</span>
- </span>
- </a>
- </li>
- <li>
- <a
- @click.prevent="handleMergeButtonClick(false, true)"
- class="accept-merge-request"
- href="#">
- <span class="media">
- <span
- v-html="warningSvg"
- class="merge-opt-icon"
- aria-hidden="true"></span>
- <span class="media-body merge-opt-title">Merge immediately</span>
- </span>
- </a>
- </li>
- </ul>
- </span>
- <div class="media-body-wrap space-children">
- <template v-if="shouldShowMergeControls()">
- <label v-if="mr.canRemoveSourceBranch">
- <input
- id="remove-source-branch-input"
- v-model="removeSourceBranch"
- class="js-remove-source-branch-checkbox"
- :disabled="isRemoveSourceBranchButtonDisabled"
- type="checkbox"/> Remove source branch
- </label>
-
- <!-- Placeholder for EE extension of this component -->
- <squash-before-merge
- v-if="shouldShowSquashBeforeMerge"
- :mr="mr"
- :is-merge-button-disabled="isMergeButtonDisabled" />
-
- <span
- v-if="mr.ffOnlyEnabled"
- class="js-fast-forward-message">
- Fast-forward merge without a merge commit
- </span>
- <button
- v-else
- @click="toggleCommitMessageEditor"
- :disabled="isMergeButtonDisabled"
- class="js-modify-commit-message-button btn btn-default btn-xs"
- type="button">
- Modify commit message
- </button>
- </template>
- <template v-else>
- <span class="bold js-resolve-mr-widget-items-message">
- You can only merge once the items above are resolved
- </span>
- </template>
- </div>
+ </template>
+ <template v-else>
+ <span class="bold js-resolve-mr-widget-items-message">
+ You can only merge once the items above are resolved
+ </span>
+ </template>
</div>
- <div
- v-if="showCommitMessageEditor"
- class="prepend-top-default commit-message-editor">
- <div class="form-group clearfix">
- <label
- class="control-label"
- for="commit-message">
- Commit message
- </label>
- <div class="col-sm-10">
- <div class="commit-message-container">
- <div class="max-width-marker"></div>
- <textarea
- v-model="commitMessage"
- class="form-control js-commit-message"
- required="required"
- rows="14"
- name="Commit message"></textarea>
- </div>
- <p class="hint">Try to keep the first line under 52 characters and the others under 72</p>
- <div class="hint">
- <a
- @click.prevent="updateCommitMessage"
- href="#">{{commitMessageLinkTitle}}</a>
- </div>
+ </div>
+ <div
+ v-if="showCommitMessageEditor"
+ class="prepend-top-default commit-message-editor">
+ <div class="form-group clearfix">
+ <label
+ class="control-label"
+ for="commit-message">
+ Commit message
+ </label>
+ <div class="col-sm-10">
+ <div class="commit-message-container">
+ <div class="max-width-marker"></div>
+ <textarea
+ id="commit-message"
+ v-model="commitMessage"
+ class="form-control js-commit-message"
+ required="required"
+ rows="14"
+ name="Commit message"></textarea>
+ </div>
+ <p class="hint">
+ Try to keep the first line under 52 characters and the others under 72
+ </p>
+ <div class="hint">
+ <a
+ @click.prevent="updateCommitMessage"
+ href="#"
+ >
+ {{ commitMessageLinkTitle }}
+ </a>
</div>
</div>
</div>
</div>
</div>
- `,
-};
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index 9ade6a91747..a1f7e696795 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -7,7 +7,10 @@ export default {
statusIcon,
},
props: {
- mr: { type: Object, required: true },
+ mr: {
+ type: Object,
+ required: true,
+ },
},
};
</script>
@@ -20,13 +23,14 @@ export default {
/>
<div class="media-body space-children">
<span class="bold">
- There are unresolved discussions. Please resolve these discussions
+ {{ s__("mrWidget|There are unresolved discussions. Please resolve these discussions") }}
</span>
<a
v-if="mr.createIssueToResolveDiscussionsPath"
:href="mr.createIssueToResolveDiscussionsPath"
- class="btn btn-default btn-xs js-create-issue">
- Create an issue to resolve them later
+ class="btn btn-default btn-xs js-create-issue"
+ >
+ {{ s__("mrWidget|Create an issue to resolve them later") }}
</a>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 44e1a616a19..fe2608e8212 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/work_in_progress.vue
@@ -1,25 +1,26 @@
+<script>
import $ from 'jquery';
import statusIcon from '../mr_widget_status_icon.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
export default {
- name: 'MRWidgetWIP',
- props: {
- mr: { type: Object, required: true },
- service: { type: Object, required: true },
+ name: 'WorkInProgress',
+ components: {
+ statusIcon,
},
directives: {
tooltip,
},
+ props: {
+ mr: { type: Object, required: true },
+ service: { type: Object, required: true },
+ },
data() {
return {
isMakingRequest: false,
};
},
- components: {
- statusIcon,
- },
methods: {
removeWIP() {
this.isMakingRequest = true;
@@ -36,32 +37,40 @@ export default {
});
},
},
- template: `
- <div class="mr-widget-body media">
- <status-icon status="warning" :show-disabled-button="Boolean(mr.removeWIPPath)" />
- <div class="media-body space-children">
- <span class="bold">
- This is a Work in Progress
- <i
- v-tooltip
- class="fa fa-question-circle"
- title="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged"
- aria-label="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged">
- </i>
- </span>
- <button
- v-if="mr.removeWIPPath"
- @click="removeWIP"
- :disabled="isMakingRequest"
- type="button"
- class="btn btn-default btn-xs js-remove-wip">
- <i
- v-if="isMakingRequest"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- Resolve WIP status
- </button>
- </div>
- </div>
- `,
};
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="Boolean(mr.removeWIPPath)"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ This is a Work in Progress
+ <i
+ v-tooltip
+ class="fa fa-question-circle"
+ title="When this merge request is ready,
+ remove the WIP: prefix from the title to allow it to be merged"
+ aria-label="When this merge request is ready,
+ remove the WIP: prefix from the title to allow it to be merged">
+ </i>
+ </span>
+ <button
+ v-if="mr.removeWIPPath"
+ @click="removeWIP"
+ :disabled="isMakingRequest"
+ type="button"
+ class="btn btn-default btn-xs js-remove-wip">
+ <i
+ v-if="isMakingRequest"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true">
+ </i>
+ Resolve WIP status
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
index ed15fc6ab0f..7f5f28091da 100644
--- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js
+++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
@@ -21,17 +21,17 @@ 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 WorkInProgressState } from './components/states/work_in_progress.vue';
export { default as ArchivedState } from './components/states/mr_widget_archived.vue';
export { default as ConflictsState } from './components/states/mr_widget_conflicts.vue';
export { default as NothingToMergeState } from './components/states/nothing_to_merge.vue';
export { default as MissingBranchState } from './components/states/mr_widget_missing_branch.vue';
export { default as NotAllowedState } from './components/states/mr_widget_not_allowed.vue';
-export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge';
+export { default as ReadyToMergeState } from './components/states/ready_to_merge.vue';
export { default as ShaMismatchState } from './components/states/sha_mismatch.vue';
export { default as UnresolvedDiscussionsState } from './components/states/unresolved_discussions.vue';
export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked.vue';
-export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed';
+export { default as PipelineFailedState } from './components/states/pipeline_failed.vue';
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';
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 0be5d9e5a55..345f9ac1b4b 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
@@ -12,7 +12,7 @@ import {
ClosedState,
MergingState,
RebaseState,
- WipState,
+ WorkInProgressState,
ArchivedState,
ConflictsState,
NothingToMergeState,
@@ -220,7 +220,7 @@ export default {
'mr-widget-closed': ClosedState,
'mr-widget-merging': MergingState,
'mr-widget-failed-to-merge': FailedToMerge,
- 'mr-widget-wip': WipState,
+ 'mr-widget-wip': WorkInProgressState,
'mr-widget-archived': ArchivedState,
'mr-widget-conflicts': ConflictsState,
'mr-widget-nothing-to-merge': NothingToMergeState,
diff --git a/app/assets/javascripts/vue_shared/components/callout.vue b/app/assets/javascripts/vue_shared/components/callout.vue
new file mode 100644
index 00000000000..ccf802c456c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/callout.vue
@@ -0,0 +1,27 @@
+<script>
+const calloutVariants = ['danger', 'success', 'info', 'warning'];
+
+export default {
+ props: {
+ category: {
+ type: String,
+ required: false,
+ default: calloutVariants[0],
+ validator: value => calloutVariants.includes(value),
+ },
+ message: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div
+ :class="`bs-callout bs-callout-${category}`"
+ role="alert"
+ aria-live="assertive"
+ >
+ {{ message }}
+ </div>
+</template>
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 5324d5dc797..0d64efcbf68 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,52 @@
<script>
- 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
- */
+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 {
- components: {
- ciIcon,
+export default {
+ components: {
+ CiIcon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ status: {
+ type: Object,
+ required: true,
},
- directives: {
- tooltip,
+ showText: {
+ type: Boolean,
+ required: false,
+ default: true,
},
- 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';
},
- computed: {
- cssClass() {
- const className = this.status.group;
- return className ? `ci-status ci-${className}` : 'ci-status';
- },
- },
- };
+ },
+};
</script>
<template>
<a
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index 8fea746f4de..fcab8f571dd 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -1,45 +1,44 @@
<script>
- import icon from '../../vue_shared/components/icon.vue';
+import Icon from '../../vue_shared/components/icon.vue';
- /**
- * Renders CI icon 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 Badge
- * - Pipelines table mini graph
- * - Pipeline graph
- * - Pipeline show view badge
- * - Jobs table
- * - Jobs show view header
- * - Jobs show view sidebar
- */
- export default {
- components: {
- icon,
+/**
+ * Renders CI icon 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 Badge
+ * - Pipelines table mini graph
+ * - Pipeline graph
+ * - Pipeline show view badge
+ * - Jobs table
+ * - Jobs show view header
+ * - Jobs show view sidebar
+ */
+export default {
+ components: {
+ Icon,
+ },
+ props: {
+ status: {
+ type: Object,
+ required: true,
},
- props: {
- status: {
- type: Object,
- required: true,
- },
+ },
+ computed: {
+ cssClass() {
+ const status = this.status.group;
+ return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`;
},
-
- computed: {
- cssClass() {
- const status = this.status.group;
- return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`;
- },
- },
- };
+ },
+};
</script>
<template>
<span :class="cssClass">
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index cab126a7eca..cb2cc3901ad 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -1,40 +1,50 @@
<script>
- /**
- * Falls back to the code used in `copy_to_clipboard.js`
- */
- import tooltip from '../directives/tooltip';
+/**
+ * Falls back to the code used in `copy_to_clipboard.js`
+ *
+ * Renders a button with a clipboard icon that copies the content of `data-clipboard-text`
+ * when clicked.
+ *
+ * @example
+ * <clipboard-button
+ * title="Copy to clipbard"
+ * text="Content to be copied"
+ * css-class="btn-transparent"
+ * />
+ */
+import tooltip from '../directives/tooltip';
- export default {
- name: 'ClipboardButton',
- directives: {
- tooltip,
+export default {
+ name: 'ClipboardButton',
+ directives: {
+ tooltip,
+ },
+ props: {
+ text: {
+ type: String,
+ required: true,
},
- props: {
- text: {
- type: String,
- required: true,
- },
- title: {
- type: String,
- required: true,
- },
- tooltipPlacement: {
- type: String,
- required: false,
- default: 'top',
- },
- tooltipContainer: {
- type: [String, Boolean],
- required: false,
- default: false,
- },
- cssClass: {
- type: String,
- required: false,
- default: 'btn-default',
- },
+ title: {
+ type: String,
+ required: true,
},
- };
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ tooltipContainer: {
+ type: [String, Boolean],
+ required: false,
+ default: false,
+ },
+ cssClass: {
+ type: String,
+ required: false,
+ default: 'btn-default',
+ },
+ },
+};
</script>
<template>
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index 97789636787..8f250a6c989 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -1,119 +1,111 @@
<script>
- import commitIconSvg from 'icons/_icon_commit.svg';
- import userAvatarLink from './user_avatar/user_avatar_link.vue';
- import tooltip from '../directives/tooltip';
- import icon from '../../vue_shared/components/icon.vue';
+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,
+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 a svg sprite fork icon
+ */
+ tag: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- components: {
- userAvatarLink,
- icon,
+ /**
+ * If provided is used to render the branch name and url.
+ * Should contain the following properties:
+ * name
+ * ref_url
+ */
+ commitRef: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ /**
+ * Used to link to the commit sha.
+ */
+ commitUrl: {
+ type: String,
+ required: false,
+ default: '',
},
- props: {
- /**
- * Indicates the existance of a tag.
- * Used to render the correct icon, if true will render `fa-tag` icon,
- * if false will render a svg sprite fork icon
- */
- tag: {
- type: Boolean,
- required: false,
- default: false,
- },
- /**
- * If provided is used to render the branch name and url.
- * Should contain the following properties:
- * name
- * ref_url
- */
- commitRef: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- /**
- * Used to link to the commit sha.
- */
- commitUrl: {
- type: String,
- required: false,
- default: '',
- },
- /**
- * Used to show the commit short sha that links to the commit url.
- */
- shortSha: {
- type: String,
- required: false,
- default: '',
- },
- /**
- * If provided shows the commit tile.
- */
- title: {
- type: String,
- required: false,
- default: '',
- },
- /**
- * If provided renders information about the author of the commit.
- * When provided should include:
- * `avatar_url` to render the avatar icon
- * `web_url` to link to user profile
- * `username` to render alt and title tags
- */
- author: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- showBranch: {
- type: Boolean,
- required: false,
- default: true,
- },
+ /**
+ * Used to show the commit short sha that links to the commit url.
+ */
+ shortSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ /**
+ * If provided shows the commit tile.
+ */
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ /**
+ * If provided renders information about the author of the commit.
+ * When provided should include:
+ * `avatar_url` to render the avatar icon
+ * `web_url` to link to user profile
+ * `username` to render alt and title tags
+ */
+ author: {
+ type: Object,
+ 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.
- *
- * @returns {Boolean}
- */
- hasCommitRef() {
- return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
- },
- /**
- * Used to verify if all the properties needed to render the commit
- * author section were provided.
- *
- * @returns {Boolean}
- */
- hasAuthor() {
- return this.author &&
- this.author.avatar_url &&
- this.author.path &&
- this.author.username;
- },
- /**
- * If information about the author is provided will return a string
- * to be rendered as the alt attribute of the img tag.
- *
- * @returns {String}
- */
- userImageAltDescription() {
- return this.author &&
- this.author.username ? `${this.author.username}'s avatar` : null;
- },
+ },
+ computed: {
+ /**
+ * Used to verify if all the properties needed to render the commit
+ * ref section were provided.
+ *
+ * @returns {Boolean}
+ */
+ hasCommitRef() {
+ return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
},
- created() {
- this.commitIconSvg = commitIconSvg;
+ /**
+ * Used to verify if all the properties needed to render the commit
+ * author section were provided.
+ *
+ * @returns {Boolean}
+ */
+ hasAuthor() {
+ return this.author && this.author.avatar_url && this.author.path && this.author.username;
},
- };
+ /**
+ * If information about the author is provided will return a string
+ * to be rendered as the alt attribute of the img tag.
+ *
+ * @returns {String}
+ */
+ userImageAltDescription() {
+ return this.author && this.author.username ? `${this.author.username}'s avatar` : null;
+ },
+ },
+};
</script>
<template>
<div class="branch-commit">
@@ -141,11 +133,10 @@
{{ commitRef.name }}
</a>
</template>
- <div
- v-html="commitIconSvg"
+ <icon
+ name="commit"
class="commit-icon js-commit-icon"
- >
- </div>
+ />
<a
class="commit-sha"
@@ -175,7 +166,7 @@
</a>
</span>
<span v-else>
- Cant find HEAD commit for this branch
+ Can't find HEAD commit for this branch
</span>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
new file mode 100644
index 00000000000..4155e1bab9c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
@@ -0,0 +1,58 @@
+<script>
+import { viewerInformationForPath } from './lib/viewer_utils';
+import MarkdownViewer from './viewers/markdown_viewer.vue';
+import ImageViewer from './viewers/image_viewer.vue';
+import DownloadViewer from './viewers/download_viewer.vue';
+
+export default {
+ props: {
+ content: {
+ type: String,
+ default: '',
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ fileSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ projectPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ viewer() {
+ if (!this.path) return null;
+
+ const previewInfo = viewerInformationForPath(this.path);
+ if (!previewInfo) return DownloadViewer;
+
+ switch (previewInfo.id) {
+ case 'markdown':
+ return MarkdownViewer;
+ case 'image':
+ return ImageViewer;
+ default:
+ return DownloadViewer;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="preview-container">
+ <component
+ :is="viewer"
+ :path="path"
+ :file-size="fileSize"
+ :project-path="projectPath"
+ :content="content"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
new file mode 100644
index 00000000000..f01a51da0b3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
@@ -0,0 +1,32 @@
+const viewers = {
+ image: {
+ id: 'image',
+ },
+ markdown: {
+ id: 'markdown',
+ previewTitle: 'Preview Markdown',
+ },
+};
+
+const fileNameViewers = {};
+const fileExtensionViewers = {
+ jpg: 'image',
+ jpeg: 'image',
+ gif: 'image',
+ png: 'image',
+ bmp: 'image',
+ ico: 'image',
+ md: 'markdown',
+ markdown: 'markdown',
+};
+
+export function viewerInformationForPath(path) {
+ if (!path) return null;
+ const name = path.split('/').pop();
+ const viewerName =
+ fileNameViewers[name] || fileExtensionViewers[name ? name.split('.').pop() : ''] || '';
+
+ return viewers[viewerName];
+}
+
+export default viewers;
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
new file mode 100644
index 00000000000..395a71acccf
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
@@ -0,0 +1,52 @@
+<script>
+import Icon from '../../icon.vue';
+import { numberToHumanSize } from '../../../../lib/utils/number_utils';
+
+export default {
+ components: {
+ Icon,
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ fileSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ computed: {
+ fileSizeReadable() {
+ return numberToHumanSize(this.fileSize);
+ },
+ fileName() {
+ return this.path.split('/').pop();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="file-container">
+ <div class="file-content">
+ <p class="prepend-top-10 file-info">
+ {{ fileName }} ({{ fileSizeReadable }})
+ </p>
+ <a
+ :href="path"
+ class="btn btn-default"
+ rel="nofollow"
+ download
+ target="_blank">
+ <icon
+ name="download"
+ css-classes="pull-left append-right-8"
+ :size="16"
+ />
+ {{ __('Download') }}
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
new file mode 100644
index 00000000000..a5999f909ca
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
@@ -0,0 +1,68 @@
+<script>
+import { numberToHumanSize } from '../../../../lib/utils/number_utils';
+
+export default {
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ fileSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ data() {
+ return {
+ width: 0,
+ height: 0,
+ isZoomable: false,
+ isZoomed: false,
+ };
+ },
+ computed: {
+ fileSizeReadable() {
+ return numberToHumanSize(this.fileSize);
+ },
+ },
+ methods: {
+ onImgLoad() {
+ const contentImg = this.$refs.contentImg;
+ this.isZoomable =
+ contentImg.naturalWidth > contentImg.width || contentImg.naturalHeight > contentImg.height;
+
+ this.width = contentImg.naturalWidth;
+ this.height = contentImg.naturalHeight;
+ },
+ onImgClick() {
+ if (this.isZoomable) this.isZoomed = !this.isZoomed;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="file-container">
+ <div class="file-content image_file">
+ <img
+ ref="contentImg"
+ :class="{ 'isZoomable': isZoomable, 'isZoomed': isZoomed }"
+ :src="path"
+ :alt="path"
+ @load="onImgLoad"
+ @click="onImgClick"/>
+ <p class="file-info prepend-top-10">
+ <template v-if="fileSize>0">
+ {{ fileSizeReadable }}
+ </template>
+ <template v-if="fileSize>0 && width && height">
+ -
+ </template>
+ <template v-if="width && height">
+ {{ width }} x {{ height }}
+ </template>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
new file mode 100644
index 00000000000..09e0094054d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -0,0 +1,90 @@
+<script>
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import $ from 'jquery';
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+
+const CancelToken = axios.CancelToken;
+let axiosSource;
+
+export default {
+ components: {
+ SkeletonLoadingContainer,
+ },
+ props: {
+ content: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ previewContent: null,
+ isLoading: false,
+ };
+ },
+ watch: {
+ content() {
+ this.previewContent = null;
+ },
+ },
+ created() {
+ axiosSource = CancelToken.source();
+ this.fetchMarkdownPreview();
+ },
+ updated() {
+ this.fetchMarkdownPreview();
+ },
+ destroyed() {
+ if (this.isLoading) axiosSource.cancel('Cancelling Preview');
+ },
+ methods: {
+ fetchMarkdownPreview() {
+ if (this.content && this.previewContent === null) {
+ this.isLoading = true;
+ const postBody = {
+ text: this.content,
+ };
+ const postOptions = {
+ cancelToken: axiosSource.token,
+ };
+
+ axios
+ .post(
+ `${gon.relative_url_root}/${this.projectPath}/preview_markdown`,
+ postBody,
+ postOptions,
+ )
+ .then(({ data }) => {
+ this.previewContent = data.body;
+ this.isLoading = false;
+
+ this.$nextTick(() => {
+ $(this.$refs['markdown-preview']).renderGFM();
+ });
+ })
+ .catch(() => {
+ this.previewContent = __('An error occurred while fetching markdown preview');
+ this.isLoading = false;
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ ref="markdown-preview"
+ class="md md-previewer">
+ <skeleton-loading-container v-if="isLoading" />
+ <div
+ v-else
+ v-html="previewContent">
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue
index c943c8d98a4..9295be3e2b2 100644
--- a/app/assets/javascripts/vue_shared/components/expand_button.vue
+++ b/app/assets/javascripts/vue_shared/components/expand_button.vue
@@ -1,33 +1,33 @@
<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,
- };
+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');
},
- computed: {
- ariaLabel() {
- return __('Click to expand text');
- },
+ },
+ methods: {
+ onClick() {
+ this.isCollapsed = !this.isCollapsed;
},
- methods: {
- onClick() {
- this.isCollapsed = !this.isCollapsed;
- },
- },
- };
+ },
+};
</script>
<template>
<span>
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
index ee1c3498748..be2755452e2 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -1,9 +1,9 @@
<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';
+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
+/* This is a re-usable vue component for rendering a svg sprite
icon
Sample configuration:
@@ -15,60 +15,60 @@
/>
*/
- export default {
- components: {
- loadingIcon,
- icon,
+export default {
+ components: {
+ loadingIcon,
+ icon,
+ },
+ props: {
+ fileName: {
+ type: String,
+ required: true,
},
- props: {
- fileName: {
- type: String,
- required: true,
- },
- folder: {
- type: Boolean,
- required: false,
- default: false,
- },
+ folder: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
- opened: {
- type: Boolean,
- required: false,
- default: false,
- },
+ opened: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
- size: {
- type: Number,
- required: false,
- default: 16,
- },
+ size: {
+ type: Number,
+ required: false,
+ default: 16,
+ },
- cssClasses: {
- type: String,
- required: false,
- default: '',
- },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ spriteHref() {
+ const iconName = getIconForFile(this.fileName) || 'file';
+ return `${gon.sprite_file_icons}#${iconName}`;
+ },
+ folderIconName() {
+ return this.opened ? 'folder-open' : 'folder';
},
- computed: {
- spriteHref() {
- const iconName = getIconForFile(this.fileName) || 'file';
- return `${gon.sprite_file_icons}#${iconName}`;
- },
- folderIconName() {
- return this.opened ? 'folder-open' : 'folder';
- },
- iconSizeClass() {
- return this.size ? `s${this.size}` : '';
- },
+ iconSizeClass() {
+ return this.size ? `s${this.size}` : '';
},
- };
+ },
+};
</script>
<template>
<span>
@@ -82,6 +82,7 @@
v-if="!loading && folder"
:name="folderIconName"
:size="size"
+ css-classes="folder-icon"
/>
<loading-icon
v-if="loading"
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue
index 67c9181c7b1..f28e5e2715d 100644
--- a/app/assets/javascripts/vue_shared/components/gl_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue
@@ -1,47 +1,42 @@
<script>
- const buttonVariants = [
- 'danger',
- 'primary',
- 'success',
- 'warning',
- ];
+const buttonVariants = ['danger', 'primary', 'success', 'warning'];
- export default {
- name: 'GlModal',
+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: '',
- },
+ props: {
+ id: {
+ type: String,
+ required: false,
+ default: null,
},
+ headerTitleText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ footerPrimaryButtonVariant: {
+ type: String,
+ required: false,
+ default: 'primary',
+ validator: value => buttonVariants.includes(value),
+ },
+ footerPrimaryButtonText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
- methods: {
- emitCancel(event) {
- this.$emit('cancel', event);
- },
- emitSubmit(event) {
- this.$emit('submit', event);
- },
+ methods: {
+ emitCancel(event) {
+ this.$emit('cancel', event);
+ },
+ emitSubmit(event) {
+ this.$emit('submit', event);
},
- };
+ },
+};
</script>
<template>
@@ -60,7 +55,7 @@
<slot name="header">
<button
type="button"
- class="close"
+ class="close js-modal-close-action"
data-dismiss="modal"
:aria-label="s__('Modal|Close')"
@click="emitCancel($event)"
@@ -83,7 +78,7 @@
<slot name="footer">
<button
type="button"
- class="btn"
+ class="btn js-modal-cancel-action"
data-dismiss="modal"
@click="emitCancel($event)"
>
@@ -91,7 +86,7 @@
</button>
<button
type="button"
- class="btn"
+ class="btn js-modal-primary-action"
:class="`btn-${footerPrimaryButtonVariant}`"
data-dismiss="modal"
@click="emitSubmit($event)"
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 a0cd0cbd200..088187ed348 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -1,78 +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';
+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,
+/**
+ * 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,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ status: {
+ type: Object,
+ required: true,
},
- directives: {
- tooltip,
+ itemName: {
+ type: String,
+ required: true,
},
- 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,
- },
+ 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,
+ },
+ },
- 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>
diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue
index 6a2e05000e1..1a0df49bc29 100644
--- a/app/assets/javascripts/vue_shared/components/icon.vue
+++ b/app/assets/javascripts/vue_shared/components/icon.vue
@@ -1,76 +1,75 @@
<script>
+/* This is a re-usable vue component for rendering a svg sprite
+ icon
- /* This is a re-usable vue component for rendering a svg sprite
- icon
+ Sample configuration:
- Sample configuration:
+ <icon
+ name="retry"
+ :size="32"
+ css-classes="top"
+ />
- <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];
- */
- // 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,
- },
+export default {
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
- size: {
- type: Number,
- required: false,
- default: 16,
- validator(value) {
- return validSizes.includes(value);
- },
+ size: {
+ type: Number,
+ required: false,
+ default: 16,
+ validator(value) {
+ return validSizes.includes(value);
},
+ },
- cssClasses: {
- type: String,
- required: false,
- default: '',
- },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
- width: {
- type: Number,
- required: false,
- default: null,
- },
+ width: {
+ type: Number,
+ required: false,
+ default: null,
+ },
- height: {
- type: Number,
- required: false,
- default: null,
- },
+ height: {
+ type: Number,
+ required: false,
+ default: null,
+ },
- y: {
- type: Number,
- required: false,
- default: null,
- },
+ y: {
+ type: Number,
+ required: false,
+ default: null,
+ },
- x: {
- 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}` : '';
- },
+ computed: {
+ spriteHref() {
+ return `${gon.sprite_icons}#${this.name}`;
+ },
+ iconSizeClass() {
+ return this.size ? `s${this.size}` : '';
},
- };
+ },
+};
</script>
<template>
@@ -79,7 +78,8 @@
:width="width"
:height="height"
:x="x"
- :y="y">
+ :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 0a30f467b08..23010f40f26 100644
--- a/app/assets/javascripts/vue_shared/components/identicon.vue
+++ b/app/assets/javascripts/vue_shared/components/identicon.vue
@@ -17,7 +17,7 @@ export default {
},
computed: {
/**
- * This method is based on app/helpers/application_helper.rb#project_identicon
+ * This method is based on app/helpers/avatars_helper.rb#project_identicon
*/
identiconStyles() {
const allowedColors = [
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index d91fe3cf0c5..db453c30576 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -27,20 +27,22 @@
$(document).off('markdown-preview:hide.vue', this.writeMarkdownTab);
},
methods: {
- isMarkdownForm(form) {
- return form && !form.find('.js-vue-markdown-field').length;
+ isValid(form) {
+ return !form ||
+ form.find('.js-vue-markdown-field').length ||
+ $(this.$el).closest('form') === form[0];
},
previewMarkdownTab(event, form) {
if (event.target.blur) event.target.blur();
- if (this.isMarkdownForm(form)) return;
+ if (!this.isValid(form)) return;
this.$emit('preview-markdown');
},
writeMarkdownTab(event, form) {
if (event.target.blur) event.target.blur();
- if (this.isMarkdownForm(form)) return;
+ if (!this.isValid(form)) return;
this.$emit('write-markdown');
},
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
index 5ede53d8d01..70b46a9c2bb 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
@@ -1,4 +1,5 @@
<script>
+import $ from 'jquery';
import { __ } from '~/locale';
import LabelsSelect from '~/labels_select';
import LoadingIcon from '../../loading_icon.vue';
@@ -98,11 +99,18 @@ export default {
this.labelsDropdown = new LabelsSelect(this.$refs.dropdownButton, {
handleClick: this.handleClick,
});
+ $(this.$refs.dropdown).on('hidden.gl.dropdown', this.handleDropdownHidden);
},
methods: {
handleClick(label) {
this.$emit('onLabelClick', label);
},
+ handleCollapsedValueClick() {
+ this.$emit('toggleCollapse');
+ },
+ handleDropdownHidden() {
+ this.$emit('onDropdownClose');
+ },
},
};
</script>
@@ -112,6 +120,7 @@ export default {
<dropdown-value-collapsed
v-if="showCreate"
:labels="context.labels"
+ @onValueClick="handleCollapsedValueClick"
/>
<dropdown-title
:can-edit="canEdit"
@@ -133,7 +142,10 @@ export default {
:name="hiddenInputName"
:label="label"
/>
- <div class="dropdown">
+ <div
+ class="dropdown"
+ ref="dropdown"
+ >
<dropdown-button
:ability-name="abilityName"
:field-name="hiddenInputName"
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
index 5cf728fe050..68fa2ab8d01 100644
--- 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
@@ -26,6 +26,11 @@ export default {
return labelsString;
},
},
+ methods: {
+ handleClick() {
+ this.$emit('onValueClick');
+ },
+ },
};
</script>
@@ -36,6 +41,7 @@ export default {
data-placement="left"
data-container="body"
:title="labelsList"
+ @click="handleClick"
>
<i
aria-hidden="true"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
index 8211d425b1f..de6f8c32e74 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
@@ -1,18 +1,29 @@
<script>
- export default {
- name: 'ToggleSidebar',
- props: {
- collapsed: {
- type: Boolean,
- required: true,
- },
+import { __ } from '~/locale';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ name: 'ToggleSidebar',
+ directives: {
+ tooltip,
+ },
+ props: {
+ collapsed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ tooltipLabel() {
+ return this.collapsed ? __('Expand sidebar') : __('Collapse sidebar');
},
- methods: {
- toggle() {
- this.$emit('toggle');
- },
+ },
+ methods: {
+ toggle() {
+ this.$emit('toggle');
},
- };
+ },
+};
</script>
<template>
@@ -20,6 +31,10 @@
type="button"
class="btn btn-blank gutter-toggle btn-sidebar-action"
@click="toggle"
+ v-tooltip
+ data-container="body"
+ data-placement="left"
+ :title="tooltipLabel"
>
<i
aria-label="toggle collapse"
diff --git a/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue b/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue
index b06493e6c66..16304e4815d 100644
--- a/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue
+++ b/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue
@@ -9,7 +9,7 @@
lines: {
type: Number,
required: false,
- default: 6,
+ default: 3,
},
},
computed: {
diff --git a/app/assets/javascripts/vue_shared/models/label.js b/app/assets/javascripts/vue_shared/models/label.js
index 70b9efe0c68..d29c7fe973a 100644
--- a/app/assets/javascripts/vue_shared/models/label.js
+++ b/app/assets/javascripts/vue_shared/models/label.js
@@ -1,4 +1,4 @@
-class ListLabel {
+export default class ListLabel {
constructor(obj) {
this.id = obj.id;
this.title = obj.title;
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 0665622fe4a..f2950308019 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -37,7 +37,11 @@
/*
* Code highlight
*/
-@import "highlight/**/*";
+@import "highlight/dark";
+@import "highlight/monokai";
+@import "highlight/solarized_dark";
+@import "highlight/solarized_light";
+@import "highlight/white";
/*
* Styles for JS behaviors.
diff --git a/app/assets/stylesheets/emoji_sprites.scss b/app/assets/stylesheets/emoji_sprites.scss
new file mode 100644
index 00000000000..8f6134c474b
--- /dev/null
+++ b/app/assets/stylesheets/emoji_sprites.scss
@@ -0,0 +1,5403 @@
+// Automatic Prettier Formatting for this big file
+// scss-lint:disable EmptyLineBetweenBlocks
+.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.scss b/app/assets/stylesheets/framework.scss
index 2fccfa4011c..360dcb6afef 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -1,64 +1,63 @@
-@import "framework/variables";
-@import "framework/mixins";
+@import 'framework/variables';
+@import 'framework/mixins';
@import 'framework/tw_bootstrap_variables';
@import 'framework/tw_bootstrap';
-@import "framework/layout";
+@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";
-@import "framework/calendar";
-@import "framework/callout";
-@import "framework/common";
-@import "framework/dropdowns";
-@import "framework/files";
-@import "framework/filters";
-@import "framework/flash";
-@import "framework/forms";
-@import "framework/gfm";
-@import "framework/gitlab_theme";
-@import "framework/header";
-@import "framework/highlight";
-@import "framework/issue_box";
-@import "framework/jquery";
-@import "framework/lists";
-@import "framework/logo";
-@import "framework/markdown_area";
-@import "framework/media_object";
-@import "framework/mobile";
-@import "framework/modal";
-@import "framework/pagination";
-@import "framework/panels";
-@import "framework/popup";
-@import "framework/secondary_navigation_elements";
-@import "framework/selects";
-@import "framework/sidebar";
-@import "framework/contextual_sidebar";
-@import "framework/tables";
-@import "framework/notes";
-@import "framework/tabs";
-@import "framework/timeline";
-@import "framework/tooltips";
-@import "framework/toggle";
-@import "framework/typography";
-@import "framework/zen";
-@import "framework/blank";
-@import "framework/wells";
-@import "framework/page_header";
-@import "framework/awards";
-@import "framework/images";
-@import "framework/broadcast_messages";
-@import "framework/emojis";
-@import "framework/emoji_sprites";
-@import "framework/icons";
-@import "framework/snippets";
-@import "framework/memory_graph";
-@import "framework/responsive_tables";
-@import "framework/stacked_progress_bar";
-@import "framework/ci_variable_list";
-@import "framework/feature_highlight";
+@import 'framework/animations';
+@import 'framework/vue_transitions';
+@import 'framework/avatar';
+@import 'framework/asciidoctor';
+@import 'framework/banner';
+@import 'framework/blocks';
+@import 'framework/buttons';
+@import 'framework/badges';
+@import 'framework/calendar';
+@import 'framework/callout';
+@import 'framework/common';
+@import 'framework/dropdowns';
+@import 'framework/files';
+@import 'framework/filters';
+@import 'framework/flash';
+@import 'framework/forms';
+@import 'framework/gfm';
+@import 'framework/gitlab_theme';
+@import 'framework/header';
+@import 'framework/highlight';
+@import 'framework/issue_box';
+@import 'framework/jquery';
+@import 'framework/lists';
+@import 'framework/logo';
+@import 'framework/markdown_area';
+@import 'framework/media_object';
+@import 'framework/mobile';
+@import 'framework/modal';
+@import 'framework/pagination';
+@import 'framework/panels';
+@import 'framework/popup';
+@import 'framework/secondary_navigation_elements';
+@import 'framework/selects';
+@import 'framework/sidebar';
+@import 'framework/contextual_sidebar';
+@import 'framework/tables';
+@import 'framework/notes';
+@import 'framework/tabs';
+@import 'framework/timeline';
+@import 'framework/tooltips';
+@import 'framework/toggle';
+@import 'framework/typography';
+@import 'framework/zen';
+@import 'framework/blank';
+@import 'framework/wells';
+@import 'framework/page_header';
+@import 'framework/awards';
+@import 'framework/images';
+@import 'framework/broadcast_messages';
+@import 'framework/emojis';
+@import 'framework/icons';
+@import 'framework/snippets';
+@import 'framework/memory_graph';
+@import 'framework/responsive_tables';
+@import 'framework/stacked_progress_bar';
+@import 'framework/ci_variable_list';
+@import 'framework/feature_highlight';
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 728f9a27aca..14cd32da9eb 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -187,12 +187,9 @@ a {
animation: fadeInFull $fade-in-duration 1;
}
-
.animation-container {
- background: $repo-editor-grey;
height: 40px;
overflow: hidden;
- position: relative;
&.animation-container-small {
height: 12px;
@@ -205,60 +202,43 @@ a {
}
}
- &::before {
- animation-duration: 1s;
- animation-fill-mode: forwards;
- animation-iteration-count: infinite;
- animation-name: blockTextShine;
- animation-timing-function: linear;
- background-image: $repo-editor-linear-gradient;
- background-repeat: no-repeat;
- background-size: 800px 45px;
- content: ' ';
- display: block;
- height: 100%;
+ [class^="skeleton-line-"] {
position: relative;
- }
-
- div {
- background: $white-light;
- height: 6px;
- left: 0;
- position: absolute;
- right: 0;
- }
-
- .skeleton-line-1 {
- left: 0;
- top: 8px;
- }
-
- .skeleton-line-2 {
- left: 150px;
- top: 0;
+ background-color: $theme-gray-100;
height: 10px;
- }
+ overflow: hidden;
- .skeleton-line-3 {
- left: 0;
- top: 23px;
- }
+ &:not(:last-of-type) {
+ margin-bottom: 4px;
+ }
- .skeleton-line-4 {
- left: 0;
- top: 38px;
+ &::after {
+ content: ' ';
+ display: block;
+ animation: blockTextShine 1s linear infinite forwards;
+ background-repeat: no-repeat;
+ background-size: cover;
+ background-image: linear-gradient(
+ to right,
+ $theme-gray-100 0%,
+ $theme-gray-50 20%,
+ $theme-gray-100 40%,
+ $theme-gray-100 100%
+ );
+ height: 10px;
+ }
}
+}
- .skeleton-line-5 {
- left: 200px;
- top: 28px;
- height: 10px;
- }
+$skeleton-line-widths: (
+ 156px,
+ 235px,
+ 200px,
+);
- .skeleton-line-6 {
- top: 14px;
- left: 230px;
- height: 10px;
+@for $count from 1 through length($skeleton-line-widths) {
+ .skeleton-line-#{$count} {
+ width: nth($skeleton-line-widths, $count);
}
}
diff --git a/app/assets/stylesheets/framework/banner.scss b/app/assets/stylesheets/framework/banner.scss
index 6433b0c7855..02f3896d591 100644
--- a/app/assets/stylesheets/framework/banner.scss
+++ b/app/assets/stylesheets/framework/banner.scss
@@ -1,7 +1,7 @@
.banner-callout {
display: flex;
position: relative;
- flex-wrap: wrap;
+ align-items: start;
.banner-close {
position: absolute;
@@ -16,10 +16,25 @@
}
.banner-graphic {
- margin: 20px auto;
+ margin: 0 $gl-padding $gl-padding 0;
}
&.banner-non-empty-state {
border-bottom: 1px solid $border-color;
}
+
+ @media (max-width: $screen-xs-max) {
+ justify-content: center;
+ flex-direction: column;
+ align-items: center;
+
+ .banner-title,
+ .banner-buttons {
+ text-align: center;
+ }
+
+ .banner-graphic {
+ margin-left: $gl-padding;
+ }
+ }
}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index c5c7afe25be..c60f65e7a85 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -46,7 +46,7 @@
}
&.middle-block {
- margin-top: 0;
+ margin-top: $gl-padding-24;
margin-bottom: 0;
}
@@ -61,7 +61,7 @@
}
&.footer-block {
- margin-top: 0;
+ margin-top: $gl-padding-24;
border-bottom: 0;
margin-bottom: -$gl-padding;
}
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 6b89387ab5f..f4f5926e198 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -422,25 +422,43 @@
}
}
-.btn-link.btn-secondary-hover-link {
- color: $gl-text-color-secondary;
+.btn-link {
+ padding: 0;
+ background-color: transparent;
+ color: $blue-600;
+ font-weight: normal;
+ border-radius: 0;
+ border-color: transparent;
&:hover,
&:active,
&:focus {
- color: $gl-link-color;
- text-decoration: none;
+ color: $blue-800;
+ text-decoration: underline;
+ background-color: transparent;
+ border-color: transparent;
}
-}
-.btn-link.btn-primary-hover-link {
- color: inherit;
+ &.btn-secondary-hover-link {
+ color: $gl-text-color-secondary;
- &:hover,
- &:active,
- &:focus {
- color: $gl-link-color;
- text-decoration: none;
+ &:hover,
+ &:active,
+ &:focus {
+ color: $gl-link-color;
+ text-decoration: none;
+ }
+ }
+
+ &.btn-primary-hover-link {
+ color: inherit;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $gl-link-color;
+ text-decoration: none;
+ }
}
}
@@ -485,3 +503,7 @@ fieldset[disabled] .btn,
@extend %disabled;
}
}
+
+.btn-no-padding {
+ padding: 0;
+}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index d0dda50a835..2faea55a5f5 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -452,6 +452,7 @@ img.emoji {
/** COMMON CLASSES **/
.prepend-top-0 { margin-top: 0; }
+.prepend-top-2 { margin-top: 2px; }
.prepend-top-5 { margin-top: 5px; }
.prepend-top-8 { margin-top: $grid-size; }
.prepend-top-10 { margin-top: 10px; }
@@ -472,6 +473,7 @@ img.emoji {
.append-right-20 { margin-right: 20px; }
.append-bottom-0 { margin-bottom: 0; }
.append-bottom-5 { margin-bottom: 5px; }
+.append-bottom-8 { margin-bottom: $grid-size; }
.append-bottom-10 { margin-bottom: 10px; }
.append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; }
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index cc74cb72795..664aade7375 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -43,7 +43,7 @@
border-color: $gray-darkest;
}
- [data-toggle="dropdown"] {
+ [data-toggle='dropdown'] {
outline: 0;
}
}
@@ -172,7 +172,11 @@
color: $brand-danger;
}
- &:hover,
+ &.disable-hover {
+ text-decoration: none;
+ }
+
+ &:not(.disable-hover):hover,
&:active,
&:focus,
&.is-focused {
@@ -481,7 +485,8 @@
.dropdown-menu-selectable {
li {
- a {
+ a,
+ button {
padding: 8px 40px;
position: relative;
@@ -507,17 +512,16 @@
}
&.is-indeterminate::before {
- content: "\f068";
+ content: '\f068';
}
&.is-active::before {
- content: "\f00c";
+ content: '\f00c';
}
}
}
}
-
.dropdown-title {
position: relative;
padding: 2px 25px 10px;
@@ -723,7 +727,6 @@
}
}
-
.dropdown-menu-due-date {
.dropdown-content {
max-height: 230px;
@@ -853,9 +856,13 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
}
.projects-list-frequent-container,
- .projects-list-search-container, {
+ .projects-list-search-container {
padding: 8px 0;
overflow-y: auto;
+
+ li.section-empty.section-failure {
+ color: $callout-danger-color;
+ }
}
.section-header,
@@ -866,13 +873,6 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
font-size: $gl-font-size;
}
- .projects-list-frequent-container,
- .projects-list-search-container {
- li.section-empty.section-failure {
- color: $callout-danger-color;
- }
- }
-
.search-input-container {
position: relative;
padding: 4px $gl-padding;
@@ -904,8 +904,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
}
.projects-list-item-container {
- .project-item-avatar-container
- .project-item-metadata-container {
+ .project-item-avatar-container .project-item-metadata-container {
float: left;
}
diff --git a/app/assets/stylesheets/framework/emoji_sprites.scss b/app/assets/stylesheets/framework/emoji_sprites.scss
deleted file mode 100644
index 0174e17b660..00000000000
--- a/app/assets/stylesheets/framework/emoji_sprites.scss
+++ /dev/null
@@ -1,1813 +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-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/images.scss b/app/assets/stylesheets/framework/images.scss
index df1cafc9f8e..ab3cceceae9 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -20,7 +20,7 @@
width: 100%;
}
- $image-widths: 80 250 306 394 430;
+ $image-widths: 80 130 250 306 394 430;
@each $width in $image-widths {
&.svg-#{$width} {
img,
@@ -39,35 +39,10 @@
svg {
fill: currentColor;
- &.s8 {
- @include svg-size(8px);
- }
-
- &.s12 {
- @include svg-size(12px);
- }
-
- &.s16 {
- @include svg-size(16px);
- }
-
- &.s18 {
- @include svg-size(18px);
- }
-
- &.s24 {
- @include svg-size(24px);
- }
-
- &.s32 {
- @include svg-size(32px);
- }
-
- &.s48 {
- @include svg-size(48px);
- }
-
- &.s72 {
- @include svg-size(72px);
+ $svg-sizes: 8 12 16 18 24 32 48 72;
+ @each $svg-size in $svg-sizes {
+ &.s#{$svg-size} {
+ @include svg-size(#{$svg-size}px);
+ }
}
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 7e829826eba..f1a8a46dda4 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -24,6 +24,10 @@
color: $list-text-disabled-color;
}
+ &:not(.ui-sort-disabled):hover {
+ background: $row-hover;
+ }
+
&.unstyled {
&:hover {
background: none;
@@ -34,14 +38,15 @@
background-color: $list-warning-row-bg;
border-color: $list-warning-row-border;
color: $list-warning-row-color;
- }
- &.smoke { background-color: $gray-light; }
+ &:hover {
+ background: $list-warning-row-bg;
+ }
- &:not(.ui-sort-disabled):hover {
- background: $row-hover;
}
+ &.smoke { background-color: $gray-light; }
+
&:last-child {
border-bottom: 0;
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 938f5f49c09..7b5d1c2cf8b 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -107,6 +107,16 @@
padding-top: 10px;
}
+.referenced-commands {
+ background: $blue-50;
+ padding: $gl-padding-8 $gl-padding;
+ border-radius: $border-radius-default;
+
+ p {
+ margin: 0;
+ }
+}
+
.md-preview-holder {
min-height: 167px;
padding: 10px 0;
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
index 8604e753c18..9e03bb98b8e 100644
--- a/app/assets/stylesheets/framework/mobile.scss
+++ b/app/assets/stylesheets/framework/mobile.scss
@@ -40,10 +40,6 @@
.project-home-panel {
padding-left: 0 !important;
- .project-avatar {
- display: block;
- }
-
.project-repo-buttons,
.git-clone-holder {
display: none;
diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss
index 7829d722560..34fccf6f0a4 100644
--- a/app/assets/stylesheets/framework/responsive_tables.scss
+++ b/app/assets/stylesheets/framework/responsive_tables.scss
@@ -39,7 +39,7 @@
.table-section {
white-space: nowrap;
- $section-widths: 10 15 20 25 30 40 100;
+ $section-widths: 10 15 20 25 30 40 50 100;
@each $width in $section-widths {
&.section-#{$width} {
flex: 0 0 #{$width + '%'};
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 17c31d6b184..66dbe403385 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -241,8 +241,6 @@
}
.scrolling-tabs-container {
- position: relative;
-
.merge-request-tabs-container & {
overflow: hidden;
}
@@ -272,8 +270,6 @@
}
.inner-page-scroll-tabs {
- position: relative;
-
.fade-right {
@include fade(left, $white-light);
right: 0;
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 798f248dad4..64fff7463d2 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -16,7 +16,7 @@
.nav-header-btn {
padding: 10px $gl-sidebar-padding;
color: inherit;
- transition-duration: .3s;
+ transition-duration: 0.3s;
position: absolute;
top: 0;
cursor: pointer;
@@ -137,6 +137,12 @@
}
}
+.issuable-sidebar .labels {
+ .value.dont-hide ~ .selectbox {
+ padding-top: $gl-padding-8;
+ }
+}
+
.pikaday-container {
.pika-single {
margin-top: 2px;
@@ -151,4 +157,3 @@
.sidebar-collapsed-icon .sidebar-collapsed-value {
font-size: 12px;
}
-
diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss
index 30c15c231d5..606d4675f19 100644
--- a/app/assets/stylesheets/framework/snippets.scss
+++ b/app/assets/stylesheets/framework/snippets.scss
@@ -29,8 +29,10 @@
}
.snippet-title {
- font-size: 24px;
+ color: $gl-text-color;
+ font-size: 2em;
font-weight: $gl-font-weight-bold;
+ min-height: $header-height;
}
.snippet-edited-ago {
@@ -46,3 +48,26 @@
.snippet-scope-menu .btn-new {
margin-top: 15px;
}
+
+.snippet-embed-input {
+ height: 35px;
+}
+
+.embed-snippet {
+ padding-right: 0;
+ padding-top: $gl-padding;
+
+ .form-control {
+ cursor: auto;
+ width: 101%;
+ margin-left: -1px;
+ }
+
+ .embed-toggle-list li button {
+ padding: 8px 40px;
+ }
+
+ .embed-toggle {
+ height: 35px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 294c59f037f..9e1371648ed 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -289,6 +289,11 @@ body {
&:last-child {
margin-bottom: 0;
}
+
+ &.with-button {
+ line-height: 34px;
+ }
+
}
.page-title-empty {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index a81904d5338..3d28df455bb 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -212,6 +212,7 @@ $tooltip-font-size: 12px;
/*
* Padding
*/
+$gl-padding-24: 24px;
$gl-padding: 16px;
$gl-padding-8: 8px;
$gl-padding-4: 4px;
@@ -247,6 +248,7 @@ $btn-sm-side-margin: 7px;
$btn-xs-side-margin: 5px;
$issue-status-expired: $orange-500;
$issuable-sidebar-color: $gl-text-color-secondary;
+$sidebar-block-hover-color: #ebebeb;
$group-path-color: #999;
$namespace-kind-color: #aaa;
$panel-heading-link-color: #777;
@@ -373,6 +375,8 @@ $dropdown-hover-color: $blue-400;
$link-active-background: rgba(0, 0, 0, 0.04);
$link-hover-background: rgba(0, 0, 0, 0.06);
$inactive-badge-background: rgba(0, 0, 0, 0.08);
+$sidebar-toggle-height: 60px;
+$sidebar-milestone-toggle-bottom-margin: 10px;
/*
* Buttons
@@ -714,20 +718,6 @@ $color-average-score: $orange-400;
$color-low-score: $red-400;
/*
-Repo editor
-*/
-$repo-editor-grey: #f6f7f9;
-$repo-editor-grey-darker: #e9ebee;
-$repo-editor-linear-gradient: linear-gradient(
- to right,
- $repo-editor-grey 0%,
- $repo-editor-grey-darker,
- 20%,
- $repo-editor-grey 40%,
- $repo-editor-grey 100%
-);
-
-/*
Performance Bar
*/
$perf-bar-text: #999;
@@ -767,3 +757,8 @@ $border-color-settings: #e1e1e1;
Modals
*/
$modal-body-height: 134px;
+
+/*
+Prometheus
+*/
+$prometheus-table-row-highlight-color: $theme-gray-100;
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index 2f3a80daa90..3fa7a260017 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -19,6 +19,7 @@
.fork-svg {
margin-right: 4px;
+ vertical-align: bottom;
}
}
diff --git a/app/assets/stylesheets/highlight/embedded.scss b/app/assets/stylesheets/highlight/embedded.scss
new file mode 100644
index 00000000000..44c8a1d39ec
--- /dev/null
+++ b/app/assets/stylesheets/highlight/embedded.scss
@@ -0,0 +1,3 @@
+.code {
+ @import "white_base";
+}
diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss
index c3d8f0c61a2..355c8d223f7 100644
--- a/app/assets/stylesheets/highlight/white.scss
+++ b/app/assets/stylesheets/highlight/white.scss
@@ -1,292 +1,3 @@
-/* https://github.com/aahan/pygments-github-style */
-
-/*
-* White Syntax Colors
-*/
-$white-code-color: $gl-text-color;
-$white-highlight: #fafe3d;
-$white-pre-hll-bg: #f8eec7;
-$white-hll-bg: #f8f8f8;
-$white-over-bg: #ded7fc;
-$white-expanded-border: #e0e0e0;
-$white-expanded-bg: #f7f7f7;
-$white-c: #998;
-$white-err: #a61717;
-$white-err-bg: #e3d2d2;
-$white-cm: #998;
-$white-cp: #999;
-$white-c1: #998;
-$white-cs: #999;
-$white-gd: $black;
-$white-gd-bg: #fdd;
-$white-gd-x: $black;
-$white-gd-x-bg: #faa;
-$white-gr: #a00;
-$white-gh: #999;
-$white-gi: $black;
-$white-gi-bg: #dfd;
-$white-gi-x: $black;
-$white-gi-x-bg: #afa;
-$white-go: #888;
-$white-gp: #555;
-$white-gu: #800080;
-$white-gt: #a00;
-$white-kt: #458;
-$white-m: #099;
-$white-s: #d14;
-$white-n: #333;
-$white-na: teal;
-$white-nb: #0086b3;
-$white-nc: #458;
-$white-no: teal;
-$white-ni: purple;
-$white-ne: #900;
-$white-nf: #900;
-$white-nn: #555;
-$white-nt: navy;
-$white-nv: teal;
-$white-w: #bbb;
-$white-mf: #099;
-$white-mh: #099;
-$white-mi: #099;
-$white-mo: #099;
-$white-sb: #d14;
-$white-sc: #d14;
-$white-sd: #d14;
-$white-s2: #d14;
-$white-se: #d14;
-$white-sh: #d14;
-$white-si: #d14;
-$white-sx: #d14;
-$white-sr: #009926;
-$white-s1: #d14;
-$white-ss: #990073;
-$white-bp: #999;
-$white-vc: teal;
-$white-vg: teal;
-$white-vi: teal;
-$white-il: #099;
-$white-gc-color: #999;
-$white-gc-bg: #eaf2f5;
-
-
-@mixin matchLine {
- color: $black-transparent;
- background-color: $gray-light;
-}
-
.code.white {
- // Line numbers
- .line-numbers,
- .diff-line-num {
- background-color: $gray-light;
- }
-
- .diff-line-num,
- .diff-line-num a {
- color: $black-transparent;
- }
-
- // Code itself
- pre.code,
- .diff-line-num {
- border-color: $white-normal;
- }
-
- &,
- pre.code,
- .line_holder .line_content {
- background-color: $white-light;
- color: $white-code-color;
- }
-
- // Diff line
- .line_holder {
-
- &.match .line_content {
- @include matchLine;
- }
-
- .diff-line-num {
- &.old {
- background-color: $line-number-old;
- border-color: $line-removed-dark;
-
- a {
- color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
- }
- }
-
- &.new {
- background-color: $line-number-new;
- border-color: $line-added-dark;
-
- a {
- color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
- }
- }
-
- &.is-over,
- &.hll:not(.empty-cell).is-over {
- background-color: $white-over-bg;
- border-color: darken($white-over-bg, 5%);
-
- a {
- color: darken($white-over-bg, 15%);
- }
- }
-
- &.hll:not(.empty-cell) {
- background-color: $line-number-select;
- border-color: $line-select-yellow-dark;
- }
- }
-
- &:not(.diff-expanded) + .diff-expanded,
- &.diff-expanded + .line_holder:not(.diff-expanded) {
- > .diff-line-num,
- > .line_content {
- border-top: 1px solid $white-expanded-border;
- }
- }
-
- &.diff-expanded {
- > .diff-line-num,
- > .line_content {
- background: $white-expanded-bg;
- border-color: $white-expanded-bg;
- }
- }
-
- .line_content {
- &.old {
- background-color: $line-removed;
-
- &::before {
- color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
- }
-
- span.idiff {
- background-color: $line-removed-dark;
- }
- }
-
- &.new {
- background-color: $line-added;
-
- &::before {
- color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
- }
-
- span.idiff {
- background-color: $line-added-dark;
- }
- }
-
- &.match {
- @include matchLine;
- }
-
- &.hll:not(.empty-cell) {
- background-color: $line-select-yellow;
- }
- }
- }
-
- // highlight line via anchor
- pre .hll {
- background-color: $white-pre-hll-bg !important;
- }
-
- // Search result highlight
- span.highlight_word {
- background-color: $white-highlight !important;
- }
-
- // Links to URLs, emails, or dependencies
- .line a {
- color: $white-nb;
- }
-
- .hll { background-color: $white-hll-bg; }
- .c { color: $white-c; font-style: italic; }
- .err { color: $white-err; background-color: $white-err-bg; }
- .k { font-weight: $gl-font-weight-bold; }
- .o { font-weight: $gl-font-weight-bold; }
- .cm { color: $white-cm; font-style: italic; }
- .cp { color: $white-cp; font-weight: $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;
-
- .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;
-
- .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; }
- .gu { color: $white-gu; font-weight: $gl-font-weight-bold; }
- .gt { color: $white-gt; }
- .kc { font-weight: $gl-font-weight-bold; }
- .kd { font-weight: $gl-font-weight-bold; }
- .kn { font-weight: $gl-font-weight-bold; }
- .kp { font-weight: $gl-font-weight-bold; }
- .kr { font-weight: $gl-font-weight-bold; }
- .kt { color: $white-kt; font-weight: $gl-font-weight-bold; }
- .m { color: $white-m; }
- .s { color: $white-s; }
- .n { color: $white-n; }
- .na { color: $white-na; }
- .nb { color: $white-nb; }
- .nc { color: $white-nc; font-weight: $gl-font-weight-bold; }
- .no { color: $white-no; }
- .ni { color: $white-ni; }
- .ne { color: $white-ne; font-weight: $gl-font-weight-bold; }
- .nf { color: $white-nf; font-weight: $gl-font-weight-bold; }
- .nn { color: $white-nn; }
- .nt { color: $white-nt; }
- .nv { color: $white-nv; }
- .ow { font-weight: $gl-font-weight-bold; }
- .w { color: $white-w; }
- .mf { color: $white-mf; }
- .mh { color: $white-mh; }
- .mi { color: $white-mi; }
- .mo { color: $white-mo; }
- .sb { color: $white-sb; }
- .sc { color: $white-sc; }
- .sd { color: $white-sd; }
- .s2 { color: $white-s2; }
- .se { color: $white-se; }
- .sh { color: $white-sh; }
- .si { color: $white-si; }
- .sx { color: $white-sx; }
- .sr { color: $white-sr; }
- .s1 { color: $white-s1; }
- .ss { color: $white-ss; }
- .bp { color: $white-bp; }
- .vc { color: $white-vc; }
- .vg { color: $white-vg; }
- .vi { color: $white-vi; }
- .il { color: $white-il; }
- .gc { color: $white-gc-color; background-color: $white-gc-bg; }
+ @import "white_base";
}
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
new file mode 100644
index 00000000000..8cc5252648d
--- /dev/null
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -0,0 +1,290 @@
+/* https://github.com/aahan/pygments-github-style */
+
+/*
+* White Syntax Colors
+*/
+$white-code-color: $gl-text-color;
+$white-highlight: #fafe3d;
+$white-pre-hll-bg: #f8eec7;
+$white-hll-bg: #f8f8f8;
+$white-over-bg: #ded7fc;
+$white-expanded-border: #e0e0e0;
+$white-expanded-bg: #f7f7f7;
+$white-c: #998;
+$white-err: #a61717;
+$white-err-bg: #e3d2d2;
+$white-cm: #998;
+$white-cp: #999;
+$white-c1: #998;
+$white-cs: #999;
+$white-gd: $black;
+$white-gd-bg: #fdd;
+$white-gd-x: $black;
+$white-gd-x-bg: #faa;
+$white-gr: #a00;
+$white-gh: #999;
+$white-gi: $black;
+$white-gi-bg: #dfd;
+$white-gi-x: $black;
+$white-gi-x-bg: #afa;
+$white-go: #888;
+$white-gp: #555;
+$white-gu: #800080;
+$white-gt: #a00;
+$white-kt: #458;
+$white-m: #099;
+$white-s: #d14;
+$white-n: #333;
+$white-na: teal;
+$white-nb: #0086b3;
+$white-nc: #458;
+$white-no: teal;
+$white-ni: purple;
+$white-ne: #900;
+$white-nf: #900;
+$white-nn: #555;
+$white-nt: navy;
+$white-nv: teal;
+$white-w: #bbb;
+$white-mf: #099;
+$white-mh: #099;
+$white-mi: #099;
+$white-mo: #099;
+$white-sb: #d14;
+$white-sc: #d14;
+$white-sd: #d14;
+$white-s2: #d14;
+$white-se: #d14;
+$white-sh: #d14;
+$white-si: #d14;
+$white-sx: #d14;
+$white-sr: #009926;
+$white-s1: #d14;
+$white-ss: #990073;
+$white-bp: #999;
+$white-vc: teal;
+$white-vg: teal;
+$white-vi: teal;
+$white-il: #099;
+$white-gc-color: #999;
+$white-gc-bg: #eaf2f5;
+
+
+@mixin matchLine {
+ color: $black-transparent;
+ background-color: $gray-light;
+}
+
+ // Line numbers
+.line-numbers,
+.diff-line-num {
+ background-color: $gray-light;
+}
+
+.diff-line-num,
+.diff-line-num a {
+ color: $black-transparent;
+}
+
+// Code itself
+pre.code,
+.diff-line-num {
+ border-color: $white-normal;
+}
+
+&,
+pre.code,
+.line_holder .line_content {
+ background-color: $white-light;
+ color: $white-code-color;
+}
+
+// Diff line
+.line_holder {
+
+ &.match .line_content {
+ @include matchLine;
+ }
+
+ .diff-line-num {
+ &.old {
+ background-color: $line-number-old;
+ border-color: $line-removed-dark;
+
+ a {
+ color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
+ }
+ }
+
+ &.new {
+ background-color: $line-number-new;
+ border-color: $line-added-dark;
+
+ a {
+ color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
+ }
+ }
+
+ &.is-over,
+ &.hll:not(.empty-cell).is-over {
+ background-color: $white-over-bg;
+ border-color: darken($white-over-bg, 5%);
+
+ a {
+ color: darken($white-over-bg, 15%);
+ }
+ }
+
+ &.hll:not(.empty-cell) {
+ background-color: $line-number-select;
+ border-color: $line-select-yellow-dark;
+ }
+ }
+
+ &:not(.diff-expanded) + .diff-expanded,
+ &.diff-expanded + .line_holder:not(.diff-expanded) {
+ > .diff-line-num,
+ > .line_content {
+ border-top: 1px solid $white-expanded-border;
+ }
+ }
+
+ &.diff-expanded {
+ > .diff-line-num,
+ > .line_content {
+ background: $white-expanded-bg;
+ border-color: $white-expanded-bg;
+ }
+ }
+
+ .line_content {
+ &.old {
+ background-color: $line-removed;
+
+ &::before {
+ color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
+ }
+
+ span.idiff {
+ background-color: $line-removed-dark;
+ }
+ }
+
+ &.new {
+ background-color: $line-added;
+
+ &::before {
+ color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
+ }
+
+ span.idiff {
+ background-color: $line-added-dark;
+ }
+ }
+
+ &.match {
+ @include matchLine;
+ }
+
+ &.hll:not(.empty-cell) {
+ background-color: $line-select-yellow;
+ }
+ }
+}
+
+// highlight line via anchor
+pre .hll {
+ background-color: $white-pre-hll-bg !important;
+}
+
+ // Search result highlight
+span.highlight_word {
+ background-color: $white-highlight !important;
+}
+
+ // Links to URLs, emails, or dependencies
+.line a {
+ color: $white-nb;
+}
+
+.hll { background-color: $white-hll-bg; }
+.c { color: $white-c; font-style: italic; }
+.err { color: $white-err; background-color: $white-err-bg; }
+.k { font-weight: $gl-font-weight-bold; }
+.o { font-weight: $gl-font-weight-bold; }
+.cm { color: $white-cm; font-style: italic; }
+.cp { color: $white-cp; font-weight: $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;
+
+ .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;
+
+ .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; }
+.gu { color: $white-gu; font-weight: $gl-font-weight-bold; }
+.gt { color: $white-gt; }
+.kc { font-weight: $gl-font-weight-bold; }
+.kd { font-weight: $gl-font-weight-bold; }
+.kn { font-weight: $gl-font-weight-bold; }
+.kp { font-weight: $gl-font-weight-bold; }
+.kr { font-weight: $gl-font-weight-bold; }
+.kt { color: $white-kt; font-weight: $gl-font-weight-bold; }
+.m { color: $white-m; }
+.s { color: $white-s; }
+.n { color: $white-n; }
+.na { color: $white-na; }
+.nb { color: $white-nb; }
+.nc { color: $white-nc; font-weight: $gl-font-weight-bold; }
+.no { color: $white-no; }
+.ni { color: $white-ni; }
+.ne { color: $white-ne; font-weight: $gl-font-weight-bold; }
+.nf { color: $white-nf; font-weight: $gl-font-weight-bold; }
+.nn { color: $white-nn; }
+.nt { color: $white-nt; }
+.nv { color: $white-nv; }
+.ow { font-weight: $gl-font-weight-bold; }
+.w { color: $white-w; }
+.mf { color: $white-mf; }
+.mh { color: $white-mh; }
+.mi { color: $white-mi; }
+.mo { color: $white-mo; }
+.sb { color: $white-sb; }
+.sc { color: $white-sc; }
+.sd { color: $white-sd; }
+.s2 { color: $white-s2; }
+.se { color: $white-se; }
+.sh { color: $white-sh; }
+.si { color: $white-si; }
+.sx { color: $white-sx; }
+.sr { color: $white-sr; }
+.s1 { color: $white-s1; }
+.ss { color: $white-ss; }
+.bp { color: $white-bp; }
+.vc { color: $white-vc; }
+.vg { color: $white-vg; }
+.vi { color: $white-vi; }
+.il { color: $white-il; }
+.gc { color: $white-gc-color; background-color: $white-gc-bg; }
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 318d3ddaece..681242f8d85 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -317,6 +317,7 @@
a {
color: $gl-text-color;
word-wrap: break-word;
+ word-break: break-word;
margin-right: 2px;
}
}
@@ -462,6 +463,7 @@
.issuable-header-text {
padding-right: 35px;
+ word-break: break-word;
> strong {
font-weight: $gl-font-weight-bold;
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 98d460339cd..50f32660445 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -1,39 +1,56 @@
@keyframes fade-out-status {
- 0%, 50% { opacity: 1; }
- 100% { opacity: 0; }
+ 0%,
+ 50% {
+ opacity: 1;
+ }
+
+ 100% {
+ opacity: 0;
+ }
}
@keyframes blinking-dots {
0% {
background-color: rgba($white-light, 1);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
- 24px 0 0 0 rgba($white-light, 0.2);
+ 24px 0 0 0 rgba($white-light, 0.2);
}
25% {
background-color: rgba($white-light, 0.4);
box-shadow: 12px 0 0 0 rgba($white-light, 2),
- 24px 0 0 0 rgba($white-light, 0.2);
+ 24px 0 0 0 rgba($white-light, 0.2);
}
75% {
background-color: rgba($white-light, 0.4);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
- 24px 0 0 0 rgba($white-light, 1);
+ 24px 0 0 0 rgba($white-light, 1);
}
100% {
background-color: rgba($white-light, 1);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
- 24px 0 0 0 rgba($white-light, 0.2);
+ 24px 0 0 0 rgba($white-light, 0.2);
}
}
@keyframes blinking-scroll-button {
- 0% { opacity: 0.2; }
- 25% { opacity: 0.5; }
- 50% { opacity: 0.7; }
- 100% { opacity: 1; }
+ 0% {
+ opacity: 0.2;
+ }
+
+ 25% {
+ opacity: 0.5;
+ }
+
+ 50% {
+ opacity: 0.7;
+ }
+
+ 100% {
+ opacity: 1;
+ }
}
.build-page {
@@ -125,12 +142,12 @@
.btn-scroll.animate {
.first-triangle {
animation: blinking-scroll-button 1s ease infinite;
- animation-delay: .3s;
+ animation-delay: 0.3s;
}
.second-triangle {
animation: blinking-scroll-button 1s ease infinite;
- animation-delay: .2s;
+ animation-delay: 0.2s;
}
.third-triangle {
@@ -391,7 +408,7 @@
}
&:hover {
- background-color: $row-hover;
+ background-color: $dropdown-item-hover-bg;
}
.icon-retry {
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index b487f6278c2..1aca3c5cf1a 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -70,7 +70,7 @@
}
.branch-info .commit-icon {
- margin-right: 3px;
+ margin-right: 8px;
svg {
top: 3px;
@@ -107,7 +107,6 @@
}
}
-
.commits-compare-switch {
float: left;
margin-right: 9px;
@@ -179,12 +178,8 @@
.commit-detail {
display: flex;
justify-content: space-between;
- align-items: flex-start;
+ align-items: center;
flex-grow: 1;
-
- .merge-request-branches & {
- flex-direction: column;
- }
}
.commit-content {
@@ -200,37 +195,63 @@
}
.ci-status-link {
- display: inline-block;
- position: relative;
- top: 2px;
+ display: inline-flex;
}
- .btn-clipboard,
- .btn-transparent {
- padding-left: 0;
- padding-right: 0;
+ > .ci-status-link,
+ > .btn,
+ > .commit-sha-group {
+ margin-left: $gl-padding-8;
}
+}
+
+.commit-sha-group {
+ display: inline-flex;
+ .label,
.btn {
- &:not(:first-child) {
- margin-left: $gl-padding;
- }
+ padding: $gl-vert-padding $gl-btn-padding;
+ border: 1px $border-color solid;
+ font-size: $gl-font-size;
+ line-height: $line-height-base;
+ border-radius: 0;
+ display: flex;
+ align-items: center;
+ }
+
+ .label-monospace {
+ @extend .monospace;
+ user-select: text;
+ color: $gl-text-color;
+ background-color: $gray-light;
}
- .commit-sha {
- font-size: 14px;
- font-weight: $gl-font-weight-bold;
+ .btn svg {
+ top: auto;
+ fill: $gl-text-color-secondary;
}
- .ci-status-icon {
- position: relative;
- top: 2px;
+ .fa-clipboard {
+ color: $gl-text-color-secondary;
+ }
+
+ :first-child {
+ border-bottom-left-radius: $border-radius-default;
+ border-top-left-radius: $border-radius-default;
+ }
+
+ :not(:first-child) {
+ border-left: 0;
+ }
+
+ :last-child {
+ border-bottom-right-radius: $border-radius-default;
+ border-top-right-radius: $border-radius-default;
}
}
.commit,
.generic_commit_status {
-
a,
button {
color: $gl-text-color;
@@ -303,10 +324,8 @@
}
}
-
.gpg-status-box {
padding: 2px 10px;
- margin-right: $gl-padding;
&:empty {
display: none;
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 7f037582ca0..70ce5de6a6c 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -44,6 +44,12 @@
}
}
+ .note-text {
+ table {
+ font-family: $font-family-sans-serif;
+ }
+ }
+
table {
width: 100%;
font-family: $monospace_font;
@@ -160,6 +166,11 @@
}
}
}
+
+ .diff-loading-error-block {
+ padding: $gl-padding * 2 $gl-padding;
+ text-align: center;
+ }
}
.image {
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 58700661142..3a300086fa3 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -273,21 +273,6 @@
line-height: 1.2;
}
- table {
- border-collapse: collapse;
- padding: 0;
- margin: 0;
- }
-
- td {
- vertical-align: middle;
-
- + td {
- padding-left: 5px;
- vertical-align: top;
- }
- }
-
.deploy-meta-content {
border-bottom: 1px solid $white-dark;
@@ -323,6 +308,26 @@
}
}
+.prometheus-table {
+ border-collapse: collapse;
+ padding: 0;
+ margin: 0;
+
+ td {
+ vertical-align: middle;
+
+ + td {
+ padding-left: 5px;
+ vertical-align: top;
+ }
+ }
+
+ .legend-metric-title {
+ font-size: 12px;
+ vertical-align: middle;
+ }
+}
+
.prometheus-svg-container {
position: relative;
height: 0;
@@ -330,8 +335,7 @@
padding: 0;
padding-bottom: 100%;
- .text-metric-usage,
- .legend-metric-title {
+ .text-metric-usage {
fill: $black;
font-weight: $gl-font-weight-normal;
font-size: 12px;
@@ -374,10 +378,6 @@
}
}
- .text-metric-title {
- font-size: 12px;
- }
-
.y-label-text,
.x-label-text {
fill: $gray-darkest;
@@ -414,3 +414,7 @@
}
}
}
+
+.prometheus-table-row-highlight {
+ background-color: $prometheus-table-row-highlight-color;
+}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 2c0ed976301..b2dad4a358a 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -187,7 +187,12 @@
padding-left: 10px;
&:hover {
- color: $gray-darkest;
+ color: $gl-text-color;
+ }
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
}
}
@@ -368,6 +373,14 @@
padding: 15px 0 0;
border-bottom: 0;
overflow: hidden;
+
+ &:hover {
+ background-color: $sidebar-block-hover-color;
+ }
+
+ &.issuable-sidebar-header {
+ padding-top: 0;
+ }
}
.participants {
@@ -380,8 +393,17 @@
.gutter-toggle {
width: 100%;
+ height: $sidebar-toggle-height;
margin-left: 0;
- padding-left: 25px;
+ padding-left: 0;
+ border-bottom: 1px solid $border-gray-dark;
+ }
+
+ a.gutter-toggle {
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ text-align: center;
}
.sidebar-collapsed-icon {
@@ -428,10 +450,10 @@
.btn-clipboard {
border: 0;
+ background: transparent;
color: $issuable-sidebar-color;
&:hover {
- background: transparent;
color: $gl-text-color;
}
}
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index b0852adb459..d81236c5883 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -314,6 +314,10 @@
display: inline-flex;
vertical-align: top;
+ &:hover .color-label {
+ text-decoration: underline;
+ }
+
.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 b2250a1ce2f..97303d02666 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -154,26 +154,10 @@
a {
width: 100%;
font-size: 18px;
- margin-right: 0;
-
- &:hover {
- border: 1px solid transparent;
- }
}
- &.active {
- border-bottom: 1px solid $border-color;
-
- a {
- border: 0;
- border-bottom: 2px solid $link-underline-blue;
- margin-right: 0;
- color: $black;
-
- &:hover {
- border-bottom: 2px solid $link-underline-blue;
- }
- }
+ &.active > a {
+ cursor: default;
}
}
}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 4692d0fb873..66db4917178 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -762,3 +762,20 @@
max-width: 100%;
}
}
+
+// Hack alert: we've rewritten `btn` class in a way that
+// we've broken it and it is not possible to use with `btn-link`
+// which causes a blank button when it's disabled and hovering
+// The css in here is the boostrap one
+.btn-link-retry {
+ &[disabled] {
+ cursor: not-allowed;
+ box-shadow: none;
+ opacity: .65;
+
+ &:hover {
+ color: $file-mode-changed;
+ text-decoration: none;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss
index e5afa8fffcb..bac3b70c734 100644
--- a/app/assets/stylesheets/pages/milestone.scss
+++ b/app/assets/stylesheets/pages/milestone.scss
@@ -53,10 +53,6 @@
}
.milestone-sidebar {
- .gutter-toggle {
- margin-bottom: 10px;
- }
-
.milestone-progress {
.title {
padding-top: 5px;
@@ -102,7 +98,17 @@
margin-right: 0;
}
+ .right-sidebar-expanded & {
+ .gutter-toggle {
+ margin-bottom: $sidebar-milestone-toggle-bottom-margin;
+ }
+ }
+
.right-sidebar-collapsed & {
+ .milestone-progress {
+ padding-top: 0;
+ }
+
.reference {
border-top: 1px solid $border-gray-normal;
}
@@ -194,3 +200,38 @@
.issuable-row {
background-color: $white-light;
}
+
+.milestone-deprecation-message {
+ .popover {
+ padding: 0;
+ }
+
+ .popover-content {
+ padding: 0;
+ }
+}
+
+.milestone-popover-body {
+ padding: $gl-padding-8;
+ background-color: $gray-light;
+}
+
+.milestone-popover-footer {
+ padding: $gl-padding-8 $gl-padding;
+ border-top: 1px solid $white-dark;
+}
+
+.milestone-popover-instructions-list {
+ padding-left: 2em;
+
+ > li {
+ padding-left: 1em;
+ }
+}
+
+@media (max-width: $screen-xs-max) {
+ .milestone-banner-text,
+ .milestone-banner-link {
+ display: inline;
+ }
+}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 81e98f358a8..6d5c6cb136f 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -772,7 +772,3 @@ ul.notes {
height: auto;
}
}
-
-.line-resolve-text {
- vertical-align: middle;
-}
diff --git a/app/assets/stylesheets/pages/pages.scss b/app/assets/stylesheets/pages/pages.scss
new file mode 100644
index 00000000000..fb42dee66d2
--- /dev/null
+++ b/app/assets/stylesheets/pages/pages.scss
@@ -0,0 +1,60 @@
+.pages-domain-list {
+ &-item {
+ position: relative;
+ display: flex;
+ align-items: center;
+
+ .domain-status {
+ display: inline-flex;
+ left: $gl-padding;
+ position: absolute;
+ }
+
+ .domain-name {
+ flex-grow: 1;
+ }
+
+ }
+
+ &.has-verification-status > li {
+ padding-left: 3 * $gl-padding;
+ }
+
+}
+
+.status-badge {
+
+ display: inline-flex;
+ margin-bottom: $gl-padding-8;
+
+ // Most of the following settings "stolen" from btn-sm
+ // Border radius is overwritten for both
+ .label,
+ .btn {
+ padding: $gl-padding-4 $gl-padding-8;
+ font-size: $gl-font-size;
+ line-height: $gl-btn-line-height;
+ border-radius: 0;
+ display: flex;
+ align-items: center;
+ }
+
+ .btn svg {
+ top: auto;
+ }
+
+ :first-child {
+ border-bottom-left-radius: $border-radius-default;
+ border-top-left-radius: $border-radius-default;
+ }
+
+ :not(:first-child) {
+ border-left: 0;
+ }
+
+ :last-child {
+ border-bottom-right-radius: $border-radius-default;
+ border-top-right-radius: $border-radius-default;
+ }
+
+}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index ce2f1482456..3a8ec779c14 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -14,6 +14,11 @@
.commit-title {
margin: 0;
+ white-space: normal;
+
+ @media (max-width: $screen-sm-max) {
+ justify-content: flex-end;
+ }
}
.ci-table {
@@ -344,7 +349,6 @@
svg {
vertical-align: middle;
- margin-right: 3px;
}
.stage-column {
@@ -464,6 +468,14 @@
margin-bottom: 10px;
white-space: normal;
+ .ci-job-dropdown-container {
+ // override dropdown.scss
+ .dropdown-menu li button {
+ padding: 0;
+ text-align: center;
+ }
+ }
+
// ensure .build-content has hover style when action-icon is hovered
.ci-job-dropdown-container:hover .build-content {
@extend .build-content:hover;
@@ -495,17 +507,12 @@
svg {
fill: $gl-text-color-secondary;
position: relative;
- left: 5px;
- top: 2px;
- width: 18px;
- height: 18px;
+ top: -1px;
}
&.play {
svg {
- width: #{$ci-action-icon-size - 8};
- height: #{$ci-action-icon-size - 8};
- left: 8px;
+ left: 2px;
}
}
}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index ac745019319..b199f9876d3 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -210,13 +210,8 @@
}
.created-personal-access-token-container {
- #created-personal-access-token {
- width: 90%;
- display: inline;
- }
-
.btn-clipboard {
- margin-left: 5px;
+ border: 1px solid $border-color;
}
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 9a770d77685..d7d343b088a 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -935,11 +935,6 @@ pre.light-well {
}
}
- .dropdown-menu-toggle {
- width: 100%;
- max-width: 300px;
- }
-
.flash-container {
padding: 0;
}
@@ -1143,3 +1138,11 @@ pre.light-well {
white-space: pre-wrap;
}
}
+
+.project-badge {
+ opacity: 0.9;
+
+ &:hover {
+ opacity: 1;
+ }
+}
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
index 1f6f7138e1f..e74606e864f 100644
--- a/app/assets/stylesheets/pages/repo.scss
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -17,6 +17,7 @@
}
.ide-view {
+ position: relative;
display: flex;
height: calc(100vh - #{$header-height});
margin-top: 0;
@@ -54,6 +55,7 @@
white-space: nowrap;
text-overflow: ellipsis;
max-width: inherit;
+ line-height: 22px;
svg {
vertical-align: middle;
@@ -66,13 +68,21 @@
}
}
+ .ide-file-icon-holder {
+ display: flex;
+ align-items: center;
+ }
+
.ide-file-changed-icon {
margin-left: auto;
+
+ > svg {
+ display: block;
+ }
}
.ide-new-btn {
display: none;
- margin-bottom: -4px;
margin-right: -8px;
}
@@ -85,10 +95,8 @@
}
}
- &.folder {
- svg {
- fill: $gl-text-color-secondary;
- }
+ .folder-icon {
+ fill: $gl-text-color-secondary;
}
}
@@ -106,6 +114,7 @@
.file-col-commit-message {
display: flex;
overflow: visible;
+ align-items: center;
padding: 6px 12px;
}
@@ -308,18 +317,81 @@
height: 100%;
}
-.multi-file-editor-btn-group {
- padding: $gl-bar-padding $gl-padding;
- border-top: 1px solid $white-dark;
+.preview-container {
+ height: 100%;
+ overflow: auto;
+
+ .file-container {
+ background-color: $gray-darker;
+ display: flex;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+
+ text-align: center;
+
+ .file-content {
+ padding: $gl-padding;
+ max-width: 100%;
+ max-height: 100%;
+
+ img {
+ max-width: 90%;
+ max-height: 90%;
+ }
+
+ .isZoomable {
+ cursor: pointer;
+ cursor: zoom-in;
+
+ &.isZoomed {
+ cursor: pointer;
+ cursor: zoom-out;
+ max-width: none;
+ max-height: none;
+ margin-right: $gl-padding;
+ }
+ }
+ }
+
+ .file-info {
+ font-size: $label-font-size;
+ color: $diff-image-info-color;
+ }
+ }
+
+ .md-previewer {
+ padding: $gl-padding;
+ }
+}
+
+.ide-mode-tabs {
border-bottom: 1px solid $white-dark;
- background: $white-light;
+
+ .nav-links {
+ border-bottom: 0;
+
+ li a {
+ padding: $gl-padding-8 $gl-padding;
+ line-height: $gl-btn-line-height;
+ }
+ }
+}
+
+.ide-btn-group {
+ padding: $gl-padding-4 $gl-vert-padding;
}
.ide-status-bar {
+ border-top: 1px solid $white-dark;
padding: $gl-bar-padding $gl-padding;
background: $white-light;
display: flex;
- justify-content: space-between;
+ justify-content: flex-end;
+
+ > div + div {
+ padding-left: $gl-padding;
+ }
svg {
vertical-align: middle;
@@ -370,6 +442,7 @@
.projects-sidebar {
display: flex;
flex-direction: column;
+ flex: 1;
.context-header {
width: auto;
@@ -379,8 +452,8 @@
.multi-file-commit-panel-inner {
display: flex;
- flex: 1;
flex-direction: column;
+ height: 100%;
}
.multi-file-commit-panel-inner-scroll {
@@ -461,9 +534,13 @@
overflow: auto;
}
-.multi-file-commit-empty-state-container {
- align-items: center;
- justify-content: center;
+.ide-commit-empty-state {
+ padding: 0 $gl-padding;
+}
+
+.ide-commit-empty-state-container {
+ margin-top: auto;
+ margin-bottom: auto;
}
.multi-file-commit-panel-header {
@@ -472,35 +549,23 @@
margin-bottom: 0;
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;
- }
- }
+ min-height: 56px;
}
.multi-file-commit-panel-header-title {
display: flex;
flex: 1;
- padding: 0 $gl-btn-padding;
+ padding-left: $grid-size;
svg {
margin-right: $gl-btn-padding;
+ color: $theme-gray-700;
}
}
.multi-file-commit-panel-collapse-btn {
border-left: 1px solid $white-dark;
+ margin-left: auto;
}
.multi-file-commit-list {
@@ -514,12 +579,14 @@
display: flex;
padding: 0;
align-items: center;
+ border-radius: $border-radius-default;
.multi-file-discard-btn {
display: none;
+ margin-top: -2px;
margin-left: auto;
+ margin-right: $grid-size;
color: $gl-link-color;
- padding: 0 2px;
&:focus,
&:hover {
@@ -531,26 +598,31 @@
background: $white-normal;
.multi-file-discard-btn {
- display: block;
+ display: flex;
}
}
}
-.multi-file-addition {
- fill: $green-500;
+.multi-file-addition,
+.multi-file-addition-solid {
+ color: $green-500;
}
-.multi-file-modified {
- fill: $orange-500;
+.multi-file-modified,
+.multi-file-modified-solid {
+ color: $orange-500;
}
.multi-file-commit-list-collapsed {
display: flex;
flex-direction: column;
+ padding: $gl-padding 0;
- > svg {
+ svg {
+ display: block;
margin-left: auto;
margin-right: auto;
+ color: $theme-gray-700;
}
.file-status-icon {
@@ -562,7 +634,7 @@
.multi-file-commit-list-path {
padding: $grid-size / 2;
- padding-left: $gl-padding;
+ padding-left: $grid-size;
background: none;
border: 0;
text-align: left;
@@ -602,9 +674,22 @@
}
}
-.multi-file-commit-message.form-control {
- height: 160px;
- resize: none;
+.multi-file-commit-panel-bottom {
+ position: relative;
+
+ .multi-file-commit-panel-success-message {
+ position: absolute;
+ top: 1px;
+ left: 3px;
+ bottom: 0;
+ right: 0;
+ z-index: 10;
+ background: $gray-light;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ }
}
.dirty-diff {
@@ -752,6 +837,41 @@
}
}
+.ide-commit-list-container {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ padding: 0 16px;
+
+ &:not(.is-collapsed) {
+ flex: 1;
+ min-height: 140px;
+ }
+
+ &.is-collapsed {
+ .multi-file-commit-panel-header {
+ margin-left: -$gl-padding;
+ margin-right: -$gl-padding;
+
+ svg {
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .multi-file-commit-panel-collapse-btn {
+ margin-right: auto;
+ margin-left: auto;
+ border-left: 0;
+ }
+ }
+ }
+}
+
+.ide-staged-action-btn {
+ margin-left: auto;
+ color: $gl-link-color;
+}
+
.ide-commit-radios {
label {
font-weight: normal;
@@ -779,3 +899,104 @@
align-items: center;
font-weight: $gl-font-weight-bold;
}
+
+.ide-file-finder-overlay {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 100;
+}
+
+.ide-file-finder {
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+
+ .highlighted {
+ color: $blue-500;
+ font-weight: $gl-font-weight-bold;
+ }
+}
+
+.ide-commit-message-field {
+ height: 200px;
+ background-color: $white-light;
+
+ .md-area {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ }
+
+ .nav-links {
+ height: 30px;
+ }
+
+ .help-block {
+ margin-top: 2px;
+ color: $blue-500;
+ cursor: pointer;
+ }
+}
+
+.ide-commit-message-textarea-container {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+
+ .note-textarea {
+ font-family: $monospace_font;
+ }
+}
+
+.ide-commit-message-highlights-container {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: -100px;
+ bottom: 0;
+ padding-right: 100px;
+ pointer-events: none;
+ z-index: 1;
+
+ .highlights {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ color: transparent;
+ }
+
+ mark {
+ margin-left: -1px;
+ padding: 0 2px;
+ border-radius: $border-radius-small;
+ background-color: $orange-200;
+ color: transparent;
+ opacity: 0.6;
+ }
+}
+
+.ide-commit-message-textarea {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 2;
+ background: transparent;
+ resize: none;
+}
+
+.ide-tree-changes {
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+}
+
+.ide-new-modal-label {
+ line-height: 34px;
+}
diff --git a/app/assets/stylesheets/pages/repo.scss.orig b/app/assets/stylesheets/pages/repo.scss.orig
deleted file mode 100644
index 57b995adb64..00000000000
--- a/app/assets/stylesheets/pages/repo.scss.orig
+++ /dev/null
@@ -1,786 +0,0 @@
-.project-refs-form,
-.project-refs-target-form {
- display: inline-block;
-}
-
-.fade-enter,
-.fade-leave-to {
- opacity: 0;
-}
-
-.commit-message {
- @include str-truncated(250px);
-}
-
-.editable-mode {
- display: inline-block;
-}
-
-.ide-view {
- display: flex;
- height: calc(100vh - #{$header-height});
- margin-top: 40px;
- color: $almost-black;
- border-top: 1px solid $white-dark;
- border-bottom: 1px solid $white-dark;
-
- &.is-collapsed {
- .ide-file-list {
- max-width: 250px;
- }
- }
-
- .file-status-icon {
- width: 10px;
- height: 10px;
- }
-}
-
-.ide-file-list {
- flex: 1;
-
- .file {
- cursor: pointer;
-
- &.file-open {
- background: $white-normal;
- }
-
- .ide-file-name {
- flex: 1;
- white-space: nowrap;
- text-overflow: ellipsis;
-
- svg {
- vertical-align: middle;
- margin-right: 2px;
- }
-
- .loading-container {
- margin-right: 4px;
- display: inline-block;
- }
- }
-
- .ide-file-changed-icon {
- margin-left: auto;
- }
-
- .ide-new-btn {
- display: none;
- margin-bottom: -4px;
- margin-right: -8px;
- }
-
- &:hover {
- .ide-new-btn {
- display: block;
- }
- }
-
- &.folder {
- svg {
- fill: $gl-text-color-secondary;
- }
- }
- }
-
- a {
- color: $gl-text-color;
- }
-
- th {
- position: sticky;
- top: 0;
- }
-}
-
-.file-name,
-.file-col-commit-message {
- display: flex;
- overflow: visible;
- padding: 6px 12px;
-}
-
-.multi-file-loading-container {
- margin-top: 10px;
- padding: 10px;
-
- .animation-container {
- background: $gray-light;
-
- div {
- background: $gray-light;
- }
- }
-}
-
-.multi-file-table-col-commit-message {
- white-space: nowrap;
- width: 50%;
-}
-
-.multi-file-edit-pane {
- display: flex;
- flex-direction: column;
- flex: 1;
- border-left: 1px solid $white-dark;
- overflow: hidden;
-}
-
-.multi-file-tabs {
- display: flex;
- background-color: $white-normal;
- box-shadow: inset 0 -1px $white-dark;
-
- > ul {
- display: flex;
- overflow-x: auto;
- }
-
- li {
- position: relative;
- }
-
- .dropdown {
- display: flex;
- margin-left: auto;
- margin-bottom: 1px;
- padding: 0 $grid-size;
- border-left: 1px solid $white-dark;
- background-color: $white-light;
-
- &.shadow {
- box-shadow: 0 0 10px $dropdown-shadow-color;
- }
-
- .btn {
- margin-top: auto;
- margin-bottom: auto;
- }
- }
-}
-
-.multi-file-tab {
- @include str-truncated(150px);
- padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding;
- background-color: $gray-normal;
- border-right: 1px solid $white-dark;
- border-bottom: 1px solid $white-dark;
- cursor: pointer;
-
- svg {
- vertical-align: middle;
- }
-
- &.active {
- background-color: $white-light;
- border-bottom-color: $white-light;
- }
-}
-
-.multi-file-tab-close {
- position: absolute;
- right: 8px;
- top: 50%;
- width: 16px;
- height: 16px;
- padding: 0;
- background: none;
- border: 0;
- border-radius: $border-radius-default;
- color: $theme-gray-900;
- transform: translateY(-50%);
-
- svg {
- position: relative;
- top: -1px;
- }
-
- &:hover {
- background-color: $theme-gray-200;
- }
-
- &:focus {
- background-color: $blue-500;
- color: $white-light;
- outline: 0;
-
- svg {
- fill: currentColor;
- }
- }
-}
-
-.multi-file-edit-pane-content {
- flex: 1;
- height: 0;
-}
-
-.blob-editor-container {
- flex: 1;
- height: 0;
- display: flex;
- flex-direction: column;
- justify-content: center;
-
- .vertical-center {
- min-height: auto;
- }
-
- .monaco-editor .lines-content .cigr {
- display: none;
- }
-
- .monaco-diff-editor.vs {
- .editor.modified {
- box-shadow: none;
- }
-
- .diagonal-fill {
- display: none !important;
- }
-
- .diffOverview {
- background-color: $white-light;
- border-left: 1px solid $white-dark;
- cursor: ns-resize;
- }
-
- .diffViewport {
- display: none;
- }
-
- .char-insert {
- background-color: $line-added-dark;
- }
-
- .char-delete {
- background-color: $line-removed-dark;
- }
-
- .line-numbers {
- color: $black-transparent;
- }
-
- .view-overlays {
- .line-insert {
- background-color: $line-added;
- }
-
- .line-delete {
- background-color: $line-removed;
- }
- }
-
- .margin {
- background-color: $gray-light;
- border-right: 1px solid $white-normal;
-
- .line-insert {
- border-right: 1px solid $line-added-dark;
- }
-
- .line-delete {
- border-right: 1px solid $line-removed-dark;
- }
- }
-
- .margin-view-overlays .insert-sign,
- .margin-view-overlays .delete-sign {
- opacity: 0.4;
- }
-
- .cursors-layer {
- display: none;
- }
- }
-}
-
-.multi-file-editor-holder {
- height: 100%;
-}
-
-.multi-file-editor-btn-group {
- padding: $gl-bar-padding $gl-padding;
- border-top: 1px solid $white-dark;
- border-bottom: 1px solid $white-dark;
- background: $white-light;
-}
-
-.ide-status-bar {
- padding: $gl-bar-padding $gl-padding;
- background: $white-light;
- display: flex;
- justify-content: space-between;
-
- svg {
- vertical-align: middle;
- }
-}
-
-// Not great, but this is to deal with our current output
-.multi-file-preview-holder {
- height: 100%;
- overflow: scroll;
-
- .file-content.code {
- display: flex;
-
- i {
- margin-left: -10px;
- }
- }
-
- .line-numbers {
- min-width: 50px;
- }
-
- .file-content,
- .line-numbers,
- .blob-content,
- .code {
- min-height: 100%;
- }
-}
-
-.file-content.blob-no-preview {
- a {
- margin-left: auto;
- margin-right: auto;
- }
-}
-
-.multi-file-commit-panel {
- display: flex;
- position: relative;
- flex-direction: column;
- width: 340px;
- padding: 0;
- background-color: $gray-light;
- padding-right: 3px;
-
- .projects-sidebar {
- display: flex;
- flex-direction: column;
-
- .context-header {
- width: auto;
- margin-right: 0;
- }
- }
-
- .multi-file-commit-panel-inner {
- display: flex;
- flex: 1;
- flex-direction: column;
- }
-
- .multi-file-commit-panel-inner-scroll {
- display: flex;
- flex: 1;
- flex-direction: column;
- overflow: auto;
- }
-
- &.is-collapsed {
- width: 60px;
-
- .multi-file-commit-list {
- padding-top: $gl-padding;
- overflow: hidden;
- }
-
- .multi-file-context-bar-icon {
- align-items: center;
-
- svg {
- float: none;
- margin: 0;
- }
- }
- }
-
- .branch-container {
- border-left: 4px solid $indigo-700;
- margin-bottom: $gl-bar-padding;
- }
-
- .branch-header {
- background: $white-dark;
- display: flex;
- }
-
- .branch-header-title {
- flex: 1;
- padding: $grid-size $gl-padding;
- color: $indigo-700;
- font-weight: $gl-font-weight-bold;
-
- svg {
- vertical-align: middle;
- }
- }
-
- .branch-header-btns {
- padding: $gl-vert-padding $gl-padding;
- }
-
- .left-collapse-btn {
- display: none;
- background: $gray-light;
- text-align: left;
- border-top: 1px solid $white-dark;
-
- svg {
- vertical-align: middle;
- }
- }
-}
-
-.multi-file-context-bar-icon {
- padding: 10px;
-
- svg {
- margin-right: 10px;
- float: left;
- }
-}
-
-.multi-file-commit-panel-section {
- display: flex;
- flex-direction: column;
- flex: 1;
-}
-
-.multi-file-commit-empty-state-container {
- align-items: center;
- justify-content: center;
-}
-
-.multi-file-commit-panel-header {
- display: flex;
- align-items: center;
- margin-bottom: 12px;
- border-bottom: 1px solid $white-dark;
- padding: $gl-btn-padding 0;
-
- &.is-collapsed {
- border-bottom: 1px solid $white-dark;
-
- svg {
- margin-left: auto;
- margin-right: auto;
- }
-
- .multi-file-commit-panel-collapse-btn {
- margin-right: auto;
- margin-left: auto;
- border-left: 0;
- }
- }
-}
-
-.multi-file-commit-panel-header-title {
- display: flex;
- flex: 1;
- padding: 0 $gl-btn-padding;
-
- svg {
- margin-right: $gl-btn-padding;
- }
-}
-
-.multi-file-commit-panel-collapse-btn {
- border-left: 1px solid $white-dark;
-}
-
-.multi-file-commit-list {
- flex: 1;
- overflow: auto;
- padding: $gl-padding 0;
- min-height: 60px;
-}
-
-.multi-file-commit-list-item {
- display: flex;
- padding: 0;
- align-items: center;
-
- .multi-file-discard-btn {
- display: none;
- margin-left: auto;
- color: $gl-link-color;
- padding: 0 2px;
-
- &:focus,
- &:hover {
- text-decoration: underline;
- }
- }
-
- &:hover {
- background: $white-normal;
-
- .multi-file-discard-btn {
- display: block;
- }
- }
-}
-
-.multi-file-addition {
- fill: $green-500;
-}
-
-.multi-file-modified {
- fill: $orange-500;
-}
-
-.multi-file-commit-list-collapsed {
- display: flex;
- flex-direction: column;
-
- > svg {
- margin-left: auto;
- margin-right: auto;
- }
-
- .file-status-icon {
- width: 10px;
- height: 10px;
- margin-left: 3px;
- }
-}
-
-.multi-file-commit-list-path {
- padding: $grid-size / 2;
- padding-left: $gl-padding;
- background: none;
- border: 0;
- text-align: left;
- width: 100%;
- min-width: 0;
-
- svg {
- min-width: 16px;
- vertical-align: middle;
- display: inline-block;
- }
-
- &:hover,
- &:focus {
- outline: 0;
- }
-}
-
-.multi-file-commit-list-file-path {
- @include str-truncated(100%);
-
- &:hover {
- text-decoration: underline;
- }
-
- &:active {
- text-decoration: none;
- }
-}
-
-.multi-file-commit-form {
- padding: $gl-padding;
- border-top: 1px solid $white-dark;
-
- .btn {
- font-size: $gl-font-size;
- }
-}
-
-.multi-file-commit-message.form-control {
- height: 160px;
- resize: none;
-}
-
-.dirty-diff {
- // !important need to override monaco inline style
- width: 4px !important;
- left: 0 !important;
-
- &-modified {
- background-color: $blue-500;
- }
-
- &-added {
- background-color: $green-600;
- }
-
- &-removed {
- height: 0 !important;
- width: 0 !important;
- bottom: -2px;
- border-style: solid;
- border-width: 5px;
- border-color: transparent transparent transparent $red-500;
-
- &::before {
- content: '';
- position: absolute;
- left: 0;
- top: 0;
- width: 100px;
- height: 1px;
- background-color: rgba($red-500, 0.5);
- }
- }
-}
-
-.ide-loading {
- display: flex;
- height: 100vh;
- align-items: center;
- justify-content: center;
-}
-
-.ide-empty-state {
- display: flex;
- height: 100vh;
- align-items: center;
- justify-content: center;
-}
-
-.ide-new-btn {
- .dropdown-toggle svg {
- margin-top: -2px;
- margin-bottom: 2px;
- }
-
- .dropdown-menu {
- left: auto;
- right: 0;
-
- label {
- font-weight: $gl-font-weight-normal;
- padding: 5px 8px;
- margin-bottom: 0;
- }
- }
-}
-
-.ide {
- overflow: hidden;
-
- &.nav-only {
- .flash-container {
- margin-top: $header-height;
- margin-bottom: 0;
- }
-
- .alert-wrapper .flash-container .flash-alert:last-child,
- .alert-wrapper .flash-container .flash-notice:last-child {
- margin-bottom: 0;
- }
-
- .content-wrapper {
- margin-top: $header-height;
- padding-bottom: 0;
- }
-
- &.flash-shown {
- .content-wrapper {
- margin-top: 0;
- }
-
- .ide-view {
- height: calc(100vh - #{$header-height + $flash-height});
- }
- }
-
- .projects-sidebar {
- .multi-file-commit-panel-inner-scroll {
- flex: 1;
- }
- }
- }
-}
-
-.with-performance-bar .ide.nav-only {
- .flash-container {
- margin-top: #{$header-height + $performance-bar-height};
- }
-
- .content-wrapper {
- margin-top: #{$header-height + $performance-bar-height};
- padding-bottom: 0;
- }
-
- .ide-view {
- height: calc(100vh - #{$header-height + $performance-bar-height});
- }
-
- &.flash-shown {
- .content-wrapper {
- margin-top: 0;
- }
-
- .ide-view {
- height: calc(
- 100vh - #{$header-height + $performance-bar-height + $flash-height}
- );
- }
- }
-}
-
-.dragHandle {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 3px;
- background-color: $white-dark;
-
- &.dragright {
- right: 0;
- }
-
- &.dragleft {
- left: 0;
- }
-}
-
-.ide-commit-radios {
- label {
- font-weight: normal;
- }
-
- .help-block {
- margin-top: 0;
- line-height: 0;
- }
-}
-
-.ide-commit-new-branch {
- margin-left: 25px;
-}
-
-.ide-external-links {
- p {
- margin: 0;
- }
-}
-
-.ide-sidebar-link {
- padding: $gl-padding-8 $gl-padding;
- background: $indigo-700;
- color: $white-light;
- text-decoration: none;
- display: flex;
- align-items: center;
-
- &:focus,
- &:hover {
- color: $white-light;
- text-decoration: underline;
- background: $indigo-500;
- }
-
- &:active {
- background: $indigo-800;
- }
-}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index a6ca8ed5016..c410049bc0b 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -284,3 +284,23 @@
.deprecated-service {
cursor: default;
}
+
+.personal-access-tokens-never-expires-label {
+ color: $note-disabled-comment-color;
+}
+
+.created-deploy-token-container {
+ .deploy-token-field {
+ width: 90%;
+ display: inline;
+ }
+
+ .btn-clipboard {
+ margin-left: 5px;
+ }
+
+ .deploy-token-help-block {
+ display: block;
+ margin-bottom: 0;
+ }
+}
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index 45ae94abaff..06ef58531d7 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -1,5 +1,4 @@
@import 'framework/variables';
-@import 'peek/views/performance_bar';
@import 'peek/views/rblineprof';
#js-peek {
diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss
new file mode 100644
index 00000000000..0d6b0735f70
--- /dev/null
+++ b/app/assets/stylesheets/snippets.scss
@@ -0,0 +1,156 @@
+@import "framework/variables";
+
+.gitlab-embed-snippets {
+ @import "highlight/embedded";
+ @import "framework/images";
+
+ $border-style: 1px solid $border-color;
+
+ font-family: $regular_font;
+ font-size: $gl-font-size;
+ line-height: $code_line_height;
+ color: $gl-text-color;
+ margin: 20px;
+ font-weight: 200;
+
+ .gl-snippet-icon {
+ display: inline-block;
+ background: url(asset_path('ext_snippet_icons/ext_snippet_icons.png')) no-repeat;
+ overflow: hidden;
+ text-align: left;
+ width: 16px;
+ height: 16px;
+ background-size: cover;
+
+ &.gl-snippet-icon-doc_code { background-position: 0 0; }
+ &.gl-snippet-icon-doc_text { background-position: 0 -16px; }
+ &.gl-snippet-icon-download { background-position: 0 -32px; }
+ }
+
+ .blob-viewer {
+ background-color: $white-light;
+ text-align: left;
+ }
+
+ .file-content.code {
+ border: $border-style;
+ border-radius: 0 0 4px 4px;
+ display: flex;
+ box-shadow: none;
+ margin: 0;
+ padding: 0;
+ table-layout: fixed;
+
+ .blob-content {
+ overflow-x: auto;
+
+ pre {
+ padding: 10px;
+ border: 0;
+ border-radius: 0;
+ font-family: $monospace_font;
+ font-size: $code_font_size;
+ line-height: $code_line_height;
+ margin: 0;
+ overflow: auto;
+ overflow-y: hidden;
+ white-space: pre;
+ word-wrap: normal;
+ border-left: $border-style;
+ }
+ }
+
+ .line-numbers {
+ padding: 10px;
+ text-align: right;
+ float: left;
+
+ .diff-line-num {
+ font-family: $monospace_font;
+ display: block;
+ font-size: $code_font_size;
+ min-height: $code_line_height;
+ white-space: nowrap;
+ color: $black-transparent;
+ min-width: 30px;
+ }
+
+ .diff-line-num:hover {
+ color: $almost-black;
+ cursor: pointer;
+ }
+ }
+ }
+
+ .file-title-flex-parent {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: $gray-light;
+ border: $border-style;
+ border-bottom: 0;
+ padding: $gl-padding-top $gl-padding;
+ margin: 0;
+ border-radius: $border-radius-default $border-radius-default 0 0;
+
+ .file-header-content {
+ .file-title-name {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .gitlab-embedded-snippets-title {
+ text-decoration: none;
+ color: $gl-text-color;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .gitlab-logo {
+ display: inline-block;
+ padding-left: 5px;
+ text-decoration: none;
+ color: $gl-text-color-secondary;
+
+ .logo-text {
+ background: image_url('ext_snippet_icons/logo.png') no-repeat left center;
+ background-size: 18px;
+ font-weight: $gl-font-weight-normal;
+ padding-left: 24px;
+ }
+ }
+ }
+
+ img,
+ .gl-snippet-icon {
+ display: inline-block;
+ vertical-align: middle;
+ }
+ }
+
+ .btn-group {
+ a.btn {
+ background-color: $white-light;
+ text-decoration: none;
+ padding: 7px 9px;
+ border: $border-style;
+ border-right: 0;
+
+ &:hover {
+ background-color: $white-normal;
+ border-color: $border-white-normal;
+ text-decoration: none;
+ }
+
+ &:first-child {
+ border-radius: 3px 0 0 3px;
+ }
+
+ &:last-child {
+ border-radius: 0 3px 3px 0;
+ border-right: $border-style;
+ }
+ }
+ }
+}
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 4dfb397e82c..8958eab0423 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -56,21 +56,18 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
def application_setting_params
- import_sources = params[:application_setting][:import_sources]
- if import_sources.nil?
- params[:application_setting][:import_sources] = []
- else
- import_sources.map! do |source|
- source.to_str
- end
- end
+ params[:application_setting] ||= {}
- enabled_oauth_sign_in_sources = params[:application_setting].delete(:enabled_oauth_sign_in_sources)
+ if params[:application_setting].key?(:enabled_oauth_sign_in_sources)
+ enabled_oauth_sign_in_sources = params[:application_setting].delete(:enabled_oauth_sign_in_sources)
+ enabled_oauth_sign_in_sources&.delete("")
- params[:application_setting][:disabled_oauth_sign_in_sources] =
- AuthHelper.button_based_providers.map(&:to_s) -
- Array(enabled_oauth_sign_in_sources)
+ params[:application_setting][:disabled_oauth_sign_in_sources] =
+ AuthHelper.button_based_providers.map(&:to_s) -
+ Array(enabled_oauth_sign_in_sources)
+ end
+ params[:application_setting][:import_sources]&.delete("")
params[:application_setting][:restricted_visibility_levels]&.delete("")
params.delete(:domain_blacklist_raw) if params[:domain_blacklist_file]
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 24651dd392c..8ad13a82f89 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -5,6 +5,7 @@ class ApplicationController < ActionController::Base
include Gitlab::GonHelper
include GitlabRoutingHelper
include PageLayoutHelper
+ include SafeParamsHelper
include SentryHelper
include WorkhorseHelper
include EnforcesTwoFactorAuthentication
@@ -109,7 +110,8 @@ class ApplicationController < ActionController::Base
def log_exception(exception)
Raven.capture_exception(exception) if sentry_enabled?
- application_trace = ActionDispatch::ExceptionWrapper.new(env, exception).application_trace
+ backtrace_cleaner = Gitlab.rails5? ? env["action_dispatch.backtrace_cleaner"] : env
+ application_trace = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).application_trace
application_trace.map! { |t| " #{t}\n" }
logger.error "\n#{exception.class.name} (#{exception.message}):\n#{application_trace.join}"
end
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index 19dbee84c11..7d7ff217e5d 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -96,7 +96,8 @@ module Boards
resource.as_json(
only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position],
labels: true,
- sidebar_endpoints: true,
+ issue_endpoints: true,
+ include_full_project_path: board.group_board?,
include: {
project: { only: [:id, :path] },
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index 2753f83c3cf..69a053d4246 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -10,7 +10,7 @@ module AuthenticatesWithTwoFactor
# This action comes from DeviseController, but because we call `sign_in`
# manually, not skipping this action would cause a "You are already signed
# in." error message to be shown upon successful login.
- skip_before_action :require_no_authentication, only: [:create]
+ skip_before_action :require_no_authentication, only: [:create], raise: false
end
# Store the user's ID in the session for later retrieval and render the
@@ -23,6 +23,9 @@ module AuthenticatesWithTwoFactor
#
# Returns nil
def prompt_for_two_factor(user)
+ # Set @user for Devise views
+ @user = user # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
return locked_user_redirect(user) unless user.can?(:log_in)
session[:otp_user_id] = user.id
diff --git a/app/controllers/concerns/checks_collaboration.rb b/app/controllers/concerns/checks_collaboration.rb
new file mode 100644
index 00000000000..81367663a06
--- /dev/null
+++ b/app/controllers/concerns/checks_collaboration.rb
@@ -0,0 +1,21 @@
+module ChecksCollaboration
+ def can_collaborate_with_project?(project, ref: nil)
+ return true if can?(current_user, :push_code, project)
+
+ can_create_merge_request =
+ can?(current_user, :create_merge_request_in, project) &&
+ current_user.already_forked?(project)
+
+ can_create_merge_request ||
+ user_access(project).can_push_to_branch?(ref)
+ end
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ # enabling this so we can easily cache the user access value as it might be
+ # used across multiple calls in the view
+ def user_access(project)
+ @user_access ||= {}
+ @user_access[project] ||= Gitlab::UserAccess.new(current_user, project: project)
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+end
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 4114ca6bf7c..ca1b80a36a0 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -57,7 +57,7 @@ module IssuableCollections
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)))
+ redirect_to(url_for(safe_params.merge(page: total_pages, only_path: true)))
end
out_of_range
@@ -165,8 +165,8 @@ module IssuableCollections
[: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
+ :target_project, :author, :assignee, :labels, :milestone,
+ source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits
]
end
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 839cac3687c..0c34e49206a 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -41,7 +41,7 @@ module NotesActions
@note = Notes::CreateService.new(note_project, current_user, create_params).execute
if @note.is_a?(Note)
- Notes::RenderService.new(current_user).execute([@note], @project)
+ Notes::RenderService.new(current_user).execute([@note])
end
respond_to do |format|
@@ -56,7 +56,7 @@ module NotesActions
@note = Notes::UpdateService.new(project, current_user, note_params).execute(note)
if @note.is_a?(Note)
- Notes::RenderService.new(current_user).execute([@note], @project)
+ Notes::RenderService.new(current_user).execute([@note])
end
respond_to do |format|
@@ -217,7 +217,7 @@ module NotesActions
def note_project
strong_memoize(:note_project) do
- return nil unless project
+ next nil unless project
note_project_id = params[:note_project_id]
@@ -228,7 +228,7 @@ module NotesActions
project
end
- return access_denied! unless can?(current_user, :create_note, the_project)
+ next access_denied! unless can?(current_user, :create_note, the_project)
the_project
end
diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb
index e7ef297879f..36e3d76ecfe 100644
--- a/app/controllers/concerns/renders_notes.rb
+++ b/app/controllers/concerns/renders_notes.rb
@@ -4,7 +4,7 @@ module RendersNotes
preload_noteable_for_regular_notes(notes)
preload_max_access_for_authors(notes, @project)
preload_first_time_contribution_for_authors(noteable, notes)
- Notes::RenderService.new(current_user).execute(notes, @project)
+ Notes::RenderService.new(current_user).execute(notes)
notes
end
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index 9095cc7f783..120614739aa 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -17,6 +17,10 @@ module SnippetsActions
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
+ def js_request?
+ request.format.js?
+ end
+
private
def convert_line_endings(content)
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index e89eaf7edda..f9e8fe624e8 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -86,7 +86,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
out_of_range = todos.current_page > total_pages
if out_of_range
- redirect_to url_for(params.merge(page: total_pages, only_path: true))
+ redirect_to url_for(safe_params.merge(page: total_pages, only_path: true))
end
out_of_range
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 280ed93faf8..68d328fa797 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -2,9 +2,17 @@ class DashboardController < Dashboard::ApplicationController
include IssuesAction
include MergeRequestsAction
+ FILTER_PARAMS = [
+ :author_id,
+ :assignee_id,
+ :milestone_title,
+ :label_name
+ ].freeze
+
before_action :event_filter, only: :activity
before_action :projects, only: [:issues, :merge_requests]
before_action :set_show_full_reference, only: [:issues, :merge_requests]
+ before_action :check_filters_presence!, only: [:issues, :merge_requests]
respond_to :html
@@ -39,4 +47,15 @@ class DashboardController < Dashboard::ApplicationController
def set_show_full_reference
@show_full_reference = true
end
+
+ def check_filters_presence!
+ @no_filters_set = FILTER_PARAMS.none? { |k| params.key?(k) }
+
+ return unless @no_filters_set
+
+ respond_to do |format|
+ format.html
+ format.atom { head :bad_request }
+ end
+ end
end
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index 9f3bb60b4cc..62213561898 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -33,6 +33,6 @@ class Groups::ApplicationController < ApplicationController
def build_canonical_path(group)
params[:group_id] = group.to_param
- url_for(params)
+ url_for(safe_params)
end
end
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index acf6aaf57f4..5903689dc62 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -12,7 +12,7 @@ class Groups::MilestonesController < Groups::ApplicationController
@milestones = Kaminari.paginate_array(milestones).page(params[:page])
end
format.json do
- render json: milestones.map { |m| m.for_display.slice(:title, :name) }
+ render json: milestones.map { |m| m.for_display.slice(:id, :title, :name) }
end
end
end
diff --git a/app/controllers/groups/settings/badges_controller.rb b/app/controllers/groups/settings/badges_controller.rb
new file mode 100644
index 00000000000..edb334a3d88
--- /dev/null
+++ b/app/controllers/groups/settings/badges_controller.rb
@@ -0,0 +1,13 @@
+module Groups
+ module Settings
+ class BadgesController < Groups::ApplicationController
+ include GrapeRouteHelpers::NamedRouteMatcher
+
+ before_action :authorize_admin_group!
+
+ def index
+ @badge_api_endpoint = api_v4_groups_badges_path(id: @group.id)
+ end
+ end
+ end
+end
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index 6142e75b4c1..4d8a20de017 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -15,7 +15,7 @@ module Groups
def update
if @group.update(group_variables_params)
respond_to do |format|
- format.json { return render_group_variables }
+ format.json { render_group_variables }
end
else
respond_to do |format|
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 283c3e5f1e0..79fa5818359 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -173,7 +173,9 @@ class GroupsController < Groups::ApplicationController
.new(@projects, offset: params[:offset].to_i, filter: event_filter)
.to_a
- Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?)
+ Events::RenderService
+ .new(current_user)
+ .execute(@events, atom_request: request.format.atom?)
end
def user_actions
@@ -187,6 +189,6 @@ class GroupsController < Groups::ApplicationController
params[:id] = group.to_param
- url_for(params)
+ url_for(safe_params)
end
end
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 7d6fe6a0232..67057b5b126 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -25,8 +25,7 @@ class JwtController < ApplicationController
authenticate_with_http_basic do |login, password|
@authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip)
- if @authentication_result.failed? ||
- (@authentication_result.actor.present? && !@authentication_result.actor.is_a?(User))
+ if @authentication_result.failed?
render_unauthorized
end
end
diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb
new file mode 100644
index 00000000000..fb24edb8602
--- /dev/null
+++ b/app/controllers/ldap/omniauth_callbacks_controller.rb
@@ -0,0 +1,31 @@
+class Ldap::OmniauthCallbacksController < OmniauthCallbacksController
+ extend ::Gitlab::Utils::Override
+
+ def self.define_providers!
+ return unless Gitlab::Auth::LDAP::Config.enabled?
+
+ Gitlab::Auth::LDAP::Config.available_servers.each do |server|
+ alias_method server['provider_name'], :ldap
+ end
+ end
+
+ # We only find ourselves here
+ # if the authentication to LDAP was successful.
+ def ldap
+ sign_in_user_flow(Gitlab::Auth::LDAP::User)
+ end
+
+ define_providers!
+
+ override :set_remember_me
+ def set_remember_me(user)
+ user.remember_me = params[:remember_me] if user.persisted?
+ end
+
+ override :fail_login
+ def fail_login(user)
+ flash[:alert] = 'Access denied for your LDAP account.'
+
+ redirect_to new_user_session_path
+ end
+end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 5e6676ea513..40d9fa18a10 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -4,18 +4,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
protect_from_forgery except: [:kerberos, :saml, :cas3]
- Gitlab.config.omniauth.providers.each do |provider|
- define_method provider['name'] do
- handle_omniauth
- end
+ def handle_omniauth
+ omniauth_flow(Gitlab::Auth::OAuth)
end
- if Gitlab::Auth::LDAP::Config.enabled?
- Gitlab::Auth::LDAP::Config.available_servers.each do |server|
- define_method server['provider_name'] do
- ldap
- end
- end
+ AuthHelper.providers_for_base_controller.each do |provider|
+ alias_method provider, :handle_omniauth
end
# Extend the standard implementation to also increment
@@ -37,51 +31,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
error ||= exception.error if exception.respond_to?(:error)
error ||= exception.message if exception.respond_to?(:message)
error ||= env["omniauth.error.type"].to_s
- error.to_s.humanize if error
- end
- # We only find ourselves here
- # if the authentication to LDAP was successful.
- def ldap
- 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
- @user.remember_me = params[:remember_me] if ldap_user.persisted?
-
- # Do additional LDAP checks for the user filter and EE features
- if ldap_user.allowed?
- if @user.two_factor_enabled?
- prompt_for_two_factor(@user)
- else
- log_audit_event(@user, with: oauth['provider'])
- sign_in_and_redirect(@user)
- end
- else
- fail_ldap_login
- end
+ error.to_s.humanize if error
end
def saml
- if current_user
- log_audit_event(current_user, with: :saml)
- # Update SAML identity if data has changed.
- 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'
- else
- redirect_to after_sign_in_path_for(current_user)
- end
- else
- 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::Auth::OAuth::User::SignupDisabledError
- handle_signup_error
+ omniauth_flow(Gitlab::Auth::Saml)
end
def omniauth_error
@@ -117,25 +72,36 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
private
- def handle_omniauth
+ def omniauth_flow(auth_module, identity_linker: nil)
if current_user
- # Add new authentication method
- 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::Auth::OAuth::User.new(oauth)
- oauth_user.save
- @user = oauth_user.gl_user
- continue_login_process
+ identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth)
+
+ identity_linker.link
+
+ if identity_linker.changed?
+ redirect_identity_linked
+ elsif identity_linker.error_message.present?
+ redirect_identity_link_failed(identity_linker.error_message)
+ else
+ redirect_identity_exists
+ end
+ else
+ sign_in_user_flow(auth_module::User)
end
- rescue Gitlab::Auth::OAuth::User::SigninDisabledForProviderError
- handle_disabled_provider
- rescue Gitlab::Auth::OAuth::User::SignupDisabledError
- handle_signup_error
+ end
+
+ def redirect_identity_exists
+ redirect_to after_sign_in_path_for(current_user)
+ end
+
+ def redirect_identity_link_failed(error_message)
+ redirect_to profile_account_path, notice: "Authentication failed: #{error_message}"
+ end
+
+ def redirect_identity_linked
+ redirect_to profile_account_path, notice: 'Authentication method updated'
end
def handle_service_ticket(provider, ticket)
@@ -144,21 +110,27 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
session[:service_tickets][provider] = ticket
end
- def continue_login_process
- # Only allow properly saved users to login.
- if @user.persisted? && @user.valid?
- log_audit_event(@user, with: oauth['provider'])
+ def sign_in_user_flow(auth_user_class)
+ auth_user = auth_user_class.new(oauth)
+ user = auth_user.find_and_update!
+
+ if auth_user.valid_sign_in?
+ log_audit_event(user, with: oauth['provider'])
+
+ set_remember_me(user)
- if @user.two_factor_enabled?
- params[:remember_me] = '1' if remember_me?
- prompt_for_two_factor(@user)
+ if user.two_factor_enabled?
+ prompt_for_two_factor(user)
else
- remember_me(@user) if remember_me?
- sign_in_and_redirect(@user)
+ sign_in_and_redirect(user)
end
else
- fail_login
+ fail_login(user)
end
+ rescue Gitlab::Auth::OAuth::User::SigninDisabledForProviderError
+ handle_disabled_provider
+ rescue Gitlab::Auth::OAuth::User::SignupDisabledError
+ handle_signup_error
end
def handle_signup_error
@@ -178,18 +150,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
@oauth ||= request.env['omniauth.auth']
end
- def fail_login
- error_message = @user.errors.full_messages.to_sentence
+ def fail_login(user)
+ error_message = user.errors.full_messages.to_sentence
return redirect_to omniauth_error_path(oauth['provider'], error: error_message)
end
- def fail_ldap_login
- flash[:alert] = 'Access denied for your LDAP account.'
-
- redirect_to new_user_session_path
- end
-
def fail_auth0_login
flash[:alert] = 'Wrong extern UID provided. Make sure Auth0 is configured correctly.'
@@ -208,6 +174,16 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
.for_authentication.security_event
end
+ def set_remember_me(user)
+ return unless remember_me?
+
+ if user.two_factor_enabled?
+ params[:remember_me] = '1'
+ else
+ remember_me(user)
+ end
+ end
+
def remember_me?
request_params = request.env['omniauth.params']
(request_params['remember_me'] == '1') if request_params.present?
diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb
new file mode 100644
index 00000000000..f0cdc228366
--- /dev/null
+++ b/app/controllers/profiles/active_sessions_controller.rb
@@ -0,0 +1,14 @@
+class Profiles::ActiveSessionsController < Profiles::ApplicationController
+ def index
+ @sessions = ActiveSession.list(current_user)
+ end
+
+ def destroy
+ ActiveSession.destroy(current_user, params[:id])
+
+ respond_to do |format|
+ format.html { redirect_to profile_active_sessions_url, status: 302 }
+ format.js { head :ok }
+ end
+ end
+end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 3d27ae18b17..ac71f72e624 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -53,13 +53,19 @@ class ProfilesController < Profiles::ApplicationController
def update_username
result = Users::UpdateService.new(current_user, user: @user, username: username_param).execute
- options = if result[:status] == :success
- { notice: "Username successfully changed" }
- else
- { alert: "Username change failed - #{result[:message]}" }
- end
+ respond_to do |format|
+ if result[:status] == :success
+ message = s_("Profiles|Username successfully changed")
- redirect_back_or_default(default: { action: 'show' }, options: options)
+ format.html { redirect_back_or_default(default: { action: 'show' }, options: { notice: message }) }
+ format.json { render json: { message: message }, status: :ok }
+ else
+ message = s_("Profiles|Username change failed - %{message}") % { message: result[:message] }
+
+ format.html { redirect_back_or_default(default: { action: 'show' }, options: { alert: message }) }
+ format.json { render json: { message: message }, status: :unprocessable_entity }
+ end
+ end
end
private
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index 6d9b42a2c04..5ab6d103c89 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -1,5 +1,6 @@
class Projects::ApplicationController < ApplicationController
include RoutableActions
+ include ChecksCollaboration
skip_before_action :authenticate_user!
before_action :project
@@ -24,21 +25,13 @@ class Projects::ApplicationController < ApplicationController
params[:namespace_id] = project.namespace.to_param
params[:project_id] = project.to_param
- url_for(params)
+ url_for(safe_params)
end
def repository
@repository ||= project.repository
end
- def can_collaborate_with_project?(project = nil, ref: nil)
- project ||= @project
-
- can?(current_user, :push_code, project) ||
- (current_user && current_user.already_forked?(project)) ||
- user_access(project).can_push_to_branch?(ref)
- end
-
def authorize_action!(action)
unless can?(current_user, action, project)
return access_denied!
@@ -91,9 +84,4 @@ class Projects::ApplicationController < ApplicationController
def check_issues_available!
return render_404 unless @project.feature_available?(:issues, current_user)
end
-
- def user_access(project)
- @user_access ||= {}
- @user_access[project] ||= Gitlab::UserAccess.new(current_user, project: project)
- end
end
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index effb484ef0f..b7f548e0e63 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -34,6 +34,7 @@ class Projects::CommitController < Projects::ApplicationController
def pipelines
@pipelines = @commit.pipelines.order(id: :desc)
+ @pipelines = @pipelines.where(ref: params[:ref]) if params[:ref]
respond_to do |format|
format.html
diff --git a/app/controllers/projects/deploy_tokens_controller.rb b/app/controllers/projects/deploy_tokens_controller.rb
new file mode 100644
index 00000000000..2f91b8f36de
--- /dev/null
+++ b/app/controllers/projects/deploy_tokens_controller.rb
@@ -0,0 +1,10 @@
+class Projects::DeployTokensController < Projects::ApplicationController
+ before_action :authorize_admin_project!
+
+ def revoke
+ @token = @project.deploy_tokens.find(params[:id])
+ @token.revoke!
+
+ redirect_to project_settings_repository_path(project)
+ end
+end
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index 7bc16214010..8e86af43fee 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -4,8 +4,8 @@ class Projects::DiscussionsController < Projects::ApplicationController
before_action :check_merge_requests_available!
before_action :merge_request
- before_action :discussion
- before_action :authorize_resolve_discussion!
+ before_action :discussion, only: [:resolve, :unresolve]
+ before_action :authorize_resolve_discussion!, only: [:resolve, :unresolve]
def resolve
Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion)
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index dd5e66f60e3..07249fe3182 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -7,6 +7,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController
attr_reader :authentication_result, :redirected_path
delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true
+ delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result
alias_method :user, :actor
alias_method :authenticated_user, :actor
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 45910a9be44..1dcf837f78e 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -64,7 +64,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
@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)
+ redirected_path: redirected_path, auth_result_type: auth_result_type)
end
def access_actor
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index b14939c4216..d69015c8665 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -20,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController
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]
+ before_action :authorize_create_merge_request_from!, only: [:create_merge_request]
respond_to :html
@@ -134,11 +134,11 @@ class Projects::IssuesController < Projects::ApplicationController
def can_create_branch
can_create = current_user &&
can?(current_user, :push_code, @project) &&
- @issue.can_be_worked_on?(current_user)
+ @issue.can_be_worked_on?
respond_to do |format|
format.json do
- render json: { can_create_branch: can_create, has_related_branch: @issue.has_related_branch? }
+ render json: { can_create_branch: can_create, suggested_branch_name: @issue.suggested_branch_name }
end
end
end
@@ -177,7 +177,7 @@ class Projects::IssuesController < Projects::ApplicationController
end
def authorize_create_merge_request!
- render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user)
+ render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?
end
def render_issue_json
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 85e972d9731..dd12d30a085 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -2,7 +2,6 @@ class Projects::JobsController < Projects::ApplicationController
include SendFileUpload
before_action :build, except: [:index, :cancel_all]
-
before_action :authorize_read_build!,
only: [:index, :show, :status, :raw, :trace]
before_action :authorize_update_build!,
@@ -45,8 +44,11 @@ class Projects::JobsController < Projects::ApplicationController
end
def show
- @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
- @builds = @builds.where("id not in (?)", @build.id)
+ @builds = @project.pipelines
+ .find_by_sha(@build.sha)
+ .builds
+ .order('id DESC')
+ .present(current_user: current_user)
@pipeline = @build.pipeline
respond_to do |format|
@@ -128,7 +130,7 @@ class Projects::JobsController < Projects::ApplicationController
if stream.file?
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
else
- render_404
+ send_data stream.raw, type: 'text/plain; charset=utf-8', disposition: 'inline', filename: 'job.log'
end
end
end
diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb
index c77f10ef1dd..ee4ed674110 100644
--- a/app/controllers/projects/lfs_api_controller.rb
+++ b/app/controllers/projects/lfs_api_controller.rb
@@ -41,7 +41,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
def existing_oids
@existing_oids ||= begin
- storage_project.lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid)
+ project.all_lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid)
end
end
diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb
index 2515e4b9a17..43d8867a536 100644
--- a/app/controllers/projects/lfs_storage_controller.rb
+++ b/app/controllers/projects/lfs_storage_controller.rb
@@ -31,7 +31,9 @@ class Projects::LfsStorageController < Projects::GitHttpClientController
render plain: 'Unprocessable entity', status: 422
end
rescue ActiveRecord::RecordInvalid
- render_400
+ render_lfs_forbidden
+ rescue UploadedFile::InvalidPathError
+ render_lfs_forbidden
rescue ObjectStorage::RemoteStoreError
render_lfs_forbidden
end
@@ -66,16 +68,16 @@ class Projects::LfsStorageController < Projects::GitHttpClientController
end
def create_file!(oid, size)
- LfsObject.new(oid: oid, size: size).tap do |object|
- object.file.store_workhorse_file!(params, :file)
- object.save!
- end
+ uploaded_file = UploadedFile.from_params(
+ params, :file, LfsObjectUploader.workhorse_local_upload_path)
+ return unless uploaded_file
+
+ LfsObject.create!(oid: oid, size: size, file: uploaded_file)
end
def link_to_project!(object)
if object && !object.projects.exists?(storage_project.id)
- object.projects << storage_project
- object.save!
+ object.lfs_objects_projects.create!(project: storage_project)
end
end
end
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index a90030a8312..81129456ad8 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -5,7 +5,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
skip_before_action :merge_request
before_action :whitelist_query_limiting, only: [:create]
- before_action :authorize_create_merge_request!
+ before_action :authorize_create_merge_request_from!
before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path]
before_action :build_merge_request, except: [:create]
@@ -83,13 +83,6 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
render layout: false
end
- def update_branches
- @target_project = selected_target_project
- @target_branches = @target_project ? @target_project.repository.branch_names : []
-
- render layout: false
- end
-
private
def build_merge_request
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 54e7d81de6a..62b739918e6 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -60,13 +60,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
format.patch do
- return render_404 unless @merge_request.diff_refs
+ break render_404 unless @merge_request.diff_refs
send_git_patch @project.repository, @merge_request.diff_refs
end
format.diff do
- return render_404 unless @merge_request.diff_refs
+ break render_404 unless @merge_request.diff_refs
send_git_diff @project.repository, @merge_request.diff_refs
end
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index dd41b9648e8..bc13b8ad7ba 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -33,9 +33,7 @@ class Projects::NotesController < Projects::ApplicationController
def resolve
return render_404 unless note.resolvable?
- note.resolve!(current_user)
-
- MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(note.noteable)
+ Notes::ResolveService.new(project, current_user).execute(note)
discussion = note.discussion
@@ -68,7 +66,7 @@ class Projects::NotesController < Projects::ApplicationController
private
def render_json_with_notes_serializer
- Notes::RenderService.new(current_user).execute([note], project)
+ Notes::RenderService.new(current_user).execute([note])
render json: note_serializer.represent(note)
end
diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb
index 557671ab186..73c613b26f3 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -4,41 +4,4 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
def show
redirect_to project_settings_ci_cd_path(@project, params: params)
end
-
- def update
- Projects::UpdateService.new(project, current_user, update_params).tap do |service|
- if service.execute
- flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
-
- run_autodevops_pipeline(service)
-
- redirect_to project_settings_ci_cd_path(@project)
- else
- render 'show'
- end
- end
- end
-
- private
-
- def run_autodevops_pipeline(service)
- return unless service.run_auto_devops_pipeline?
-
- if @project.empty_repo?
- flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch."
- return
- end
-
- CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false)
- flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe
- end
-
- def update_params
- params.require(:project).permit(
- :runners_token, :builds_enabled, :build_allow_git_fetch,
- :build_timeout_in_minutes, :build_coverage_regex, :public_builds,
- :auto_cancel_pending_pipelines, :ci_config_path,
- auto_devops_attributes: [:id, :domain, :enabled]
- )
- end
end
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index 2376f469213..48a09e1ddb8 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -25,7 +25,7 @@ class Projects::RefsController < Projects::ApplicationController
when "graphs_commits"
commits_project_graph_path(@project, @id)
when "badges"
- project_pipelines_settings_path(@project, ref: @id)
+ project_settings_ci_cd_path(@project, ref: @id)
else
project_commits_path(@project, @id)
end
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index d5af0341d18..d01f324e6fd 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -1,6 +1,9 @@
class Projects::RepositoriesController < Projects::ApplicationController
+ include ExtractsPath
+
# Authorize
before_action :require_non_empty_project, except: :create
+ before_action :assign_archive_vars, only: :archive
before_action :authorize_download_code!
before_action :authorize_admin_project!, only: :create
@@ -11,9 +14,27 @@ class Projects::RepositoriesController < Projects::ApplicationController
end
def archive
- send_git_archive @repository, ref: params[:ref], format: params[:format]
+ append_sha = params[:append_sha]
+
+ if @ref
+ shortname = "#{@project.path}-#{@ref.tr('/', '-')}"
+ append_sha = false if @filename == shortname
+ end
+
+ send_git_archive @repository, ref: @ref, format: params[:format], append_sha: append_sha
rescue => ex
logger.error("#{self.class.name}: #{ex}")
return git_not_found!
end
+
+ def assign_archive_vars
+ if params[:id]
+ @ref, @filename = extract_ref(params[:id])
+ else
+ @ref = params[:ref]
+ @filename = nil
+ end
+ rescue InvalidPathError
+ render_404
+ end
end
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index c950d0f7001..b9bbe7115c4 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -52,6 +52,12 @@ class Projects::RunnersController < Projects::ApplicationController
redirect_to project_settings_ci_cd_path(@project)
end
+ def toggle_group_runners
+ project.toggle_ci_cd_settings!(:group_runners_enabled)
+
+ redirect_to project_settings_ci_cd_path(@project)
+ end
+
protected
def set_runner
diff --git a/app/controllers/projects/settings/badges_controller.rb b/app/controllers/projects/settings/badges_controller.rb
new file mode 100644
index 00000000000..f7b70dd4b7b
--- /dev/null
+++ b/app/controllers/projects/settings/badges_controller.rb
@@ -0,0 +1,13 @@
+module Projects
+ module Settings
+ class BadgesController < Projects::ApplicationController
+ include GrapeRouteHelpers::NamedRouteMatcher
+
+ before_action :authorize_admin_project!
+
+ def index
+ @badge_api_endpoint = api_v4_projects_badges_path(id: @project.id)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 96125b549b7..177c8a54099 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -2,13 +2,24 @@ module Projects
module Settings
class CiCdController < Projects::ApplicationController
before_action :authorize_admin_pipeline!
+ before_action :define_variables
def show
- define_runners_variables
- define_secret_variables
- define_triggers_variables
- define_badges_variables
- define_auto_devops_variables
+ end
+
+ def update
+ Projects::UpdateService.new(project, current_user, update_params).tap do |service|
+ result = service.execute
+ if result[:status] == :success
+ flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
+
+ run_autodevops_pipeline(service)
+
+ redirect_to project_settings_ci_cd_path(@project)
+ else
+ render 'show'
+ end
+ end
end
def reset_cache
@@ -25,12 +36,49 @@ module Projects
private
+ def update_params
+ params.require(:project).permit(
+ :runners_token, :builds_enabled, :build_allow_git_fetch,
+ :build_timeout_human_readable, :build_coverage_regex, :public_builds,
+ :auto_cancel_pending_pipelines, :ci_config_path,
+ auto_devops_attributes: [:id, :domain, :enabled]
+ )
+ end
+
+ def run_autodevops_pipeline(service)
+ return unless service.run_auto_devops_pipeline?
+
+ if @project.empty_repo?
+ flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch."
+ return
+ end
+
+ CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false)
+ flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe
+ end
+
+ def define_variables
+ define_runners_variables
+ define_secret_variables
+ define_triggers_variables
+ define_badges_variables
+ define_auto_devops_variables
+ end
+
def define_runners_variables
@project_runners = @project.runners.ordered
- @assignable_runners = current_user.ci_authorized_runners
- .assignable_for(project).ordered.page(params[:page]).per(20)
+
+ @assignable_runners = current_user
+ .ci_authorized_runners
+ .assignable_for(project)
+ .ordered
+ .page(params[:page]).per(20)
+
@shared_runners = ::Ci::Runner.shared.active
+
@shared_runners_count = @shared_runners.count(:all)
+
+ @group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id)
end
def define_secret_variables
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index dd9e4a2af3e..f17056f13e0 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -4,13 +4,31 @@ module Projects
before_action :authorize_admin_project!
def show
- @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user)
+ render_show
+ end
- define_protected_refs
+ def create_deploy_token
+ @new_deploy_token = DeployTokens::CreateService.new(@project, current_user, deploy_token_params).execute
+
+ if @new_deploy_token.persisted?
+ flash.now[:notice] = s_('DeployTokens|Your new project deploy token has been created.')
+ end
+
+ render_show
end
private
+ def render_show
+ @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user)
+ @deploy_tokens = @project.deploy_tokens.active
+
+ define_deploy_token
+ define_protected_refs
+
+ render 'show'
+ end
+
def define_protected_refs
@protected_branches = @project.protected_branches.order(:name).page(params[:page])
@protected_tags = @project.protected_tags.order(:name).page(params[:page])
@@ -51,6 +69,14 @@ module Projects
gon.push(protectable_branches_for_dropdown)
gon.push(access_levels_options)
end
+
+ def define_deploy_token
+ @new_deploy_token ||= DeployToken.new
+ end
+
+ def deploy_token_params
+ params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry)
+ end
end
end
end
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index 7c19aa7bb23..208a1d19862 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -5,6 +5,8 @@ class Projects::SnippetsController < Projects::ApplicationController
include SnippetsActions
include RendersBlob
+ skip_before_action :verify_authenticity_token, only: [:show], if: :js_request?
+
before_action :check_snippets_available!
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :toggle_award_emoji, :mark_as_spam]
@@ -71,6 +73,7 @@ class Projects::SnippetsController < Projects::ApplicationController
format.json do
render_blob_json(blob)
end
+ format.js { render 'shared/snippets/show'}
end
end
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index 517d0b026c2..bf09ea7e4d8 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -12,7 +12,7 @@ class Projects::VariablesController < Projects::ApplicationController
def update
if @project.update(variables_params)
respond_to do |format|
- format.json { return render_variables }
+ format.json { render_variables }
end
else
respond_to do |format|
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index c4930d3d18d..1b0751f48c5 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -29,8 +29,7 @@ class Projects::WikisController < Projects::ApplicationController
else
return render('empty') unless can?(current_user, :create_wiki, @project)
- @page = WikiPage.new(@project_wiki)
- @page.title = params[:id]
+ @page = build_page(title: params[:id])
render 'edit'
end
@@ -54,7 +53,7 @@ class Projects::WikisController < Projects::ApplicationController
else
render 'edit'
end
- rescue WikiPage::PageChangedError, WikiPage::PageRenameError => e
+ rescue WikiPage::PageChangedError, WikiPage::PageRenameError, Gitlab::Git::Wiki::OperationError => e
@error = e
render 'edit'
end
@@ -70,6 +69,11 @@ class Projects::WikisController < Projects::ApplicationController
else
render action: "edit"
end
+ rescue Gitlab::Git::Wiki::OperationError => e
+ @page = build_page(wiki_params)
+ @error = e
+
+ render 'edit'
end
def history
@@ -94,6 +98,9 @@ class Projects::WikisController < Projects::ApplicationController
redirect_to project_wiki_path(@project, :home),
status: 302,
notice: "Page was successfully deleted"
+ rescue Gitlab::Git::Wiki::OperationError => e
+ @error = e
+ render 'edit'
end
def git_access
@@ -116,4 +123,10 @@ class Projects::WikisController < Projects::ApplicationController
def wiki_params
params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha)
end
+
+ def build_page(args)
+ WikiPage.new(@project_wiki).tap do |page|
+ page.update_attributes(args)
+ end
+ end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index ee197c75764..a93b116c6fe 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -324,7 +324,7 @@ class ProjectsController < Projects::ApplicationController
:avatar,
:build_allow_git_fetch,
:build_coverage_regex,
- :build_timeout_in_minutes,
+ :build_timeout_human_readable,
:resolve_outdated_diff_discussions,
:container_registry_enabled,
:default_branch,
@@ -404,7 +404,7 @@ class ProjectsController < Projects::ApplicationController
params[:namespace_id] = project.namespace.to_param
params[:id] = project.to_param
- url_for(params)
+ url_for(safe_params)
end
def project_export_enabled
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index be2d3f638ff..3d51520ddf4 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -6,6 +6,8 @@ class SnippetsController < ApplicationController
include RendersBlob
include PreviewMarkdown
+ skip_before_action :verify_authenticity_token, only: [:show], if: :js_request?
+
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw]
# Allow read snippet
@@ -77,6 +79,8 @@ class SnippetsController < ApplicationController
format.json do
render_blob_json(blob)
end
+
+ format.js { render 'shared/snippets/show' }
end
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 956df4a0a16..31f47a7aa7c 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -146,6 +146,6 @@ class UsersController < ApplicationController
end
def build_canonical_path(user)
- url_for(params.merge(username: user.to_param))
+ url_for(safe_params.merge(username: user.to_param))
end
end
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
index e72fd8eb3a5..051ea108e06 100644
--- a/app/finders/group_descendants_finder.rb
+++ b/app/finders/group_descendants_finder.rb
@@ -134,10 +134,8 @@ class GroupDescendantsFinder
end
def direct_child_projects
- GroupProjectsFinder.new(group: parent_group,
- current_user: current_user,
- options: { only_owned: true },
- params: params).execute
+ GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params)
+ .execute
end
# Finds all projects nested under `parent_group` or any of its descendant
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index 0282b378d88..0754123a3cf 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -39,7 +39,7 @@ class GroupsFinder < UnionFinder
def all_groups
return [owned_groups] if params[:owned]
- return [Group.all] if current_user&.full_private_access?
+ return [Group.all] if current_user&.full_private_access? && all_available?
groups = []
groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups if current_user
@@ -67,6 +67,10 @@ class GroupsFinder < UnionFinder
end
def include_public_groups?
- current_user.nil? || params.fetch(:all_available, true)
+ current_user.nil? || all_available?
+ end
+
+ def all_available?
+ params.fetch(:all_available, true)
end
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 61c72aa22a8..7ed9b1fc6d0 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -159,7 +159,10 @@ class IssuableFinder
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
+ opts = { current_user: current_user }
+ opts[:project_ids_relation] = item_project_ids(items) if items
+
+ ProjectsFinder.new(opts).execute
end
@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
@@ -316,9 +319,9 @@ class IssuableFinder
def by_project(items)
items =
if project?
- items.of_projects(projects(items)).references_project
- elsif projects(items)
- items.merge(projects(items).reorder(nil)).join_project
+ items.of_projects(projects).references_project
+ elsif projects
+ items.merge(projects.reorder(nil)).join_project
else
items.none
end
diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb
index f358938344e..188ec447a94 100644
--- a/app/finders/merge_request_target_project_finder.rb
+++ b/app/finders/merge_request_target_project_finder.rb
@@ -12,6 +12,7 @@ class MergeRequestTargetProjectFinder
if @source_project.fork_network
@source_project.fork_network.projects
.public_or_visible_to_user(current_user)
+ .non_archived
.with_feature_available_for_user(:merge_requests, current_user)
else
Project.where(id: source_project)
diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb
index f187a3b61fe..0a487839aff 100644
--- a/app/finders/pipelines_finder.rb
+++ b/app/finders/pipelines_finder.rb
@@ -14,6 +14,7 @@ class PipelinesFinder
items = by_scope(items)
items = by_status(items)
items = by_ref(items)
+ items = by_sha(items)
items = by_name(items)
items = by_username(items)
items = by_yaml_errors(items)
@@ -69,6 +70,14 @@ class PipelinesFinder
end
end
+ def by_sha(items)
+ if params[:sha].present?
+ items.where(sha: params[:sha])
+ else
+ items
+ end
+ end
+
def by_name(items)
if params[:name].present?
items.joins(:user).where(users: { name: params[:name] })
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
index edde8022ec9..65824a51919 100644
--- a/app/finders/users_finder.rb
+++ b/app/finders/users_finder.rb
@@ -32,6 +32,7 @@ class UsersFinder
users = by_active(users)
users = by_external_identity(users)
users = by_external(users)
+ users = by_2fa(users)
users = by_created_at(users)
users = by_custom_attributes(users)
@@ -76,4 +77,15 @@ class UsersFinder
users.external
end
+
+ def by_2fa(users)
+ case params[:two_factor]
+ when 'enabled'
+ users.with_two_factor
+ when 'disabled'
+ users.without_two_factor
+ else
+ users
+ end
+ end
end
diff --git a/app/helpers/active_sessions_helper.rb b/app/helpers/active_sessions_helper.rb
new file mode 100644
index 00000000000..97b6dac67c5
--- /dev/null
+++ b/app/helpers/active_sessions_helper.rb
@@ -0,0 +1,23 @@
+module ActiveSessionsHelper
+ # Maps a device type as defined in `ActiveSession` to an svg icon name and
+ # outputs the icon html.
+ #
+ # see `DeviceDetector::Device::DEVICE_NAMES` about the available device types
+ def active_session_device_type_icon(active_session)
+ icon_name =
+ case active_session.device_type
+ when 'smartphone', 'feature phone', 'phablet'
+ 'mobile'
+ when 'tablet'
+ 'tablet'
+ when 'tv', 'smart display', 'camera', 'portable media player', 'console'
+ 'media'
+ when 'car browser'
+ 'car'
+ else
+ 'monitor-o'
+ end
+
+ sprite_icon(icon_name, size: 16, css_class: 'prepend-top-2')
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 86ec500ceb3..6aa307b4db4 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -32,80 +32,6 @@ module ApplicationHelper
args.any? { |v| v.to_s.downcase == action_name }
end
- def project_icon(project_id, options = {})
- project =
- if project_id.respond_to?(:avatar_url)
- project_id
- else
- Project.find_by_full_path(project_id)
- end
-
- if project.avatar_url
- image_tag project.avatar_url, options
- else # generated icon
- project_identicon(project, options)
- end
- end
-
- def project_identicon(project, options = {})
- allowed_colors = {
- red: 'FFEBEE',
- purple: 'F3E5F5',
- indigo: 'E8EAF6',
- blue: 'E3F2FD',
- teal: 'E0F2F1',
- orange: 'FBE9E7',
- gray: 'EEEEEE'
- }
-
- options[:class] ||= ''
- options[:class] << ' identicon'
- bg_key = project.id % 7
- style = "background-color: ##{allowed_colors.values[bg_key]}; color: #555"
-
- content_tag(:div, class: options[:class], style: style) do
- project.name[0, 1].upcase
- end
- 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(nil, size, scale)
- end
- end
-
- def gravatar_icon(user_email = '', size = nil, scale = 2)
- GravatarService.new.execute(user_email, size, scale) ||
- default_avatar
- end
-
- def default_avatar
- asset_path('no_avatar.png')
- end
-
def last_commit(project)
if project.repo_exists?
time_ago_with_tooltip(project.repository.commit.committed_date)
@@ -228,9 +154,7 @@ module ApplicationHelper
scope: params[:scope],
milestone_title: params[:milestone_title],
assignee_id: params[:assignee_id],
- assignee_username: params[:assignee_username],
author_id: params[:author_id],
- author_username: params[:author_username],
search: params[:search],
label_name: params[:label_name]
}
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index b3b080e6dcf..3fbb32c5229 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -74,10 +74,12 @@ module ApplicationSettingsHelper
css_class = 'btn'
css_class << ' active' unless disabled
checkbox_name = 'application_setting[enabled_oauth_sign_in_sources][]'
+ name = Gitlab::Auth::OAuth::Provider.label_for(source)
label_tag(checkbox_name, class: css_class) do
check_box_tag(checkbox_name, source, !disabled,
- autocomplete: 'off') + Gitlab::Auth::OAuth::Provider.label_for(source)
+ autocomplete: 'off',
+ id: name.tr(' ', '_')) + name
end
end
end
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index c109954f3a3..d2daee22aba 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -1,6 +1,6 @@
module AuthHelper
PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze
- FORM_BASED_PROVIDERS = [/\Aldap/, 'crowd'].freeze
+ LDAP_PROVIDER = /\Aldap/
def ldap_enabled?
Gitlab::Auth::LDAP::Config.enabled?
@@ -23,7 +23,7 @@ module AuthHelper
end
def form_based_provider?(name)
- FORM_BASED_PROVIDERS.any? { |pattern| pattern === name.to_s }
+ [LDAP_PROVIDER, 'crowd'].any? { |pattern| pattern === name.to_s }
end
def form_based_providers
@@ -38,6 +38,10 @@ module AuthHelper
auth_providers.reject { |provider| form_based_provider?(provider) }
end
+ def providers_for_base_controller
+ auth_providers.reject { |provider| LDAP_PROVIDER === provider }
+ end
+
def enabled_button_based_providers
disabled_providers = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources || []
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 21b6c0a8ad5..d339c01d492 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -1,4 +1,78 @@
module AvatarsHelper
+ def project_icon(project_id, options = {})
+ project =
+ if project_id.respond_to?(:avatar_url)
+ project_id
+ else
+ Project.find_by_full_path(project_id)
+ end
+
+ if project.avatar_url
+ image_tag project.avatar_url, options
+ else # generated icon
+ project_identicon(project, options)
+ end
+ end
+
+ def project_identicon(project, options = {})
+ allowed_colors = {
+ red: 'FFEBEE',
+ purple: 'F3E5F5',
+ indigo: 'E8EAF6',
+ blue: 'E3F2FD',
+ teal: 'E0F2F1',
+ orange: 'FBE9E7',
+ gray: 'EEEEEE'
+ }
+
+ options[:class] ||= ''
+ options[:class] << ' identicon'
+ bg_key = project.id % 7
+ style = "background-color: ##{allowed_colors.values[bg_key]}; color: #555"
+
+ content_tag(:div, class: options[:class], style: style) do
+ project.name[0, 1].upcase
+ end
+ 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(nil, size, scale)
+ end
+ end
+
+ def gravatar_icon(user_email = '', size = nil, scale = 2)
+ GravatarService.new.execute(user_email, size, scale) ||
+ default_avatar
+ end
+
+ def default_avatar
+ ActionController::Base.helpers.image_path('no_avatar.png')
+ end
+
def author_avatar(commit_or_event, options = {})
user_avatar(options.merge({
user: commit_or_event.author,
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 2b440e4d584..e7a36e20050 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -17,7 +17,7 @@ module BlobHelper
end
def ide_edit_path(project = @project, ref = @ref, path = @path, options = {})
- "#{ide_path}/project#{edit_blob_path(project, ref, path, options)}"
+ "#{ide_path}/project#{url_for([project, "edit", "blob", id: [ref, path], script_name: "/"])}"
end
def edit_blob_button(project = @project, ref = @ref, path = @path, options = {})
@@ -59,7 +59,7 @@ module BlobHelper
button_tag label, class: "#{common_classes} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' }
elsif can_modify_blob?(blob, project, ref)
button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal'
- elsif can?(current_user, :fork_project, project)
+ elsif can?(current_user, :fork_project, project) && can?(current_user, :create_merge_request_in, project)
edit_fork_button_tag(common_classes, project, label, edit_modify_file_fork_params(action), action)
end
end
@@ -259,7 +259,7 @@ module BlobHelper
options = []
if error == :collapsed
- options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, expanded: true, format: nil)))
+ options << link_to('load it anyway', url_for(safe_params.merge(viewer: viewer.type, expanded: true, format: nil)))
end
# If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error,
@@ -280,7 +280,7 @@ module BlobHelper
options << link_to("submit an issue", new_project_issue_path(project))
end
- merge_project = can?(current_user, :create_merge_request, project) ? project : (current_user && current_user.fork_of(project))
+ merge_project = merge_request_source_project_for_project(@project)
if merge_project
options << link_to("create a merge request", project_new_merge_request_path(project))
end
@@ -334,7 +334,7 @@ module BlobHelper
# 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)
+ elsif can?(current_user, :fork_project, project) && can?(current_user, :create_merge_request_in, project)
edit_fork_button_tag(common_classes, project, text, edit_blob_fork_params(edit_path))
end
end
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
index 636316da80a..f0afcac5986 100644
--- a/app/helpers/ci_status_helper.rb
+++ b/app/helpers/ci_status_helper.rb
@@ -94,7 +94,7 @@ module CiStatusHelper
def render_project_pipeline_status(pipeline_status, tooltip_placement: 'auto left')
project = pipeline_status.project
- path = pipelines_project_commit_path(project, pipeline_status.sha)
+ path = pipelines_project_commit_path(project, pipeline_status.sha, ref: pipeline_status.ref)
render_status_with_link(
'commit',
@@ -105,7 +105,7 @@ module CiStatusHelper
def render_commit_status(commit, ref: nil, tooltip_placement: 'auto left')
project = commit.project
- path = pipelines_project_commit_path(project, commit)
+ path = pipelines_project_commit_path(project, commit, ref: ref)
render_status_with_link(
'commit',
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index 0333c29e2fd..e594a1d0ba3 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -63,7 +63,7 @@ module CommitsHelper
# 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}"
+ sprite_icon('fork', size: 12, css_class: 'fork-svg') + "#{text}"
end
end
@@ -93,25 +93,18 @@ module CommitsHelper
return unless current_controller?(:commits)
if @path.blank?
- return link_to(
- _("Browse Files"),
- project_tree_path(project, commit),
- class: "btn btn-default"
- )
+ url = project_tree_path(project, commit)
+ tooltip = _("Browse Files")
elsif @repo.blob_at(commit.id, @path)
- return link_to(
- _("Browse File"),
- project_blob_path(project,
- tree_join(commit.id, @path)),
- class: "btn btn-default"
- )
+ url = project_blob_path(project, tree_join(commit.id, @path))
+ tooltip = _("Browse File")
elsif @path.present?
- return link_to(
- _("Browse Directory"),
- project_tree_path(project,
- tree_join(commit.id, @path)),
- class: "btn btn-default"
- )
+ url = project_tree_path(project, tree_join(commit.id, @path))
+ tooltip = _("Browse Directory")
+ end
+
+ link_to url, class: "btn btn-default has-tooltip", title: tooltip, data: { container: "body" } do
+ sprite_icon('folder-open')
end
end
@@ -170,7 +163,7 @@ module CommitsHelper
tooltip = "#{action.capitalize} this #{commit.change_type_title(current_user)} in a new merge request" if has_tooltip
btn_class = "btn btn-#{btn_class}" unless btn_class.nil?
- if can_collaborate_with_project?
+ if can_collaborate_with_project?(@project)
link_to action.capitalize, "#modal-#{action}-commit", 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}"
elsif can?(current_user, :fork_project, @project)
continue_params = {
diff --git a/app/helpers/compare_helper.rb b/app/helpers/compare_helper.rb
index 8bf96c0905f..2df5b5d1695 100644
--- a/app/helpers/compare_helper.rb
+++ b/app/helpers/compare_helper.rb
@@ -3,7 +3,7 @@ module CompareHelper
from.present? &&
to.present? &&
from != to &&
- can?(current_user, :create_merge_request, project) &&
+ can?(current_user, :create_merge_request_from, project) &&
project.repository.branch_exists?(from) &&
project.repository.branch_exists?(to)
end
diff --git a/app/helpers/deploy_tokens_helper.rb b/app/helpers/deploy_tokens_helper.rb
new file mode 100644
index 00000000000..bd921322476
--- /dev/null
+++ b/app/helpers/deploy_tokens_helper.rb
@@ -0,0 +1,12 @@
+module DeployTokensHelper
+ def expand_deploy_tokens_section?(deploy_token)
+ deploy_token.persisted? ||
+ deploy_token.errors.present? ||
+ Rails.env.test?
+ end
+
+ def container_registry_enabled?(project)
+ Gitlab.config.registry.enabled &&
+ can?(current_user, :read_container_image, project)
+ end
+end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index b5ca39711bc..1bb82fd8150 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -180,7 +180,7 @@ module DiffHelper
private
def diff_btn(title, name, selected)
- params_copy = params.dup
+ params_copy = safe_params.dup
params_copy[:view] = name
# Always use HTML to handle case where JSON diff rendered this button
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 5089da519df..5a2360b4661 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -41,7 +41,7 @@ module DropdownsHelper
def dropdown_toggle(toggle_text, data_attr, options = {})
default_label = data_attr[:default_label]
- content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), type: "button", data: data_attr) do
+ content_tag(:button, disabled: options[:disabled], class: "dropdown-menu-toggle #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), type: "button", data: data_attr) do
output = content_tag(:span, toggle_text, class: "dropdown-toggle-text #{'is-default' if toggle_text == default_label}")
output << icon('chevron-down')
output.html_safe
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 7f3c118c7ab..40073f714ee 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -81,6 +81,14 @@ module GitlabRoutingHelper
end
end
+ def edit_milestone_path(entity, *args)
+ if entity.parent.is_a?(Group)
+ edit_group_milestone_path(entity.parent, entity, *args)
+ else
+ edit_project_milestone_path(entity.parent, entity, *args)
+ end
+ end
+
def toggle_subscription_path(entity, *args)
if entity.is_a?(Issue)
toggle_subscription_project_issue_path(entity.project, entity)
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 16eceb3f48f..95fea2f18d1 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -1,6 +1,6 @@
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]
+ %w[groups#projects groups#edit badges#index ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index]
end
def group_sidebar_links
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index c5522ff7a69..2f304b040c7 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -43,6 +43,10 @@ module IconsHelper
content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes)
end
+ def external_snippet_icon(name)
+ content_tag(:span, "", class: "gl-snippet-icon gl-snippet-icon-#{name}")
+ end
+
def audit_icon(names, options = {})
case names
when "standard"
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 6d6b840f485..f39a62bccc8 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -9,6 +9,32 @@ module IssuablesHelper
"right-sidebar-#{sidebar_gutter_collapsed? ? 'collapsed' : 'expanded'}"
end
+ def sidebar_gutter_tooltip_text
+ sidebar_gutter_collapsed? ? _('Expand sidebar') : _('Collapse sidebar')
+ end
+
+ def sidebar_assignee_tooltip_label(issuable)
+ if issuable.assignee
+ issuable.assignee.name
+ else
+ issuable.allows_multiple_assignees? ? _('Assignee(s)') : _('Assignee')
+ end
+ end
+
+ def sidebar_due_date_tooltip_label(issuable)
+ if issuable.due_date
+ "#{_('Due date')}<br />#{due_date_remaining_days(issuable)}"
+ else
+ _('Due date')
+ end
+ end
+
+ def due_date_remaining_days(issuable)
+ remaining_days_in_words = remaining_days_in_words(issuable)
+
+ "#{issuable.due_date.to_s(:medium)} (#{remaining_days_in_words})"
+ end
+
def multi_label_name(current_labels, default_label)
if current_labels && current_labels.any?
title = current_labels.first.try(:title)
@@ -153,22 +179,28 @@ module IssuablesHelper
def issuable_labels_tooltip(labels, limit: 5)
first, last = labels.partition.with_index { |_, i| i < limit }
- label_names = first.collect(&:name)
- label_names << "and #{last.size} more" unless last.empty?
+ if labels && labels.any?
+ label_names = first.collect(&:name)
+ label_names << "and #{last.size} more" unless last.empty?
- label_names.join(', ')
+ label_names.join(', ')
+ else
+ _("Labels")
+ end
end
- def issuables_state_counter_text(issuable_type, state)
+ def issuables_state_counter_text(issuable_type, state, display_count)
titles = {
opened: "Open"
}
state_title = titles[state] || state.to_s.humanize
- count = issuables_count_for_state(issuable_type, state)
-
html = content_tag(:span, state_title)
- html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
+
+ if display_count
+ count = issuables_count_for_state(issuable_type, state)
+ html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
+ end
html.html_safe
end
@@ -191,24 +223,10 @@ module IssuablesHelper
end
end
- def issuable_filter_params
- [
- :search,
- :author_id,
- :assignee_id,
- :milestone_title,
- :label_name
- ]
- end
-
def issuable_reference(issuable)
@show_full_reference ? issuable.to_reference(full: true) : issuable.to_reference(@group || @project)
end
- def issuable_filter_present?
- issuable_filter_params.any? { |k| params.key?(k) }
- end
-
def issuable_initial_data(issuable)
data = {
endpoint: issuable_path(issuable),
@@ -333,7 +351,7 @@ module IssuablesHelper
def issuable_todo_button_data(issuable, todo, is_collapsed)
{
todo_text: "Add todo",
- mark_text: "Mark done",
+ mark_text: "Mark todo as done",
todo_icon: (is_collapsed ? icon('plus-square') : nil),
mark_icon: (is_collapsed ? icon('check-square', class: 'todo-undone') : nil),
issuable_id: issuable.id,
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 0f25d401406..96dc7ae1185 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -82,8 +82,8 @@ module IssuesHelper
names.to_sentence
end
- def award_state_class(awards, current_user)
- if !current_user
+ def award_state_class(awardable, awards, current_user)
+ if !can?(current_user, :award_emoji, awardable)
"disabled"
elsif current_user && awards.find { |a| a.user_id == current_user.id }
"active"
@@ -126,6 +126,17 @@ module IssuesHelper
link_to link_text, path
end
+ def show_new_issue_link?(project)
+ return false unless project
+ return false if project.archived?
+
+ # We want to show the link to users that are not signed in, that way they
+ # get directed to the sign-in/sign-up flow and afterwards to the new issue page.
+ return true unless current_user
+
+ can?(current_user, :create_issue, project)
+ end
+
# Required for Banzai::Filter::IssueReferenceFilter
module_function :url_for_issue
module_function :url_for_internal_issue
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 2fe1927a189..39e7a7fd396 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -256,7 +256,7 @@ module MarkupHelper
return '' unless html.present?
context.merge!(
- current_user: (current_user if defined?(current_user)),
+ current_user: (current_user if defined?(current_user)),
# RelativeLinkFilter
commit: @commit,
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index fb4fe1c40b7..c19c5b9cc82 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -138,6 +138,18 @@ module MergeRequestsHelper
end
end
+ def merge_request_source_project_for_project(project = @project)
+ unless can?(current_user, :create_merge_request_in, project)
+ return nil
+ end
+
+ if can?(current_user, :create_merge_request_from, project)
+ project
+ else
+ current_user.fork_of(project)
+ end
+ end
+
def merge_params_ee(merge_request)
{}
end
diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb
index be8cb358de2..e8caab3e50c 100644
--- a/app/helpers/milestones_helper.rb
+++ b/app/helpers/milestones_helper.rb
@@ -1,4 +1,6 @@
module MilestonesHelper
+ include EntityDateHelper
+
def milestones_filter_path(opts = {})
if @project
project_milestones_path(@project, opts)
@@ -72,6 +74,19 @@ module MilestonesHelper
end
end
+ def milestone_progress_tooltip_text(milestone)
+ has_issues = milestone.total_issues_count(current_user) > 0
+
+ if has_issues
+ [
+ _('Progress'),
+ _("%{percent}%% complete") % { percent: milestone.percent_complete(current_user) }
+ ].join('<br />')
+ else
+ _('Progress')
+ end
+ end
+
def milestone_progress_bar(milestone)
options = {
class: 'progress-bar progress-bar-success',
@@ -95,27 +110,69 @@ module MilestonesHelper
end
def milestone_tooltip_title(milestone)
- if milestone.due_date
- [milestone.due_date.to_s(:medium), "(#{milestone_remaining_days(milestone)})"].join(' ')
+ if milestone
+ "#{milestone.title}<br />#{milestone_tooltip_due_date(milestone)}"
+ else
+ _('Milestone')
end
end
- def milestone_remaining_days(milestone)
- if milestone.expired?
- content_tag(:strong, 'Past due')
- elsif milestone.upcoming?
- content_tag(:strong, 'Upcoming')
- elsif milestone.due_date
- time_ago = time_ago_in_words(milestone.due_date)
- content = time_ago.gsub(/\d+/) { |match| "<strong>#{match}</strong>" }
- content.slice!("about ")
- content << " remaining"
- content.html_safe
- elsif milestone.start_date && milestone.start_date.past?
- days = milestone.elapsed_days
- content = content_tag(:strong, days)
- content << " #{'day'.pluralize(days)} elapsed"
+ def milestone_time_for(date, date_type)
+ title = date_type == :start ? "Start date" : "End date"
+
+ if date
+ time_ago = time_ago_in_words(date)
+ time_ago.slice!("about ")
+
+ time_ago << if date.past?
+ " ago"
+ else
+ " remaining"
+ end
+
+ content = [
+ title,
+ "<br />",
+ date.to_s(:medium),
+ "(#{time_ago})"
+ ].join(" ")
+
content.html_safe
+ else
+ title
+ end
+ end
+
+ def milestone_issues_tooltip_text(milestone)
+ issues = milestone.count_issues_by_state(current_user)
+
+ return _("Issues") if issues.empty?
+
+ content = []
+
+ content << n_("1 open issue", "%d open issues", issues["opened"]) % issues["opened"] if issues["opened"]
+ content << n_("1 closed issue", "%d closed issues", issues["closed"]) % issues["closed"] if issues["closed"]
+
+ content.join('<br />').html_safe
+ end
+
+ def milestone_merge_requests_tooltip_text(milestone)
+ merge_requests = milestone.merge_requests
+
+ return _("Merge requests") if merge_requests.empty?
+
+ content = []
+
+ content << n_("1 open merge request", "%d open merge requests", merge_requests.opened.count) % merge_requests.opened.count if merge_requests.opened.any?
+ content << n_("1 closed merge request", "%d closed merge requests", merge_requests.closed.count) % merge_requests.closed.count if merge_requests.closed.any?
+ content << n_("1 merged merge request", "%d merged merge requests", merge_requests.merged.count) % merge_requests.merged.count if merge_requests.merged.any?
+
+ content.join('<br />').html_safe
+ end
+
+ def milestone_tooltip_due_date(milestone)
+ if milestone.due_date
+ "#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone)})"
end
end
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 56c88e6eab0..7754c34d6f0 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -28,7 +28,7 @@ module NavHelper
end
elsif current_path?('jobs#show')
%w[page-gutter build-sidebar right-sidebar-expanded]
- elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access')
+ elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access', 'destroy')
%w[page-gutter wiki-sidebar right-sidebar-expanded]
else
[]
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index 27ed48fdbc7..7f67574a428 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -6,10 +6,6 @@ module NotesHelper
end
end
- def note_editable?(note)
- Ability.can_edit_note?(current_user, note)
- end
-
def note_supports_quick_actions?(note)
Notes::QuickActionsService.supported?(note)
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 15f48e43a28..eb81dc2de43 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -157,40 +157,6 @@ module ProjectsHelper
current_user&.recent_push(@project)
end
- def project_feature_access_select(field)
- # Don't show option "everyone with access" if project is private
- options = project_feature_options
-
- level = @project.project_feature.public_send(field) # rubocop:disable GitlabSecurity/PublicSend
-
- if @project.private?
- disabled_option = ProjectFeature::ENABLED
- highest_available_option = ProjectFeature::PRIVATE if level == disabled_option
- end
-
- options = options_for_select(
- options.invert,
- selected: highest_available_option || level,
- disabled: disabled_option
- )
-
- content_tag :div, class: "select-wrapper" do
- concat(
- content_tag(
- :select,
- options,
- name: "project[project_feature_attributes][#{field}]",
- id: "project_project_feature_attributes_#{field}",
- class: "pull-right form-control select-control #{repo_children_classes(field)} ",
- data: { field: field }
- )
- )
- concat(
- icon('chevron-down')
- )
- end.html_safe
- end
-
def link_to_autodeploy_doc
link_to _('About auto deploy'), help_page_path('ci/autodeploy/index'), target: '_blank'
end
@@ -274,16 +240,6 @@ module ProjectsHelper
private
- def repo_children_classes(field)
- needs_repo_check = [:merge_requests_access_level, :builds_access_level]
- return unless needs_repo_check.include?(field)
-
- classes = "project-repo-select js-repo-select"
- classes << " disabled" unless @project.feature_available?(:repository, current_user)
-
- classes
- end
-
def get_project_nav_tabs(project, current_user)
nav_tabs = [:home]
@@ -444,15 +400,8 @@ module ProjectsHelper
exports_path = File.join(Settings.shared['path'], 'tmp/project_exports')
filtered_message = message.strip.gsub(exports_path, "[REPO EXPORT PATH]")
- filtered_message.gsub(project.repository_storage_path.chomp('/'), "[REPOS PATH]")
- end
-
- def project_feature_options
- {
- ProjectFeature::DISABLED => s_('ProjectFeature|Disabled'),
- ProjectFeature::PRIVATE => s_('ProjectFeature|Only team members'),
- ProjectFeature::ENABLED => s_('ProjectFeature|Everyone with access')
- }
+ disk_path = Gitlab.config.repositories.storages[project.repository_storage].legacy_disk_path
+ filtered_message.gsub(disk_path.chomp('/'), "[REPOS PATH]")
end
def project_child_container_class(view_path)
@@ -463,20 +412,6 @@ module ProjectsHelper
IssuesFinder.new(current_user, project_id: project.id).execute
end
- def visibility_select_options(project, selected_level)
- level_options = Gitlab::VisibilityLevel.values.each_with_object([]) do |level, level_options|
- next if restricted_levels.include?(level)
-
- level_options << [
- visibility_level_label(level),
- { data: { description: visibility_level_description(level, project) } },
- level
- ]
- end
-
- options_for_select(level_options, selected_level)
- end
-
def restricted_levels
return [] if current_user.admin?
@@ -507,7 +442,7 @@ module ProjectsHelper
visibilityHelpPath: help_page_path('public_access/public_access'),
registryAvailable: Gitlab.config.registry.enabled,
registryHelpPath: help_page_path('user/project/container_registry'),
- lfsAvailable: Gitlab.config.lfs.enabled && current_user.admin?,
+ lfsAvailable: Gitlab.config.lfs.enabled,
lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
}
diff --git a/app/helpers/safe_params_helper.rb b/app/helpers/safe_params_helper.rb
new file mode 100644
index 00000000000..b568e8810cc
--- /dev/null
+++ b/app/helpers/safe_params_helper.rb
@@ -0,0 +1,11 @@
+module SafeParamsHelper
+ # Rails 5.0 requires to permit `params` if they're used in url helpers.
+ # Use this helper when generating links with `params.merge(...)`
+ def safe_params
+ if params.respond_to?(:permit!)
+ params.except(:host, :port, :protocol).permit!
+ else
+ params
+ end
+ end
+end
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index f435c80c656..f872990122e 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -1,4 +1,29 @@
module ServicesHelper
+ def service_event_description(event)
+ case event
+ when "push", "push_events"
+ "Event will be triggered by a push to the repository"
+ when "tag_push", "tag_push_events"
+ "Event will be triggered when a new tag is pushed to the repository"
+ when "note", "note_events"
+ "Event will be triggered when someone adds a comment"
+ when "confidential_note", "confidential_note_events"
+ "Event will be triggered when someone adds a comment on a confidential issue"
+ when "issue", "issue_events"
+ "Event will be triggered when an issue is created/updated/closed"
+ when "confidential_issue", "confidential_issues_events"
+ "Event will be triggered when a confidential issue is created/updated/closed"
+ when "merge_request", "merge_request_events"
+ "Event will be triggered when a merge request is created/updated/merged"
+ when "pipeline", "pipeline_events"
+ "Event will be triggered when a pipeline status changes"
+ when "wiki_page", "wiki_page_events"
+ "Event will be triggered when a wiki page is created/updated"
+ when "commit", "commit_events"
+ "Event will be triggered when a commit is created/updated"
+ end
+ end
+
def service_event_field_name(event)
event = event.pluralize if %w[merge_request issue confidential_issue].include?(event)
"#{event}_events"
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 00e7e4230b9..733832c1bbb 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -101,4 +101,39 @@ module SnippetsHelper
# Return snippet with chunk array
{ snippet_object: snippet, snippet_chunks: snippet_chunks }
end
+
+ def snippet_embed
+ "<script src=\"#{url_for(only_path: false, overwrite_params: nil)}.js\"></script>"
+ end
+
+ def embedded_snippet_raw_button
+ blob = @snippet.blob
+ return if blob.empty? || blob.raw_binary? || blob.stored_externally?
+
+ snippet_raw_url = if @snippet.is_a?(PersonalSnippet)
+ raw_snippet_url(@snippet)
+ else
+ raw_project_snippet_url(@snippet.project, @snippet)
+ end
+
+ link_to external_snippet_icon('doc_code'), snippet_raw_url, class: 'btn', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw'
+ end
+
+ def embedded_snippet_download_button
+ download_url = if @snippet.is_a?(PersonalSnippet)
+ raw_snippet_url(@snippet, inline: false)
+ else
+ raw_project_snippet_url(@snippet.project, @snippet, inline: false)
+ end
+
+ link_to external_snippet_icon('download'), download_url, class: 'btn', target: '_blank', title: 'Download', rel: 'noopener noreferrer'
+ end
+
+ def public_snippet?
+ if @snippet.project_id?
+ can?(nil, :read_project_snippet, @snippet)
+ else
+ can?(nil, :read_personal_snippet, @snippet)
+ end
+ end
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 00fe67d6ffb..5b4a141dbcf 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -1,14 +1,14 @@
module SystemNoteHelper
ICON_NAMES_BY_ACTION = {
'commit' => 'commit',
- 'description' => 'pencil',
+ 'description' => 'pencil-square',
'merge' => 'git-merge',
'merged' => 'git-merge',
'opened' => 'issue-open',
'closed' => 'issue-close',
'time_tracking' => 'timer',
'assignee' => 'user',
- 'title' => 'pencil',
+ 'title' => 'pencil-square',
'task' => 'task-done',
'label' => 'label',
'cross_reference' => 'comment-dots',
@@ -18,7 +18,7 @@ module SystemNoteHelper
'milestone' => 'clock',
'discussion' => 'comment',
'moved' => 'arrow-right',
- 'outdated' => 'pencil',
+ 'outdated' => 'pencil-square',
'duplicate' => 'issue-duplicate',
'locked' => 'lock',
'unlocked' => 'lock-open'
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 5e7c20ef51e..dc42caa70e5 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -90,7 +90,7 @@ module TreeHelper
end
def commit_in_single_accessible_branch
- branch_name = html_escape(selected_branch)
+ branch_name = ERB::Util.html_escape(selected_branch)
message = _("Your changes can be committed to %{branch_name} because a merge "\
"request is open.") % { branch_name: "<strong>#{branch_name}</strong>" }
diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb
index 88f374be1e5..9f78b80c71d 100644
--- a/app/helpers/workhorse_helper.rb
+++ b/app/helpers/workhorse_helper.rb
@@ -24,8 +24,8 @@ module WorkhorseHelper
end
# Archive a Git repository and send it through Workhorse
- def send_git_archive(repository, ref:, format:)
- headers.store(*Gitlab::Workhorse.send_git_archive(repository, ref: ref, format: format))
+ def send_git_archive(repository, **kwargs)
+ headers.store(*Gitlab::Workhorse.send_git_archive(repository, **kwargs))
head :ok
end
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index b33131becd3..392cc0bee03 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -6,6 +6,12 @@ module Emails
mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id, reason))
end
+ def issue_due_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, reason))
+ end
+
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, reason))
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index e4212775956..3646e08a15f 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -16,6 +16,7 @@ class Notify < BaseMailer
helper BlobHelper
helper EmailsHelper
helper MembersHelper
+ helper AvatarsHelper
helper GitlabRoutingHelper
def test_email(recipient_email, subject, body)
diff --git a/app/models/ability.rb b/app/models/ability.rb
index 6dae49f38dc..618d4af4272 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -46,10 +46,6 @@ class Ability
end
end
- def can_edit_note?(user, note)
- allowed?(user, :edit_note, note)
- end
-
def allowed?(user, action, subject = :global, opts = {})
if subject.is_a?(Hash)
opts, subject = subject, :global
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
new file mode 100644
index 00000000000..b4a86dbb331
--- /dev/null
+++ b/app/models/active_session.rb
@@ -0,0 +1,110 @@
+class ActiveSession
+ include ActiveModel::Model
+
+ attr_accessor :created_at, :updated_at,
+ :session_id, :ip_address,
+ :browser, :os, :device_name, :device_type
+
+ def current?(session)
+ return false if session_id.nil? || session.id.nil?
+
+ session_id == session.id
+ end
+
+ def human_device_type
+ device_type&.titleize
+ end
+
+ def self.set(user, request)
+ Gitlab::Redis::SharedState.with do |redis|
+ session_id = request.session.id
+ client = DeviceDetector.new(request.user_agent)
+ timestamp = Time.current
+
+ active_user_session = new(
+ ip_address: request.ip,
+ browser: client.name,
+ os: client.os_name,
+ device_name: client.device_name,
+ device_type: client.device_type,
+ created_at: user.current_sign_in_at || timestamp,
+ updated_at: timestamp,
+ session_id: session_id
+ )
+
+ redis.pipelined do
+ redis.setex(
+ key_name(user.id, session_id),
+ Settings.gitlab['session_expire_delay'] * 60,
+ Marshal.dump(active_user_session)
+ )
+
+ redis.sadd(
+ lookup_key_name(user.id),
+ session_id
+ )
+ end
+ end
+ end
+
+ def self.list(user)
+ Gitlab::Redis::SharedState.with do |redis|
+ cleaned_up_lookup_entries(redis, user.id).map do |entry|
+ # rubocop:disable Security/MarshalLoad
+ Marshal.load(entry)
+ # rubocop:enable Security/MarshalLoad
+ end
+ end
+ end
+
+ def self.destroy(user, session_id)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.srem(lookup_key_name(user.id), session_id)
+
+ deleted_keys = redis.del(key_name(user.id, session_id))
+
+ # only allow deleting the devise session if we could actually find a
+ # related active session. this prevents another user from deleting
+ # someone else's session.
+ if deleted_keys > 0
+ redis.del("#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}")
+ end
+ end
+ end
+
+ def self.cleanup(user)
+ Gitlab::Redis::SharedState.with do |redis|
+ cleaned_up_lookup_entries(redis, user.id)
+ end
+ end
+
+ def self.key_name(user_id, session_id = '*')
+ "#{Gitlab::Redis::SharedState::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}"
+ end
+
+ def self.lookup_key_name(user_id)
+ "#{Gitlab::Redis::SharedState::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}"
+ end
+
+ def self.cleaned_up_lookup_entries(redis, user_id)
+ lookup_key = lookup_key_name(user_id)
+
+ session_ids = redis.smembers(lookup_key)
+
+ entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
+ return [] if entry_keys.empty?
+
+ entries = redis.mget(entry_keys)
+
+ session_ids_and_entries = session_ids.zip(entries)
+
+ # remove expired keys.
+ # only the single key entries are automatically expired by redis, the
+ # lookup entries in the set need to be removed manually.
+ session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry|
+ redis.srem(lookup_key, session_id)
+ end
+
+ session_ids_and_entries.select { |_session_id, entry| entry }.map { |_session_id, entry| entry }
+ end
+end
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index 2a6406d63c7..fb66dd0b766 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -16,7 +16,7 @@ class Appearance < ActiveRecord::Base
has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- CACHE_KEY = 'current_appearance'.freeze
+ CACHE_KEY = "current_appearance:#{Gitlab::VERSION}".freeze
after_commit :flush_redis_cache
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 0b561203914..4aa236555cb 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -19,7 +19,7 @@ class BroadcastMessage < ActiveRecord::Base
after_commit :flush_redis_cache
def self.current
- messages = Rails.cache.fetch(CACHE_KEY) { current_and_future_messages.to_a }
+ messages = Rails.cache.fetch(CACHE_KEY, expires_in: cache_expires_in) { current_and_future_messages.to_a }
return messages if messages.empty?
@@ -36,6 +36,10 @@ class BroadcastMessage < ActiveRecord::Base
where('ends_at > :now', now: Time.zone.now).order_id_asc
end
+ def self.cache_expires_in
+ nil
+ end
+
def active?
started? && !ended?
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 18e96389199..9000ad860e9 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -20,13 +20,14 @@ module Ci
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_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # 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
has_one :metadata, class_name: 'Ci::BuildMetadata'
delegate :timeout, to: :metadata, prefix: true, allow_nil: true
+ delegate :gitlab_deploy_token, to: :project
##
# The "environment" field for builds is a String, and is the unexpanded name!
@@ -90,12 +91,13 @@ module Ci
before_save :ensure_token
before_destroy { unscoped_project }
+ before_create :ensure_metadata
after_create unless: :importing? do |build|
run_after_commit { BuildHooksWorker.perform_async(build.id) }
end
- after_commit :update_project_statistics_after_save, on: [:create, :update]
- after_commit :update_project_statistics, on: :destroy
+ after_save :update_project_statistics_after_save, if: :artifacts_size_changed?
+ after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed?
class << self
# This is needed for url_for to work,
@@ -161,7 +163,7 @@ module Ci
build.validates_dependencies! unless Feature.enabled?('ci_disable_validates_dependencies')
end
- before_transition pending: :running do |build|
+ after_transition pending: :running do |build|
build.ensure_metadata.update_timeout_state
end
end
@@ -478,7 +480,7 @@ module Ci
def user_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
- return variables if user.blank?
+ break variables if user.blank?
variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s)
variables.append(key: 'GITLAB_USER_EMAIL', value: user.email)
@@ -593,7 +595,7 @@ module Ci
def persisted_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
- return variables unless persisted?
+ break variables unless persisted?
variables
.append(key: 'CI_JOB_ID', value: id.to_s)
@@ -603,6 +605,7 @@ module Ci
.append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER)
.append(key: 'CI_REGISTRY_PASSWORD', value: token, public: false)
.append(key: 'CI_REPOSITORY_URL', value: repo_url, public: false)
+ .concat(deploy_token_variables)
end
end
@@ -610,7 +613,7 @@ module Ci
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'CI', value: 'true')
variables.append(key: 'GITLAB_CI', value: 'true')
- variables.append(key: 'GITLAB_FEATURES', value: project.namespace.features.join(','))
+ variables.append(key: 'GITLAB_FEATURES', value: project.licensed_features.join(','))
variables.append(key: 'CI_SERVER_NAME', value: 'GitLab')
variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION)
variables.append(key: 'CI_SERVER_REVISION', value: Gitlab::REVISION)
@@ -642,7 +645,7 @@ module Ci
def persisted_environment_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
- return variables unless persisted? && persisted_environment.present?
+ break variables unless persisted? && persisted_environment.present?
variables.concat(persisted_environment.predefined_variables)
@@ -653,6 +656,15 @@ module Ci
end
end
+ def deploy_token_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless gitlab_deploy_token
+
+ variables.append(key: 'CI_DEPLOY_USER', value: gitlab_deploy_token.name)
+ variables.append(key: 'CI_DEPLOY_PASSWORD', value: gitlab_deploy_token.token, public: false)
+ end
+ end
+
def environment_url
options&.dig(:environment, :url) || persisted_environment&.external_url
end
@@ -663,16 +675,20 @@ module Ci
pipeline.config_processor.build_attributes(name)
end
- def update_project_statistics
- return unless project
+ def update_project_statistics_after_save
+ update_project_statistics(read_attribute(:artifacts_size).to_i - artifacts_size_was.to_i)
+ end
- ProjectCacheWorker.perform_async(project_id, [], [:build_artifacts_size])
+ def update_project_statistics_after_destroy
+ update_project_statistics(-artifacts_size)
end
- def update_project_statistics_after_save
- if previous_changes.include?('artifacts_size')
- update_project_statistics
- end
+ def update_project_statistics(difference)
+ ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference)
+ end
+
+ def project_destroyed?
+ project.pending_delete?
end
end
end
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index 62d768cc6cf..44cb583e1bd 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -4,7 +4,7 @@ module Ci
include HasVariable
include Presentable
- belongs_to :group
+ belongs_to :group, class_name: "::Group"
alias_attribute :secret_value, :value
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index df57b4f65e3..3b952391b7e 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -7,11 +7,15 @@ module Ci
belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
+ mount_uploader :file, JobArtifactUploader
+
before_save :set_size, if: :file_changed?
+ after_save :update_project_statistics_after_save, if: :size_changed?
+ after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed?
- scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
+ after_save :update_file_store, if: :file_changed?
- mount_uploader :file, JobArtifactUploader
+ scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
delegate :exists?, :open, to: :file
@@ -21,6 +25,12 @@ module Ci
trace: 3
}
+ def update_file_store
+ # The file.object_store is set during `uploader.store!`
+ # which happens after object is inserted/updated
+ self.update_column(:file_store, file.object_store)
+ end
+
def self.artifacts_size_for(project)
self.where(project: project).sum(:size)
end
@@ -29,10 +39,6 @@ module Ci
[nil, ::JobArtifactUploader::Store::LOCAL].include?(self.file_store)
end
- def set_size
- self.size = file.size
- end
-
def expire_in
expire_at - Time.now if expire_at
end
@@ -43,5 +49,28 @@ module Ci
ChronicDuration.parse(value)&.seconds&.from_now
end
end
+
+ private
+
+ def set_size
+ self.size = file.size
+ end
+
+ def update_project_statistics_after_save
+ update_project_statistics(size.to_i - size_was.to_i)
+ end
+
+ def update_project_statistics_after_destroy
+ update_project_statistics(-self.size)
+ end
+
+ def update_project_statistics(difference)
+ ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference)
+ end
+
+ def project_destroyed?
+ # Use job.project to avoid extra DB query for project
+ job.project.pending_delete?
+ end
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 434b9b64c65..e1b9bc76475 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -530,6 +530,17 @@ module Ci
@latest_builds_with_artifacts ||= builds.latest.with_artifacts_archive.to_a
end
+ # Rails 5.0 autogenerated question mark enum methods return wrong result if enum value is nil.
+ # They always return `false`.
+ # These methods overwrite autogenerated ones to return correct results.
+ def unknown?
+ Gitlab.rails5? ? source.nil? : super
+ end
+
+ def unknown_source?
+ Gitlab.rails5? ? config_source.nil? : super
+ end
+
private
def ci_yaml_from_repo
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 5a4c56ec0dc..23078f1c3ed 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -14,31 +14,49 @@ module Ci
has_many :builds
has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :runner_projects
+ has_many :runner_namespaces
+ has_many :groups, through: :runner_namespaces
has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build'
before_validation :set_default_values
- scope :specific, ->() { where(is_shared: false) }
- scope :shared, ->() { where(is_shared: true) }
- scope :active, ->() { where(active: true) }
- scope :paused, ->() { where(active: false) }
- scope :online, ->() { where('contacted_at > ?', contact_time_deadline) }
- scope :ordered, ->() { order(id: :desc) }
+ scope :specific, -> { where(is_shared: false) }
+ scope :shared, -> { where(is_shared: true) }
+ scope :active, -> { where(active: true) }
+ scope :paused, -> { where(active: false) }
+ scope :online, -> { where('contacted_at > ?', contact_time_deadline) }
+ scope :ordered, -> { order(id: :desc) }
- scope :owned_or_shared, ->(project_id) do
- joins('LEFT JOIN ci_runner_projects ON ci_runner_projects.runner_id = ci_runners.id')
- .where("ci_runner_projects.project_id = :project_id OR ci_runners.is_shared = true", project_id: project_id)
+ scope :belonging_to_project, -> (project_id) {
+ joins(:runner_projects).where(ci_runner_projects: { project_id: project_id })
+ }
+
+ scope :belonging_to_parent_group_of_project, -> (project_id) {
+ project_groups = ::Group.joins(:projects).where(projects: { id: project_id })
+ hierarchy_groups = Gitlab::GroupHierarchy.new(project_groups).base_and_ancestors
+
+ joins(:groups).where(namespaces: { id: hierarchy_groups })
+ }
+
+ scope :owned_or_shared, -> (project_id) do
+ union = Gitlab::SQL::Union.new(
+ [belonging_to_project(project_id), belonging_to_parent_group_of_project(project_id), shared],
+ remove_duplicates: false
+ )
+ from("(#{union.to_sql}) ci_runners")
end
scope :assignable_for, ->(project) do
# FIXME: That `to_sql` is needed to workaround a weird Rails bug.
# Without that, placeholders would miss one and couldn't match.
where(locked: false)
- .where.not("id IN (#{project.runners.select(:id).to_sql})").specific
+ .where.not("ci_runners.id IN (#{project.runners.select(:id).to_sql})")
+ .specific
end
validate :tag_constraints
+ validate :either_projects_or_group
validates :access_level, presence: true
acts_as_taggable
@@ -50,6 +68,12 @@ module Ci
ref_protected: 1
}
+ enum runner_type: {
+ instance_type: 1,
+ group_type: 2,
+ project_type: 3
+ }
+
cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address
chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout
@@ -120,6 +144,14 @@ module Ci
!shared?
end
+ def assigned_to_group?
+ runner_namespaces.any?
+ end
+
+ def assigned_to_project?
+ runner_projects.any?
+ end
+
def can_pick?(build)
return false if self.ref_protected? && !build.protected?
@@ -174,6 +206,12 @@ module Ci
end
end
+ def pick_build!(build)
+ if can_pick?(build)
+ tick_runner_queue
+ end
+ end
+
private
def cleanup_runner_queue
@@ -205,7 +243,17 @@ module Ci
end
def assignable_for?(project_id)
- is_shared? || projects.exists?(id: project_id)
+ self.class.owned_or_shared(project_id).where(id: self.id).any?
+ end
+
+ def either_projects_or_group
+ if groups.many?
+ errors.add(:runner, 'can only be assigned to one group')
+ end
+
+ if assigned_to_group? && assigned_to_project?
+ errors.add(:runner, 'can only be assigned either to projects or to a group')
+ end
end
def accepting_tags?(build)
diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb
new file mode 100644
index 00000000000..3269f86e8ca
--- /dev/null
+++ b/app/models/ci/runner_namespace.rb
@@ -0,0 +1,9 @@
+module Ci
+ class RunnerNamespace < ActiveRecord::Base
+ extend Gitlab::Ci::Model
+
+ belongs_to :runner
+ belongs_to :namespace, class_name: '::Namespace'
+ belongs_to :group, class_name: '::Group', foreign_key: :namespace_id
+ end
+end
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 75b8ea2a371..5a1eeb966aa 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -13,14 +13,27 @@ module Ci
has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id
has_many :builds, foreign_key: :stage_id
- validates :project, presence: true, unless: :importing?
- validates :pipeline, presence: true, unless: :importing?
- validates :name, presence: true, unless: :importing?
+ with_options unless: :importing? do
+ validates :project, presence: true
+ validates :pipeline, presence: true
+ validates :name, presence: true
+ validates :position, presence: true
+ end
- after_initialize do |stage|
+ after_initialize do
self.status = DEFAULT_STATUS if self.status.nil?
end
+ before_validation unless: :importing? do
+ next if position.present?
+
+ self.position = statuses.select(:stage_idx)
+ .where('stage_idx IS NOT NULL')
+ .group(:stage_idx)
+ .order('COUNT(*) DESC')
+ .first&.stage_idx.to_i
+ end
+
state_machine :status, initial: :created do
event :enqueue do
transition created: :pending
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 3f7f36e83c0..b46f9f34689 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -30,6 +30,8 @@ class Commit
MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH
COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze
+ # Used by GFM to match and present link extensions on node texts and hrefs.
+ LINK_EXTENSION_PATTERN = /(patch)/.freeze
def banzai_render_context(field)
pipeline = field == :description ? :commit_description : :single_line
@@ -103,6 +105,10 @@ class Commit
end
end
end
+
+ def parent_class
+ ::Project
+ end
end
attr_accessor :raw
@@ -143,7 +149,8 @@ class Commit
end
def self.link_reference_pattern
- @link_reference_pattern ||= super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})/)
+ @link_reference_pattern ||=
+ super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})?(\.(?<extension>#{LINK_EXTENSION_PATTERN}))?/)
end
def to_reference(from = nil, full: false)
@@ -245,7 +252,7 @@ class Commit
end
def notes_with_associations
- notes.includes(:author)
+ notes.includes(:author, :award_emoji)
end
def merge_requests
@@ -417,6 +424,12 @@ class Commit
# no-op but needs to be defined since #persisted? is defined
end
+ def touch_later
+ # No-op.
+ # This method is called by ActiveRecord.
+ # We don't want to do anything for `Commit` model, so this is empty.
+ end
+
WIP_REGEX = /\A\s*(((?i)(\[WIP\]|WIP:|WIP)\s|WIP$))|(fixup!|squash!)\s/.freeze
def work_in_progress?
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 3469d5d795c..97d89422594 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -87,7 +87,7 @@ class CommitStatus < ActiveRecord::Base
transition [:created, :pending, :running, :manual] => :canceled
end
- before_transition created: [:pending, :running] do |commit_status|
+ before_transition [:created, :skipped, :manual] => :pending do |commit_status|
commit_status.queued_at = Time.now
end
@@ -189,4 +189,11 @@ class CommitStatus < ActiveRecord::Base
v =~ /\d+/ ? v.to_i : v
end
end
+
+ # Rails 5.0 autogenerated question mark enum methods return wrong result if enum value is nil.
+ # They always return `false`.
+ # This method overwrites the autogenerated one to return correct result.
+ def unknown_failure?
+ Gitlab.rails5? ? failure_reason.nil? : super
+ end
end
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
index 4b66725a3e6..22f516a172f 100644
--- a/app/models/concerns/atomic_internal_id.rb
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -27,8 +27,9 @@ module AtomicInternalId
module ClassMethods
def has_internal_id(column, scope:, init:) # rubocop:disable Naming/PredicateName
before_validation(on: :create) do
- if read_attribute(column).blank?
- scope_attrs = { scope => association(scope).reader }
+ scope_value = association(scope).reader
+ if read_attribute(column).blank? && scope_value
+ scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value }
usage = self.class.table_name.to_sym
new_iid = InternalId.generate_next(self, scope_attrs, usage, init)
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 7677891b9ce..13246a774e3 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -31,12 +31,13 @@ module Avatarable
asset_host = ActionController::Base.asset_host
use_asset_host = asset_host.present?
+ use_authentication = respond_to?(:public?) && !public?
# 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?
+ if use_asset_host && use_authentication
use_asset_host = false
only_path = false
end
@@ -49,6 +50,6 @@ module Avatarable
url_base << gitlab_config.relative_url_root
end
- url_base + avatar.url
+ url_base + avatar.local_url
end
end
diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb
index d8394415362..fce37e7f78e 100644
--- a/app/models/concerns/awardable.rb
+++ b/app/models/concerns/awardable.rb
@@ -79,11 +79,7 @@ module Awardable
end
def user_can_award?(current_user, name)
- if user_authored?(current_user)
- !awardable_votes?(normalize_name(name))
- else
- true
- end
+ awardable_by_user?(current_user, name) && Ability.allowed?(current_user, :award_emoji, self)
end
def user_authored?(current_user)
@@ -119,4 +115,12 @@ module Awardable
def normalize_name(name)
Gitlab::Emoji.normalize_emoji_name(name)
end
+
+ def awardable_by_user?(current_user, name)
+ if user_authored?(current_user)
+ !awardable_votes?(normalize_name(name))
+ else
+ true
+ end
+ end
end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 4ae5dd8c677..db8cf322ef7 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -11,7 +11,9 @@ module CacheMarkdownField
extend ActiveSupport::Concern
# Increment this number every time the renderer changes its output
- CACHE_VERSION = 3
+ CACHE_REDCARPET_VERSION = 3
+ CACHE_COMMONMARK_VERSION_START = 10
+ CACHE_COMMONMARK_VERSION = 11
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
@@ -49,12 +51,14 @@ module CacheMarkdownField
# Always include a project key, or Banzai complains
project = self.project if self.respond_to?(:project)
- group = self.group if self.respond_to?(:group)
+ 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)
+ context[:markdown_engine] = markdown_engine
+
context
end
@@ -69,7 +73,7 @@ module CacheMarkdownField
Banzai::Renderer.cacheless_render_field(self, markdown_field, options)
]
end.to_h
- updates['cached_markdown_version'] = CacheMarkdownField::CACHE_VERSION
+ updates['cached_markdown_version'] = latest_cached_markdown_version
updates.each {|html_field, data| write_attribute(html_field, data) }
end
@@ -90,7 +94,7 @@ module CacheMarkdownField
markdown_changed = attribute_changed?(markdown_field) || false
html_changed = attribute_changed?(html_field) || false
- CacheMarkdownField::CACHE_VERSION == cached_markdown_version &&
+ latest_cached_markdown_version == cached_markdown_version &&
(html_changed || markdown_changed == html_changed)
end
@@ -109,6 +113,24 @@ module CacheMarkdownField
__send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend
end
+ def latest_cached_markdown_version
+ return CacheMarkdownField::CACHE_REDCARPET_VERSION unless cached_markdown_version
+
+ if cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
+ CacheMarkdownField::CACHE_REDCARPET_VERSION
+ else
+ CacheMarkdownField::CACHE_COMMONMARK_VERSION
+ end
+ end
+
+ def markdown_engine
+ if latest_cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
+ :redcarpet
+ else
+ :common_mark
+ end
+ end
+
included do
cattr_reader :cached_markdown_fields do
FieldData.new
diff --git a/app/models/concerns/chronic_duration_attribute.rb b/app/models/concerns/chronic_duration_attribute.rb
index fa1eafb1d7a..593a9b3d71d 100644
--- a/app/models/concerns/chronic_duration_attribute.rb
+++ b/app/models/concerns/chronic_duration_attribute.rb
@@ -8,14 +8,14 @@ module ChronicDurationAttribute
end
end
- def chronic_duration_attr_writer(virtual_attribute, source_attribute)
+ def chronic_duration_attr_writer(virtual_attribute, source_attribute, parameters = {})
chronic_duration_attr_reader(virtual_attribute, source_attribute)
define_method("#{virtual_attribute}=") do |value|
- chronic_duration_attributes[virtual_attribute] = value.presence || ''
+ chronic_duration_attributes[virtual_attribute] = value.presence || parameters[:default].presence.to_s
begin
- new_value = ChronicDuration.parse(value).to_i if value.present?
+ new_value = value.present? ? ChronicDuration.parse(value).to_i : parameters[:default].presence
assign_attributes(source_attribute => new_value)
rescue ChronicDuration::DurationParseError
# ignore error as it will be caught by validation
diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb
index 01957da0bf3..261ace57a17 100644
--- a/app/models/concerns/group_descendant.rb
+++ b/app/models/concerns/group_descendant.rb
@@ -37,7 +37,20 @@ module GroupDescendant
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')
+ parent = child.parent
+
+ exception = ArgumentError.new <<~MSG
+ parent: [GroupDescendant: #{parent.inspect}] was not preloaded for [#{child.inspect}]")
+ This error is not user facing, but causes a +1 query.
+ MSG
+ extras = {
+ parent: parent,
+ child: child,
+ preloaded: preloaded.map(&:full_path)
+ }
+ issue_url = 'https://gitlab.com/gitlab-org/gitlab-ce/issues/40785'
+
+ Gitlab::Sentry.track_exception(exception, issue_url: issue_url, extra: extras)
end
if parent.nil? && hierarchy_top.present?
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index 5130ecec472..967fd9c5eea 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -102,14 +102,14 @@ module Milestoneish
Gitlab::TimeTrackingFormatter.output(total_issue_time_estimate)
end
- private
-
def count_issues_by_state(user)
memoize_per_user(user, :count_issues_by_state) do
issues_visible_to_user(user).reorder(nil).group(:state).count
end
end
+ private
+
def memoize_per_user(user, method_name)
memoized_users[method_name][user&.id] ||= yield
end
diff --git a/app/models/concerns/nonatomic_internal_id.rb b/app/models/concerns/nonatomic_internal_id.rb
deleted file mode 100644
index 9d0c9b8512f..00000000000
--- a/app/models/concerns/nonatomic_internal_id.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-module NonatomicInternalId
- extend ActiveSupport::Concern
-
- included do
- validate :set_iid, on: :create
- validates :iid, presence: true, numericality: true
- end
-
- def set_iid
- if iid.blank?
- parent = project || group
- records = parent.public_send(self.class.name.tableize) # rubocop:disable GitlabSecurity/PublicSend
- max_iid = records.maximum(:iid)
-
- self.iid = max_iid.to_i + 1
- end
- end
-
- def to_param
- iid.to_s
- end
-end
diff --git a/app/models/concerns/presentable.rb b/app/models/concerns/presentable.rb
index 7b33b837004..bc4fbd19a02 100644
--- a/app/models/concerns/presentable.rb
+++ b/app/models/concerns/presentable.rb
@@ -1,4 +1,12 @@
module Presentable
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def present(attributes)
+ all.map { |klass_object| klass_object.present(attributes) }
+ end
+ end
+
def present(**attributes)
Gitlab::View::Presenter::Factory
.new(self, attributes)
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
index 454374121f3..94eef4ff7cd 100644
--- a/app/models/concerns/protected_ref.rb
+++ b/app/models/concerns/protected_ref.rb
@@ -31,7 +31,7 @@ module ProtectedRef
end
end
- def protected_ref_accessible_to?(ref, user, action:, protected_refs: nil)
+ def protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil)
access_levels_for_ref(ref, action: action, protected_refs: protected_refs).any? do |access_level|
access_level.check_access(user)
end
diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb
index dfd7d94450b..915ad6959be 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -102,7 +102,7 @@ module Routable
# the route. Caching this per request ensures that even if we have multiple instances,
# we will not have to duplicate work, avoiding N+1 queries in some cases.
def full_path
- return uncached_full_path unless RequestStore.active?
+ return uncached_full_path unless RequestStore.active? && persisted?
RequestStore[full_path_key] ||= uncached_full_path
end
@@ -124,6 +124,11 @@ module Routable
end
end
+ # Group would override this to check from association
+ def owned_by?(user)
+ owner == user
+ end
+
private
def set_path_errors
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index f05e606995d..f66bdd529f1 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -45,25 +45,25 @@ module Storage
# Hooks
- # Save the storage paths before the projects are destroyed to use them on after destroy
+ # Save the storages before the projects are destroyed to use them on after destroy
def prepare_for_destroy
- old_repository_storage_paths
+ old_repository_storages
end
private
def move_repositories
- # Move the namespace directory in all storage paths used by member projects
- repository_storage_paths.each do |repository_storage_path|
+ # Move the namespace directory in all storages used by member projects
+ repository_storages.each do |repository_storage|
# Ensure old directory exists before moving it
- gitlab_shell.add_namespace(repository_storage_path, full_path_was)
+ gitlab_shell.add_namespace(repository_storage, 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
+ gitlab_shell.add_namespace(repository_storage, parent.full_path) if parent
- unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path)
+ unless gitlab_shell.mv_namespace(repository_storage, full_path_was, full_path)
- Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}"
+ Rails.logger.error "Exception moving path #{repository_storage} from #{full_path_was} to #{full_path}"
# if we cannot move namespace directory we should rollback
# db changes in order to prevent out of sync between db and fs
@@ -72,33 +72,33 @@ module Storage
end
end
- def old_repository_storage_paths
- @old_repository_storage_paths ||= repository_storage_paths
+ def old_repository_storages
+ @old_repository_storage_paths ||= repository_storages
end
- def repository_storage_paths
+ def repository_storages
# We need to get the storage paths for all the projects, even the ones that are
# pending delete. Unscoping also get rids of the default order, which causes
# problems with SELECT DISTINCT.
Project.unscoped do
- all_projects.select('distinct(repository_storage)').to_a.map(&:repository_storage_path)
+ all_projects.select('distinct(repository_storage)').to_a.map(&:repository_storage)
end
end
def rm_dir
# Remove the namespace directory in all storages paths used by member projects
- old_repository_storage_paths.each do |repository_storage_path|
+ old_repository_storages.each do |repository_storage|
# Move namespace directory into trash.
# We will remove it later async
new_path = "#{full_path}+#{id}+deleted"
- if gitlab_shell.mv_namespace(repository_storage_path, full_path, new_path)
+ if gitlab_shell.mv_namespace(repository_storage, full_path, new_path)
Gitlab::AppLogger.info %Q(Namespace directory "#{full_path}" moved to "#{new_path}")
# Remove namespace directroy async with delay so
# GitLab has time to remove all projects first
run_after_commit do
- GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path)
+ GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage, new_path)
end
end
end
diff --git a/app/models/concerns/uniquify.rb b/app/models/concerns/uniquify.rb
index a7fe5951b6e..549a76da20e 100644
--- a/app/models/concerns/uniquify.rb
+++ b/app/models/concerns/uniquify.rb
@@ -1,13 +1,21 @@
+# Uniquify
+#
+# Return a version of the given 'base' string that is unique
+# by appending a counter to it. Uniqueness is determined by
+# repeated calls to the passed block.
+#
+# You can pass an initial value for the counter, if not given
+# counting starts from 1.
+#
+# If `base` is a function/proc, we expect that calling it with a
+# candidate counter returns a string to test/return.
class Uniquify
- # Return a version of the given 'base' string that is unique
- # by appending a counter to it. Uniqueness is determined by
- # repeated calls to the passed block.
- #
- # If `base` is a function/proc, we expect that calling it with a
- # candidate counter returns a string to test/return.
+ def initialize(counter = nil)
+ @counter = counter
+ end
+
def string(base)
@base = base
- @counter = nil
increment_counter! while yield(base_string)
base_string
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
new file mode 100644
index 00000000000..5082dc45368
--- /dev/null
+++ b/app/models/deploy_token.rb
@@ -0,0 +1,66 @@
+class DeployToken < ActiveRecord::Base
+ include Expirable
+ include TokenAuthenticatable
+ add_authentication_token_field :token
+
+ AVAILABLE_SCOPES = %i(read_repository read_registry).freeze
+ GITLAB_DEPLOY_TOKEN_NAME = 'gitlab-deploy-token'.freeze
+
+ default_value_for(:expires_at) { Forever.date }
+
+ has_many :project_deploy_tokens, inverse_of: :deploy_token
+ has_many :projects, through: :project_deploy_tokens
+
+ validate :ensure_at_least_one_scope
+ before_save :ensure_token
+
+ accepts_nested_attributes_for :project_deploy_tokens
+
+ scope :active, -> { where("revoked = false AND expires_at >= NOW()") }
+
+ def self.gitlab_deploy_token
+ active.find_by(name: GITLAB_DEPLOY_TOKEN_NAME)
+ end
+
+ def revoke!
+ update!(revoked: true)
+ end
+
+ def active?
+ !revoked
+ end
+
+ def scopes
+ AVAILABLE_SCOPES.select { |token_scope| read_attribute(token_scope) }
+ end
+
+ def username
+ "gitlab+deploy-token-#{id}"
+ end
+
+ def has_access_to?(requested_project)
+ active? && project == requested_project
+ end
+
+ # This is temporal. Currently we limit DeployToken
+ # to a single project, later we're going to extend
+ # that to be for multiple projects and namespaces.
+ def project
+ projects.first
+ end
+
+ def expires_at
+ expires_at = read_attribute(:expires_at)
+ expires_at != Forever.date ? expires_at : nil
+ end
+
+ def expires_at=(value)
+ write_attribute(:expires_at, value.presence || Forever.date)
+ end
+
+ private
+
+ def ensure_at_least_one_scope
+ errors.add(:base, "Scopes can't be blank") unless read_repository || read_registry
+ end
+end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index e18ea8bfea4..254764eefde 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -1,11 +1,13 @@
class Deployment < ActiveRecord::Base
- include NonatomicInternalId
+ include AtomicInternalId
belongs_to :project, required: true
belongs_to :environment, required: true
belongs_to :user
belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
+ has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.deployments&.maximum(:iid) }
+
validates :sha, presence: true
validates :ref, presence: true
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 15122cbc693..616a626419b 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -54,7 +54,20 @@ class DiffNote < Note
end
def diff_file
- @diff_file ||= self.original_position.diff_file(self.project.repository)
+ @diff_file ||=
+ begin
+ if created_at_diff?(noteable.diff_refs)
+ # We're able to use the already persisted diffs (Postgres) if we're
+ # presenting a "current version" of the MR discussion diff.
+ # So no need to make an extra Gitaly diff request for it.
+ # As an extra benefit, the returned `diff_file` already
+ # has `highlighted_diff_lines` data set from Redis on
+ # `Diff::FileCollection::MergeRequestDiff`.
+ noteable.diffs(paths: original_position.paths, expanded: true).diff_files.first
+ else
+ original_position.diff_file(self.project.repository)
+ end
+ end
end
def diff_line
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 9517723d9d9..fddb269af4b 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -224,7 +224,7 @@ class Environment < ActiveRecord::Base
end
def deployment_platform
- project.deployment_platform(environment: self)
+ project.deployment_platform(environment: self.name)
end
private
diff --git a/app/models/event.rb b/app/models/event.rb
index 3805f6cf857..741a84194e2 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -110,7 +110,10 @@ class Event < ActiveRecord::Base
end
end
+ # Remove this method when removing Gitlab.rails5? code.
def subclass_from_attributes(attrs)
+ return super if Gitlab.rails5?
+
# Without this Rails will keep calling this method on the returned class,
# resulting in an infinite loop.
return unless self == Event
diff --git a/app/models/group.rb b/app/models/group.rb
index 3cfe21ac93b..f493836a92e 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -9,6 +9,7 @@ class Group < Namespace
include SelectForProjectAuthorization
include LoadedInGroupList
include GroupDescendant
+ include TokenAuthenticatable
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members
@@ -43,6 +44,8 @@ class Group < Namespace
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
+ add_authentication_token_field :runners_token
+
after_create :post_create_hook
after_destroy :post_destroy_hook
after_save :update_two_factor_requirement
@@ -125,6 +128,10 @@ class Group < Namespace
self[:lfs_enabled]
end
+ def owned_by?(user)
+ owners.include?(user)
+ end
+
def add_users(users, access_level, current_user: nil, expires_at: nil)
GroupMember.add_users(
self,
@@ -286,6 +293,17 @@ class Group < Namespace
false
end
+ def refresh_project_authorizations
+ refresh_members_authorized_projects(blocking: false)
+ end
+
+ # each existing group needs to have a `runners_token`.
+ # we do this on read since migrating all existing groups is not a feasible
+ # solution.
+ def runners_token
+ ensure_runners_token!
+ end
+
private
def update_two_factor_requirement
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index b6dd39b860b..ec072882cc9 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -7,6 +7,7 @@ class ProjectHook < WebHook
:issue_hooks,
:confidential_issue_hooks,
:note_hooks,
+ :confidential_note_hooks,
:merge_request_hooks,
:job_hooks,
:pipeline_hooks,
diff --git a/app/models/identity.rb b/app/models/identity.rb
index 1011b9f1109..3fd0c5e751d 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -1,12 +1,16 @@
class Identity < ActiveRecord::Base
+ def self.uniqueness_scope
+ :provider
+ end
+
include Sortable
include CaseSensitivity
belongs_to :user
validates :provider, presence: true
- validates :extern_uid, allow_blank: true, uniqueness: { scope: :provider, case_sensitive: false }
- validates :user_id, uniqueness: { scope: :provider }
+ validates :extern_uid, allow_blank: true, uniqueness: { scope: uniqueness_scope, case_sensitive: false }
+ validates :user_id, uniqueness: { scope: uniqueness_scope }
before_save :ensure_normalized_extern_uid, if: :extern_uid_changed?
after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider?
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index cbec735c2dd..189942c5ad8 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -12,8 +12,9 @@
# * (Optionally) add columns to `internal_ids` if needed for scope.
class InternalId < ActiveRecord::Base
belongs_to :project
+ belongs_to :namespace
- enum usage: { issues: 0 }
+ enum usage: { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4 }
validates :usage, presence: true
@@ -23,9 +24,12 @@ class InternalId < ActiveRecord::Base
#
# The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
# As such, the increment is atomic and safe to be called concurrently.
- def increment_and_save!
+ #
+ # If a `maximum_iid` is passed in, this overrides the incremented value if it's
+ # greater than that. This can be used to correct the increment value if necessary.
+ def increment_and_save!(maximum_iid)
lock!
- self.last_value = (last_value || 0) + 1
+ self.last_value = [(last_value || 0) + 1, (maximum_iid || 0) + 1].max
save!
last_value
end
@@ -89,7 +93,16 @@ class InternalId < ActiveRecord::Base
# and increment its last value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
- (lookup || create_record).increment_and_save!
+
+ # Note we always calculate the maximum iid present here and
+ # pass it in to correct the InternalId entry if it's last_value is off.
+ #
+ # This can happen in a transition phase where both `AtomicInternalId` and
+ # `NonatomicInternalId` code runs (e.g. during a deploy).
+ #
+ # This is subject to be cleaned up with the 10.8 release:
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/45389.
+ (lookup || create_record).increment_and_save!(maximum_iid)
end
end
@@ -115,11 +128,15 @@ class InternalId < ActiveRecord::Base
InternalId.create!(
**scope,
usage: usage_value,
- last_value: init.call(subject) || 0
+ last_value: maximum_iid
)
end
rescue ActiveRecord::RecordNotUnique
lookup
end
+
+ def maximum_iid
+ @maximum_iid ||= init.call(subject) || 0
+ end
end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 13abc6c1a0d..0332bfa9371 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -49,6 +49,7 @@ class Issue < ActiveRecord::Base
scope :without_due_date, -> { where(due_date: nil) }
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
+ scope :due_tomorrow, -> { where(due_date: Date.tomorrow) }
scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') }
scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') }
@@ -193,6 +194,15 @@ class Issue < ActiveRecord::Base
branches_with_iid - branches_with_merge_request
end
+ def suggested_branch_name
+ return to_branch_name unless project.repository.branch_exists?(to_branch_name)
+
+ start_counting_from = 2
+ Uniquify.new(start_counting_from).string(-> (counter) { "#{to_branch_name}-#{counter}" }) do |suggested_branch_name|
+ project.repository.branch_exists?(suggested_branch_name)
+ end
+ end
+
# Returns boolean if a related branch exists for the current issue
# ignores merge requests branchs
def has_related_branch?
@@ -247,11 +257,8 @@ class Issue < ActiveRecord::Base
end
end
- def can_be_worked_on?(current_user)
- !self.closed? &&
- !self.project.forked? &&
- self.related_branches(current_user).empty? &&
- self.closed_by_merge_requests(current_user).empty?
+ def can_be_worked_on?
+ !self.closed? && !self.project.forked?
end
# Returns `true` if the current issue can be viewed by either a logged in User
@@ -272,11 +279,17 @@ class Issue < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- if options.key?(:sidebar_endpoints) && project
+ if options.key?(:issue_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))
+ issue_reference = options[:include_full_project_path] ? to_reference(full: true) : to_reference
+
+ json.merge!(
+ reference_path: issue_reference,
+ real_path: url_helper.project_issue_path(project, self),
+ 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)
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index b7de46fa202..84487031ee5 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -11,10 +11,12 @@ class LfsObject < ActiveRecord::Base
mount_uploader :file, LfsObjectUploader
- before_save :update_file_store
+ after_save :update_file_store, if: :file_changed?
def update_file_store
- self.file_store = file.object_store
+ # The file.object_store is set during `uploader.store!`
+ # which happens after object is inserted/updated
+ self.update_column(:file_store, file.object_store)
end
def project_allowed_access?(project)
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 661e668dbf9..5da739f9618 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -37,20 +37,20 @@ class GroupMember < Member
private
def send_invite
- notification_service.invite_group_member(self, @raw_invite_token)
+ run_after_commit_or_now { notification_service.invite_group_member(self, @raw_invite_token) }
super
end
def post_create_hook
- notification_service.new_group_member(self)
+ run_after_commit_or_now { notification_service.new_group_member(self) }
super
end
def post_update_hook
if access_level_changed?
- notification_service.update_group_member(self)
+ run_after_commit { notification_service.update_group_member(self) }
end
super
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 1c7ed4a96df..024106056b4 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -92,7 +92,7 @@ class ProjectMember < Member
private
def send_invite
- notification_service.invite_project_member(self, @raw_invite_token)
+ run_after_commit_or_now { notification_service.invite_project_member(self, @raw_invite_token) }
super
end
@@ -100,7 +100,7 @@ class ProjectMember < Member
def post_create_hook
unless owner?
event_service.join_project(self.project, self.user)
- notification_service.new_project_member(self)
+ run_after_commit_or_now { notification_service.new_project_member(self) }
end
super
@@ -108,7 +108,7 @@ class ProjectMember < Member
def post_update_hook
if access_level_changed?
- notification_service.update_project_member(self)
+ run_after_commit { notification_service.update_project_member(self) }
end
super
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 91d8be5559b..63c6ada86e1 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1,5 +1,5 @@
class MergeRequest < ActiveRecord::Base
- include NonatomicInternalId
+ include AtomicInternalId
include Issuable
include Noteable
include Referable
@@ -18,6 +18,8 @@ class MergeRequest < ActiveRecord::Base
belongs_to :source_project, class_name: "Project"
belongs_to :merge_user, class_name: "User"
+ has_internal_id :iid, scope: :target_project, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) }
+
has_many :merge_request_diffs
has_one :merge_request_diff,
@@ -321,7 +323,7 @@ class MergeRequest < ActiveRecord::Base
# updates `merge_jid` with the MergeWorker#jid.
# This helps tracking enqueued and ongoing merge jobs.
def merge_async(user_id, params)
- jid = MergeWorker.perform_async(id, user_id, params)
+ jid = MergeWorker.perform_async(id, user_id, params.to_h)
update_column(:merge_jid, jid)
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index c1c27ccf3e5..06aa67c600f 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -197,10 +197,6 @@ class MergeRequestDiff < ActiveRecord::Base
CompareService.new(project, head_commit_sha).execute(project, sha, straight: true)
end
- def commits_count
- super || merge_request_diff_commits.size
- end
-
private
def create_merge_request_diff_files(diffs)
diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb
index b75387e236e..1c2e57bb01f 100644
--- a/app/models/merge_request_diff_commit.rb
+++ b/app/models/merge_request_diff_commit.rb
@@ -17,7 +17,7 @@ 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.serialize(sha), # rubocop:disable Cop/ActiveRecordSerialize
authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]),
committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date])
)
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index dafae58d121..d14e3a4ded5 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -8,7 +8,7 @@ class Milestone < ActiveRecord::Base
Started = MilestoneStruct.new('Started', '#started', -3)
include CacheMarkdownField
- include NonatomicInternalId
+ include AtomicInternalId
include Sortable
include Referable
include StripAttribute
@@ -21,6 +21,9 @@ class Milestone < ActiveRecord::Base
belongs_to :project
belongs_to :group
+ has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.milestones&.maximum(:iid) }
+ has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.milestones&.maximum(:iid) }
+
has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests
@@ -34,8 +37,8 @@ class Milestone < ActiveRecord::Base
scope :for_projects_and_groups, -> (project_ids, group_ids) do
conditions = []
- conditions << arel_table[:project_id].in(project_ids) if project_ids.compact.any?
- conditions << arel_table[:group_id].in(group_ids) if group_ids.compact.any?
+ conditions << arel_table[:project_id].in(project_ids) if project_ids&.compact&.any?
+ conditions << arel_table[:group_id].in(group_ids) if group_ids&.compact&.any?
where(conditions.reduce(:or))
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index e350b675639..5621eeba7c4 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -21,6 +21,9 @@ class Namespace < ActiveRecord::Base
has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :project_statistics
+ has_many :runner_namespaces, class_name: 'Ci::RunnerNamespace'
+ has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner'
+
# 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"
@@ -248,8 +251,8 @@ class Namespace < ActiveRecord::Base
all_projects.with_storage_feature(:repository).find_each(&:remove_exports)
end
- def features
- []
+ def refresh_project_authorizations
+ owner.refresh_authorized_projects
end
private
diff --git a/app/models/note.rb b/app/models/note.rb
index 0f5fb529a87..e426f84832b 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -268,6 +268,10 @@ class Note < ActiveRecord::Base
self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
end
+ def confidential?
+ noteable.try(:confidential?)
+ end
+
def editable?
!system?
end
@@ -313,6 +317,10 @@ class Note < ActiveRecord::Base
!system? && !for_snippet?
end
+ def can_create_notification?
+ true
+ end
+
def discussion_class(noteable = nil)
# When commit notes are rendered on an MR's Discussion page, they are
# displayed in one discussion instead of individually.
diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb
index b3ffad00a07..2c3580bbdc6 100644
--- a/app/models/notification_recipient.rb
+++ b/app/models/notification_recipient.rb
@@ -83,14 +83,14 @@ class NotificationRecipient
def has_access?
DeclarativePolicy.subject_scope do
- return false unless user.can?(:receive_notifications)
- return true if @skip_read_ability
+ break false unless user.can?(:receive_notifications)
+ break true if @skip_read_ability
- return false if @target && !user.can?(:read_cross_project)
- return false if @project && !user.can?(:read_project, @project)
+ break false if @target && !user.can?(:read_cross_project)
+ break false if @project && !user.can?(:read_project, @project)
- return true unless read_ability
- return true unless DeclarativePolicy.has_policy?(@target)
+ break true unless read_ability
+ break true unless DeclarativePolicy.has_policy?(@target)
user.can?(read_ability, @target)
end
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index f6d9b0215fc..9195408551f 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -47,7 +47,8 @@ class NotificationSetting < ActiveRecord::Base
].freeze
EXCLUDED_WATCHER_EVENTS = [
- :push_to_merge_request
+ :push_to_merge_request,
+ :issue_due
].push(*EXCLUDED_PARTICIPATING_EVENTS).freeze
def self.find_or_create_for(source)
diff --git a/app/models/project.rb b/app/models/project.rb
index 714a15ade9c..50c404c300a 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -21,6 +21,7 @@ class Project < ActiveRecord::Base
include Gitlab::SQL::Pattern
include DeploymentPlatform
include ::Gitlab::Utils::StrongMemoize
+ include ChronicDurationAttribute
extend Gitlab::ConfigHelper
@@ -67,6 +68,11 @@ class Project < ActiveRecord::Base
after_save :update_project_statistics, if: :namespace_id_changed?
after_create :create_project_feature, unless: :project_feature
+
+ after_create :create_ci_cd_settings,
+ unless: :ci_cd_settings,
+ if: proc { ProjectCiCdSetting.available? }
+
after_create :set_last_activity_at
after_create :set_last_repository_updated_at
after_update :update_forks_visibility_level
@@ -221,13 +227,14 @@ class Project < ActiveRecord::Base
has_many :environments
has_many :deployments
has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule'
-
- has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
+ has_many :project_deploy_tokens
+ has_many :deploy_tokens, through: :project_deploy_tokens
has_one :auto_devops, class_name: 'ProjectAutoDevops'
has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
has_many :project_badges, class_name: 'ProjectBadge'
+ has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
@@ -238,6 +245,7 @@ class Project < ActiveRecord::Base
delegate :members, to: :team, prefix: true
delegate :add_user, :add_users, to: :team
delegate :add_guest, :add_reporter, :add_developer, :add_master, :add_role, to: :team
+ delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings
# Validations
validates :creator, presence: true, on: :create
@@ -323,8 +331,19 @@ class Project < ActiveRecord::Base
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) }
+ scope :with_group_runners_enabled, -> do
+ joins(:ci_cd_settings)
+ .where(project_ci_cd_settings: { group_runners_enabled: true })
+ end
+
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
+ chronic_duration_attr :build_timeout_human_readable, :build_timeout, default: 3600
+
+ validates :build_timeout, allow_nil: true,
+ numericality: { greater_than_or_equal_to: 600,
+ message: 'needs to be at least 10 minutes' }
+
# Returns a collection of projects that is either public or visible to the
# logged in user.
def self.public_or_visible_to_user(user = nil)
@@ -503,10 +522,6 @@ class Project < ActiveRecord::Base
repository.empty?
end
- def repository_storage_path
- Gitlab.config.repositories.storages[repository_storage]&.legacy_disk_path
- end
-
def team
@team ||= ProjectTeam.new(self)
end
@@ -630,7 +645,7 @@ class Project < ActiveRecord::Base
end
def create_or_update_import_data(data: nil, credentials: nil)
- return unless import_url.present? && valid_import_url?
+ return if data.nil? && credentials.nil?
project_import_data = import_data || build_import_data
if data
@@ -1032,13 +1047,6 @@ class Project < ActiveRecord::Base
"#{web_url}.git"
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
@@ -1066,6 +1074,16 @@ class Project < ActiveRecord::Base
end
end
+ # This will return all `lfs_objects` that are accessible to the project.
+ # So this might be `self.lfs_objects` if the project is not part of a fork
+ # network, or it is the base of the fork network.
+ #
+ # TODO: refactor this to get the correct lfs objects when implementing
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/39769
+ def all_lfs_objects
+ lfs_storage_project.lfs_objects
+ end
+
def personal?
!group
end
@@ -1087,7 +1105,7 @@ class Project < ActiveRecord::Base
# Check if repository already exists on disk
def check_repository_path_availability
return true if skip_disk_validation
- return false unless repository_storage_path
+ return false unless repository_storage
expires_full_path_cache # we need to clear cache to validate renames correctly
@@ -1287,26 +1305,23 @@ class Project < ActiveRecord::Base
@shared_runners ||= shared_runners_available? ? Ci::Runner.shared : Ci::Runner.none
end
- def active_shared_runners
- @active_shared_runners ||= shared_runners.active
+ def group_runners
+ @group_runners ||= group_runners_enabled? ? Ci::Runner.belonging_to_parent_group_of_project(self.id) : Ci::Runner.none
+ end
+
+ def all_runners
+ union = Gitlab::SQL::Union.new([runners, group_runners, shared_runners])
+ Ci::Runner.from("(#{union.to_sql}) ci_runners")
end
def any_runners?(&block)
- active_runners.any?(&block) || active_shared_runners.any?(&block)
+ all_runners.active.any?(&block)
end
def valid_runners_token?(token)
self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token)
end
- def build_timeout_in_minutes
- build_timeout / 60
- end
-
- def build_timeout_in_minutes=(value)
- self.build_timeout = value.to_i * 60
- end
-
def open_issues_count
Projects::OpenIssuesCountService.new(self).count
end
@@ -1463,7 +1478,9 @@ class Project < ActiveRecord::Base
end
def rename_repo_notify!
- send_move_instructions(full_path_was)
+ # When we import a project overwriting the original project, there
+ # is a move operation. In that case we don't want to send the instructions.
+ send_move_instructions(full_path_was) unless started?
expires_full_path_cache
self.old_path_with_namespace = full_path_was
@@ -1478,6 +1495,7 @@ class Project < ActiveRecord::Base
remove_import_jid
update_project_counter_caches
after_create_default_branch
+ refresh_markdown_cache!
end
def update_project_counter_caches
@@ -1623,7 +1641,7 @@ class Project < ActiveRecord::Base
def container_registry_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
- return variables unless Gitlab.config.registry.enabled
+ break variables unless Gitlab.config.registry.enabled
variables.append(key: 'CI_REGISTRY', value: Gitlab.config.registry.host_port)
@@ -1861,6 +1879,18 @@ class Project < ActiveRecord::Base
memoized_results[cache_key]
end
+ def licensed_features
+ []
+ end
+
+ def toggle_ci_cd_settings!(settings_attribute)
+ ci_cd_settings.toggle!(settings_attribute)
+ end
+
+ def gitlab_deploy_token
+ @gitlab_deploy_token ||= deploy_tokens.gitlab_deploy_token
+ end
+
private
def storage
@@ -1889,14 +1919,14 @@ class Project < ActiveRecord::Base
def check_repository_absence!
return if skip_disk_validation
- if repository_storage_path.blank? || repository_with_same_path_already_exists?
+ if repository_storage.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")
+ gitlab_shell.exists?(repository_storage, "#{disk_path}.git")
end
# set last_activity_at to the same as created_at
@@ -1986,10 +2016,11 @@ class Project < ActiveRecord::Base
def fetch_branch_allows_maintainer_push?(user, branch_name)
check_access = -> do
+ next false if empty_repo?
+
merge_request = source_of_merge_requests.opened
.where(allow_maintainer_to_push: true)
.find_by(source_branch: branch_name)
-
merge_request&.can_be_merged_by?(user)
end
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
new file mode 100644
index 00000000000..588cced5781
--- /dev/null
+++ b/app/models/project_ci_cd_setting.rb
@@ -0,0 +1,16 @@
+class ProjectCiCdSetting < ActiveRecord::Base
+ belongs_to :project, inverse_of: :ci_cd_settings
+
+ # The version of the schema that first introduced this model/table.
+ MINIMUM_SCHEMA_VERSION = 20180403035759
+
+ def self.available?
+ @available ||=
+ ActiveRecord::Migrator.current_version >= MINIMUM_SCHEMA_VERSION
+ end
+
+ def self.reset_column_information
+ @available = nil
+ super
+ end
+end
diff --git a/app/models/project_deploy_token.rb b/app/models/project_deploy_token.rb
new file mode 100644
index 00000000000..ab4482f0c0b
--- /dev/null
+++ b/app/models/project_deploy_token.rb
@@ -0,0 +1,8 @@
+class ProjectDeployToken < ActiveRecord::Base
+ belongs_to :project
+ belongs_to :deploy_token, inverse_of: :project_deploy_tokens
+
+ validates :deploy_token, presence: true
+ validates :project, presence: true
+ validates :deploy_token_id, uniqueness: { scope: [:project_id] }
+end
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index dab0ea1a681..7591ab4f478 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -21,8 +21,16 @@ class ChatNotificationService < Service
end
end
+ def confidential_issue_channel
+ properties['confidential_issue_channel'].presence || properties['issue_channel']
+ end
+
+ def confidential_note_channel
+ properties['confidential_note_channel'].presence || properties['note_channel']
+ end
+
def self.supported_events
- %w[push issue confidential_issue merge_request note tag_push
+ %w[push issue confidential_issue merge_request note confidential_note tag_push
pipeline wiki_page]
end
@@ -55,7 +63,9 @@ class ChatNotificationService < Service
return false unless message
- channel_name = get_channel_field(object_kind).presence || channel
+ event_type = data[:event_type] || object_kind
+
+ channel_name = get_channel_field(event_type).presence || channel
opts = {}
opts[:channel] = channel_name if channel_name
diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb
index 4d23a17a545..da01ac1b7cf 100644
--- a/app/models/project_services/flowdock_service.rb
+++ b/app/models/project_services/flowdock_service.rb
@@ -1,5 +1,51 @@
require "flowdock-git-hook"
+# Flow dock depends on Grit to compute the number of commits between two given
+# commits. To make this depend on Gitaly, a monkey patch is applied
+module Flowdock
+ class Git
+ # pass down a Repository all the way down
+ def repo
+ @options[:repo]
+ end
+
+ def config
+ {}
+ end
+
+ def messages
+ Git::Builder.new(repo: repo,
+ ref: @ref,
+ before: @from,
+ after: @to,
+ commit_url: @commit_url,
+ branch_url: @branch_url,
+ diff_url: @diff_url,
+ repo_url: @repo_url,
+ repo_name: @repo_name,
+ permanent_refs: @permanent_refs,
+ tags: tags
+ ).to_hashes
+ end
+
+ class Builder
+ def commits
+ @repo.commits_between(@before, @after).map do |commit|
+ {
+ url: @opts[:commit_url] ? @opts[:commit_url] % [commit.sha] : nil,
+ id: commit.sha,
+ message: commit.message,
+ author: {
+ name: commit.author_name,
+ email: commit.author_email
+ }
+ }
+ end
+ end
+ end
+ end
+end
+
class FlowdockService < Service
prop_accessor :token
validates :token, presence: true, if: :activated?
@@ -34,7 +80,7 @@ class FlowdockService < Service
data[:before],
data[:after],
token: token,
- repo: project.repository.path_to_repo,
+ repo: project.repository,
repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}",
commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/commit/%s",
diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s"
diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
index f31c3f02af2..dce878e485f 100644
--- a/app/models/project_services/hipchat_service.rb
+++ b/app/models/project_services/hipchat_service.rb
@@ -46,7 +46,7 @@ class HipchatService < Service
end
def self.supported_events
- %w(push issue confidential_issue merge_request note tag_push pipeline)
+ %w(push issue confidential_issue merge_request note confidential_note tag_push pipeline)
end
def execute(data)
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 87a4350f022..5d4e3c34b39 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -4,15 +4,15 @@ class ProjectStatistics < ActiveRecord::Base
before_save :update_storage_size
- STORAGE_COLUMNS = [:repository_size, :lfs_objects_size, :build_artifacts_size].freeze
- STATISTICS_COLUMNS = [:commit_count] + STORAGE_COLUMNS
+ COLUMNS_TO_REFRESH = [:repository_size, :lfs_objects_size, :commit_count].freeze
+ INCREMENTABLE_COLUMNS = [:build_artifacts_size].freeze
def total_repository_size
repository_size + lfs_objects_size
end
def refresh!(only: nil)
- STATISTICS_COLUMNS.each do |column, generator|
+ COLUMNS_TO_REFRESH.each do |column, generator|
if only.blank? || only.include?(column)
public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend
end
@@ -34,13 +34,15 @@ class ProjectStatistics < ActiveRecord::Base
self.lfs_objects_size = project.lfs_objects.sum(:size)
end
- def update_build_artifacts_size
- self.build_artifacts_size =
- project.builds.sum(:artifacts_size) +
- Ci::JobArtifact.artifacts_size_for(self.project)
+ def update_storage_size
+ self.storage_size = repository_size + lfs_objects_size + build_artifacts_size
end
- def update_storage_size
- self.storage_size = STORAGE_COLUMNS.sum(&method(:read_attribute))
+ def self.increment_statistic(project_id, key, amount)
+ raise ArgumentError, "Cannot increment attribute: #{key}" unless key.in?(INCREMENTABLE_COLUMNS)
+ return if amount == 0
+
+ where(project_id: project_id)
+ .update_all(["#{key} = COALESCE(#{key}, 0) + (?)", amount])
end
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index 52e067cb44c..f799a0b4227 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, :hashed_storage?, to: :project
+ delegate :repository_storage, :hashed_storage?, to: :project
def path
@project.path + '.wiki'
@@ -179,7 +179,11 @@ class ProjectWiki
def commit_details(action, message = nil, title = nil)
commit_message = message || default_message(action, title)
- Gitlab::Git::Wiki::CommitDetails.new(@user.name, @user.email, commit_message)
+ Gitlab::Git::Wiki::CommitDetails.new(@user.id,
+ @user.username,
+ @user.name,
+ @user.email,
+ commit_message)
end
def default_message(action, title)
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 609780c5587..cb361a66591 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -4,6 +4,15 @@ class ProtectedBranch < ActiveRecord::Base
protected_ref_access_levels :merge, :push
+ def self.protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil)
+ # Masters, owners and admins are allowed to create the default branch
+ if default_branch_protected? && project.empty_repo?
+ return true if user.admin? || project.team.max_member_access(user.id) > Gitlab::Access::DEVELOPER
+ end
+
+ super
+ end
+
# 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?
diff --git a/app/models/repository.rb b/app/models/repository.rb
index fd1afafe4df..6831305fb93 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -84,9 +84,14 @@ class Repository
# Return absolute path to repository
def path_to_repo
- @path_to_repo ||= File.expand_path(
- File.join(repository_storage_path, disk_path + '.git')
- )
+ @path_to_repo ||=
+ begin
+ storage = Gitlab.config.repositories.storages[@project.repository_storage]
+
+ File.expand_path(
+ File.join(storage.legacy_disk_path, disk_path + '.git')
+ )
+ end
end
def inspect
@@ -331,6 +336,7 @@ class Repository
return unless empty?
expire_method_caches(%i(has_visible_content?))
+ raw_repository.expire_has_local_branches_cache
end
def lookup_cache
@@ -914,10 +920,6 @@ class Repository
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,
diff --git a/app/models/service.rb b/app/models/service.rb
index 7424cef0fc0..f7e3f7590ad 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -14,6 +14,7 @@ class Service < ActiveRecord::Base
default_value_for :merge_requests_events, true
default_value_for :tag_push_events, true
default_value_for :note_events, true
+ default_value_for :confidential_note_events, true
default_value_for :job_events, true
default_value_for :pipeline_events, true
default_value_for :wiki_page_events, true
@@ -42,6 +43,7 @@ class Service < ActiveRecord::Base
scope :confidential_issue_hooks, -> { where(confidential_issues_events: true, active: true) }
scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) }
scope :note_hooks, -> { where(note_events: true, active: true) }
+ scope :confidential_note_hooks, -> { where(confidential_note_events: true, active: true) }
scope :job_hooks, -> { where(job_events: true, active: true) }
scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) }
scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) }
@@ -168,8 +170,10 @@ class Service < ActiveRecord::Base
def self.prop_accessor(*args)
args.each do |arg|
class_eval %{
- def #{arg}
- properties['#{arg}']
+ unless method_defined?(arg)
+ def #{arg}
+ properties['#{arg}']
+ end
end
def #{arg}=(value)
@@ -202,7 +206,11 @@ class Service < ActiveRecord::Base
args.each do |arg|
class_eval %{
def #{arg}?
- ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(#{arg})
+ if Gitlab.rails5?
+ !ActiveModel::Type::Boolean::FALSE_VALUES.include?(#{arg})
+ else
+ ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(#{arg})
+ end
end
}
end
diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed_project.rb
index fae1b64961a..26b4b78ac64 100644
--- a/app/models/storage/hashed_project.rb
+++ b/app/models/storage/hashed_project.rb
@@ -1,7 +1,7 @@
module Storage
class HashedProject
attr_accessor :project
- delegate :gitlab_shell, :repository_storage_path, to: :project
+ delegate :gitlab_shell, :repository_storage, to: :project
ROOT_PATH_PREFIX = '@hashed'.freeze
@@ -24,7 +24,7 @@ module Storage
end
def ensure_storage_path_exists
- gitlab_shell.add_namespace(repository_storage_path, base_dir)
+ gitlab_shell.add_namespace(repository_storage, base_dir)
end
def rename_repo
diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb
index 9d9e5e1d352..27cb388c702 100644
--- a/app/models/storage/legacy_project.rb
+++ b/app/models/storage/legacy_project.rb
@@ -1,7 +1,7 @@
module Storage
class LegacyProject
attr_accessor :project
- delegate :namespace, :gitlab_shell, :repository_storage_path, to: :project
+ delegate :namespace, :gitlab_shell, :repository_storage, to: :project
def initialize(project)
@project = project
@@ -24,18 +24,18 @@ module Storage
def ensure_storage_path_exists
return unless namespace
- gitlab_shell.add_namespace(repository_storage_path, base_dir)
+ gitlab_shell.add_namespace(repository_storage, base_dir)
end
def rename_repo
new_full_path = project.build_full_path
- if gitlab_shell.mv_repository(repository_storage_path, project.full_path_was, new_full_path)
+ if gitlab_shell.mv_repository(repository_storage, project.full_path_was, new_full_path)
# If repository moved successfully we need to send update instructions to users.
# However we cannot allow rollback since we moved repository
# So we basically we mute exceptions in next actions
begin
- gitlab_shell.mv_repository(repository_storage_path, "#{project.full_path_was}.wiki", "#{new_full_path}.wiki")
+ gitlab_shell.mv_repository(repository_storage, "#{project.full_path_was}.wiki", "#{new_full_path}.wiki")
return true
rescue => e
Rails.logger.error "Exception renaming #{project.full_path_was} -> #{new_full_path}: #{e}"
diff --git a/app/models/user.rb b/app/models/user.rb
index ba51595e6a3..4a602ffbb05 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -97,8 +97,8 @@ class User < ActiveRecord::Base
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
+ 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
# Projects
has_many :groups_projects, through: :groups, source: :projects
@@ -164,12 +164,15 @@ class User < ActiveRecord::Base
before_validation :sanitize_attrs
before_validation :set_notification_email, if: :email_changed?
+ before_save :set_notification_email, if: :email_changed? # in case validation is skipped
before_validation :set_public_email, if: :public_email_changed?
+ before_save :set_public_email, if: :public_email_changed? # in case validation is skipped
before_save :ensure_incoming_email_token
before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? }
before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) }
before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? }
before_validation :ensure_namespace_correct
+ before_save :ensure_namespace_correct # in case validation is skipped
after_validation :set_username_errors
after_update :username_changed_hook, if: :username_changed?
after_destroy :post_destroy_hook
@@ -408,7 +411,6 @@ class User < ActiveRecord::Base
unique_internal(where(ghost: true), 'ghost', email) do |u|
u.bio = 'This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.'
u.name = 'Ghost User'
- u.notification_email = email
end
end
end
@@ -698,10 +700,6 @@ class User < ActiveRecord::Base
projects_limit - personal_projects_count
end
- def personal_projects_count
- @personal_projects_count ||= personal_projects.count
- end
-
def recent_push(project = nil)
service = Users::LastPushEventService.new(self)
@@ -912,7 +910,7 @@ class User < ActiveRecord::Base
def delete_async(deleted_by:, params: {})
block if params[:hard_delete]
- DeleteUserWorker.perform_async(deleted_by.id, id, params)
+ DeleteUserWorker.perform_async(deleted_by.id, id, params.to_h)
end
def notification_service
@@ -949,10 +947,13 @@ class User < ActiveRecord::Base
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))
+ union_sql = Gitlab::SQL::Union.new([owned_groups.select(:id), masters_groups.select(:id)]).to_sql
+
+ # Update this line to not use raw SQL when migrated to Rails 5.2.
+ # Either ActiveRecord or Arel constructions are fine.
+ # This was replaced with the raw SQL construction because of bugs in the arel gem.
+ # Bugs were fixed in arel 9.0.0 (Rails 5.2).
+ owned_and_master_groups = Group.where("namespaces.id IN (#{union_sql})") # rubocop:disable GitlabSecurity/SqlInjection
Gitlab::GroupHierarchy.new(owned_and_master_groups).base_and_descendants
end
@@ -995,7 +996,7 @@ class User < ActiveRecord::Base
def ci_authorized_runners
@ci_authorized_runners ||= begin
runner_ids = Ci::RunnerProject
- .where("ci_runner_projects.project_id IN (#{ci_projects_union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
+ .where(project: authorized_projects(Gitlab::Access::MASTER))
.select(:runner_id)
Ci::Runner.specific.where(id: runner_ids)
end
@@ -1044,9 +1045,10 @@ class User < ActiveRecord::Base
end
end
- def update_cache_counts
- assigned_open_merge_requests_count(force: true)
- assigned_open_issues_count(force: true)
+ def personal_projects_count(force: false)
+ Rails.cache.fetch(['users', id, 'personal_projects_count'], force: force, expires_in: 24.hours, raw: true) do
+ personal_projects.count
+ end.to_i
end
def update_todos_count_cache
@@ -1059,6 +1061,7 @@ class User < ActiveRecord::Base
invalidate_merge_request_cache_counts
invalidate_todos_done_count
invalidate_todos_pending_count
+ invalidate_personal_projects_count
end
def invalidate_issue_cache_counts
@@ -1077,6 +1080,10 @@ class User < ActiveRecord::Base
Rails.cache.delete(['users', id, 'todos_pending_count'])
end
+ def invalidate_personal_projects_count
+ Rails.cache.delete(['users', id, 'personal_projects_count'])
+ end
+
# This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth
# flow means we don't call that automatically (and can't conveniently do so).
#
@@ -1200,15 +1207,6 @@ class User < ActiveRecord::Base
], remove_duplicates: false)
end
- def ci_projects_union
- scope = { access_level: [Gitlab::Access::MASTER, Gitlab::Access::OWNER] }
- groups = groups_projects.where(members: scope)
- other = projects.where(members: scope)
-
- Gitlab::SQL::Union.new([personal_projects.select(:id), groups.select(:id),
- other.select(:id)])
- end
-
# 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)
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 0f5536415f7..cde79b95062 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -265,6 +265,15 @@ class WikiPage
title.present? && self.class.unhyphenize(@page.url_path) != title
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
+
private
# Process and format the title based on the user input.
@@ -290,15 +299,6 @@ class WikiPage
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
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index 1ab391a5a9d..808a81cbbf9 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -11,7 +11,7 @@ module Ci
end
condition(:owner_of_job) do
- can?(:developer_access) && @subject.triggered_by?(@user)
+ @subject.triggered_by?(@user)
end
rule { protected_ref }.policy do
@@ -19,6 +19,6 @@ module Ci
prevent :erase_build
end
- rule { can?(:master_access) | owner_of_job }.enable :erase_build
+ rule { can?(:admin_build) | (can?(:update_build) & owner_of_job) }.enable :erase_build
end
end
diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb
index dc7a4aed577..ecba0488d3c 100644
--- a/app/policies/ci/pipeline_schedule_policy.rb
+++ b/app/policies/ci/pipeline_schedule_policy.rb
@@ -7,23 +7,17 @@ module Ci
end
condition(:owner_of_schedule) do
- can?(:developer_access) && pipeline_schedule.owned_by?(@user)
+ 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?(:create_pipeline) }.enable :play_pipeline_schedule
- rule { can?(:master_access) | owner_of_schedule }.policy do
+ rule { can?(:admin_pipeline) | (can?(:update_build) & owner_of_schedule) }.policy do
enable :update_pipeline_schedule
enable :admin_pipeline_schedule
end
- rule { can?(:master_access) & non_owner_of_schedule }.policy do
+ rule { can?(:admin_pipeline_schedule) & ~owner_of_schedule }.policy do
enable :take_ownership_pipeline_schedule
end
diff --git a/app/policies/deploy_token_policy.rb b/app/policies/deploy_token_policy.rb
new file mode 100644
index 00000000000..7aa9106e8b1
--- /dev/null
+++ b/app/policies/deploy_token_policy.rb
@@ -0,0 +1,11 @@
+class DeployTokenPolicy < BasePolicy
+ with_options scope: :subject, score: 0
+ condition(:master) { @subject.project.team.master?(@user) }
+
+ rule { anonymous }.prevent_all
+
+ rule { master }.policy do
+ enable :create_deploy_token
+ enable :update_deploy_token
+ end
+end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index c9cb730c4e9..520710b757d 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -22,7 +22,7 @@ class GroupPolicy < BasePolicy
condition(:can_change_parent_share_with_group_lock) { can?(:change_share_with_group_lock, @subject.parent) }
condition(:has_projects) do
- GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any?
+ GroupProjectsFinder.new(group: @subject, current_user: @user, options: { include_subgroups: true }).execute.any?
end
with_options scope: :subject, score: 0
@@ -43,7 +43,11 @@ class GroupPolicy < BasePolicy
end
rule { admin } .enable :read_group
- rule { has_projects } .enable :read_group
+
+ rule { has_projects }.policy do
+ enable :read_group
+ enable :read_label
+ end
rule { has_access }.enable :read_namespace
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index 3f6d7d04667..b431d376e3d 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -2,20 +2,6 @@ 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"
@@ -32,9 +18,7 @@ class IssuablePolicy < BasePolicy
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 ed499511999..263c6e3039c 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -17,6 +17,4 @@ class IssuePolicy < IssuablePolicy
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 e003376d219..c3fe857f8a2 100644
--- a/app/policies/merge_request_policy.rb
+++ b/app/policies/merge_request_policy.rb
@@ -1,3 +1,2 @@
class MergeRequestPolicy < IssuablePolicy
- rule { can?(:read_merge_request) | visible_to_user }.enable :read_merge_request_iid
end
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index d4cb5a77e63..077a6761ee6 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -1,26 +1,21 @@
class NotePolicy < BasePolicy
delegate { @subject.project }
- delegate { @subject.noteable if @subject.noteable.lockable? }
+ delegate { @subject.noteable if DeclarativePolicy.has_policy?(@subject.noteable) }
condition(:is_author) { @user && @subject.author == @user }
- condition(:for_merge_request, scope: :subject) { @subject.for_merge_request? }
condition(:is_noteable_author) { @user && @subject.noteable.author_id == @user.id }
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
+ rule { ~editable }.prevent :admin_note
rule { is_author }.policy do
enable :read_note
- enable :update_note
enable :admin_note
enable :resolve_note
end
- rule { for_merge_request & is_noteable_author }.policy do
+ rule { is_noteable_author }.policy do
enable :resolve_note
end
end
diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb
index cac0530b9f7..c1a84727cfa 100644
--- a/app/policies/personal_snippet_policy.rb
+++ b/app/policies/personal_snippet_policy.rb
@@ -25,4 +25,6 @@ class PersonalSnippetPolicy < BasePolicy
end
rule { anonymous }.prevent :comment_personal_snippet
+
+ rule { can?(:comment_personal_snippet) }.enable :award_emoji
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 57ab0c23dcd..3529d0aa60c 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -1,12 +1,26 @@
class ProjectPolicy < BasePolicy
- def self.create_read_update_admin(name)
- [
- :"create_#{name}",
- :"read_#{name}",
- :"update_#{name}",
- :"admin_#{name}"
- ]
- end
+ extend ClassMethods
+
+ READONLY_FEATURES_WHEN_ARCHIVED = %i[
+ issue
+ list
+ merge_request
+ label
+ milestone
+ project_snippet
+ wiki
+ note
+ pipeline
+ pipeline_schedule
+ build
+ trigger
+ environment
+ deployment
+ commit_status
+ container_image
+ pages
+ cluster
+ ].freeze
desc "User is a project owner"
condition :owner do
@@ -15,7 +29,7 @@ class ProjectPolicy < BasePolicy
end
desc "Project has public builds enabled"
- condition(:public_builds, scope: :subject) { project.public_builds? }
+ condition(:public_builds, scope: :subject, score: 0) { project.public_builds? }
# For guest access we use #team_member? so we can use
# project.members, which gets cached in subject scope.
@@ -35,7 +49,7 @@ class ProjectPolicy < BasePolicy
condition(:master) { team_access_level >= Gitlab::Access::MASTER }
desc "Project is public"
- condition(:public_project, scope: :subject) { project.public? }
+ condition(:public_project, scope: :subject, score: 0) { project.public? }
desc "Project is visible to internal users"
condition(:internal_access) do
@@ -46,7 +60,7 @@ class ProjectPolicy < BasePolicy
condition(:group_member, scope: :subject) { project_group_member? }
desc "Project is archived"
- condition(:archived, scope: :subject) { project.archived? }
+ condition(:archived, scope: :subject, score: 0) { project.archived? }
condition(:default_issues_tracker, scope: :subject) { project.default_issues_tracker? }
@@ -56,16 +70,32 @@ class ProjectPolicy < BasePolicy
end
desc "Project has an external wiki"
- condition(:has_external_wiki, scope: :subject) { project.has_external_wiki? }
+ condition(:has_external_wiki, scope: :subject, score: 0) { project.has_external_wiki? }
desc "Project has request access enabled"
- condition(:request_access_enabled, scope: :subject) { project.request_access_enabled }
+ condition(:request_access_enabled, scope: :subject, score: 0) { project.request_access_enabled }
desc "Has merge requests allowing pushes to user"
condition(:has_merge_requests_allowing_pushes, scope: :subject) do
project.merge_requests_allowing_push_to_user(user).any?
end
+ # 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. These checks are intended to be used alongside
+ # `:read_project_for_iids`.
+ #
+ # `:read_issue` & `:read_issue_iid` could diverge in gitlab-ee.
+ condition(:issues_visible_to_user, score: 4) do
+ @subject.feature_available?(:issues, @user)
+ end
+
+ condition(:merge_requests_visible_to_user, score: 4) do
+ @subject.feature_available?(:merge_requests, @user)
+ end
+
features = %w[
merge_requests
issues
@@ -81,6 +111,10 @@ class ProjectPolicy < BasePolicy
condition(:"#{f}_disabled", score: 32) { !feature_available?(f.to_sym) }
end
+ # `:read_project` may be prevented in EE, but `:read_project_for_iids` should
+ # not.
+ rule { guest | admin }.enable :read_project_for_iids
+
rule { guest }.enable :guest_access
rule { reporter }.enable :reporter_access
rule { developer }.enable :developer_access
@@ -106,6 +140,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:guest_access) }.policy do
enable :read_project
+ enable :create_merge_request_in
enable :read_board
enable :read_list
enable :read_wiki
@@ -120,10 +155,11 @@ class ProjectPolicy < BasePolicy
enable :create_note
enable :upload_file
enable :read_cycle_analytics
+ enable :award_emoji
end
# These abilities are not allowed to admins that are not members of the project,
- # that's why they are defined separatly.
+ # that's why they are defined separately.
rule { guest & can?(:download_code) }.enable :build_download_code
rule { guest & can?(:read_container_image) }.enable :build_read_container_image
@@ -150,6 +186,7 @@ class ProjectPolicy < BasePolicy
# where we enable or prevent it based on other coditions.
rule { (~anonymous & public_project) | internal_access }.policy do
enable :public_user_access
+ enable :read_project_for_iids
end
rule { can?(:public_user_access) }.policy do
@@ -176,7 +213,7 @@ class ProjectPolicy < BasePolicy
enable :create_pipeline
enable :update_pipeline
enable :create_pipeline_schedule
- enable :create_merge_request
+ enable :create_merge_request_from
enable :create_wiki
enable :push_code
enable :resolve_note
@@ -187,7 +224,7 @@ class ProjectPolicy < BasePolicy
end
rule { can?(:master_access) }.policy do
- enable :delete_protected_branch
+ enable :push_to_delete_protected_branch
enable :update_project_snippet
enable :update_environment
enable :update_deployment
@@ -210,37 +247,50 @@ class ProjectPolicy < BasePolicy
end
rule { archived }.policy do
- prevent :create_merge_request
prevent :push_code
- prevent :delete_protected_branch
- prevent :update_merge_request
- prevent :admin_merge_request
+ prevent :push_to_delete_protected_branch
+ prevent :request_access
+ prevent :upload_file
+ prevent :resolve_note
+ prevent :create_merge_request_from
+ prevent :create_merge_request_in
+ prevent :award_emoji
+
+ READONLY_FEATURES_WHEN_ARCHIVED.each do |feature|
+ prevent(*create_update_admin_destroy(feature))
+ end
+ end
+
+ rule { issues_disabled }.policy do
+ prevent(*create_read_update_admin_destroy(:issue))
end
rule { merge_requests_disabled | repository_disabled }.policy do
- prevent(*create_read_update_admin(:merge_request))
+ prevent :create_merge_request_in
+ prevent :create_merge_request_from
+ prevent(*create_read_update_admin_destroy(:merge_request))
end
rule { issues_disabled & merge_requests_disabled }.policy do
- prevent(*create_read_update_admin(:label))
- prevent(*create_read_update_admin(:milestone))
+ prevent(*create_read_update_admin_destroy(:label))
+ prevent(*create_read_update_admin_destroy(:milestone))
end
rule { snippets_disabled }.policy do
- prevent(*create_read_update_admin(:project_snippet))
+ prevent(*create_read_update_admin_destroy(:project_snippet))
end
rule { wiki_disabled & ~has_external_wiki }.policy do
- prevent(*create_read_update_admin(:wiki))
+ prevent(*create_read_update_admin_destroy(:wiki))
prevent(:download_wiki_code)
end
rule { builds_disabled | repository_disabled }.policy do
- prevent(*create_read_update_admin(:build))
- prevent(*(create_read_update_admin(:pipeline) - [:read_pipeline]))
- prevent(*create_read_update_admin(:pipeline_schedule))
- prevent(*create_read_update_admin(:environment))
- prevent(*create_read_update_admin(:deployment))
+ prevent(*create_update_admin_destroy(:pipeline))
+ prevent(*create_read_update_admin_destroy(:build))
+ prevent(*create_read_update_admin_destroy(:pipeline_schedule))
+ prevent(*create_read_update_admin_destroy(:environment))
+ prevent(*create_read_update_admin_destroy(:deployment))
end
rule { repository_disabled }.policy do
@@ -251,11 +301,15 @@ class ProjectPolicy < BasePolicy
end
rule { container_registry_disabled }.policy do
- prevent(*create_read_update_admin(:container_image))
+ prevent(*create_read_update_admin_destroy(:container_image))
end
rule { anonymous & ~public_project }.prevent_all
- rule { public_project }.enable(:public_access)
+
+ rule { public_project }.policy do
+ enable :public_access
+ enable :read_project_for_iids
+ end
rule { can?(:public_access) }.policy do
enable :read_project
@@ -289,13 +343,6 @@ class ProjectPolicy < BasePolicy
enable :read_pipeline_schedule
end
- rule { issues_disabled }.policy do
- prevent :create_issue
- prevent :update_issue
- prevent :admin_issue
- prevent :read_issue
- end
-
# These rules are included to allow maintainers of projects to push to certain
# to run pipelines for the branches they have access to.
rule { can?(:public_access) & has_merge_requests_allowing_pushes }.policy do
@@ -305,6 +352,14 @@ class ProjectPolicy < BasePolicy
enable :update_pipeline
end
+ rule do
+ (can?(:read_project_for_iids) & issues_visible_to_user) | can?(:read_issue)
+ end.enable :read_issue_iid
+
+ rule do
+ (can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request)
+ end.enable :read_merge_request_iid
+
private
def team_member?
diff --git a/app/policies/project_policy/class_methods.rb b/app/policies/project_policy/class_methods.rb
new file mode 100644
index 00000000000..60e5aba00ba
--- /dev/null
+++ b/app/policies/project_policy/class_methods.rb
@@ -0,0 +1,19 @@
+class ProjectPolicy
+ module ClassMethods
+ def create_read_update_admin_destroy(name)
+ [
+ :"read_#{name}",
+ *create_update_admin_destroy(name)
+ ]
+ end
+
+ def create_update_admin_destroy(name)
+ [
+ :"create_#{name}",
+ :"update_#{name}",
+ :"admin_#{name}",
+ :"destroy_#{name}"
+ ]
+ end
+ end
+end
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
index 255475e1fe6..4873d7ce662 100644
--- a/app/presenters/ci/build_presenter.rb
+++ b/app/presenters/ci/build_presenter.rb
@@ -1,5 +1,14 @@
module Ci
class BuildPresenter < Gitlab::View::Presenter::Delegated
+ CALLOUT_FAILURE_MESSAGES = {
+ unknown_failure: 'There is an unknown failure, please try again',
+ script_failure: 'There has been a script failure. Check the job log for more information',
+ api_failure: 'There has been an API failure, please try again',
+ stuck_or_timeout_failure: 'There has been a timeout failure or the job got stuck. Check your timeout limits or try again',
+ runner_system_failure: 'There has been a runner system failure, please try again',
+ missing_dependency_failure: 'There has been a missing dependency failure, check the job log for more information'
+ }.freeze
+
presents :build
def erased_by_user?
@@ -15,6 +24,8 @@ module Ci
def status_title
if auto_canceled?
"Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
+ else
+ tooltip_for_badge
end
end
@@ -28,5 +39,31 @@ module Ci
trigger_request.user_variables
end
end
+
+ def tooltip_message
+ "#{subject.name} - #{detailed_status.status_tooltip}"
+ end
+
+ def callout_failure_message
+ CALLOUT_FAILURE_MESSAGES[failure_reason.to_sym]
+ end
+
+ def recoverable?
+ failed? && !unrecoverable?
+ end
+
+ private
+
+ def tooltip_for_badge
+ detailed_status.badge_tooltip.capitalize
+ end
+
+ def detailed_status
+ @detailed_status ||= subject.detailed_status(user)
+ end
+
+ def unrecoverable?
+ script_failure? || missing_dependency_failure?
+ end
end
end
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 9f3f2637183..4b4132af2d0 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 ChecksCollaboration
include Gitlab::Utils::StrongMemoize
presents :merge_request
@@ -152,11 +153,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
def can_revert_on_current_merge_request?
- user_can_collaborate_with_project? && cached_can_be_reverted?
+ can_collaborate_with_project?(project) && cached_can_be_reverted?
end
def can_cherry_pick_on_current_merge_request?
- user_can_collaborate_with_project? && can_be_cherry_picked?
+ can_collaborate_with_project?(project) && can_be_cherry_picked?
end
def can_push_to_source_branch?
@@ -195,12 +196,6 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end.sort.to_sentence
end
- def user_can_collaborate_with_project?
- can?(current_user, :push_code, project) ||
- (current_user && current_user.already_forked?(project)) ||
- can_push_to_source_branch?
- end
-
def user_can_fork_project?
can?(current_user, :fork_project, project)
end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 484ac64580d..ad655a7b3f4 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -4,6 +4,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
include GitlabRoutingHelper
include StorageHelper
include TreeHelper
+ include ChecksCollaboration
include Gitlab::Utils::StrongMemoize
presents :project
@@ -170,9 +171,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def can_current_user_push_to_branch?(branch)
- return false unless repository.branch_exists?(branch)
+ user_access(project).can_push_to_branch?(branch)
+ end
- ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch)
+ def can_current_user_push_to_default_branch?
+ can_current_user_push_to_branch?(default_branch)
end
def files_anchor_data
@@ -200,7 +203,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def new_file_anchor_data
- if current_user && can_current_user_push_code?
+ if current_user && can_current_user_push_to_default_branch?
OpenStruct.new(enabled: false,
label: _('New file'),
link: project_new_blob_path(project, default_branch || 'master'),
@@ -209,7 +212,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def readme_anchor_data
- if current_user && can_current_user_push_code? && repository.readme.blank?
+ if current_user && can_current_user_push_to_default_branch? && repository.readme.blank?
OpenStruct.new(enabled: false,
label: _('Add Readme'),
link: add_readme_path)
@@ -221,7 +224,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def changelog_anchor_data
- if current_user && can_current_user_push_code? && repository.changelog.blank?
+ if current_user && can_current_user_push_to_default_branch? && repository.changelog.blank?
OpenStruct.new(enabled: false,
label: _('Add Changelog'),
link: add_changelog_path)
@@ -233,7 +236,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def license_anchor_data
- if current_user && can_current_user_push_code? && repository.license_blob.blank?
+ if current_user && can_current_user_push_to_default_branch? && repository.license_blob.blank?
OpenStruct.new(enabled: false,
label: _('Add License'),
link: add_license_path)
@@ -245,7 +248,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def contribution_guide_anchor_data
- if current_user && can_current_user_push_code? && repository.contribution_guide.blank?
+ if current_user && can_current_user_push_to_default_branch? && repository.contribution_guide.blank?
OpenStruct.new(enabled: false,
label: _('Add Contribution guide'),
link: add_contribution_guide_path)
@@ -260,7 +263,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
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'))
+ link: project_settings_ci_cd_path(project, anchor: 'autodevops-settings'))
elsif auto_devops_enabled?
OpenStruct.new(enabled: true,
label: _('Auto DevOps enabled'),
diff --git a/app/serializers/build_metadata_entity.rb b/app/serializers/build_metadata_entity.rb
index 39f429aa6c3..f16f3badffa 100644
--- a/app/serializers/build_metadata_entity.rb
+++ b/app/serializers/build_metadata_entity.rb
@@ -1,8 +1,5 @@
class BuildMetadataEntity < Grape::Entity
- expose :timeout_human_readable do |metadata|
- metadata.timeout_human_readable unless metadata.timeout.nil?
- end
-
+ expose :timeout_human_readable
expose :timeout_source do |metadata|
metadata.present.timeout_source
end
diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb
index 71d9a65fb58..464217123b4 100644
--- a/app/serializers/entity_date_helper.rb
+++ b/app/serializers/entity_date_helper.rb
@@ -1,5 +1,6 @@
module EntityDateHelper
include ActionView::Helpers::DateHelper
+ include ActionView::Helpers::TagHelper
def interval_in_words(diff)
return 'Not started' unless diff
@@ -34,4 +35,30 @@ module EntityDateHelper
duration_hash
end
+
+ # Generates an HTML-formatted string for remaining dates based on start_date and due_date
+ #
+ # It returns "Past due" for expired entities
+ # It returns "Upcoming" for upcoming entities
+ # If due date is provided, it returns "# days|weeks|months remaining|ago"
+ # If start date is provided and elapsed, with no due date, it returns "# days elapsed"
+ def remaining_days_in_words(entity)
+ if entity.try(:expired?)
+ content_tag(:strong, 'Past due')
+ elsif entity.try(:upcoming?)
+ content_tag(:strong, 'Upcoming')
+ elsif entity.due_date
+ is_upcoming = (entity.due_date - Date.today).to_i > 0
+ time_ago = time_ago_in_words(entity.due_date)
+ content = time_ago.gsub(/\d+/) { |match| "<strong>#{match}</strong>" }
+ content.slice!("about ")
+ content << " " + (is_upcoming ? _("remaining") : _("ago"))
+ content.html_safe
+ elsif entity.start_date && entity.start_date.past?
+ days = entity.elapsed_days
+ content = content_tag(:strong, days)
+ content << " #{'day'.pluralize(days)} elapsed"
+ content.html_safe
+ end
+ end
end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index b5e2334b6e3..840fdbcbf14 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -29,6 +29,10 @@ class IssueEntity < IssuableEntity
expose :can_update do |issue|
can?(request.current_user, :update_issue, issue)
end
+
+ expose :can_award_emoji do |issue|
+ can?(request.current_user, :award_emoji, issue)
+ end
end
expose :create_note_path do |issue|
diff --git a/app/serializers/job_entity.rb b/app/serializers/job_entity.rb
index 523b522d449..3076fed1674 100644
--- a/app/serializers/job_entity.rb
+++ b/app/serializers/job_entity.rb
@@ -26,6 +26,8 @@ class JobEntity < Grape::Entity
expose :created_at
expose :updated_at
expose :detailed_status, as: :status, with: StatusEntity
+ expose :callout_message, if: -> (*) { failed? }
+ expose :recoverable, if: -> (*) { failed? }
private
@@ -50,4 +52,20 @@ class JobEntity < Grape::Entity
def path_to(route, build)
send("#{route}_path", build.project.namespace, build.project, build) # rubocop:disable GitlabSecurity/PublicSend
end
+
+ def failed?
+ build.failed?
+ end
+
+ def callout_message
+ build_presenter.callout_failure_message
+ end
+
+ def recoverable
+ build_presenter.recoverable?
+ end
+
+ def build_presenter
+ @build_presenter ||= build.present
+ end
end
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index c964aa9c99b..06d603b277e 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -15,7 +15,11 @@ class NoteEntity < API::Entities::Note
expose :current_user do
expose :can_edit do |note|
- Ability.can_edit_note?(request.current_user, note)
+ Ability.allowed?(request.current_user, :admin_note, note)
+ end
+
+ expose :can_award_emoji do |note|
+ Ability.allowed?(request.current_user, :award_emoji, note)
end
end
diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb
index a7c2e21e92b..8e8bda2f9df 100644
--- a/app/serializers/status_entity.rb
+++ b/app/serializers/status_entity.rb
@@ -2,7 +2,7 @@ class StatusEntity < Grape::Entity
include RequestAwareEntity
expose :icon, :text, :label, :group
-
+ expose :status_tooltip, as: :tooltip
expose :has_details?, as: :has_details
expose :details_path
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 2b77f6be72a..f28cddb2af3 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -109,7 +109,7 @@ module Auth
case requested_action
when 'pull'
- build_can_pull?(requested_project) || user_can_pull?(requested_project)
+ build_can_pull?(requested_project) || user_can_pull?(requested_project) || deploy_token_can_pull?(requested_project)
when 'push'
build_can_push?(requested_project) || user_can_push?(requested_project)
when '*'
@@ -123,22 +123,34 @@ module Auth
Gitlab.config.registry
end
+ def can_user?(ability, project)
+ user = current_user.is_a?(User) ? current_user : nil
+ can?(user, ability, project)
+ end
+
def build_can_pull?(requested_project)
# Build can:
# 1. pull from its own project (for ex. a build)
# 2. read images from dependent projects if creator of build is a team member
has_authentication_ability?(:build_read_container_image) &&
- (requested_project == project || can?(current_user, :build_read_container_image, requested_project))
+ (requested_project == project || can_user?(:build_read_container_image, requested_project))
end
def user_can_admin?(requested_project)
has_authentication_ability?(:admin_container_image) &&
- can?(current_user, :admin_container_image, requested_project)
+ can_user?(:admin_container_image, requested_project)
end
def user_can_pull?(requested_project)
has_authentication_ability?(:read_container_image) &&
- can?(current_user, :read_container_image, requested_project)
+ can_user?(:read_container_image, requested_project)
+ end
+
+ def deploy_token_can_pull?(requested_project)
+ has_authentication_ability?(:read_container_image) &&
+ current_user.is_a?(DeployToken) &&
+ current_user.has_access_to?(requested_project) &&
+ current_user.read_registry?
end
##
@@ -154,7 +166,7 @@ module Auth
def user_can_push?(requested_project)
has_authentication_ability?(:create_container_image) &&
- can?(current_user, :create_container_image, requested_project)
+ can_user?(:create_container_image, requested_project)
end
def error(code, status:, message: '')
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index ecd74b74f8a..ac70a99c2c5 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -35,6 +35,7 @@ module Boards
def filter_params
set_parent
set_state
+ set_scope
params
end
@@ -51,6 +52,10 @@ module Boards
params[:state] = list && list.closed? ? 'closed' : 'opened'
end
+ def set_scope
+ params[:include_subgroups] = board.group_board?
+ end
+
def board_label_ids
@board_label_ids ||= board.lists.movable.pluck(:label_id)
end
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
index 15fed7d17c1..3ceab209f3f 100644
--- a/app/services/boards/issues/move_service.rb
+++ b/app/services/boards/issues/move_service.rb
@@ -42,7 +42,10 @@ module Boards
)
end
- attrs[:move_between_ids] = move_between_ids if move_between_ids
+ if move_between_ids
+ attrs[:move_between_ids] = move_between_ids
+ attrs[:board_group_id] = board.group&.id
+ end
attrs
end
diff --git a/app/services/ci/ensure_stage_service.rb b/app/services/ci/ensure_stage_service.rb
index 87f19b333de..b8c7be2d350 100644
--- a/app/services/ci/ensure_stage_service.rb
+++ b/app/services/ci/ensure_stage_service.rb
@@ -42,6 +42,7 @@ module Ci
def create_stage
Ci::Stage.create!(name: @build.stage,
+ position: @build.stage_idx,
pipeline: @build.pipeline,
project: @build.project)
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index e09b445636f..4291631913a 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -4,6 +4,9 @@ module Ci
class RegisterJobService
attr_reader :runner
+ JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30].freeze
+ JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET = 5.freeze
+
Result = Struct.new(:build, :valid?)
def initialize(runner)
@@ -14,8 +17,10 @@ module Ci
builds =
if runner.shared?
builds_for_shared_runner
+ elsif runner.group_type?
+ builds_for_group_runner
else
- builds_for_specific_runner
+ builds_for_project_runner
end
valid = true
@@ -41,7 +46,7 @@ module Ci
build.run!
register_success(build)
- return Result.new(build, true)
+ return Result.new(build, true) # rubocop:disable Cop/AvoidReturnFromBlocks
rescue Ci::Build::MissingDependenciesError
build.drop!(:missing_dependency_failure)
end
@@ -72,15 +77,24 @@ module Ci
.joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id')
.where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0').
- # Implement fair scheduling
- # this returns builds that are ordered by number of running builds
- # we prefer projects that don't use shared runners at all
- joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id")
+ # Implement fair scheduling
+ # this returns builds that are ordered by number of running builds
+ # we prefer projects that don't use shared runners at all
+ joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id")
.order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC')
end
- def builds_for_specific_runner
- new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('created_at ASC')
+ def builds_for_project_runner
+ new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('id ASC')
+ end
+
+ def builds_for_group_runner
+ hierarchy_groups = Gitlab::GroupHierarchy.new(runner.groups).base_and_descendants
+ projects = Project.where(namespace_id: hierarchy_groups)
+ .with_group_runners_enabled
+ .with_builds_enabled
+ .without_deleted
+ new_builds.where(project: projects).order('id ASC')
end
def running_builds_for_shared_runners
@@ -94,20 +108,28 @@ module Ci
builds
end
- def shared_runner_build_limits_feature_enabled?
- ENV['DISABLE_SHARED_RUNNER_BUILD_MINUTES_LIMIT'].to_s != 'true'
- end
-
def register_failure
failed_attempt_counter.increment
attempt_counter.increment
end
def register_success(job)
- job_queue_duration_seconds.observe({ shared_runner: @runner.shared? }, Time.now - job.created_at)
+ labels = { shared_runner: runner.shared?,
+ jobs_running_for_project: jobs_running_for_project(job) }
+
+ job_queue_duration_seconds.observe(labels, Time.now - job.queued_at) unless job.queued_at.nil?
attempt_counter.increment
end
+ def jobs_running_for_project(job)
+ return '+Inf' unless runner.shared?
+
+ # excluding currently started job
+ running_jobs_count = job.project.builds.running.where(runner: Ci::Runner.shared)
+ .limit(JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET + 1).count - 1
+ running_jobs_count < JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET ? running_jobs_count : "#{JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET}+"
+ end
+
def failed_attempt_counter
@failed_attempt_counter ||= Gitlab::Metrics.counter(:job_register_attempts_failed_total, "Counts the times a runner tries to register a job")
end
@@ -117,7 +139,7 @@ module Ci
end
def job_queue_duration_seconds
- @job_queue_duration_seconds ||= Gitlab::Metrics.histogram(:job_queue_duration_seconds, 'Request handling execution time')
+ @job_queue_duration_seconds ||= Gitlab::Metrics.histogram(:job_queue_duration_seconds, 'Request handling execution time', {}, JOB_QUEUE_DURATION_SECONDS_BUCKETS)
end
end
end
diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb
index 152c8ae5006..41b1c144c3e 100644
--- a/app/services/ci/update_build_queue_service.rb
+++ b/app/services/ci/update_build_queue_service.rb
@@ -1,18 +1,14 @@
module Ci
class UpdateBuildQueueService
def execute(build)
- build.project.runners.each do |runner|
- if runner.can_pick?(build)
- runner.tick_runner_queue
- end
- end
+ tick_for(build, build.project.all_runners)
+ end
- return unless build.project.shared_runners_enabled?
+ private
- Ci::Runner.shared.each do |runner|
- if runner.can_pick?(build)
- runner.tick_runner_queue
- end
+ def tick_for(build, runners)
+ runners.each do |runner|
+ runner.pick_build!(build)
end
end
end
diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb
index 15ab2d54404..84944e95542 100644
--- a/app/services/clusters/gcp/finalize_creation_service.rb
+++ b/app/services/clusters/gcp/finalize_creation_service.rb
@@ -13,7 +13,7 @@ module Clusters
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}")
+ provider.make_errored!("Failed to configure Google Kubernetes Engine Cluster: #{e.message}")
end
private
diff --git a/app/services/clusters/gcp/verify_provision_status_service.rb b/app/services/clusters/gcp/verify_provision_status_service.rb
index f994aacd086..7cc4324677e 100644
--- a/app/services/clusters/gcp/verify_provision_status_service.rb
+++ b/app/services/clusters/gcp/verify_provision_status_service.rb
@@ -17,7 +17,7 @@ module Clusters
when 'DONE'
finalize_creation
else
- return provider.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}")
+ provider.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}")
end
end
end
diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb
index 88dfb7a4a90..7e5a77fb056 100644
--- a/app/services/create_deployment_service.rb
+++ b/app/services/create_deployment_service.rb
@@ -19,8 +19,8 @@ class CreateDeploymentService
environment.fire_state_event(action)
- return unless environment.save
- return if environment.stopped?
+ break unless environment.save
+ break if environment.stopped?
deploy.tap(&:update_merge_request_metrics!)
end
diff --git a/app/services/deploy_tokens/create_service.rb b/app/services/deploy_tokens/create_service.rb
new file mode 100644
index 00000000000..52f545947af
--- /dev/null
+++ b/app/services/deploy_tokens/create_service.rb
@@ -0,0 +1,7 @@
+module DeployTokens
+ class CreateService < BaseService
+ def execute
+ @project.deploy_tokens.create(params)
+ end
+ end
+end
diff --git a/app/services/events/render_service.rb b/app/services/events/render_service.rb
index 0b62d8aedf1..bb72d7685dd 100644
--- a/app/services/events/render_service.rb
+++ b/app/services/events/render_service.rb
@@ -1,15 +1,17 @@
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
+ notes = events.map(&:note).compact
+
+ render_notes(notes, atom_request)
end
private
- def render_notes(notes, project, atom_request)
- Notes::RenderService.new(current_user).execute(notes, project, render_options(atom_request))
+ def render_notes(notes, atom_request)
+ Notes::RenderService
+ .new(current_user)
+ .execute(notes, render_options(atom_request))
end
def render_options(atom_request)
diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb
index 6442406d77e..74088b970c9 100644
--- a/app/services/import_export_clean_up_service.rb
+++ b/app/services/import_export_clean_up_service.rb
@@ -10,7 +10,7 @@ class ImportExportCleanUpService
def execute
Gitlab::Metrics.measure(:import_export_clean_up) do
- return unless File.directory?(path)
+ next unless File.directory?(path)
clean_up_export_files
end
diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb
index 7197a426a72..0b1a33518c6 100644
--- a/app/services/issuable/destroy_service.rb
+++ b/app/services/issuable/destroy_service.rb
@@ -4,6 +4,7 @@ module Issuable
TodoService.new.destroy_target(issuable) do |issuable|
if issuable.destroy
issuable.update_project_counter_caches
+ issuable.assignees.each(&:invalidate_cache_counts)
end
end
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 91ec702fbc6..1f67e3ecf9d 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -51,9 +51,10 @@ class IssuableBaseService < BaseService
return unless milestone_id
params[:milestone_id] = '' if milestone_id == IssuableFinder::NONE
+ group_ids = project.group&.self_and_ancestors&.pluck(:id)
milestone =
- Milestone.for_projects_and_groups([project.id], [project.group&.id]).find_by_id(milestone_id)
+ Milestone.for_projects_and_groups([project.id], group_ids).find_by_id(milestone_id)
params[:milestone_id] = '' unless milestone
end
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index fee5bc38f7b..4a99367c575 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -26,7 +26,7 @@ module Issues
issue.update(closed_by: current_user)
event_service.close_issue(issue, current_user)
create_note(issue, commit) if system_note
- notification_service.close_issue(issue, current_user) if notifications
+ notification_service.async.close_issue(issue, current_user) if notifications
todo_service.close_issue(issue, current_user)
execute_hooks(issue, 'close')
invalidate_cache_counts(issue, users: issue.assignees)
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 7140890d201..78e79344c99 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -139,7 +139,7 @@ module Issues
end
def notify_participants
- notification_service.issue_moved(@old_issue, @new_issue, @current_user)
+ notification_service.async.issue_moved(@old_issue, @new_issue, @current_user)
end
end
end
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index 62b4b4b6a1e..02224f3357a 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -6,7 +6,7 @@ module Issues
if issue.reopen
event_service.reopen_issue(issue, current_user)
create_note(issue, 'reopened')
- notification_service.reopen_issue(issue, current_user)
+ notification_service.async.reopen_issue(issue, current_user)
execute_hooks(issue, 'reopen')
invalidate_cache_counts(issue, users: issue.assignees)
issue.update_project_counter_caches
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index d7aa7e2347e..1000e1842b6 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -30,7 +30,7 @@ module Issues
if issue.assignees != old_assignees
create_assignee_note(issue, old_assignees)
- notification_service.reassigned_issue(issue, current_user, old_assignees)
+ notification_service.async.reassigned_issue(issue, current_user, old_assignees)
todo_service.reassigned_issue(issue, current_user, old_assignees)
end
@@ -41,13 +41,13 @@ module Issues
added_labels = issue.labels - old_labels
if added_labels.present?
- notification_service.relabeled_issue(issue, added_labels, current_user)
+ notification_service.async.relabeled_issue(issue, added_labels, current_user)
end
added_mentions = issue.mentioned_users - old_mentioned_users
if added_mentions.present?
- notification_service.new_mentions_in_issue(issue, added_mentions, current_user)
+ notification_service.async.new_mentions_in_issue(issue, added_mentions, current_user)
end
end
@@ -55,9 +55,10 @@ module Issues
return unless params[:move_between_ids]
after_id, before_id = params.delete(:move_between_ids)
+ board_group_id = params.delete(:board_group_id)
- issue_before = get_issue_if_allowed(issue.project, before_id) if before_id
- issue_after = get_issue_if_allowed(issue.project, after_id) if after_id
+ issue_before = get_issue_if_allowed(before_id, board_group_id)
+ issue_after = get_issue_if_allowed(after_id, board_group_id)
issue.move_between(issue_before, issue_after)
end
@@ -84,8 +85,16 @@ module Issues
private
- def get_issue_if_allowed(project, id)
- issue = project.issues.find(id)
+ def get_issue_if_allowed(id, board_group_id = nil)
+ return unless id
+
+ issue =
+ if board_group_id
+ IssuesFinder.new(current_user, group_id: board_group_id, include_subgroups: true).find_by(id: id)
+ else
+ project.issues.find(id)
+ end
+
issue if can?(current_user, :update_issue, issue)
end
diff --git a/app/services/labels/transfer_service.rb b/app/services/labels/transfer_service.rb
index 775efed48eb..9b7486cf53b 100644
--- a/app/services/labels/transfer_service.rb
+++ b/app/services/labels/transfer_service.rb
@@ -64,9 +64,14 @@ module Labels
end
def update_label_links(labels, old_label_id:, new_label_id:)
- LabelLink.joins(:label)
- .merge(labels)
- .where(label_id: old_label_id)
+ # use 'labels' relation to get label_link ids only of issues/MRs
+ # in the project being transferred.
+ # IDs are fetched in a separate query because MySQL doesn't
+ # allow referring of 'label_links' table in UPDATE query:
+ # https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/62435068
+ link_ids = labels.pluck('label_links.id')
+
+ LabelLink.where(id: link_ids, label_id: old_label_id)
.update_all(label_id: new_label_id)
end
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index f727ec002e7..db701c1145d 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -10,7 +10,7 @@ module MergeRequests
if merge_request.close
create_event(merge_request)
create_note(merge_request)
- notification_service.close_mr(merge_request, current_user)
+ notification_service.async.close_mr(merge_request, current_user)
todo_service.close_merge_request(merge_request, current_user)
execute_hooks(merge_request, 'close')
invalidate_cache_counts(merge_request, users: merge_request.assignees)
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index c57a2445341..fe1ac70781e 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -71,8 +71,8 @@ module MergeRequests
params.delete(:source_project_id)
params.delete(:target_project_id)
- unless can?(current_user, :read_project, @source_project) &&
- can?(current_user, :read_project, @project)
+ unless can?(current_user, :create_merge_request_from, @source_project) &&
+ can?(current_user, :create_merge_request_in, @project)
raise Gitlab::Access::AccessDeniedError
end
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index cedfcb50e09..2209a60a840 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -50,21 +50,30 @@ module MergeRequests
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}")
+ commit_id = try_merge
+
+ if commit_id
+ log_info("Git merge finished on JID #{merge_jid} commit #{commit_id}")
+ else
+ raise MergeError, 'Conflicts detected during merge'
+ end
- raise MergeError, 'Conflicts detected during merge' unless commit_id
+ merge_request.update!(merge_commit_sha: commit_id)
+ end
+
+ def try_merge
+ message = params[:commit_message] || merge_request.merge_commit_message
- merge_request.update(merge_commit_sha: commit_id)
+ repository.merge(current_user, source, merge_request, message)
rescue Gitlab::Git::HooksService::PreReceiveError => e
- raise MergeError, e.message
- rescue StandardError => e
- raise MergeError, "Something went wrong during merge: #{e.message}"
+ handle_merge_error(log_message: e.message)
+ raise MergeError, 'Something went wrong during merge pre-receive hook'
+ rescue => e
+ handle_merge_error(log_message: e.message)
+ raise MergeError, 'Something went wrong during merge'
ensure
- merge_request.update(in_progress_merge_commit_sha: nil)
+ merge_request.update!(in_progress_merge_commit_sha: nil)
end
def after_merge
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index 120677a7149..8f1c95ac1b7 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -6,7 +6,7 @@ module MergeRequests
if merge_request.reopen
create_event(merge_request)
create_note(merge_request, 'reopened')
- notification_service.reopen_mr(merge_request, current_user)
+ notification_service.async.reopen_mr(merge_request, current_user)
execute_hooks(merge_request, 'reopen')
merge_request.reload_diff(current_user)
merge_request.mark_as_unchecked
diff --git a/app/services/merge_requests/resolved_discussion_notification_service.rb b/app/services/merge_requests/resolved_discussion_notification_service.rb
index 3a09350c847..66a0cbc81d4 100644
--- a/app/services/merge_requests/resolved_discussion_notification_service.rb
+++ b/app/services/merge_requests/resolved_discussion_notification_service.rb
@@ -4,7 +4,7 @@ module MergeRequests
return unless merge_request.discussions_resolved?
SystemNoteService.resolve_all_discussions(merge_request, project, current_user)
- notification_service.resolve_all_discussions(merge_request, current_user)
+ notification_service.async.resolve_all_discussions(merge_request, current_user)
end
end
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 8a40ad88182..7350725e223 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -21,6 +21,7 @@ module MergeRequests
update(merge_request)
end
+ # rubocop:disable Metrics/AbcSize
def handle_changes(merge_request, options)
old_associations = options.fetch(:old_associations, {})
old_labels = old_associations.fetch(:labels, [])
@@ -42,8 +43,11 @@ module MergeRequests
end
if merge_request.previous_changes.include?('assignee_id')
+ old_assignee_id = merge_request.previous_changes['assignee_id'].first
+ old_assignee = User.find(old_assignee_id) if old_assignee_id
+
create_assignee_note(merge_request)
- notification_service.reassigned_merge_request(merge_request, current_user)
+ notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignee)
todo_service.reassigned_merge_request(merge_request, current_user)
end
@@ -54,7 +58,7 @@ module MergeRequests
added_labels = merge_request.labels - old_labels
if added_labels.present?
- notification_service.relabeled_merge_request(
+ notification_service.async.relabeled_merge_request(
merge_request,
added_labels,
current_user
@@ -63,13 +67,14 @@ module MergeRequests
added_mentions = merge_request.mentioned_users - old_mentioned_users
if added_mentions.present?
- notification_service.new_mentions_in_merge_request(
+ notification_service.async.new_mentions_in_merge_request(
merge_request,
added_mentions,
current_user
)
end
end
+ # rubocop:enable Metrics/AbcSize
def merge_from_quick_action(merge_request)
last_diff_sha = params.delete(:merge)
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index ad3dcc5010b..199b8028dbc 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -11,7 +11,7 @@ module Notes
unless @note.system?
EventCreateService.new.leave_note(@note, @note.author)
- return unless @note.for_project_noteable?
+ return if @note.for_personal_snippet?
@note.create_cross_references!
execute_note_hooks
@@ -23,9 +23,13 @@ module Notes
end
def execute_note_hooks
+ return unless @note.project
+
note_data = hook_data
- @note.project.execute_hooks(note_data, :note_hooks)
- @note.project.execute_services(note_data, :note_hooks)
+ hooks_scope = @note.confidential? ? :confidential_note_hooks : :note_hooks
+
+ @note.project.execute_hooks(note_data, hooks_scope)
+ @note.project.execute_services(note_data, hooks_scope)
end
end
end
diff --git a/app/services/notes/render_service.rb b/app/services/notes/render_service.rb
index a77e98c2b07..efc9d6da2aa 100644
--- a/app/services/notes/render_service.rb
+++ b/app/services/notes/render_service.rb
@@ -3,19 +3,18 @@ module Notes
# 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)
+ def execute(notes, options = {})
+ Banzai::ObjectRenderer
+ .new(user: current_user, redaction_context: options)
+ .render(notes, :note)
end
end
end
diff --git a/app/services/notes/resolve_service.rb b/app/services/notes/resolve_service.rb
new file mode 100644
index 00000000000..0db8ee809a9
--- /dev/null
+++ b/app/services/notes/resolve_service.rb
@@ -0,0 +1,9 @@
+module Notes
+ class ResolveService < ::BaseService
+ def execute(note)
+ note.resolve!(current_user)
+
+ ::MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(note.noteable)
+ end
+ end
+end
diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb
index e4be953e810..83e59a649b6 100644
--- a/app/services/notification_recipient_service.rb
+++ b/app/services/notification_recipient_service.rb
@@ -54,8 +54,7 @@ module NotificationRecipientService
users = users.includes(:notification_settings)
end
- users = Array(users)
- users.compact!
+ users = Array(users).compact
recipients.concat(users.map { |u| make_recipient(u, type, reason) })
end
@@ -204,10 +203,11 @@ module NotificationRecipientService
attr_reader :action
attr_reader :previous_assignee
attr_reader :skip_current_user
- def initialize(target, current_user, action:, previous_assignee: nil, skip_current_user: true)
+ def initialize(target, current_user, action:, custom_action: nil, previous_assignee: nil, skip_current_user: true)
@target = target
@current_user = current_user
@action = action
+ @custom_action = custom_action
@previous_assignee = previous_assignee
@skip_current_user = skip_current_user
end
@@ -237,7 +237,13 @@ module NotificationRecipientService
add_mentions(current_user, target: target)
# Add the assigned users, if any
- assignees = custom_action == :new_issue ? target.assignees : target.assignee
+ assignees = case custom_action
+ when :new_issue
+ target.assignees
+ else
+ target.assignee
+ end
+
# 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
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index f94c76cf3ac..55a1735e54b 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -7,7 +7,32 @@
# Ex.
# NotificationService.new.new_issue(issue, current_user)
#
+# When calculating the recipients of a notification is expensive (for instance,
+# in the new issue case), `#async` will make that calculation happen in Sidekiq
+# instead:
+#
+# NotificationService.new.async.new_issue(issue, current_user)
+#
class NotificationService
+ class Async
+ attr_reader :parent
+ delegate :respond_to_missing, to: :parent
+
+ def initialize(parent)
+ @parent = parent
+ end
+
+ def method_missing(meth, *args)
+ return super unless parent.respond_to?(meth)
+
+ MailScheduler::NotificationServiceWorker.perform_async(meth.to_s, *args)
+ end
+ end
+
+ def async
+ @async ||= Async.new(self)
+ end
+
# Always notify user about ssh key added
# only if ssh key is not deploy key
#
@@ -142,8 +167,23 @@ class NotificationService
# * merge_request assignee if their notification level is not Disabled
# * users with custom level checked with "reassign merge request"
#
- def reassigned_merge_request(merge_request, current_user)
- reassign_resource_email(merge_request, current_user, :reassigned_merge_request_email)
+ def reassigned_merge_request(merge_request, current_user, previous_assignee)
+ recipients = NotificationRecipientService.build_recipients(
+ merge_request,
+ current_user,
+ action: "reassign",
+ previous_assignee: previous_assignee
+ )
+
+ recipients.each do |recipient|
+ mailer.reassigned_merge_request_email(
+ recipient.user.id,
+ merge_request.id,
+ previous_assignee&.id,
+ current_user.id,
+ recipient.reason
+ ).deliver_later
+ end
end
# When we add labels to a merge request we should send an email to:
@@ -373,6 +413,20 @@ class NotificationService
end
end
+ def issue_due(issue)
+ recipients = NotificationRecipientService.build_recipients(
+ issue,
+ issue.author,
+ action: 'due',
+ custom_action: :issue_due,
+ skip_current_user: false
+ )
+
+ recipients.each do |recipient|
+ mailer.send(:issue_due_email, recipient.user.id, issue.id, recipient.reason).deliver_later
+ end
+ end
+
protected
def new_resource_email(target, method)
@@ -407,29 +461,6 @@ class NotificationService
end
end
- def reassign_resource_email(target, current_user, method)
- previous_assignee_id = previous_record(target, 'assignee_id')
- previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
-
- recipients = NotificationRecipientService.build_recipients(
- target,
- current_user,
- action: "reassign",
- previous_assignee: previous_assignee
- )
-
- recipients.each do |recipient|
- mailer.send(
- method,
- recipient.user.id,
- target.id,
- previous_assignee_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) }.uniq
recipients = notifiable_users(
@@ -457,14 +488,6 @@ class NotificationService
Notify
end
- def previous_record(object, attribute)
- return unless object && attribute
-
- if object.previous_changes.include?(attribute)
- object.previous_changes[attribute].first
- end
- end
-
private
def recipients_for_pages_domain(domain)
diff --git a/app/services/projects/base_move_relations_service.rb b/app/services/projects/base_move_relations_service.rb
new file mode 100644
index 00000000000..e8fd3ef57e5
--- /dev/null
+++ b/app/services/projects/base_move_relations_service.rb
@@ -0,0 +1,22 @@
+module Projects
+ class BaseMoveRelationsService < BaseService
+ attr_reader :source_project
+ def execute(source_project, remove_remaining_elements: true)
+ return if source_project.blank?
+
+ @source_project = source_project
+
+ true
+ end
+
+ private
+
+ def prepare_relation(relation, id_param = :id)
+ if Gitlab::Database.postgresql?
+ relation
+ else
+ relation.model.where("#{id_param}": relation.pluck(id_param))
+ end
+ end
+ end
+end
diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb
index a549cfbabea..29b133cc466 100644
--- a/app/services/projects/create_from_template_service.rb
+++ b/app/services/projects/create_from_template_service.rb
@@ -8,9 +8,10 @@ module Projects
template_name = params.delete(:template_name)
file = Gitlab::ProjectTemplate.find(template_name).file
+ override_params = params.dup
params[:file] = file
- GitlabProjectsImportService.new(current_user, params).execute
+ GitlabProjectsImportService.new(current_user, params, override_params).execute
ensure
file&.close
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 633e2c8236c..d361d070993 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -96,6 +96,8 @@ module Projects
system_hook_service.execute_hooks_for(@project, :create)
setup_authorizations
+
+ current_user.invalidate_personal_projects_count
end
# Refresh the current user's authorizations inline (so they can access the
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 4b8f955ae69..71c93660b4b 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -34,6 +34,8 @@ module Projects
system_hook_service.execute_hooks_for(project, :destroy)
log_info("Project \"#{project.full_path}\" was removed")
+ current_user.invalidate_personal_projects_count
+
true
rescue => error
attempt_rollback(project, error.message)
@@ -44,6 +46,20 @@ module Projects
raise
end
+ def attempt_repositories_rollback
+ return unless @project
+
+ flush_caches(@project)
+
+ unless mv_repository(removal_path(repo_path), repo_path)
+ raise_error('Failed to restore project repository. Please contact the administrator.')
+ end
+
+ unless mv_repository(removal_path(wiki_path), wiki_path)
+ raise_error('Failed to restore wiki repository. Please contact the administrator.')
+ end
+ end
+
private
def repo_path
@@ -68,23 +84,27 @@ module Projects
# Skip repository removal. We use this flag when remove user or group
return true if params[:skip_repo] == true
- # There is a possibility project does not have repository or wiki
- return true unless gitlab_shell.exists?(project.repository_storage_path, path + '.git')
-
new_path = removal_path(path)
- if gitlab_shell.mv_repository(project.repository_storage_path, path, new_path)
+ if mv_repository(path, new_path)
log_info("Repository \"#{path}\" moved to \"#{new_path}\"")
project.run_after_commit do
# self is now project
- GitlabShellWorker.perform_in(5.minutes, :remove_repository, self.repository_storage_path, new_path)
+ GitlabShellWorker.perform_in(5.minutes, :remove_repository, self.repository_storage, new_path)
end
else
false
end
end
+ def mv_repository(from_path, to_path)
+ # There is a possibility project does not have repository or wiki
+ return true unless gitlab_shell.exists?(project.repository_storage, from_path + '.git')
+
+ gitlab_shell.mv_repository(project.repository_storage, from_path, to_path)
+ end
+
def attempt_rollback(project, message)
return unless project
@@ -117,7 +137,7 @@ module Projects
return true unless Gitlab.config.registry.enabled
ContainerRepository.build_root_repository(project).tap do |repository|
- return repository.has_tags? ? repository.delete_tags! : true
+ break repository.has_tags? ? repository.delete_tags! : true
end
end
diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb
index a68ecb4abe1..a16268f4fd2 100644
--- a/app/services/projects/gitlab_projects_import_service.rb
+++ b/app/services/projects/gitlab_projects_import_service.rb
@@ -5,8 +5,8 @@ module Projects
class GitlabProjectsImportService
attr_reader :current_user, :params
- def initialize(user, params)
- @current_user, @params = user, params.dup
+ def initialize(user, import_params, override_params = nil)
+ @current_user, @params, @override_params = user, import_params.dup, override_params
end
def execute
@@ -15,8 +15,18 @@ module Projects
file = params.delete(:file)
FileUtils.copy_entry(file.path, import_upload_path)
+ @overwrite = params.delete(:overwrite)
+ data = {}
+ data[:override_params] = @override_params if @override_params
+
+ if overwrite_project?
+ data[:original_path] = params[:path]
+ params[:path] += "-#{tmp_filename}"
+ end
+
params[:import_type] = 'gitlab_project'
params[:import_source] = import_upload_path
+ params[:import_data] = { data: data } if data.present?
::Projects::CreateService.new(current_user, params).execute
end
@@ -30,5 +40,17 @@ module Projects
def tmp_filename
SecureRandom.hex
end
+
+ def overwrite_project?
+ @overwrite && project_with_same_full_path?
+ end
+
+ def project_with_same_full_path?
+ Project.find_by_full_path("#{current_namespace.full_path}/#{params[:path]}").present?
+ end
+
+ def current_namespace
+ @current_namespace ||= Namespace.find_by(id: params[:namespace_id])
+ 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
index 67178de75de..68c1af2396b 100644
--- a/app/services/projects/hashed_storage/migrate_repository_service.rb
+++ b/app/services/projects/hashed_storage/migrate_repository_service.rb
@@ -47,8 +47,8 @@ module Projects
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")
+ from_exists = gitlab_shell.exists?(project.repository_storage, "#{from_name}.git")
+ to_exists = gitlab_shell.exists?(project.repository_storage, "#{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.
@@ -60,7 +60,7 @@ module Projects
return true
end
- gitlab_shell.mv_repository(project.repository_storage_path, from_name, to_name)
+ gitlab_shell.mv_repository(project.repository_storage, from_name, to_name)
end
def rollback_folder_move
diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb
index 402cddd3ec1..7bf0b90b491 100644
--- a/app/services/projects/import_export/export_service.rb
+++ b/app/services/projects/import_export/export_service.rb
@@ -28,7 +28,7 @@ module Projects
end
def save_services
- [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver].all?(&:save)
+ [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver, lfs_saver].all?(&:save)
end
def version_saver
@@ -55,6 +55,10 @@ module Projects
Gitlab::ImportExport::WikiRepoSaver.new(project: project, shared: @shared)
end
+ def lfs_saver
+ Gitlab::ImportExport::LfsSaver.new(project: project, shared: @shared)
+ end
+
def cleanup_and_notify_error
Rails.logger.error("Import/Export - Project #{project.name} with ID: #{project.id} export error - #{@shared.errors.join(', ')}")
diff --git a/app/services/projects/move_access_service.rb b/app/services/projects/move_access_service.rb
new file mode 100644
index 00000000000..3af3a22d486
--- /dev/null
+++ b/app/services/projects/move_access_service.rb
@@ -0,0 +1,25 @@
+module Projects
+ class MoveAccessService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ @project.with_transaction_returning_status do
+ if @project.namespace != source_project.namespace
+ @project.run_after_commit do
+ source_project.namespace.refresh_project_authorizations
+ self.namespace.refresh_project_authorizations
+ end
+ end
+
+ ::Projects::MoveProjectMembersService.new(@project, @current_user)
+ .execute(source_project, remove_remaining_elements: remove_remaining_elements)
+ ::Projects::MoveProjectGroupLinksService.new(@project, @current_user)
+ .execute(source_project, remove_remaining_elements: remove_remaining_elements)
+ ::Projects::MoveProjectAuthorizationsService.new(@project, @current_user)
+ .execute(source_project, remove_remaining_elements: remove_remaining_elements)
+
+ success
+ end
+ end
+ end
+end
diff --git a/app/services/projects/move_deploy_keys_projects_service.rb b/app/services/projects/move_deploy_keys_projects_service.rb
new file mode 100644
index 00000000000..dde420655b0
--- /dev/null
+++ b/app/services/projects/move_deploy_keys_projects_service.rb
@@ -0,0 +1,31 @@
+module Projects
+ class MoveDeployKeysProjectsService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_deploy_keys_projects
+ remove_remaining_deploy_keys_projects if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_deploy_keys_projects
+ prepare_relation(non_existent_deploy_keys_projects)
+ .update_all(project_id: @project.id)
+ end
+
+ def non_existent_deploy_keys_projects
+ source_project.deploy_keys_projects
+ .joins(:deploy_key)
+ .where.not(keys: { fingerprint: @project.deploy_keys.select(:fingerprint) })
+ end
+
+ def remove_remaining_deploy_keys_projects
+ source_project.deploy_keys_projects.destroy_all
+ end
+ end
+end
diff --git a/app/services/projects/move_forks_service.rb b/app/services/projects/move_forks_service.rb
new file mode 100644
index 00000000000..d2901ea1457
--- /dev/null
+++ b/app/services/projects/move_forks_service.rb
@@ -0,0 +1,42 @@
+module Projects
+ class MoveForksService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super && source_project.fork_network
+
+ Project.transaction(requires_new: true) do
+ move_forked_project_links
+ move_fork_network_members
+ update_root_project
+ refresh_forks_count
+
+ success
+ end
+ end
+
+ private
+
+ def move_forked_project_links
+ # Update ancestor
+ ForkedProjectLink.where(forked_to_project: source_project)
+ .update_all(forked_to_project_id: @project.id)
+
+ # Update the descendants
+ ForkedProjectLink.where(forked_from_project: source_project)
+ .update_all(forked_from_project_id: @project.id)
+ end
+
+ def move_fork_network_members
+ ForkNetworkMember.where(project: source_project).update_all(project_id: @project.id)
+ ForkNetworkMember.where(forked_from_project: source_project).update_all(forked_from_project_id: @project.id)
+ end
+
+ def update_root_project
+ # Update root network project
+ ForkNetwork.where(root_project: source_project).update_all(root_project_id: @project.id)
+ end
+
+ def refresh_forks_count
+ Projects::ForksCountService.new(@project).refresh_cache
+ end
+ end
+end
diff --git a/app/services/projects/move_lfs_objects_projects_service.rb b/app/services/projects/move_lfs_objects_projects_service.rb
new file mode 100644
index 00000000000..298da5f1a82
--- /dev/null
+++ b/app/services/projects/move_lfs_objects_projects_service.rb
@@ -0,0 +1,29 @@
+module Projects
+ class MoveLfsObjectsProjectsService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_lfs_objects_projects
+ remove_remaining_lfs_objects_project if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_lfs_objects_projects
+ prepare_relation(non_existent_lfs_objects_projects)
+ .update_all(project_id: @project.lfs_storage_project.id)
+ end
+
+ def remove_remaining_lfs_objects_project
+ source_project.lfs_objects_projects.destroy_all
+ end
+
+ def non_existent_lfs_objects_projects
+ source_project.lfs_objects_projects.where.not(lfs_object: @project.lfs_objects)
+ end
+ end
+end
diff --git a/app/services/projects/move_notification_settings_service.rb b/app/services/projects/move_notification_settings_service.rb
new file mode 100644
index 00000000000..f7be461a5da
--- /dev/null
+++ b/app/services/projects/move_notification_settings_service.rb
@@ -0,0 +1,38 @@
+module Projects
+ class MoveNotificationSettingsService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_notification_settings
+ remove_remaining_notification_settings if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_notification_settings
+ prepare_relation(non_existent_notifications)
+ .update_all(source_id: @project.id)
+ end
+
+ # Remove remaining notification settings from source_project
+ def remove_remaining_notification_settings
+ source_project.notification_settings.destroy_all
+ end
+
+ # Get users of current notification_settings
+ def users_in_target_project
+ @project.notification_settings.select(:user_id)
+ end
+
+ # Look for notification_settings in source_project that are not in the target project
+ def non_existent_notifications
+ source_project.notification_settings
+ .select(:id)
+ .where.not(user_id: users_in_target_project)
+ end
+ end
+end
diff --git a/app/services/projects/move_project_authorizations_service.rb b/app/services/projects/move_project_authorizations_service.rb
new file mode 100644
index 00000000000..5ef12fc49e5
--- /dev/null
+++ b/app/services/projects/move_project_authorizations_service.rb
@@ -0,0 +1,40 @@
+# NOTE: This service cannot be used directly because it is part of a
+# a bigger process. Instead, use the service MoveAccessService which moves
+# project memberships, project group links, authorizations and refreshes
+# the authorizations if neccessary
+module Projects
+ class MoveProjectAuthorizationsService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_project_authorizations
+
+ remove_remaining_authorizations if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_project_authorizations
+ prepare_relation(non_existent_authorization, :user_id)
+ .update_all(project_id: @project.id)
+ end
+
+ def remove_remaining_authorizations
+ # I think because the Project Authorization table does not have a primary key
+ # it brings a lot a problems/bugs. First, Rails raises PG::SyntaxException if we use
+ # destroy_all instead of delete_all.
+ source_project.project_authorizations.delete_all(:delete_all)
+ end
+
+ # Look for authorizations in source_project that are not in the target project
+ def non_existent_authorization
+ source_project.project_authorizations
+ .select(:user_id)
+ .where.not(user: @project.authorized_users)
+ end
+ end
+end
diff --git a/app/services/projects/move_project_group_links_service.rb b/app/services/projects/move_project_group_links_service.rb
new file mode 100644
index 00000000000..dbeffd7dae9
--- /dev/null
+++ b/app/services/projects/move_project_group_links_service.rb
@@ -0,0 +1,40 @@
+# NOTE: This service cannot be used directly because it is part of a
+# a bigger process. Instead, use the service MoveAccessService which moves
+# project memberships, project group links, authorizations and refreshes
+# the authorizations if neccessary
+module Projects
+ class MoveProjectGroupLinksService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_group_links
+ remove_remaining_project_group_links if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_group_links
+ prepare_relation(non_existent_group_links)
+ .update_all(project_id: @project.id)
+ end
+
+ # Remove remaining project group links from source_project
+ def remove_remaining_project_group_links
+ source_project.reload.project_group_links.destroy_all
+ end
+
+ def group_links_in_target_project
+ @project.project_group_links.select(:group_id)
+ end
+
+ # Look for groups in source_project that are not in the target project
+ def non_existent_group_links
+ source_project.project_group_links
+ .where.not(group_id: group_links_in_target_project)
+ end
+ end
+end
diff --git a/app/services/projects/move_project_members_service.rb b/app/services/projects/move_project_members_service.rb
new file mode 100644
index 00000000000..22a5f0a3fe6
--- /dev/null
+++ b/app/services/projects/move_project_members_service.rb
@@ -0,0 +1,40 @@
+# NOTE: This service cannot be used directly because it is part of a
+# a bigger process. Instead, use the service MoveAccessService which moves
+# project memberships, project group links, authorizations and refreshes
+# the authorizations if neccessary
+module Projects
+ class MoveProjectMembersService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_project_members
+ remove_remaining_members if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_project_members
+ prepare_relation(non_existent_members).update_all(source_id: @project.id)
+ end
+
+ def remove_remaining_members
+ # Remove remaining members and authorizations from source_project
+ source_project.project_members.destroy_all
+ end
+
+ def project_members_in_target_project
+ @project.project_members.select(:user_id)
+ end
+
+ # Look for members in source_project that are not in the target project
+ def non_existent_members
+ source_project.members
+ .select(:id)
+ .where.not(user_id: @project.project_members.select(:user_id))
+ end
+ end
+end
diff --git a/app/services/projects/move_users_star_projects_service.rb b/app/services/projects/move_users_star_projects_service.rb
new file mode 100644
index 00000000000..079fd5b9685
--- /dev/null
+++ b/app/services/projects/move_users_star_projects_service.rb
@@ -0,0 +1,20 @@
+module Projects
+ class MoveUsersStarProjectsService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ user_stars = source_project.users_star_projects
+
+ return unless user_stars.any?
+
+ Project.transaction(requires_new: true) do
+ user_stars.update_all(project_id: @project.id)
+
+ Project.reset_counters @project.id, :users_star_projects
+ Project.reset_counters source_project.id, :users_star_projects
+
+ success
+ end
+ end
+ end
+end
diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb
new file mode 100644
index 00000000000..ce94f147aa9
--- /dev/null
+++ b/app/services/projects/overwrite_project_service.rb
@@ -0,0 +1,69 @@
+module Projects
+ class OverwriteProjectService < BaseService
+ def execute(source_project)
+ return unless source_project && source_project.namespace == @project.namespace
+
+ Project.transaction do
+ move_before_destroy_relationships(source_project)
+ destroy_old_project(source_project)
+ rename_project(source_project.name, source_project.path)
+
+ @project
+ end
+ # Projects::DestroyService can raise Exceptions, but we don't want
+ # to pass that kind of exception to the caller. Instead, we change it
+ # for a StandardError exception
+ rescue Exception => e # rubocop:disable Lint/RescueException
+ attempt_restore_repositories(source_project)
+
+ if e.class == Exception
+ raise StandardError, e.message
+ else
+ raise
+ end
+ end
+
+ private
+
+ def move_before_destroy_relationships(source_project)
+ options = { remove_remaining_elements: false }
+
+ ::Projects::MoveUsersStarProjectsService.new(@project, @current_user).execute(source_project, options)
+ ::Projects::MoveAccessService.new(@project, @current_user).execute(source_project, options)
+ ::Projects::MoveDeployKeysProjectsService.new(@project, @current_user).execute(source_project, options)
+ ::Projects::MoveNotificationSettingsService.new(@project, @current_user).execute(source_project, options)
+ ::Projects::MoveForksService.new(@project, @current_user).execute(source_project, options)
+ ::Projects::MoveLfsObjectsProjectsService.new(@project, @current_user).execute(source_project, options)
+ add_source_project_to_fork_network(source_project)
+ end
+
+ def destroy_old_project(source_project)
+ # Delete previous project (synchronously) and unlink relations
+ ::Projects::DestroyService.new(source_project, @current_user).execute
+ end
+
+ def rename_project(name, path)
+ # Update de project's name and path to the original name/path
+ ::Projects::UpdateService.new(@project,
+ @current_user,
+ { name: name, path: path })
+ .execute
+ end
+
+ def attempt_restore_repositories(project)
+ ::Projects::DestroyService.new(project, @current_user).attempt_repositories_rollback
+ end
+
+ def add_source_project_to_fork_network(source_project)
+ return unless @project.fork_network
+
+ # Because he have moved all references in the fork network from the source_project
+ # we won't be able to query the database (only through its cached data),
+ # for its former relationships. That's why we're adding it to the network
+ # as a fork of the target project
+ ForkNetworkMember.create!(fork_network: @project.fork_network,
+ project: source_project,
+ forked_from_project: @project)
+ end
+ end
+end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 26765e5c3f3..61acdd58021 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -24,6 +24,8 @@ module Projects
transfer(project)
+ current_user.invalidate_personal_projects_count
+
true
rescue Projects::TransferService::TransferError => ex
project.reload
@@ -125,7 +127,7 @@ module Projects
end
def move_repo_folder(from_name, to_name)
- gitlab_shell.mv_repository(project.repository_storage_path, from_name, to_name)
+ gitlab_shell.mv_repository(project.repository_storage, from_name, to_name)
end
def execute_system_hooks
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 7e228d1833d..1d8caec9c6f 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -1,6 +1,6 @@
module Projects
class UpdatePagesService < BaseService
- InvaildStateError = Class.new(StandardError)
+ InvalidStateError = Class.new(StandardError)
FailedToExtractError = Class.new(StandardError)
BLOCK_SIZE = 32.kilobytes
@@ -21,8 +21,8 @@ module Projects
@status.enqueue!
@status.run!
- raise InvaildStateError, 'missing pages artifacts' unless build.artifacts?
- raise InvaildStateError, 'pages are outdated' unless latest?
+ raise InvalidStateError, 'missing pages artifacts' unless build.artifacts?
+ raise InvalidStateError, 'pages are outdated' unless latest?
# Create temporary directory in which we will extract the artifacts
FileUtils.mkdir_p(tmp_path)
@@ -31,16 +31,16 @@ module Projects
# Check if we did extract public directory
archive_public_path = File.join(archive_path, 'public')
- raise InvaildStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path)
- raise InvaildStateError, 'pages are outdated' unless latest?
+ raise InvalidStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path)
+ raise InvalidStateError, 'pages are outdated' unless latest?
deploy_page!(archive_public_path)
success
end
- rescue InvaildStateError => e
+ rescue InvalidStateError => e
error(e.message)
rescue => e
- error(e.message, false)
+ error(e.message)
raise e
end
@@ -48,17 +48,15 @@ module Projects
def success
@status.success
- delete_artifact!
super
end
- def error(message, allow_delete_artifact = true)
+ def error(message)
register_failure
log_error("Projects::UpdatePagesService: #{message}")
@status.allow_failure = !latest?
@status.description = message
@status.drop(:script_failure)
- delete_artifact! if allow_delete_artifact
super
end
@@ -74,33 +72,21 @@ module Projects
end
def extract_archive!(temp_path)
- if artifacts.ends_with?('.tar.gz') || artifacts.ends_with?('.tgz')
- extract_tar_archive!(temp_path)
- elsif artifacts.ends_with?('.zip')
+ if artifacts.ends_with?('.zip')
extract_zip_archive!(temp_path)
else
- raise InvaildStateError, 'unsupported artifacts format'
- end
- end
-
- def extract_tar_archive!(temp_path)
- build.artifacts_file.use_file do |artifacts_path|
- results = Open3.pipeline(%W(gunzip -c #{artifacts_path}),
- %W(dd bs=#{BLOCK_SIZE} count=#{blocks}),
- %W(tar -x -C #{temp_path} #{SITE_PATH}),
- err: '/dev/null')
- raise FailedToExtractError, 'pages failed to extract' unless results.compact.all?(&:success?)
+ raise InvalidStateError, 'unsupported artifacts format'
end
end
def extract_zip_archive!(temp_path)
- raise InvaildStateError, 'missing artifacts metadata' unless build.artifacts_metadata?
+ raise InvalidStateError, '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 InvaildStateError, "artifacts for pages are too large: #{public_entry.total_size}"
+ raise InvalidStateError, "artifacts for pages are too large: #{public_entry.total_size}"
end
# Requires UnZip at least 6.00 Info-ZIP.
@@ -174,11 +160,6 @@ 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
ensure
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index 6cc51b6ee1b..0215994b1a7 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -138,8 +138,10 @@ module QuickActions
'Remove assignee'
end
end
- explanation do
- "Removes #{'assignee'.pluralize(issuable.assignees.size)} #{issuable.assignees.map(&:to_reference).to_sentence}."
+ explanation do |users = nil|
+ assignees = issuable.assignees
+ assignees &= users if users.present? && issuable.allows_multiple_assignees?
+ "Removes #{'assignee'.pluralize(assignees.size)} #{assignees.map(&:to_reference).to_sentence}."
end
params do
issuable.allows_multiple_assignees? ? '@user1 @user2' : ''
@@ -268,6 +270,26 @@ module QuickActions
end
end
+ desc 'Copy labels and milestone from other issue or merge request'
+ explanation do |source_issuable|
+ "Copy labels and milestone from #{source_issuable.to_reference}."
+ end
+ params '#issue | !merge_request'
+ condition do
+ issuable.persisted? &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ parse_params do |issuable_param|
+ extract_references(issuable_param, :issue).first ||
+ extract_references(issuable_param, :merge_request).first
+ end
+ command :copy_metadata do |source_issuable|
+ if source_issuable.present? && source_issuable.project.id == issuable.project.id
+ @updates[:add_label_ids] = source_issuable.labels.map(&:id)
+ @updates[:milestone_id] = source_issuable.milestone.id if source_issuable.milestone
+ end
+ end
+
desc 'Add a todo'
explanation 'Adds a todo.'
condition do
diff --git a/app/services/repository_archive_clean_up_service.rb b/app/services/repository_archive_clean_up_service.rb
index aa84d36a206..ba7be4b3f89 100644
--- a/app/services/repository_archive_clean_up_service.rb
+++ b/app/services/repository_archive_clean_up_service.rb
@@ -10,7 +10,7 @@ class RepositoryArchiveCleanUpService
def execute
Gitlab::Metrics.measure(:repository_archive_clean_up) do
- return unless File.directory?(path)
+ next unless File.directory?(path)
clean_up_old_archives
clean_up_empty_directories
@@ -20,11 +20,12 @@ class RepositoryArchiveCleanUpService
private
def clean_up_old_archives
- run(%W(find #{path} -not -path #{path} -type f \( -name \*.tar -o -name \*.bz2 -o -name \*.tar.gz -o -name \*.zip \) -maxdepth 2 -mmin +#{mmin} -delete))
+ run(%W(find #{path} -mindepth 1 -maxdepth 3 -type f \( -name \*.tar -o -name \*.bz2 -o -name \*.tar.gz -o -name \*.zip \) -mmin +#{mmin} -delete))
end
def clean_up_empty_directories
- run(%W(find #{path} -not -path #{path} -type d -empty -name \*.git -maxdepth 1 -delete))
+ run(%W(find #{path} -mindepth 2 -maxdepth 2 -type d -empty -delete))
+ run(%W(find #{path} -mindepth 1 -maxdepth 1 -type d -empty -delete))
end
def run(cmd)
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 2253d638e93..00bf5434b7f 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -429,7 +429,7 @@ module SystemNoteService
def cross_reference(noteable, mentioner, author)
return if cross_reference_disallowed?(noteable, mentioner)
- gfm_reference = mentioner.gfm_reference(noteable.project)
+ gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group)
body = cross_reference_note_content(gfm_reference)
if noteable.is_a?(ExternalIssue)
@@ -582,7 +582,7 @@ module SystemNoteService
text = "#{cross_reference_note_prefix}%#{mentioner.to_reference(nil)}"
notes.where('(note LIKE ? OR note LIKE ?)', text, text.capitalize)
else
- gfm_reference = mentioner.gfm_reference(noteable.project)
+ gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group)
text = cross_reference_note_content(gfm_reference)
notes.where(note: [text, text.capitalize])
end
diff --git a/app/services/test_hooks/base_service.rb b/app/services/test_hooks/base_service.rb
index e9aefb1fb75..aadc1ea644b 100644
--- a/app/services/test_hooks/base_service.rb
+++ b/app/services/test_hooks/base_service.rb
@@ -19,7 +19,7 @@ module TestHooks
error_message = catch(:validation_error) do
sample_data = self.__send__(trigger_data_method) # rubocop:disable GitlabSecurity/PublicSend
- return hook.execute(sample_data, trigger_key)
+ return hook.execute(sample_data, trigger_key) # rubocop:disable Cop/AvoidReturnFromBlocks
end
error(error_message)
diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb
index f12f0466a1d..f8a237178d9 100644
--- a/app/uploaders/gitlab_uploader.rb
+++ b/app/uploaders/gitlab_uploader.rb
@@ -65,6 +65,10 @@ class GitlabUploader < CarrierWave::Uploader::Base
!!model
end
+ def local_url
+ File.join('/', self.class.base_dir, dynamic_segment, filename)
+ end
+
private
# Designed to be overridden by child uploaders that have a dynamic path
diff --git a/app/uploaders/job_artifact_uploader.rb b/app/uploaders/job_artifact_uploader.rb
index ef0f8acefd6..2a5a830ce4f 100644
--- a/app/uploaders/job_artifact_uploader.rb
+++ b/app/uploaders/job_artifact_uploader.rb
@@ -2,12 +2,14 @@ class JobArtifactUploader < GitlabUploader
extend Workhorse::UploadPath
include ObjectStorage::Concern
+ ObjectNotReadyError = Class.new(StandardError)
+
storage_options Gitlab.config.artifacts
- def size
- return super if model.size.nil?
+ def cached_size
+ return model.size if model.size.present? && !model.file_changed?
- model.size
+ size
end
def store_dir
@@ -18,13 +20,15 @@ class JobArtifactUploader < GitlabUploader
if file_storage?
File.open(path, "rb") if path
else
- ::Gitlab::Ci::Trace::HttpIO.new(url, size) if url
+ ::Gitlab::Ci::Trace::HttpIO.new(url, cached_size) if url
end
end
private
def dynamic_segment
+ raise ObjectNotReadyError, 'JobArtifact is not ready' unless model.id
+
creation_date = model.created_at.utc.strftime('%Y_%m_%d')
File.join(disk_hash[0..1], disk_hash[2..3], disk_hash,
diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb
index b726b053493..efb7893d153 100644
--- a/app/uploaders/legacy_artifact_uploader.rb
+++ b/app/uploaders/legacy_artifact_uploader.rb
@@ -2,6 +2,8 @@ class LegacyArtifactUploader < GitlabUploader
extend Workhorse::UploadPath
include ObjectStorage::Concern
+ ObjectNotReadyError = Class.new(StandardError)
+
storage_options Gitlab.config.artifacts
def store_dir
@@ -11,6 +13,8 @@ class LegacyArtifactUploader < GitlabUploader
private
def dynamic_segment
+ raise ObjectNotReadyError, 'Build is not ready' unless model.id
+
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/object_storage.rb b/app/uploaders/object_storage.rb
index 4028b052768..a3549cada95 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -128,7 +128,7 @@ module ObjectStorage
end
def direct_upload_enabled?
- object_store_options.direct_upload
+ object_store_options&.direct_upload
end
def background_upload_enabled?
@@ -156,11 +156,10 @@ module ObjectStorage
end
def workhorse_authorize
- if options = workhorse_remote_upload_options
- { RemoteObject: options }
- else
- { TempPath: workhorse_local_upload_path }
- end
+ {
+ RemoteObject: workhorse_remote_upload_options,
+ TempPath: workhorse_local_upload_path
+ }.compact
end
def workhorse_local_upload_path
@@ -204,6 +203,7 @@ module ObjectStorage
end
def object_store
+ # We use Store::LOCAL as null value indicates the local storage
@object_store ||= model.try(store_serialization_column) || Store::LOCAL
end
@@ -285,16 +285,23 @@ module ObjectStorage
}
end
- def store_workhorse_file!(params, identifier)
- filename = params["#{identifier}.name"]
+ def cache!(new_file = sanitized_file)
+ # We intercept ::UploadedFile which might be stored on remote storage
+ # We use that for "accelerated" uploads, where we store result on remote storage
+ if new_file.is_a?(::UploadedFile) && new_file.remote_id
+ return cache_remote_file!(new_file.remote_id, new_file.original_filename)
+ end
- if remote_object_id = params["#{identifier}.remote_id"]
- store_remote_file!(remote_object_id, filename)
- elsif local_path = params["#{identifier}.path"]
- store_local_file!(local_path, filename)
- else
- raise RemoteStoreError, 'Bad file'
+ super
+ end
+
+ def store!(new_file = nil)
+ # when direct upload is enabled, always store on remote storage
+ if self.class.object_store_enabled? && self.class.direct_upload_enabled?
+ self.object_store = Store::REMOTE
end
+
+ super
end
private
@@ -305,36 +312,29 @@ module ObjectStorage
self.file_storage?
end
- def store_remote_file!(remote_object_id, filename)
- raise RemoteStoreError, 'Missing filename' unless filename
-
+ def cache_remote_file!(remote_object_id, original_filename)
file_path = File.join(TMP_UPLOAD_PATH, remote_object_id)
file_path = Pathname.new(file_path).cleanpath.to_s
raise RemoteStoreError, 'Bad file path' unless file_path.start_with?(TMP_UPLOAD_PATH + '/')
- self.object_store = Store::REMOTE
-
# TODO:
# This should be changed to make use of `tmp/cache` mechanism
# instead of using custom upload directory,
# using tmp/cache makes this implementation way easier than it is today
- CarrierWave::Storage::Fog::File.new(self, storage, file_path).tap do |file|
+ CarrierWave::Storage::Fog::File.new(self, storage_for(Store::REMOTE), file_path).tap do |file|
raise RemoteStoreError, 'Missing file' unless file.exists?
- self.filename = filename
- self.file = storage.store!(file)
- end
- end
+ # Remote stored file, we force to store on remote storage
+ self.object_store = Store::REMOTE
- def store_local_file!(local_path, filename)
- raise RemoteStoreError, 'Missing filename' unless filename
-
- root_path = File.realpath(self.class.workhorse_local_upload_path)
- file_path = File.realpath(local_path)
- raise RemoteStoreError, 'Bad file path' unless file_path.start_with?(root_path)
-
- self.object_store = Store::LOCAL
- self.store!(UploadedFile.new(file_path, filename))
+ # TODO:
+ # We store file internally and force it to be considered as `cached`
+ # This makes CarrierWave to store file in permament location (copy/delete)
+ # once this object is saved, but not sooner
+ @cache_id = "force-to-use-cache" # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ @file = file # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ @filename = original_filename # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
end
# this is a hack around CarrierWave. The #migrate method needs to be
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
index ac31977e1a9..4eebb59110a 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -21,7 +21,7 @@
.help-block
Manage repository storage paths. Learn more in the
= succeed "." do
- = link_to "repository storages documentation", help_page_path("administration/repository_storages")
+ = link_to "repository storages documentation", help_page_path("administration/repository_storage_paths")
.sub-section
%h4 Circuit breaker
.form-group
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 864e64b5fa9..48331c40bca 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -24,6 +24,7 @@
- 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'
+ = hidden_field_tag 'application_setting[enabled_oauth_sign_in_sources][]'
.col-sm-10
.btn-group{ data: { toggle: 'buttons' } }
- oauth_providers_checkboxes.each do |source|
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index cbc779548f6..a75dd90fe6b 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -32,6 +32,7 @@
.form-group
= f.label :import_sources, class: 'control-label col-sm-2'
.col-sm-10
+ = hidden_field_tag 'application_setting[import_sources][]'
- import_sources_checkboxes('import-sources-help').each do |source|
.checkbox= source
%span.help-block#import-sources-help
diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml
index 9e605054523..caaa93aa1e2 100644
--- a/app/views/admin/application_settings/show.html.haml
+++ b/app/views/admin/application_settings/show.html.haml
@@ -76,7 +76,7 @@
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
- = _('Auto DevOps, runners amd job artifacts')
+ = _('Auto DevOps, runners and job artifacts')
.settings-content
= render 'ci_cd'
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 05c41082882..bbf0e0fb95c 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -126,6 +126,7 @@
GitLab
%span.pull-right
= Gitlab::VERSION
+ = "(#{Gitlab::REVISION})"
%p
GitLab Shell
%span.pull-right
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index c47b8a88f56..75ca5106fd5 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -13,7 +13,7 @@
.panel
.panel-heading.alert.alert-danger
Last repository check
- = "(#{time_ago_in_words(@project.last_repository_check_at)} ago)"
+ = "(#{time_ago_with_tooltip(@project.last_repository_check_at)})"
failed. See
= link_to 'repocheck.log', admin_logs_path
for error messages.
@@ -101,7 +101,7 @@
- if @project.archived?
%li
%span.light archived:
- %strong repository is read-only
+ %strong project is read-only
%li
%span.light access:
diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml
index e1cee584929..99fbbaec487 100644
--- a/app/views/admin/runners/_runner.html.haml
+++ b/app/views/admin/runners/_runner.html.haml
@@ -2,6 +2,8 @@
%td
- if runner.shared?
%span.label.label-success shared
+ - elsif runner.group_type?
+ %span.label.label-success group
- else
%span.label.label-info specific
- if runner.locked?
@@ -19,7 +21,7 @@
%td
= runner.ip_address
%td
- - if runner.shared?
+ - if runner.shared? || runner.group_type?
n/a
- else
= runner.projects.count(:all)
@@ -31,7 +33,7 @@
= tag
%td
- if runner.contacted_at
- #{time_ago_in_words(runner.contacted_at)} ago
+ = time_ago_with_tooltip runner.contacted_at
- else
Never
%td.admin-runner-btn-group-cell
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 9f13dbbbd82..1a3b5e58ed5 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -17,6 +17,9 @@
%span.label.label-success shared
\- Runner runs jobs from all unassigned projects
%li
+ %span.label.label-success group
+ \- Runner runs jobs from all unassigned projects in its group
+ %li
%span.label.label-info specific
\- Runner runs jobs from assigned projects
%li
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index 37269862de6..d022016f70d 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -19,6 +19,9 @@
%p
If you want Runners to build only specific projects, enable them in the table below.
Keep in mind that this is a one way transition.
+- elsif @runner.group_type?
+ .bs-callout.bs-callout-success
+ %h4 This runner will process jobs from all projects in its group and subgroups
- else
.bs-callout.bs-callout-info
%h4 This Runner will process jobs only from ASSIGNED projects
@@ -108,4 +111,4 @@
%td.timestamp
- if build.finished_at
- %span #{time_ago_in_words build.finished_at} ago
+ %span= time_ago_with_tooltip build.finished_at
diff --git a/app/views/admin/services/index.html.haml b/app/views/admin/services/index.html.haml
index 50132572096..89872c1b91a 100644
--- a/app/views/admin/services/index.html.haml
+++ b/app/views/admin/services/index.html.haml
@@ -20,5 +20,4 @@
%td
= service.description
%td.light
- = time_ago_in_words service.updated_at
- ago
+ = time_ago_with_tooltip service.updated_at
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index bbfeceff5b9..badf3dd74b3 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -33,7 +33,7 @@
= link_to 'Block', block_admin_user_path(user), data: { confirm: 'USER WILL BE BLOCKED! Are you sure?' }, method: :put
- if user.access_locked?
%li
- = link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' }
+ = link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') }
- if can?(current_user, :destroy_user, user)
%li.divider
- if user.can_be_removed?
@@ -43,7 +43,7 @@
delete_user_url: admin_user_path(user),
block_user_url: block_admin_user_path(user),
username: user.name,
- delete_contributions: 'false' }, type: 'button' }
+ delete_contributions: false }, type: 'button' }
= s_('AdminUsers|Delete user')
%li
@@ -52,5 +52,5 @@
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' }
+ 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 0ef4b71f4fe..10b8bf5d565 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -42,31 +42,31 @@
= nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
= link_to admin_users_path do
Active
- %small.badge= number_with_delimiter(User.active.count)
+ %small.badge= limited_counter_with_delimiter(User.active)
= nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
= link_to admin_users_path(filter: "admins") do
Admins
- %small.badge= number_with_delimiter(User.admins.count)
+ %small.badge= limited_counter_with_delimiter(User.admins)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
= link_to admin_users_path(filter: 'two_factor_enabled') do
2FA Enabled
- %small.badge= number_with_delimiter(User.with_two_factor.count)
+ %small.badge= limited_counter_with_delimiter(User.with_two_factor)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
= link_to admin_users_path(filter: 'two_factor_disabled') do
2FA Disabled
- %small.badge= number_with_delimiter(User.without_two_factor.count)
+ %small.badge= limited_counter_with_delimiter(User.without_two_factor)
= nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
= link_to admin_users_path(filter: 'external') do
External
- %small.badge= number_with_delimiter(User.external.count)
+ %small.badge= limited_counter_with_delimiter(User.external)
= nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
= link_to admin_users_path(filter: "blocked") do
Blocked
- %small.badge= number_with_delimiter(User.blocked.count)
+ %small.badge= limited_counter_with_delimiter(User.blocked)
= nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
= link_to admin_users_path(filter: "wop") do
Without projects
- %small.badge= number_with_delimiter(User.without_projects.count)
+ %small.badge= limited_counter_with_delimiter(User.without_projects)
%ul.flex-list.content-list
- if @users.empty?
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index ec3be869797..814ccdae8f3 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -183,7 +183,7 @@
delete_user_url: admin_user_path(@user),
block_user_url: block_admin_user_path(@user),
username: @user.name,
- delete_contributions: 'false' }, type: 'button' }
+ delete_contributions: false }, type: 'button' }
= s_('AdminUsers|Delete user')
- else
- if @user.solo_owned_groups.present?
@@ -215,7 +215,7 @@
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' }
+ delete_contributions: true }, type: 'button' }
= s_('AdminUsers|Delete user and contributions')
- else
%p
diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml
index 5f07d2720c2..4b3c52af16a 100644
--- a/app/views/award_emoji/_awards_block.html.haml
+++ b/app/views/award_emoji/_awards_block.html.haml
@@ -3,13 +3,13 @@
.awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.empty?), data: { award_url: toggle_award_url(awardable) } }
- awards_sort(grouped_emojis).each do |emoji, awards|
%button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button",
- class: [(award_state_class(awards, current_user)), (award_user_authored_class(emoji) if user_authored)],
+ class: [(award_state_class(awardable, awards, current_user)), (award_user_authored_class(emoji) if user_authored)],
data: { placement: "bottom", title: award_user_list(awards, current_user) } }
= emoji_icon(emoji)
%span.award-control-text.js-counter
= awards.count
- - if current_user
+ - if can?(current_user, :award_emoji, awardable)
.award-menu-holder.js-award-holder
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
'aria-label': 'Add reaction',
diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml
index 35a3563dff1..5114387984b 100644
--- a/app/views/ci/status/_badge.html.haml
+++ b/app/views/ci/status/_badge.html.haml
@@ -4,10 +4,10 @@
- css_classes = "ci-status ci-#{status.group} #{'has-tooltip' if title.present?}"
- if link && status.has_details?
- = link_to status.details_path, class: css_classes, title: title do
+ = link_to status.details_path, class: css_classes, title: title, data: { html: title.present? } do
= sprite_icon(status.icon)
= status.text
- else
- %span{ class: css_classes, title: title }
+ %span{ class: css_classes, title: title, data: { html: title.present? } }
= 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 c5b4439e273..db2040110fa 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -3,14 +3,15 @@
- subject = local_assigns.fetch(:subject)
- status = subject.detailed_status(current_user)
- klass = "ci-status-icon ci-status-icon-#{status.group}"
-- tooltip = "#{subject.name} - #{status.label}"
+- tooltip = "#{subject.name} - #{status.status_tooltip}"
- if status.has_details?
- = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
+ = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, html: true, container: 'body' } do
%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' } }
+ .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', html: true, title: tooltip, container: 'body' } }
%span{ class: klass }= sprite_icon(status.icon)
%span.ci-build-text= subject.name
diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
index 440623b34f5..571eb28f195 100644
--- a/app/views/ci/variables/_variable_row.html.haml
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -17,14 +17,14 @@
.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",
+ %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.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) }
+ .form-control.js-secret-value-placeholder.qa-ci-variable-input-value{ class: ('hide' unless id) }
= '*' * 20
- %textarea.js-ci-variable-input-value.js-secret-value.form-control{ class: ('hide' if id),
+ %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ class: ('hide' if id),
rows: 1,
name: value_input_name,
placeholder: s_('CiVariables|Input variable value') }
diff --git a/app/views/dashboard/issues.atom.builder b/app/views/dashboard/issues.atom.builder
index 70ec6bc6257..d7b6fb9a4a1 100644
--- a/app/views/dashboard/issues.atom.builder
+++ b/app/views/dashboard/issues.atom.builder
@@ -1,5 +1,5 @@
xml.title "#{current_user.name} issues"
-xml.link href: url_for(params), rel: "self", type: "application/atom+xml"
+xml.link href: url_for(safe_params), rel: "self", type: "application/atom+xml"
xml.link href: issues_dashboard_url, rel: "alternate", type: "text/html"
xml.id issues_dashboard_url
xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any?
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index 3e85535dae0..4bf04dadf01 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -1,15 +1,19 @@
- @hide_top_links = true
-- page_title "Issues"
-- header_title "Issues", issues_dashboard_path(assignee_id: current_user.id)
+- page_title _("Issues")
+- @breadcrumb_link = issues_dashboard_path(assignee_id: current_user.id)
= content_for :meta_tags do
- = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{current_user.name} issues")
+ = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues")
.top-area
- = render 'shared/issuable/nav', type: :issues
+ = render 'shared/issuable/nav', type: :issues, display_count: !@no_filters_set
.nav-controls
- = link_to params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe' do
+ = link_to safe_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
= render 'shared/issuable/filter', type: :issues
-= render 'shared/issues'
+
+- if current_user && @no_filters_set
+ = render 'shared/dashboard/no_filter_selected'
+- else
+ = render 'shared/issues'
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index 53cd1130299..61aae31be60 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -1,11 +1,15 @@
- @hide_top_links = true
-- page_title "Merge Requests"
-- header_title "Merge Requests", merge_requests_dashboard_path(assignee_id: current_user.id)
+- page_title _("Merge Requests")
+- @breadcrumb_link = merge_requests_dashboard_path(assignee_id: current_user.id)
.top-area
- = render 'shared/issuable/nav', type: :merge_requests
+ = render 'shared/issuable/nav', type: :merge_requests, display_count: !@no_filters_set
.nav-controls
= render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests', type: :merge_requests
= render 'shared/issuable/filter', type: :merge_requests
-= render 'shared/merge_requests'
+
+- if current_user && @no_filters_set
+ = render 'shared/dashboard/no_filter_selected'
+- else
+ = render 'shared/merge_requests'
diff --git a/app/views/devise/mailer/unlock_instructions.html.haml b/app/views/devise/mailer/unlock_instructions.html.haml
index 79e3a35cc9a..8ddfd3ea74a 100644
--- a/app/views/devise/mailer/unlock_instructions.html.haml
+++ b/app/views/devise/mailer/unlock_instructions.html.haml
@@ -2,7 +2,7 @@
= email_default_heading("Hello, #{@resource.name}!")
%p
Your GitLab account has been locked due to an excessive amount of unsuccessful
- sign in attempts. Your account will automatically unlock in #{time_ago_in_words(Devise.unlock_in.from_now)}
+ sign in attempts. Your account will automatically unlock in #{distance_of_time_in_words(Devise.unlock_in)}
or you may click the link below to unlock now.
#cta
= link_to('Unlock account', unlock_url(@resource, unlock_token: @token))
diff --git a/app/views/devise/mailer/unlock_instructions.text.erb b/app/views/devise/mailer/unlock_instructions.text.erb
index 3aea3e20145..8d4abbf3500 100644
--- a/app/views/devise/mailer/unlock_instructions.text.erb
+++ b/app/views/devise/mailer/unlock_instructions.text.erb
@@ -1,7 +1,7 @@
Hello, <%= @resource.name %>!
Your GitLab account has been locked due to an excessive amount of unsuccessful
-sign in attempts. Your account will automatically unlock in <%= time_ago_in_words(Devise.unlock_in.from_now) %>
+sign in attempts. Your account will automatically unlock in <%= distance_of_time_in_words(Devise.unlock_in) %>
or you may click the link below to unlock now.
<%= unlock_url(@resource, unlock_token: @token) %>
diff --git a/app/views/devise/shared/_tab_single.html.haml b/app/views/devise/shared/_tab_single.html.haml
index f943d25e41a..7bd414d64c3 100644
--- a/app/views/devise/shared/_tab_single.html.haml
+++ b/app/views/devise/shared/_tab_single.html.haml
@@ -1,3 +1,3 @@
-%ul.nav-links.nav-tabs.new-session-tabs.single-tab
+%ul.nav-links.new-session-tabs.single-tab
%li.active
%a= tab_title
diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml
index 270191f9452..f50e0724e09 100644
--- a/app/views/devise/shared/_tabs_ldap.html.haml
+++ b/app/views/devise/shared/_tabs_ldap.html.haml
@@ -1,4 +1,4 @@
-%ul.new-session-tabs.nav-links.nav-tabs{ class: ('custom-provider-tabs' if form_based_providers.any?) }
+%ul.nav-links.new-session-tabs{ class: ('custom-provider-tabs' if form_based_providers.any?) }
- if crowd_enabled?
%li.active
= link_to "Crowd", "#crowd", 'data-toggle' => 'tab'
diff --git a/app/views/devise/shared/_tabs_normal.html.haml b/app/views/devise/shared/_tabs_normal.html.haml
index 1ba6d390875..fa3c3df7f60 100644
--- a/app/views/devise/shared/_tabs_normal.html.haml
+++ b/app/views/devise/shared/_tabs_normal.html.haml
@@ -1,4 +1,4 @@
-%ul.nav-links.new-session-tabs.nav-tabs{ role: 'tablist' }
+%ul.nav-links.new-session-tabs{ role: 'tablist' }
%li.active{ role: 'presentation' }
%a{ href: '#login-pane', data: { toggle: 'tab' }, role: 'tab' } Sign in
- if allow_signup?
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 8680ec2e298..646e89e9bd1 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -7,7 +7,7 @@
- unless expanded
- diff_data = { lines_path: project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion) }
-.diff-file.file-holder{ class: diff_file_class, data: diff_data }
+.diff-file.file-holder.js-lazy-load-discussion{ class: diff_file_class, data: diff_data }
.js-file-title.file-title.file-title-flex-parent
.file-header-content
= render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false
@@ -28,8 +28,11 @@
%tr.line_holder.line-holder-placeholder
%td.old_line.diff-line-num
%td.new_line.diff-line-num
- %td.line_content
+ %td.line_content.js-success-lazy-load
.js-code-placeholder
+ %td.js-error-lazy-load-diff.hidden.diff-loading-error-block
+ - button = button_tag(_("Try again"), class: "btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button")
+ = _("Unable to load the diff. %{button_try_again}").html_safe % { button_try_again: button}
= render "discussions/diff_discussion", discussions: [discussion], expanded: true
- else
- partial = (diff_file.new_file? || diff_file.deleted_file?) ? 'single_image_diff' : 'replaced_image_diff'
diff --git a/app/views/email_rejection_mailer/rejection.text.haml b/app/views/email_rejection_mailer/rejection.text.haml
index 6693e6f90e8..af518b5b583 100644
--- a/app/views/email_rejection_mailer/rejection.text.haml
+++ b/app/views/email_rejection_mailer/rejection.text.haml
@@ -1,4 +1,3 @@
Unfortunately, your email message to GitLab could not be processed.
-
-
+\
= @reason
diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml
index 2ace1e2dd1e..65e95f3aeef 100644
--- a/app/views/groups/_group_admin_settings.html.haml
+++ b/app/views/groups/_group_admin_settings.html.haml
@@ -1,28 +1,26 @@
-- if current_user.admin?
- .form-group
- = f.label :lfs_enabled, 'Large File Storage', class: 'control-label'
- .col-sm-10
- .checkbox
- = f.label :lfs_enabled do
- = f.check_box :lfs_enabled, checked: @group.lfs_enabled?
- %strong
- Allow projects within this group to use Git LFS
- = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
- %br/
- %span.descr This setting can be overridden in each project.
+.form-group
+ = f.label :lfs_enabled, 'Large File Storage', class: 'control-label'
+ .col-sm-10
+ .checkbox
+ = f.label :lfs_enabled do
+ = f.check_box :lfs_enabled, checked: @group.lfs_enabled?
+ %strong
+ Allow projects within this group to use Git LFS
+ = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
+ %br/
+ %span.descr This setting can be overridden in each project.
-- if can? current_user, :admin_group, @group
- .form-group
- = f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2'
- .col-sm-10
- .checkbox
- = f.label :require_two_factor_authentication do
- = f.check_box :require_two_factor_authentication
- %strong
- Require all users in this group to setup Two-factor authentication
- = link_to icon('question-circle'), help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group')
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.text_field :two_factor_grace_period, class: 'form-control'
- .help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication
+.form-group
+ = f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2'
+ .col-sm-10
+ .checkbox
+ = f.label :require_two_factor_authentication do
+ = f.check_box :require_two_factor_authentication
+ %strong
+ Require all users in this group to setup Two-factor authentication
+ = link_to icon('question-circle'), help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group')
+.form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.text_field :two_factor_grace_period, class: 'form-control'
+ .help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 86cd0759a2c..3375e01b3a1 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -1,4 +1,6 @@
- breadcrumb_title "General Settings"
+- @content_class = "limit-container-width" unless fluid_layout
+
.panel.panel-default.prepend-top-default
.panel-heading
Group settings
diff --git a/app/views/groups/issues.atom.builder b/app/views/groups/issues.atom.builder
index a239ea8caf0..2a385b661e5 100644
--- a/app/views/groups/issues.atom.builder
+++ b/app/views/groups/issues.atom.builder
@@ -1,5 +1,5 @@
xml.title "#{@group.name} issues"
-xml.link href: url_for(params), rel: "self", type: "application/atom+xml"
+xml.link href: url_for(safe_params), rel: "self", type: "application/atom+xml"
xml.link href: issues_group_url, rel: "alternate", type: "text/html"
xml.id issues_group_url
xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any?
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 36df03302e8..bbfbea4ac7a 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -1,6 +1,6 @@
- page_title "Issues"
= content_for :meta_tags do
- = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@group.name} issues")
+ = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
- if group_issues_count(state: 'all').zero?
= render 'shared/empty_states/issues', project_select_button: true
diff --git a/app/views/groups/settings/badges/index.html.haml b/app/views/groups/settings/badges/index.html.haml
new file mode 100644
index 00000000000..c7afb25d0f8
--- /dev/null
+++ b/app/views/groups/settings/badges/index.html.haml
@@ -0,0 +1,4 @@
+- breadcrumb_title _('Project Badges')
+- page_title _('Project Badges')
+
+= render 'shared/badges/badge_settings'
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index eb32f393310..6f53f5ac1ae 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -19,8 +19,8 @@
%li.dropdown-bold-header GitLab
- if @project&.persisted?
- - create_project_issue = can?(current_user, :create_issue, @project)
- - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
+ - create_project_issue = show_new_issue_link?(@project)
+ - merge_project = merge_request_source_project_for_project(@project)
- create_project_snippet = can?(current_user, :create_project_snippet, @project)
- if create_project_issue || merge_project || create_project_snippet
%li.dropdown-bold-header This project
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
index 198f30a1dc4..8e20c4a4b2a 100644
--- a/app/views/layouts/mailer.text.erb
+++ b/app/views/layouts/mailer.text.erb
@@ -1,4 +1,4 @@
<%= yield -%>
----
+-- <%# signature marker %>
You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>.
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 5ea19c9882d..517d9aa3d99 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -112,7 +112,7 @@
%span.nav-item-name
Settings
%ul.sidebar-sub-level-items
- = nav_link(path: %w[groups#projects groups#edit ci_cd#show], html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(path: %w[groups#projects groups#edit badges#index ci_cd#show], html_options: { class: "fly-out-top-item" } ) do
= link_to edit_group_path(@group) do
%strong.fly-out-top-item-name
#{ _('Settings') }
@@ -122,6 +122,12 @@
%span
General
+ = nav_link(controller: :badges) do
+ = link_to group_settings_badges_path(@group), title: _('Project Badges') do
+ %span
+ = _('Project Badges')
+
+
= nav_link(path: 'groups#projects') do
= link_to projects_group_path(@group), title: 'Projects' do
%span
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index c878fcf2808..6cbd163dd41 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -129,6 +129,17 @@
= link_to profile_preferences_path do
%strong.fly-out-top-item-name
#{ _('Preferences') }
+ = nav_link(controller: :active_sessions) do
+ = link_to profile_active_sessions_path do
+ .nav-icon-container
+ = sprite_icon('monitor-lines')
+ %span.nav-item-name
+ Active Sessions
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :active_sessions, html_options: { class: "fly-out-top-item" } ) do
+ = link_to profile_active_sessions_path do
+ %strong.fly-out-top-item-name
+ #{ _('Active Sessions') }
= nav_link(path: 'profiles#audit_log') do
= link_to audit_log_profile_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 5c90d13420f..196db08cebd 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -13,7 +13,7 @@
.nav-icon-container
= sprite_icon('project')
%span.nav-item-name
- Overview
+ Project
%ul.sidebar-sub-level-items
= nav_link(path: 'projects#show', html_options: { class: "fly-out-top-item" } ) do
@@ -258,7 +258,7 @@
#{ _('Snippets') }
- if project_nav_tab? :settings
- = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show]) do
+ = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show]) do
= link_to edit_project_path(@project), class: 'shortcuts-tree' do
.nav-icon-container
= sprite_icon('settings')
@@ -268,7 +268,7 @@
%ul.sidebar-sub-level-items
- can_edit = can?(current_user, :admin_project, @project)
- if can_edit
- = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show], html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show], html_options: { class: "fly-out-top-item" } ) do
= link_to edit_project_path(@project) do
%strong.fly-out-top-item-name
#{ _('Settings') }
@@ -282,6 +282,11 @@
%span
Members
- if can_edit
+ = nav_link(controller: :badges) do
+ = link_to project_settings_badges_path(@project), title: _('Badges') do
+ %span
+ = _('Badges')
+ - if can_edit
= nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do
= link_to project_settings_integrations_path(@project), title: 'Integrations' do
%span
diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb
index de48f548a1b..9dc490efa9a 100644
--- a/app/views/layouts/notify.text.erb
+++ b/app/views/layouts/notify.text.erb
@@ -1,6 +1,6 @@
<%= yield -%>
----
+-- <%# signature marker %>
<% if @target_url -%>
<% if @reply_by_email -%>
<%= "Reply to this email directly or view it on GitLab: #{@target_url}" -%>
diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml
new file mode 100644
index 00000000000..e81144b8fcb
--- /dev/null
+++ b/app/views/notify/issue_due_email.html.haml
@@ -0,0 +1,12 @@
+%p.details
+ #{link_to @issue.author_name, user_url(@issue.author)}'s issue is due soon.
+
+- if @issue.assignees.any?
+ %p
+ Assignee: #{@issue.assignee_list}
+%p
+ This issue is due on: #{@issue.due_date.to_s(:medium)}
+
+- if @issue.description
+ %div
+ = markdown(@issue.description, pipeline: :email, author: @issue.author)
diff --git a/app/views/notify/issue_due_email.text.erb b/app/views/notify/issue_due_email.text.erb
new file mode 100644
index 00000000000..3c7a57a8a2e
--- /dev/null
+++ b/app/views/notify/issue_due_email.text.erb
@@ -0,0 +1,7 @@
+The following issue is due on <%= @issue.due_date %>:
+
+Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
+Author: <%= @issue.author_name %>
+Assignee: <%= @issue.assignee_list %>
+
+<%= @issue.description %>
diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml
index 4c507c08ed7..67744ec1cee 100644
--- a/app/views/notify/push_to_merge_request_email.html.haml
+++ b/app/views/notify/push_to_merge_request_email.html.haml
@@ -7,7 +7,7 @@
- count = @existing_commits.size
%ul
%li
- - if count.one?
+ - if count == 1
- commit_id = @existing_commits.first[:short_id]
= link_to(commit_id, project_commit_url(@merge_request.target_project, commit_id))
- else
diff --git a/app/views/notify/push_to_merge_request_email.text.haml b/app/views/notify/push_to_merge_request_email.text.haml
index 553f771f1a6..95759d127e2 100644
--- a/app/views/notify/push_to_merge_request_email.text.haml
+++ b/app/views/notify/push_to_merge_request_email.text.haml
@@ -4,7 +4,7 @@
\
- if @existing_commits.any?
- count = @existing_commits.size
- - commits_id = count.one? ? @existing_commits.first[:short_id] : "#{@existing_commits.first[:short_id]}...#{@existing_commits.last[:short_id]}"
+ - commits_id = count == 1 ? @existing_commits.first[:short_id] : "#{@existing_commits.first[:short_id]}...#{@existing_commits.last[:short_id]}"
- commits_text = "#{count} commit".pluralize(count)
* #{commits_id} - #{commits_text} from branch `#{@merge_request.target_branch}`
diff --git a/app/views/peek/_bar.html.haml b/app/views/peek/_bar.html.haml
index b4d86e1601c..cb0cccb8f8a 100644
--- a/app/views/peek/_bar.html.haml
+++ b/app/views/peek/_bar.html.haml
@@ -3,10 +3,5 @@
#js-peek{ data: { env: Peek.env,
request_id: Peek.request_id,
peek_url: peek_routes.results_url,
- profile_url: url_for(params.merge(lineprofiler: 'true')) },
+ profile_url: url_for(safe_params.merge(lineprofiler: 'true')) },
class: Peek.env }
-
-#peek-view-performance-bar.hidden
- = render_server_response_time
- %span#serverstats
- %ul.performance-bar
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index 02263095599..9c95b6281ba 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -57,20 +57,8 @@
= succeed '.' do
= link_to 'Learn more', help_page_path('user/profile/index', anchor: 'changing-your-username'), target: '_blank'
.col-lg-8
- = form_for @user, url: update_username_profile_path, method: :put, html: {class: "update-username"} do |f|
- .form-group
- = f.label :username, "Path", class: "label-light"
- .input-group
- .input-group-addon
- = root_url
- = f.text_field :username, required: true, class: 'form-control'
- .help-block
- Current path:
- #{root_url}#{current_user.username}
- .prepend-top-default
- = f.button class: "btn btn-warning", type: "submit" do
- = icon "spinner spin", class: "hidden loading-username"
- Update username
+ - data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) }
+ #update-username{ data: data }
%hr
.row.prepend-top-default
diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml
new file mode 100644
index 00000000000..d40b771f48b
--- /dev/null
+++ b/app/views/profiles/active_sessions/_active_session.html.haml
@@ -0,0 +1,31 @@
+- is_current_session = active_session.current?(session)
+
+%li
+ .pull-left.append-right-10{ data: { toggle: 'tooltip' }, title: active_session.human_device_type }
+ = active_session_device_type_icon(active_session)
+
+ .description.pull-left
+ %div
+ %strong= active_session.ip_address
+ - if is_current_session
+ %div This is your current session
+ - else
+ %div
+ Last accessed on
+ = l(active_session.updated_at, format: :short)
+
+ %div
+ %strong= active_session.browser
+ on
+ %strong= active_session.os
+
+ %div
+ %strong Signed in
+ on
+ = l(active_session.created_at, format: :short)
+
+ - unless is_current_session
+ .pull-right
+ = link_to profile_active_session_path(active_session.session_id), data: { confirm: 'Are you sure? The device will be signed out of GitLab.' }, method: :delete, class: "btn btn-danger prepend-left-10" do
+ %span.sr-only Revoke
+ Revoke
diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml
new file mode 100644
index 00000000000..d0250bb4eab
--- /dev/null
+++ b/app/views/profiles/active_sessions/index.html.haml
@@ -0,0 +1,14 @@
+- page_title 'Active Sessions'
+- @content_class = "limit-container-width" unless fluid_layout
+
+.row.prepend-top-default
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0
+ = page_title
+ %p
+ This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.
+ .col-lg-8
+ .append-bottom-default
+
+ %ul.well-list
+ = render partial: 'profiles/active_sessions/active_session', collection: @sessions
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 78848542810..9b87a7aaca8 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
-
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
@@ -19,8 +18,10 @@
%h5.prepend-top-0
Your New Personal Access Token
.form-group
- = 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")
+ .input-group
+ = 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"
+ %span.input-group-btn
+ = clipboard_button(text: @new_personal_access_token, title: "Copy personal access token to clipboard", placement: "left", class: "btn-default btn-clipboard")
%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
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 1bd10018b40..d1eae05c46c 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -20,7 +20,7 @@
- else
%p
Download the Google Authenticator application from App Store or Google Play Store and scan this code.
- More information is available in the #{link_to('documentation', help_page_path('profile/two_factor_authentication'))}.
+ More information is available in the #{link_to('documentation', help_page_path('user/profile/account/two_factor_authentication'))}.
.row.append-bottom-10
.col-md-4
= raw @qr_code
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
index 825bfd0707f..1e7d9444986 100644
--- a/app/views/projects/_export.html.haml
+++ b/app/views/projects/_export.html.haml
@@ -21,11 +21,11 @@
%li Project uploads
%li Project configuration including web hooks and services
%li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities
+ %li LFS objects
%p
The following items will NOT be exported:
%ul
%li Job traces and artifacts
- %li LFS objects
%li Container registry images
%li CI variables
%li Any encrypted tokens
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index a2ecfddb163..043057e79ee 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -23,11 +23,14 @@
- deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)')
= deleted_message % { project_name: fork_source_name(@project) }
- .project-badges
+ .project-badges.prepend-top-default.append-bottom-default
- @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 }
+ %a.append-right-8{ href: badge.rendered_link_url(@project),
+ target: '_blank',
+ rel: 'noopener noreferrer' }>
+ %img.project-badge{ src: badge.rendered_image_url(@project),
+ 'aria-hidden': true,
+ alt: '' }>
.project-repo-buttons
.count-buttons
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
new file mode 100644
index 00000000000..4bee6cb97eb
--- /dev/null
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -0,0 +1,51 @@
+- active_tab = local_assigns.fetch(:active_tab, 'blank')
+- f = local_assigns.fetch(:f)
+
+.project-import.row
+ .col-lg-12
+ .form-group.import-btn-container.clearfix
+ = f.label :visibility_level, class: 'label-light' do #the label here seems wrong
+ Import project from
+ .import-buttons
+ - if gitlab_project_import_enabled?
+ .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
+ = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
+ = icon('gitlab', text: 'GitLab export')
+ %div
+ - if github_import_enabled?
+ = link_to new_import_github_path, class: 'btn js-import-github' do
+ = icon('github', text: 'GitHub')
+ %div
+ - if bitbucket_import_enabled?
+ = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
+ = icon('bitbucket', text: 'Bitbucket')
+ - unless bitbucket_import_configured?
+ = render 'bitbucket_import_modal'
+ %div
+ - if gitlab_import_enabled?
+ = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
+ = icon('gitlab', text: 'GitLab.com')
+ - unless gitlab_import_configured?
+ = render 'gitlab_import_modal'
+ %div
+ - if google_code_import_enabled?
+ = link_to new_import_google_code_path, class: 'btn import_google_code' do
+ = icon('google', text: 'Google Code')
+ %div
+ - if fogbugz_import_enabled?
+ = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do
+ = icon('bug', text: 'Fogbugz')
+ %div
+ - if gitea_import_enabled?
+ = link_to new_import_gitea_path, class: 'btn import_gitea' do
+ = custom_icon('go_logo')
+ Gitea
+ %div
+ - if git_import_enabled?
+ %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' } }
+ = icon('git', text: 'Repo by URL')
+ .col-lg-12
+ .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
+ %hr
+ = render "shared/import_form", f: f
+ = render 'new_project_fields', f: f, project_name_id: "import-url-name"
diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index 6a1035d2dc7..f6d396c8127 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -13,6 +13,7 @@
#{time_ago_with_tooltip(event.created_at)}
- .flex-right
- = link_to new_mr_path_from_push_event(event), title: _("New merge request"), class: "btn btn-info btn-sm qa-create-merge-request" do
- #{ _('Create merge request') }
+ - if can?(current_user, :create_merge_request_in, event.project.default_merge_request_target)
+ .flex-right
+ = link_to new_mr_path_from_push_event(event), title: _("New merge request"), class: "btn btn-info btn-sm qa-create-merge-request" do
+ #{ _('Create merge request') }
diff --git a/app/views/projects/_visibility_select.html.haml b/app/views/projects/_visibility_select.html.haml
deleted file mode 100644
index 4026b9e3c46..00000000000
--- a/app/views/projects/_visibility_select.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- if can_change_visibility_level?(@project, current_user)
- .select-wrapper
- = form.select(model_method, visibility_select_options(@project, selected_level), {}, class: 'form-control visibility-select select-control')
- = icon('chevron-down')
-- else
- .info.js-locked{ data: { help_block: visibility_level_description(@project.visibility_level, @project) } }
- = visibility_level_icon(@project.visibility_level)
- %strong
- = visibility_level_label(@project.visibility_level)
diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml
index 3124443b4e4..b9663bbba15 100644
--- a/app/views/projects/blob/_viewer.html.haml
+++ b/app/views/projects/blob/_viewer.html.haml
@@ -2,13 +2,16 @@
- 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?)
+- external_embed = local_assigns.fetch(:external_embed, false)
-- viewer_url = local_assigns.fetch(:viewer_url) { url_for(params.merge(viewer: viewer.type, format: :json)) } if load_async
+- viewer_url = local_assigns.fetch(:viewer_url) { url_for(safe_params.merge(viewer: viewer.type, format: :json)) } if load_async
.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
= render viewer.loading_partial_path, viewer: viewer
+ - elsif external_embed
+ = render 'projects/blob/viewers/highlight_embed', blob: viewer.blob
- else
- viewer.prepare!
diff --git a/app/views/projects/blob/viewers/_highlight_embed.html.haml b/app/views/projects/blob/viewers/_highlight_embed.html.haml
new file mode 100644
index 00000000000..9bd4ef6ad0b
--- /dev/null
+++ b/app/views/projects/blob/viewers/_highlight_embed.html.haml
@@ -0,0 +1,7 @@
+.file-content.code.js-syntax-highlight
+ .line-numbers
+ - if blob.data.present?
+ - blob.data.each_line.each_with_index do |_, index|
+ %span.diff-line-num= index + 1
+ .blob-content{ data: { blob_id: blob.id } }
+ = highlight(blob.path, blob.data, repository: nil, plain: blob.no_highlighting?)
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 883dfb3e6c8..0e012b5a216 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -4,22 +4,21 @@
- diverging_commit_counts = @repository.diverging_commit_counts(branch)
- number_commits_behind = diverging_commit_counts[:behind]
- number_commits_ahead = diverging_commit_counts[:ahead]
-- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
+- merge_project = merge_request_source_project_for_project(@project)
%li{ class: "branch-item js-branch-#{branch.name}" }
.branch-info
.branch-title
- = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name' do
- = sprite_icon('fork', size: 12)
+ = sprite_icon('fork', size: 12)
+ = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name prepend-left-8' do
= branch.name
- &nbsp;
- if branch.name == @repository.root_ref
- %span.label.label-primary default
+ %span.label.label-primary.prepend-left-5 default
- elsif merged
- %span.label.label-info.has-tooltip{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } }
+ %span.label.label-info.has-tooltip.prepend-left-5{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } }
= s_('Branches|merged')
- if protected_branch?(@project, branch)
- %span.label.label-success
+ %span.label.label-success.prepend-left-5
= s_('Branches|protected')
.block-truncated
@@ -29,7 +28,7 @@
= s_('Branches|Cant find HEAD commit for this branch')
- if branch.name != @repository.root_ref
- .divergence-graph{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind),
+ .divergence-graph.hidden-xs{ 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
@@ -61,7 +60,7 @@
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)
+ - if can?(current_user, :push_to_delete_protected_branch, @project)
%button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip",
title: s_('Branches|Delete protected branch'),
data: { toggle: "modal",
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index fa9a9bfc8f7..f49f6e630d2 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -1,6 +1,7 @@
- pipeline = local_assigns.fetch(:pipeline) { project.latest_successful_pipeline_for(ref) }
- if !project.empty_repo? && can?(current_user, :download_code, project)
+ - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}"
.project-action-button.dropdown.inline>
%button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download') }
= sprite_icon('download')
@@ -10,16 +11,16 @@
%li.dropdown-header
#{ _('Source code') }
%li
- = link_to archive_project_repository_path(project, ref: ref, format: 'zip'), rel: 'nofollow', download: '' do
+ = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'zip'), rel: 'nofollow', download: '' do
%span= _('Download zip')
%li
- = link_to archive_project_repository_path(project, ref: ref, format: 'tar.gz'), rel: 'nofollow', download: '' do
+ = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.gz'), rel: 'nofollow', download: '' do
%span= _('Download tar.gz')
%li
- = link_to archive_project_repository_path(project, ref: ref, format: 'tar.bz2'), rel: 'nofollow', download: '' do
+ = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.bz2'), rel: 'nofollow', download: '' do
%span= _('Download tar.bz2')
%li
- = link_to archive_project_repository_path(project, ref: ref, format: 'tar'), rel: 'nofollow', download: '' do
+ = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar'), rel: 'nofollow', download: '' do
%span= _('Download tar')
- if pipeline && pipeline.latest_builds_with_artifacts.any?
diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml
index 18e948ce35a..2e86a7d36d7 100644
--- a/app/views/projects/buttons/_dropdown.html.haml
+++ b/app/views/projects/buttons/_dropdown.html.haml
@@ -1,13 +1,17 @@
-- if current_user
+- can_create_issue = show_new_issue_link?(@project)
+- can_create_project_snippet = can?(current_user, :create_project_snippet, @project)
+- can_push_code = can?(current_user, :push_code, @project)
+- create_mr_from_new_fork = can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
+- merge_project = merge_request_source_project_for_project(@project)
+
+- show_menu = can_create_issue || can_create_project_snippet || can_push_code || create_mr_from_new_fork || merge_project
+
+- if show_menu
.project-action-button.dropdown.inline
%a.btn.dropdown-toggle.has-tooltip{ href: '#', title: _('Create new...'), 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => _('Create new...') }
= icon('plus')
= icon("caret-down")
%ul.dropdown-menu.dropdown-menu-align-right.project-home-dropdown
- - 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_project_snippet = can?(current_user, :create_project_snippet, @project)
-
- if can_create_issue || merge_project || can_create_project_snippet
%li.dropdown-header= _('This project')
@@ -20,17 +24,17 @@
- if can_create_project_snippet
%li= link_to _('New snippet'), new_project_snippet_path(@project)
- - if can?(current_user, :push_code, @project)
+ - if can_push_code
%li.dropdown-header= _('This repository')
- - if can?(current_user, :push_code, @project)
+ - if can_push_code
%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)
+ - elsif can_collaborate_with_project?(@project)
%li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master')
- - elsif can?(current_user, :fork_project, @project)
+ - elsif create_mr_from_new_fork
- 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 }
diff --git a/app/views/projects/clusters/_empty_state.html.haml b/app/views/projects/clusters/_empty_state.html.haml
index 112dde66ff7..5f49d03b1bb 100644
--- a/app/views/projects/clusters/_empty_state.html.haml
+++ b/app/views/projects/clusters/_empty_state.html.haml
@@ -7,5 +7,6 @@
- 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'
+ - if can?(current_user, :create_cluster, @project)
+ .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/new.html.haml b/app/views/projects/clusters/new.html.haml
index ebb7d247125..e004966bdcc 100644
--- a/app/views/projects/clusters/new.html.haml
+++ b/app/views/projects/clusters/new.html.haml
@@ -8,6 +8,6 @@
%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'
+ = link_to s_('ClusterIntegration|Create on Google Kubernetes Engine'), 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/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 74c5317428c..213c4c90a0e 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -1,3 +1,5 @@
+- can_collaborate = can_collaborate_with_project?(@project)
+
.page-content-header.js-commit-box{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) }
.header-main-content
= render partial: 'signature', object: @commit.signature
@@ -32,12 +34,13 @@
%li.visible-xs-block.visible-sm-block
= link_to project_tree_path(@project, @commit) do
#{ _('Browse Files') }
- - unless @commit.has_been_reverted?(current_user)
+ - if can_collaborate && !@commit.has_been_reverted?(current_user)
%li.clearfix
= revert_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false)
- %li.clearfix
- = cherry_pick_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false)
- - if can_collaborate_with_project?
+ - if can_collaborate
+ %li.clearfix
+ = cherry_pick_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false)
+ - if can?(current_user, :push_code, @project)
%li.clearfix
= link_to s_("CreateTag|Tag"), new_project_tag_path(@project, ref: @commit)
%li.divider
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index abb292f8f27..541ae905246 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -17,6 +17,6 @@
.limited-width-notes
= render "shared/notes/notes_with_form", :autocomplete => true
- - if can_collaborate_with_project?
+ - if can_collaborate_with_project?(@project)
- %w(revert cherry-pick).each do |type|
= render "projects/commit/change", type: type, commit: @commit, title: @commit.title
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 078bd0eee63..3fd0fa348b3 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -5,6 +5,7 @@
- link = commit_path(project, commit, merge_request: merge_request)
- cache_key = [project.full_path,
+ ref,
commit.id,
Gitlab::CurrentSettings.current_application_settings,
@path.presence,
@@ -21,8 +22,11 @@
= author_avatar(commit, size: 36)
.commit-detail.flex-list
- .commit-content
- = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title")
+ .commit-content.qa-commit-content
+ - if view_details && merge_request
+ = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title"
+ - else
+ = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title")
%span.commit-row-message.visible-xs-inline
&middot;
= commit.short_id
@@ -51,10 +55,10 @@
- if commit.status(ref)
= render_commit_status(commit, ref: ref)
- .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)
+ .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } }
- - 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"
+ .commit-sha-group
+ .label.label-monospace
+ = commit.short_id
+ = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"), class: "btn btn-default", container: "body")
+ = link_to_browse_code(project, commit)
diff --git a/app/views/projects/deploy_tokens/_form.html.haml b/app/views/projects/deploy_tokens/_form.html.haml
new file mode 100644
index 00000000000..f8db30df7b4
--- /dev/null
+++ b/app/views/projects/deploy_tokens/_form.html.haml
@@ -0,0 +1,29 @@
+%p.profile-settings-content
+ = s_("DeployTokens|Pick a name for the application, and we'll give you a unique deploy token.")
+
+= form_for token, url: create_deploy_token_namespace_project_settings_repository_path(project.namespace, project), method: :post do |f|
+ = form_errors(token)
+
+ .form-group
+ = f.label :name, class: 'label-light'
+ = f.text_field :name, class: 'form-control', required: true
+
+ .form-group
+ = f.label :expires_at, class: 'label-light'
+ = f.text_field :expires_at, class: 'datepicker form-control', value: f.object.expires_at
+
+ .form-group
+ = f.label :scopes, class: 'label-light'
+ %fieldset
+ = f.check_box :read_repository
+ = label_tag ("deploy_token_read_repository"), 'read_repository'
+ %span= s_('DeployTokens|Allows read-only access to the repository')
+
+ - if container_registry_enabled?(project)
+ %fieldset
+ = f.check_box :read_registry
+ = label_tag ("deploy_token_read_registry"), 'read_registry'
+ %span= s_('DeployTokens|Allows read-only access to the registry images')
+
+ .prepend-top-default
+ = f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success'
diff --git a/app/views/projects/deploy_tokens/_index.html.haml b/app/views/projects/deploy_tokens/_index.html.haml
new file mode 100644
index 00000000000..50e5950ced4
--- /dev/null
+++ b/app/views/projects/deploy_tokens/_index.html.haml
@@ -0,0 +1,18 @@
+- expanded = expand_deploy_tokens_section?(@new_deploy_token)
+
+%section.settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4= s_('DeployTokens|Deploy Tokens')
+ %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = s_('DeployTokens|Deploy tokens allow read-only access to your repository and registry images.')
+ .settings-content
+ - if @new_deploy_token.persisted?
+ = render 'projects/deploy_tokens/new_deploy_token', deploy_token: @new_deploy_token
+ - else
+ %h5.prepend-top-0
+ = s_('DeployTokens|Add a deploy token')
+ = render 'projects/deploy_tokens/form', project: @project, token: @new_deploy_token, presenter: @deploy_tokens
+ %hr
+ = render 'projects/deploy_tokens/table', project: @project, active_tokens: @deploy_tokens
diff --git a/app/views/projects/deploy_tokens/_new_deploy_token.html.haml b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml
new file mode 100644
index 00000000000..1e715681e59
--- /dev/null
+++ b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml
@@ -0,0 +1,14 @@
+.created-deploy-token-container
+ %h5.prepend-top-0
+ = s_('DeployTokens|Your New Deploy Token')
+
+ .form-group
+ = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus'
+ = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username to clipboard'), placement: 'left')
+ %span.deploy-token-help-block.prepend-top-5.text-success= s_("DeployTokens|Use this username as a login.")
+
+ .form-group
+ = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus'
+ = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token to clipboard'), placement: 'left')
+ %span.deploy-token-help-block.prepend-top-5.text-danger= s_("DeployTokens|Use this token as a password. Make sure you save it - you won't be able to access it again.")
+%hr
diff --git a/app/views/projects/deploy_tokens/_revoke_modal.html.haml b/app/views/projects/deploy_tokens/_revoke_modal.html.haml
new file mode 100644
index 00000000000..085964fe22e
--- /dev/null
+++ b/app/views/projects/deploy_tokens/_revoke_modal.html.haml
@@ -0,0 +1,17 @@
+.modal{ id: "revoke-modal-#{token.id}" }
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %h4.modal-title.pull-left
+ = s_('DeployTokens|Revoke')
+ %b #{token.name}?
+ %button.close{ 'aria-label' => _('Close'), 'data-dismiss' => 'modal', type: 'button' }
+ %span{ 'aria-hidden' => 'true' } &times;
+ .modal-body
+ %p
+ = s_('DeployTokens|You are about to revoke')
+ %b #{token.name}.
+ = s_('DeployTokens|This action cannot be undone.')
+ .modal-footer
+ %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' }= _('Cancel')
+ = link_to s_('DeployTokens|Revoke %{name}') % { name: token.name }, revoke_project_deploy_token_path(project, token), method: :put, class: 'btn btn-danger'
diff --git a/app/views/projects/deploy_tokens/_table.html.haml b/app/views/projects/deploy_tokens/_table.html.haml
new file mode 100644
index 00000000000..5013a9b250d
--- /dev/null
+++ b/app/views/projects/deploy_tokens/_table.html.haml
@@ -0,0 +1,31 @@
+%h5= s_("DeployTokens|Active Deploy Tokens (%{active_tokens})") % { active_tokens: active_tokens.length }
+
+- if active_tokens.present?
+ .table-responsive.deploy-tokens
+ %table.table
+ %thead
+ %tr
+ %th= s_('DeployTokens|Name')
+ %th= s_('DeployTokens|Username')
+ %th= s_('DeployTokens|Created')
+ %th= s_('DeployTokens|Expires')
+ %th= s_('DeployTokens|Scopes')
+ %th
+ %tbody
+ - active_tokens.each do |token|
+ %tr
+ %td= token.name
+ %td= token.username
+ %td= token.created_at.to_date.to_s(:medium)
+ %td
+ - if token.expires?
+ %span{ class: ('text-warning' if token.expires_soon?) }
+ In #{distance_of_time_in_words_to_now(token.expires_at)}
+ - else
+ %span.token-never-expires-label Never
+ %td= token.scopes.present? ? token.scopes.join(", ") : "<no scopes selected>"
+ %td= link_to s_('DeployTokens|Revoke'), "#", class: "btn btn-danger pull-right", data: { toggle: "modal", target: "#revoke-modal-#{token.id}"}
+ = render 'projects/deploy_tokens/revoke_modal', token: token, project: project
+- else
+ .settings-message.text-center
+ = s_('DeployTokens|This project has no active Deploy Tokens.')
diff --git a/app/views/projects/diffs/_collapsed.html.haml b/app/views/projects/diffs/_collapsed.html.haml
index 8772bd4705f..5762f4d86d7 100644
--- a/app/views/projects/diffs/_collapsed.html.haml
+++ b/app/views/projects/diffs/_collapsed.html.haml
@@ -1,5 +1,5 @@
- diff_file = viewer.diff_file
-- url = url_for(params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path, file_identifier: diff_file.file_identifier))
+- url = url_for(safe_params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path, file_identifier: diff_file.file_identifier))
.nothing-here-block.diff-collapsed{ data: { diff_for_path: url } }
This diff is collapsed.
%a.click-to-expand Click to expand it.
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index 376f672f424..9f420ee86f7 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -8,7 +8,7 @@
.files-changed-inner
.inline-parallel-buttons.hidden-xs.hidden-sm
- if !diffs_expanded? && diff_files.any? { |diff_file| diff_file.collapsed? }
- = link_to 'Expand all', url_for(params.merge(expanded: 1, format: nil)), class: 'btn btn-default'
+ = link_to 'Expand all', url_for(safe_params.merge(expanded: 1, format: nil)), class: 'btn btn-default'
- if show_whitespace_toggle
- if current_controller?(:commit)
= commit_diff_whitespace_link(diffs.project, @commit, class: 'hidden-xs')
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 99eeb9551e3..0994498c6be 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -114,17 +114,18 @@
Archive project
- if @project.archived?
%p
- Unarchiving the project will mark its repository as active. The project can be committed to.
+ Unarchiving the project will restore people's ability to make changes to it.
+ The repository can be committed to, and issues, comments and other entities can be created.
%strong Once active this project shows up in the search and on the dashboard.
= link_to 'Unarchive project', unarchive_project_path(@project),
- data: { confirm: "Are you sure that you want to unarchive this project?\nWhen this project is unarchived it is active and can be committed to again." },
+ data: { confirm: "Are you sure that you want to unarchive this project?" },
method: :post, class: "btn btn-success"
- else
%p
- Archiving the project will mark its repository as read-only. It is hidden from the dashboard and doesn't show up in searches.
- %strong Archived projects cannot be committed to!
+ Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches.
+ %strong The repository cannot be committed to, and no issues, comments or other entities can be created.
= link_to 'Archive project', archive_project_path(@project),
- data: { confirm: "Are you sure that you want to archive this project?\nAn archived project cannot be committed to." },
+ data: { confirm: "Are you sure that you want to archive this project?" },
method: :post, class: "btn btn-warning"
.sub-section.rename-respository
%h4.warning-title
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index b15fe514a08..a066f9f4cca 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -22,7 +22,7 @@
%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_auto_devops_settings = link_to(s_('AutoDevOps|enable Auto DevOps (Beta)'), project_settings_ci_cd_path(@project, anchor: 'autodevops-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 }
@@ -58,7 +58,9 @@
touch README.md
git add README.md
git commit -m "add README"
- git push -u origin master
+ - if @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin master
%fieldset
%h5 Existing folder
@@ -69,7 +71,9 @@
git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'clone')}
git add .
git commit -m "Initial commit"
- git push -u origin master
+ - if @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin master
%fieldset
%h5 Existing Git repository
@@ -78,8 +82,10 @@
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 @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin --all
+ git push -u origin --tags
- if can? current_user, :remove_project, @project
.prepend-top-20
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index 475c6ba4d3d..a603b1024eb 100644..100755
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -12,7 +12,7 @@
- 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
+ = _("Select a namespace to fork the project")
- @namespaces.each do |namespace|
= render 'fork_button', namespace: namespace
- else
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index cdfc3e232c5..816f2fa816d 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -8,4 +8,5 @@
%section.js-vue-notes-event
#js-vue-notes{ data: { notes_data: notes_data(@issue),
noteable_data: serialize_issuable(@issue),
+ noteable_type: 'issue',
current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } }
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 0c58dd60e2c..e27f5658e87 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -26,7 +26,7 @@
- if issue.milestone
%span.issuable-milestone.hidden-xs
&nbsp;
- = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(issue) } do
+ = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 1, toggle: 'tooltip', title: milestone_tooltip_due_date(issue.milestone) } do
= icon('clock-o')
= issue.milestone.title
- if issue.due_date
diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml
index 0d39edb7bfd..297b928f020 100644
--- a/app/views/projects/issues/_nav_btns.html.haml
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -1,10 +1,11 @@
-= link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10 has-tooltip', title: 'Subscribe' do
+= link_to safe_params.merge(rss_url_options), class: 'btn btn-default append-right-10 has-tooltip', title: 'Subscribe' do
= icon('rss')
- if @can_bulk_update
= button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
-= link_to "New issue", new_project_issue_path(@project,
- issue: { assignee_id: finder.assignee.try(:id),
- milestone_id: finder.milestones.first.try(:id) }),
- class: "btn btn-new",
- title: "New issue",
- id: "new_issue_link"
+- if show_new_issue_link?(@project)
+ = link_to "New issue", new_project_issue_path(@project,
+ issue: { assignee_id: finder.assignee.try(:id),
+ milestone_id: finder.milestones.first.try(:id) }),
+ class: "btn btn-new",
+ title: "New issue",
+ id: "new_issue_link"
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 36e24037214..4b8bf578b28 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -1,8 +1,8 @@
-- 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)
+ - can_create_merge_request = can?(current_user, :create_merge_request_in, @project)
+ - data_action = can_create_merge_request ? 'create-mr' : 'create-branch'
+ - value = can_create_merge_request ? 'Create merge request' : 'Create branch'
+
- 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)
diff --git a/app/views/projects/issues/index.atom.builder b/app/views/projects/issues/index.atom.builder
index 4029926f373..6330245954e 100644
--- a/app/views/projects/issues/index.atom.builder
+++ b/app/views/projects/issues/index.atom.builder
@@ -1,5 +1,5 @@
xml.title "#{@project.name} issues"
-xml.link href: url_for(params), rel: "self", type: "application/atom+xml"
+xml.link href: url_for(safe_params), rel: "self", type: "application/atom+xml"
xml.link href: project_issues_url(@project), rel: "alternate", type: "text/html"
xml.id project_issues_url(@project)
xml.updated @issues.first.updated_at.xmlschema if @issues.reorder(nil).any?
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index c427a9eedc2..1e7737aeb97 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -5,7 +5,7 @@
- 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")
+ = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues")
- if project_issues(@project).exists?
%div{ class: (container_class) }
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index ec7e87219f5..f1fc1c2316d 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -7,6 +7,7 @@
- can_update_issue = can?(current_user, :update_issue, @issue)
- can_report_spam = @issue.submittable_as_spam_by?(current_user)
+- can_create_issue = show_new_issue_link?(@project)
.detail-page-header
.detail-page-header-body
@@ -42,16 +43,18 @@
%li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- if can_report_spam
%li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
- - if can_update_issue || can_report_spam
- %li.divider
- %li= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link'
+ - if can_create_issue
+ - if can_update_issue || can_report_spam
+ %li.divider
+ %li= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link'
= render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue
- if can_report_spam
= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
- = link_to new_project_issue_path(@project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do
- New issue
+ - if can_create_issue
+ = link_to new_project_issue_path(@project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do
+ New issue
.issue-details.issuable-details
.detail-page-description.content-block
diff --git a/app/views/projects/jobs/_empty_state.html.haml b/app/views/projects/jobs/_empty_state.html.haml
index c66313bdbf3..311934d9c33 100644
--- a/app/views/projects/jobs/_empty_state.html.haml
+++ b/app/views/projects/jobs/_empty_state.html.haml
@@ -1,7 +1,7 @@
- illustration = local_assigns.fetch(:illustration)
- illustration_size = local_assigns.fetch(:illustration_size)
- title = local_assigns.fetch(:title)
-- content = local_assigns.fetch(:content)
+- content = local_assigns.fetch(:content, nil)
- action = local_assigns.fetch(:action, nil)
.row.empty-state
@@ -11,7 +11,8 @@
.col-xs-12
.text-content
%h4.text-center= title
- %p= content
+ - if content
+ %p= content
- if action
.text-center
= action
diff --git a/app/views/projects/jobs/_empty_states.html.haml b/app/views/projects/jobs/_empty_states.html.haml
new file mode 100644
index 00000000000..e5198d047df
--- /dev/null
+++ b/app/views/projects/jobs/_empty_states.html.haml
@@ -0,0 +1,9 @@
+- detailed_status = @build.detailed_status(current_user)
+- illustration = detailed_status.illustration
+
+= render 'empty_state',
+ illustration: illustration[:image],
+ illustration_size: illustration[:size],
+ title: illustration[:title],
+ content: illustration[:content],
+ action: detailed_status.has_action? ? link_to(detailed_status.action_button_title, detailed_status.action_path, method: detailed_status.action_method, class: 'btn btn-primary', title: detailed_status.action_button_title) : nil
diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml
index ecf186e3dc8..826404c2008 100644
--- a/app/views/projects/jobs/_sidebar.html.haml
+++ b/app/views/projects/jobs/_sidebar.html.haml
@@ -1,17 +1,8 @@
-- builds = @build.pipeline.builds.to_a
-
%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } }
.sidebar-container
.blocks-container
- .block
- %strong.inline.prepend-top-8
- = @build.name
- - if can?(current_user, :update_build, @build) && @build.retryable?
- = link_to "Retry", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'js-retry-button pull-right btn btn-inverted-secondary btn-retry visible-md-block visible-lg-block', method: :post
- %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-build-toggle{ href: "#", 'aria-label': 'Toggle Sidebar', role: 'button' }
- = icon('angle-double-right')
- #js-details-block-vue
+ #js-details-block-vue{ data: { can_user_retry: can?(current_user, :update_build, @build) && @build.retryable? } }
- if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?)
.block
@@ -24,7 +15,7 @@
- elsif @build.has_expiring_artifacts?
%p.build-detail-row
The artifacts will be removed in
- %span= time_ago_in_words @build.artifacts_expire_at
+ %span= time_ago_with_tooltip @build.artifacts_expire_at
- if @build.artifacts?
.btn-group.btn-group-justified{ role: :group }
@@ -91,7 +82,8 @@
- HasStatus::ORDERED_STATUSES.each do |build_status|
- builds.select{|build| build.status == build_status}.each do |build|
.build-job{ class: sidebar_build_class(build, @build), data: { stage: build.stage } }
- = link_to project_job_path(@project, build) do
+ - tooltip = build.tooltip_message
+ = link_to(project_job_path(@project, build), data: { toggle: 'tooltip', html: true, title: tooltip, container: 'body' }) do
= sprite_icon('arrow-right', size:16, css_class: 'icon-arrow-right')
%span{ class: "ci-status-icon-#{build.status}" }
= ci_icon_for_status(build.status)
@@ -101,5 +93,4 @@
- else
= build.id
- if build.retried?
- %span.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' }
- = sprite_icon('retry', size:16, css_class: 'icon-retry')
+ = sprite_icon('retry', size:16, css_class: 'icon-retry')
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
index 9963cc93633..fe1c338b634 100644
--- a/app/views/projects/jobs/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -15,7 +15,7 @@
- unless @repository.gitlab_ci_yml
= link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
- = link_to ci_lint_path, class: 'btn btn-default' do
+ = link_to project_ci_lint_path(@project), class: 'btn btn-default' do
%span CI lint
.content-list.builds-content-list
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index fa27ded7cc2..cbbcc8f1db5 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -54,7 +54,8 @@
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?
+
+ - if @build.running? || @build.has_trace?
.build-trace-container.prepend-top-default
.top-bar.js-top-bar
.js-truncated-info.truncated-info.hidden-xs.pull-left.hidden<
@@ -88,26 +89,10 @@
%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"
+ = render "empty_states"
+
+ = render "sidebar", builds: @builds
.js-build-options{ data: javascript_build_options }
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index a94267deeb2..027a9ff1416 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -23,7 +23,7 @@
- if merge_request.milestone
%span.issuable-milestone.hidden-xs
&nbsp;
- = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(merge_request) } do
+ = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 1, toggle: 'tooltip', title: milestone_tooltip_due_date(merge_request.milestone) } do
= icon('clock-o')
= merge_request.milestone.title
- if merge_request.target_project.default_branch != merge_request.target_branch
diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml
index 9d5cebdda53..4e10511411f 100644
--- a/app/views/projects/merge_requests/creations/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -3,7 +3,7 @@
= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form form-inline js-requires-input" } do |f|
.hide.alert.alert-danger.mr-compare-errors
- .merge-request-branches.js-merge-request-new-compare.row{ 'data-target-project-url': project_new_merge_request_update_branches_path(@source_project), 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) }
+ .js-merge-request-new-compare.row{ 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) }
.col-md-6
.panel.panel-default.panel-new-merge-request
.panel-heading
@@ -11,7 +11,7 @@
.panel-body.clearfix
.merge-request-select.dropdown
= f.hidden_field :source_project_id
- = dropdown_toggle @merge_request.source_project_path, { toggle: "dropdown", field_name: "#{f.object_name}[source_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-source-project" }
+ = dropdown_toggle @merge_request.source_project_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[source_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-source-project" }
.dropdown-menu.dropdown-menu-selectable.dropdown-source-project
= dropdown_title("Select source project")
= dropdown_filter("Search projects")
@@ -21,14 +21,12 @@
selected: f.object.source_project_id
.merge-request-select.dropdown
= f.hidden_field :source_branch
- = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch git-revision-dropdown-toggle" }
- .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch.git-revision-dropdown
- = dropdown_title("Select source branch")
- = dropdown_filter("Search branches")
- = dropdown_content do
- = render 'projects/merge_requests/dropdowns/branch',
- branches: @merge_request.source_branches,
- selected: f.object.source_branch
+ = dropdown_toggle f.object.source_branch || _("Select source branch"), { toggle: "dropdown", 'field-name': "#{f.object_name}[source_branch]", 'refs-url': refs_project_path(@source_project), selected: f.object.source_branch }, { toggle_class: "js-compare-dropdown js-source-branch git-revision-dropdown-toggle" }
+ .dropdown-menu.dropdown-menu-selectable.js-source-branch-dropdown.git-revision-dropdown
+ = dropdown_title(_("Select source branch"))
+ = dropdown_filter(_("Search branches"))
+ = dropdown_content
+ = dropdown_loading
.panel-footer
.text-center= icon('spinner spin', class: 'js-source-loading')
%ul.list-unstyled.mr_source_commit
@@ -41,7 +39,7 @@
- projects = target_projects(@project)
.merge-request-select.dropdown
= f.hidden_field :target_project_id
- = dropdown_toggle f.object.target_project.full_path, { toggle: "dropdown", field_name: "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" }
+ = dropdown_toggle f.object.target_project.full_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" }
.dropdown-menu.dropdown-menu-selectable.dropdown-target-project
= dropdown_title("Select target project")
= dropdown_filter("Search projects")
@@ -51,14 +49,12 @@
selected: f.object.target_project_id
.merge-request-select.dropdown
= f.hidden_field :target_branch
- = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch git-revision-dropdown-toggle" }
- .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown.git-revision-dropdown
- = dropdown_title("Select target branch")
- = dropdown_filter("Search branches")
- = dropdown_content do
- = render 'projects/merge_requests/dropdowns/branch',
- branches: @merge_request.target_branches,
- selected: f.object.target_branch
+ = dropdown_toggle f.object.target_branch, { toggle: "dropdown", 'field-name': "#{f.object_name}[target_branch]", 'refs-url': refs_project_path(f.object.target_project), selected: f.object.target_branch }, { toggle_class: "js-compare-dropdown js-target-branch git-revision-dropdown-toggle" }
+ .dropdown-menu.dropdown-menu-selectable.js-target-branch-dropdown.git-revision-dropdown
+ = dropdown_title(_("Select target branch"))
+ = dropdown_filter(_("Search branches"))
+ = dropdown_content
+ = dropdown_loading
.panel-footer
.text-center= icon('spinner spin', class: "js-target-loading")
%ul.list-unstyled.mr_target_commit
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 376ac377562..68780cedeb1 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -26,16 +26,16 @@
- else
%ul.merge-request-tabs.nav-links.no-top.no-bottom
%li.commits-tab.active
- = link_to url_for(params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do
+ = link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do
Commits
%span.badge= @commits.size
- if @pipelines.any?
%li.builds-tab
- = link_to url_for(params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do
+ = link_to url_for(safe_params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do
Pipelines
%span.badge= @pipelines.size
%li.diffs-tab
- = link_to url_for(params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do
+ = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do
Changes
%span.badge= @merge_request.diff_size
@@ -46,7 +46,7 @@
-# This tab is always loaded via AJAX
- if @pipelines.any?
#pipelines.pipelines.tab-pane
- = render 'projects/merge_requests/pipelines', endpoint: url_for(params.merge(action: 'pipelines', format: :json)), disable_initialization: true
+ = render 'projects/merge_requests/pipelines', endpoint: url_for(safe_params.merge(action: 'pipelines', format: :json)), disable_initialization: true
.mr-loading-status
= spinner
diff --git a/app/views/projects/merge_requests/dropdowns/_project.html.haml b/app/views/projects/merge_requests/dropdowns/_project.html.haml
index aaf1ab00eeb..b3cf3c1d369 100644
--- a/app/views/projects/merge_requests/dropdowns/_project.html.haml
+++ b/app/views/projects/merge_requests/dropdowns/_project.html.haml
@@ -1,5 +1,5 @@
%ul
- projects.each do |project|
%li
- %a{ href: "#", class: "#{('is-active' if selected == project.id)}", data: { id: project.id } }
+ %a{ href: "#", class: "#{('is-active' if selected == project.id)}", data: { id: project.id, 'refs-url': refs_project_path(project) } }
= project.full_path
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index b2c0d9e1cfa..623380c9c61 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -1,6 +1,6 @@
- @no_container = true
- @can_bulk_update = can?(current_user, :admin_merge_request, @project)
-- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
+- merge_project = merge_request_source_project_for_project(@project)
- new_merge_request_path = project_new_merge_request_path(merge_project) if merge_project
- page_title "Merge Requests"
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 9866cc716ee..15a0e4d7ef5 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -80,6 +80,7 @@
- if has_vue_discussions_cookie?
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request),
noteable_data: serialize_issuable(@merge_request),
+ noteable_type: 'merge_request',
current_user_data: UserSerializer.new.represent(current_user).to_json} }
#commits.commits.tab-pane
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index b66e0559603..5beaa3c6d23 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -57,54 +57,11 @@
.tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' }
= form_for @project, html: { class: 'new_project' } do |f|
- if import_sources_enabled?
- .project-import.row
- .col-lg-12
- .form-group.import-btn-container.clearfix
- = f.label :visibility_level, class: 'label-light' do #the label here seems wrong
- Import project from
- .import-buttons
- - if gitlab_project_import_enabled?
- .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
- = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
- = icon('gitlab', text: 'GitLab export')
- %div
- - if github_import_enabled?
- = link_to new_import_github_path, class: 'btn js-import-github' do
- = icon('github', text: 'GitHub')
- %div
- - if bitbucket_import_enabled?
- = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
- = icon('bitbucket', text: 'Bitbucket')
- - unless bitbucket_import_configured?
- = render 'bitbucket_import_modal'
- %div
- - if gitlab_import_enabled?
- = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
- = icon('gitlab', text: 'GitLab.com')
- - unless gitlab_import_configured?
- = render 'gitlab_import_modal'
- %div
- - if google_code_import_enabled?
- = link_to new_import_google_code_path, class: 'btn import_google_code' do
- = icon('google', text: 'Google Code')
- %div
- - if fogbugz_import_enabled?
- = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do
- = icon('bug', text: 'Fogbugz')
- %div
- - if gitea_import_enabled?
- = link_to new_import_gitea_path, class: 'btn import_gitea' do
- = custom_icon('go_logo')
- Gitea
- %div
- - if git_import_enabled?
- %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' } }
- = icon('git', text: 'Repo by URL')
- .col-lg-12
- .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
- %hr
- = render "shared/import_form", f: f
- = render 'new_project_fields', f: f, project_name_id: "import-url-name"
+ = render 'import_project_pane', f: f, active_tab: active_tab
+ - else
+ .nothing-here-block
+ %h4 No import options available
+ %p Contact an administrator to enable options for importing your project.
.save-project-loader.hide
.center
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index 5ea653ccad5..b4fe1cabdfd 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -36,7 +36,7 @@
%template{ 'v-else' => '' }
= render 'shared/icons/icon_resolve_discussion.svg'
-- if current_user
+- if can?(current_user, :award_emoji, note)
- if note.emoji_awardable?
- user_authored = note.user_authored?(current_user)
.note-actions-item
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index 75df92b05a7..27bbe52a714 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -1,28 +1,29 @@
+- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
+
- if can?(current_user, :update_pages, @project) && @domains.any?
.panel.panel-default
.panel-heading
Domains (#{@domains.count})
- %ul.well-list
- - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
+ %ul.well-list.pages-domain-list{ class: ("has-verification-status" if verification_enabled) }
- @domains.each do |domain|
- %li
- .pull-right
+ %li.pages-domain-list-item.unstyled
+ - if verification_enabled
+ - tooltip, status = domain.unverified? ? [_('Unverified'), 'failed'] : [_('Verified'), 'success']
+ .domain-status.ci-status-icon.has-tooltip{ class: "ci-status-icon-#{status}", title: tooltip }
+ = sprite_icon("status_#{status}", size: 16 )
+ .domain-name
+ = link_to domain.url do
+ = domain.url
+ = icon('external-link')
+ - if domain.subject
+ %p
+ %span.label.label-gray Certificate: #{domain.subject}
+ - if domain.expired?
+ %span.label.label-danger Expired
+ %div
= 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
- - 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)
+ #{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 f17d9d24db6..6adaea799b2 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -1,11 +1,10 @@
- page_title 'Pages'
-%h3.page_title
+%h3.page-title.with-button
Pages
- if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https)
= link_to new_project_pages_domain_path(@project), class: 'btn btn-new pull-right', title: 'New Domain' do
- %i.fa.fa-plus
New Domain
%p.light
diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml
index 5645a4604bf..6c404990492 100644
--- a/app/views/projects/pages_domains/edit.html.haml
+++ b/app/views/projects/pages_domains/edit.html.haml
@@ -1,7 +1,7 @@
- add_to_breadcrumbs "Pages", project_pages_path(@project)
- breadcrumb_title @domain.domain
- page_title @domain.domain
-%h3.page_title
+%h3.page-title
= @domain.domain
%hr.clearfix
%div
diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml
index e49163880c7..269df803a2b 100644
--- a/app/views/projects/pages_domains/new.html.haml
+++ b/app/views/projects/pages_domains/new.html.haml
@@ -1,6 +1,6 @@
- add_to_breadcrumbs "Pages", project_pages_path(@project)
- page_title 'New Pages Domain'
-%h3.page_title
+%h3.page-title
New Pages Domain
%hr.clearfix
%div
diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml
index ba0713daee9..44d66f3b2d0 100644
--- a/app/views/projects/pages_domains/show.html.haml
+++ b/app/views/projects/pages_domains/show.html.haml
@@ -1,17 +1,19 @@
- add_to_breadcrumbs "Pages", project_pages_path(@project)
- breadcrumb_title @domain.domain
- page_title "#{@domain.domain}", 'Pages Domains'
+- dns_record = "#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}."
- 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.
+ = content_for :flash_message do
+ .alert.alert-warning
+ .container-fluid.container-limited
+ This domain is not verified. You will need to verify ownership before access is enabled.
-%h3.page-title
- Pages Domain
+%h3.page-title.with-button
= link_to 'Edit', edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success pull-right'
+ Pages Domain
.table-holder
%table.table
@@ -19,31 +21,41 @@
%td
Domain
%td
- = link_to @domain.domain, @domain.url
+ = link_to @domain.url do
+ = @domain.url
+ = icon('external-link')
%tr
%td
DNS
%td
- %p
- To access this domain create a new DNS record:
- %pre
- #{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}.
+ .input-group
+ = text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true
+ .input-group-btn
+ = clipboard_button(target: '#domain_dns', class: 'btn-default hidden-xs')
+ %p.help-block
+ To access this domain create a new DNS record
+
- if verification_enabled
+ - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}"
%tr
%td
Verification status
%td
- %p
+ = form_tag verify_project_pages_domain_path(@project, @domain) do
+ .status-badge
+ - text, status = @domain.unverified? ? [_('Unverified'), 'label-danger'] : [_('Verified'), 'label-success']
+ .label{ class: status }
+ = text
+ %button.btn.has-tooltip{ type: "submit", data: { container: 'body' }, title: _("Retry verification") }
+ = sprite_icon('redo')
+ .input-group
+ = text_field_tag :domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true
+ .input-group-btn
+ = clipboard_button(target: '#domain_verification', class: 'btn-default hidden-xs')
+ %p.help-block
- 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'
+ To #{link_to 'verify ownership', help_link} of your domain,
+ add the above key to a TXT record within to your DNS configuration.
%tr
%td
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 852143ecb2a..218e7338c83 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -26,7 +26,7 @@
%ul
- pipeline.yaml_errors.split(",").each do |error|
%li= error
- You can also test your .gitlab-ci.yml in the #{link_to "Lint", ci_lint_path}
+ You can also test your .gitlab-ci.yml in the #{link_to "Lint", project_ci_lint_path(@project)}
- if pipeline.project.builds_enabled? && !pipeline.ci_yaml_file
.bs-callout.bs-callout-warning
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index 877101b05ca..8f2142af2ce 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -1,24 +1,25 @@
- breadcrumb_title "Pipelines"
-- page_title "New Pipeline"
+- page_title = s_("Pipeline|Run Pipeline")
%h3.page-title
- New Pipeline
+ = s_("Pipeline|Run Pipeline")
%hr
= form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "form-horizontal js-new-pipeline-form js-requires-input" } do |f|
= form_errors(@pipeline)
.form-group
- = f.label :ref, 'Create for', class: 'control-label'
+ = f.label :ref, s_('Pipeline|Run on'), class: 'control-label'
.col-sm-10
= hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch
= dropdown_tag(params[:ref] || @project.default_branch,
options: { toggle_class: 'js-branch-select wide git-revision-dropdown-toggle',
- filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search branches",
+ filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: s_("Pipeline|Search branches"),
data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } })
- .help-block Existing branch name, tag
+ .help-block
+ = s_("Pipeline|Existing branch name, tag")
.form-actions
- = f.submit 'Create pipeline', class: 'btn btn-create', tabindex: 3
- = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-cancel'
+ = f.submit s_('Pipeline|Run pipeline'), class: 'btn btn-success', tabindex: 3
+ = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-default pull-right'
-# haml-lint:disable InlineJavaScript
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/protected_branches/_branches_list.html.haml b/app/views/projects/protected_branches/_branches_list.html.haml
index 5377d745371..24d2b971472 100644
--- a/app/views/projects/protected_branches/_branches_list.html.haml
+++ b/app/views/projects/protected_branches/_branches_list.html.haml
@@ -1,4 +1,4 @@
- can_admin_project = can?(current_user, :admin_project, @project)
= render layout: 'projects/protected_branches/shared/branches_list', locals: { can_admin_project: can_admin_project } do
- = render partial: 'projects/protected_branches/protected_branch', collection: @protected_branches, locals: { can_admin_project: can_admin_project}
+ = render partial: 'projects/protected_branches/protected_branch', collection: @protected_branches
diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml
index 98d56a3e5c5..24b53555cdc 100644
--- a/app/views/projects/protected_branches/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml
@@ -1,14 +1,14 @@
- content_for :merge_access_levels do
.merge_access_levels-container
= dropdown_tag('Select',
- options: { toggle_class: 'js-allowed-to-merge wide',
- dropdown_class: 'dropdown-menu-selectable capitalize-header',
+ options: { toggle_class: 'js-allowed-to-merge qa-allowed-to-merge-select wide',
+ dropdown_class: 'dropdown-menu-selectable qa-allowed-to-merge-dropdown capitalize-header',
data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }})
- content_for :push_access_levels do
.push_access_levels-container
= dropdown_tag('Select',
- options: { toggle_class: 'js-allowed-to-push wide',
- dropdown_class: 'dropdown-menu-selectable capitalize-header',
+ options: { toggle_class: 'js-allowed-to-push qa-allowed-to-push-select wide',
+ dropdown_class: 'dropdown-menu-selectable qa-allowed-to-push-dropdown capitalize-header',
data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
= render 'projects/protected_branches/shared/create_protected_branch'
diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml
index c61b2951e1e..f242459f69b 100644
--- a/app/views/projects/protected_branches/_update_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml
@@ -1,10 +1,10 @@
%td
= hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level
= dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') ,
- options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header',
+ options: { toggle_class: 'js-allowed-to-merge qa-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header',
data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }})
%td
= hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level
= dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') ,
- options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header',
+ options: { toggle_class: 'js-allowed-to-push qa-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header',
data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }})
diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml
index a09c13176c3..d1ed438eb21 100644
--- a/app/views/projects/protected_branches/shared/_branches_list.html.haml
+++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml
@@ -1,4 +1,4 @@
-.panel.panel-default.protected-branches-list.js-protected-branches-list
+.protected-branches-list.js-protected-branches-list.qa-protected-branches-list
- if @protected_branches.empty?
.panel-heading
%h3.panel-title
diff --git a/app/views/projects/protected_branches/shared/_dropdown.html.haml b/app/views/projects/protected_branches/shared/_dropdown.html.haml
index 74435236808..b3d6068039a 100644
--- a/app/views/projects/protected_branches/shared/_dropdown.html.haml
+++ b/app/views/projects/protected_branches/shared/_dropdown.html.haml
@@ -1,8 +1,8 @@
= f.hidden_field(:name)
= dropdown_tag('Select branch or create wildcard',
- options: { toggle_class: 'js-protected-branch-select js-filter-submit wide git-revision-dropdown-toggle',
- filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search protected branches",
+ options: { toggle_class: 'js-protected-branch-select js-filter-submit wide git-revision-dropdown-toggle qa-protected-branch-select',
+ filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown qa-protected-branch-dropdown", placeholder: "Search protected branches",
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
selected: params[:protected_branch_name],
diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml
index 55d87c35a80..fd5c1aa342a 100644
--- a/app/views/projects/protected_branches/shared/_index.html.haml
+++ b/app/views/projects/protected_branches/shared/_index.html.haml
@@ -4,7 +4,7 @@
.settings-header
%h4
Protected Branches
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.js-settings-toggle.qa-expand-protected-branches{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Keep stable branches secure and force developers to use merge requests.
diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
index 10b81e42572..2d3b2af00c2 100644
--- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
@@ -2,7 +2,7 @@
%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } }
%td
- %span.ref-name= protected_branch.name
+ %span.ref-name.qa-protected-branch-name= protected_branch.name
- if @project.root_ref?(protected_branch.name)
%span.label.label-info.prepend-left-5 default
@@ -21,4 +21,4 @@
- if can_admin_project
%td
- = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
+ = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], disabled: local_assigns[:disabled], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning"
diff --git a/app/views/projects/protected_tags/shared/_tags_list.html.haml b/app/views/projects/protected_tags/shared/_tags_list.html.haml
index 02908e16dc5..3ed82e51dbe 100644
--- a/app/views/projects/protected_tags/shared/_tags_list.html.haml
+++ b/app/views/projects/protected_tags/shared/_tags_list.html.haml
@@ -1,4 +1,4 @@
-.panel.panel-default.protected-tags-list.js-protected-tags-list
+.protected-tags-list.js-protected-tags-list
- if @protected_tags.empty?
.panel-heading
%h3.panel-title
diff --git a/app/views/projects/registry/repositories/_tag.html.haml b/app/views/projects/registry/repositories/_tag.html.haml
index 0b082a2137f..0223372bff8 100644
--- a/app/views/projects/registry/repositories/_tag.html.haml
+++ b/app/views/projects/registry/repositories/_tag.html.haml
@@ -18,7 +18,7 @@
\-
%td
- if tag.created_at
- = time_ago_in_words(tag.created_at)
+ = time_ago_with_tooltip tag.created_at
- else
.light
\-
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 12d56e244ce..76f57320f99 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -29,6 +29,10 @@
docker login #{Gitlab.config.registry.host_port}
%br
%p
+ - deploy_token = link_to(_('deploy token'), help_page_path('user/project/deploy_tokens/index', anchor: 'read-container-registry-images'), target: '_blank')
+ = s_('ContainerRegistry|You can also %{deploy_token} for read-only access to the registry images.').html_safe % { deploy_token: deploy_token }
+ %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
diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml
new file mode 100644
index 00000000000..a9dfd9cc786
--- /dev/null
+++ b/app/views/projects/runners/_group_runners.html.haml
@@ -0,0 +1,32 @@
+%h3 Group Runners
+
+.bs-callout.bs-callout-warning
+ GitLab Group Runners can execute code for all the projects in this group.
+ They can be managed using the #{link_to 'Runners API', help_page_path('api/runners.md')}.
+
+ - if @project.group
+ %hr
+ - if @project.group_runners_enabled?
+ = link_to toggle_group_runners_project_runners_path(@project), class: 'btn btn-warning', method: :post do
+ Disable group Runners
+ - else
+ = link_to toggle_group_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do
+ Enable group Runners
+ &nbsp; for this project
+
+- if !@project.group
+ This project does not belong to a group and can therefore not make use of group Runners.
+
+- elsif @group_runners.empty?
+ This group does not provide any group Runners yet.
+
+ - if can?(current_user, :admin_pipeline, @project.group)
+ = render partial: 'ci/runner/how_to_setup_runner',
+ locals: { registration_token: @project.group.runners_token, type: 'group' }
+ - else
+ Ask your group master to setup a group Runner.
+
+- else
+ %h4.underlined-title Available group Runners : #{@group_runners.count}
+ %ul.bordered-list
+ = render partial: 'projects/runners/runner', collection: @group_runners, as: :runner
diff --git a/app/views/projects/runners/_index.html.haml b/app/views/projects/runners/_index.html.haml
index f9808f7c990..3f5119d408b 100644
--- a/app/views/projects/runners/_index.html.haml
+++ b/app/views/projects/runners/_index.html.haml
@@ -23,3 +23,7 @@
= render 'projects/runners/specific_runners'
.col-sm-6
= render 'projects/runners/shared_runners'
+.row
+ .col-sm-6
+ .col-sm-6
+ = render 'projects/runners/group_runners'
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index 6376496ee1a..0d2c0536eb5 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -26,7 +26,7 @@
- else
- runner_project = @project.runner_projects.find_by(runner_id: runner)
= link_to 'Disable for this project', project_runner_project_path(@project, runner_project), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm'
- - elsif runner.specific?
+ - elsif !(runner.is_shared? || runner.group_type?) # We can simplify this to `runner.project_type?` when migrating #runner_type is complete
= form_for [@project.namespace.becomes(Namespace), @project, @project.runner_projects.new] do |f|
= f.hidden_field :runner_id, value: runner.id
= f.submit 'Enable for this project', class: 'btn btn-sm'
diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml
index f33e7e25b68..322152cfaca 100644
--- a/app/views/projects/runners/show.html.haml
+++ b/app/views/projects/runners/show.html.haml
@@ -62,6 +62,6 @@
%td Last contact
%td
- if @runner.contacted_at
- #{time_ago_in_words(@runner.contacted_at)} ago
+ = time_ago_with_tooltip @runner.contacted_at
- else
Never
diff --git a/app/views/projects/services/_index.html.haml b/app/views/projects/services/_index.html.haml
index 915c6b22162..dac7d4d1bbb 100644
--- a/app/views/projects/services/_index.html.haml
+++ b/app/views/projects/services/_index.html.haml
@@ -27,5 +27,4 @@
= service.description
%td.light
- if service.updated_at.present?
- = time_ago_in_words service.updated_at
- ago
+ = time_ago_with_tooltip service.updated_at
diff --git a/app/views/projects/settings/badges/index.html.haml b/app/views/projects/settings/badges/index.html.haml
new file mode 100644
index 00000000000..b68ed70de89
--- /dev/null
+++ b/app/views/projects/settings/badges/index.html.haml
@@ -0,0 +1,4 @@
+- breadcrumb_title _('Badges')
+- page_title _('Badges')
+
+= render 'shared/badges/badge_settings'
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
new file mode 100644
index 00000000000..71e77dae69e
--- /dev/null
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -0,0 +1,41 @@
+.row.prepend-top-default
+ .col-lg-12
+ = form_for @project, url: project_settings_ci_cd_path(@project) do |f|
+ = form_errors(@project)
+ %fieldset.builds-feature
+ .form-group
+ - message = auto_devops_warning_message(@project)
+ - ci_file_formatted = '<code>.gitlab-ci.yml</code>'.html_safe
+ - if message
+ %p.settings-message.text-center
+ = message.html_safe
+ = f.fields_for :auto_devops_attributes, @auto_devops do |form|
+ .radio
+ = form.label :enabled_true do
+ = form.radio_button :enabled, 'true'
+ %strong= s_('CICD|Enable Auto DevOps')
+ %br
+ = s_('CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project.').html_safe % { ci_file: ci_file_formatted }
+
+ .radio
+ = form.label :enabled_false do
+ = form.radio_button :enabled, 'false'
+ %strong= s_('CICD|Disable Auto DevOps')
+ %br
+ = s_('CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery.').html_safe % { ci_file: ci_file_formatted }
+
+ .radio
+ = form.label :enabled_ do
+ = form.radio_button :enabled, ''
+ %strong= s_('CICD|Instance default (%{state})') % { state: "#{Gitlab::CurrentSettings.auto_devops_enabled? ? _('enabled') : _('disabled')}" }
+ %br
+ = s_('CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}.').html_safe % { ci_file: ci_file_formatted }
+
+ = form.label :domain, class:"prepend-top-10" do
+ = _('Domain')
+ = form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
+ .help-block
+ = s_('CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.')
+ = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank'
+
+ = f.submit 'Save changes', class: "btn btn-success prepend-top-15"
diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/settings/ci_cd/_badge.html.haml
index e8028059487..e8028059487 100644
--- a/app/views/projects/pipelines_settings/_badge.html.haml
+++ b/app/views/projects/settings/ci_cd/_badge.html.haml
diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 646c01c0989..80c226ad273 100644
--- a/app/views/projects/pipelines_settings/_show.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -1,45 +1,8 @@
.row.prepend-top-default
.col-lg-12
- = form_for @project, url: project_pipelines_settings_path(@project) do |f|
+ = form_for @project, url: project_settings_ci_cd_path(@project) do |f|
+ = form_errors(@project)
%fieldset.builds-feature
- .form-group
- %h5 Auto DevOps (Beta)
- %p
- Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.
- = link_to 'Learn more about Auto DevOps', help_page_path('topics/autodevops/index.md')
- - message = auto_devops_warning_message(@project)
- - if message
- %p.settings-message.text-center
- = message.html_safe
- = f.fields_for :auto_devops_attributes, @auto_devops do |form|
- .radio
- = form.label :enabled_true do
- = form.radio_button :enabled, 'true'
- %strong Enable Auto DevOps
- %br
- %span.descr
- The Auto DevOps pipeline configuration will be used when there is no <code>.gitlab-ci.yml</code> in the project.
-
- .radio
- = form.label :enabled_false do
- = form.radio_button :enabled, 'false'
- %strong Disable Auto DevOps
- %br
- %span.descr
- An explicit <code>.gitlab-ci.yml</code> needs to be specified before you can begin using Continuous Integration and Delivery.
-
- .radio
- = form.label :enabled_ do
- = form.radio_button :enabled, ''
- %strong Instance default (#{Gitlab::CurrentSettings.auto_devops_enabled? ? 'enabled' : 'disabled'})
- %br
- %span.descr
- Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific <code>.gitlab-ci.yml</code>.
- %p
- You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.
- = form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
-
- %hr
.form-group.append-bottom-default.js-secret-runner-token
= f.label :runners_token, "Runner token", class: 'label-light'
.form-control.js-secret-value-placeholder
@@ -73,10 +36,10 @@
%hr
.form-group
- = f.label :build_timeout_in_minutes, 'Timeout', class: 'label-light'
- = f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0'
+ = f.label :build_timeout_human_readable, 'Timeout', class: 'label-light'
+ = f.text_field :build_timeout_human_readable, class: 'form-control'
%p.help-block
- Per job in minutes. If a job passes this threshold, it will be marked as failed
+ Per job. If a job passes this threshold, it will be marked as failed
= link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank'
%hr
@@ -151,10 +114,13 @@
%li
excoveralls (Elixir) -
%code \[TOTAL\]\s+(\d+\.\d+)%
+ %li
+ JaCoCo (Java/Kotlin)
+ %code Total.*?([0-9]{1,3})%
= f.submit 'Save changes', class: "btn btn-save"
%hr
.row.prepend-top-default
- = render partial: 'projects/pipelines_settings/badge', collection: @badges
+ = render partial: 'badge', collection: @badges
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index d65341dbd40..5f596a019f7 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -3,17 +3,30 @@
- page_title "CI / CD"
- expanded = Rails.env.test?
+- general_expanded = @project.errors.empty? ? expanded : true
-%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if expanded) }
+%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) }
.settings-header
%h4
General pipelines settings
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
- Update your CI/CD configuration, like job timeout or Auto DevOps.
+ Access your runner token, customize your pipeline configuration, and view your pipeline status and coverage report.
.settings-content
- = render 'projects/pipelines_settings/show'
+ = render 'form'
+
+%section.settings#autodevops-settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = s_('CICD|Auto DevOps (Beta)')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = s_('CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.')
+ = link_to s_('CICD|Learn more about Auto DevOps'), help_page_path('topics/autodevops/index.md')
+ .settings-content
+ = render 'autodevops_form'
%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 6bef4d19434..f57590a908f 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -9,3 +9,4 @@
= render "projects/protected_branches/index"
= render "projects/protected_tags/index"
= render @deploy_keys
+= render "projects/deploy_tokens/index"
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 94331a16abd..e28accd5b43 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -24,7 +24,7 @@
.text-warning.center.prepend-top-20
%p
= icon("exclamation-triangle fw")
- #{ _('Archived project! Repository is read-only') }
+ #{ _('Archived project! Repository and other project resources are read-only') }
- view_path = @project.default_view
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 3d5f92f9aaa..98b4d6339da 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -31,6 +31,6 @@
= 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 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")
+ - if can?(current_user, :admin_project, @project)
+ = 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/show.html.haml b/app/views/projects/tags/show.html.haml
index dfe2c37ed8e..7a3469cdd26 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -28,7 +28,7 @@
= icon('history')
.btn-container.controls-item
= render 'projects/buttons/download', project: @project, ref: @tag.name
- - if can?(current_user, :admin_project, @project)
+ - if can?(current_user, :push_code, @project) && 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: 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
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 5ef5e9c09a2..8587d3b0c0d 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -1,3 +1,6 @@
+- can_collaborate = can_collaborate_with_project?(@project)
+- can_create_mr_from_fork = can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
+
.tree-ref-container
.tree-ref-holder
= render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true
@@ -15,7 +18,7 @@
%li
= link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path))
- - if current_user
+ - if can_collaborate || can_create_mr_from_fork
%li
%a.btn.add-to-tree{ addtotree_toggle_attributes }
= sprite_icon('plus', size: 16, css_class: 'pull-left')
@@ -35,7 +38,7 @@
%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)
+ - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
%li
- continue_params = { to: project_new_blob_path(@project, @id),
notice: edit_in_new_fork_notice,
@@ -61,23 +64,25 @@
= 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') }
+ - if can?(current_user, :push_code, @project)
+ %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
= link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
= render 'projects/find_file_link'
- = succeed " " do
- = link_to ide_edit_path(@project, @id, ""), class: 'btn btn-default' do
- = _('Web IDE')
+ - if can_collaborate
+ = succeed " " do
+ = link_to ide_edit_path(@project, @id, ""), class: 'btn btn-default' do
+ = _('Web IDE')
= render 'projects/buttons/download', project: @project, ref: @ref
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index 6249c32b7cc..9201680f119 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -25,7 +25,7 @@
%td
- if trigger.last_used
- #{time_ago_in_words(trigger.last_used)} ago
+ = time_ago_with_tooltip trigger.last_used
- else
Never
diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml
index 934d65e8b42..d3fa324e460 100644
--- a/app/views/shared/_auto_devops_callout.html.haml
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -1,15 +1,15 @@
-.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
+.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20.prepend-top-10{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
.banner-graphic
= custom_icon('icon_autodevops')
- .prepend-top-10.prepend-left-10.append-bottom-10
- %h5= s_('AutoDevOps|Auto DevOps (Beta)')
+ .banner-body.prepend-left-10.append-bottom-10
+ %h5.banner-title= 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'
+ .banner-buttons
+ = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn js-close-callout'
%button.btn-transparent.banner-close.close.js-close-callout{ type: 'button',
'aria-label' => 'Dismiss Auto DevOps box' }
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index cb21f90696f..403d22c79f8 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -32,6 +32,13 @@
required: true,
title: 'You can choose a descriptive name different from the path.'
+- if @group.persisted?
+ .form-group.group-name-holder
+ = f.label :id, class: 'control-label' do
+ = _("Group ID")
+ .col-sm-10
+ = f.text_field :id, class: 'form-control', readonly: true
+
.form-group.group-description-holder
= f.label :description, class: 'control-label'
.col-sm-10
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 56403907844..836df57a3a2 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -47,20 +47,20 @@
class: 'text-danger'
.pull-right.hidden-xs.hidden-sm
- - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group)
- %button.js-promote-project-label-button.btn.btn-transparent.btn-action.has-tooltip{ title: _('Promote to Group Label'),
- disabled: true,
- type: 'button',
- data: { url: promote_project_label_path(label.project, label),
- label_title: label.title,
- label_color: label.color,
- label_text_color: label.text_color,
- group_name: label.project.group.name,
- target: '#promote-label-modal',
- container: 'body',
- toggle: 'modal' } }
- = sprite_icon('level-up')
- if can?(current_user, :admin_label, label)
+ - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group)
+ %button.js-promote-project-label-button.btn.btn-transparent.btn-action.has-tooltip{ title: _('Promote to Group Label'),
+ disabled: true,
+ type: 'button',
+ data: { url: promote_project_label_path(label.project, label),
+ label_title: label.title,
+ label_color: label.color,
+ label_text_color: label.text_color,
+ group_name: label.project.group.name,
+ target: '#promote-label-modal',
+ container: 'body',
+ toggle: 'modal' } }
+ = sprite_icon('level-up')
= 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')
diff --git a/app/views/shared/_recaptcha_form.html.haml b/app/views/shared/_recaptcha_form.html.haml
index 93a4301f366..a0ba1afc284 100644
--- a/app/views/shared/_recaptcha_form.html.haml
+++ b/app/views/shared/_recaptcha_form.html.haml
@@ -10,7 +10,7 @@
= 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'
+ = recaptcha_tags script: script, callback: 'recaptchaDialogCallback' unless Rails.env.test?
-# Yields a block with given extra params.
= yield
diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml
index 4c8c92d722a..f1c39b9e923 100644
--- a/app/views/shared/_ref_switcher.html.haml
+++ b/app/views/shared/_ref_switcher.html.haml
@@ -8,8 +8,8 @@
- @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, 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_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 qa-branches-select" }
+ .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging.qa-branches-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) }
.dropdown-page-one
= dropdown_title _("Switch branch/tag")
= dropdown_filter _("Search branches and tags")
diff --git a/app/views/shared/badges/_badge_settings.html.haml b/app/views/shared/badges/_badge_settings.html.haml
new file mode 100644
index 00000000000..b7c250d3b1c
--- /dev/null
+++ b/app/views/shared/badges/_badge_settings.html.haml
@@ -0,0 +1,4 @@
+#badge-settings{ data: { api_endpoint_url: @badge_api_endpoint,
+ docs_url: help_page_path('user/project/badges')} }
+ .text-center.prepend-top-default
+ = icon('spinner spin 2x')
diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml
index 8e5e32e9f16..b385cc3f962 100644
--- a/app/views/shared/boards/components/_sidebar.html.haml
+++ b/app/views/shared/boards/components/_sidebar.html.haml
@@ -22,6 +22,6 @@
= render "shared/boards/components/sidebar/labels"
= render "shared/boards/components/sidebar/notifications"
%remove-btn{ ":issue" => "issue",
- ":issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'",
+ ":issue-update" => "issue.sidebarInfoEndpoint",
":list" => "list",
"v-if" => "canRemove" }
diff --git a/app/views/shared/boards/components/sidebar/_assignee.html.haml b/app/views/shared/boards/components/sidebar/_assignee.html.haml
index 3d2e8471a60..1374da9d82c 100644
--- a/app/views/shared/boards/components/sidebar/_assignee.html.haml
+++ b/app/views/shared/boards/components/sidebar/_assignee.html.haml
@@ -21,8 +21,7 @@
.dropdown
- dropdown_options = issue_assignees_dropdown_options
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: board_sidebar_user_data,
- ":data-issuable-id" => "issue.iid",
- ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ ":data-issuable-id" => "issue.iid" }
= dropdown_options[:title]
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
diff --git a/app/views/shared/boards/components/sidebar/_due_date.html.haml b/app/views/shared/boards/components/sidebar/_due_date.html.haml
index db794d6f855..d13b998e6f4 100644
--- a/app/views/shared/boards/components/sidebar/_due_date.html.haml
+++ b/app/views/shared/boards/components/sidebar/_due_date.html.haml
@@ -22,8 +22,7 @@
":value" => "issue.dueDate" }
.dropdown
%button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button',
- data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" },
- ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" } }
%span.dropdown-toggle-text Due date
= icon('chevron-down')
.dropdown-menu.dropdown-menu-due-date
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
index dfc0f9be321..1c73534c642 100644
--- a/app/views/shared/boards/components/sidebar/_labels.html.haml
+++ b/app/views/shared/boards/components/sidebar/_labels.html.haml
@@ -4,7 +4,7 @@
- if can_admin_issue?
= icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
- .value.issuable-show-labels
+ .value.issuable-show-labels.dont-hide
%span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" }
None
%a{ href: "#",
@@ -26,8 +26,7 @@
project_id: @project&.try(:id),
labels: labels_filter_path(false),
namespace_path: @namespace_path,
- project_path: @project.try(:path) },
- ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ project_path: @project.try(:path) } }
%span.dropdown-toggle-text
Label
= icon('chevron-down')
diff --git a/app/views/shared/boards/components/sidebar/_milestone.html.haml b/app/views/shared/boards/components/sidebar/_milestone.html.haml
index d09c7c218e0..f51c4a97f2b 100644
--- a/app/views/shared/boards/components/sidebar/_milestone.html.haml
+++ b/app/views/shared/boards/components/sidebar/_milestone.html.haml
@@ -18,8 +18,7 @@
.dropdown
%button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", milestones: milestones_filter_path(format: :json), ability_name: "issue", use_id: "true", default_no: "true" },
":data-selected" => "milestoneTitle",
- ":data-issuable-id" => "issue.iid",
- ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ ":data-issuable-id" => "issue.iid" }
Milestone
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable
diff --git a/app/views/shared/dashboard/_no_filter_selected.html.haml b/app/views/shared/dashboard/_no_filter_selected.html.haml
new file mode 100644
index 00000000000..b2e6967f6aa
--- /dev/null
+++ b/app/views/shared/dashboard/_no_filter_selected.html.haml
@@ -0,0 +1,8 @@
+.row.empty-state.text-center
+ .col-xs-12
+ .svg-130.prepend-top-default
+ = image_tag 'illustrations/issue-dashboard_results-without-filter.svg'
+ .col-xs-12
+ .text-content
+ %h4
+ = _("Please select at least one filter to see results")
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index 7704c88905b..1bd5b4164b1 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -24,12 +24,9 @@
.filter-item.inline.labels-filter
= 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
- %a{ href: page_filter_path(without: issuable_filter_params) } Reset filters
-
- .pull-right
- = render 'shared/sort_dropdown'
+ - unless @no_filters_set
+ .pull-right
+ = render 'shared/sort_dropdown'
- has_labels = @labels && @labels.any?
.row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) }
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index 4d8109eb90c..a5f40ea934b 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -1,22 +1,23 @@
- type = local_assigns.fetch(:type, :issues)
- page_context_word = type.to_s.humanize(capitalize: false)
+- display_count = local_assigns.fetch(:display_count, :true)
%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)}
+ #{issuables_state_counter_text(type, :opened, display_count)}
- if type == :merge_requests
%li{ class: active_when(params[:state] == 'merged') }>
= link_to page_filter_path(state: 'merged', label: true), id: 'state-merged', title: 'Filter by merge requests that are currently merged.', data: { state: 'merged' } do
- #{issuables_state_counter_text(type, :merged)}
+ #{issuables_state_counter_text(type, :merged, display_count)}
%li{ class: active_when(params[:state] == 'closed') }>
= link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.', data: { state: 'closed' } do
- #{issuables_state_counter_text(type, :closed)}
+ #{issuables_state_counter_text(type, :closed, display_count)}
- else
%li{ class: active_when(params[:state] == 'closed') }>
= 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)}
+ #{issuables_state_counter_text(type, :closed, display_count)}
- = render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: 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, display_count)
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 975b9cb4729..093033775a9 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -7,7 +7,7 @@
- if current_user
%span.issuable-header-text.hide-collapsed.pull-left
= _('Todo')
- %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
+ %a.gutter-toggle.pull-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left' } }
= sidebar_gutter_toggle_icon
- if current_user
= render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable
@@ -19,12 +19,11 @@
.block.assignee
= render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present?
.block.milestone
- .sidebar-collapsed-icon
+ .sidebar-collapsed-icon.has-tooltip{ title: milestone_tooltip_title(issuable.milestone), data: { container: 'body', html: 1, placement: 'left' } }
= icon('clock-o', 'aria-hidden': 'true')
%span.milestone-title
- if issuable.milestone
- %span.has-tooltip{ title: "#{issuable.milestone.title}<br>#{milestone_tooltip_title(issuable.milestone)}", data: { container: 'body', html: 1, placement: 'left' } }
- = issuable.milestone.title
+ = issuable.milestone.title
- else
= _('None')
.title.hide-collapsed
@@ -34,7 +33,7 @@
= 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 }
+ = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_due_date(issuable.milestone), data: { container: "body", html: 1 }
- else
%span.no-value
= _('None')
@@ -50,7 +49,7 @@
= icon('spinner spin', 'aria-hidden': 'true')
- if issuable.has_attribute?(:due_date)
.block.due_date
- .sidebar-collapsed-icon
+ .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 1 }, title: sidebar_due_date_tooltip_label(issuable) }
= icon('calendar', 'aria-hidden': 'true')
%span.js-due-date-sidebar-value
= issuable.due_date.try(:to_s, :medium) || 'None'
@@ -96,7 +95,7 @@
= 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'
- .value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) }
+ .value.issuable-show-labels.dont-hide.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)
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 304df38a096..21006a76b28 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -4,7 +4,7 @@
= _('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) }
+ .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: sidebar_assignee_tooltip_label(issuable) }
- if issuable.assignee
= link_to_member(@project, issuable.assignee, size: 24)
- else
diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml
index b77e104c072..74327fb1ba8 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')
+- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark todo as 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 todo as done')),
+ 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark todo as 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/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
index bf8613b0f0d..d7740eddcca 100644
--- a/app/views/shared/issuable/form/_merge_request_assignee.html.haml
+++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
@@ -1,6 +1,6 @@
- merge_request = issuable
.block.assignee
- .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) }
+ .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: sidebar_assignee_tooltip_label(issuable) }
- if merge_request.assignee
= link_to_member(@project, merge_request.assignee, size: 24)
- else
diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml
index 5868c52566d..fc634856061 100644
--- a/app/views/shared/members/_group.html.haml
+++ b/app/views/shared/members/_group.html.haml
@@ -8,7 +8,7 @@
%strong
= link_to group.full_name, group_path(group)
.cgray
- Joined #{time_ago_with_tooltip(group.created_at)}
+ Given access #{time_ago_with_tooltip(group_link.created_at)}
- if group_link.expires?
·
%span{ class: ('text-warning' if group_link.expires_soon?) }
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index ba57d922c6d..1c139827acf 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -29,7 +29,7 @@
Requested
= time_ago_with_tooltip(member.requested_at)
- else
- Joined #{time_ago_with_tooltip(member.created_at)}
+ Given access #{time_ago_with_tooltip(member.created_at)}
- if member.expires?
·
%span{ class: "#{"text-warning" if member.expires_soon?} has-tooltip", title: member.expires_at.to_time.in_time_zone.to_s(:medium) }
diff --git a/app/views/shared/milestones/_deprecation_message.html.haml b/app/views/shared/milestones/_deprecation_message.html.haml
new file mode 100644
index 00000000000..4a8f90937ea
--- /dev/null
+++ b/app/views/shared/milestones/_deprecation_message.html.haml
@@ -0,0 +1,14 @@
+.banner-callout.compact.milestone-deprecation-message.js-milestone-deprecation-message.prepend-top-20
+ .banner-graphic= image_tag 'illustrations/milestone_removing-page.svg'
+ .banner-body.prepend-left-10.append-right-10
+ %h5.banner-title.prepend-top-0= _('This page will be removed in a future release.')
+ %p.milestone-banner-text= _('Use group milestones to manage issues from multiple projects in the same milestone.')
+ = button_tag _('Promote these project milestones into a group milestone.'), class: 'btn btn-link js-popover-link text-align-left milestone-banner-link'
+ .milestone-banner-buttons.prepend-top-20= link_to _('Learn more'), help_page_url('user/project/milestones/index', anchor: 'promoting-project-milestones-to-group-milestones'), class: 'btn btn-default', target: '_blank'
+
+ %template.js-milestone-deprecation-message-template
+ .milestone-popover-body
+ %ol.milestone-popover-instructions-list.append-bottom-0
+ %li= _('Click any <strong>project name</strong> in the project list below to navigate to the project milestone.').html_safe
+ %li= _('Click the <strong>Promote</strong> button in the top right corner to promote it to a group milestone.').html_safe
+ .milestone-popover-footer= link_to _('Learn more'), help_page_url('user/project/milestones/index', anchor: 'promoting-project-milestones-to-group-milestones'), class: 'btn btn-link prepend-left-0', target: '_blank'
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index a942ebc328b..8e9a1b56bb8 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -4,12 +4,8 @@
%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar.milestone-sidebar
.block.milestone-progress.issuable-sidebar-header
- %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
+ %a.gutter-toggle.pull-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left' } }
= sidebar_gutter_toggle_icon
-
- .sidebar-collapsed-icon
- %span== #{milestone.percent_complete(current_user)}%
- = milestone_progress_bar(milestone)
.title.hide-collapsed
%strong.bold== #{milestone.percent_complete(current_user)}%
%span.hide-collapsed
@@ -17,6 +13,11 @@
.value.hide-collapsed
= milestone_progress_bar(milestone)
+ .block.milestone-progress.hide-expanded
+ .sidebar-collapsed-icon.has-tooltip{ title: milestone_progress_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } }
+ %span== #{milestone.percent_complete(current_user)}%
+ = milestone_progress_bar(milestone)
+
.block.start_date.hide-collapsed
.title
Start date
@@ -35,19 +36,25 @@
%span.collapsed-milestone-date
- if milestone.start_date && milestone.due_date
- if milestone.start_date.year == milestone.due_date.year
- .milestone-date= milestone.start_date.strftime('%b %-d')
+ .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } }
+ = milestone.start_date.strftime('%b %-d')
- else
- .milestone-date= milestone.start_date.strftime('%b %-d %Y')
+ .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } }
+ = milestone.start_date.strftime('%b %-d %Y')
.date-separator -
- .due_date= milestone.due_date.strftime('%b %-d %Y')
+ .due_date.has-tooltip{ title: milestone_time_for(milestone.due_date, :end), data: { container: 'body', html: 1, placement: 'left' } }
+ = milestone.due_date.strftime('%b %-d %Y')
- elsif milestone.start_date
From
- .milestone-date= milestone.start_date.strftime('%b %-d %Y')
+ .milestone-date.has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } }
+ = milestone.start_date.strftime('%b %-d %Y')
- elsif milestone.due_date
Until
- .milestone-date= milestone.due_date.strftime('%b %-d %Y')
+ .milestone-date.has-tooltip{ title: milestone_time_for(milestone.due_date, :end), data: { container: 'body', html: 1, placement: 'left' } }
+ = milestone.due_date.strftime('%b %-d %Y')
- else
- None
+ .has-tooltip{ title: milestone_time_for(milestone.start_date, :start), data: { container: 'body', html: 1, placement: 'left' } }
+ None
.title.hide-collapsed
Due date
- if @project && can?(current_user, :admin_milestone, @project)
@@ -58,21 +65,21 @@
%span.bold= milestone.due_date.to_s(:medium)
- else
%span.no-value No due date
- - remaining_days = milestone_remaining_days(milestone)
+ - remaining_days = remaining_days_in_words(milestone)
- if remaining_days.present?
= surround '(', ')' do
%span.remaining-days= remaining_days
- if !project || can?(current_user, :read_issue, project)
.block.issues
- .sidebar-collapsed-icon
+ .sidebar-collapsed-icon.has-tooltip{ title: milestone_issues_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } }
%strong
= custom_icon('issues')
%span= milestone.issues_visible_to_user(current_user).count
.title.hide-collapsed
Issues
%span.badge= milestone.issues_visible_to_user(current_user).count
- - if project && can?(current_user, :create_issue, project)
+ - if show_new_issue_link?(project)
= link_to new_project_issue_path(project, issue: { milestone_id: milestone.id }), class: "pull-right", title: "New Issue" do
New issue
.value.hide-collapsed.bold
@@ -93,7 +100,7 @@
= icon('spinner spin')
.block.merge-requests
- .sidebar-collapsed-icon
+ .sidebar-collapsed-icon.has-tooltip{ title: milestone_merge_requests_tooltip_text(milestone), data: { container: 'body', html: 1, placement: 'left' } }
%strong
= custom_icon('mr_bold')
%span= milestone.merge_requests.count
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index f302299eb24..797ff034bb2 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -1,7 +1,8 @@
-- page_title @milestone.title
+- page_title milestone.title
- @breadcrumb_link = dashboard_milestone_path(milestone.safe_title, title: milestone.title)
- group = local_assigns[:group]
+- is_dynamic_milestone = milestone.legacy_group_milestone? || milestone.dashboard_milestone?
.detail-page-header
%a.btn.btn-default.btn-grouped.pull-right.visible-xs-block.js-sidebar-toggle{ href: "#" }
@@ -31,21 +32,23 @@
- else
= link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
+= render 'shared/milestones/deprecation_message' if is_dynamic_milestone
+
.detail-page-description.milestone-detail
%h2.title
= markdown_field(milestone, :title)
- - if @milestone.group_milestone? && @milestone.description.present?
+ - if milestone.group_milestone? && milestone.description.present?
%div
.description
.wiki
- = markdown_field(@milestone, :description)
+ = markdown_field(milestone, :description)
- if milestone.complete?(current_user) && milestone.active?
.alert.alert-success.prepend-top-default
- close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.'
%span All issues for this milestone are closed. #{close_msg}
-- if @milestone.legacy_group_milestone? || @milestone.dashboard_milestone?
+- if is_dynamic_milestone
.table-holder
%table.table
%thead
@@ -68,7 +71,7 @@
Open
%td
= ms.expires_at
-- elsif @milestone.group_milestone?
+- elsif milestone.group_milestone?
%br
View
= link_to 'Issues', issues_group_path(@group, milestone_title: milestone.title)
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index bf359774ead..893a7f26ebd 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -2,7 +2,7 @@
- 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_editable = can?(current_user, :admin_note, note)
- note_counter = local_assigns.fetch(:note_counter, 0)
%li.timeline-entry{ id: dom_id(note),
diff --git a/app/views/shared/snippets/_embed.html.haml b/app/views/shared/snippets/_embed.html.haml
new file mode 100644
index 00000000000..2d93e51a2d9
--- /dev/null
+++ b/app/views/shared/snippets/_embed.html.haml
@@ -0,0 +1,24 @@
+- blob = @snippet.blob
+.gitlab-embed-snippets
+ .js-file-title.file-title-flex-parent
+ .file-header-content
+ = external_snippet_icon('doc_text')
+
+ %strong.file-title-name
+ %a.gitlab-embedded-snippets-title{ href: url_for(only_path: false, overwrite_params: nil) }
+ = blob.name
+
+ %small
+ = number_to_human_size(blob.raw_size)
+ %a.gitlab-logo{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' }
+ on &nbsp;
+ %span.logo-text
+ GitLab
+
+ .file-actions.hidden-xs
+ .btn-group{ role: "group" }<
+ = embedded_snippet_raw_button
+
+ = embedded_snippet_download_button
+ %article.file-holder.snippet-file-content
+ = render 'projects/blob/viewer', viewer: @snippet.blob.simple_viewer, load_async: false, external_embed: true
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index 12df79a28c7..836230ae8ee 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -19,11 +19,32 @@
%h2.snippet-title.prepend-top-0.append-bottom-0
= markdown_field(@snippet, :title)
- - if @snippet.updated_at != @snippet.created_at
- = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true)
- if @snippet.description.present?
.description
.wiki
= markdown_field(@snippet, :description)
%textarea.hidden.js-task-list-field
= @snippet.description
+
+ - if @snippet.updated_at != @snippet.created_at
+ = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true)
+
+ - if public_snippet?
+ .embed-snippet
+ .input-group
+ .input-group-btn
+ %button.btn.embed-toggle{ 'data-toggle': 'dropdown', type: 'button' }
+ %span.js-embed-action= _("Embed")
+ = sprite_icon('angle-down', size: 12)
+ %ul.dropdown-menu.dropdown-menu-selectable.embed-toggle-list
+ %li
+ %button.js-embed-btn.btn.btn-transparent.is-active{ type: 'button' }
+ %strong.embed-toggle-list-item= _("Embed")
+ %li
+ %button.js-share-btn.btn.btn-transparent{ type: 'button' }
+ %strong.embed-toggle-list-item= _("Share")
+ %input.js-snippet-url-area.snippet-embed-input.form-control{ type: "text", autocomplete: 'off', value: snippet_embed }
+ .input-group-btn
+ %button.js-clipboard-btn.btn.btn-default.has-tooltip{ title: "Copy to clipboard", 'data-clipboard-target': '#snippet-url-area' }
+ = sprite_icon('duplicate', size: 16)
+ .clearfix
diff --git a/app/views/shared/snippets/show.js.haml b/app/views/shared/snippets/show.js.haml
new file mode 100644
index 00000000000..a9af732bbb5
--- /dev/null
+++ b/app/views/shared/snippets/show.js.haml
@@ -0,0 +1,2 @@
+document.write('#{escape_javascript(stylesheet_link_tag "#{stylesheet_url 'snippets'}")}');
+document.write('#{escape_javascript(render 'shared/snippets/embed')}');
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index ad4d39b4aa1..d36ca032558 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -33,6 +33,13 @@
%p.light
This URL will be triggered when someone adds a comment
%li
+ = form.check_box :confidential_note_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :confidential_note_events, class: 'list-label' do
+ %strong Confidential Comments
+ %p.light
+ This URL will be triggered when someone adds a comment on a confidential issue
+ %li
= form.check_box :issues_events, class: 'pull-left'
.prepend-left-20
= form.label :issues_events, class: 'list-label' do
diff --git a/app/views/sherlock/transactions/_general.html.haml b/app/views/sherlock/transactions/_general.html.haml
index 8533b130da6..a37fb5d449a 100644
--- a/app/views/sherlock/transactions/_general.html.haml
+++ b/app/views/sherlock/transactions/_general.html.haml
@@ -35,5 +35,4 @@
%span.light
#{t('sherlock.finished_at')}:
%strong
- = time_ago_in_words(@transaction.finished_at)
- = t('sherlock.ago')
+ = time_ago_with_tooltip @transaction.finished_at
diff --git a/app/views/sherlock/transactions/index.html.haml b/app/views/sherlock/transactions/index.html.haml
index bc05659dfa8..6ed7e9e21a6 100644
--- a/app/views/sherlock/transactions/index.html.haml
+++ b/app/views/sherlock/transactions/index.html.haml
@@ -35,8 +35,7 @@
= t('sherlock.seconds')
%td= trans.queries.length
%td
- = time_ago_in_words(trans.finished_at)
- = t('sherlock.ago')
+ = time_ago_with_tooltip trans.finished_at
%td
= link_to(sherlock_transaction_path(trans), class: 'btn btn-xs') do
= t('sherlock.view')
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 4bf01ecb48c..d35ddf3eb39 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -35,7 +35,7 @@
= 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
+ .user-info.prepend-left-default.append-right-default
.cover-title
= @user.name
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 9a11cdb121e..c469aea7052 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -18,6 +18,7 @@
- cronjob:stuck_import_jobs
- cronjob:stuck_merge_jobs
- cronjob:trending_projects
+- cronjob:issue_due_scheduler
- gcp_cluster:cluster_install_app
- gcp_cluster:cluster_provision
@@ -39,6 +40,9 @@
- github_importer:github_import_stage_import_pull_requests
- github_importer:github_import_stage_import_repository
+- mail_scheduler:mail_scheduler_issue_due
+- mail_scheduler:mail_scheduler_notification_service
+
- object_storage_upload
- object_storage:object_storage_background_move
- object_storage:object_storage_migrate_uploads
diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb
index d7e24491516..8fe3619f6ee 100644
--- a/app/workers/authorized_projects_worker.rb
+++ b/app/workers/authorized_projects_worker.rb
@@ -2,6 +2,14 @@ class AuthorizedProjectsWorker
include ApplicationWorker
prepend WaitableWorker
+ # This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the
+ # visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231
+ # for more details.
+ if Rails.env.test?
+ def self.bulk_perform_and_wait(args_list, timeout: 10)
+ end
+ end
+
def perform(user_id)
user = User.find_by(id: user_id)
diff --git a/app/workers/concerns/mail_scheduler_queue.rb b/app/workers/concerns/mail_scheduler_queue.rb
new file mode 100644
index 00000000000..f3e9680d756
--- /dev/null
+++ b/app/workers/concerns/mail_scheduler_queue.rb
@@ -0,0 +1,11 @@
+module MailSchedulerQueue
+ extend ActiveSupport::Concern
+
+ included do
+ queue_namespace :mail_scheduler
+ end
+
+ def notification_service
+ @notification_service ||= NotificationService.new
+ end
+end
diff --git a/app/workers/issue_due_scheduler_worker.rb b/app/workers/issue_due_scheduler_worker.rb
new file mode 100644
index 00000000000..16ab5d069e0
--- /dev/null
+++ b/app/workers/issue_due_scheduler_worker.rb
@@ -0,0 +1,10 @@
+class IssueDueSchedulerWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ def perform
+ project_ids = Issue.opened.due_tomorrow.group(:project_id).pluck(:project_id).map { |id| [id] }
+
+ MailScheduler::IssueDueWorker.bulk_perform_async(project_ids)
+ end
+end
diff --git a/app/workers/mail_scheduler/issue_due_worker.rb b/app/workers/mail_scheduler/issue_due_worker.rb
new file mode 100644
index 00000000000..54285884a52
--- /dev/null
+++ b/app/workers/mail_scheduler/issue_due_worker.rb
@@ -0,0 +1,12 @@
+module MailScheduler
+ class IssueDueWorker
+ include ApplicationWorker
+ include MailSchedulerQueue
+
+ def perform(project_id)
+ Issue.opened.due_tomorrow.in_projects(project_id).preload(:project).find_each do |issue|
+ notification_service.issue_due(issue)
+ end
+ end
+ end
+end
diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb
new file mode 100644
index 00000000000..7cfe0aa0df1
--- /dev/null
+++ b/app/workers/mail_scheduler/notification_service_worker.rb
@@ -0,0 +1,19 @@
+require 'active_job/arguments'
+
+module MailScheduler
+ class NotificationServiceWorker
+ include ApplicationWorker
+ include MailSchedulerQueue
+
+ def perform(meth, *args)
+ deserialized_args = ActiveJob::Arguments.deserialize(args)
+
+ notification_service.public_send(meth, *deserialized_args) # rubocop:disable GitlabSecurity/PublicSend
+ rescue ActiveJob::DeserializationError
+ end
+
+ def self.perform_async(*args)
+ super(*ActiveJob::Arguments.serialize(args))
+ end
+ end
+end
diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb
index 67c54fbf10e..b925741934a 100644
--- a/app/workers/new_note_worker.rb
+++ b/app/workers/new_note_worker.rb
@@ -5,7 +5,7 @@ class NewNoteWorker
# old `NewNoteWorker` jobs (can remove later)
def perform(note_id, _params = {})
if note = Note.find_by(id: note_id)
- NotificationService.new.new_note(note)
+ NotificationService.new.new_note(note) if note.can_create_notification?
Notes::PostProcessService.new(note).execute
else
Rails.logger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job")
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 3909dbf7d7f..f88b3fdbfb1 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -33,7 +33,7 @@ class PostReceive
unless @user
log("Triggered hook for non-existing user \"#{post_received.identifier}\"")
- return false
+ return false # rubocop:disable Cop/AvoidReturnFromBlocks
end
if Gitlab::Git.tag_ref?(ref)
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index 51fad4faf36..08b1c3a7d7a 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -13,7 +13,9 @@ class RepositoryForkWorker
# See https://gitlab.com/gitlab-org/gitaly/issues/1110
if args.empty?
source_project = target_project.forked_from_project
- return target_project.mark_import_as_failed('Source project cannot be found.') unless source_project
+ unless source_project
+ return target_project.mark_import_as_failed('Source project cannot be found.')
+ end
fork_repository(target_project, source_project.repository_storage, source_project.disk_path)
else
diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb
index fb26fa4c515..7ebf69bdc39 100644
--- a/app/workers/stuck_ci_jobs_worker.rb
+++ b/app/workers/stuck_ci_jobs_worker.rb
@@ -38,7 +38,7 @@ class StuckCiJobsWorker
def drop_stuck(status, timeout)
search(status, timeout) do |build|
- return unless build.stuck?
+ break unless build.stuck?
drop_build :stuck, build, status, timeout
end