summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/bot_avatars/alert-bot.pngbin0 -> 9362 bytes
-rw-r--r--app/assets/images/bot_avatars/security-bot.pngbin0 -> 9561 bytes
-rw-r--r--app/assets/images/bot_avatars/support-bot.pngbin0 -> 9806 bytes
-rw-r--r--app/assets/images/confluence.svg1
-rw-r--r--app/assets/images/logos/jira-gray.svg1
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue54
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_empty_state.vue90
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue75
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue (renamed from app/assets/javascripts/alert_management/components/alert_management_list.vue)250
-rw-r--r--app/assets/javascripts/alert_management/components/alert_metrics.vue56
-rw-r--r--app/assets/javascripts/alert_management/components/alert_sidebar.vue48
-rw-r--r--app/assets/javascripts/alert_management/components/alert_status.vue129
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue64
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue27
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue99
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue106
-rw-r--r--app/assets/javascripts/alert_management/components/system_notes/system_note.vue2
-rw-r--r--app/assets/javascripts/alert_management/details.js54
-rw-r--r--app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql23
-rw-r--r--app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql2
-rw-r--r--app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql (renamed from app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql)9
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql11
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql8
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql8
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql3
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql10
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.mutation.graphql17
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/details.query.graphql12
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql54
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql18
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql3
-rw-r--r--app/assets/javascripts/alert_management/list.js16
-rw-r--r--app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue23
-rw-r--r--app/assets/javascripts/alerts_service_settings/index.js4
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue563
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js50
-rw-r--r--app/assets/javascripts/alerts_settings/index.js67
-rw-r--r--app/assets/javascripts/alerts_settings/services/index.js36
-rw-r--r--app/assets/javascripts/api.js43
-rw-r--r--app/assets/javascripts/awards_handler.js8
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue2
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue11
-rw-r--r--app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue8
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue2
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_item.vue15
-rw-r--r--app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js41
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js92
-rw-r--r--app/assets/javascripts/behaviors/index.js4
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js4
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js2
-rw-r--r--app/assets/javascripts/behaviors/select2.js23
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue28
-rw-r--r--app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue12
-rw-r--r--app/assets/javascripts/blob/components/constants.js2
-rw-r--r--app/assets/javascripts/blob/notebook/notebook_viewer.vue4
-rw-r--r--app/assets/javascripts/blob/pdf/pdf_viewer.vue2
-rw-r--r--app/assets/javascripts/blob/sketch/index.js2
-rw-r--r--app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue31
-rw-r--r--app/assets/javascripts/blob/viewer/index.js5
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js7
-rw-r--r--app/assets/javascripts/blob_edit/constants.js4
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js83
-rw-r--r--app/assets/javascripts/boards/components/board.js192
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue78
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue19
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue2
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue4
-rw-r--r--app/assets/javascripts/boards/components/modal/header.vue2
-rw-r--r--app/assets/javascripts/boards/index.js12
-rw-r--r--app/assets/javascripts/boards/models/list.js21
-rw-r--r--app/assets/javascripts/boards/queries/board.fragment.graphql2
-rw-r--r--app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql20
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js24
-rw-r--r--app/assets/javascripts/boards/toggle_focus.js2
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_key_field.vue169
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js14
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue81
-rw-r--r--app/assets/javascripts/ci_variable_list/store/actions.js28
-rw-r--r--app/assets/javascripts/ci_variable_list/store/mutation_types.js6
-rw-r--r--app/assets/javascripts/ci_variable_list/store/mutations.js35
-rw-r--r--app/assets/javascripts/ci_variable_list/store/state.js4
-rw-r--r--app/assets/javascripts/ci_variable_list/store/utils.js4
-rw-r--r--app/assets/javascripts/close_reopen_report_toggle.js7
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js3
-rw-r--r--app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue8
-rw-r--r--app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue19
-rw-r--r--app/assets/javascripts/clusters_list/components/ancestor_notice.vue34
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue39
-rw-r--r--app/assets/javascripts/clusters_list/store/actions.js30
-rw-r--r--app/assets/javascripts/clusters_list/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/clusters_list/store/mutations.js7
-rw-r--r--app/assets/javascripts/clusters_list/store/state.js4
-rw-r--r--app/assets/javascripts/code_navigation/components/popover.vue92
-rw-r--r--app/assets/javascripts/code_navigation/store/actions.js6
-rw-r--r--app/assets/javascripts/code_navigation/utils/index.js1
-rw-r--r--app/assets/javascripts/commit_merge_requests.js4
-rw-r--r--app/assets/javascripts/commons/polyfills.js45
-rw-r--r--app/assets/javascripts/commons/polyfills/custom_event.js21
-rw-r--r--app/assets/javascripts/commons/polyfills/element.js74
-rw-r--r--app/assets/javascripts/commons/polyfills/event.js22
-rw-r--r--app/assets/javascripts/commons/polyfills/nodelist.js14
-rw-r--r--app/assets/javascripts/commons/polyfills/request_idle_callback.js24
-rw-r--r--app/assets/javascripts/commons/polyfills/svg.js11
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue5
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue8
-rw-r--r--app/assets/javascripts/design_management/components/design_destroyer.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue4
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue5
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue2
-rw-r--r--app/assets/javascripts/design_management/components/upload/design_dropzone.vue2
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql6
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql (renamed from app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql)0
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql (renamed from app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql)2
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/diff_refs.fragment.graphql (renamed from app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql)0
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql (renamed from app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql)2
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/create_note.mutation.graphql (renamed from app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql)2
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/destroy_design.mutation.graphql (renamed from app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql)0
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql2
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql2
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql (renamed from app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql)2
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql2
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql (renamed from app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql)2
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/app_data.query.graphql (renamed from app/assets/javascripts/design_management/graphql/queries/appData.query.graphql)0
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql (renamed from app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql)0
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql2
-rw-r--r--app/assets/javascripts/design_management/index.js3
-rw-r--r--app/assets/javascripts/design_management/mixins/all_versions.js2
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue12
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue2
-rw-r--r--app/assets/javascripts/design_management/utils/tracking.js27
-rw-r--r--app/assets/javascripts/design_management_new/components/app.vue3
-rw-r--r--app/assets/javascripts/design_management_new/components/delete_button.vue81
-rw-r--r--app/assets/javascripts/design_management_new/components/design_destroyer.vue67
-rw-r--r--app/assets/javascripts/design_management_new/components/design_note_pin.vue61
-rw-r--r--app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue297
-rw-r--r--app/assets/javascripts/design_management_new/components/design_notes/design_note.vue156
-rw-r--r--app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue141
-rw-r--r--app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue70
-rw-r--r--app/assets/javascripts/design_management_new/components/design_overlay.vue287
-rw-r--r--app/assets/javascripts/design_management_new/components/design_presentation.vue322
-rw-r--r--app/assets/javascripts/design_management_new/components/design_scaler.vue65
-rw-r--r--app/assets/javascripts/design_management_new/components/design_sidebar.vue178
-rw-r--r--app/assets/javascripts/design_management_new/components/image.vue110
-rw-r--r--app/assets/javascripts/design_management_new/components/list/item.vue174
-rw-r--r--app/assets/javascripts/design_management_new/components/toolbar/index.vue124
-rw-r--r--app/assets/javascripts/design_management_new/components/toolbar/pagination.vue83
-rw-r--r--app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue48
-rw-r--r--app/assets/javascripts/design_management_new/components/upload/button.vue59
-rw-r--r--app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue136
-rw-r--r--app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue76
-rw-r--r--app/assets/javascripts/design_management_new/constants.js16
-rw-r--r--app/assets/javascripts/design_management_new/graphql.js45
-rw-r--r--app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql24
-rw-r--r--app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql8
-rw-r--r--app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql29
-rw-r--r--app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql5
-rw-r--r--app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql9
-rw-r--r--app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql3
-rw-r--r--app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql4
-rw-r--r--app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql21
-rw-r--r--app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql17
-rw-r--r--app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql3
-rw-r--r--app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql21
-rw-r--r--app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql6
-rw-r--r--app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql10
-rw-r--r--app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql31
-rw-r--r--app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql26
-rw-r--r--app/assets/javascripts/design_management_new/graphql/typedefs.graphql12
-rw-r--r--app/assets/javascripts/design_management_new/index.js33
-rw-r--r--app/assets/javascripts/design_management_new/mixins/all_designs.js49
-rw-r--r--app/assets/javascripts/design_management_new/mixins/all_versions.js59
-rw-r--r--app/assets/javascripts/design_management_new/pages/design/index.vue367
-rw-r--r--app/assets/javascripts/design_management_new/pages/index.vue346
-rw-r--r--app/assets/javascripts/design_management_new/router/constants.js2
-rw-r--r--app/assets/javascripts/design_management_new/router/index.js32
-rw-r--r--app/assets/javascripts/design_management_new/router/routes.js29
-rw-r--r--app/assets/javascripts/design_management_new/utils/cache_update.js276
-rw-r--r--app/assets/javascripts/design_management_new/utils/design_management_utils.js128
-rw-r--r--app/assets/javascripts/design_management_new/utils/error_messages.js95
-rw-r--r--app/assets/javascripts/design_management_new/utils/tracking.js27
-rw-r--r--app/assets/javascripts/diffs/components/app.vue56
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue5
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue24
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_row.vue13
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue35
-rw-r--r--app/assets/javascripts/diffs/components/diff_table_cell.vue107
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue16
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue15
-rw-r--r--app/assets/javascripts/diffs/components/no_changes.vue8
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue13
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue15
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue3
-rw-r--r--app/assets/javascripts/diffs/constants.js6
-rw-r--r--app/assets/javascripts/diffs/index.js16
-rw-r--r--app/assets/javascripts/diffs/store/actions.js68
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js11
-rw-r--r--app/assets/javascripts/diffs/store/utils.js9
-rw-r--r--app/assets/javascripts/editor/editor_lite.js25
-rw-r--r--app/assets/javascripts/editor/editor_markdown_ext.js99
-rw-r--r--app/assets/javascripts/emoji/index.js88
-rw-r--r--app/assets/javascripts/environments/components/container.vue7
-rw-r--r--app/assets/javascripts/environments/components/empty_state.vue20
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue6
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue2
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js4
-rw-r--r--app/assets/javascripts/environments/mount_show.js2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue47
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue9
-rw-r--r--app/assets/javascripts/error_tracking/queries/details.query.graphql50
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue4
-rw-r--r--app/assets/javascripts/filtered_search/constants.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js8
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_tokenizer.js8
-rw-r--r--app/assets/javascripts/filtered_search/stores/recent_searches_store.js12
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js20
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js22
-rw-r--r--app/assets/javascripts/gl_field_error.js2
-rw-r--r--app/assets/javascripts/gl_field_errors.js23
-rw-r--r--app/assets/javascripts/gl_form.js13
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/user.fragment.graphql7
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue4
-rw-r--r--app/assets/javascripts/groups/components/groups.vue2
-rw-r--r--app/assets/javascripts/groups/components/item_caret.vue2
-rw-r--r--app/assets/javascripts/header.js5
-rw-r--r--app/assets/javascripts/helpers/event_hub_factory.js109
-rw-r--r--app/assets/javascripts/helpers/monitor_helper.js22
-rw-r--r--app/assets/javascripts/ide/components/branches/item.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/success_message.vue2
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue2
-rw-r--r--app/assets/javascripts/ide/components/file_templates/bar.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue9
-rw-r--r--app/assets/javascripts/ide/components/ide_review.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_status_list.vue17
-rw-r--r--app/assets/javascripts/ide/components/jobs/item.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/list.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue2
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/item.vue2
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/list.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue5
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue4
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue3
-rw-r--r--app/assets/javascripts/ide/components/terminal/empty_state.vue2
-rw-r--r--app/assets/javascripts/ide/lib/editor.js7
-rw-r--r--app/assets/javascripts/ide/lib/schemas/index.js4
-rw-r--r--app/assets/javascripts/ide/lib/schemas/json/index.js8
-rw-r--r--app/assets/javascripts/ide/lib/schemas/yaml/gitlab_ci.js4
-rw-r--r--app/assets/javascripts/ide/lib/schemas/yaml/index.js12
-rw-r--r--app/assets/javascripts/ide/queries/getUserPermissions.query.graphql4
-rw-r--r--app/assets/javascripts/ide/services/gql.js23
-rw-r--r--app/assets/javascripts/ide/services/index.js12
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js85
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js9
-rw-r--r--app/assets/javascripts/ide/stores/utils.js7
-rw-r--r--app/assets/javascripts/ide/utils.js15
-rw-r--r--app/assets/javascripts/import_projects/components/bitbucket_status_table.vue2
-rw-r--r--app/assets/javascripts/import_projects/store/actions.js15
-rw-r--r--app/assets/javascripts/importer_status.js6
-rw-r--r--app/assets/javascripts/incidents_settings/components/alerts_form.vue139
-rw-r--r--app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue61
-rw-r--r--app/assets/javascripts/incidents_settings/components/pagerduty_form.vue183
-rw-r--r--app/assets/javascripts/incidents_settings/constants.js83
-rw-r--r--app/assets/javascripts/incidents_settings/incidents_settings_service.js32
-rw-r--r--app/assets/javascripts/incidents_settings/index.js46
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_toggle.vue12
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue22
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue82
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue151
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue58
-rw-r--r--app/assets/javascripts/integrations/edit/components/override_dropdown.vue63
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_fields.vue7
-rw-r--r--app/assets/javascripts/integrations/edit/index.js97
-rw-r--r--app/assets/javascripts/integrations/edit/store/actions.js4
-rw-r--r--app/assets/javascripts/integrations/edit/store/getters.js6
-rw-r--r--app/assets/javascripts/integrations/edit/store/index.js17
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutations.js7
-rw-r--r--app/assets/javascripts/integrations/edit/store/state.js9
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js5
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js81
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js16
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/app.vue2
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/item.vue6
-rw-r--r--app/assets/javascripts/issuables_list/components/issuable.vue121
-rw-r--r--app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue52
-rw-r--r--app/assets/javascripts/issuables_list/components/issuables_list_app.vue187
-rw-r--r--app/assets/javascripts/issuables_list/constants.js23
-rw-r--r--app/assets/javascripts/issuables_list/index.js2
-rw-r--r--app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql5
-rw-r--r--app/assets/javascripts/issue.js10
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue2
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue4
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue2
-rw-r--r--app/assets/javascripts/issue_show/components/issuable_header_warnings.vue28
-rw-r--r--app/assets/javascripts/issue_show/components/pinned_links.vue65
-rw-r--r--app/assets/javascripts/issue_show/constants.js3
-rw-r--r--app/assets/javascripts/issue_show/index.js12
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_app.vue59
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_form.vue184
-rw-r--r--app/assets/javascripts/jira_import/index.js1
-rw-r--r--app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql11
-rw-r--r--app/assets/javascripts/jira_import/utils/jira_import_utils.js34
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/environments_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/erased_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue8
-rw-r--r--app/assets/javascripts/jobs/components/job_log.vue59
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue2
-rw-r--r--app/assets/javascripts/jobs/components/log/collapsible_section.vue2
-rw-r--r--app/assets/javascripts/jobs/components/log/line.vue40
-rw-r--r--app/assets/javascripts/jobs/components/log/line_number.vue57
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue6
-rw-r--r--app/assets/javascripts/jobs/components/manual_variables_form.vue10
-rw-r--r--app/assets/javascripts/jobs/components/stuck_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/trigger_block.vue2
-rw-r--r--app/assets/javascripts/jobs/store/actions.js2
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js16
-rw-r--r--app/assets/javascripts/jobs/store/state.js4
-rw-r--r--app/assets/javascripts/jobs/store/utils.js6
-rw-r--r--app/assets/javascripts/labels_select.js51
-rw-r--r--app/assets/javascripts/lazy_loader.js29
-rw-r--r--app/assets/javascripts/lib/utils/axios_startup_calls.js52
-rw-r--r--app/assets/javascripts/lib/utils/axios_utils.js3
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js97
-rw-r--r--app/assets/javascripts/lib/utils/constants.js2
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js7
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js22
-rw-r--r--app/assets/javascripts/lib/utils/grammar.js18
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js43
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js81
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js13
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue41
-rw-r--r--app/assets/javascripts/logs/components/log_control_buttons.vue37
-rw-r--r--app/assets/javascripts/logs/constants.js6
-rw-r--r--app/assets/javascripts/logs/stores/actions.js38
-rw-r--r--app/assets/javascripts/logs/stores/mutation_types.js4
-rw-r--r--app/assets/javascripts/logs/stores/mutations.js23
-rw-r--r--app/assets/javascripts/logs/stores/state.js10
-rw-r--r--app/assets/javascripts/main.js98
-rw-r--r--app/assets/javascripts/members.js3
-rw-r--r--app/assets/javascripts/merge_request.js11
-rw-r--r--app/assets/javascripts/merge_request_tabs.js2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue5
-rw-r--r--app/assets/javascripts/monitoring/components/charts/options.js18
-rw-r--r--app/assets/javascripts/monitoring/components/charts/single_stat.vue11
-rw-r--r--app/assets/javascripts/monitoring/components/charts/stacked_column.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/create_dashboard_modal.vue66
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue121
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue168
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue67
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue85
-rw-r--r--app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue95
-rw-r--r--app/assets/javascripts/monitoring/components/empty_state.vue74
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue19
-rw-r--r--app/assets/javascripts/monitoring/components/refresh_button.vue163
-rw-r--r--app/assets/javascripts/monitoring/components/variables/dropdown_field.vue (renamed from app/assets/javascripts/monitoring/components/variables/custom_variable.vue)19
-rw-r--r--app/assets/javascripts/monitoring/components/variables/text_field.vue (renamed from app/assets/javascripts/monitoring/components/variables/text_variable.vue)2
-rw-r--r--app/assets/javascripts/monitoring/components/variables_section.vue33
-rw-r--r--app/assets/javascripts/monitoring/constants.js30
-rw-r--r--app/assets/javascripts/monitoring/format_date.js1
-rw-r--r--app/assets/javascripts/monitoring/monitoring_app.js35
-rw-r--r--app/assets/javascripts/monitoring/pages/dashboard_page.vue11
-rw-r--r--app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql18
-rw-r--r--app/assets/javascripts/monitoring/router/constants.js1
-rw-r--r--app/assets/javascripts/monitoring/router/routes.js9
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js124
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js35
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js13
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js67
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js31
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js198
-rw-r--r--app/assets/javascripts/monitoring/stores/variable_mapping.js158
-rw-r--r--app/assets/javascripts/monitoring/utils.js51
-rw-r--r--app/assets/javascripts/namespace_storage_limit_alert.js20
-rw-r--r--app/assets/javascripts/notes.js2
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue42
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue10
-rw-r--r--app/assets/javascripts/notes/components/multiline_comment_form.vue45
-rw-r--r--app/assets/javascripts/notes/components/multiline_comment_utils.js78
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue21
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue18
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue79
-rw-r--r--app/assets/javascripts/notes/components/sort_discussion.vue5
-rw-r--r--app/assets/javascripts/notes/mixins/diff_line_note_form.js5
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js13
-rw-r--r--app/assets/javascripts/notes/stores/actions.js46
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js10
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js12
-rw-r--r--app/assets/javascripts/notes/stores/utils.js8
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js2
-rw-r--r--app/assets/javascripts/pages/admin/clusters/show/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/groups/show/index.js24
-rw-r--r--app/assets/javascripts/pages/admin/projects/index.js22
-rw-r--r--app/assets/javascripts/pages/constants.js1
-rw-r--r--app/assets/javascripts/pages/groups/clusters/show/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js30
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index/index.js14
-rw-r--r--app/assets/javascripts/pages/groups/new/group_path_validator.js10
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js9
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js3
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/clusters/show/cluster_health.js18
-rw-r--r--app/assets/javascripts/pages/projects/clusters/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/commit/pipelines/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue91
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue147
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue20
-rw-r--r--app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js30
-rw-r--r--app/assets/javascripts/pages/projects/issues/service_desk/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js20
-rw-r--r--app/assets/javascripts/pages/projects/metrics_dashboard/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue215
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js26
-rw-r--r--app/assets/javascripts/pages/projects/releases/new/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue2
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js27
-rw-r--r--app/assets/javascripts/pages/projects/tree/show/index.js45
-rw-r--r--app/assets/javascripts/pages/search/init_filtered_search.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/length_validator.js23
-rw-r--r--app/assets/javascripts/pages/sessions/new/username_validator.js2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue15
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js19
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue2
-rw-r--r--app/assets/javascripts/persistent_user_callout.js35
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js3
-rw-r--r--app/assets/javascripts/pipelines/components/dag/constants.js5
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue117
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag_annotations.vue73
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag_graph.vue70
-rw-r--r--app/assets/javascripts/pipelines/components/dag/interactions.js50
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue16
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue53
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue (renamed from app/assets/javascripts/pipelines/components/blank_state.vue)0
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue (renamed from app/assets/javascripts/pipelines/components/empty_state.vue)0
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue (renamed from app/assets/javascripts/pipelines/components/nav_controls.vue)2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue (renamed from app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue)0
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue (renamed from app/assets/javascripts/pipelines/components/pipeline_triggerer.vue)4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue (renamed from app/assets/javascripts/pipelines/components/pipeline_url.vue)30
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue (renamed from app/assets/javascripts/pipelines/components/pipelines.vue)56
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_actions.vue)2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_artifacts.vue)0
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue)0
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_table.vue)8
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_table_row.vue)26
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/stage.vue (renamed from app/assets/javascripts/pipelines/components/stage.vue)14
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue (renamed from app/assets/javascripts/pipelines/components/time_ago.vue)6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue (renamed from app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue)2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue (renamed from app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue)0
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue (renamed from app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue)2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue (renamed from app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue)2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue36
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue30
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue16
-rw-r--r--app/assets/javascripts/pipelines/constants.js1
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines.js12
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js56
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/actions.js45
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/getters.js12
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/index.js13
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js4
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/mutations.js12
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/state.js13
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js2
-rw-r--r--app/assets/javascripts/projects/components/remove_modal.vue108
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue2
-rw-r--r--app/assets/javascripts/projects/project_remove_modal.js24
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue160
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue169
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/event_hub.js3
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/index.js41
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js27
-rw-r--r--app/assets/javascripts/prometheus_alerts/components/reset_key.vue36
-rw-r--r--app/assets/javascripts/prometheus_alerts/index.js3
-rw-r--r--app/assets/javascripts/ref/components/ref_results_section.vue124
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue186
-rw-r--r--app/assets/javascripts/ref/constants.js19
-rw-r--r--app/assets/javascripts/ref/stores/actions.js65
-rw-r--r--app/assets/javascripts/ref/stores/getters.js5
-rw-r--r--app/assets/javascripts/ref/stores/index.js16
-rw-r--r--app/assets/javascripts/ref/stores/mutation_types.js16
-rw-r--r--app/assets/javascripts/ref/stores/mutations.js91
-rw-r--r--app/assets/javascripts/ref/stores/state.js24
-rw-r--r--app/assets/javascripts/registry/explorer/components/delete_button.vue56
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/details_row.vue26
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue77
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue220
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue210
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_item.vue128
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue3
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list.vue3
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue120
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue60
-rw-r--r--app/assets/javascripts/registry/explorer/constants/details.js36
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue29
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue1
-rw-r--r--app/assets/javascripts/registry/settings/components/registry_settings_app.vue48
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue57
-rw-r--r--app/assets/javascripts/registry/settings/constants.js14
-rw-r--r--app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue63
-rw-r--r--app/assets/javascripts/registry/shared/constants.js20
-rw-r--r--app/assets/javascripts/registry/shared/utils.js2
-rw-r--r--app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue2
-rw-r--r--app/assets/javascripts/releases/components/app_new.vue9
-rw-r--r--app/assets/javascripts/releases/components/app_show.vue2
-rw-r--r--app/assets/javascripts/releases/components/evidence_block.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block_assets.vue10
-rw-r--r--app/assets/javascripts/releases/components/release_block_header.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block_metadata.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block_milestone_info.vue4
-rw-r--r--app/assets/javascripts/releases/mount_new.js20
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/state.js12
-rw-r--r--app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue10
-rw-r--r--app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue42
-rw-r--r--app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue83
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/actions.js30
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/getters.js58
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/index.js16
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/mutations.js24
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/state.js15
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js41
-rw-r--r--app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js28
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue25
-rw-r--r--app/assets/javascripts/reports/components/issue_body.js3
-rw-r--r--app/assets/javascripts/reports/components/report_item.vue2
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue10
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue9
-rw-r--r--app/assets/javascripts/reports/components/test_issue_body.vue4
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue3
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue6
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue6
-rw-r--r--app/assets/javascripts/repository/components/web_ide_link.vue47
-rw-r--r--app/assets/javascripts/repository/graphql.js2
-rw-r--r--app/assets/javascripts/repository/index.js22
-rw-r--r--app/assets/javascripts/repository/queries/getFiles.query.graphql4
-rw-r--r--app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql3
-rw-r--r--app/assets/javascripts/search_autocomplete.js (renamed from app/assets/javascripts/global_search_input.js)207
-rw-r--r--app/assets/javascripts/serverless/components/environment_row.vue2
-rw-r--r--app/assets/javascripts/serverless/components/function_details.vue2
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue12
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue24
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue55
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql7
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue2
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js29
-rw-r--r--app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql6
-rw-r--r--app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql6
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue112
-rw-r--r--app/assets/javascripts/snippets/components/show.vue13
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_edit.vue98
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_view.vue70
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue9
-rw-r--r--app/assets/javascripts/snippets/constants.js5
-rw-r--r--app/assets/javascripts/snippets/fragments/project.fragment.graphql4
-rw-r--r--app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql2
-rw-r--r--app/assets/javascripts/snippets/mixins/snippets.js4
-rw-r--r--app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql4
-rw-r--r--app/assets/javascripts/snippets/queries/projectPermissions.query.graphql2
-rw-r--r--app/assets/javascripts/snippets/queries/snippet.query.graphql2
-rw-r--r--app/assets/javascripts/snippets/queries/userPermissions.query.graphql2
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_area.vue51
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js2
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql2
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql2
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql2
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js4
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/typedefs.graphql2
-rw-r--r--app/assets/javascripts/static_site_editor/image_repository.js20
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue7
-rw-r--r--app/assets/javascripts/static_site_editor/services/image_service.js9
-rw-r--r--app/assets/javascripts/static_site_editor/services/parse_source_file.js40
-rw-r--r--app/assets/javascripts/static_site_editor/services/submit_content_changes.js32
-rw-r--r--app/assets/javascripts/user_popovers.js3
-rw-r--r--app/assets/javascripts/users_select/index.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue212
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue70
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue50
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/loading.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue72
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue109
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue121
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue139
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue136
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue31
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue140
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue111
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue33
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue65
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue (renamed from app/assets/javascripts/vue_shared/components/issue/issue_warning.vue)48
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/remove_member_modal.vue78
-rw-r--r--app/assets/javascripts/vue_shared/components/resizable_chart/constants.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue95
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue147
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue75
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js68
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js53
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js (renamed from app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js)16
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js63
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js40
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js16
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js27
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue98
-rw-r--r--app/assets/javascripts/vue_shared/constants.js2
-rw-r--r--app/assets/stylesheets/application.scss29
-rw-r--r--app/assets/stylesheets/application_dark.scss4
-rw-r--r--app/assets/stylesheets/behaviors.scss3
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss19
-rw-r--r--app/assets/stylesheets/components/design_management/design.scss12
-rw-r--r--app/assets/stylesheets/components/design_management/design_list_item.scss5
-rw-r--r--app/assets/stylesheets/components/popover.scss49
-rw-r--r--app/assets/stylesheets/components/ref_selector.scss17
-rw-r--r--app/assets/stylesheets/components/related_items_list.scss29
-rw-r--r--app/assets/stylesheets/components/rich_content_editor.scss53
-rw-r--r--app/assets/stylesheets/disable_animations.scss2
-rw-r--r--app/assets/stylesheets/emoji_sprites.scss1796
-rw-r--r--app/assets/stylesheets/errors.scss5
-rw-r--r--app/assets/stylesheets/framework.scss2
-rw-r--r--app/assets/stylesheets/framework/awards.scss2
-rw-r--r--app/assets/stylesheets/framework/broadcast_messages.scss2
-rw-r--r--app/assets/stylesheets/framework/buttons.scss21
-rw-r--r--app/assets/stylesheets/framework/common.scss54
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss6
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss21
-rw-r--r--app/assets/stylesheets/framework/files.scss23
-rw-r--r--app/assets/stylesheets/framework/filters.scss8
-rw-r--r--app/assets/stylesheets/framework/forms.scss6
-rw-r--r--app/assets/stylesheets/framework/gitlab_theme.scss27
-rw-r--r--app/assets/stylesheets/framework/header.scss6
-rw-r--r--app/assets/stylesheets/framework/images.scss2
-rw-r--r--app/assets/stylesheets/framework/issue_box.scss1
-rw-r--r--app/assets/stylesheets/framework/job_log.scss2
-rw-r--r--app/assets/stylesheets/framework/memory_graph.scss2
-rw-r--r--app/assets/stylesheets/framework/mixins.scss3
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss4
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss2
-rw-r--r--app/assets/stylesheets/framework/stacked_progress_bar.scss2
-rw-r--r--app/assets/stylesheets/framework/system_messages.scss3
-rw-r--r--app/assets/stylesheets/framework/timeline.scss8
-rw-r--r--app/assets/stylesheets/framework/typography.scss6
-rw-r--r--app/assets/stylesheets/framework/variables.scss53
-rw-r--r--app/assets/stylesheets/framework/variables_overrides.scss8
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss17
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss7
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss9
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss12
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss12
-rw-r--r--app/assets/stylesheets/highlight/themes/white.scss2
-rw-r--r--app/assets/stylesheets/mailer.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss16
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss2
-rw-r--r--app/assets/stylesheets/pages/alert_management/details.scss4
-rw-r--r--app/assets/stylesheets/pages/alert_management/list.scss33
-rw-r--r--app/assets/stylesheets/pages/boards.scss15
-rw-r--r--app/assets/stylesheets/pages/branches.scss4
-rw-r--r--app/assets/stylesheets/pages/builds.scss2
-rw-r--r--app/assets/stylesheets/pages/container_registry.scss47
-rw-r--r--app/assets/stylesheets/pages/diff.scss3
-rw-r--r--app/assets/stylesheets/pages/editor.scss5
-rw-r--r--app/assets/stylesheets/pages/environment_logs.scss4
-rw-r--r--app/assets/stylesheets/pages/issuable.scss20
-rw-r--r--app/assets/stylesheets/pages/issues/issue_count_badge.scss2
-rw-r--r--app/assets/stylesheets/pages/issues/issues_list.scss5
-rw-r--r--app/assets/stylesheets/pages/labels.scss68
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss42
-rw-r--r--app/assets/stylesheets/pages/note_form.scss16
-rw-r--r--app/assets/stylesheets/pages/notes.scss9
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss13
-rw-r--r--app/assets/stylesheets/pages/profiles/preferences.scss5
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss2
-rw-r--r--app/assets/stylesheets/pages/runners.scss3
-rw-r--r--app/assets/stylesheets/pages/service_desk.scss7
-rw-r--r--app/assets/stylesheets/pages/settings.scss2
-rw-r--r--app/assets/stylesheets/pages/sherlock.scss6
-rw-r--r--app/assets/stylesheets/pages/wiki.scss4
-rw-r--r--app/assets/stylesheets/performance_bar.scss2
-rw-r--r--app/assets/stylesheets/snippets.scss7
-rw-r--r--app/assets/stylesheets/themes/_dark.scss10
-rw-r--r--app/assets/stylesheets/utilities.scss21
-rw-r--r--app/controllers/admin/application_settings_controller.rb1
-rw-r--r--app/controllers/admin/clusters_controller.rb9
-rw-r--r--app/controllers/admin/jobs_controller.rb4
-rw-r--r--app/controllers/admin/services_controller.rb11
-rw-r--r--app/controllers/application_controller.rb12
-rw-r--r--app/controllers/autocomplete_controller.rb16
-rw-r--r--app/controllers/chaos_controller.rb1
-rw-r--r--app/controllers/clusters/clusters_controller.rb25
-rw-r--r--app/controllers/concerns/controller_with_feature_category.rb45
-rw-r--r--app/controllers/concerns/controller_with_feature_category/config.rb38
-rw-r--r--app/controllers/concerns/filters_events.rb14
-rw-r--r--app/controllers/concerns/integrations_actions.rb8
-rw-r--r--app/controllers/concerns/issuable_actions.rb12
-rw-r--r--app/controllers/concerns/issuable_collections.rb61
-rw-r--r--app/controllers/concerns/known_sign_in.rb19
-rw-r--r--app/controllers/concerns/membership_actions.rb5
-rw-r--r--app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb53
-rw-r--r--app/controllers/concerns/metrics_dashboard.rb14
-rw-r--r--app/controllers/concerns/notes_actions.rb84
-rw-r--r--app/controllers/concerns/renders_member_access.rb6
-rw-r--r--app/controllers/concerns/renders_projects_list.rb13
-rw-r--r--app/controllers/concerns/service_params.rb4
-rw-r--r--app/controllers/concerns/snippets/blobs_actions.rb53
-rw-r--r--app/controllers/concerns/snippets/send_blob.rb22
-rw-r--r--app/controllers/concerns/snippets_actions.rb19
-rw-r--r--app/controllers/concerns/snippets_sort.rb9
-rw-r--r--app/controllers/concerns/wiki_actions.rb49
-rw-r--r--app/controllers/dashboard/projects_controller.rb3
-rw-r--r--app/controllers/dashboard/snippets_controller.rb3
-rw-r--r--app/controllers/dashboard/todos_controller.rb3
-rw-r--r--app/controllers/dashboard_controller.rb1
-rw-r--r--app/controllers/explore/projects_controller.rb1
-rw-r--r--app/controllers/groups/application_controller.rb8
-rw-r--r--app/controllers/groups/boards_controller.rb1
-rw-r--r--app/controllers/groups/clusters_controller.rb10
-rw-r--r--app/controllers/groups/runners_controller.rb12
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb8
-rw-r--r--app/controllers/groups/variables_controller.rb4
-rw-r--r--app/controllers/groups_controller.rb1
-rw-r--r--app/controllers/ide_controller.rb1
-rw-r--r--app/controllers/import/base_controller.rb11
-rw-r--r--app/controllers/import/bitbucket_controller.rb17
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb46
-rw-r--r--app/controllers/import/fogbugz_controller.rb18
-rw-r--r--app/controllers/import/gitea_controller.rb14
-rw-r--r--app/controllers/import/github_controller.rb99
-rw-r--r--app/controllers/import/gitlab_controller.rb20
-rw-r--r--app/controllers/instance_statistics/cohorts_controller.rb4
-rw-r--r--app/controllers/instance_statistics/dev_ops_score_controller.rb4
-rw-r--r--app/controllers/invites_controller.rb19
-rw-r--r--app/controllers/oauth/applications_controller.rb6
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb2
-rw-r--r--app/controllers/profiles/keys_controller.rb21
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb10
-rw-r--r--app/controllers/profiles/preferences_controller.rb1
-rw-r--r--app/controllers/projects/application_controller.rb6
-rw-r--r--app/controllers/projects/blob_controller.rb7
-rw-r--r--app/controllers/projects/boards_controller.rb1
-rw-r--r--app/controllers/projects/ci/lints_controller.rb2
-rw-r--r--app/controllers/projects/clusters_controller.rb11
-rw-r--r--app/controllers/projects/confluences_controller.rb14
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb3
-rw-r--r--app/controllers/projects/deployments_controller.rb1
-rw-r--r--app/controllers/projects/environments/prometheus_api_controller.rb46
-rw-r--r--app/controllers/projects/environments_controller.rb4
-rw-r--r--app/controllers/projects/forks_controller.rb1
-rw-r--r--app/controllers/projects/graphs_controller.rb3
-rw-r--r--app/controllers/projects/imports_controller.rb7
-rw-r--r--app/controllers/projects/incident_management/pager_duty_incidents_controller.rb35
-rw-r--r--app/controllers/projects/issues_controller.rb31
-rw-r--r--app/controllers/projects/jobs_controller.rb10
-rw-r--r--app/controllers/projects/logs_controller.rb46
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb9
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb4
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb9
-rw-r--r--app/controllers/projects/merge_requests_controller.rb29
-rw-r--r--app/controllers/projects/metrics_dashboard_controller.rb33
-rw-r--r--app/controllers/projects/pipelines/application_controller.rb24
-rw-r--r--app/controllers/projects/pipelines/stages_controller.rb29
-rw-r--r--app/controllers/projects/pipelines/tests_controller.rb59
-rw-r--r--app/controllers/projects/pipelines_controller.rb25
-rw-r--r--app/controllers/projects/refs_controller.rb15
-rw-r--r--app/controllers/projects/releases_controller.rb21
-rw-r--r--app/controllers/projects/service_desk_controller.rb45
-rw-r--r--app/controllers/projects/services_controller.rb9
-rw-r--r--app/controllers/projects/settings/operations_controller.rb29
-rw-r--r--app/controllers/projects/snippets/blobs_controller.rb5
-rw-r--r--app/controllers/projects/snippets_controller.rb6
-rw-r--r--app/controllers/projects/stages_controller.rb25
-rw-r--r--app/controllers/projects/static_site_editor_controller.rb3
-rw-r--r--app/controllers/projects/tree_controller.rb18
-rw-r--r--app/controllers/projects/variables_controller.rb4
-rw-r--r--app/controllers/projects/wikis_controller.rb1
-rw-r--r--app/controllers/projects_controller.rb9
-rw-r--r--app/controllers/registrations/experience_levels_controller.rb9
-rw-r--r--app/controllers/registrations_controller.rb8
-rw-r--r--app/controllers/root_controller.rb5
-rw-r--r--app/controllers/search_controller.rb15
-rw-r--r--app/controllers/snippets/blobs_controller.rb7
-rw-r--r--app/controllers/snippets_controller.rb2
-rw-r--r--app/controllers/users_controller.rb7
-rw-r--r--app/finders/branches_finder.rb26
-rw-r--r--app/finders/ci/pipelines_finder.rb2
-rw-r--r--app/finders/ci/pipelines_for_merge_request_finder.rb44
-rw-r--r--app/finders/ci/runner_jobs_finder.rb2
-rw-r--r--app/finders/ci/variables_finder.rb31
-rw-r--r--app/finders/events_finder.rb9
-rw-r--r--app/finders/group_projects_finder.rb55
-rw-r--r--app/finders/issuable_finder/params.rb4
-rw-r--r--app/finders/issues_finder.rb1
-rw-r--r--app/finders/issues_finder/params.rb17
-rw-r--r--app/finders/notes_finder.rb9
-rw-r--r--app/finders/packages/composer/packages_finder.rb16
-rw-r--r--app/finders/packages/conan/package_file_finder.rb28
-rw-r--r--app/finders/packages/conan/package_finder.rb32
-rw-r--r--app/finders/packages/go/module_finder.rb29
-rw-r--r--app/finders/packages/go/version_finder.rb44
-rw-r--r--app/finders/packages/group_packages_finder.rb70
-rw-r--r--app/finders/packages/maven/package_finder.rb62
-rw-r--r--app/finders/packages/npm/package_finder.rb29
-rw-r--r--app/finders/packages/nuget/package_finder.rb31
-rw-r--r--app/finders/packages/package_file_finder.rb36
-rw-r--r--app/finders/packages/package_finder.rb16
-rw-r--r--app/finders/packages/packages_finder.rb41
-rw-r--r--app/finders/packages/tags_finder.rb26
-rw-r--r--app/finders/personal_access_tokens_finder.rb2
-rw-r--r--app/finders/projects_finder.rb10
-rw-r--r--app/finders/resource_milestone_event_finder.rb69
-rw-r--r--app/finders/resource_state_event_finder.rb30
-rw-r--r--app/finders/snippets_finder.rb23
-rw-r--r--app/finders/todos_finder.rb23
-rw-r--r--app/graphql/mutations/alert_management/alerts/todo/create.rb30
-rw-r--r--app/graphql/mutations/alert_management/base.rb5
-rw-r--r--app/graphql/mutations/alert_management/update_alert_status.rb4
-rw-r--r--app/graphql/mutations/award_emojis/add.rb2
-rw-r--r--app/graphql/mutations/award_emojis/remove.rb2
-rw-r--r--app/graphql/mutations/award_emojis/toggle.rb2
-rw-r--r--app/graphql/mutations/base_mutation.rb2
-rw-r--r--app/graphql/mutations/concerns/mutations/resolves_issuable.rb29
-rw-r--r--app/graphql/mutations/container_expiration_policies/update.rb10
-rw-r--r--app/graphql/mutations/issues/set_locked.rb26
-rw-r--r--app/graphql/mutations/jira_import/start.rb9
-rw-r--r--app/graphql/mutations/merge_requests/update.rb39
-rw-r--r--app/graphql/mutations/notes/create/base.rb8
-rw-r--r--app/graphql/mutations/snippets/create.rb26
-rw-r--r--app/graphql/mutations/snippets/update.rb18
-rw-r--r--app/graphql/mutations/todos/mark_all_done.rb6
-rw-r--r--app/graphql/mutations/todos/restore_many.rb8
-rw-r--r--app/graphql/resolvers/base_resolver.rb5
-rw-r--r--app/graphql/resolvers/ci_configuration/sast_resolver.rb17
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb28
-rw-r--r--app/graphql/resolvers/environments_resolver.rb2
-rw-r--r--app/graphql/resolvers/issues_resolver.rb17
-rw-r--r--app/graphql/resolvers/last_commit_resolver.rb2
-rw-r--r--app/graphql/resolvers/milestone_resolver.rb6
-rw-r--r--app/graphql/resolvers/packages_resolver.rb19
-rw-r--r--app/graphql/resolvers/projects/jira_projects_resolver.rb42
-rw-r--r--app/graphql/resolvers/projects_resolver.rb2
-rw-r--r--app/graphql/resolvers/release_resolver.rb2
-rw-r--r--app/graphql/resolvers/releases_resolver.rb2
-rw-r--r--app/graphql/types/alert_management/alert_sort_enum.rb8
-rw-r--r--app/graphql/types/alert_management/alert_type.rb6
-rw-r--r--app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb25
-rw-r--r--app/graphql/types/ci_configuration/sast/entity_type.rb34
-rw-r--r--app/graphql/types/ci_configuration/sast/options_entity_type.rb19
-rw-r--r--app/graphql/types/ci_configuration/sast/type.rb22
-rw-r--r--app/graphql/types/container_expiration_policy_type.rb4
-rw-r--r--app/graphql/types/deprecated_mutations.rb19
-rw-r--r--app/graphql/types/diff_stats_summary_type.rb25
-rw-r--r--app/graphql/types/diff_stats_type.rb19
-rw-r--r--app/graphql/types/error_tracking/sentry_detailed_error_type.rb8
-rw-r--r--app/graphql/types/error_tracking/sentry_error_collection_type.rb2
-rw-r--r--app/graphql/types/global_id_type.rb64
-rw-r--r--app/graphql/types/issue_connection_type.rb13
-rw-r--r--app/graphql/types/issue_type.rb4
-rw-r--r--app/graphql/types/jira_user_type.rb6
-rw-r--r--app/graphql/types/jira_users_mapping_input_type.rb18
-rw-r--r--app/graphql/types/merge_request_type.rb26
-rw-r--r--app/graphql/types/milestone_stats_type.rb16
-rw-r--r--app/graphql/types/milestone_type.rb11
-rw-r--r--app/graphql/types/mutation_type.rb4
-rw-r--r--app/graphql/types/namespace_type.rb2
-rw-r--r--app/graphql/types/notes/note_type.rb6
-rw-r--r--app/graphql/types/package_type.rb16
-rw-r--r--app/graphql/types/package_type_enum.rb9
-rw-r--r--app/graphql/types/project_statistics_type.rb2
-rw-r--r--app/graphql/types/project_type.rb19
-rw-r--r--app/graphql/types/projects/services/jira_service_type.rb2
-rw-r--r--app/graphql/types/query_type.rb4
-rw-r--r--app/graphql/types/release_asset_link_type.rb (renamed from app/graphql/types/release_link_type.rb)7
-rw-r--r--app/graphql/types/release_asset_link_type_enum.rb (renamed from app/graphql/types/release_link_type_enum.rb)4
-rw-r--r--app/graphql/types/release_assets_type.rb5
-rw-r--r--app/graphql/types/release_links_type.rb23
-rw-r--r--app/graphql/types/release_source_type.rb3
-rw-r--r--app/graphql/types/release_type.rb14
-rw-r--r--app/graphql/types/root_storage_statistics_type.rb1
-rw-r--r--app/graphql/types/todo_target_enum.rb1
-rw-r--r--app/graphql/types/tree/blob_type.rb2
-rw-r--r--app/graphql/types/untrusted_regexp.rb22
-rw-r--r--app/helpers/analytics/unique_visits_helper.rb32
-rw-r--r--app/helpers/application_helper.rb9
-rw-r--r--app/helpers/application_settings_helper.rb10
-rw-r--r--app/helpers/auto_devops_helper.rb4
-rw-r--r--app/helpers/blob_helper.rb3
-rw-r--r--app/helpers/builds_helper.rb38
-rw-r--r--app/helpers/ci/builds_helper.rb40
-rw-r--r--app/helpers/ci/jobs_helper.rb23
-rw-r--r--app/helpers/ci/pipeline_schedules_helper.rb15
-rw-r--r--app/helpers/ci/runners_helper.rb45
-rw-r--r--app/helpers/ci/status_helper.rb148
-rw-r--r--app/helpers/ci/variables_helper.rb54
-rw-r--r--app/helpers/ci_status_helper.rb146
-rw-r--r--app/helpers/ci_variables_helper.rb52
-rw-r--r--app/helpers/clusters_helper.rb8
-rw-r--r--app/helpers/commits_helper.rb12
-rw-r--r--app/helpers/cookies_helper.rb16
-rw-r--r--app/helpers/dashboard_helper.rb2
-rw-r--r--app/helpers/diff_helper.rb7
-rw-r--r--app/helpers/dropdowns_helper.rb5
-rw-r--r--app/helpers/environments_helper.rb26
-rw-r--r--app/helpers/events_helper.rb64
-rw-r--r--app/helpers/export_helper.rb2
-rw-r--r--app/helpers/gitlab_routing_helper.rb30
-rw-r--r--app/helpers/groups_helper.rb4
-rw-r--r--app/helpers/icons_helper.rb19
-rw-r--r--app/helpers/ide_helper.rb4
-rw-r--r--app/helpers/import_helper.rb6
-rw-r--r--app/helpers/issuables_helper.rb17
-rw-r--r--app/helpers/issues_helper.rb7
-rw-r--r--app/helpers/jobs_helper.rb19
-rw-r--r--app/helpers/markup_helper.rb1
-rw-r--r--app/helpers/members_helper.rb8
-rw-r--r--app/helpers/merge_requests_helper.rb2
-rw-r--r--app/helpers/namespaces_helper.rb39
-rw-r--r--app/helpers/nav_helper.rb2
-rw-r--r--app/helpers/notify_helper.rb11
-rw-r--r--app/helpers/onboarding_experiment_helper.rb9
-rw-r--r--app/helpers/operations_helper.rb54
-rw-r--r--app/helpers/pipeline_schedules_helper.rb13
-rw-r--r--app/helpers/preferences_helper.rb5
-rw-r--r--app/helpers/projects/alert_management_helper.rb16
-rw-r--r--app/helpers/projects_helper.rb35
-rw-r--r--app/helpers/releases_helper.rb27
-rw-r--r--app/helpers/runners_helper.rb43
-rw-r--r--app/helpers/search_helper.rb109
-rw-r--r--app/helpers/services_helper.rb75
-rw-r--r--app/helpers/storage_helper.rb5
-rw-r--r--app/helpers/system_note_helper.rb4
-rw-r--r--app/helpers/todos_helper.rb20
-rw-r--r--app/helpers/tree_helper.rb2
-rw-r--r--app/helpers/wiki_helper.rb44
-rw-r--r--app/mailers/emails/merge_requests.rb7
-rw-r--r--app/mailers/emails/service_desk.rb92
-rw-r--r--app/mailers/notify.rb1
-rw-r--r--app/mailers/previews/notify_preview.rb16
-rw-r--r--app/models/active_session.rb17
-rw-r--r--app/models/alert_management/alert.rb50
-rw-r--r--app/models/application_record.rb8
-rw-r--r--app/models/application_setting_implementation.rb10
-rw-r--r--app/models/approval.rb11
-rw-r--r--app/models/audit_event.rb18
-rw-r--r--app/models/blob_viewer/image.rb2
-rw-r--r--app/models/blob_viewer/notebook.rb2
-rw-r--r--app/models/blob_viewer/open_api.rb4
-rw-r--r--app/models/blob_viewer/rich.rb2
-rw-r--r--app/models/blob_viewer/svg.rb2
-rw-r--r--app/models/ci/build.rb22
-rw-r--r--app/models/ci/build_metadata.rb1
-rw-r--r--app/models/ci/build_need.rb2
-rw-r--r--app/models/ci/build_trace.rb26
-rw-r--r--app/models/ci/build_trace_chunks/redis.rb5
-rw-r--r--app/models/ci/instance_variable.rb8
-rw-r--r--app/models/ci/job_artifact.rb53
-rw-r--r--app/models/ci/pipeline.rb144
-rw-r--r--app/models/ci/pipeline_enums.rb5
-rw-r--r--app/models/ci/pipeline_message.rb25
-rw-r--r--app/models/ci/ref.rb2
-rw-r--r--app/models/ci/runner.rb4
-rw-r--r--app/models/ci/stage.rb6
-rw-r--r--app/models/ci/variable.rb2
-rw-r--r--app/models/clusters/applications/cilium.rb21
-rw-r--r--app/models/clusters/applications/prometheus.rb3
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/cluster.rb71
-rw-r--r--app/models/clusters/platforms/kubernetes.rb11
-rw-r--r--app/models/commit.rb6
-rw-r--r--app/models/commit_collection.rb11
-rw-r--r--app/models/commit_status.rb3
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stage.rb2
-rw-r--r--app/models/concerns/approvable_base.rb16
-rw-r--r--app/models/concerns/avatarable.rb6
-rw-r--r--app/models/concerns/bulk_insert_safe.rb8
-rw-r--r--app/models/concerns/ci/contextable.rb2
-rw-r--r--app/models/concerns/ci/has_status.rb168
-rw-r--r--app/models/concerns/ci/metadatable.rb2
-rw-r--r--app/models/concerns/deployment_platform.rb22
-rw-r--r--app/models/concerns/has_repository.rb8
-rw-r--r--app/models/concerns/has_status.rb166
-rw-r--r--app/models/concerns/integration.rb14
-rw-r--r--app/models/concerns/issuable.rb6
-rw-r--r--app/models/concerns/noteable.rb4
-rw-r--r--app/models/concerns/partitioned_table.rb21
-rw-r--r--app/models/concerns/reactive_caching.rb8
-rw-r--r--app/models/concerns/routable.rb9
-rw-r--r--app/models/concerns/update_project_statistics.rb12
-rw-r--r--app/models/custom_emoji.rb22
-rw-r--r--app/models/deploy_keys_project.rb1
-rw-r--r--app/models/diff_viewer/image.rb2
-rw-r--r--app/models/environment.rb23
-rw-r--r--app/models/epic.rb2
-rw-r--r--app/models/event.rb4
-rw-r--r--app/models/event_collection.rb9
-rw-r--r--app/models/group.rb53
-rw-r--r--app/models/incident_management/project_incident_management_setting.rb19
-rw-r--r--app/models/issue.rb6
-rw-r--r--app/models/issue_assignee.rb5
-rw-r--r--app/models/iteration.rb5
-rw-r--r--app/models/label.rb4
-rw-r--r--app/models/lfs_objects_project.rb2
-rw-r--r--app/models/member.rb9
-rw-r--r--app/models/members/group_member.rb9
-rw-r--r--app/models/merge_request.rb71
-rw-r--r--app/models/merge_request_assignee.rb4
-rw-r--r--app/models/merge_request_diff.rb8
-rw-r--r--app/models/namespace.rb17
-rw-r--r--app/models/namespace/root_storage_size.rb31
-rw-r--r--app/models/namespace/root_storage_statistics.rb26
-rw-r--r--app/models/namespace/traversal_hierarchy.rb84
-rw-r--r--app/models/namespace_setting.rb9
-rw-r--r--app/models/note.rb9
-rw-r--r--app/models/packages.rb6
-rw-r--r--app/models/packages/build_info.rb6
-rw-r--r--app/models/packages/composer/metadatum.rb14
-rw-r--r--app/models/packages/conan.rb8
-rw-r--r--app/models/packages/conan/file_metadatum.rb32
-rw-r--r--app/models/packages/conan/metadatum.rb41
-rw-r--r--app/models/packages/dependency.rb47
-rw-r--r--app/models/packages/dependency_link.rb19
-rw-r--r--app/models/packages/go/module.rb93
-rw-r--r--app/models/packages/go/module_version.rb115
-rw-r--r--app/models/packages/maven.rb8
-rw-r--r--app/models/packages/maven/metadatum.rb28
-rw-r--r--app/models/packages/nuget.rb8
-rw-r--r--app/models/packages/nuget/dependency_link_metadatum.rb19
-rw-r--r--app/models/packages/nuget/metadatum.rb27
-rw-r--r--app/models/packages/package.rb195
-rw-r--r--app/models/packages/package_file.rb56
-rw-r--r--app/models/packages/pypi.rb8
-rw-r--r--app/models/packages/pypi/metadatum.rb19
-rw-r--r--app/models/packages/sem_ver.rb54
-rw-r--r--app/models/packages/tag.rb18
-rw-r--r--app/models/performance_monitoring/prometheus_dashboard.rb20
-rw-r--r--app/models/performance_monitoring/prometheus_panel.rb11
-rw-r--r--app/models/performance_monitoring/prometheus_panel_group.rb11
-rw-r--r--app/models/personal_access_token.rb8
-rw-r--r--app/models/plan.rb2
-rw-r--r--app/models/plan_limits.rb33
-rw-r--r--app/models/product_analytics_event.rb22
-rw-r--r--app/models/project.rb100
-rw-r--r--app/models/project_services/alerts_service.rb2
-rw-r--r--app/models/project_services/bugzilla_service.rb4
-rw-r--r--app/models/project_services/confluence_service.rb91
-rw-r--r--app/models/project_services/custom_issue_tracker_service.rb6
-rw-r--r--app/models/project_services/gitlab_issue_tracker_service.rb4
-rw-r--r--app/models/project_services/issue_tracker_service.rb32
-rw-r--r--app/models/project_services/jira_service.rb12
-rw-r--r--app/models/project_services/prometheus_service.rb44
-rw-r--r--app/models/project_services/redmine_service.rb4
-rw-r--r--app/models/project_services/youtrack_service.rb5
-rw-r--r--app/models/project_setting.rb15
-rw-r--r--app/models/project_statistics.rb38
-rw-r--r--app/models/prometheus_alert.rb1
-rw-r--r--app/models/prometheus_metric.rb1
-rw-r--r--app/models/repository.rb21
-rw-r--r--app/models/resource_event.rb1
-rw-r--r--app/models/resource_state_event.rb6
-rw-r--r--app/models/service.rb38
-rw-r--r--app/models/service_desk_setting.rb30
-rw-r--r--app/models/snippet.rb13
-rw-r--r--app/models/snippet_input_action.rb9
-rw-r--r--app/models/snippet_statistics.rb69
-rw-r--r--app/models/state_note.rb34
-rw-r--r--app/models/suggestion.rb21
-rw-r--r--app/models/synthetic_note.rb18
-rw-r--r--app/models/system_note_metadata.rb3
-rw-r--r--app/models/todo.rb24
-rw-r--r--app/models/user.rb47
-rw-r--r--app/models/user_callout_enums.rb3
-rw-r--r--app/models/user_detail.rb26
-rw-r--r--app/models/webauthn_registration.rb11
-rw-r--r--app/models/wiki_page.rb4
-rw-r--r--app/policies/base_policy.rb6
-rw-r--r--app/policies/concerns/find_group_projects.rb4
-rw-r--r--app/policies/concerns/policy_actor.rb4
-rw-r--r--app/policies/global_policy.rb3
-rw-r--r--app/policies/group_policy.rb17
-rw-r--r--app/policies/merge_request_policy.rb4
-rw-r--r--app/policies/packages/package_policy.rb6
-rw-r--r--app/policies/project_member_policy.rb5
-rw-r--r--app/policies/project_policy.rb28
-rw-r--r--app/policies/releases/source_policy.rb6
-rw-r--r--app/presenters/alert_management/alert_presenter.rb101
-rw-r--r--app/presenters/alert_management/prometheus_alert_presenter.rb27
-rw-r--r--app/presenters/ci/pipeline_presenter.rb11
-rw-r--r--app/presenters/clusterable_presenter.rb20
-rw-r--r--app/presenters/clusters/cluster_presenter.rb42
-rw-r--r--app/presenters/group_clusterable_presenter.rb4
-rw-r--r--app/presenters/instance_clusterable_presenter.rb4
-rw-r--r--app/presenters/merge_request_presenter.rb18
-rw-r--r--app/presenters/packages/composer/packages_presenter.rb71
-rw-r--r--app/presenters/packages/conan/package_presenter.rb114
-rw-r--r--app/presenters/packages/detail/package_presenter.rb75
-rw-r--r--app/presenters/packages/go/module_version_presenter.rb19
-rw-r--r--app/presenters/packages/npm/package_presenter.rb87
-rw-r--r--app/presenters/packages/nuget/package_metadata_presenter.rb25
-rw-r--r--app/presenters/packages/nuget/packages_metadata_presenter.rb63
-rw-r--r--app/presenters/packages/nuget/packages_versions_presenter.rb15
-rw-r--r--app/presenters/packages/nuget/presenter_helpers.rb113
-rw-r--r--app/presenters/packages/nuget/search_results_presenter.rb56
-rw-r--r--app/presenters/packages/nuget/service_index_presenter.rb85
-rw-r--r--app/presenters/packages/pypi/package_presenter.rb75
-rw-r--r--app/presenters/project_clusterable_presenter.rb4
-rw-r--r--app/presenters/project_presenter.rb2
-rw-r--r--app/presenters/projects/prometheus/alert_presenter.rb38
-rw-r--r--app/presenters/release_presenter.rb14
-rw-r--r--app/presenters/snippet_blob_presenter.rb14
-rw-r--r--app/serializers/build_trace_entity.rb3
-rw-r--r--app/serializers/ci/group_variable_entity.rb6
-rw-r--r--app/serializers/ci/group_variable_serializer.rb7
-rw-r--r--app/serializers/ci/variable_entity.rb7
-rw-r--r--app/serializers/ci/variable_serializer.rb7
-rw-r--r--app/serializers/cluster_application_entity.rb2
-rw-r--r--app/serializers/cluster_entity.rb4
-rw-r--r--app/serializers/cluster_serializer.rb1
-rw-r--r--app/serializers/deploy_key_entity.rb5
-rw-r--r--app/serializers/diff_file_base_entity.rb8
-rw-r--r--app/serializers/evidences/release_entity.rb2
-rw-r--r--app/serializers/fork_namespace_entity.rb52
-rw-r--r--app/serializers/fork_namespace_serializer.rb5
-rw-r--r--app/serializers/group_variable_entity.rb4
-rw-r--r--app/serializers/group_variable_serializer.rb5
-rw-r--r--app/serializers/merge_request_poll_cached_widget_entity.rb24
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb16
-rw-r--r--app/serializers/merge_request_widget_entity.rb34
-rw-r--r--app/serializers/pipeline_entity.rb4
-rw-r--r--app/serializers/pipeline_serializer.rb4
-rw-r--r--app/serializers/service_event_entity.rb2
-rw-r--r--app/serializers/service_field_entity.rb2
-rw-r--r--app/serializers/stage_entity.rb4
-rw-r--r--app/serializers/suggestion_entity.rb31
-rw-r--r--app/serializers/test_report_summary_entity.rb7
-rw-r--r--app/serializers/test_report_summary_serializer.rb5
-rw-r--r--app/serializers/test_suite_entity.rb8
-rw-r--r--app/serializers/test_suite_serializer.rb5
-rw-r--r--app/serializers/test_suite_summary_entity.rb7
-rw-r--r--app/serializers/triggered_pipeline_entity.rb8
-rw-r--r--app/serializers/variable_entity.rb5
-rw-r--r--app/serializers/variable_serializer.rb5
-rw-r--r--app/services/access_token_validation_service.rb10
-rw-r--r--app/services/admin/propagate_integration_service.rb18
-rw-r--r--app/services/alert_management/alerts/todo/create_service.rb51
-rw-r--r--app/services/alert_management/alerts/update_service.rb123
-rw-r--r--app/services/alert_management/create_alert_issue_service.rb65
-rw-r--r--app/services/alert_management/process_prometheus_alert_service.rb18
-rw-r--r--app/services/alert_management/update_alert_status_service.rb63
-rw-r--r--app/services/audit_event_service.rb4
-rw-r--r--app/services/authorized_project_update/project_create_service.rb2
-rw-r--r--app/services/authorized_project_update/project_group_link_create_service.rb70
-rw-r--r--app/services/auto_merge/base_service.rb6
-rw-r--r--app/services/auto_merge/merge_when_pipeline_succeeds_service.rb8
-rw-r--r--app/services/branches/delete_service.rb7
-rw-r--r--app/services/ci/authorize_job_artifact_service.rb53
-rw-r--r--app/services/ci/create_job_artifacts_service.rb122
-rw-r--r--app/services/ci/create_pipeline_service.rb29
-rw-r--r--app/services/ci/destroy_expired_job_artifacts_service.rb2
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb2
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb2
-rw-r--r--app/services/ci/pipeline_processing/legacy_processing_service.rb4
-rw-r--r--app/services/ci/process_pipeline_service.rb10
-rw-r--r--app/services/ci/register_job_service.rb19
-rw-r--r--app/services/ci/retry_build_service.rb8
-rw-r--r--app/services/ci/unlock_artifacts_service.rb33
-rw-r--r--app/services/clusters/create_service.rb11
-rw-r--r--app/services/clusters/parse_cluster_applications_artifact_service.rb2
-rw-r--r--app/services/concerns/exclusive_lease_guard.rb2
-rw-r--r--app/services/concerns/incident_management/settings.rb2
-rw-r--r--app/services/deploy_keys/collect_keys_service.rb27
-rw-r--r--app/services/event_create_service.rb58
-rw-r--r--app/services/files/base_service.rb2
-rw-r--r--app/services/git/branch_push_service.rb7
-rw-r--r--app/services/git/tag_push_service.rb18
-rw-r--r--app/services/git/wiki_push_service.rb2
-rw-r--r--app/services/gpg_keys/destroy_service.rb9
-rw-r--r--app/services/groups/create_service.rb10
-rw-r--r--app/services/groups/update_shared_runners_service.rb50
-rw-r--r--app/services/import/bitbucket_server_service.rb104
-rw-r--r--app/services/incident_management/create_incident_label_service.rb40
-rw-r--r--app/services/incident_management/create_issue_service.rb55
-rw-r--r--app/services/incident_management/pager_duty/create_incident_issue_service.rb72
-rw-r--r--app/services/incident_management/pager_duty/process_webhook_service.rb71
-rw-r--r--app/services/issuable/bulk_update_service.rb53
-rw-r--r--app/services/issuable_base_service.rb39
-rw-r--r--app/services/issues/move_service.rb11
-rw-r--r--app/services/jira/requests/base.rb22
-rw-r--r--app/services/jira/requests/projects.rb32
-rw-r--r--app/services/jira/requests/projects/list_service.rb47
-rw-r--r--app/services/jira_import/start_import_service.rb20
-rw-r--r--app/services/jira_import/users_mapper.rb7
-rw-r--r--app/services/labels/available_labels_service.rb2
-rw-r--r--app/services/labels/transfer_service.rb25
-rw-r--r--app/services/members/create_service.rb2
-rw-r--r--app/services/members/destroy_service.rb29
-rw-r--r--app/services/members/unassign_issuables_service.rb23
-rw-r--r--app/services/merge_requests/approval_service.rb56
-rw-r--r--app/services/merge_requests/base_service.rb16
-rw-r--r--app/services/merge_requests/create_pipeline_service.rb20
-rw-r--r--app/services/merge_requests/create_service.rb6
-rw-r--r--app/services/merge_requests/ff_merge_service.rb2
-rw-r--r--app/services/merge_requests/merge_base_service.rb2
-rw-r--r--app/services/merge_requests/merge_service.rb3
-rw-r--r--app/services/merge_requests/post_merge_service.rb1
-rw-r--r--app/services/merge_requests/remove_approval_service.rb43
-rw-r--r--app/services/merge_requests/squash_service.rb7
-rw-r--r--app/services/merge_requests/update_service.rb56
-rw-r--r--app/services/metrics/dashboard/base_service.rb22
-rw-r--r--app/services/metrics/dashboard/clone_dashboard_service.rb66
-rw-r--r--app/services/metrics/dashboard/cluster_dashboard_service.rb40
-rw-r--r--app/services/metrics/dashboard/cluster_metrics_embed_service.rb37
-rw-r--r--app/services/metrics/dashboard/custom_dashboard_service.rb5
-rw-r--r--app/services/metrics/dashboard/gitlab_alert_embed_service.rb2
-rw-r--r--app/services/metrics/dashboard/grafana_metric_embed_service.rb6
-rw-r--r--app/services/metrics/dashboard/pod_dashboard_service.rb9
-rw-r--r--app/services/metrics/dashboard/predefined_dashboard_service.rb15
-rw-r--r--app/services/metrics/dashboard/self_monitoring_dashboard_service.rb15
-rw-r--r--app/services/metrics/dashboard/system_dashboard_service.rb15
-rw-r--r--app/services/metrics/dashboard/transient_embed_service.rb4
-rw-r--r--app/services/namespaces/check_storage_size_service.rb95
-rw-r--r--app/services/notes/post_process_service.rb22
-rw-r--r--app/services/notes/quick_actions_service.rb2
-rw-r--r--app/services/notes/update_service.rb10
-rw-r--r--app/services/notification_service.rb24
-rw-r--r--app/services/packages/composer/composer_json_service.rb31
-rw-r--r--app/services/packages/composer/create_package_service.rb57
-rw-r--r--app/services/packages/composer/version_parser_service.rb33
-rw-r--r--app/services/packages/conan/create_package_file_service.rb31
-rw-r--r--app/services/packages/conan/create_package_service.rb19
-rw-r--r--app/services/packages/conan/search_service.rb58
-rw-r--r--app/services/packages/create_dependency_service.rb82
-rw-r--r--app/services/packages/create_package_file_service.rb22
-rw-r--r--app/services/packages/maven/create_package_service.rb28
-rw-r--r--app/services/packages/maven/find_or_create_package_service.rb41
-rw-r--r--app/services/packages/npm/create_package_service.rb91
-rw-r--r--app/services/packages/npm/create_tag_service.rb34
-rw-r--r--app/services/packages/nuget/create_dependency_service.rb71
-rw-r--r--app/services/packages/nuget/create_package_service.rb23
-rw-r--r--app/services/packages/nuget/metadata_extraction_service.rb106
-rw-r--r--app/services/packages/nuget/search_service.rb101
-rw-r--r--app/services/packages/nuget/sync_metadatum_service.rb50
-rw-r--r--app/services/packages/nuget/update_package_from_metadata_service.rb125
-rw-r--r--app/services/packages/pypi/create_package_service.rb40
-rw-r--r--app/services/packages/remove_tag_service.rb16
-rw-r--r--app/services/packages/update_tags_service.rb41
-rw-r--r--app/services/personal_access_tokens/last_used_service.rb28
-rw-r--r--app/services/post_receive_service.rb15
-rw-r--r--app/services/projects/after_import_service.rb2
-rw-r--r--app/services/projects/alerting/notify_service.rb9
-rw-r--r--app/services/projects/batch_forks_count_service.rb23
-rw-r--r--app/services/projects/container_repository/delete_tags_service.rb25
-rw-r--r--app/services/projects/create_service.rb17
-rw-r--r--app/services/projects/forks_count_service.rb2
-rw-r--r--app/services/projects/group_links/create_service.rb22
-rw-r--r--app/services/projects/operations/update_service.rb13
-rw-r--r--app/services/projects/prometheus/alerts/create_events_service.rb71
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb15
-rw-r--r--app/services/projects/propagate_service_template.rb18
-rw-r--r--app/services/projects/update_remote_mirror_service.rb2
-rw-r--r--app/services/projects/update_repository_storage_service.rb22
-rw-r--r--app/services/prometheus/proxy_service.rb10
-rw-r--r--app/services/prometheus/proxy_variable_substitution_service.rb42
-rw-r--r--app/services/releases/create_evidence_service.rb10
-rw-r--r--app/services/repositories/base_service.rb5
-rw-r--r--app/services/repositories/destroy_service.rb11
-rw-r--r--app/services/repositories/shell_destroy_service.rb2
-rw-r--r--app/services/resource_access_tokens/create_service.rb10
-rw-r--r--app/services/resource_access_tokens/revoke_service.rb2
-rw-r--r--app/services/resource_events/base_synthetic_notes_builder_service.rb20
-rw-r--r--app/services/resource_events/change_state_service.rb38
-rw-r--r--app/services/resource_events/synthetic_label_notes_builder_service.rb2
-rw-r--r--app/services/resource_events/synthetic_milestone_notes_builder_service.rb2
-rw-r--r--app/services/resource_events/synthetic_state_notes_builder_service.rb2
-rw-r--r--app/services/service_desk_settings/update_service.rb19
-rw-r--r--app/services/snippets/base_service.rb20
-rw-r--r--app/services/snippets/create_service.rb10
-rw-r--r--app/services/snippets/update_service.rb7
-rw-r--r--app/services/snippets/update_statistics_service.rb28
-rw-r--r--app/services/spam/spam_verdict_service.rb11
-rw-r--r--app/services/system_note_service.rb32
-rw-r--r--app/services/system_notes/alert_management_service.rb37
-rw-r--r--app/services/system_notes/issuables_service.rb25
-rw-r--r--app/services/system_notes/merge_requests_service.rb21
-rw-r--r--app/services/tags/destroy_service.rb8
-rw-r--r--app/services/terraform/remote_state_handler.rb45
-rw-r--r--app/services/todo_service.rb6
-rw-r--r--app/services/update_container_registry_info_service.rb24
-rw-r--r--app/services/users/block_service.rb2
-rw-r--r--app/services/wiki_pages/base_service.rb2
-rw-r--r--app/services/wiki_pages/event_create_service.rb2
-rw-r--r--app/uploaders/object_storage.rb6
-rw-r--r--app/uploaders/packages/package_file_uploader.rb30
-rw-r--r--app/validators/addressable_url_validator.rb6
-rw-r--r--app/validators/array_members_validator.rb21
-rw-r--r--app/validators/json_schemas/build_metadata_secrets.json30
-rw-r--r--app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json153
-rw-r--r--app/views/admin/appearances/_form.html.haml4
-rw-r--r--app/views/admin/appearances/show.html.haml2
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml4
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml2
-rw-r--r--app/views/admin/application_settings/_email.html.haml2
-rw-r--r--app/views/admin/application_settings/_import_export_limits.html.haml34
-rw-r--r--app/views/admin/application_settings/_initial_branch_name.html.haml12
-rw-r--r--app/views/admin/application_settings/_registry.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml2
-rw-r--r--app/views/admin/application_settings/_signin.html.haml9
-rw-r--r--app/views/admin/application_settings/_usage.html.haml2
-rw-r--r--app/views/admin/application_settings/ci/_header.html.haml2
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml2
-rw-r--r--app/views/admin/application_settings/integrations.html.haml2
-rw-r--r--app/views/admin/application_settings/network.html.haml11
-rw-r--r--app/views/admin/application_settings/repository.html.haml12
-rw-r--r--app/views/admin/applications/edit.html.haml4
-rw-r--r--app/views/admin/applications/index.html.haml2
-rw-r--r--app/views/admin/applications/new.html.haml4
-rw-r--r--app/views/admin/applications/show.html.haml4
-rw-r--r--app/views/admin/background_jobs/show.html.haml2
-rw-r--r--app/views/admin/broadcast_messages/edit.html.haml4
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml4
-rw-r--r--app/views/admin/dashboard/index.html.haml5
-rw-r--r--app/views/admin/deploy_keys/new.html.haml2
-rw-r--r--app/views/admin/gitaly_servers/index.html.haml1
-rw-r--r--app/views/admin/groups/index.html.haml2
-rw-r--r--app/views/admin/groups/show.html.haml2
-rw-r--r--app/views/admin/hook_logs/_index.html.haml2
-rw-r--r--app/views/admin/hook_logs/show.html.haml4
-rw-r--r--app/views/admin/hooks/_form.html.haml2
-rw-r--r--app/views/admin/hooks/edit.html.haml4
-rw-r--r--app/views/admin/hooks/index.html.haml4
-rw-r--r--app/views/admin/impersonation_tokens/index.html.haml2
-rw-r--r--app/views/admin/jobs/index.html.haml3
-rw-r--r--app/views/admin/keys/show.html.haml2
-rw-r--r--app/views/admin/projects/show.html.haml89
-rw-r--r--app/views/admin/requests_profiles/index.html.haml4
-rw-r--r--app/views/admin/runners/_runner.html.haml4
-rw-r--r--app/views/admin/runners/_sort_dropdown.html.haml2
-rw-r--r--app/views/admin/runners/index.html.haml1
-rw-r--r--app/views/admin/runners/show.html.haml1
-rw-r--r--app/views/admin/services/_form.html.haml2
-rw-r--r--app/views/admin/services/edit.html.haml5
-rw-r--r--app/views/admin/services/index.html.haml33
-rw-r--r--app/views/admin/sessions/new.html.haml2
-rw-r--r--app/views/admin/spam_logs/index.html.haml2
-rw-r--r--app/views/admin/system_info/show.html.haml6
-rw-r--r--app/views/admin/users/_access_levels.html.haml29
-rw-r--r--app/views/admin/users/_head.html.haml2
-rw-r--r--app/views/admin/users/_user_listing_note.html.haml2
-rw-r--r--app/views/admin/users/edit.html.haml2
-rw-r--r--app/views/admin/users/index.html.haml6
-rw-r--r--app/views/admin/users/keys.html.haml4
-rw-r--r--app/views/admin/users/new.html.haml2
-rw-r--r--app/views/admin/users/projects.html.haml8
-rw-r--r--app/views/admin/users/show.html.haml24
-rw-r--r--app/views/ci/group_variables/_index.html.haml4
-rw-r--r--app/views/ci/group_variables/_variable_header.html.haml4
-rw-r--r--app/views/ci/variables/_content.html.haml2
-rw-r--r--app/views/ci/variables/_environment_scope_header.html.haml2
-rw-r--r--app/views/ci/variables/_header.html.haml2
-rw-r--r--app/views/ci/variables/_index.html.haml4
-rw-r--r--app/views/ci/variables/_variable_header.html.haml6
-rw-r--r--app/views/ci/variables/_variable_row.html.haml6
-rw-r--r--app/views/clusters/clusters/_advanced_settings.html.haml4
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml2
-rw-r--r--app/views/clusters/clusters/_gitlab_integration_form.html.haml15
-rw-r--r--app/views/clusters/clusters/_health.html.haml6
-rw-r--r--app/views/clusters/clusters/_health_tab.html.haml5
-rw-r--r--app/views/clusters/clusters/_multiple_clusters_message.html.haml6
-rw-r--r--app/views/clusters/clusters/_sidebar.html.haml2
-rw-r--r--app/views/clusters/clusters/aws/_new.html.haml4
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml13
-rw-r--r--app/views/clusters/clusters/index.html.haml11
-rw-r--r--app/views/clusters/clusters/new.html.haml2
-rw-r--r--app/views/clusters/clusters/show.html.haml4
-rw-r--r--app/views/clusters/clusters/user/_form.html.haml8
-rw-r--r--app/views/dashboard/_projects_head.html.haml4
-rw-r--r--app/views/dashboard/activity.html.haml4
-rw-r--r--app/views/dashboard/groups/index.html.haml4
-rw-r--r--app/views/dashboard/milestones/index.html.haml4
-rw-r--r--app/views/dashboard/projects/index.html.haml4
-rw-r--r--app/views/dashboard/snippets/index.html.haml4
-rw-r--r--app/views/dashboard/todos/_todo.html.haml2
-rw-r--r--app/views/dashboard/todos/index.html.haml6
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_account.html.haml4
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_account.text.erb5
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_secondary.html.haml2
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_secondary.text.erb2
-rw-r--r--app/views/devise/registrations/new.html.haml2
-rw-r--r--app/views/devise/sessions/new.html.haml2
-rw-r--r--app/views/devise/sessions/two_factor.html.haml4
-rw-r--r--app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml3
-rw-r--r--app/views/devise/shared/_signup_box.html.haml3
-rw-r--r--app/views/discussions/_discussion.html.haml2
-rw-r--r--app/views/doorkeeper/applications/_form.html.haml2
-rw-r--r--app/views/doorkeeper/applications/index.html.haml6
-rw-r--r--app/views/doorkeeper/applications/show.html.haml2
-rw-r--r--app/views/doorkeeper/authorizations/new.html.haml2
-rw-r--r--app/views/events/_event.html.haml2
-rw-r--r--app/views/events/event/_common.html.haml10
-rw-r--r--app/views/events/event/_created_project.html.haml2
-rw-r--r--app/views/events/event/_design.html.haml11
-rw-r--r--app/views/events/event/_note.html.haml4
-rw-r--r--app/views/events/event/_push.html.haml4
-rw-r--r--app/views/events/event/_wiki.html.haml2
-rw-r--r--app/views/explore/snippets/index.html.haml4
-rw-r--r--app/views/groups/_flash_messages.html.haml2
-rw-r--r--app/views/groups/_home_panel.html.haml10
-rw-r--r--app/views/groups/activity.html.haml2
-rw-r--r--app/views/groups/edit.html.haml1
-rw-r--r--app/views/groups/group_members/index.html.haml3
-rw-r--r--app/views/groups/issues.html.haml2
-rw-r--r--app/views/groups/labels/edit.html.haml2
-rw-r--r--app/views/groups/labels/index.html.haml6
-rw-r--r--app/views/groups/merge_requests.html.haml2
-rw-r--r--app/views/groups/milestones/_form.html.haml6
-rw-r--r--app/views/groups/milestones/index.html.haml4
-rw-r--r--app/views/groups/new.html.haml2
-rw-r--r--app/views/groups/projects.html.haml5
-rw-r--r--app/views/groups/runners/_group_runners.html.haml10
-rw-r--r--app/views/groups/runners/_index.html.haml94
-rw-r--r--app/views/groups/runners/_runner.html.haml103
-rw-r--r--app/views/groups/settings/_general.html.haml2
-rw-r--r--app/views/groups/settings/_lfs.html.haml2
-rw-r--r--app/views/groups/settings/_permissions.html.haml9
-rw-r--r--app/views/groups/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml4
-rw-r--r--app/views/groups/show.html.haml5
-rw-r--r--app/views/help/_shortcuts.html.haml8
-rw-r--r--app/views/help/index.html.haml4
-rw-r--r--app/views/help/instance_configuration.html.haml2
-rw-r--r--app/views/help/show.html.haml2
-rw-r--r--app/views/help/ui.html.haml2
-rw-r--r--app/views/ide/_show.html.haml2
-rw-r--r--app/views/import/bitbucket/status.html.haml91
-rw-r--r--app/views/import/bitbucket_server/new.html.haml4
-rw-r--r--app/views/import/bitbucket_server/status.html.haml96
-rw-r--r--app/views/import/fogbugz/status.html.haml65
-rw-r--r--app/views/import/gitlab/status.html.haml55
-rw-r--r--app/views/import/gitlab_projects/new.html.haml5
-rw-r--r--app/views/import/manifest/new.html.haml4
-rw-r--r--app/views/import/manifest/status.html.haml4
-rw-r--r--app/views/instance_statistics/cohorts/index.html.haml3
-rw-r--r--app/views/instance_statistics/dev_ops_score/_callout.html.haml2
-rw-r--r--app/views/instance_statistics/dev_ops_score/_disabled.html.haml2
-rw-r--r--app/views/instance_statistics/dev_ops_score/index.html.haml2
-rw-r--r--app/views/invites/show.html.haml10
-rw-r--r--app/views/kaminari/gitlab/_paginator.html.haml2
-rw-r--r--app/views/kaminari/gitlab/_without_count.html.haml2
-rw-r--r--app/views/layouts/_head.html.haml3
-rw-r--r--app/views/layouts/_img_loader.html.haml17
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/_search.html.haml5
-rw-r--r--app/views/layouts/_startup_js.html.haml13
-rw-r--r--app/views/layouts/application.html.haml2
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml1
-rw-r--r--app/views/layouts/header/_help_dropdown.html.haml1
-rw-r--r--app/views/layouts/header/_new_dropdown.haml1
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml6
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml8
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml5
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml65
-rw-r--r--app/views/layouts/nav/sidebar/_wiki_link.html.haml11
-rw-r--r--app/views/layouts/service_desk.html.haml24
-rw-r--r--app/views/layouts/snippets.html.haml1
-rw-r--r--app/views/notify/closed_merge_request_email.html.haml3
-rw-r--r--app/views/notify/closed_merge_request_email.text.haml2
-rw-r--r--app/views/notify/merge_request_status_email.html.haml3
-rw-r--r--app/views/notify/merge_request_status_email.text.haml2
-rw-r--r--app/views/notify/merge_request_unmergeable_email.html.haml2
-rw-r--r--app/views/notify/merge_request_unmergeable_email.text.haml2
-rw-r--r--app/views/notify/merge_when_pipeline_succeeds_email.html.haml159
-rw-r--r--app/views/notify/merge_when_pipeline_succeeds_email.text.haml8
-rw-r--r--app/views/notify/merged_merge_request_email.html.haml2
-rw-r--r--app/views/notify/new_issue_email.html.haml2
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.html.haml2
-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.haml4
-rw-r--r--app/views/notify/resolved_all_discussions_email.html.haml3
-rw-r--r--app/views/notify/service_desk_new_note_email.html.haml5
-rw-r--r--app/views/notify/service_desk_new_note_email.text.erb6
-rw-r--r--app/views/notify/service_desk_thank_you_email.html.haml2
-rw-r--r--app/views/notify/service_desk_thank_you_email.text.erb6
-rw-r--r--app/views/profiles/_event_table.html.haml2
-rw-r--r--app/views/profiles/accounts/show.html.haml10
-rw-r--r--app/views/profiles/active_sessions/_active_session.html.haml4
-rw-r--r--app/views/profiles/active_sessions/index.html.haml4
-rw-r--r--app/views/profiles/audit_log.html.haml2
-rw-r--r--app/views/profiles/chat_names/index.html.haml2
-rw-r--r--app/views/profiles/chat_names/new.html.haml2
-rw-r--r--app/views/profiles/emails/index.html.haml10
-rw-r--r--app/views/profiles/gpg_keys/_form.html.haml2
-rw-r--r--app/views/profiles/gpg_keys/_key.html.haml6
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml4
-rw-r--r--app/views/profiles/keys/_form.html.haml4
-rw-r--r--app/views/profiles/keys/_key.html.haml8
-rw-r--r--app/views/profiles/keys/_key_details.html.haml2
-rw-r--r--app/views/profiles/keys/index.html.haml6
-rw-r--r--app/views/profiles/notifications/_group_settings.html.haml2
-rw-r--r--app/views/profiles/notifications/_project_settings.html.haml2
-rw-r--r--app/views/profiles/notifications/show.html.haml6
-rw-r--r--app/views/profiles/passwords/edit.html.haml6
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml8
-rw-r--r--app/views/profiles/preferences/show.html.haml20
-rw-r--r--app/views/profiles/show.html.haml11
-rw-r--r--app/views/profiles/two_factor_auths/_codes.html.haml2
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml14
-rw-r--r--app/views/projects/_files.html.haml23
-rw-r--r--app/views/projects/_flash_messages.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml10
-rw-r--r--app/views/projects/_import_project_pane.html.haml6
-rw-r--r--app/views/projects/_merge_request_settings.html.haml3
-rw-r--r--app/views/projects/_merge_request_squash_options_settings.html.haml42
-rw-r--r--app/views/projects/_readme.html.haml14
-rw-r--r--app/views/projects/_remove.html.haml7
-rw-r--r--app/views/projects/_service_desk_settings.html.haml19
-rw-r--r--app/views/projects/_wiki.html.haml2
-rw-r--r--app/views/projects/artifacts/browse.html.haml2
-rw-r--r--app/views/projects/artifacts/file.html.haml2
-rw-r--r--app/views/projects/blame/show.html.haml2
-rw-r--r--app/views/projects/blob/_editor.html.haml10
-rw-r--r--app/views/projects/blob/_header_content.html.haml2
-rw-r--r--app/views/projects/blob/_viewer.html.haml2
-rw-r--r--app/views/projects/blob/_viewer_switcher.html.haml4
-rw-r--r--app/views/projects/blob/edit.html.haml9
-rw-r--r--app/views/projects/blob/new.html.haml10
-rw-r--r--app/views/projects/blob/viewers/_license.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_loading.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_sketch.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_stl.html.haml4
-rw-r--r--app/views/projects/branches/_branch.html.haml8
-rw-r--r--app/views/projects/branches/new.html.haml2
-rw-r--r--app/views/projects/buttons/_clone.html.haml2
-rw-r--r--app/views/projects/buttons/_download.html.haml8
-rw-r--r--app/views/projects/cleanup/_show.html.haml2
-rw-r--r--app/views/projects/commit/_commit_box.html.haml4
-rw-r--r--app/views/projects/commit/_limit_exceeded_message.html.haml2
-rw-r--r--app/views/projects/commits/show.html.haml10
-rw-r--r--app/views/projects/compare/_form.html.haml6
-rw-r--r--app/views/projects/compare/index.html.haml4
-rw-r--r--app/views/projects/confluences/show.html.haml13
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml2
-rw-r--r--app/views/projects/default_branch/_show.html.haml2
-rw-r--r--app/views/projects/deploy_keys/edit.html.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml2
-rw-r--r--app/views/projects/diffs/_file_header.html.haml2
-rw-r--r--app/views/projects/diffs/_stats.html.haml4
-rw-r--r--app/views/projects/edit.html.haml2
-rw-r--r--app/views/projects/empty.html.haml6
-rw-r--r--app/views/projects/environments/_form.html.haml4
-rw-r--r--app/views/projects/find_file/show.html.haml4
-rw-r--r--app/views/projects/forks/_fork_button.html.haml8
-rw-r--r--app/views/projects/forks/new.html.haml6
-rw-r--r--app/views/projects/hook_logs/_index.html.haml2
-rw-r--r--app/views/projects/hook_logs/show.html.haml4
-rw-r--r--app/views/projects/hooks/edit.html.haml4
-rw-r--r--app/views/projects/hooks/index.html.haml4
-rw-r--r--app/views/projects/import/jira/show.html.haml1
-rw-r--r--app/views/projects/imports/new.html.haml2
-rw-r--r--app/views/projects/issues/_alert_moved_from_service_desk.html.haml10
-rw-r--r--app/views/projects/issues/_by_email_description.html.haml4
-rw-r--r--app/views/projects/issues/_design_management.html.haml38
-rw-r--r--app/views/projects/issues/_discussion.html.haml2
-rw-r--r--app/views/projects/issues/_issue.html.haml2
-rw-r--r--app/views/projects/issues/_issues.html.haml21
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml2
-rw-r--r--app/views/projects/issues/_new_branch.html.haml2
-rw-r--r--app/views/projects/issues/_service_desk_info_content.html.haml39
-rw-r--r--app/views/projects/issues/edit.html.haml2
-rw-r--r--app/views/projects/issues/export_csv/_modal.html.haml2
-rw-r--r--app/views/projects/issues/import_csv/_button.html.haml5
-rw-r--r--app/views/projects/issues/index.html.haml2
-rw-r--r--app/views/projects/issues/service_desk.html.haml21
-rw-r--r--app/views/projects/issues/show.html.haml23
-rw-r--r--app/views/projects/jobs/index.html.haml2
-rw-r--r--app/views/projects/jobs/show.html.haml2
-rw-r--r--app/views/projects/jobs/terminal.html.haml6
-rw-r--r--app/views/projects/labels/edit.html.haml6
-rw-r--r--app/views/projects/labels/index.html.haml4
-rw-r--r--app/views/projects/labels/new.html.haml6
-rw-r--r--app/views/projects/merge_requests/_approvals_count.html.haml13
-rw-r--r--app/views/projects/merge_requests/_discussion.html.haml2
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml2
-rw-r--r--app/views/projects/merge_requests/_nav_btns.html.haml2
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml7
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml4
-rw-r--r--app/views/projects/merge_requests/creations/new.html.haml6
-rw-r--r--app/views/projects/merge_requests/diffs/_commit_widget.html.haml2
-rw-r--r--app/views/projects/merge_requests/edit.html.haml2
-rw-r--r--app/views/projects/merge_requests/index.html.haml2
-rw-r--r--app/views/projects/merge_requests/invalid.html.haml2
-rw-r--r--app/views/projects/merge_requests/show.html.haml14
-rw-r--r--app/views/projects/milestones/_form.html.haml6
-rw-r--r--app/views/projects/milestones/index.html.haml2
-rw-r--r--app/views/projects/milestones/show.html.haml4
-rw-r--r--app/views/projects/mirrors/_instructions.html.haml2
-rw-r--r--app/views/projects/mirrors/_ssh_host_keys.html.haml4
-rw-r--r--app/views/projects/network/show.html.haml6
-rw-r--r--app/views/projects/new.html.haml4
-rw-r--r--app/views/projects/no_repo.html.haml3
-rw-r--r--app/views/projects/notes/_actions.html.haml2
-rw-r--r--app/views/projects/notes/_more_actions_dropdown.html.haml4
-rw-r--r--app/views/projects/pages/show.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml4
-rw-r--r--app/views/projects/pipelines/_stage.html.haml2
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml8
-rw-r--r--app/views/projects/pipelines/index.html.haml1
-rw-r--r--app/views/projects/pipelines/show.html.haml4
-rw-r--r--app/views/projects/project_members/index.html.haml3
-rw-r--r--app/views/projects/project_templates/_built_in_templates.html.haml4
-rw-r--r--app/views/projects/project_templates/_project_fields_form.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_matching_branch.html.haml2
-rw-r--r--app/views/projects/protected_branches/show.html.haml4
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml3
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_dropdown.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_matching_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_protected_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/show.html.haml4
-rw-r--r--app/views/projects/refs/logs_tree.js.haml23
-rw-r--r--app/views/projects/releases/new.html.haml3
-rw-r--r--app/views/projects/serverless/functions/index.html.haml4
-rw-r--r--app/views/projects/services/_form.html.haml11
-rw-r--r--app/views/projects/services/alerts/_help.html.haml7
-rw-r--r--app/views/projects/services/alerts/_top.html.haml8
-rw-r--r--app/views/projects/services/prometheus/_configuration_banner.html.haml4
-rw-r--r--app/views/projects/services/prometheus/_custom_metrics.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_external_alerts.html.haml4
-rw-r--r--app/views/projects/services/prometheus/_help.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_metrics.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_show.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_top.html.haml10
-rw-r--r--app/views/projects/settings/_general.html.haml2
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml6
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml4
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml6
-rw-r--r--app/views/projects/settings/integrations/show.html.haml2
-rw-r--r--app/views/projects/settings/operations/_alert_management.html.haml14
-rw-r--r--app/views/projects/settings/operations/_configuration_banner.html.haml4
-rw-r--r--app/views/projects/settings/operations/_incidents.html.haml33
-rw-r--r--app/views/projects/settings/operations/_prometheus.html.haml2
-rw-r--r--app/views/projects/settings/operations/show.html.haml1
-rw-r--r--app/views/projects/show.html.haml5
-rw-r--r--app/views/projects/sidebar/_issues_service_desk.html.haml3
-rw-r--r--app/views/projects/snippets/_actions.html.haml2
-rw-r--r--app/views/projects/starrers/_starrer.html.haml2
-rw-r--r--app/views/projects/tags/_tag.html.haml4
-rw-r--r--app/views/projects/tags/index.html.haml2
-rw-r--r--app/views/projects/tags/new.html.haml10
-rw-r--r--app/views/projects/tags/releases/edit.html.haml6
-rw-r--r--app/views/projects/tags/show.html.haml8
-rw-r--r--app/views/projects/tree/_readme.html.haml2
-rw-r--r--app/views/projects/tree/_tree_commit_column.html.haml3
-rw-r--r--app/views/projects/tree/_tree_content.html.haml2
-rw-r--r--app/views/projects/tree/_tree_header.html.haml81
-rw-r--r--app/views/projects/tree/_tree_row.html.haml2
-rw-r--r--app/views/projects/tree/show.html.haml4
-rw-r--r--app/views/projects/triggers/_index.html.haml12
-rw-r--r--app/views/projects/triggers/_trigger.html.haml2
-rw-r--r--app/views/projects/triggers/edit.html.haml4
-rw-r--r--app/views/projects/wikis/git_access.html.haml3
-rw-r--r--app/views/search/_category.html.haml4
-rw-r--r--app/views/search/results/_issue.html.haml2
-rw-r--r--app/views/search/results/_merge_request.html.haml4
-rw-r--r--app/views/search/results/_note.html.haml2
-rw-r--r--app/views/search/show.html.haml2
-rw-r--r--app/views/sent_notifications/unsubscribe.html.haml4
-rw-r--r--app/views/shared/_commit_well.html.haml2
-rw-r--r--app/views/shared/_event_filter.html.haml8
-rw-r--r--app/views/shared/_field.html.haml2
-rw-r--r--app/views/shared/_issuable_meta_data.html.haml8
-rw-r--r--app/views/shared/_label_row.html.haml4
-rw-r--r--app/views/shared/_md_preview.html.haml4
-rw-r--r--app/views/shared/_milestone_expired.html.haml6
-rw-r--r--app/views/shared/_milestones_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/_namespace_storage_limit_alert.html.haml26
-rw-r--r--app/views/shared/_service_settings.html.haml25
-rw-r--r--app/views/shared/_sidebar_toggle_button.html.haml4
-rw-r--r--app/views/shared/_zen.html.haml2
-rw-r--r--app/views/shared/access_tokens/_form.html.haml2
-rw-r--r--app/views/shared/access_tokens/_table.html.haml16
-rw-r--r--app/views/shared/boards/_show.html.haml1
-rw-r--r--app/views/shared/boards/components/_board.html.haml82
-rw-r--r--app/views/shared/dashboard/_no_filter_selected.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_new_deploy_token.html.haml4
-rw-r--r--app/views/shared/empty_states/_wikis.html.haml4
-rw-r--r--app/views/shared/empty_states/_wikis_layout.html.haml2
-rw-r--r--app/views/shared/empty_states/icons/_service_desk_callout.svg1
-rw-r--r--app/views/shared/empty_states/icons/_service_desk_empty_state.svg1
-rw-r--r--app/views/shared/empty_states/icons/_service_desk_setup.svg39
-rw-r--r--app/views/shared/file_hooks/_index.html.haml4
-rw-r--r--app/views/shared/form_elements/_description.html.haml12
-rw-r--r--app/views/shared/groups/_dropdown.html.haml2
-rw-r--r--app/views/shared/icons/_icon_service_desk.svg1
-rw-r--r--app/views/shared/integrations/edit.html.haml1
-rw-r--r--app/views/shared/issuable/_board_create_list_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml16
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml11
-rw-r--r--app/views/shared/issuable/_close_reopen_report_toggle.html.haml11
-rw-r--r--app/views/shared/issuable/_form.html.haml4
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml4
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml4
-rw-r--r--app/views/shared/issuable/_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml2
-rw-r--r--app/views/shared/issuable/form/_contribution.html.haml2
-rw-r--r--app/views/shared/issuable/form/_default_templates.html.haml2
-rw-r--r--app/views/shared/issuable/form/_merge_params.html.haml23
-rw-r--r--app/views/shared/issuable/form/_title.html.haml4
-rw-r--r--app/views/shared/members/_member.html.haml14
-rw-r--r--app/views/shared/members/_requests.html.haml2
-rw-r--r--app/views/shared/milestones/_deprecation_message.html.haml2
-rw-r--r--app/views/shared/milestones/_description.html.haml5
-rw-r--r--app/views/shared/milestones/_form_dates.html.haml8
-rw-r--r--app/views/shared/milestones/_header.html.haml2
-rw-r--r--app/views/shared/milestones/_issues_tab.html.haml2
-rw-r--r--app/views/shared/milestones/_merge_requests_tab.haml2
-rw-r--r--app/views/shared/milestones/_milestone.html.haml9
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml4
-rw-r--r--app/views/shared/milestones/_tab_loading.html.haml2
-rw-r--r--app/views/shared/milestones/_tabs.html.haml4
-rw-r--r--app/views/shared/milestones/_top.html.haml2
-rw-r--r--app/views/shared/notes/_comment_button.html.haml4
-rw-r--r--app/views/shared/notes/_edit_form.html.haml4
-rw-r--r--app/views/shared/notes/_form.html.haml2
-rw-r--r--app/views/shared/notes/_hints.html.haml15
-rw-r--r--app/views/shared/notes/_note.html.haml4
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml4
-rw-r--r--app/views/shared/notifications/_new_button.html.haml2
-rw-r--r--app/views/shared/projects/_edit_information.html.haml2
-rw-r--r--app/views/shared/projects/_project.html.haml25
-rw-r--r--app/views/shared/promotions/_promote_servicedesk.html.haml13
-rw-r--r--app/views/shared/runners/_runner_description.html.haml2
-rw-r--r--app/views/shared/runners/show.html.haml2
-rw-r--r--app/views/shared/snippets/_header.html.haml2
-rw-r--r--app/views/shared/snippets/_snippet.html.haml2
-rw-r--r--app/views/shared/web_hooks/_hook.html.haml2
-rw-r--r--app/views/shared/web_hooks/_index.html.haml2
-rw-r--r--app/views/shared/wikis/_form.html.haml4
-rw-r--r--app/views/shared/wikis/_pages_wiki_page.html.haml2
-rw-r--r--app/views/shared/wikis/_sidebar.html.haml8
-rw-r--r--app/views/shared/wikis/_sidebar_wiki_page.html.haml2
-rw-r--r--app/views/shared/wikis/diff.html.haml32
-rw-r--r--app/views/shared/wikis/edit.html.haml10
-rw-r--r--app/views/shared/wikis/history.html.haml57
-rw-r--r--app/views/shared/wikis/pages.html.haml4
-rw-r--r--app/views/shared/wikis/show.html.haml15
-rw-r--r--app/views/sherlock/file_samples/show.html.haml4
-rw-r--r--app/views/sherlock/queries/_backtrace.html.haml2
-rw-r--r--app/views/sherlock/queries/_general.html.haml2
-rw-r--r--app/views/sherlock/transactions/_general.html.haml2
-rw-r--r--app/views/snippets/_actions.html.haml2
-rw-r--r--app/views/snippets/new.html.haml2
-rw-r--r--app/views/snippets/notes/_actions.html.haml2
-rw-r--r--app/views/users/_overview.html.haml6
-rw-r--r--app/views/users/show.html.haml7
-rw-r--r--app/workers/all_queues.yml95
-rw-r--r--app/workers/authorized_project_update/project_group_link_create_worker.rb21
-rw-r--r--app/workers/build_finished_worker.rb1
-rw-r--r--app/workers/ci/pipeline_success_unlock_artifacts_worker.rb20
-rw-r--r--app/workers/ci/ref_delete_unlock_artifacts_worker.rb22
-rw-r--r--app/workers/concerns/project_export_options.rb25
-rw-r--r--app/workers/concerns/reenqueuer.rb6
-rw-r--r--app/workers/concerns/worker_attributes.rb68
-rw-r--r--app/workers/delete_merged_branches_worker.rb1
-rw-r--r--app/workers/gitlab/jira_import/import_issue_worker.rb2
-rw-r--r--app/workers/group_export_worker.rb1
-rw-r--r--app/workers/incident_management/pager_duty/process_incident_worker.rb42
-rw-r--r--app/workers/incident_management/process_alert_worker.rb36
-rw-r--r--app/workers/incident_management/process_prometheus_alert_worker.rb69
-rw-r--r--app/workers/members_destroyer/unassign_issuables_worker.rb32
-rw-r--r--app/workers/new_release_worker.rb18
-rw-r--r--app/workers/packages/nuget/extraction_worker.rb25
-rw-r--r--app/workers/partition_creation_worker.rb15
-rw-r--r--app/workers/pipeline_update_worker.rb4
-rw-r--r--app/workers/post_receive.rb2
-rw-r--r--app/workers/process_commit_worker.rb2
-rw-r--r--app/workers/project_export_worker.rb3
-rw-r--r--app/workers/project_update_repository_storage_worker.rb4
-rw-r--r--app/workers/repository_check/batch_worker.rb4
-rw-r--r--app/workers/repository_check/single_repository_worker.rb2
-rw-r--r--app/workers/repository_import_worker.rb3
-rw-r--r--app/workers/service_desk_email_receiver_worker.rb15
-rw-r--r--app/workers/stuck_import_jobs_worker.rb19
-rw-r--r--app/workers/update_container_registry_info_worker.rb15
1869 files changed, 33072 insertions, 10065 deletions
diff --git a/app/assets/images/bot_avatars/alert-bot.png b/app/assets/images/bot_avatars/alert-bot.png
new file mode 100644
index 00000000000..985d67d6179
--- /dev/null
+++ b/app/assets/images/bot_avatars/alert-bot.png
Binary files differ
diff --git a/app/assets/images/bot_avatars/security-bot.png b/app/assets/images/bot_avatars/security-bot.png
new file mode 100644
index 00000000000..0709f62f07b
--- /dev/null
+++ b/app/assets/images/bot_avatars/security-bot.png
Binary files differ
diff --git a/app/assets/images/bot_avatars/support-bot.png b/app/assets/images/bot_avatars/support-bot.png
new file mode 100644
index 00000000000..1335205c191
--- /dev/null
+++ b/app/assets/images/bot_avatars/support-bot.png
Binary files differ
diff --git a/app/assets/images/confluence.svg b/app/assets/images/confluence.svg
new file mode 100644
index 00000000000..f51d4318b6b
--- /dev/null
+++ b/app/assets/images/confluence.svg
@@ -0,0 +1 @@
+<svg height="16" width="16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a"><stop offset="0" stop-color="#344563"/><stop offset=".68" stop-color="#637088"/><stop offset="1" stop-color="#7a869a"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="14.873" x2="5.739" xlink:href="#a" y1="15.883" y2="10.625"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="-168376" x2="-168177" xlink:href="#a" y1="-6722.4" y2="-6493.53"/><path d="m1.517 11.68c-.15.243-.32.53-.453.757a.462.462 0 0 0 .155.63l3.013 1.863a.466.466 0 0 0 .645-.158l.445-.735c1.197-1.97 2.402-1.732 4.571-.703l2.995 1.424a.468.468 0 0 0 .626-.232l1.448-3.24a.466.466 0 0 0 -.229-.606c-.633-.298-1.89-.89-3.016-1.434-4.089-2.004-7.551-1.86-10.2 2.434z" fill="url(#b)"/><path d="m14.479 4.315c.15-.243.324-.53.456-.758a.46.46 0 0 0 -.158-.63l-3.025-1.857a.464.464 0 0 0 -.644.158 22.81 22.81 0 0 1 -.446.736c-1.196 1.972-2.4 1.733-4.567.703l-2.993-1.424a.468.468 0 0 0 -.625.231l-1.437 3.246a.46.46 0 0 0 .225.607c.633.298 1.892.89 3.014 1.435 4.097 1.99 7.556 1.858 10.199-2.446z" fill="url(#c)"/></svg> \ No newline at end of file
diff --git a/app/assets/images/logos/jira-gray.svg b/app/assets/images/logos/jira-gray.svg
new file mode 100644
index 00000000000..0e7069f2bd2
--- /dev/null
+++ b/app/assets/images/logos/jira-gray.svg
@@ -0,0 +1 @@
+<svg id="Logos" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><style>.cls-1{fill:#7a869a;}.cls-2{fill:url(#linear-gradient);}.cls-3{fill:url(#linear-gradient-2);}</style><linearGradient id="linear-gradient" x1="38.11" y1="18.54" x2="23.17" y2="33.48" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#344563"/><stop offset="1" stop-color="#7a869a"/></linearGradient><linearGradient id="linear-gradient-2" x1="42.07" y1="61.47" x2="56.98" y2="46.55" xlink:href="#linear-gradient"/></defs><title>jira software-icon-gradient-neutral</title><path class="cls-1" d="M74.18,38,43,6.9l-3-3h0L16.58,27.32h0L5.86,38a2.86,2.86,0,0,0,0,4.05L27.28,63.51,40,76.25,63.47,52.81l.36-.36L74.18,42.09A2.86,2.86,0,0,0,74.18,38ZM40,50.77l-10.7-10.7L40,29.37l10.7,10.7Z"/><path class="cls-2" d="M40,29.37A18,18,0,0,1,40,4L16.54,27.37,29.28,40.11,40,29.37Z"/><path class="cls-3" d="M50.75,40,40,50.77a18,18,0,0,1,0,25.48h0L63.5,52.78Z"/></svg>
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue
index ed6b4b7fdb2..0731349630c 100644
--- a/app/assets/javascripts/alert_management/components/alert_details.vue
+++ b/app/assets/javascripts/alert_management/components/alert_details.vue
@@ -12,18 +12,21 @@ import {
GlTable,
} from '@gitlab/ui';
import { s__ } from '~/locale';
-import query from '../graphql/queries/details.query.graphql';
+import alertQuery from '../graphql/queries/details.query.graphql';
+import sidebarStatusQuery from '../graphql/queries/sidebar_status.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import initUserPopovers from '~/user_popovers';
import { ALERTS_SEVERITY_LABELS, trackAlertsDetailsViewsOptions } from '../constants';
-import createIssueQuery from '../graphql/mutations/create_issue_from_alert.graphql';
+import createIssueMutation from '../graphql/mutations/create_issue_from_alert.mutation.graphql';
+import toggleSidebarStatusMutation from '../graphql/mutations/toggle_sidebar_status.mutation.graphql';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
import { toggleContainerClasses } from '~/lib/utils/dom_utils';
import SystemNote from './system_notes/system_note.vue';
import AlertSidebar from './alert_sidebar.vue';
+import AlertMetrics from './alert_metrics.vue';
const containerEl = document.querySelector('.page-with-contextual-sidebar');
@@ -34,6 +37,7 @@ export default {
),
fullAlertDetailsTitle: s__('AlertManagement|Alert details'),
overviewTitle: s__('AlertManagement|Overview'),
+ metricsTitle: s__('AlertManagement|Metrics'),
reportedAt: s__('AlertManagement|Reported %{when}'),
reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'),
},
@@ -51,25 +55,29 @@ export default {
TimeAgoTooltip,
AlertSidebar,
SystemNote,
+ AlertMetrics,
},
- props: {
+ inject: {
+ projectPath: {
+ default: '',
+ },
alertId: {
type: String,
- required: true,
+ default: '',
},
- projectPath: {
+ projectId: {
type: String,
- required: true,
+ default: '',
},
projectIssuesPath: {
type: String,
- required: true,
+ default: '',
},
},
apollo: {
alert: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
- query,
+ query: alertQuery,
variables() {
return {
fullPath: this.projectPath,
@@ -84,15 +92,18 @@ export default {
Sentry.captureException(error);
},
},
+ sidebarStatus: {
+ query: sidebarStatusQuery,
+ },
},
data() {
return {
alert: null,
errored: false,
+ sidebarStatus: false,
isErrorDismissed: false,
createIssueError: '',
issueCreationInProgress: false,
- sidebarCollapsed: false,
sidebarErrorMessage: '',
};
},
@@ -128,10 +139,10 @@ export default {
this.sidebarErrorMessage = '';
},
toggleSidebar() {
- this.sidebarCollapsed = !this.sidebarCollapsed;
+ this.$apollo.mutate({ mutation: toggleSidebarStatusMutation });
toggleContainerClasses(containerEl, {
- 'right-sidebar-collapsed': this.sidebarCollapsed,
- 'right-sidebar-expanded': !this.sidebarCollapsed,
+ 'right-sidebar-collapsed': !this.sidebarStatus,
+ 'right-sidebar-expanded': this.sidebarStatus,
});
},
handleAlertSidebarError(errorMessage) {
@@ -143,7 +154,7 @@ export default {
this.$apollo
.mutate({
- mutation: createIssueQuery,
+ mutation: createIssueMutation,
variables: {
iid: this.alert.iid,
projectPath: this.projectPath,
@@ -169,9 +180,6 @@ export default {
const { category, action } = trackAlertsDetailsViewsOptions;
Tracking.event(category, action);
},
- alertRefresh() {
- this.$apollo.queries.alert.refetch();
- },
},
};
</script>
@@ -179,7 +187,7 @@ export default {
<template>
<div>
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError">
- {{ sidebarErrorMessage || $options.i18n.errorMsg }}
+ <p v-html="sidebarErrorMessage || $options.i18n.errorMsg"></p>
</gl-alert>
<gl-alert
v-if="createIssueError"
@@ -193,10 +201,10 @@ export default {
<div
v-if="alert"
class="alert-management-details gl-relative"
- :class="{ 'pr-sm-8': sidebarCollapsed }"
+ :class="{ 'pr-sm-8': sidebarStatus }"
>
<div
- class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid flex-column flex-sm-row"
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid flex-column flex-sm-row"
>
<div
data-testid="alert-header"
@@ -324,14 +332,14 @@ export default {
</template>
</gl-table>
</gl-tab>
+ <gl-tab data-testId="metricsTab" :title="$options.i18n.metricsTitle">
+ <alert-metrics :dashboard-url="alert.metricsDashboardUrl" />
+ </gl-tab>
</gl-tabs>
<alert-sidebar
- :project-path="projectPath"
:alert="alert"
- :sidebar-collapsed="sidebarCollapsed"
- @alert-refresh="alertRefresh"
@toggle-sidebar="toggleSidebar"
- @alert-sidebar-error="handleAlertSidebarError"
+ @alert-error="handleAlertSidebarError"
/>
</div>
</div>
diff --git a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue
new file mode 100644
index 00000000000..13b6a8e6653
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlEmptyState, GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ emptyState: {
+ opsgenie: {
+ title: s__('AlertManagement|Opsgenie is enabled'),
+ info: s__(
+ 'AlertManagement|You have enabled the Opsgenie integration. Your alerts will be visible directly in Opsgenie.',
+ ),
+ buttonText: s__('AlertManagement|View alerts in Opsgenie'),
+ },
+ gitlab: {
+ title: s__('AlertManagement|Surface alerts in GitLab'),
+ info: s__(
+ 'AlertManagement|Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.',
+ ),
+ buttonText: s__('AlertManagement|Authorize external service'),
+ },
+ },
+ moreInformation: s__('AlertManagement|More information'),
+ },
+ components: {
+ GlEmptyState,
+ GlButton,
+ },
+ props: {
+ enableAlertManagementPath: {
+ type: String,
+ required: true,
+ },
+ userCanEnableAlertManagement: {
+ type: Boolean,
+ required: true,
+ },
+ emptyAlertSvgPath: {
+ type: String,
+ required: true,
+ },
+ opsgenieMvcEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ opsgenieMvcTargetUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ emptyState() {
+ return {
+ ...(this.opsgenieMvcEnabled
+ ? this.$options.i18n.emptyState.opsgenie
+ : this.$options.i18n.emptyState.gitlab),
+ link: this.opsgenieMvcEnabled ? this.opsgenieMvcTargetUrl : this.enableAlertManagementPath,
+ };
+ },
+ alertsCanBeEnabled() {
+ return this.userCanEnableAlertManagement || this.opsgenieMvcEnabled;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-empty-state :title="emptyState.title" :svg-path="emptyAlertSvgPath">
+ <template #description>
+ <div class="gl-display-block">
+ <span>{{ emptyState.info }}</span>
+ <a
+ v-if="!opsgenieMvcEnabled"
+ href="/help/user/project/operations/alert_management.html"
+ target="_blank"
+ >
+ {{ $options.i18n.moreInformation }}
+ </a>
+ </div>
+ <div v-if="alertsCanBeEnabled" class="gl-display-block center gl-pt-4">
+ <gl-button category="primary" variant="success" :href="emptyState.link">
+ {{ emptyState.buttonText }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-empty-state>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue b/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue
new file mode 100644
index 00000000000..094f33fed3b
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue
@@ -0,0 +1,75 @@
+<script>
+import Tracking from '~/tracking';
+import { trackAlertListViewsOptions } from '../constants';
+import AlertManagementEmptyState from './alert_management_empty_state.vue';
+import AlertManagementTable from './alert_management_table.vue';
+
+export default {
+ components: {
+ AlertManagementEmptyState,
+ AlertManagementTable,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ alertManagementEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ enableAlertManagementPath: {
+ type: String,
+ required: true,
+ },
+ populatingAlertsHelpUrl: {
+ type: String,
+ required: true,
+ },
+ userCanEnableAlertManagement: {
+ type: Boolean,
+ required: true,
+ },
+ emptyAlertSvgPath: {
+ type: String,
+ required: true,
+ },
+ opsgenieMvcEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ opsgenieMvcTargetUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ mounted() {
+ this.trackPageViews();
+ },
+ methods: {
+ trackPageViews() {
+ const { category, action } = trackAlertListViewsOptions;
+ Tracking.event(category, action);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <alert-management-table
+ v-if="alertManagementEnabled"
+ :populating-alerts-help-url="populatingAlertsHelpUrl"
+ :project-path="projectPath"
+ />
+ <alert-management-empty-state
+ v-else
+ :empty-alert-svg-path="emptyAlertSvgPath"
+ :enable-alert-management-path="enableAlertManagementPath"
+ :user-can-enable-alert-management="userCanEnableAlertManagement"
+ :opsgenie-mvc-enabled="opsgenieMvcEnabled"
+ :opsgenie-mvc-target-url="opsgenieMvcTargetUrl"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue
index 37901c21f9b..7dd3d7b5dc3 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_list.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -1,23 +1,24 @@
<script>
import {
- GlEmptyState,
- GlDeprecatedButton,
GlLoadingIcon,
GlTable,
GlAlert,
GlIcon,
- GlDropdown,
- GlDropdownItem,
+ GlLink,
GlTabs,
GlTab,
GlBadge,
GlPagination,
+ GlSearchBoxByType,
+ GlSprintf,
} from '@gitlab/ui';
-import createFlash from '~/flash';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
+import { debounce, trim } from 'lodash';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { convertToSnakeCase } from '~/lib/utils/text_utility';
+import Tracking from '~/tracking';
import getAlerts from '../graphql/queries/get_alerts.query.graphql';
import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import {
@@ -27,11 +28,10 @@ import {
trackAlertListViewsOptions,
trackAlertStatusUpdateOptions,
} from '../constants';
-import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql';
-import { convertToSnakeCase } from '~/lib/utils/text_utility';
-import Tracking from '~/tracking';
+import AlertStatus from './alert_status.vue';
-const tdClass = 'table-col gl-display-flex d-md-table-cell gl-align-items-center';
+const tdClass =
+ 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap';
const thClass = 'gl-hover-bg-blue-50';
const bodyTrClass =
'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-bg-blue-50 gl-hover-cursor-pointer gl-hover-border-b-solid gl-hover-border-blue-200';
@@ -44,54 +44,57 @@ const initialPaginationState = {
lastPageSize: null,
};
+const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000;
+
export default {
i18n: {
noAlertsMsg: s__(
- "AlertManagement|No alerts available to display. If you think you're seeing this message in error, refresh the page.",
+ 'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.',
),
errorMsg: s__(
"AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.",
),
+ searchPlaceholder: __('Search or filter results...'),
},
fields: [
{
key: 'severity',
label: s__('AlertManagement|Severity'),
tdClass: `${tdClass} rounded-top text-capitalize`,
- thClass,
+ thClass: `${thClass} gl-w-eighth`,
sortable: true,
},
{
key: 'startedAt',
label: s__('AlertManagement|Start time'),
- thClass: `${thClass} js-started-at`,
- tdClass,
- sortable: true,
- },
- {
- key: 'endedAt',
- label: s__('AlertManagement|End time'),
- thClass,
+ thClass: `${thClass} js-started-at w-15p`,
tdClass,
sortable: true,
},
{
key: 'title',
label: s__('AlertManagement|Alert'),
- thClass: `${thClass} w-30p gl-pointer-events-none`,
+ thClass: `gl-pointer-events-none`,
tdClass,
- sortable: false,
},
{
key: 'eventCount',
label: s__('AlertManagement|Events'),
- thClass: `${thClass} text-right gl-pr-9 w-3rem`,
+ thClass: `${thClass} text-right gl-w-12`,
tdClass: `${tdClass} text-md-right`,
sortable: true,
},
{
+ key: 'issue',
+ label: s__('AlertManagement|Issue'),
+ thClass: 'gl-w-12 gl-pointer-events-none',
+ tdClass,
+ sortable: false,
+ },
+ {
key: 'assignees',
label: s__('AlertManagement|Assignees'),
+ thClass: 'gl-w-eighth gl-pointer-events-none',
tdClass,
},
{
@@ -102,46 +105,29 @@ export default {
sortable: true,
},
],
- statuses: {
- TRIGGERED: s__('AlertManagement|Triggered'),
- ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
- RESOLVED: s__('AlertManagement|Resolved'),
- },
severityLabels: ALERTS_SEVERITY_LABELS,
statusTabs: ALERTS_STATUS_TABS,
components: {
- GlEmptyState,
GlLoadingIcon,
GlTable,
GlAlert,
- GlDeprecatedButton,
TimeAgo,
- GlDropdown,
- GlDropdownItem,
GlIcon,
+ GlLink,
GlTabs,
GlTab,
GlBadge,
GlPagination,
+ GlSearchBoxByType,
+ GlSprintf,
+ AlertStatus,
},
props: {
projectPath: {
type: String,
required: true,
},
- alertManagementEnabled: {
- type: Boolean,
- required: true,
- },
- enableAlertManagementPath: {
- type: String,
- required: true,
- },
- userCanEnableAlertManagement: {
- type: Boolean,
- required: true,
- },
- emptyAlertSvgPath: {
+ populatingAlertsHelpUrl: {
type: String,
required: true,
},
@@ -152,6 +138,7 @@ export default {
query: getAlerts,
variables() {
return {
+ searchTerm: this.searchTerm,
projectPath: this.projectPath,
statuses: this.statusFilter,
sort: this.sort,
@@ -164,9 +151,20 @@ export default {
update(data) {
const { alertManagementAlerts: { nodes: list = [], pageInfo = {} } = {} } =
data.project || {};
+ const now = new Date();
+
+ const listWithData = list.map(alert => {
+ const then = new Date(alert.startedAt);
+ const diff = now - then;
+
+ return {
+ ...alert,
+ isNew: diff < TWELVE_HOURS_IN_MS,
+ };
+ });
return {
- list,
+ list: listWithData,
pageInfo,
};
},
@@ -178,6 +176,7 @@ export default {
query: getAlertsCountByStatus,
variables() {
return {
+ searchTerm: this.searchTerm,
projectPath: this.projectPath,
};
},
@@ -188,7 +187,9 @@ export default {
},
data() {
return {
+ searchTerm: '',
errored: false,
+ errorMessage: '',
isAlertDismissed: false,
isErrorAlertDismissed: false,
sort: 'STARTED_AT_DESC',
@@ -203,7 +204,11 @@ export default {
computed: {
showNoAlertsMsg() {
return (
- !this.errored && !this.loading && this.alertsCount?.all === 0 && !this.isAlertDismissed
+ !this.errored &&
+ !this.loading &&
+ this.alertsCount?.all === 0 &&
+ !this.searchTerm &&
+ !this.isAlertDismissed
);
},
showErrorMsg() {
@@ -215,9 +220,6 @@ export default {
hasAlerts() {
return this.alerts?.list?.length;
},
- tbodyTrClass() {
- return !this.loading && this.hasAlerts ? bodyTrClass : '';
- },
showPaginationControls() {
return Boolean(this.prevPage || this.nextPage);
},
@@ -249,30 +251,13 @@ export default {
this.resetPagination();
this.sort = `${sortingColumn}_${sortingDirection}`;
},
- updateAlertStatus(status, iid) {
- this.$apollo
- .mutate({
- mutation: updateAlertStatus,
- variables: {
- iid,
- status: status.toUpperCase(),
- projectPath: this.projectPath,
- },
- })
- .then(() => {
- this.trackStatusUpdate(status);
- this.$apollo.queries.alerts.refetch();
- this.$apollo.queries.alertsCount.refetch();
- this.resetPagination();
- })
- .catch(() => {
- createFlash(
- s__(
- 'AlertManagement|There was an error while updating the status of the alert. Please try again.',
- ),
- );
- });
- },
+ onInputChange: debounce(function debounceSearch(input) {
+ const trimmedInput = trim(input);
+ if (trimmedInput !== this.searchTerm) {
+ this.resetPagination();
+ this.searchTerm = trimmedInput;
+ }
+ }, 500),
navigateToAlertDetails({ iid }) {
return visitUrl(joinPaths(window.location.pathname, iid, 'details'));
},
@@ -290,6 +275,9 @@ export default {
? assignees.nodes[0]?.username
: s__('AlertManagement|Unassigned');
},
+ getIssueLink(item) {
+ return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid);
+ },
handlePageChange(page) {
const { startCursor, endCursor } = this.alerts.pageInfo;
@@ -312,20 +300,49 @@ export default {
resetPagination() {
this.pagination = initialPaginationState;
},
+ tbodyTrClass(item) {
+ return {
+ [bodyTrClass]: !this.loading && this.hasAlerts,
+ 'new-alert': item?.isNew,
+ };
+ },
+ handleAlertError(errorMessage) {
+ this.errored = true;
+ this.errorMessage = errorMessage;
+ },
+ dismissError() {
+ this.isErrorAlertDismissed = true;
+ this.errorMessage = '';
+ },
},
};
</script>
<template>
<div>
- <div v-if="alertManagementEnabled" class="alert-management-list">
+ <div class="alert-management-list">
<gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true">
- {{ $options.i18n.noAlertsMsg }}
+ <gl-sprintf :message="$options.i18n.noAlertsMsg">
+ <template #link="{ content }">
+ <gl-link
+ class="gl-display-inline-block"
+ :href="populatingAlertsHelpUrl"
+ target="_blank"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
</gl-alert>
- <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true">
- {{ $options.i18n.errorMsg }}
+ <gl-alert
+ v-if="showErrorMsg"
+ variant="danger"
+ data-testid="alert-error"
+ @dismiss="dismissError"
+ >
+ <p v-html="errorMessage || $options.i18n.errorMsg"></p>
</gl-alert>
- <gl-tabs @input="filterAlertsByStatus">
+ <gl-tabs content-class="gl-p-0" @input="filterAlertsByStatus">
<gl-tab v-for="tab in $options.statusTabs" :key="tab.status">
<template slot="title">
<span>{{ tab.title }}</span>
@@ -336,11 +353,19 @@ export default {
</gl-tab>
</gl-tabs>
+ <div class="gl-bg-gray-10 gl-p-5 gl-border-b-solid gl-border-b-1 gl-border-gray-100">
+ <gl-search-box-by-type
+ class="gl-bg-white"
+ :placeholder="$options.i18n.searchPlaceholder"
+ @input="onInputChange"
+ />
+ </div>
+
<h4 class="d-block d-md-none my-3">
{{ s__('AlertManagement|Alerts') }}
</h4>
<gl-table
- class="alert-management-table mt-3"
+ class="alert-management-table"
:items="alerts ? alerts.list : []"
:fields="$options.fields"
:show-empty="true"
@@ -352,6 +377,7 @@ export default {
:sort-desc.sync="sortDesc"
:sort-by.sync="sortBy"
sort-icon-left
+ fixed
@row-clicked="navigateToAlertDetails"
@sort-changed="fetchSortedData"
>
@@ -374,16 +400,19 @@ export default {
<time-ago v-if="item.startedAt" :time="item.startedAt" />
</template>
- <template #cell(endedAt)="{ item }">
- <time-ago v-if="item.endedAt" :time="item.endedAt" />
- </template>
-
<template #cell(eventCount)="{ item }">
{{ item.eventCount }}
</template>
<template #cell(title)="{ item }">
- <div class="gl-max-w-full text-truncate">{{ item.title }}</div>
+ <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div>
+ </template>
+
+ <template #cell(issue)="{ item }">
+ <gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)">
+ #{{ item.issueIid }}
+ </gl-link>
+ <div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div>
</template>
<template #cell(assignees)="{ item }">
@@ -393,22 +422,12 @@ export default {
</template>
<template #cell(status)="{ item }">
- <gl-dropdown :text="$options.statuses[item.status]" class="w-100" right>
- <gl-dropdown-item
- v-for="(label, field) in $options.statuses"
- :key="field"
- @click="updateAlertStatus(label, item.iid)"
- >
- <span class="d-flex">
- <gl-icon
- class="flex-shrink-0 append-right-4"
- :class="{ invisible: label.toUpperCase() !== item.status }"
- name="mobile-issue-close"
- />
- {{ label }}
- </span>
- </gl-dropdown-item>
- </gl-dropdown>
+ <alert-status
+ :alert="item"
+ :project-path="projectPath"
+ :is-sidebar="false"
+ @alert-error="handleAlertError"
+ />
</template>
<template #empty>
@@ -426,36 +445,9 @@ export default {
:prev-page="prevPage"
:next-page="nextPage"
align="center"
- class="gl-pagination prepend-top-default"
+ class="gl-pagination gl-mt-3"
@input="handlePageChange"
/>
</div>
- <gl-empty-state
- v-else
- :title="s__('AlertManagement|Surface alerts in GitLab')"
- :svg-path="emptyAlertSvgPath"
- >
- <template #description>
- <div class="d-block">
- <span>{{
- s__(
- 'AlertManagement|Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.',
- )
- }}</span>
- <a href="/help/user/project/operations/alert_management.html" target="_blank">
- {{ s__('AlertManagement|More information') }}
- </a>
- </div>
- <div v-if="userCanEnableAlertManagement" class="d-block center pt-4">
- <gl-deprecated-button
- category="primary"
- variant="success"
- :href="enableAlertManagementPath"
- >
- {{ s__('AlertManagement|Authorize external service') }}
- </gl-deprecated-button>
- </div>
- </template>
- </gl-empty-state>
</div>
</template>
diff --git a/app/assets/javascripts/alert_management/components/alert_metrics.vue b/app/assets/javascripts/alert_management/components/alert_metrics.vue
new file mode 100644
index 00000000000..c5b40edc672
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_metrics.vue
@@ -0,0 +1,56 @@
+<script>
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as Sentry from '@sentry/browser';
+
+Vue.use(Vuex);
+
+export default {
+ props: {
+ dashboardUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ metricEmbedComponent: null,
+ namespace: 'alertMetrics',
+ };
+ },
+ mounted() {
+ if (this.dashboardUrl) {
+ Promise.all([
+ import('~/monitoring/components/embeds/metric_embed.vue'),
+ import('~/monitoring/stores'),
+ ])
+ .then(([{ default: MetricEmbed }, { monitoringDashboard }]) => {
+ this.$store = new Vuex.Store({
+ modules: {
+ [this.namespace]: monitoringDashboard,
+ },
+ });
+ this.metricEmbedComponent = MetricEmbed;
+ })
+ .catch(e => Sentry.captureException(e));
+ }
+ },
+};
+</script>
+
+<template>
+ <div class="gl-py-3">
+ <div v-if="dashboardUrl" ref="metricsChart">
+ <component
+ :is="metricEmbedComponent"
+ v-if="metricEmbedComponent"
+ :dashboard-url="dashboardUrl"
+ :namespace="namespace"
+ />
+ </div>
+ <div v-else ref="emptyState">
+ {{ s__("AlertManagement|Metrics weren't available in the alerts payload.") }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/alert_sidebar.vue b/app/assets/javascripts/alert_management/components/alert_sidebar.vue
index dcd22e2062e..64e4089c85a 100644
--- a/app/assets/javascripts/alert_management/components/alert_sidebar.vue
+++ b/app/assets/javascripts/alert_management/components/alert_sidebar.vue
@@ -4,6 +4,8 @@ import SidebarTodo from './sidebar/sidebar_todo.vue';
import SidebarStatus from './sidebar/sidebar_status.vue';
import SidebarAssignees from './sidebar/sidebar_assignees.vue';
+import sidebarStatusQuery from '../graphql/queries/sidebar_status.query.graphql';
+
export default {
components: {
SidebarAssignees,
@@ -11,23 +13,34 @@ export default {
SidebarTodo,
SidebarStatus,
},
- props: {
- sidebarCollapsed: {
- type: Boolean,
- required: true,
- },
+ inject: {
projectPath: {
+ default: '',
+ },
+ projectId: {
type: String,
- required: true,
+ default: '',
},
+ },
+ props: {
alert: {
type: Object,
required: true,
},
},
+ apollo: {
+ sidebarStatus: {
+ query: sidebarStatusQuery,
+ },
+ },
+ data() {
+ return {
+ sidebarStatus: false,
+ };
+ },
computed: {
sidebarCollapsedClass() {
- return this.sidebarCollapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded';
+ return this.sidebarStatus ? 'right-sidebar-collapsed' : 'right-sidebar-expanded';
},
},
};
@@ -37,23 +50,32 @@ export default {
<aside :class="sidebarCollapsedClass" class="right-sidebar alert-sidebar">
<div class="issuable-sidebar js-issuable-update">
<sidebar-header
- :sidebar-collapsed="sidebarCollapsed"
+ :sidebar-collapsed="sidebarStatus"
+ :project-path="projectPath"
+ :alert="alert"
@toggle-sidebar="$emit('toggle-sidebar')"
+ @alert-error="$emit('alert-error', $event)"
+ />
+ <sidebar-todo
+ v-if="sidebarStatus"
+ :project-path="projectPath"
+ :alert="alert"
+ :sidebar-collapsed="sidebarStatus"
+ @alert-error="$emit('alert-error', $event)"
/>
- <sidebar-todo v-if="sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" />
<sidebar-status
:project-path="projectPath"
:alert="alert"
@toggle-sidebar="$emit('toggle-sidebar')"
- @alert-sidebar-error="$emit('alert-sidebar-error', $event)"
+ @alert-error="$emit('alert-error', $event)"
/>
<sidebar-assignees
:project-path="projectPath"
+ :project-id="projectId"
:alert="alert"
- :sidebar-collapsed="sidebarCollapsed"
- @alert-refresh="$emit('alert-refresh')"
+ :sidebar-collapsed="sidebarStatus"
@toggle-sidebar="$emit('toggle-sidebar')"
- @alert-sidebar-error="$emit('alert-sidebar-error', $event)"
+ @alert-error="$emit('alert-error', $event)"
/>
<div class="block"></div>
</div>
diff --git a/app/assets/javascripts/alert_management/components/alert_status.vue b/app/assets/javascripts/alert_management/components/alert_status.vue
new file mode 100644
index 00000000000..9b726fe2944
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_status.vue
@@ -0,0 +1,129 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import { trackAlertStatusUpdateOptions } from '../constants';
+import updateAlertStatus from '../graphql/mutations/update_alert_status.mutation.graphql';
+
+export default {
+ i18n: {
+ UPDATE_ALERT_STATUS_ERROR: s__(
+ 'AlertManagement|There was an error while updating the status of the alert.',
+ ),
+ UPDATE_ALERT_STATUS_INSTRUCTION: s__('AlertManagement|Please try again.'),
+ },
+ statuses: {
+ TRIGGERED: s__('AlertManagement|Triggered'),
+ ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
+ RESOLVED: s__('AlertManagement|Resolved'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlButton,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ alert: {
+ type: Object,
+ required: true,
+ },
+ isDropdownShowing: {
+ type: Boolean,
+ required: false,
+ },
+ isSidebar: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ dropdownClass() {
+ // eslint-disable-next-line no-nested-ternary
+ return this.isSidebar ? (this.isDropdownShowing ? 'show' : 'gl-display-none') : '';
+ },
+ },
+ methods: {
+ updateAlertStatus(status) {
+ this.$emit('handle-updating', true);
+ this.$apollo
+ .mutate({
+ mutation: updateAlertStatus,
+ variables: {
+ iid: this.alert.iid,
+ status: status.toUpperCase(),
+ projectPath: this.projectPath,
+ },
+ })
+ .then(resp => {
+ this.trackStatusUpdate(status);
+ this.$emit('hide-dropdown');
+
+ const errors = resp.data?.updateAlertStatus?.errors || [];
+
+ if (errors[0]) {
+ this.$emit(
+ 'alert-error',
+ `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${errors[0]}`,
+ );
+ }
+ })
+ .catch(() => {
+ this.$emit(
+ 'alert-error',
+ `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${this.$options.i18n.UPDATE_ALERT_STATUS_INSTRUCTION}`,
+ );
+ })
+ .finally(() => {
+ this.$emit('handle-updating', false);
+ });
+ },
+ trackStatusUpdate(status) {
+ const { category, action, label } = trackAlertStatusUpdateOptions;
+ Tracking.event(category, action, { label, property: status });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
+ <gl-dropdown
+ ref="dropdown"
+ right
+ :text="$options.statuses[alert.status]"
+ class="w-100"
+ toggle-class="dropdown-menu-toggle"
+ variant="outline-default"
+ @keydown.esc.native="$emit('hide-dropdown')"
+ @hide="$emit('hide-dropdown')"
+ >
+ <div v-if="isSidebar" class="dropdown-title text-center">
+ <span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ class="dropdown-title-button dropdown-menu-close"
+ icon="close"
+ @click="$emit('hide-dropdown')"
+ />
+ </div>
+ <div class="dropdown-content dropdown-body">
+ <gl-dropdown-item
+ v-for="(label, field) in $options.statuses"
+ :key="field"
+ data-testid="statusDropdownItem"
+ class="gl-vertical-align-middle"
+ :active="label.toUpperCase() === alert.status"
+ :active-class="'is-active'"
+ @click="updateAlertStatus(label)"
+ >
+ {{ label }}
+ </gl-dropdown-item>
+ </div>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
index 453a3901665..cb32a5ffd4f 100644
--- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
@@ -11,20 +11,26 @@ import {
GlSprintf,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
-import { s__ } from '~/locale';
-import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.graphql';
+import { s__, __ } from '~/locale';
+import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.mutation.graphql';
import SidebarAssignee from './sidebar_assignee.vue';
import { debounce } from 'lodash';
const DATA_REFETCH_DELAY = 250;
export default {
- FETCH_USERS_ERROR: s__(
- 'AlertManagement|There was an error while updating the assignee(s) list. Please try again.',
- ),
- UPDATE_ALERT_ASSIGNEES_ERROR: s__(
- 'AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again.',
- ),
+ i18n: {
+ FETCH_USERS_ERROR: s__(
+ 'AlertManagement|There was an error while updating the assignee(s) list. Please try again.',
+ ),
+ UPDATE_ALERT_ASSIGNEES_ERROR: s__(
+ 'AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again.',
+ ),
+ UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR: s__(
+ 'AlertManagement|This assignee cannot be assigned to this alert.',
+ ),
+ ASSIGNEES_BLOCK: s__('AlertManagement|Alert assignee(s): %{assignees}'),
+ },
components: {
GlIcon,
GlDropdown,
@@ -38,6 +44,10 @@ export default {
SidebarAssignee,
},
props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
projectPath: {
type: String,
required: true,
@@ -73,7 +83,7 @@ export default {
return this.alert?.assignees?.nodes[0]?.username;
},
assignedUser() {
- return this.userName || s__('AlertManagement|None');
+ return this.userName || __('None');
},
sortedUsers() {
return this.users
@@ -122,20 +132,20 @@ export default {
updateAssigneesDropdown() {
this.isDropdownSearching = true;
return axios
- .get(this.buildUrl(gon.relative_url_root, '/autocomplete/users.json'), {
+ .get(this.buildUrl(gon.relative_url_root, '/-/autocomplete/users.json'), {
params: {
search: this.search,
per_page: 20,
active: true,
current_user: true,
- project_id: gon?.current_project_id,
+ project_id: this.projectId,
},
})
.then(({ data }) => {
this.users = data;
})
.catch(() => {
- this.$emit('alert-sidebar-error', this.$options.FETCH_USERS_ERROR);
+ this.$emit('alert-error', this.$options.i18n.FETCH_USERS_ERROR);
})
.finally(() => {
this.isDropdownSearching = false;
@@ -152,12 +162,18 @@ export default {
projectPath: this.projectPath,
},
})
- .then(() => {
+ .then(({ data: { alertSetAssignees: { errors } = [] } = {} } = {}) => {
this.hideDropdown();
- this.$emit('alert-refresh');
+
+ if (errors[0]) {
+ this.$emit(
+ 'alert-error',
+ `${this.$options.i18n.UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR} ${errors[0]}.`,
+ );
+ }
})
.catch(() => {
- this.$emit('alert-sidebar-error', this.$options.UPDATE_ALERT_ASSIGNEES_ERROR);
+ this.$emit('alert-error', this.$options.i18n.UPDATE_ALERT_ASSIGNEES_ERROR);
})
.finally(() => {
this.isUpdating = false;
@@ -174,7 +190,7 @@ export default {
<gl-loading-icon v-if="isUpdating" />
</div>
<gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
- <gl-sprintf :message="s__('AlertManagement|Alert assignee(s): %{assignees}')">
+ <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK">
<template #assignees>
{{ assignedUser }}
</template>
@@ -183,7 +199,7 @@ export default {
<div class="hide-collapsed">
<p class="title gl-display-flex gl-justify-content-space-between">
- {{ s__('AlertManagement|Assignee') }}
+ {{ __('Assignee') }}
<a
v-if="isEditable"
ref="editButton"
@@ -192,7 +208,7 @@ export default {
@click="toggleFormDropdown"
@keydown.esc="hideDropdown"
>
- {{ s__('AlertManagement|Edit') }}
+ {{ __('Edit') }}
</a>
</p>
@@ -207,7 +223,7 @@ export default {
@hide="hideDropdown"
>
<div class="dropdown-title">
- <span class="alert-title">{{ s__('AlertManagement|Assign To') }}</span>
+ <span class="alert-title">{{ __('Assign To') }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
@@ -232,12 +248,12 @@ export default {
active-class="is-active"
@click="updateAlertAssignees('')"
>
- {{ s__('AlertManagement|Unassigned') }}
+ {{ __('Unassigned') }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-header class="mt-0">
- {{ s__('AlertManagement|Assignee') }}
+ {{ __('Assignee') }}
</gl-dropdown-header>
<sidebar-assignee
v-for="user in sortedUsers"
@@ -248,7 +264,7 @@ export default {
/>
</template>
<gl-dropdown-item v-else-if="userListEmpty">
- {{ s__('AlertManagement|No Matching Results') }}
+ {{ __('No Matching Results') }}
</gl-dropdown-item>
<gl-loading-icon v-else />
</div>
@@ -261,7 +277,7 @@ export default {
assignedUser
}}</span>
<span v-else class="gl-display-flex gl-align-items-center">
- {{ s__('AlertManagement|None -') }}
+ {{ __('None') }} -
<gl-button
class="gl-pl-2"
href="#"
@@ -269,7 +285,7 @@ export default {
data-testid="unassigned-users"
@click="updateAlertAssignees(currentUser)"
>
- {{ s__('AlertManagement| assign yourself') }}
+ {{ __('assign yourself') }}
</gl-button>
</span>
</p>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue
index 047793d8cee..fd40b5d9f65 100644
--- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue
@@ -8,6 +8,14 @@ export default {
SidebarTodo,
},
props: {
+ alert: {
+ type: Object,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
sidebarCollapsed: {
type: Boolean,
required: true,
@@ -17,18 +25,17 @@ export default {
</script>
<template>
- <div class="block d-flex justify-content-between">
+ <div class="block gl-display-flex gl-justify-content-space-between">
<span class="issuable-header-text hide-collapsed">
- {{ __('Quick actions') }}
+ {{ __('To Do') }}
</span>
- <toggle-sidebar
- :collapsed="sidebarCollapsed"
- css-classes="ml-auto"
- @toggle="$emit('toggle-sidebar')"
+ <sidebar-todo
+ v-if="!sidebarCollapsed"
+ :project-path="projectPath"
+ :alert="alert"
+ :sidebar-collapsed="sidebarCollapsed"
+ @alert-error="$emit('alert-error', $event)"
/>
- <!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 -->
- <template v-if="false">
- <sidebar-todo v-if="!sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" />
- </template>
+ <toggle-sidebar :collapsed="sidebarCollapsed" @toggle="$emit('toggle-sidebar')" />
</div>
</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue
index 89dbbedd9c1..44a81aba828 100644
--- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue
@@ -1,17 +1,7 @@
<script>
-import {
- GlIcon,
- GlDropdown,
- GlDropdownItem,
- GlLoadingIcon,
- GlTooltip,
- GlButton,
- GlSprintf,
-} from '@gitlab/ui';
+import { GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
-import Tracking from '~/tracking';
-import { trackAlertStatusUpdateOptions } from '../../constants';
-import updateAlertStatus from '../../graphql/mutations/update_alert_status.graphql';
+import AlertStatus from '../alert_status.vue';
export default {
statuses: {
@@ -21,12 +11,10 @@ export default {
},
components: {
GlIcon,
- GlDropdown,
- GlDropdownItem,
GlLoadingIcon,
GlTooltip,
- GlButton,
GlSprintf,
+ AlertStatus,
},
props: {
projectPath: {
@@ -60,44 +48,13 @@ export default {
},
toggleFormDropdown() {
this.isDropdownShowing = !this.isDropdownShowing;
- const { dropdown } = this.$refs.dropdown.$refs;
+ const { dropdown } = this.$children[2].$refs.dropdown.$refs;
if (dropdown && this.isDropdownShowing) {
dropdown.show();
}
},
- isSelected(status) {
- return this.alert.status === status;
- },
- updateAlertStatus(status) {
- this.isUpdating = true;
- this.$apollo
- .mutate({
- mutation: updateAlertStatus,
- variables: {
- iid: this.alert.iid,
- status: status.toUpperCase(),
- projectPath: this.projectPath,
- },
- })
- .then(() => {
- this.trackStatusUpdate(status);
- this.hideDropdown();
- })
- .catch(() => {
- this.$emit(
- 'alert-sidebar-error',
- s__(
- 'AlertManagement|There was an error while updating the status of the alert. Please try again.',
- ),
- );
- })
- .finally(() => {
- this.isUpdating = false;
- });
- },
- trackStatusUpdate(status) {
- const { category, action, label } = trackAlertStatusUpdateOptions;
- Tracking.event(category, action, { label, property: status });
+ handleUpdating(updating) {
+ this.isUpdating = updating;
},
},
};
@@ -132,41 +89,15 @@ export default {
</a>
</p>
- <div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
- <gl-dropdown
- ref="dropdown"
- :text="$options.statuses[alert.status]"
- class="w-100"
- toggle-class="dropdown-menu-toggle"
- variant="outline-default"
- @keydown.esc.native="hideDropdown"
- @hide="hideDropdown"
- >
- <div class="dropdown-title">
- <span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span>
- <gl-button
- :aria-label="__('Close')"
- variant="link"
- class="dropdown-title-button dropdown-menu-close"
- icon="close"
- @click="hideDropdown"
- />
- </div>
- <div class="dropdown-content dropdown-body">
- <gl-dropdown-item
- v-for="(label, field) in $options.statuses"
- :key="field"
- data-testid="statusDropdownItem"
- class="gl-vertical-align-middle"
- :active="label.toUpperCase() === alert.status"
- :active-class="'is-active'"
- @click="updateAlertStatus(label)"
- >
- {{ label }}
- </gl-dropdown-item>
- </div>
- </gl-dropdown>
- </div>
+ <alert-status
+ :alert="alert"
+ :project-path="projectPath"
+ :is-dropdown-showing="isDropdownShowing"
+ :is-sidebar="true"
+ @alert-error="$emit('alert-error', $event)"
+ @hide-dropdown="hideDropdown"
+ @handle-updating="handleUpdating"
+ />
<gl-loading-icon v-if="isUpdating" :inline="true" />
<p
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue
index 87090165f82..7d3135ad50d 100644
--- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue
@@ -1,29 +1,123 @@
<script>
+import { s__ } from '~/locale';
import Todo from '~/sidebar/components/todo_toggle/todo.vue';
+import axios from '~/lib/utils/axios_utils';
+import createAlertTodo from '../../graphql/mutations/alert_todo_create.graphql';
export default {
+ i18n: {
+ UPDATE_ALERT_TODO_ERROR: s__(
+ 'AlertManagement|There was an error while updating the To Do of the alert.',
+ ),
+ },
components: {
Todo,
},
props: {
+ alert: {
+ type: Object,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
sidebarCollapsed: {
type: Boolean,
required: true,
},
},
+ data() {
+ return {
+ isUpdating: false,
+ isTodo: false,
+ todo: '',
+ };
+ },
+ computed: {
+ alertID() {
+ return parseInt(this.alert.iid, 10);
+ },
+ },
+ methods: {
+ updateToDoCount(add) {
+ const oldCount = parseInt(document.querySelector('.todos-count').innerText, 10);
+ const count = add ? oldCount + 1 : oldCount - 1;
+ const headerTodoEvent = new CustomEvent('todo:toggle', {
+ detail: {
+ count,
+ },
+ });
+
+ return document.dispatchEvent(headerTodoEvent);
+ },
+ toggleTodo() {
+ if (this.todo) {
+ return this.markAsDone();
+ }
+
+ this.isUpdating = true;
+ return this.$apollo
+ .mutate({
+ mutation: createAlertTodo,
+ variables: {
+ iid: this.alert.iid,
+ projectPath: this.projectPath,
+ },
+ })
+ .then(({ data: { alertTodoCreate: { todo = {}, errors = [] } } = {} } = {}) => {
+ if (errors[0]) {
+ return this.$emit(
+ 'alert-error',
+ `${this.$options.i18n.UPDATE_ALERT_TODO_ERROR} ${errors[0]}.`,
+ );
+ }
+
+ this.todo = todo.id;
+ return this.updateToDoCount(true);
+ })
+ .catch(() => {
+ this.$emit(
+ 'alert-error',
+ `${this.$options.i18n.UPDATE_ALERT_TODO_ERROR} ${s__(
+ 'AlertManagement|Please try again.',
+ )}`,
+ );
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ markAsDone() {
+ this.isUpdating = true;
+
+ return axios
+ .delete(`/dashboard/todos/${this.todo.split('/').pop()}`)
+ .then(() => {
+ this.todo = '';
+ return this.updateToDoCount(false);
+ })
+ .catch(() => {
+ this.$emit('alert-error', this.$options.i18n.UPDATE_ALERT_TODO_ERROR);
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ },
};
</script>
-<!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 -->
<template>
- <div v-if="false" :class="{ 'block todo': sidebarCollapsed }">
+ <div :class="{ 'block todo': sidebarCollapsed, 'gl-ml-auto': !sidebarCollapsed }">
<todo
+ data-testid="alert-todo-button"
:collapsed="sidebarCollapsed"
- :issuable-id="1"
- :is-todo="false"
- :is-action-active="false"
+ :issuable-id="alertID"
+ :is-todo="todo !== ''"
+ :is-action-active="isUpdating"
issuable-type="alert"
- @toggleTodo="() => {}"
+ @toggleTodo="toggleTodo"
/>
</div>
</template>
diff --git a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
index 9042d51aecf..39717ab609f 100644
--- a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
+++ b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
@@ -24,7 +24,7 @@ export default {
return { ...author, id: id?.split('/').pop() };
},
iconHtml() {
- return spriteIcon('user');
+ return spriteIcon(this.note?.systemNoteIconName);
},
},
};
diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js
index aa8a839ea3f..2820bcb9665 100644
--- a/app/assets/javascripts/alert_management/details.js
+++ b/app/assets/javascripts/alert_management/details.js
@@ -3,45 +3,59 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import AlertDetails from './components/alert_details.vue';
+import sidebarStatusQuery from './graphql/queries/sidebar_status.query.graphql';
Vue.use(VueApollo);
export default selector => {
const domEl = document.querySelector(selector);
- const { alertId, projectPath, projectIssuesPath } = domEl.dataset;
+ const { alertId, projectPath, projectIssuesPath, projectId } = domEl.dataset;
+
+ const resolvers = {
+ Mutation: {
+ toggleSidebarStatus: (_, __, { cache }) => {
+ const data = cache.readQuery({ query: sidebarStatusQuery });
+ data.sidebarStatus = !data.sidebarStatus;
+ cache.writeQuery({ query: sidebarStatusQuery, data });
+ },
+ },
+ };
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- cacheConfig: {
- dataIdFromObject: object => {
- // eslint-disable-next-line no-underscore-dangle
- if (object.__typename === 'AlertManagementAlert') {
- return object.iid;
- }
- return defaultDataIdFromObject(object);
- },
+ defaultClient: createDefaultClient(resolvers, {
+ cacheConfig: {
+ dataIdFromObject: object => {
+ // eslint-disable-next-line no-underscore-dangle
+ if (object.__typename === 'AlertManagementAlert') {
+ return object.iid;
+ }
+ return defaultDataIdFromObject(object);
},
},
- ),
+ }),
+ });
+
+ apolloProvider.clients.defaultClient.cache.writeData({
+ data: {
+ sidebarStatus: false,
+ },
});
// eslint-disable-next-line no-new
new Vue({
el: selector,
+ provide: {
+ projectPath,
+ alertId,
+ projectIssuesPath,
+ projectId,
+ },
apolloProvider,
components: {
AlertDetails,
},
render(createElement) {
- return createElement('alert-details', {
- props: {
- alertId,
- projectPath,
- projectIssuesPath,
- },
- });
+ return createElement('alert-details', {});
},
});
};
diff --git a/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql
index c72300e9757..74b425717a0 100644
--- a/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql
+++ b/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql
@@ -1,16 +1,17 @@
#import "~/graphql_shared/fragments/author.fragment.graphql"
fragment AlertNote on Note {
+ id
+ author {
id
- author {
- id
- state
- ...Author
- }
- body
- bodyHtml
- createdAt
- discussion {
- id
- }
+ state
+ ...Author
+ }
+ body
+ bodyHtml
+ createdAt
+ discussion {
+ id
+ }
+ systemNoteIconName
}
diff --git a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql
index cbe7e169be3..18fab429164 100644
--- a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql
+++ b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql
@@ -5,9 +5,11 @@ fragment AlertDetailItem on AlertManagementAlert {
...AlertListItem
createdAt
monitoringTool
+ metricsDashboardUrl
service
description
updatedAt
+ endedAt
details
notes {
nodes {
diff --git a/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql
index 746c4435f38..c37f29c74fc 100644
--- a/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql
+++ b/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql
@@ -4,7 +4,6 @@ fragment AlertListItem on AlertManagementAlert {
severity
status
startedAt
- endedAt
eventCount
issueIid
assignees {
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql b/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql
index efeaf8fa372..40b4b6ae854 100644
--- a/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql
+++ b/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql
@@ -1,4 +1,6 @@
-mutation($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) {
+#import "../fragments/alert_note.fragment.graphql"
+
+mutation alertSetAssignees($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) {
alertSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath }
) {
@@ -10,6 +12,11 @@ mutation($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) {
username
}
}
+ notes {
+ nodes {
+ ...AlertNote
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql b/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql
new file mode 100644
index 00000000000..cdf3d763302
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql
@@ -0,0 +1,11 @@
+mutation($projectPath: ID!, $iid: String!) {
+ alertTodoCreate(input: { iid: $iid, projectPath: $projectPath }) {
+ errors
+ alert {
+ iid
+ }
+ todo {
+ id
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql
deleted file mode 100644
index 664596ab88f..00000000000
--- a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql
+++ /dev/null
@@ -1,8 +0,0 @@
-mutation ($projectPath: ID!, $iid: String!) {
- createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) {
- errors
- issue {
- iid
- }
- }
-}
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql
new file mode 100644
index 00000000000..bc4d91a51d1
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql
@@ -0,0 +1,8 @@
+mutation createAlertIssue($projectPath: ID!, $iid: String!) {
+ createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) {
+ errors
+ issue {
+ iid
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql b/app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql
new file mode 100644
index 00000000000..f666fcd6782
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql
@@ -0,0 +1,3 @@
+mutation toggleSidebarStatus {
+ toggleSidebarStatus @client
+}
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql
deleted file mode 100644
index 09151f233f5..00000000000
--- a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql
+++ /dev/null
@@ -1,10 +0,0 @@
-mutation ($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) {
- updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) {
- errors
- alert {
- iid,
- status,
- endedAt
- }
- }
-}
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.mutation.graphql b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.mutation.graphql
new file mode 100644
index 00000000000..ba1e607bc10
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.mutation.graphql
@@ -0,0 +1,17 @@
+#import "../fragments/alert_note.fragment.graphql"
+
+mutation updateAlertStatus($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) {
+ updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) {
+ errors
+ alert {
+ iid
+ status
+ endedAt
+ notes {
+ nodes {
+ ...AlertNote
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
index c02b8accdd1..8881f49b689 100644
--- a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
+++ b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
@@ -1,11 +1,11 @@
#import "../fragments/detail_item.fragment.graphql"
query alertDetails($fullPath: ID!, $alertId: String) {
- project(fullPath: $fullPath) {
- alertManagementAlerts(iid: $alertId) {
- nodes {
- ...AlertDetailItem
- }
- }
+ project(fullPath: $fullPath) {
+ alertManagementAlerts(iid: $alertId) {
+ nodes {
+ ...AlertDetailItem
+ }
}
+ }
}
diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql
index 1d3c3c83cc1..8ac00bbc6b5 100644
--- a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql
+++ b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql
@@ -1,32 +1,34 @@
#import "../fragments/list_item.fragment.graphql"
query getAlerts(
- $projectPath: ID!,
- $statuses: [AlertManagementStatus!],
- $sort: AlertManagementAlertSort,
- $firstPageSize: Int,
- $lastPageSize: Int,
- $prevPageCursor: String = ""
- $nextPageCursor: String = ""
+ $searchTerm: String
+ $projectPath: ID!
+ $statuses: [AlertManagementStatus!]
+ $sort: AlertManagementAlertSort
+ $firstPageSize: Int
+ $lastPageSize: Int
+ $prevPageCursor: String = ""
+ $nextPageCursor: String = ""
) {
- project(fullPath: $projectPath, ) {
- alertManagementAlerts(
- statuses: $statuses,
- sort: $sort,
- first: $firstPageSize
- last: $lastPageSize,
- after: $nextPageCursor,
- before: $prevPageCursor
- ) {
- nodes {
- ...AlertListItem
- },
- pageInfo {
- hasNextPage
- endCursor
- hasPreviousPage
- startCursor
- }
- }
+ project(fullPath: $projectPath) {
+ alertManagementAlerts(
+ search: $searchTerm
+ statuses: $statuses
+ sort: $sort
+ first: $firstPageSize
+ last: $lastPageSize
+ after: $nextPageCursor
+ before: $prevPageCursor
+ ) {
+ nodes {
+ ...AlertListItem
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ hasPreviousPage
+ startCursor
+ }
}
+ }
}
diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql
index 1143050200c..5a6faea5cd8 100644
--- a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql
+++ b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql
@@ -1,11 +1,11 @@
-query getAlertsCount($projectPath: ID!) {
- project(fullPath: $projectPath) {
- alertManagementAlertStatusCounts {
- all
- open
- acknowledged
- resolved
- triggered
- }
+query getAlertsCount($searchTerm: String, $projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ alertManagementAlertStatusCounts(search: $searchTerm) {
+ all
+ open
+ acknowledged
+ resolved
+ triggered
}
+ }
}
diff --git a/app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql
new file mode 100644
index 00000000000..61c570c5cd0
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql
@@ -0,0 +1,3 @@
+query sidebarStatus {
+ sidebarStatus @client
+}
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
index cae6a536b56..3f78ca66a59 100644
--- a/app/assets/javascripts/alert_management/list.js
+++ b/app/assets/javascripts/alert_management/list.js
@@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import { parseBoolean } from '~/lib/utils/common_utils';
-import AlertManagementList from './components/alert_management_list.vue';
+import AlertManagementList from './components/alert_management_list_wrapper.vue';
Vue.use(VueApollo);
@@ -11,11 +11,18 @@ export default () => {
const selector = '#js-alert_management';
const domEl = document.querySelector(selector);
- const { projectPath, enableAlertManagementPath, emptyAlertSvgPath } = domEl.dataset;
- let { alertManagementEnabled, userCanEnableAlertManagement } = domEl.dataset;
+ const {
+ projectPath,
+ enableAlertManagementPath,
+ emptyAlertSvgPath,
+ populatingAlertsHelpUrl,
+ opsgenieMvcTargetUrl,
+ } = domEl.dataset;
+ let { alertManagementEnabled, userCanEnableAlertManagement, opsgenieMvcEnabled } = domEl.dataset;
alertManagementEnabled = parseBoolean(alertManagementEnabled);
userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement);
+ opsgenieMvcEnabled = parseBoolean(opsgenieMvcEnabled);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
@@ -45,9 +52,12 @@ export default () => {
props: {
projectPath,
enableAlertManagementPath,
+ populatingAlertsHelpUrl,
emptyAlertSvgPath,
alertManagementEnabled,
userCanEnableAlertManagement,
+ opsgenieMvcTargetUrl,
+ opsgenieMvcEnabled,
},
});
},
diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
index ac30b086875..a2d94fb8083 100644
--- a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
+++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
@@ -64,6 +64,11 @@ export default {
type: Boolean,
required: true,
},
+ isDisabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -142,7 +147,7 @@ export default {
<gl-form-group :label="__('Active')" label-for="activated" label-class="label-bold">
<toggle-button
id="activated"
- :disabled-input="loadingActivated"
+ :disabled-input="loadingActivated || isDisabled"
:is-loading="loadingActivated"
:value="activated"
@change="toggleActivated"
@@ -152,7 +157,11 @@ export default {
<div class="input-group">
<gl-form-input id="url" :readonly="true" :value="url" />
<span class="input-group-append">
- <clipboard-button :text="url" :title="$options.COPY_TO_CLIPBOARD" />
+ <clipboard-button
+ :text="url"
+ :title="$options.COPY_TO_CLIPBOARD"
+ :disabled="isDisabled"
+ />
</span>
</div>
</gl-form-group>
@@ -164,10 +173,16 @@ export default {
<div class="input-group">
<gl-form-input id="authorization-key" :readonly="true" :value="authorizationKey" />
<span class="input-group-append">
- <clipboard-button :text="authorizationKey" :title="$options.COPY_TO_CLIPBOARD" />
+ <clipboard-button
+ :text="authorizationKey"
+ :title="$options.COPY_TO_CLIPBOARD"
+ :disabled="isDisabled"
+ />
</span>
</div>
- <gl-button v-gl-modal.authKeyModal class="mt-2">{{ $options.RESET_KEY }}</gl-button>
+ <gl-button v-gl-modal.authKeyModal class="mt-2" :disabled="isDisabled">{{
+ $options.RESET_KEY
+ }}</gl-button>
<gl-modal
modal-id="authKeyModal"
:title="$options.RESET_KEY"
diff --git a/app/assets/javascripts/alerts_service_settings/index.js b/app/assets/javascripts/alerts_service_settings/index.js
index c26adf24a7f..fe83ced2ee7 100644
--- a/app/assets/javascripts/alerts_service_settings/index.js
+++ b/app/assets/javascripts/alerts_service_settings/index.js
@@ -14,8 +14,11 @@ export default el => {
formPath,
authorizationKey,
url,
+ disabled,
} = el.dataset;
+
const activated = parseBoolean(activatedStr);
+ const isDisabled = parseBoolean(disabled);
return new Vue({
el,
@@ -28,6 +31,7 @@ export default el => {
formPath,
initialAuthorizationKey: authorizationKey,
url,
+ isDisabled,
},
});
},
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
new file mode 100644
index 00000000000..18c9f82f052
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -0,0 +1,563 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormInputGroup,
+ GlFormTextarea,
+ GlLink,
+ GlModal,
+ GlModalDirective,
+ GlSprintf,
+ GlFormSelect,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ToggleButton from '~/vue_shared/components/toggle_button.vue';
+import csrf from '~/lib/utils/csrf';
+import service from '../services';
+import {
+ i18n,
+ serviceOptions,
+ JSON_VALIDATE_DELAY,
+ targetPrometheusUrlPlaceholder,
+ targetOpsgenieUrlPlaceholder,
+} from '../constants';
+
+export default {
+ i18n,
+ csrf,
+ targetOpsgenieUrlPlaceholder,
+ targetPrometheusUrlPlaceholder,
+ components: {
+ GlAlert,
+ GlButton,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormInputGroup,
+ GlFormSelect,
+ GlFormTextarea,
+ GlLink,
+ GlModal,
+ GlSprintf,
+ ClipboardButton,
+ ToggleButton,
+ },
+ directives: {
+ 'gl-modal': GlModalDirective,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ prometheus: {
+ type: Object,
+ required: true,
+ validator: ({ activated }) => {
+ return activated !== undefined;
+ },
+ },
+ generic: {
+ type: Object,
+ required: true,
+ validator: ({ formPath }) => {
+ return formPath !== undefined;
+ },
+ },
+ opsgenie: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ activated: {
+ generic: this.generic.activated,
+ prometheus: this.prometheus.activated,
+ opsgenie: this.opsgenie?.activated,
+ },
+ loading: false,
+ authorizationKey: {
+ generic: this.generic.initialAuthorizationKey,
+ prometheus: this.prometheus.prometheusAuthorizationKey,
+ },
+ selectedEndpoint: serviceOptions[0].value,
+ options: serviceOptions,
+ targetUrl: null,
+ feedback: {
+ variant: 'danger',
+ feedbackMessage: null,
+ isFeedbackDismissed: false,
+ },
+ serverError: null,
+ testAlert: {
+ json: null,
+ error: null,
+ },
+ canSaveForm: false,
+ };
+ },
+ computed: {
+ sections() {
+ return [
+ {
+ text: this.$options.i18n.usageSection,
+ url: this.generic.alertsUsageUrl,
+ },
+ {
+ text: this.$options.i18n.setupSection,
+ url: this.generic.alertsSetupUrl,
+ },
+ ];
+ },
+ isPrometheus() {
+ return this.selectedEndpoint === 'prometheus';
+ },
+ isOpsgenie() {
+ return this.selectedEndpoint === 'opsgenie';
+ },
+ selectedService() {
+ switch (this.selectedEndpoint) {
+ case 'generic': {
+ return {
+ url: this.generic.url,
+ authKey: this.authorizationKey.generic,
+ active: this.activated.generic,
+ resetKey: this.resetGenericKey.bind(this),
+ };
+ }
+ case 'prometheus': {
+ return {
+ url: this.prometheus.prometheusUrl,
+ authKey: this.authorizationKey.prometheus,
+ active: this.activated.prometheus,
+ resetKey: this.resetPrometheusKey.bind(this),
+ targetUrl: this.prometheus.prometheusApiUrl,
+ };
+ }
+ case 'opsgenie': {
+ return {
+ targetUrl: this.opsgenie.opsgenieMvcTargetUrl,
+ active: this.activated.opsgenie,
+ };
+ }
+ default: {
+ return {};
+ }
+ }
+ },
+ showFeedbackMsg() {
+ return this.feedback.feedbackMessage && !this.isFeedbackDismissed;
+ },
+ showAlertSave() {
+ return (
+ this.feedback.feedbackMessage === this.$options.i18n.testAlertFailed &&
+ !this.isFeedbackDismissed
+ );
+ },
+ prometheusInfo() {
+ return this.isPrometheus ? this.$options.i18n.prometheusInfo : '';
+ },
+ jsonIsValid() {
+ return this.testAlert.error === null;
+ },
+ canTestAlert() {
+ return this.selectedService.active && this.testAlert.json !== null;
+ },
+ canSaveConfig() {
+ return !this.loading && this.canSaveForm;
+ },
+ baseUrlPlaceholder() {
+ return this.isOpsgenie
+ ? this.$options.targetOpsgenieUrlPlaceholder
+ : this.$options.targetPrometheusUrlPlaceholder;
+ },
+ },
+ watch: {
+ 'testAlert.json': debounce(function debouncedJsonValidate() {
+ this.validateJson();
+ }, JSON_VALIDATE_DELAY),
+ targetUrl(oldVal, newVal) {
+ if (newVal && oldVal !== this.selectedService.targetUrl) {
+ this.canSaveForm = true;
+ }
+ },
+ },
+ mounted() {
+ if (
+ this.activated.prometheus ||
+ this.activated.generic ||
+ !this.opsgenie.opsgenieMvcIsAvailable
+ ) {
+ this.removeOpsGenieOption();
+ } else if (this.activated.opsgenie) {
+ this.setOpsgenieAsDefault();
+ }
+ },
+ methods: {
+ createUserErrorMessage(errors) {
+ // eslint-disable-next-line prefer-destructuring
+ this.serverError = Object.values(errors)[0][0];
+ },
+ setOpsgenieAsDefault() {
+ this.options = this.options.map(el => {
+ if (el.value !== 'opsgenie') {
+ return { ...el, disabled: true };
+ }
+ return { ...el, disabled: false };
+ });
+ this.selectedEndpoint = this.options.find(({ value }) => value === 'opsgenie').value;
+ if (this.targetUrl === null) {
+ this.targetUrl = this.selectedService.targetUrl;
+ }
+ },
+ removeOpsGenieOption() {
+ this.options = this.options.map(el => {
+ if (el.value !== 'opsgenie') {
+ return { ...el, disabled: false };
+ }
+ return { ...el, disabled: true };
+ });
+ },
+ resetFormValues() {
+ this.testAlert.json = null;
+ this.targetUrl = this.selectedService.targetUrl;
+ },
+ dismissFeedback() {
+ this.serverError = null;
+ this.feedback = { ...this.feedback, feedbackMessage: null };
+ this.isFeedbackDismissed = false;
+ },
+ resetGenericKey() {
+ return service
+ .updateGenericKey({ endpoint: this.generic.formPath, params: { service: { token: '' } } })
+ .then(({ data: { token } }) => {
+ this.authorizationKey.generic = token;
+ this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' });
+ })
+ .catch(() => {
+ this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' });
+ });
+ },
+ resetPrometheusKey() {
+ return service
+ .updatePrometheusKey({ endpoint: this.prometheus.prometheusResetKeyPath })
+ .then(({ data: { token } }) => {
+ this.authorizationKey.prometheus = token;
+ this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' });
+ })
+ .catch(() => {
+ this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' });
+ });
+ },
+ toggleService(value) {
+ this.canSaveForm = true;
+ if (this.isPrometheus) {
+ this.activated.prometheus = value;
+ } else {
+ this.activated[this.selectedEndpoint] = value;
+ }
+ },
+ toggle(value) {
+ return this.isPrometheus ? this.togglePrometheusActive(value) : this.toggleActivated(value);
+ },
+ toggleActivated(value) {
+ this.loading = true;
+ return service
+ .updateGenericActive({
+ endpoint: this[this.selectedEndpoint].formPath,
+ params: this.isOpsgenie
+ ? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } }
+ : { service: { active: value } },
+ })
+ .then(() => {
+ this.activated[this.selectedEndpoint] = value;
+ this.toggleSuccess(value);
+
+ if (!this.isOpsgenie && value) {
+ if (!this.selectedService.authKey) {
+ return window.location.reload();
+ }
+
+ return this.removeOpsGenieOption();
+ }
+
+ if (this.isOpsgenie && value) {
+ return this.setOpsgenieAsDefault();
+ }
+
+ // eslint-disable-next-line no-return-assign
+ return (this.options = serviceOptions);
+ })
+ .catch(({ response: { data: { errors } = {} } = {} }) => {
+ this.createUserErrorMessage(errors);
+ this.setFeedback({
+ feedbackMessage: `${this.$options.i18n.errorMsg}.`,
+ variant: 'danger',
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ this.canSaveForm = false;
+ });
+ },
+ togglePrometheusActive(value) {
+ this.loading = true;
+ return service
+ .updatePrometheusActive({
+ endpoint: this.prometheus.prometheusFormPath,
+ params: {
+ token: this.$options.csrf.token,
+ config: value,
+ url: this.targetUrl,
+ redirect: window.location,
+ },
+ })
+ .then(() => {
+ this.activated.prometheus = value;
+ this.toggleSuccess(value);
+ this.removeOpsGenieOption();
+ })
+ .catch(({ response: { data: { errors } = {} } = {} }) => {
+ this.createUserErrorMessage(errors);
+ this.setFeedback({
+ feedbackMessage: `${this.$options.i18n.errorMsg}.`,
+ variant: 'danger',
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ this.canSaveForm = false;
+ });
+ },
+ toggleSuccess(value) {
+ if (value) {
+ this.setFeedback({
+ feedbackMessage: this.$options.i18n.endPointActivated,
+ variant: 'info',
+ });
+ } else {
+ this.setFeedback({
+ feedbackMessage: this.$options.i18n.changesSaved,
+ variant: 'info',
+ });
+ }
+ },
+ setFeedback({ feedbackMessage, variant }) {
+ this.feedback = { feedbackMessage, variant };
+ },
+ validateJson() {
+ this.testAlert.error = null;
+ try {
+ JSON.parse(this.testAlert.json);
+ } catch (e) {
+ this.testAlert.error = JSON.stringify(e.message);
+ }
+ },
+ validateTestAlert() {
+ this.loading = true;
+ this.validateJson();
+ return service
+ .updateTestAlert({
+ endpoint: this.selectedService.url,
+ data: this.testAlert.json,
+ authKey: this.selectedService.authKey,
+ })
+ .then(() => {
+ this.setFeedback({
+ feedbackMessage: this.$options.i18n.testAlertSuccess,
+ variant: 'success',
+ });
+ })
+ .catch(() => {
+ this.setFeedback({
+ feedbackMessage: this.$options.i18n.testAlertFailed,
+ variant: 'danger',
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ onSubmit() {
+ this.toggle(this.selectedService.active);
+ },
+ onReset() {
+ this.testAlert.json = null;
+ this.dismissFeedback();
+ this.targetUrl = this.selectedService.targetUrl;
+
+ if (this.canSaveForm) {
+ this.canSaveForm = false;
+ this.activated[this.selectedEndpoint] = this[this.selectedEndpoint].activated;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert v-if="showFeedbackMsg" :variant="feedback.variant" @dismiss="dismissFeedback">
+ {{ feedback.feedbackMessage }}
+ <br />
+ <i v-if="serverError">{{ __('Error message:') }} {{ serverError }}</i>
+ <gl-button
+ v-if="showAlertSave"
+ variant="danger"
+ category="primary"
+ class="gl-display-block gl-mt-3"
+ @click="toggle(selectedService.active)"
+ >
+ {{ __('Save anyway') }}
+ </gl-button>
+ </gl-alert>
+ <div data-testid="alert-settings-description" class="gl-mt-5">
+ <p v-for="section in sections" :key="section.text">
+ <gl-sprintf :message="section.text">
+ <template #link="{ content }">
+ <gl-link :href="section.url" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset">
+ <gl-form-group
+ :label="$options.i18n.integrationsLabel"
+ label-for="integrations"
+ label-class="label-bold"
+ >
+ <gl-form-select
+ v-model="selectedEndpoint"
+ :options="options"
+ data-testid="alert-settings-select"
+ @change="resetFormValues"
+ />
+ <span class="gl-text-gray-400">
+ <gl-sprintf :message="$options.i18n.integrationsInfo">
+ <template #link="{ content }">
+ <gl-link
+ class="gl-display-inline-block"
+ href="https://gitlab.com/groups/gitlab-org/-/epics/3362"
+ target="_blank"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+ </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.activeLabel"
+ label-for="activated"
+ label-class="label-bold"
+ >
+ <toggle-button
+ id="activated"
+ :disabled-input="loading"
+ :is-loading="loading"
+ :value="selectedService.active"
+ @change="toggleService"
+ />
+ </gl-form-group>
+ <gl-form-group
+ v-if="isOpsgenie || isPrometheus"
+ :label="$options.i18n.apiBaseUrlLabel"
+ label-for="api-url"
+ label-class="label-bold"
+ >
+ <gl-form-input
+ id="api-url"
+ v-model="targetUrl"
+ type="url"
+ :placeholder="baseUrlPlaceholder"
+ :disabled="!selectedService.active"
+ />
+ <span class="gl-text-gray-400">
+ {{ $options.i18n.apiBaseUrlHelpText }}
+ </span>
+ </gl-form-group>
+ <template v-if="!isOpsgenie">
+ <gl-form-group :label="$options.i18n.urlLabel" label-for="url" label-class="label-bold">
+ <gl-form-input-group id="url" readonly :value="selectedService.url">
+ <template #append>
+ <clipboard-button
+ :text="selectedService.url"
+ :title="$options.i18n.copyToClipboard"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
+ <span class="gl-text-gray-400">
+ {{ prometheusInfo }}
+ </span>
+ </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.authKeyLabel"
+ label-for="authorization-key"
+ label-class="label-bold"
+ >
+ <gl-form-input-group
+ id="authorization-key"
+ class="gl-mb-2"
+ readonly
+ :value="selectedService.authKey"
+ >
+ <template #append>
+ <clipboard-button
+ :text="selectedService.authKey || ''"
+ :title="$options.i18n.copyToClipboard"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
+ <gl-button v-gl-modal.authKeyModal :disabled="!selectedService.active" class="gl-mt-3">{{
+ $options.i18n.resetKey
+ }}</gl-button>
+ <gl-modal
+ modal-id="authKeyModal"
+ :title="$options.i18n.resetKey"
+ :ok-title="$options.i18n.resetKey"
+ ok-variant="danger"
+ @ok="selectedService.resetKey"
+ >
+ {{ $options.i18n.restKeyInfo }}
+ </gl-modal>
+ </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.alertJson"
+ label-for="alert-json"
+ label-class="label-bold"
+ :invalid-feedback="testAlert.error"
+ >
+ <gl-form-textarea
+ id="alert-json"
+ v-model.trim="testAlert.json"
+ :disabled="!selectedService.active"
+ :state="jsonIsValid"
+ :placeholder="$options.i18n.alertJsonPlaceholder"
+ rows="6"
+ max-rows="10"
+ />
+ </gl-form-group>
+ <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{
+ $options.i18n.testAlertInfo
+ }}</gl-button>
+ </template>
+ <div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between">
+ <gl-button
+ variant="success"
+ category="primary"
+ :disabled="!canSaveConfig"
+ @click="onSubmit"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ <gl-button variant="default" category="primary" :disabled="!canSaveConfig" @click="onReset">
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </gl-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
new file mode 100644
index 00000000000..d15e8619df4
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -0,0 +1,50 @@
+import { s__ } from '~/locale';
+
+export const i18n = {
+ usageSection: s__(
+ 'AlertSettings|You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.',
+ ),
+ setupSection: s__(
+ "AlertSettings|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
+ ),
+ errorMsg: s__('AlertSettings|There was an error updating the alert settings'),
+ errorKeyMsg: s__(
+ 'AlertSettings|There was an error while trying to reset the key. Please refresh the page to try again.',
+ ),
+ restKeyInfo: s__(
+ 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
+ ),
+ endPointActivated: s__('AlertSettings|Alerts endpoint successfully activated.'),
+ changesSaved: s__('AlertSettings|Your changes were successfully updated.'),
+ prometheusInfo: s__('AlertSettings|Add URL and auth key to your Prometheus config file'),
+ integrationsInfo: s__(
+ 'AlertSettings|Learn more about our %{linkStart}upcoming integrations%{linkEnd}',
+ ),
+ resetKey: s__('AlertSettings|Reset key'),
+ copyToClipboard: s__('AlertSettings|Copy'),
+ integrationsLabel: s__('AlertSettings|Integrations'),
+ apiBaseUrlLabel: s__('AlertSettings|API URL'),
+ authKeyLabel: s__('AlertSettings|Authorization key'),
+ urlLabel: s__('AlertSettings|Webhook URL'),
+ activeLabel: s__('AlertSettings|Active'),
+ apiBaseUrlHelpText: s__('AlertSettings|URL cannot be blank and must start with http or https'),
+ testAlertInfo: s__('AlertSettings|Test alert payload'),
+ alertJson: s__('AlertSettings|Alert test payload'),
+ alertJsonPlaceholder: s__('AlertSettings|Enter test alert JSON....'),
+ testAlertFailed: s__('AlertSettings|Test failed. Do you still want to save your changes anyway?'),
+ testAlertSuccess: s__(
+ 'AlertSettings|Test alert sent successfully. If you have made other changes, please save them now.',
+ ),
+ authKeyRest: s__('AlertSettings|Authorization key has been successfully reset'),
+};
+
+export const serviceOptions = [
+ { value: 'generic', text: s__('AlertSettings|Generic') },
+ { value: 'prometheus', text: s__('AlertSettings|External Prometheus') },
+ { value: 'opsgenie', text: s__('AlertSettings|Opsgenie') },
+];
+
+export const JSON_VALIDATE_DELAY = 250;
+
+export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/';
+export const targetOpsgenieUrlPlaceholder = 'https://app.opsgenie.com/alert/list/';
diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js
new file mode 100644
index 00000000000..a4c2bf6b18e
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/index.js
@@ -0,0 +1,67 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import AlertSettingsForm from './components/alerts_settings_form.vue';
+
+export default el => {
+ if (!el) {
+ return null;
+ }
+
+ const {
+ prometheusActivated,
+ prometheusUrl,
+ prometheusAuthorizationKey,
+ prometheusFormPath,
+ prometheusResetKeyPath,
+ prometheusApiUrl,
+ activated: activatedStr,
+ alertsSetupUrl,
+ alertsUsageUrl,
+ formPath,
+ authorizationKey,
+ url,
+ opsgenieMvcAvailable,
+ opsgenieMvcFormPath,
+ opsgenieMvcEnabled,
+ opsgenieMvcTargetUrl,
+ } = el.dataset;
+
+ const genericActivated = parseBoolean(activatedStr);
+ const prometheusIsActivated = parseBoolean(prometheusActivated);
+ const opsgenieMvcActivated = parseBoolean(opsgenieMvcEnabled);
+ const opsgenieMvcIsAvailable = parseBoolean(opsgenieMvcAvailable);
+
+ const props = {
+ prometheus: {
+ activated: prometheusIsActivated,
+ prometheusUrl,
+ prometheusAuthorizationKey,
+ prometheusFormPath,
+ prometheusResetKeyPath,
+ prometheusApiUrl,
+ },
+ generic: {
+ alertsSetupUrl,
+ alertsUsageUrl,
+ activated: genericActivated,
+ formPath,
+ initialAuthorizationKey: authorizationKey,
+ url,
+ },
+ opsgenie: {
+ formPath: opsgenieMvcFormPath,
+ activated: opsgenieMvcActivated,
+ opsgenieMvcTargetUrl,
+ opsgenieMvcIsAvailable,
+ },
+ };
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(AlertSettingsForm, {
+ props,
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js
new file mode 100644
index 00000000000..c49992d4f57
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/services/index.js
@@ -0,0 +1,36 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ updateGenericKey({ endpoint, params }) {
+ return axios.put(endpoint, params);
+ },
+ updatePrometheusKey({ endpoint }) {
+ return axios.post(endpoint);
+ },
+ updateGenericActive({ endpoint, params }) {
+ return axios.put(endpoint, params);
+ },
+ updatePrometheusActive({ endpoint, params: { token, config, url, redirect } }) {
+ const data = new FormData();
+ data.set('_method', 'put');
+ data.set('authenticity_token', token);
+ data.set('service[manual_configuration]', config);
+ data.set('service[api_url]', url);
+ data.set('redirect_to', redirect);
+
+ return axios.post(endpoint, data, {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ });
+ },
+ updateTestAlert({ endpoint, data, authKey }) {
+ return axios.post(endpoint, data, {
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${authKey}`,
+ },
+ });
+ },
+};
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 94d155840ea..c84e73ccdb4 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -11,6 +11,9 @@ const Api = {
groupMembersPath: '/api/:version/groups/:id/members',
subgroupsPath: '/api/:version/groups/:id/subgroups',
namespacesPath: '/api/:version/namespaces.json',
+ groupPackagesPath: '/api/:version/groups/:id/packages',
+ projectPackagesPath: '/api/:version/projects/:id/packages',
+ projectPackagePath: '/api/:version/projects/:id/packages/:package_id',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json',
projectPath: '/api/:version/projects/:id',
@@ -36,7 +39,9 @@ const Api = {
userStatusPath: '/api/:version/users/:id/status',
userProjectsPath: '/api/:version/users/:id/projects',
userPostStatusPath: '/api/:version/user/status',
- commitPath: '/api/:version/projects/:id/repository/commits',
+ commitPath: '/api/:version/projects/:id/repository/commits/:sha',
+ commitsPath: '/api/:version/projects/:id/repository/commits',
+
applySuggestionPath: '/api/:version/suggestions/:id/apply',
applySuggestionBatchPath: '/api/:version/suggestions/batch_apply',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
@@ -64,6 +69,32 @@ const Api = {
});
},
+ groupPackages(id, options = {}) {
+ const url = Api.buildUrl(this.groupPackagesPath).replace(':id', id);
+ return axios.get(url, options);
+ },
+
+ projectPackages(id, options = {}) {
+ const url = Api.buildUrl(this.projectPackagesPath).replace(':id', id);
+ return axios.get(url, options);
+ },
+
+ buildProjectPackageUrl(projectId, packageId) {
+ return Api.buildUrl(this.projectPackagePath)
+ .replace(':id', projectId)
+ .replace(':package_id', packageId);
+ },
+
+ projectPackage(projectId, packageId) {
+ const url = this.buildProjectPackageUrl(projectId, packageId);
+ return axios.get(url);
+ },
+
+ deleteProjectPackage(projectId, packageId) {
+ const url = this.buildProjectPackageUrl(projectId, packageId);
+ return axios.delete(url);
+ },
+
groupMembers(id, options) {
const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
@@ -308,9 +339,17 @@ const Api = {
.catch(() => flash(__('Something went wrong while fetching projects')));
},
+ commit(id, sha, params = {}) {
+ const url = Api.buildUrl(this.commitPath)
+ .replace(':id', encodeURIComponent(id))
+ .replace(':sha', encodeURIComponent(sha));
+
+ return axios.get(url, { params });
+ },
+
commitMultiple(id, data) {
// see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
- const url = Api.buildUrl(Api.commitPath).replace(':id', encodeURIComponent(id));
+ const url = Api.buildUrl(Api.commitsPath).replace(':id', encodeURIComponent(id));
return axios.post(url, JSON.stringify(data), {
headers: {
'Content-Type': 'application/json; charset=utf-8',
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 8381b050900..0e83ba3d528 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -9,14 +9,10 @@ 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';
+import * as Emoji from '~/emoji';
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
-const requestAnimationFrame =
- window.requestAnimationFrame ||
- window.webkitRequestAnimationFrame ||
- window.mozRequestAnimationFrame ||
- window.setTimeout;
const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; // For separating lists produced by ruby's Array#toSentence
@@ -619,7 +615,7 @@ export class AwardsHandler {
let awardsHandlerPromise = null;
export default function loadAwardsHandler(reload = false) {
if (!awardsHandlerPromise || reload) {
- awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then(Emoji => {
+ awardsHandlerPromise = Emoji.initEmojiMap().then(() => {
const awardsHandler = new AwardsHandler(Emoji);
awardsHandler.bindEvents();
return awardsHandler;
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index dccc0b024ba..4145a4a4145 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -164,7 +164,7 @@ export default {
<template>
<form
:class="{ 'was-validated': wasValidated }"
- class="prepend-top-default append-bottom-default needs-validation"
+ class="gl-mt-3 gl-mb-3 needs-validation"
novalidate
@submit.prevent.stop="onSubmit"
>
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index 963d104b6b3..4c100ec7335 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -51,6 +51,7 @@ export default {
'scrollToDraft',
'toggleResolveDiscussion',
]),
+ ...mapActions(['setSelectedCommentPositionHover']),
update(data) {
this.updateDraft(data);
},
@@ -67,12 +68,16 @@ export default {
};
</script>
<template>
- <article class="draft-note-component note-wrapper">
+ <article
+ class="draft-note-component note-wrapper"
+ @mouseenter="setSelectedCommentPositionHover(draft.position.line_range)"
+ @mouseleave="setSelectedCommentPositionHover()"
+ >
<ul class="notes draft-notes">
<noteable-note
:note="draft"
- :diff-lines="diffFile.highlighted_diff_lines"
:line="line"
+ :discussion-root="true"
class="draft-note"
@handleEdit="handleEditing"
@cancelForm="handleNotEditing"
@@ -81,7 +86,7 @@ export default {
@handleUpdateNote="update"
@toggleResolveStatus="toggleResolveDiscussion(draft.id)"
>
- <strong slot="note-header-info" class="badge draft-pending-label append-right-4">
+ <strong slot="note-header-info" class="badge draft-pending-label gl-mr-2">
{{ __('Pending') }}
</strong>
</noteable-note>
diff --git a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue
index 68fd20e56bc..b0916623cd2 100644
--- a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue
+++ b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue
@@ -35,11 +35,15 @@ export default {
<tr :class="className" class="notes_holder">
<td class="notes_line old"></td>
<td class="notes-content parallel old" colspan="2">
- <div v-if="leftDraft.isDraft" class="content"><draft-note :draft="leftDraft" /></div>
+ <div v-if="leftDraft.isDraft" class="content">
+ <draft-note :draft="leftDraft" :line="line.left" />
+ </div>
</td>
<td class="notes_line new"></td>
<td class="notes-content parallel new" colspan="2">
- <div v-if="rightDraft.isDraft" class="content"><draft-note :draft="rightDraft" /></div>
+ <div v-if="rightDraft.isDraft" class="content">
+ <draft-note :draft="rightDraft" :line="line.right" />
+ </div>
</td>
</tr>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
index 195e1b7ec5c..7520cc2401b 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -96,7 +96,7 @@ export default {
<preview-item :draft="draft" :is-last="isLast(index)" />
</li>
</ul>
- <gl-loading-icon v-else size="lg" class="prepend-top-default append-bottom-default" />
+ <gl-loading-icon v-else size="lg" class="gl-mt-3 gl-mb-3" />
</div>
<div class="dropdown-footer">
<publish-button
diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue
index 22495eb4d7d..3162a83f099 100644
--- a/app/assets/javascripts/batch_comments/components/preview_item.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_item.vue
@@ -52,14 +52,12 @@ export default {
});
},
linePosition() {
- if (this.draft.position && this.draft.position.position_type === IMAGE_DIFF_POSITION_TYPE) {
+ if (this.position?.position_type === IMAGE_DIFF_POSITION_TYPE) {
// eslint-disable-next-line @gitlab/require-i18n-strings
- return `${this.draft.position.x}x ${this.draft.position.y}y`;
+ return `${this.position.x}x ${this.position.y}y`;
}
- const position = this.discussion ? this.discussion.position : this.draft.position;
-
- return position?.new_line || position?.old_line;
+ return this.position?.new_line || this.position?.old_line;
},
content() {
const el = document.createElement('div');
@@ -70,11 +68,14 @@ export default {
showLinePosition() {
return this.draft.file_hash || this.isDiffDiscussion;
},
+ position() {
+ return this.draft.position || this.discussion.position;
+ },
startLineNumber() {
- return getStartLineNumber(this.draft.position?.line_range);
+ return getStartLineNumber(this.position?.line_range);
},
endLineNumber() {
- return getEndLineNumber(this.draft.position?.line_range);
+ return getEndLineNumber(this.position?.line_range);
},
},
methods: {
diff --git a/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js
new file mode 100644
index 00000000000..d9164f6204a
--- /dev/null
+++ b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+
+/**
+ * This behavior collapses the right sidebar
+ * if the window size changes
+ *
+ * @sentrify
+ */
+export default () => {
+ const $sidebarGutterToggle = $('.js-sidebar-toggle');
+ let bootstrapBreakpoint = bp.getBreakpointSize();
+
+ $(window).on('resize.app', () => {
+ const oldBootstrapBreakpoint = bootstrapBreakpoint;
+ bootstrapBreakpoint = bp.getBreakpointSize();
+
+ if (bootstrapBreakpoint !== oldBootstrapBreakpoint) {
+ const breakpointSizes = ['md', 'sm', 'xs'];
+
+ if (breakpointSizes.includes(bootstrapBreakpoint)) {
+ const $gutterIcon = $sidebarGutterToggle.find('i');
+ if ($gutterIcon.hasClass('fa-angle-double-right')) {
+ $sidebarGutterToggle.trigger('click');
+ }
+
+ const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle');
+
+ // Sidebar has an icon which corresponds to collapsing the sidebar
+ // only then trigger the click.
+ if (sidebarGutterVueToggleEl) {
+ const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right');
+
+ if (collapseIcon) {
+ collapseIcon.click();
+ }
+ }
+ }
+ }
+ });
+};
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index d1d75658181..bcf732e9522 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -1,47 +1,69 @@
import 'document-register-element';
import isEmojiUnicodeSupported from '../emoji/support';
+import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji';
class GlEmoji extends HTMLElement {
constructor() {
super();
- const emojiUnicode = this.textContent.trim();
- const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset;
-
- 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)) {
- // 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;
+ this.initialize();
+ }
+ initialize() {
+ let emojiUnicode = this.textContent.trim();
+ const { fallbackSpriteClass, fallbackSrc } = this.dataset;
+ let { name, unicodeVersion } = this.dataset;
+
+ return initEmojiMap().then(() => {
+ if (!unicodeVersion) {
+ const emojiInfo = getEmojiInfo(name);
+
+ if (emojiInfo) {
+ if (name !== emojiInfo.name) {
+ ({ name } = emojiInfo);
+ this.dataset.name = emojiInfo.name;
+ }
+ unicodeVersion = emojiInfo.u;
+ this.dataset.unicodeVersion = unicodeVersion;
+
+ emojiUnicode = emojiInfo.e;
+ this.innerHTML = emojiInfo.e;
+
+ this.title = emojiInfo.d;
+ }
+ }
+
+ const isEmojiUnicode =
+ this.childNodes &&
+ Array.prototype.every.call(this.childNodes, childNode => childNode.nodeType === 3);
+
+ if (
+ emojiUnicode &&
+ isEmojiUnicode &&
+ !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)
+ ) {
+ const hasImageFallback = fallbackSrc && fallbackSrc.length > 0;
+ const hasCssSpriteFallback = fallbackSpriteClass && fallbackSpriteClass.length > 0;
+
+ // CSS sprite fallback takes precedence over image fallback
+ if (hasCssSpriteFallback) {
+ 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);
+ } else if (hasImageFallback) {
+ this.innerHTML = emojiImageTag(name, fallbackSrc);
+ } else {
+ const src = emojiFallbackImageSrc(name);
+ this.innerHTML = emojiImageTag(name, src);
}
- // IE 11 doesn't like adding multiple at once :(
- this.classList.add('emoji-icon');
- this.classList.add(fallbackSpriteClass);
- } else {
- import(/* webpackChunkName: 'emoji' */ '../emoji')
- .then(({ emojiImageTag, emojiFallbackImageSrc }) => {
- if (hasImageFallback) {
- this.innerHTML = emojiImageTag(name, fallbackSrc);
- } else {
- const src = emojiFallbackImageSrc(name);
- this.innerHTML = emojiImageTag(name, src);
- }
- })
- .catch(() => {
- // do nothing
- });
}
- }
+ });
}
}
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index 8c4eccc34a3..8060938c72a 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -11,9 +11,13 @@ import './requires_input';
import initPageShortcuts from './shortcuts';
import './toggler_behavior';
import './preview_markdown';
+import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize';
+import initSelect2Dropdowns from './select2';
installGlEmojiElement();
initGFMInput();
initCopyAsGFM();
initCopyToClipboard();
initPageShortcuts();
+initCollapseSidebarOnWindowResize();
+initSelect2Dropdowns();
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
index 03c1b5a0169..bbcfa50ba35 100644
--- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { getSelectedFragment } from '~/lib/utils/common_utils';
+import { getSelectedFragment, insertText } from '~/lib/utils/common_utils';
export class CopyAsGFM {
constructor() {
@@ -79,7 +79,7 @@ export class CopyAsGFM {
}
static insertPastedText(target, text, gfm) {
- window.gl.utils.insertText(target, textBefore => {
+ insertText(target, textBefore => {
// If the text before the cursor contains an odd number of backticks,
// we are either inside an inline code span that starts with 1 backtick
// or a code block that starts with 3 backticks.
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index e4c69a114e0..94033e914ef 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -174,7 +174,7 @@ export default function renderMermaid($els) {
if (!$els.length) return;
const visibleMermaids = $els.filter(function filter() {
- return $(this).closest('details').length === 0;
+ return $(this).closest('details').length === 0 && $(this).is(':visible');
});
renderMermaids(visibleMermaids);
diff --git a/app/assets/javascripts/behaviors/select2.js b/app/assets/javascripts/behaviors/select2.js
new file mode 100644
index 00000000000..37b75bb5e56
--- /dev/null
+++ b/app/assets/javascripts/behaviors/select2.js
@@ -0,0 +1,23 @@
+import $ from 'jquery';
+
+export default () => {
+ if ($('select.select2').length) {
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ $('select.select2').select2({
+ width: 'resolve',
+ minimumResultsForSearch: 10,
+ dropdownAutoWidth: true,
+ });
+
+ // Close select2 on escape
+ $('.js-select2').on('select2-close', () => {
+ setTimeout(() => {
+ $('.select2-container-active').removeClass('select2-container-active');
+ $(':focus').blur();
+ }, 1);
+ });
+ })
+ .catch(() => {});
+ }
+};
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
index a53b1b06be9..8418c0f66ac 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
@@ -1,11 +1,10 @@
<script>
-import { GlToggle, GlSprintf } from '@gitlab/ui';
+import { GlToggle } from '@gitlab/ui';
import AccessorUtilities from '~/lib/utils/accessor';
import { disableShortcuts, enableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
export default {
components: {
- GlSprintf,
GlToggle,
},
data() {
@@ -32,29 +31,10 @@ export default {
<gl-toggle
v-model="shortcutsEnabled"
aria-describedby="shortcutsToggle"
- class="prepend-left-10 mb-0"
- label-position="right"
+ label="Keyboard shortcuts"
+ label-position="left"
@change="onChange"
- >
- <template #labelOn>
- <gl-sprintf
- :message="__('%{screenreaderOnlyStart}Keyboard shorcuts%{screenreaderOnlyEnd} Enabled')"
- >
- <template #screenreaderOnly="{ content }">
- <span class="sr-only">{{ content }}</span>
- </template>
- </gl-sprintf>
- </template>
- <template #labelOff>
- <gl-sprintf
- :message="__('%{screenreaderOnlyStart}Keyboard shorcuts%{screenreaderOnlyEnd} Disabled')"
- >
- <template #screenreaderOnly="{ content }">
- <span class="sr-only">{{ content }}</span>
- </template>
- </gl-sprintf>
- </template>
- </gl-toggle>
+ />
<div id="shortcutsToggle" class="sr-only">{{ __('Enable or disable keyboard shortcuts') }}</div>
</div>
</template>
diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
index ed03213d7cf..5b15fe2d7cc 100644
--- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
+++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import {
RICH_BLOB_VIEWER,
RICH_BLOB_VIEWER_TITLE,
@@ -11,7 +11,7 @@ export default {
components: {
GlIcon,
GlButtonGroup,
- GlDeprecatedButton,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -46,7 +46,7 @@ export default {
</script>
<template>
<gl-button-group class="js-blob-viewer-switcher mx-2">
- <gl-deprecated-button
+ <gl-button
v-gl-tooltip.hover
:aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE"
:title="$options.SIMPLE_BLOB_VIEWER_TITLE"
@@ -55,8 +55,8 @@ export default {
@click="switchToViewer($options.SIMPLE_BLOB_VIEWER)"
>
<gl-icon name="code" :size="14" />
- </gl-deprecated-button>
- <gl-deprecated-button
+ </gl-button>
+ <gl-button
v-gl-tooltip.hover
:aria-label="$options.RICH_BLOB_VIEWER_TITLE"
:title="$options.RICH_BLOB_VIEWER_TITLE"
@@ -65,6 +65,6 @@ export default {
@click="switchToViewer($options.RICH_BLOB_VIEWER)"
>
<gl-icon name="document" :size="14" />
- </gl-deprecated-button>
+ </gl-button>
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js
index 93dceacabdd..0137bd38d28 100644
--- a/app/assets/javascripts/blob/components/constants.js
+++ b/app/assets/javascripts/blob/components/constants.js
@@ -25,7 +25,7 @@ export const BLOB_RENDER_ERRORS = {
TOO_LARGE: {
id: 'too_large',
text: sprintf(__('it is larger than %{limit}'), {
- limit: numberToHumanSize(104857600), // 100MB in bytes
+ limit: numberToHumanSize(10485760), // 10MB in bytes
}),
},
EXTERNAL: {
diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
index 401fe9beb62..b1713989997 100644
--- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue
+++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
@@ -62,9 +62,7 @@ export default {
</script>
<template>
- <div
- class="js-notebook-viewer-mounted container-fluid md prepend-top-default append-bottom-default"
- >
+ <div class="js-notebook-viewer-mounted container-fluid md gl-mt-3 gl-mb-3">
<div v-if="loading && !error" class="text-center loading">
<gl-loading-icon class="mt-5" size="lg" />
</div>
diff --git a/app/assets/javascripts/blob/pdf/pdf_viewer.vue b/app/assets/javascripts/blob/pdf/pdf_viewer.vue
index 5eaddfc099a..64fc832ee54 100644
--- a/app/assets/javascripts/blob/pdf/pdf_viewer.vue
+++ b/app/assets/javascripts/blob/pdf/pdf_viewer.vue
@@ -34,7 +34,7 @@ export default {
</script>
<template>
- <div class="js-pdf-viewer container-fluid md prepend-top-default append-bottom-default">
+ <div class="js-pdf-viewer container-fluid md gl-mt-3 gl-mb-3">
<div v-if="loading && !error" class="text-center loading">
<gl-loading-icon class="mt-5" size="lg" />
</div>
diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js
index dbff03dc734..767e205fcaa 100644
--- a/app/assets/javascripts/blob/sketch/index.js
+++ b/app/assets/javascripts/blob/sketch/index.js
@@ -56,7 +56,7 @@ export default class SketchLoader {
error() {
const errorMsg = document.createElement('p');
- errorMsg.className = 'prepend-top-default append-bottom-default text-center';
+ errorMsg.className = 'gl-mt-3 gl-mb-3 text-center';
errorMsg.textContent = __(`
Cannot show preview. For previews on sketch files, they must have the file format
introduced by Sketch version 43 and above.
diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
index 1e9e36feecc..932b6e8a0f7 100644
--- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
+++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
@@ -2,7 +2,6 @@
import { GlPopover, GlSprintf, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
import { parseBoolean, scrollToElement, setCookie, getCookie } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
-import { glEmojiTag } from '~/emoji';
import Tracking from '~/tracking';
const trackingMixin = Tracking.mixin();
@@ -11,14 +10,16 @@ const popoverStates = {
suggest_gitlab_ci_yml: {
title: s__(`suggestPipeline|1/2: Choose a template`),
content: s__(
- `suggestPipeline|We recommend the %{boldStart}Code Quality%{boldEnd} template, which will add a report widget to your Merge Requests. This way you’ll learn about code quality degradations much sooner. %{footerStart} Goodbye technical debt! %{footerEnd}`,
+ `suggestPipeline|We’re adding a GitLab CI configuration file to add a pipeline to the project. You could create it manually, but we recommend that you start with a GitLab template that works out of the box.`,
+ ),
+ footer: s__(
+ `suggestPipeline|Choose %{boldStart}Code Quality%{boldEnd} to add a pipeline that tests the quality of your code.`,
),
- emoji: glEmojiTag('wave'),
},
suggest_commit_first_project_gitlab_ci_yml: {
title: s__(`suggestPipeline|2/2: Commit your changes`),
content: s__(
- `suggestPipeline|Commit the changes and your pipeline will automatically run for the first time.`,
+ `suggestPipeline|The template is ready! You can now commit it to create your first pipeline.`,
),
},
};
@@ -66,6 +67,9 @@ export default {
suggestContent() {
return popoverStates[this.trackLabel].content || '';
},
+ suggestFooter() {
+ return popoverStates[this.trackLabel].footer || '';
+ },
emoji() {
return popoverStates[this.trackLabel].emoji || '';
},
@@ -123,16 +127,13 @@ export default {
</span>
</template>
- <gl-sprintf :message="suggestContent">
- <template #bold="{content}">
- <strong> {{ content }} </strong>
- </template>
- <template #footer="{content}">
- <div class="mt-3">
- {{ content }}
- <span v-html="emoji"></span>
- </div>
- </template>
- </gl-sprintf>
+ <gl-sprintf :message="suggestContent" />
+ <div class="mt-3">
+ <gl-sprintf :message="suggestFooter">
+ <template #bold="{ content }">
+ <strong> {{ content }} </strong>
+ </template>
+ </gl-sprintf>
+ </div>
</gl-popover>
</template>
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 3ac419557eb..b18faea628a 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -3,6 +3,7 @@ import '~/behaviors/markdown/render_gfm';
import Flash from '../../flash';
import { handleLocationHash } from '../../lib/utils/common_utils';
import axios from '../../lib/utils/axios_utils';
+import eventHub from '../../notes/event_hub';
import { __ } from '~/locale';
const loadRichBlobViewer = type => {
@@ -178,6 +179,10 @@ export default class BlobViewer {
viewer.innerHTML = data.html;
viewer.setAttribute('data-loaded', 'true');
+ if (window.gon?.features?.codeNavigation) {
+ eventHub.$emit('showBlobInteractionZones', viewer.dataset.path);
+ }
+
return viewer;
});
}
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 95b84497de3..9b9ade28623 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -5,7 +5,7 @@ import NewCommitForm from '../new_commit_form';
import EditBlob from './edit_blob';
import BlobFileDropzone from '../blob/blob_file_dropzone';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
-import { setCookie } from '~/lib/utils/common_utils';
+import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
export default () => {
@@ -51,10 +51,7 @@ export default () => {
new BlobFileDropzone(uploadBlobForm, method);
new NewCommitForm(uploadBlobForm);
- window.gl.utils.disableButtonIfEmptyField(
- uploadBlobForm.find('.js-commit-message'),
- '.btn-upload-file',
- );
+ disableButtonIfEmptyField(uploadBlobForm.find('.js-commit-message'), '.btn-upload-file');
}
if (deleteBlobForm.length) {
diff --git a/app/assets/javascripts/blob_edit/constants.js b/app/assets/javascripts/blob_edit/constants.js
new file mode 100644
index 00000000000..a19da2098cf
--- /dev/null
+++ b/app/assets/javascripts/blob_edit/constants.js
@@ -0,0 +1,4 @@
+import { __ } from '~/locale';
+
+export const BLOB_EDITOR_ERROR = __('An error occurred while rendering the editor');
+export const BLOB_PREVIEW_ERROR = __('An error occurred previewing the blob');
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 011898a5e7a..7e5be8454fe 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -3,39 +3,87 @@
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
-import { __ } from '~/locale';
+import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants';
import TemplateSelectorMediator from '../blob/file_template_mediator';
import getModeByFileExtension from '~/lib/utils/ace_utils';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
+const monacoEnabledGlobally = window.gon.features?.monacoBlobs;
+
export default class EditBlob {
// The options object has:
// assetsPath, filePath, currentAction, projectId, isMarkdown
constructor(options) {
this.options = options;
- this.configureAceEditor();
- this.initModePanesAndLinks();
- this.initSoftWrap();
- this.initFileSelectors();
+ this.options.monacoEnabled = this.options.monacoEnabled ?? monacoEnabledGlobally;
+ const { isMarkdown, monacoEnabled } = this.options;
+ return Promise.resolve()
+ .then(() => {
+ return monacoEnabled ? this.configureMonacoEditor() : this.configureAceEditor();
+ })
+ .then(() => {
+ this.initModePanesAndLinks();
+ this.initFileSelectors();
+ this.initSoftWrap();
+ if (isMarkdown) {
+ addEditorMarkdownListeners(this.editor);
+ }
+ this.editor.focus();
+ })
+ .catch(() => createFlash(BLOB_EDITOR_ERROR));
+ }
+
+ configureMonacoEditor() {
+ const EditorPromise = import(
+ /* webpackChunkName: 'monaco_editor_lite' */ '~/editor/editor_lite'
+ );
+ const MarkdownExtensionPromise = this.options.isMarkdown
+ ? import('~/editor/editor_markdown_ext')
+ : Promise.resolve(false);
+
+ return Promise.all([EditorPromise, MarkdownExtensionPromise])
+ .then(([EditorModule, MarkdownExtension]) => {
+ const EditorLite = EditorModule.default;
+ const editorEl = document.getElementById('editor');
+ const fileNameEl =
+ document.getElementById('file_path') || document.getElementById('file_name');
+ const fileContentEl = document.getElementById('file-content');
+ const form = document.querySelector('.js-edit-blob-form');
+
+ this.editor = new EditorLite();
+
+ if (MarkdownExtension) {
+ this.editor.use(MarkdownExtension.default);
+ }
+
+ this.editor.createInstance({
+ el: editorEl,
+ blobPath: fileNameEl.value,
+ blobContent: editorEl.innerText,
+ });
+
+ fileNameEl.addEventListener('change', () => {
+ this.editor.updateModelLanguage(fileNameEl.value);
+ });
+
+ form.addEventListener('submit', () => {
+ fileContentEl.value = this.editor.getValue();
+ });
+ })
+ .catch(() => createFlash(BLOB_EDITOR_ERROR));
}
configureAceEditor() {
- const { filePath, assetsPath, isMarkdown } = this.options;
+ const { filePath, assetsPath } = this.options;
ace.config.set('modePath', `${assetsPath}/ace`);
ace.config.loadModule('ace/ext/searchbox');
ace.config.loadModule('ace/ext/modelist');
this.editor = ace.edit('editor');
- if (isMarkdown) {
- addEditorMarkdownListeners(this.editor);
- }
-
// This prevents warnings re: automatic scrolling being logged
this.editor.$blockScrolling = Infinity;
- this.editor.focus();
-
if (filePath) {
this.editor.getSession().setMode(getModeByFileExtension(filePath));
}
@@ -81,7 +129,7 @@ export default class EditBlob {
currentPane.empty().append(data);
currentPane.renderGFM();
})
- .catch(() => createFlash(__('An error occurred previewing the blob')));
+ .catch(() => createFlash(BLOB_PREVIEW_ERROR));
}
this.$toggleButton.show();
@@ -90,14 +138,19 @@ export default class EditBlob {
}
initSoftWrap() {
- this.isSoftWrapped = false;
+ this.isSoftWrapped = Boolean(this.options.monacoEnabled);
this.$toggleButton = $('.soft-wrap-toggle');
+ this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped);
this.$toggleButton.on('click', () => this.toggleSoftWrap());
}
toggleSoftWrap() {
this.isSoftWrapped = !this.isSoftWrapped;
this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped);
- this.editor.getSession().setUseWrapMode(this.isSoftWrapped);
+ if (this.options.monacoEnabled) {
+ this.editor.updateOptions({ wordWrap: this.isSoftWrapped ? 'on' : 'off' });
+ } else {
+ this.editor.getSession().setUseWrapMode(this.isSoftWrapped);
+ }
}
}
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
deleted file mode 100644
index 517a13ceb27..00000000000
--- a/app/assets/javascripts/boards/components/board.js
+++ /dev/null
@@ -1,192 +0,0 @@
-import $ from 'jquery';
-import Sortable from 'sortablejs';
-import Vue from 'vue';
-import { GlButtonGroup, GlDeprecatedButton, GlLabel, GlTooltip } from '@gitlab/ui';
-import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits';
-import { s__, __, sprintf } from '~/locale';
-import Icon from '~/vue_shared/components/icon.vue';
-import Tooltip from '~/vue_shared/directives/tooltip';
-import AccessorUtilities from '../../lib/utils/accessor';
-import BoardBlankState from './board_blank_state.vue';
-import BoardDelete from './board_delete';
-import BoardList from './board_list.vue';
-import IssueCount from './issue_count.vue';
-import boardsStore from '../stores/boards_store';
-import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
-import { ListType } from '../constants';
-import { isScopedLabel } from '~/lib/utils/common_utils';
-
-/**
- * Please don't edit this file, have a look at:
- * ./board_column.vue
- * https://gitlab.com/gitlab-org/gitlab/-/issues/212300
- *
- * This file here will be deleted soon
- * @deprecated
- */
-export default Vue.extend({
- components: {
- BoardBlankState,
- BoardDelete,
- BoardList,
- Icon,
- GlButtonGroup,
- IssueCount,
- GlDeprecatedButton,
- GlLabel,
- GlTooltip,
- },
- directives: {
- Tooltip,
- },
- mixins: [isWipLimitsOn],
- props: {
- list: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- disabled: {
- type: Boolean,
- required: true,
- },
- issueLinkBase: {
- type: String,
- required: true,
- },
- rootPath: {
- type: String,
- required: true,
- },
- boardId: {
- type: String,
- required: true,
- },
- // Does not do anything but is used
- // to support the API of the new board_column.vue
- canAdminList: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- detailIssue: boardsStore.detail,
- filter: boardsStore.filter,
- };
- },
- computed: {
- isLoggedIn() {
- return Boolean(gon.current_user_id);
- },
- showListHeaderButton() {
- return (
- !this.disabled && this.list.type !== ListType.closed && this.list.type !== ListType.blank
- );
- },
- issuesTooltip() {
- const { issuesSize } = this.list;
-
- return sprintf(__('%{issuesSize} issues'), { issuesSize });
- },
- // Only needed to make karma pass.
- weightCountToolTip() {}, // eslint-disable-line vue/return-in-computed-property
- caretTooltip() {
- return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
- },
- isNewIssueShown() {
- return this.list.type === ListType.backlog || this.showListHeaderButton;
- },
- isSettingsShown() {
- return (
- this.list.type !== ListType.backlog &&
- this.showListHeaderButton &&
- this.list.isExpanded &&
- this.isWipLimitsOn
- );
- },
- showBoardListAndBoardInfo() {
- return this.list.type !== ListType.blank && this.list.type !== ListType.promotion;
- },
- uniqueKey() {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- return `boards.${this.boardId}.${this.list.type}.${this.list.id}`;
- },
- },
- watch: {
- filter: {
- handler() {
- this.list.page = 1;
- this.list.getIssues(true).catch(() => {
- // TODO: handle request error
- });
- },
- deep: true,
- },
- },
- mounted() {
- const instance = this;
-
- const sortableOptions = getBoardSortableDefaultOptions({
- disabled: this.disabled,
- group: 'boards',
- draggable: '.is-draggable',
- handle: '.js-board-handle',
- onEnd(e) {
- sortableEnd();
-
- const sortable = this;
-
- if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
- const order = sortable.toArray();
- const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10));
-
- instance.$nextTick(() => {
- boardsStore.moveList(list, order);
- });
- }
- },
- });
-
- Sortable.create(this.$el.parentNode, sortableOptions);
- },
- created() {
- if (
- this.list.isExpandable &&
- AccessorUtilities.isLocalStorageAccessSafe() &&
- !this.isLoggedIn
- ) {
- const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false';
-
- this.list.isExpanded = !isCollapsed;
- }
- },
- methods: {
- showScopedLabels(label) {
- return boardsStore.scopedLabels.enabled && isScopedLabel(label);
- },
-
- showNewIssueForm() {
- this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm;
- },
- toggleExpanded() {
- if (this.list.isExpandable) {
- this.list.isExpanded = !this.list.isExpanded;
-
- if (AccessorUtilities.isLocalStorageAccessSafe() && !this.isLoggedIn) {
- localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
- }
-
- if (this.isLoggedIn) {
- this.list.update();
- }
-
- // When expanding/collapsing, the tooltip on the caret button sometimes stays open.
- // Close all tooltips manually to prevent dangling tooltips.
- $('.tooltip').tooltip('hide');
- }
- },
- },
- template: '#js-board-template',
-});
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index f0497ea0b64..6ac7fdce6a7 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -54,7 +54,7 @@ export default {
<div>
<div
v-if="!isSwimlanesOn"
- class="boards-list w-100 py-3 px-2 text-nowrap"
+ class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap"
data-qa-selector="boards_list"
>
<board-column
@@ -77,6 +77,7 @@ export default {
:can-admin-list="canAdminList"
:disabled="disabled"
:board-id="boardId"
+ :group-id="groupId"
/>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index eb12617a66e..02a04cb4e46 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -5,10 +5,11 @@ import {
GlLabel,
GlTooltip,
GlIcon,
+ GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits';
-import { s__, __, sprintf } from '~/locale';
+import { n__, s__ } from '~/locale';
import AccessorUtilities from '../../lib/utils/accessor';
import BoardDelete from './board_delete';
import IssueCount from './issue_count.vue';
@@ -25,6 +26,7 @@ export default {
GlLabel,
GlTooltip,
GlIcon,
+ GlSprintf,
IssueCount,
},
directives: {
@@ -82,10 +84,20 @@ export default {
this.listType !== ListType.promotion
);
},
- issuesTooltip() {
+ showMilestoneListDetails() {
+ return (
+ this.list.type === 'milestone' &&
+ this.list.milestone &&
+ (this.list.isExpanded || !this.isSwimlanesHeader)
+ );
+ },
+ showAssigneeListDetails() {
+ return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader);
+ },
+ issuesTooltipLabel() {
const { issuesSize } = this.list;
- return sprintf(__('%{issuesSize} issues'), { issuesSize });
+ return n__(`%d issue`, `%d issues`, issuesSize);
},
chevronTooltip() {
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
@@ -111,6 +123,9 @@ export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `boards.${this.boardId}.${this.listType}.${this.list.id}`;
},
+ collapsedTooltipTitle() {
+ return this.listTitle || this.listAssignee;
+ },
},
methods: {
showScopedLabels(label) {
@@ -147,7 +162,7 @@ export default {
'has-border': list.label && list.label.color,
'gl-relative': list.isExpanded,
'gl-h-full': !list.isExpanded,
- 'board-inner gl-rounded-base gl-border-b-0': isSwimlanesHeader,
+ 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
}"
:style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }"
class="board-header gl-relative"
@@ -157,7 +172,9 @@ export default {
<h3
:class="{
'user-can-drag': !disabled && !list.preset,
- 'gl-border-b-0': !list.isExpanded,
+ 'gl-py-3': !list.isExpanded && !isSwimlanesHeader,
+ 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
+ 'gl-py-2': !list.isExpanded && isSwimlanesHeader,
}"
class="board-title gl-m-0 gl-display-flex js-board-handle"
>
@@ -167,21 +184,17 @@ export default {
:aria-label="chevronTooltip"
:title="chevronTooltip"
:icon="chevronIcon"
- class="board-title-caret no-drag"
+ class="board-title-caret no-drag gl-cursor-pointer"
variant="link"
@click="toggleExpanded"
/>
<!-- The following is only true in EE and if it is a milestone -->
- <span
- v-if="list.type === 'milestone' && list.milestone"
- aria-hidden="true"
- class="gl-mr-2 milestone-icon"
- >
+ <span v-if="showMilestoneListDetails" aria-hidden="true" class="gl-mr-2 milestone-icon">
<gl-icon name="timer" />
</span>
<a
- v-if="list.type === 'assignee'"
+ v-if="showAssigneeListDetails"
:href="list.assignee.path"
class="user-avatar-link js-no-trigger"
>
@@ -195,7 +208,10 @@ export default {
width="20"
/>
</a>
- <div class="board-title-text">
+ <div
+ class="board-title-text"
+ :class="{ 'gl-display-none': !list.isExpanded && isSwimlanesHeader }"
+ >
<span
v-if="list.type !== 'label'"
v-gl-tooltip.hover
@@ -208,7 +224,7 @@ export default {
{{ list.title }}
</span>
<span v-if="list.type === 'assignee'" class="board-title-sub-text gl-ml-2">
- @{{ list.assignee.username }}
+ @{{ listAssignee }}
</span>
<gl-label
v-if="list.type === 'label'"
@@ -220,6 +236,33 @@ export default {
:title="list.label.title"
/>
</div>
+
+ <span
+ v-if="isSwimlanesHeader && !list.isExpanded"
+ ref="collapsedInfo"
+ aria-hidden="true"
+ class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-700"
+ >
+ <gl-icon name="information" />
+ </span>
+ <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo">
+ <div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div>
+ <div v-if="list.maxIssueCount !== 0">
+ &#8226;
+ <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
+ <template #issuesSize>{{ issuesTooltipLabel }}</template>
+ <template #maxIssueCount>{{ list.maxIssueCount }}</template>
+ </gl-sprintf>
+ </div>
+ <div v-else>&#8226; {{ issuesTooltipLabel }}</div>
+ <div v-if="weightFeatureAvailable">
+ &#8226;
+ <gl-sprintf :message="__('%{totalWeight} total weight')">
+ <template #totalWeight>{{ list.totalWeight }}</template>
+ </gl-sprintf>
+ </div>
+ </gl-tooltip>
+
<board-delete
v-if="canAdminList && !list.preset && list.id"
:list="list"
@@ -229,7 +272,7 @@ export default {
v-gl-tooltip.hover.bottom
:class="{ 'gl-display-none': !list.isExpanded }"
:aria-label="__('Delete list')"
- class="board-delete no-drag gl-pr-0 gl-shadow-none gl-mr-3"
+ class="board-delete no-drag gl-pr-0 gl-shadow-none! gl-mr-3"
:title="__('Delete list')"
icon="remove"
size="small"
@@ -238,10 +281,11 @@ export default {
</board-delete>
<div
v-if="showBoardListAndBoardInfo"
- class="issue-count-badge gl-pr-0 no-drag text-secondary"
+ class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary"
+ :class="{ 'gl-display-none': !list.isExpanded && isSwimlanesHeader }"
>
<span class="gl-display-inline-flex">
- <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltip" />
+ <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" />
<span ref="issueCount" class="issue-count-badge-count">
<gl-icon class="gl-mr-2" name="issues" />
<issue-count :issues-size="list.issuesSize" :max-issue-count="list.maxIssueCount" />
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index c72fb7b30f9..02ac45f8ef9 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -1,6 +1,6 @@
<script>
import $ from 'jquery';
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import ListIssue from 'ee_else_ce/boards/models/issue';
import eventHub from '../eventhub';
@@ -11,7 +11,7 @@ export default {
name: 'BoardNewIssue',
components: {
ProjectSelect,
- GlDeprecatedButton,
+ GlButton,
},
props: {
groupId: {
@@ -120,21 +120,18 @@ export default {
/>
<project-select v-if="groupId" :group-id="groupId" :list="list" />
<div class="clearfix prepend-top-10">
- <gl-deprecated-button
+ <gl-button
ref="submit-button"
:disabled="disabled"
class="float-left"
variant="success"
+ category="primary"
type="submit"
- >{{ __('Submit issue') }}</gl-deprecated-button
- >
- <gl-deprecated-button
- class="float-right"
- type="button"
- variant="default"
- @click="cancel"
- >{{ __('Cancel') }}</gl-deprecated-button
+ >{{ __('Submit issue') }}</gl-button
>
+ <gl-button class="float-right" type="button" variant="default" @click="cancel">{{
+ __('Cancel')
+ }}</gl-button>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 80db9930259..dbe3e0790f6 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -233,7 +233,7 @@ export default {
</script>
<template>
- <div class="boards-switcher js-boards-selector append-right-10">
+ <div class="boards-switcher js-boards-selector gl-mr-3">
<span class="boards-selector-wrapper js-boards-selector-wrapper">
<gl-dropdown
data-qa-selector="boards_dropdown"
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index f2e198eaedb..d90928f35b6 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -153,7 +153,7 @@ export default {
v-gl-tooltip
name="issue-block"
:title="__('Blocked issue')"
- class="issue-blocked-icon append-right-4"
+ class="issue-blocked-icon gl-mr-2"
:aria-label="__('Blocked issue')"
/>
<icon
@@ -161,7 +161,7 @@ export default {
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
- class="confidential-icon append-right-4"
+ class="confidential-icon gl-mr-2"
:aria-label="__('Confidential')"
/>
<a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{
diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue
index a42e691dcf3..8eae8e4726f 100644
--- a/app/assets/javascripts/boards/components/modal/header.vue
+++ b/app/assets/javascripts/boards/components/modal/header.vue
@@ -72,7 +72,7 @@ export default {
<button
ref="selectAllBtn"
type="button"
- class="btn btn-success btn-inverted prepend-left-10"
+ class="btn btn-success btn-inverted gl-ml-3"
@click="toggleAll"
>
{{ selectAllText }}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index a882cd1cdfa..5b4a1d262dd 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -80,15 +80,7 @@ export default () => {
el: $boardApp,
components: {
BoardContent,
- Board: () =>
- window?.gon?.features?.sfcIssueBoards
- ? import('ee_else_ce/boards/components/board_column.vue')
- : /**
- * Please have a look at, we are moving to the SFC soon:
- * https://gitlab.com/gitlab-org/gitlab/-/issues/212300
- * @deprecated
- */
- import('ee_else_ce/boards/components/board'),
+ Board: () => import('ee_else_ce/boards/components/board_column.vue'),
BoardSidebar,
BoardAddIssuesModal,
BoardSettingsSidebar: () =>
@@ -360,7 +352,7 @@ export default () => {
template: `
<div class="board-extra-actions">
<button
- class="btn btn-success prepend-left-10"
+ class="btn btn-success gl-ml-3"
type="button"
data-placement="bottom"
ref="addIssuesButton"
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 0bd606c6297..2aa92f86125 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -119,16 +119,12 @@ class List {
}
moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
- oldIndicies.reverse().forEach(index => {
- this.issues.splice(index, 1);
- });
- this.issues.splice(newIndex, 0, ...issues);
-
boardsStore
- .moveMultipleIssues({
- ids: issues.map(issue => issue.id),
- fromListId: null,
- toListId: null,
+ .moveListMultipleIssues({
+ list: this,
+ issues,
+ oldIndicies,
+ newIndex,
moveBeforeId,
moveAfterId,
})
@@ -170,12 +166,7 @@ class List {
}
onNewIssueResponse(issue, data) {
- issue.refreshData(data);
-
- if (this.issuesSize > 1) {
- const moveBeforeId = this.issues[1].id;
- boardsStore.moveIssue(issue.id, null, null, null, moveBeforeId);
- }
+ boardsStore.onNewListIssueResponse(this, issue, data);
}
}
diff --git a/app/assets/javascripts/boards/queries/board.fragment.graphql b/app/assets/javascripts/boards/queries/board.fragment.graphql
index 48f55e899bf..872a4c4afbc 100644
--- a/app/assets/javascripts/boards/queries/board.fragment.graphql
+++ b/app/assets/javascripts/boards/queries/board.fragment.graphql
@@ -1,4 +1,4 @@
fragment BoardFragment on Board {
- id,
+ id
name
}
diff --git a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql
index 6ba6c05d6d9..5b532906f6a 100644
--- a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql
+++ b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql
@@ -1,15 +1,15 @@
fragment BoardListShared on BoardList {
- id,
- title,
- position,
- listType,
- collapsed,
+ id
+ title
+ position
+ listType
+ collapsed
label {
- id,
- title,
- color,
- textColor,
- description,
+ id
+ title
+ color
+ textColor
+ description
descriptionHtml
}
}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index a930f39189e..da7d2e19ec1 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -296,6 +296,15 @@ const boardsStore = {
Object.assign(this.moving, { list, issue });
},
+ onNewListIssueResponse(list, issue, data) {
+ issue.refreshData(data);
+
+ if (list.issuesSize > 1) {
+ const moveBeforeId = list.issues[1].id;
+ this.moveIssue(issue.id, null, null, null, moveBeforeId);
+ }
+ },
+
moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) {
const issueTo = issues.map(issue => listTo.findIssue(issue.id));
const issueLists = issues.map(issue => issue.getLists()).flat();
@@ -675,6 +684,21 @@ const boardsStore = {
});
},
+ moveListMultipleIssues({ list, issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
+ oldIndicies.reverse().forEach(index => {
+ list.issues.splice(index, 1);
+ });
+ list.issues.splice(newIndex, 0, ...issues);
+
+ return this.moveMultipleIssues({
+ ids: issues.map(issue => issue.id),
+ fromListId: null,
+ toListId: null,
+ moveBeforeId,
+ moveAfterId,
+ });
+ },
+
newIssue(id, issue) {
return axios.post(this.generateIssuesPath(id), {
issue,
diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js
index a437a34c948..e60e7059192 100644
--- a/app/assets/javascripts/boards/toggle_focus.js
+++ b/app/assets/javascripts/boards/toggle_focus.js
@@ -25,7 +25,7 @@ export default (ModalStore, boardsStore) => {
<div class="board-extra-actions">
<a
href="#"
- class="btn btn-default has-tooltip prepend-left-10 js-focus-mode-btn"
+ class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn"
data-qa-selector="focus_mode_button"
role="button"
aria-label="Toggle focus mode"
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue
deleted file mode 100644
index c15d638d92b..00000000000
--- a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue
+++ /dev/null
@@ -1,169 +0,0 @@
-<script>
-import { uniqueId } from 'lodash';
-import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
-
-export default {
- name: 'CiKeyField',
- components: {
- GlButton,
- GlFormGroup,
- GlFormInput,
- },
- model: {
- prop: 'value',
- event: 'input',
- },
- props: {
- tokenList: {
- type: Array,
- required: true,
- },
- value: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- results: [],
- arrowCounter: -1,
- userDismissedResults: false,
- suggestionsId: uniqueId('token-suggestions-'),
- };
- },
- computed: {
- showAutocomplete() {
- return this.showSuggestions ? 'off' : 'on';
- },
- showSuggestions() {
- return this.results.length > 0;
- },
- },
- mounted() {
- document.addEventListener('click', this.handleClickOutside);
- },
- destroyed() {
- document.removeEventListener('click', this.handleClickOutside);
- },
- methods: {
- closeSuggestions() {
- this.results = [];
- this.arrowCounter = -1;
- },
- handleClickOutside(event) {
- if (!this.$el.contains(event.target)) {
- this.closeSuggestions();
- }
- },
- onArrowDown() {
- const newCount = this.arrowCounter + 1;
-
- if (newCount >= this.results.length) {
- this.arrowCounter = 0;
- return;
- }
-
- this.arrowCounter = newCount;
- },
- onArrowUp() {
- const newCount = this.arrowCounter - 1;
-
- if (newCount < 0) {
- this.arrowCounter = this.results.length - 1;
- return;
- }
-
- this.arrowCounter = newCount;
- },
- onEnter() {
- const currentToken = this.results[this.arrowCounter] || this.value;
- this.selectToken(currentToken);
- },
- onEsc() {
- if (!this.showSuggestions) {
- this.$emit('input', '');
- }
- this.closeSuggestions();
- this.userDismissedResults = true;
- },
- onEntry(value) {
- this.$emit('input', value);
- this.userDismissedResults = false;
-
- // short circuit so that we don't false match on empty string
- if (value.length < 1) {
- this.closeSuggestions();
- return;
- }
-
- const filteredTokens = this.tokenList.filter(token =>
- token.toLowerCase().includes(value.toLowerCase()),
- );
-
- if (filteredTokens.length) {
- this.openSuggestions(filteredTokens);
- } else {
- this.closeSuggestions();
- }
- },
- openSuggestions(filteredResults) {
- this.results = filteredResults;
- },
- selectToken(value) {
- this.$emit('input', value);
- this.closeSuggestions();
- this.$emit('key-selected');
- },
- },
-};
-</script>
-<template>
- <div>
- <div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions">
- <gl-form-group :label="__('Key')" label-for="ci-variable-key">
- <gl-form-input
- id="ci-variable-key"
- :value="value"
- type="text"
- role="searchbox"
- class="form-control pl-2 js-env-input"
- :autocomplete="showAutocomplete"
- aria-autocomplete="list"
- aria-controls="token-suggestions"
- aria-haspopup="listbox"
- :aria-expanded="showSuggestions"
- data-qa-selector="ci_variable_key_field"
- @input="onEntry"
- @keydown.down="onArrowDown"
- @keydown.up="onArrowUp"
- @keydown.enter.prevent="onEnter"
- @keydown.esc.stop="onEsc"
- @keydown.tab="closeSuggestions"
- />
- </gl-form-group>
-
- <div
- v-show="showSuggestions && !userDismissedResults"
- id="ci-variable-dropdown"
- class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"
- :class="{ 'd-block': showSuggestions }"
- >
- <div class="dropdown-content">
- <ul :id="suggestionsId">
- <li
- v-for="(result, i) in results"
- :key="i"
- role="option"
- :class="{ 'gl-bg-gray-50': i === arrowCounter }"
- :aria-selected="i === arrowCounter"
- >
- <gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{
- result
- }}</gl-button>
- </li>
- </ul>
- </div>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js
index 9022bf51514..3f25e3df305 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js
@@ -1,28 +1,14 @@
-import { __ } from '~/locale';
-
import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants';
export const awsTokens = {
[AWS_ACCESS_KEY_ID]: {
name: AWS_ACCESS_KEY_ID,
- /* Checks for exactly twenty characters that match key.
- Based on greps suggested by Amazon at:
- https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/
- */
- validation: val => /^[A-Za-z0-9]{20}$/.test(val),
- invalidMessage: __('This variable does not match the expected pattern.'),
},
[AWS_DEFAULT_REGION]: {
name: AWS_DEFAULT_REGION,
},
[AWS_SECRET_ACCESS_KEY]: {
name: AWS_SECRET_ACCESS_KEY,
- /* Checks for exactly forty characters that match secret.
- Based on greps suggested by Amazon at:
- https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/
- */
- validation: val => /^[A-Za-z0-9/+=]{40}$/.test(val),
- invalidMessage: __('This variable does not match the expected pattern.'),
},
};
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index 6531b945212..0ba58430de1 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -5,6 +5,7 @@ import {
GlCollapse,
GlDeprecatedButton,
GlFormCheckbox,
+ GlFormCombobox,
GlFormGroup,
GlFormInput,
GlFormSelect,
@@ -16,6 +17,7 @@ import {
} from '@gitlab/ui';
import Cookies from 'js-cookie';
import { mapActions, mapState } from 'vuex';
+import { mapComputed } from '~/vuex_shared/bindings';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
@@ -25,19 +27,21 @@ import {
AWS_TIP_MESSAGE,
} from '../constants';
import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
-import CiKeyField from './ci_key_field.vue';
import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
export default {
modalId: ADD_CI_VARIABLE_MODAL_ID,
+ tokens: awsTokens,
+ tokenList: awsTokenList,
+ awsTipMessage: AWS_TIP_MESSAGE,
components: {
CiEnvironmentsDropdown,
- CiKeyField,
GlAlert,
GlButton,
GlCollapse,
GlDeprecatedButton,
GlFormCheckbox,
+ GlFormCombobox,
GlFormGroup,
GlFormInput,
GlFormSelect,
@@ -48,9 +52,6 @@ export default {
GlSprintf,
},
mixins: [glFeatureFlagsMixin()],
- tokens: awsTokens,
- tokenList: awsTokenList,
- awsTipMessage: AWS_TIP_MESSAGE,
data() {
return {
isTipDismissed: Cookies.get(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
@@ -74,22 +75,34 @@ export default {
'protectedEnvironmentVariablesLink',
'maskedEnvironmentVariablesLink',
]),
+ ...mapComputed(
+ [
+ { key: 'key', updateFn: 'updateVariableKey' },
+ { key: 'secret_value', updateFn: 'updateVariableValue' },
+ { key: 'variable_type', updateFn: 'updateVariableType' },
+ { key: 'environment_scope', updateFn: 'setEnvironmentScope' },
+ { key: 'protected_variable', updateFn: 'updateVariableProtected' },
+ { key: 'masked', updateFn: 'updateVariableMasked' },
+ ],
+ false,
+ 'variable',
+ ),
isTipVisible() {
- return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variableData.key);
+ return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
},
canSubmit() {
return (
this.variableValidationState &&
- this.variableData.key !== '' &&
- this.variableData.secret_value !== ''
+ this.variable.key !== '' &&
+ this.variable.secret_value !== ''
);
},
canMask() {
const regex = RegExp(this.maskableRegex);
- return regex.test(this.variableData.secret_value);
+ return regex.test(this.variable.secret_value);
},
displayMaskedError() {
- return !this.canMask && this.variableData.masked;
+ return !this.canMask && this.variable.masked;
},
maskedState() {
if (this.displayMaskedError) {
@@ -97,9 +110,6 @@ export default {
}
return true;
},
- variableData() {
- return this.variableBeingEdited || this.variable;
- },
modalActionText() {
return this.variableBeingEdited ? __('Update variable') : __('Add variable');
},
@@ -107,7 +117,7 @@ export default {
return this.displayMaskedError ? __('This variable can not be masked.') : '';
},
tokenValidationFeedback() {
- const tokenSpecificFeedback = this.$options.tokens?.[this.variableData.key]?.invalidMessage;
+ const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage;
if (!this.tokenValidationState && tokenSpecificFeedback) {
return tokenSpecificFeedback;
}
@@ -119,10 +129,10 @@ export default {
return true;
}
- const validator = this.$options.tokens?.[this.variableData.key]?.validation;
+ const validator = this.$options.tokens?.[this.variable.key]?.validation;
if (validator) {
- return validator(this.variableData.secret_value);
+ return validator(this.variable.secret_value);
}
return true;
@@ -131,14 +141,7 @@ export default {
return `${this.tokenValidationFeedback} ${this.maskedFeedback}`;
},
variableValidationState() {
- if (
- this.variableData.secret_value === '' ||
- (this.tokenValidationState && this.maskedState)
- ) {
- return true;
- }
-
- return false;
+ return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState);
},
},
methods: {
@@ -160,7 +163,7 @@ export default {
this.isTipDismissed = true;
},
deleteVarAndClose() {
- this.deleteVariable(this.variableBeingEdited);
+ this.deleteVariable();
this.hideModal();
},
hideModal() {
@@ -169,14 +172,14 @@ export default {
resetModalHandler() {
if (this.variableBeingEdited) {
this.resetEditing();
- } else {
- this.clearModal();
}
+
+ this.clearModal();
this.resetSelectedEnvironment();
},
updateOrAddVariable() {
if (this.variableBeingEdited) {
- this.updateVariable(this.variableBeingEdited);
+ this.updateVariable();
} else {
this.addVariable();
}
@@ -202,16 +205,17 @@ export default {
@shown="setVariableProtectedByDefault"
>
<form>
- <ci-key-field
+ <gl-form-combobox
v-if="glFeatures.ciKeyAutocomplete"
- v-model="variableData.key"
+ v-model="key"
:token-list="$options.tokenList"
+ :label-text="__('Key')"
/>
<gl-form-group v-else :label="__('Key')" label-for="ci-variable-key">
<gl-form-input
id="ci-variable-key"
- v-model="variableData.key"
+ v-model="key"
data-qa-selector="ci_variable_key_field"
/>
</gl-form-group>
@@ -225,11 +229,12 @@ export default {
<gl-form-textarea
id="ci-variable-value"
ref="valueField"
- v-model="variableData.secret_value"
+ v-model="secret_value"
:state="variableValidationState"
rows="3"
max-rows="6"
data-qa-selector="ci_variable_value_field"
+ class="gl-font-monospace!"
/>
</gl-form-group>
@@ -240,11 +245,7 @@ export default {
class="w-50 append-right-15"
:class="{ 'w-100': isGroup }"
>
- <gl-form-select
- id="ci-variable-type"
- v-model="variableData.variable_type"
- :options="typeOptions"
- />
+ <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" />
</gl-form-group>
<gl-form-group
@@ -255,7 +256,7 @@ export default {
>
<ci-environments-dropdown
class="w-100"
- :value="variableData.environment_scope"
+ :value="environment_scope"
@selectEnvironment="setEnvironmentScope"
@createClicked="addWildCardScope"
/>
@@ -263,7 +264,7 @@ export default {
</div>
<gl-form-group :label="__('Flags')" label-for="ci-variable-flags">
- <gl-form-checkbox v-model="variableData.protected" class="mb-0">
+ <gl-form-checkbox v-model="protected_variable" class="mb-0">
{{ __('Protect variable') }}
<gl-link target="_blank" :href="protectedEnvironmentVariablesLink">
<gl-icon name="question" :size="12" />
@@ -275,7 +276,7 @@ export default {
<gl-form-checkbox
ref="masked-ci-variable"
- v-model="variableData.masked"
+ v-model="masked"
data-qa-selector="ci_variable_masked_checkbox"
>
{{ __('Mask variable') }}
diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js
index d9129c919f8..60c7a480769 100644
--- a/app/assets/javascripts/ci_variable_list/store/actions.js
+++ b/app/assets/javascripts/ci_variable_list/store/actions.js
@@ -65,10 +65,10 @@ export const receiveUpdateVariableError = ({ commit }, error) => {
commit(types.RECEIVE_UPDATE_VARIABLE_ERROR, error);
};
-export const updateVariable = ({ state, dispatch }, variable) => {
+export const updateVariable = ({ state, dispatch }) => {
dispatch('requestUpdateVariable');
- const updatedVariable = prepareDataForApi(variable);
+ const updatedVariable = prepareDataForApi(state.variable);
updatedVariable.secrect_value = updateVariable.value;
return axios
@@ -121,13 +121,13 @@ export const receiveDeleteVariableError = ({ commit }, error) => {
commit(types.RECEIVE_DELETE_VARIABLE_ERROR, error);
};
-export const deleteVariable = ({ dispatch, state }, variable) => {
+export const deleteVariable = ({ dispatch, state }) => {
dispatch('requestDeleteVariable');
const destroy = true;
return axios
- .patch(state.endpoint, { variables_attributes: [prepareDataForApi(variable, destroy)] })
+ .patch(state.endpoint, { variables_attributes: [prepareDataForApi(state.variable, destroy)] })
.then(() => {
dispatch('receiveDeleteVariableSuccess');
dispatch('fetchVariables');
@@ -176,3 +176,23 @@ export const resetSelectedEnvironment = ({ commit }) => {
export const setSelectedEnvironment = ({ commit }, environment) => {
commit(types.SET_SELECTED_ENVIRONMENT, environment);
};
+
+export const updateVariableKey = ({ commit }, { key }) => {
+ commit(types.UPDATE_VARIABLE_KEY, key);
+};
+
+export const updateVariableValue = ({ commit }, { secret_value }) => {
+ commit(types.UPDATE_VARIABLE_VALUE, secret_value);
+};
+
+export const updateVariableType = ({ commit }, { variable_type }) => {
+ commit(types.UPDATE_VARIABLE_TYPE, variable_type);
+};
+
+export const updateVariableProtected = ({ commit }, { protected_variable }) => {
+ commit(types.UPDATE_VARIABLE_PROTECTED, protected_variable);
+};
+
+export const updateVariableMasked = ({ commit }, { masked }) => {
+ commit(types.UPDATE_VARIABLE_MASKED, masked);
+};
diff --git a/app/assets/javascripts/ci_variable_list/store/mutation_types.js b/app/assets/javascripts/ci_variable_list/store/mutation_types.js
index ccf8fbd3cb5..5db8f610192 100644
--- a/app/assets/javascripts/ci_variable_list/store/mutation_types.js
+++ b/app/assets/javascripts/ci_variable_list/store/mutation_types.js
@@ -25,3 +25,9 @@ export const SET_ENVIRONMENT_SCOPE = 'SET_ENVIRONMENT_SCOPE';
export const ADD_WILD_CARD_SCOPE = 'ADD_WILD_CARD_SCOPE';
export const RESET_SELECTED_ENVIRONMENT = 'RESET_SELECTED_ENVIRONMENT';
export const SET_SELECTED_ENVIRONMENT = 'SET_SELECTED_ENVIRONMENT';
+
+export const UPDATE_VARIABLE_KEY = 'UPDATE_VARIABLE_KEY';
+export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE';
+export const UPDATE_VARIABLE_TYPE = 'UPDATE_VARIABLE_TYPE';
+export const UPDATE_VARIABLE_PROTECTED = 'UPDATE_VARIABLE_PROTECTED';
+export const UPDATE_VARIABLE_MASKED = 'UPDATE_VARIABLE_MASKED';
diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js
index 7d9cd0dd727..961cecee298 100644
--- a/app/assets/javascripts/ci_variable_list/store/mutations.js
+++ b/app/assets/javascripts/ci_variable_list/store/mutations.js
@@ -65,7 +65,8 @@ export default {
},
[types.VARIABLE_BEING_EDITED](state, variable) {
- state.variableBeingEdited = variable;
+ state.variableBeingEdited = true;
+ state.variable = variable;
},
[types.CLEAR_MODAL](state) {
@@ -73,23 +74,19 @@ export default {
variable_type: displayText.variableText,
key: '',
secret_value: '',
- protected: false,
+ protected_variable: false,
masked: false,
environment_scope: displayText.allEnvironmentsText,
};
},
[types.RESET_EDITING](state) {
- state.variableBeingEdited = null;
+ state.variableBeingEdited = false;
state.showInputValue = false;
},
[types.SET_ENVIRONMENT_SCOPE](state, environment) {
- if (state.variableBeingEdited) {
- state.variableBeingEdited.environment_scope = environment;
- } else {
- state.variable.environment_scope = environment;
- }
+ state.variable.environment_scope = environment;
},
[types.ADD_WILD_CARD_SCOPE](state, environment) {
@@ -106,6 +103,26 @@ export default {
},
[types.SET_VARIABLE_PROTECTED](state) {
- state.variable.protected = true;
+ state.variable.protected_variable = true;
+ },
+
+ [types.UPDATE_VARIABLE_KEY](state, key) {
+ state.variable.key = key;
+ },
+
+ [types.UPDATE_VARIABLE_VALUE](state, value) {
+ state.variable.secret_value = value;
+ },
+
+ [types.UPDATE_VARIABLE_TYPE](state, type) {
+ state.variable.variable_type = type;
+ },
+
+ [types.UPDATE_VARIABLE_PROTECTED](state, bool) {
+ state.variable.protected_variable = bool;
+ },
+
+ [types.UPDATE_VARIABLE_MASKED](state, bool) {
+ state.variable.masked = bool;
},
};
diff --git a/app/assets/javascripts/ci_variable_list/store/state.js b/app/assets/javascripts/ci_variable_list/store/state.js
index 2fffd115589..96b27792664 100644
--- a/app/assets/javascripts/ci_variable_list/store/state.js
+++ b/app/assets/javascripts/ci_variable_list/store/state.js
@@ -12,7 +12,7 @@ export default () => ({
variable_type: displayText.variableText,
key: '',
secret_value: '',
- protected: false,
+ protected_variable: false,
masked: false,
environment_scope: displayText.allEnvironmentsText,
},
@@ -21,6 +21,6 @@ export default () => ({
error: null,
environments: [],
typeOptions: [displayText.variableText, displayText.fileText],
- variableBeingEdited: null,
+ variableBeingEdited: false,
selectedEnvironment: '',
});
diff --git a/app/assets/javascripts/ci_variable_list/store/utils.js b/app/assets/javascripts/ci_variable_list/store/utils.js
index 3cd8c85024b..f04530359e7 100644
--- a/app/assets/javascripts/ci_variable_list/store/utils.js
+++ b/app/assets/javascripts/ci_variable_list/store/utils.js
@@ -18,6 +18,7 @@ export const prepareDataForDisplay = variables => {
if (variableCopy.environment_scope === types.allEnvironmentsType) {
variableCopy.environment_scope = displayText.allEnvironmentsText;
}
+ variableCopy.protected_variable = variableCopy.protected;
variablesToDisplay.push(variableCopy);
});
return variablesToDisplay;
@@ -25,7 +26,8 @@ export const prepareDataForDisplay = variables => {
export const prepareDataForApi = (variable, destroy = false) => {
const variableCopy = cloneDeep(variable);
- variableCopy.protected = variableCopy.protected.toString();
+ variableCopy.protected = variableCopy.protected_variable.toString();
+ delete variableCopy.protected_variable;
variableCopy.masked = variableCopy.masked.toString();
variableCopy.variable_type = variableTypeHandler(variableCopy.variable_type);
if (variableCopy.environment_scope === displayText.allEnvironmentsText) {
diff --git a/app/assets/javascripts/close_reopen_report_toggle.js b/app/assets/javascripts/close_reopen_report_toggle.js
index bcddce6e727..9bbbe07e7a1 100644
--- a/app/assets/javascripts/close_reopen_report_toggle.js
+++ b/app/assets/javascripts/close_reopen_report_toggle.js
@@ -80,12 +80,7 @@ class CloseReopenReportToggle {
{
input: this.button,
valueAttribute: 'data-url',
- inputAttribute: 'href',
- },
- {
- input: this.button,
- valueAttribute: 'data-method',
- inputAttribute: 'data-method',
+ inputAttribute: 'data-endpoint',
},
],
};
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index f15efb2fdeb..83bdea15e62 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -222,7 +222,7 @@ export default class Clusters {
initRemoveClusterActions() {
const el = document.querySelector('#js-cluster-remove-actions');
if (el && el.dataset) {
- const { clusterName, clusterPath } = el.dataset;
+ const { clusterName, clusterPath, hasManagementProject } = el.dataset;
this.removeClusterAction = new Vue({
el,
@@ -231,6 +231,7 @@ export default class Clusters {
props: {
clusterName,
clusterPath,
+ hasManagementProject,
},
});
},
diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
index 54f5468bdd0..87c3225085f 100644
--- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
+++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
@@ -198,13 +198,7 @@ export default {
</strong>
</p>
<div class="form-check form-check-inline mt-3">
- <gl-toggle
- v-model="modSecurityEnabled"
- :label-on="__('Enabled')"
- :label-off="__('Disabled')"
- :disabled="saveButtonDisabled"
- label-position="right"
- />
+ <gl-toggle v-model="modSecurityEnabled" :disabled="saveButtonDisabled" />
</div>
<div
v-if="ingress.modsecurity_enabled"
diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
index c5375cbfbdc..45f2dd48961 100644
--- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
+++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
@@ -1,7 +1,7 @@
<script>
import { escape } from 'lodash';
import SplitButton from '~/vue_shared/components/split_button.vue';
-import { GlModal, GlDeprecatedButton, GlFormInput } from '@gitlab/ui';
+import { GlModal, GlButton, GlDeprecatedButton, GlFormInput } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import csrf from '~/lib/utils/csrf';
@@ -27,6 +27,7 @@ export default {
components: {
SplitButton,
GlModal,
+ GlButton,
GlDeprecatedButton,
GlFormInput,
},
@@ -39,6 +40,10 @@ export default {
type: String,
required: true,
},
+ hasManagementProject: {
+ type: Boolean,
+ required: false,
+ },
},
data() {
return {
@@ -90,6 +95,9 @@ export default {
canSubmit() {
return this.enteredClusterName === this.clusterName;
},
+ canCleanupResources() {
+ return !this.hasManagementProject;
+ },
},
methods: {
handleClickRemoveCluster(cleanup = false) {
@@ -112,12 +120,21 @@ export default {
<template>
<div>
<split-button
+ v-if="canCleanupResources"
:action-items="$options.splitButtonActionItems"
menu-class="dropdown-menu-large"
variant="danger"
@remove-cluster="handleClickRemoveCluster(false)"
@remove-cluster-and-cleanup="handleClickRemoveCluster(true)"
/>
+ <gl-button
+ v-else
+ variant="danger"
+ data-testid="btnRemove"
+ @click="handleClickRemoveCluster(false)"
+ >
+ {{ s__('ClusterIntegration|Remove integration') }}
+ </gl-button>
<gl-modal
ref="modal"
size="lg"
diff --git a/app/assets/javascripts/clusters_list/components/ancestor_notice.vue b/app/assets/javascripts/clusters_list/components/ancestor_notice.vue
new file mode 100644
index 00000000000..7954fc61785
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/ancestor_notice.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { mapState } from 'vuex';
+
+export default {
+ components: {
+ GlLink,
+ GlSprintf,
+ },
+ computed: {
+ ...mapState(['ancestorHelperPath', 'hasAncestorClusters']),
+ },
+};
+</script>
+
+<template>
+ <div v-if="hasAncestorClusters" class="bs-callout bs-callout-info">
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters. %{linkStart}More information%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="ancestorHelperPath">
+ <strong>{{ content }}</strong>
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index a3104038c17..7e9b720d269 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -1,14 +1,15 @@
<script>
-import * as Sentry from '@sentry/browser';
import { mapState, mapActions } from 'vuex';
import {
GlDeprecatedBadge as GlBadge,
GlLink,
GlLoadingIcon,
GlPagination,
+ GlSkeletonLoading,
GlSprintf,
GlTable,
} from '@gitlab/ui';
+import AncestorNotice from './ancestor_notice.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import { CLUSTER_TYPES, STATUSES } from '../constants';
import { __, sprintf } from '~/locale';
@@ -17,10 +18,12 @@ export default {
nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'),
nodeCpuText: __('%{totalCpu} (%{freeSpacePercentage}%{percentSymbol} free)'),
components: {
+ AncestorNotice,
GlBadge,
GlLink,
GlLoadingIcon,
GlPagination,
+ GlSkeletonLoading,
GlSprintf,
GlTable,
},
@@ -28,7 +31,18 @@ export default {
tooltip,
},
computed: {
- ...mapState(['clusters', 'clustersPerPage', 'loading', 'page', 'providers', 'totalCulsters']),
+ ...mapState([
+ 'clusters',
+ 'clustersPerPage',
+ 'loadingClusters',
+ 'loadingNodes',
+ 'page',
+ 'providers',
+ 'totalCulsters',
+ ]),
+ contentAlignClasses() {
+ return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start';
+ },
currentPage: {
get() {
return this.page;
@@ -75,7 +89,7 @@ export default {
this.fetchClusters();
},
methods: {
- ...mapActions(['fetchClusters', 'setPage']),
+ ...mapActions(['fetchClusters', 'reportSentryError', 'setPage']),
k8sQuantityToGb(quantity) {
if (!quantity) {
return 0;
@@ -137,7 +151,7 @@ export default {
};
}
} catch (error) {
- Sentry.captureException(error);
+ this.reportSentryError({ error, tag: 'totalMemoryAndUsageError' });
}
return { totalMemory: null, freeSpacePercentage: null };
@@ -170,7 +184,7 @@ export default {
};
}
} catch (error) {
- Sentry.captureException(error);
+ this.reportSentryError({ error, tag: 'totalCpuAndUsageError' });
}
return { totalCpu: null, freeSpacePercentage: null };
@@ -180,14 +194,14 @@ export default {
</script>
<template>
- <gl-loading-icon v-if="loading" size="md" class="mt-3" />
+ <gl-loading-icon v-if="loadingClusters" size="md" class="gl-mt-3" />
<section v-else>
+ <ancestor-notice />
+
<gl-table :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table">
<template #cell(name)="{ item }">
- <div
- class="gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start js-status"
- >
+ <div :class="[contentAlignClasses, 'js-status']">
<img
:src="selectedProvider(item.provider_type).path"
:alt="selectedProvider(item.provider_type).text"
@@ -214,6 +228,9 @@ export default {
<template #cell(node_size)="{ item }">
<span v-if="item.nodes">{{ item.nodes.length }}</span>
+
+ <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
+
<small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-400">{{
__('Unknown')
}}</small>
@@ -231,6 +248,8 @@ export default {
>
</gl-sprintf>
</span>
+
+ <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
</template>
<template #cell(total_memory)="{ item }">
@@ -245,6 +264,8 @@ export default {
>
</gl-sprintf>
</span>
+
+ <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
</template>
<template #cell(cluster_type)="{value}">
diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js
index 5245c307c8c..dddcfb3d975 100644
--- a/app/assets/javascripts/clusters_list/store/actions.js
+++ b/app/assets/javascripts/clusters_list/store/actions.js
@@ -16,9 +16,18 @@ const allNodesPresent = (clusters, retryCount) => {
return retryCount > MAX_REQUESTS || clusters.every(cluster => cluster.nodes != null);
};
-export const fetchClusters = ({ state, commit }) => {
+export const reportSentryError = (_store, { error, tag }) => {
+ Sentry.withScope(scope => {
+ scope.setTag('javascript_clusters_list', tag);
+ Sentry.captureException(error);
+ });
+};
+
+export const fetchClusters = ({ state, commit, dispatch }) => {
let retryCount = 0;
+ commit(types.SET_LOADING_NODES, true);
+
const poll = new Poll({
resource: {
fetchClusters: paginatedEndPoint => axios.get(paginatedEndPoint),
@@ -34,31 +43,30 @@ export const fetchClusters = ({ state, commit }) => {
const paginationInformation = parseIntPagination(normalizedHeaders);
commit(types.SET_CLUSTERS_DATA, { data, paginationInformation });
- commit(types.SET_LOADING_STATE, false);
+ commit(types.SET_LOADING_CLUSTERS, false);
if (allNodesPresent(data.clusters, retryCount)) {
poll.stop();
+ commit(types.SET_LOADING_NODES, false);
}
}
} catch (error) {
poll.stop();
- Sentry.withScope(scope => {
- scope.setTag('javascript_clusters_list', 'fetchClustersSuccessCallback');
- Sentry.captureException(error);
- });
+ commit(types.SET_LOADING_CLUSTERS, false);
+ commit(types.SET_LOADING_NODES, false);
+
+ dispatch('reportSentryError', { error, tag: 'fetchClustersSuccessCallback' });
}
},
errorCallback: response => {
poll.stop();
- commit(types.SET_LOADING_STATE, false);
+ commit(types.SET_LOADING_CLUSTERS, false);
+ commit(types.SET_LOADING_NODES, false);
flash(__('Clusters|An error occurred while loading clusters'));
- Sentry.withScope(scope => {
- scope.setTag('javascript_clusters_list', 'fetchClustersErrorCallback');
- Sentry.captureException(response);
- });
+ dispatch('reportSentryError', { error: response, tag: 'fetchClustersErrorCallback' });
},
});
diff --git a/app/assets/javascripts/clusters_list/store/mutation_types.js b/app/assets/javascripts/clusters_list/store/mutation_types.js
index a5275f28c13..beb4388c93e 100644
--- a/app/assets/javascripts/clusters_list/store/mutation_types.js
+++ b/app/assets/javascripts/clusters_list/store/mutation_types.js
@@ -1,3 +1,4 @@
export const SET_CLUSTERS_DATA = 'SET_CLUSTERS_DATA';
-export const SET_LOADING_STATE = 'SET_LOADING_STATE';
+export const SET_LOADING_CLUSTERS = 'SET_LOADING_CLUSTERS';
+export const SET_LOADING_NODES = 'SET_LOADING_NODES';
export const SET_PAGE = 'SET_PAGE';
diff --git a/app/assets/javascripts/clusters_list/store/mutations.js b/app/assets/javascripts/clusters_list/store/mutations.js
index 2a9df9f38f0..5b462928518 100644
--- a/app/assets/javascripts/clusters_list/store/mutations.js
+++ b/app/assets/javascripts/clusters_list/store/mutations.js
@@ -1,8 +1,11 @@
import * as types from './mutation_types';
export default {
- [types.SET_LOADING_STATE](state, value) {
- state.loading = value;
+ [types.SET_LOADING_CLUSTERS](state, value) {
+ state.loadingClusters = value;
+ },
+ [types.SET_LOADING_NODES](state, value) {
+ state.loadingNodes = value;
},
[types.SET_CLUSTERS_DATA](state, { data, paginationInformation }) {
Object.assign(state, {
diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js
index 0023b43ed92..51fafd49479 100644
--- a/app/assets/javascripts/clusters_list/store/state.js
+++ b/app/assets/javascripts/clusters_list/store/state.js
@@ -1,9 +1,11 @@
export default (initialState = {}) => ({
+ ancestorHelperPath: initialState.ancestorHelpPath,
endpoint: initialState.endpoint,
hasAncestorClusters: false,
- loading: true,
clusters: [],
clustersPerPage: 0,
+ loadingClusters: true,
+ loadingNodes: true,
page: 1,
providers: {
aws: { path: initialState.imgTagsAwsPath, text: initialState.imgTagsAwsText },
diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue
index df5f89e4faf..b7fa3242fbf 100644
--- a/app/assets/javascripts/code_navigation/components/popover.vue
+++ b/app/assets/javascripts/code_navigation/components/popover.vue
@@ -1,10 +1,14 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlTabs, GlTab, GlLink, GlBadge } from '@gitlab/ui';
import DocLine from './doc_line.vue';
export default {
components: {
GlButton,
+ GlTabs,
+ GlTab,
+ GlLink,
+ GlBadge,
DocLine,
},
props: {
@@ -31,6 +35,9 @@ export default {
};
},
computed: {
+ isCurrentDefinition() {
+ return this.data.definitionLineNumber - 1 === this.position.lineIndex;
+ },
positionStyles() {
return {
left: `${this.position.x - this.offsetLeft}px`,
@@ -43,7 +50,7 @@ export default {
}
if (this.isDefinitionCurrentBlob) {
- return `#${this.data.definition_path.split('#').pop()}`;
+ return `#L${this.data.definitionLineNumber}`;
}
return `${this.definitionPathPrefix}/${this.data.definition_path}`;
@@ -51,6 +58,9 @@ export default {
isDefinitionCurrentBlob() {
return this.data.definition_path.indexOf(this.blobPath) === 0;
},
+ references() {
+ return this.data.references || [];
+ },
},
watch: {
position: {
@@ -79,27 +89,61 @@ export default {
class="popover code-navigation-popover popover-font-size-normal gl-popover bs-popover-bottom show"
>
<div :style="{ left: `${offsetLeft}px` }" class="arrow"></div>
- <div v-for="(hover, index) in data.hover" :key="index" class="border-bottom">
- <pre
- v-if="hover.language"
- ref="code-output"
- :class="$options.colorScheme"
- class="border-0 bg-transparent m-0 code highlight"
- ><doc-line v-for="(tokens, tokenIndex) in hover.tokens" :key="tokenIndex" :language="hover.language" :tokens="tokens"/></pre>
- <p v-else ref="doc-output" class="p-3 m-0">
- {{ hover.value }}
- </p>
- </div>
- <div v-if="definitionPath" class="popover-body">
- <gl-button
- :href="definitionPath"
- :target="isDefinitionCurrentBlob ? null : '_blank'"
- class="w-100"
- variant="default"
- data-testid="go-to-definition-btn"
- >
- {{ __('Go to definition') }}
- </gl-button>
- </div>
+ <gl-tabs nav-class="gl-hidden" content-class="gl-py-0">
+ <gl-tab :title="__('Definition')">
+ <div class="overflow-auto code-navigation-popover-container">
+ <div
+ v-for="(hover, index) in data.hover"
+ :key="index"
+ :class="{ 'border-bottom': index !== data.hover.length - 1 }"
+ >
+ <pre
+ v-if="hover.language"
+ ref="code-output"
+ :class="$options.colorScheme"
+ class="border-0 bg-transparent m-0 code highlight text-wrap"
+ ><doc-line v-for="(tokens, tokenIndex) in hover.tokens" :key="tokenIndex" :language="hover.language" :tokens="tokens"/></pre>
+ <p v-else ref="doc-output" class="p-3 m-0">
+ {{ hover.value }}
+ </p>
+ </div>
+ </div>
+ <div v-if="definitionPath || isCurrentDefinition" class="popover-body border-top">
+ <span v-if="isCurrentDefinition" class="gl-font-weight-bold gl-font-base">
+ {{ s__('CodeIntelligence|This is the definition') }}
+ </span>
+ <gl-button
+ v-else
+ :href="definitionPath"
+ :target="isDefinitionCurrentBlob ? null : '_blank'"
+ class="w-100"
+ variant="default"
+ data-testid="go-to-definition-btn"
+ >
+ {{ __('Go to definition') }}
+ </gl-button>
+ </div>
+ </gl-tab>
+ <gl-tab data-testid="references-tab" class="py-2">
+ <template #title>
+ {{ __('References') }}
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ references.length }}</gl-badge>
+ </template>
+ <template v-if="references.length">
+ <div v-for="(reference, index) in references" :key="index" class="gl-dropdown-item">
+ <gl-link
+ :href="`${definitionPathPrefix}/${reference.path}`"
+ class="dropdown-item"
+ data-testid="reference-link"
+ >
+ {{ reference.path }}
+ </gl-link>
+ </div>
+ </template>
+ <p v-else class="gl-my-4 gl-px-4">
+ {{ s__('CodeNavigation|No references found') }}
+ </p>
+ </gl-tab>
+ </gl-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js
index 7b2669691bd..9a472ca014f 100644
--- a/app/assets/javascripts/code_navigation/store/actions.js
+++ b/app/assets/javascripts/code_navigation/store/actions.js
@@ -18,7 +18,10 @@ export default {
.then(({ data }) => {
const normalizedData = data.reduce((acc, d) => {
if (d.hover) {
- acc[`${d.start_line}:${d.start_char}`] = d;
+ acc[`${d.start_line}:${d.start_char}`] = {
+ ...d,
+ definitionLineNumber: parseInt(d.definition_path?.split('#L').pop() || 0, 10),
+ };
addInteractionClass(path, d);
}
return acc;
@@ -67,6 +70,7 @@ export default {
x: x || 0,
y: y + window.scrollY || 0,
height: el.offsetHeight,
+ lineIndex: parseInt(lineIndex, 10),
};
definition = data[`${lineIndex}:${charIndex}`];
diff --git a/app/assets/javascripts/code_navigation/utils/index.js b/app/assets/javascripts/code_navigation/utils/index.js
index 4d118852a94..bb33bc556af 100644
--- a/app/assets/javascripts/code_navigation/utils/index.js
+++ b/app/assets/javascripts/code_navigation/utils/index.js
@@ -22,6 +22,7 @@ export const addInteractionClass = (path, d) => {
el.setAttribute('data-char-index', d.start_char);
el.setAttribute('data-line-index', d.start_line);
el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation');
+ el.closest('.line').classList.add('code-navigation-line');
}
});
};
diff --git a/app/assets/javascripts/commit_merge_requests.js b/app/assets/javascripts/commit_merge_requests.js
index 3a0ab119df6..3cdb1587a3b 100644
--- a/app/assets/javascripts/commit_merge_requests.js
+++ b/app/assets/javascripts/commit_merge_requests.js
@@ -15,14 +15,14 @@ export function createHeader(childElementCount, mergeRequestCount) {
const headerText = getHeaderText(childElementCount, mergeRequestCount);
return $('<span />', {
- class: 'append-right-5',
+ class: 'gl-mr-2',
text: headerText,
});
}
export function createLink(mergeRequest) {
return $('<a />', {
- class: 'append-right-5',
+ class: 'gl-mr-2',
href: mergeRequest.path,
text: `!${mergeRequest.iid}`,
});
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index fdeb64a7644..655109bad9a 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -1,27 +1,24 @@
-// Browser polyfills
-
-/**
- * Polyfill: fetch
- * @what https://fetch.spec.whatwg.org/
- * @why Because Apollo GraphQL client relies on fetch
- * @browsers Internet Explorer 11
- * @see https://caniuse.com/#feat=fetch
- */
-import 'unfetch/polyfill/index';
-
/**
- * Polyfill: FormData APIs
- * @what delete(), get(), getAll(), has(), set(), entries(), keys(), values(),
- * and support for for...of
- * @why Because Apollo GraphQL client relies on fetch
- * @browsers Internet Explorer 11, Edge < 18
- * @see https://caniuse.com/#feat=mdn-api_formdata and subfeatures
+ * Polyfill
+ * @what requestIdleCallback
+ * @why To align browser features
+ * @browsers Safari (all versions)
+ * @see https://caniuse.com/#feat=requestidlecallback
*/
-import 'formdata-polyfill';
+window.requestIdleCallback =
+ window.requestIdleCallback ||
+ function requestShim(cb) {
+ const start = Date.now();
+ return setTimeout(() => {
+ cb({
+ didTimeout: false,
+ timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
+ });
+ }, 1);
+ };
-import './polyfills/custom_event';
-import './polyfills/element';
-import './polyfills/event';
-import './polyfills/nodelist';
-import './polyfills/request_idle_callback';
-import './polyfills/svg';
+window.cancelIdleCallback =
+ window.cancelIdleCallback ||
+ function cancelShim(id) {
+ clearTimeout(id);
+ };
diff --git a/app/assets/javascripts/commons/polyfills/custom_event.js b/app/assets/javascripts/commons/polyfills/custom_event.js
deleted file mode 100644
index 6b14eff6f05..00000000000
--- a/app/assets/javascripts/commons/polyfills/custom_event.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * Polyfill: CustomEvent constructor
- * @what new CustomEvent()
- * @why Certain features, e.g. notes utilize this
- * @browsers Internet Explorer 11
- * @see https://caniuse.com/#feat=customevent
- */
-if (typeof window.CustomEvent !== 'function') {
- window.CustomEvent = function CustomEvent(event, params) {
- const evt = document.createEvent('CustomEvent');
- const evtParams = {
- bubbles: false,
- cancelable: false,
- detail: undefined,
- ...params,
- };
- evt.initCustomEvent(event, evtParams.bubbles, evtParams.cancelable, evtParams.detail);
- return evt;
- };
- window.CustomEvent.prototype = Event;
-}
diff --git a/app/assets/javascripts/commons/polyfills/element.js b/app/assets/javascripts/commons/polyfills/element.js
deleted file mode 100644
index b13ceccf511..00000000000
--- a/app/assets/javascripts/commons/polyfills/element.js
+++ /dev/null
@@ -1,74 +0,0 @@
-/**
- * Polyfill
- * @what Element.classList
- * @why In order to align browser features
- * @browsers Internet Explorer 11
- * @see https://caniuse.com/#feat=classlist
- */
-import 'classlist-polyfill';
-
-/**
- * Polyfill
- * @what Element.closest
- * @why In order to align browser features
- * @browsers Internet Explorer 11
- * @see https://caniuse.com/#feat=element-closest
- */
-Element.prototype.closest =
- Element.prototype.closest ||
- function closest(selector, selectedElement = this) {
- if (!selectedElement) return null;
- return selectedElement.matches(selector)
- ? selectedElement
- : Element.prototype.closest(selector, selectedElement.parentElement);
- };
-
-/**
- * Polyfill
- * @what Element.matches
- * @why In order to align browser features
- * @browsers Internet Explorer 11
- * @see https://caniuse.com/#feat=mdn-api_element_matches
- */
-Element.prototype.matches =
- Element.prototype.matches ||
- Element.prototype.matchesSelector ||
- Element.prototype.mozMatchesSelector ||
- Element.prototype.msMatchesSelector ||
- Element.prototype.oMatchesSelector ||
- Element.prototype.webkitMatchesSelector ||
- function matches(selector) {
- const elms = (this.document || this.ownerDocument).querySelectorAll(selector);
- let i = elms.length - 1;
- while (i >= 0 && elms.item(i) !== this) {
- i -= 1;
- }
- return i > -1;
- };
-
-/**
- * Polyfill
- * @what ChildNode.remove, Element.remove, CharacterData.remove, DocumentType.remove
- * @why In order to align browser features
- * @browsers Internet Explorer 11
- * @see https://caniuse.com/#feat=childnode-remove
- *
- * From the polyfill on MDN, https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/remove#Polyfill
- */
-(arr => {
- arr.forEach(item => {
- if (Object.prototype.hasOwnProperty.call(item, 'remove')) {
- return;
- }
- Object.defineProperty(item, 'remove', {
- configurable: true,
- enumerable: true,
- writable: true,
- value: function remove() {
- if (this.parentNode !== null) {
- this.parentNode.removeChild(this);
- }
- },
- });
- });
-})([Element.prototype, CharacterData.prototype, DocumentType.prototype]);
diff --git a/app/assets/javascripts/commons/polyfills/event.js b/app/assets/javascripts/commons/polyfills/event.js
deleted file mode 100644
index 543dd5f9a93..00000000000
--- a/app/assets/javascripts/commons/polyfills/event.js
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * Polyfill: Event constructor
- * @what new Event()
- * @why To align browser support
- * @browsers Internet Explorer 11
- * @see https://caniuse.com/#feat=mdn-api_event_event
- *
- * Although `initEvent` is deprecated for modern browsers it is the one supported by IE
- */
-if (typeof window.Event !== 'function') {
- window.Event = function Event(event, params) {
- const evt = document.createEvent('Event');
- const evtParams = {
- bubbles: false,
- cancelable: false,
- ...params,
- };
- evt.initEvent(event, evtParams.bubbles, evtParams.cancelable);
- return evt;
- };
- window.Event.prototype = Event;
-}
diff --git a/app/assets/javascripts/commons/polyfills/nodelist.js b/app/assets/javascripts/commons/polyfills/nodelist.js
deleted file mode 100644
index 3a9111e64f8..00000000000
--- a/app/assets/javascripts/commons/polyfills/nodelist.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Polyfill
- * @what NodeList.forEach
- * @why To align browser support
- * @browsers Internet Explorer 11
- * @see https://caniuse.com/#feat=mdn-api_nodelist_foreach
- */
-if (window.NodeList && !NodeList.prototype.forEach) {
- NodeList.prototype.forEach = function forEach(callback, thisArg = window) {
- for (let i = 0; i < this.length; i += 1) {
- callback.call(thisArg, this[i], i, this);
- }
- };
-}
diff --git a/app/assets/javascripts/commons/polyfills/request_idle_callback.js b/app/assets/javascripts/commons/polyfills/request_idle_callback.js
deleted file mode 100644
index 51dc82e593a..00000000000
--- a/app/assets/javascripts/commons/polyfills/request_idle_callback.js
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * Polyfill
- * @what requestIdleCallback
- * @why To align browser features
- * @browsers Safari (all versions), Internet Explorer 11
- * @see https://caniuse.com/#feat=requestidlecallback
- */
-window.requestIdleCallback =
- window.requestIdleCallback ||
- function requestShim(cb) {
- const start = Date.now();
- return setTimeout(() => {
- cb({
- didTimeout: false,
- timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
- });
- }, 1);
- };
-
-window.cancelIdleCallback =
- window.cancelIdleCallback ||
- function cancelShim(id) {
- clearTimeout(id);
- };
diff --git a/app/assets/javascripts/commons/polyfills/svg.js b/app/assets/javascripts/commons/polyfills/svg.js
deleted file mode 100644
index 92a8b03fbb4..00000000000
--- a/app/assets/javascripts/commons/polyfills/svg.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * polyfill support for external SVG file references via <use xlink:href>
- * @what polyfill support for external SVG file references via <use xlink:href>
- * @why This is used in our GitLab SVG icon library
- * @browsers Internet Explorer 11
- * @see https://caniuse.com/#feat=mdn-svg_elements_use_external_uri
- * @see https//css-tricks.com/svg-use-external-source/
- */
-import svg4everybody from 'svg4everybody';
-
-svg4everybody();
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue
index 3c18608eb75..4b15bd55cbd 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue
@@ -25,11 +25,6 @@ export default {
default: '',
required: false,
},
- canEdit: {
- type: Boolean,
- default: false,
- required: false,
- },
},
computed: {
hasValue() {
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index 5505704f430..1b8668b533e 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -7,12 +7,14 @@ import eventHub from '../eventhub';
import DeployKeysService from '../service';
import DeployKeysStore from '../store';
import KeysPanel from './keys_panel.vue';
+import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
KeysPanel,
NavigationTabs,
GlLoadingIcon,
+ Icon,
},
props: {
endpoint: {
@@ -115,7 +117,7 @@ export default {
</script>
<template>
- <div class="append-bottom-default deploy-keys">
+ <div class="gl-mb-3 deploy-keys">
<gl-loading-icon
v-if="isLoading && !hasKeys"
:label="s__('DeployKeys|Loading deploy keys')"
@@ -123,8 +125,8 @@ export default {
/>
<template v-else-if="hasKeys">
<div class="top-area scrolling-tabs-container inner-page-scroll-tabs">
- <div class="fade-left"><i class="fa fa-angle-left" aria-hidden="true"> </i></div>
- <div class="fade-right"><i class="fa fa-angle-right" aria-hidden="true"> </i></div>
+ <div class="fade-left"><icon name="chevron-lg-left" :size="12" /></div>
+ <div class="fade-right"><icon name="chevron-lg-right" :size="12" /></div>
<navigation-tabs :tabs="tabs" scope="deployKeys" @onChangeTab="onChangeTab" />
</div>
diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue
index ad3f2736c4a..62460ca551c 100644
--- a/app/assets/javascripts/design_management/components/design_destroyer.vue
+++ b/app/assets/javascripts/design_management/components/design_destroyer.vue
@@ -1,7 +1,7 @@
<script>
import { ApolloMutation } from 'vue-apollo';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
-import destroyDesignMutation from '../graphql/mutations/destroyDesign.mutation.graphql';
+import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql';
import { updateStoreAfterDesignsDelete } from '../utils/cache_update';
export default {
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 7e442bb295f..4aaf43e3a5b 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -5,9 +5,9 @@ import { s__ } from '~/locale';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import allVersionsMixin from '../../mixins/all_versions';
-import createNoteMutation from '../../graphql/mutations/createNote.mutation.graphql';
+import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql';
import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
-import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
+import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql';
import DesignNote from './design_note.vue';
import DesignReplyForm from './design_reply_form.vue';
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 756da7f55aa..969034909f2 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -62,7 +62,7 @@ export default {
},
},
mounted() {
- this.$refs.textarea.focus();
+ this.focusInput();
},
methods: {
submitForm() {
@@ -75,6 +75,9 @@ export default {
this.$emit('cancelForm');
}
},
+ focusInput() {
+ this.$refs.textarea.focus();
+ },
},
};
</script>
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index ea9f7300981..b998dfc47b8 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -6,7 +6,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago';
import Pagination from './pagination.vue';
import DeleteButton from '../delete_button.vue';
import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql';
-import appDataQuery from '../../graphql/queries/appData.query.graphql';
+import appDataQuery from '../../graphql/queries/app_data.query.graphql';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
export default {
diff --git a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue
index e2e1fc8bfad..33261134c15 100644
--- a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue
+++ b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import createFlash from '~/flash';
-import uploadDesignMutation from '../../graphql/mutations/uploadDesign.mutation.graphql';
+import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql';
import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages';
import { isValidDesignFile } from '../../utils/design_management_utils';
import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
diff --git a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql
index c1439c56ff5..4b1703e41c3 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql
@@ -1,6 +1,6 @@
-#import "./designNote.fragment.graphql"
-#import "./designList.fragment.graphql"
-#import "./diffRefs.fragment.graphql"
+#import "./design_note.fragment.graphql"
+#import "./design_list.fragment.graphql"
+#import "./diff_refs.fragment.graphql"
#import "./discussion_resolved_status.fragment.graphql"
fragment DesignItem on Design {
diff --git a/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql
index bc3132f9b42..bc3132f9b42 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql
diff --git a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql
index cb7cfd89abf..26edd2c0be1 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql
@@ -1,4 +1,4 @@
-#import "./diffRefs.fragment.graphql"
+#import "./diff_refs.fragment.graphql"
#import "~/graphql_shared/fragments/author.fragment.graphql"
#import "./note_permissions.fragment.graphql"
diff --git a/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/diff_refs.fragment.graphql
index 984a55814b0..984a55814b0 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/diff_refs.fragment.graphql
diff --git a/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql
index 9e2931b23f2..c8ade328120 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql
@@ -1,4 +1,4 @@
-#import "../fragments/designNote.fragment.graphql"
+#import "../fragments/design_note.fragment.graphql"
mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
createImageDiffNote(input: $input) {
diff --git a/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/create_note.mutation.graphql
index 3ae478d658e..184ee6955dc 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/create_note.mutation.graphql
@@ -1,4 +1,4 @@
-#import "../fragments/designNote.fragment.graphql"
+#import "../fragments/design_note.fragment.graphql"
mutation createNote($input: CreateNoteInput!) {
createNote(input: $input) {
diff --git a/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/destroy_design.mutation.graphql
index 0b3cf636cdb..0b3cf636cdb 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/destroy_design.mutation.graphql
diff --git a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql
index d5f54ec9b58..1157fc05d5f 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql
@@ -1,4 +1,4 @@
-#import "../fragments/designNote.fragment.graphql"
+#import "../fragments/design_note.fragment.graphql"
#import "../fragments/discussion_resolved_status.fragment.graphql"
mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) {
diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql
index 343de4e3025..a24b6737159 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql
@@ -1,3 +1,3 @@
mutation updateActiveDiscussion($id: String, $source: String) {
- updateActiveDiscussion (id: $id, source: $source ) @client
+ updateActiveDiscussion(id: $id, source: $source) @client
}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql
index cdb2264d233..5562ca9d89f 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql
@@ -1,4 +1,4 @@
-#import "../fragments/designNote.fragment.graphql"
+#import "../fragments/design_note.fragment.graphql"
mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) {
updateImageDiffNote(input: $input) {
diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql
index d96b2f3934a..b995e99fb6a 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql
@@ -1,4 +1,4 @@
-#import "../fragments/designNote.fragment.graphql"
+#import "../fragments/design_note.fragment.graphql"
mutation updateNote($input: UpdateNoteInput!) {
updateNote(input: $input) {
diff --git a/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
index 904acef599b..d694e6558a0 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
@@ -11,7 +11,7 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
sha
}
}
- },
+ }
}
skippedDesigns {
filename
diff --git a/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql b/app/assets/javascripts/design_management/graphql/queries/app_data.query.graphql
index e1269761206..e1269761206 100644
--- a/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql
+++ b/app/assets/javascripts/design_management/graphql/queries/app_data.query.graphql
diff --git a/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql
index 07a9af55787..07a9af55787 100644
--- a/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql
+++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql
diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
index 857f205ab07..121a50555b3 100644
--- a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
+++ b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
@@ -1,4 +1,4 @@
-#import "../fragments/designList.fragment.graphql"
+#import "../fragments/design_list.fragment.graphql"
#import "../fragments/version.fragment.graphql"
query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js
index eb00e1742ea..1fc5779515a 100644
--- a/app/assets/javascripts/design_management/index.js
+++ b/app/assets/javascripts/design_management/index.js
@@ -1,3 +1,6 @@
+// This application is being moved, please do not touch this files
+// Please see https://gitlab.com/gitlab-org/gitlab/-/issues/14744#note_364468096 for details
+
import $ from 'jquery';
import Vue from 'vue';
import createRouter from './router';
diff --git a/app/assets/javascripts/design_management/mixins/all_versions.js b/app/assets/javascripts/design_management/mixins/all_versions.js
index 41c93064c26..3966fe71732 100644
--- a/app/assets/javascripts/design_management/mixins/all_versions.js
+++ b/app/assets/javascripts/design_management/mixins/all_versions.js
@@ -1,5 +1,5 @@
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
-import appDataQuery from '../graphql/queries/appData.query.graphql';
+import appDataQuery from '../graphql/queries/app_data.query.graphql';
import { findVersionId } from '../utils/design_management_utils';
export default {
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index fe121b6530a..9a959222e22 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -11,10 +11,10 @@ import DesignScaler from '../../components/design_scaler.vue';
import DesignPresentation from '../../components/design_presentation.vue';
import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
import DesignSidebar from '../../components/design_sidebar.vue';
-import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
-import appDataQuery from '../../graphql/queries/appData.query.graphql';
-import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql';
-import updateImageDiffNoteMutation from '../../graphql/mutations/updateImageDiffNote.mutation.graphql';
+import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
+import appDataQuery from '../../graphql/queries/app_data.query.graphql';
+import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql';
+import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql';
import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql';
import {
extractDiscussions,
@@ -254,6 +254,9 @@ export default {
},
openCommentForm(annotationCoordinates) {
this.annotationCoordinates = annotationCoordinates;
+ if (this.$refs.newDiscussionForm) {
+ this.$refs.newDiscussionForm.focusInput();
+ }
},
closeCommentForm() {
this.comment = '';
@@ -361,6 +364,7 @@ export default {
@error="onCreateImageDiffNoteError"
>
<design-reply-form
+ ref="newDiscussionForm"
v-model="comment"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 922c800009f..d14a1fc8c1c 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -8,7 +8,7 @@ import Design from '../components/list/item.vue';
import DesignDestroyer from '../components/design_destroyer.vue';
import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
import DesignDropzone from '../components/upload/design_dropzone.vue';
-import uploadDesignMutation from '../graphql/mutations/uploadDesign.mutation.graphql';
+import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
import permissionsQuery from '../graphql/queries/design_permissions.query.graphql';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js
index 39c20376271..b3ecc1453a6 100644
--- a/app/assets/javascripts/design_management/utils/tracking.js
+++ b/app/assets/javascripts/design_management/utils/tracking.js
@@ -1,18 +1,9 @@
import Tracking from '~/tracking';
-function assembleDesignPayload(payloadArr) {
- return {
- value: {
- 'internal-object-refrerer': payloadArr[0],
- 'design-collection-owner': payloadArr[1],
- 'design-version-number': payloadArr[2],
- 'design-is-current-version': payloadArr[3],
- },
- };
-}
-
// Tracking Constants
+const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0';
const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
+const DESIGN_TRACKING_EVENT_NAME = 'view_design';
// eslint-disable-next-line import/prefer-default-export
export function trackDesignDetailView(
@@ -21,8 +12,16 @@ export function trackDesignDetailView(
designVersion = 1,
latestVersion = false,
) {
- Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', {
- label: 'design_viewed',
- ...assembleDesignPayload([referer, owner, designVersion, latestVersion]),
+ Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, {
+ label: DESIGN_TRACKING_EVENT_NAME,
+ context: {
+ schema: DESIGN_TRACKING_CONTEXT_SCHEMA,
+ data: {
+ 'design-version-number': designVersion,
+ 'design-is-current-version': latestVersion,
+ 'internal-object-referrer': referer,
+ 'design-collection-owner': owner,
+ },
+ },
});
}
diff --git a/app/assets/javascripts/design_management_new/components/app.vue b/app/assets/javascripts/design_management_new/components/app.vue
new file mode 100644
index 00000000000..98240aef810
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/app.vue
@@ -0,0 +1,3 @@
+<template>
+ <router-view />
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/delete_button.vue b/app/assets/javascripts/design_management_new/components/delete_button.vue
new file mode 100644
index 00000000000..77e1b97a227
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/delete_button.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'DeleteButton',
+ components: {
+ GlButton,
+ GlModal,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ props: {
+ isDeleting: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ buttonClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ buttonVariant: {
+ type: String,
+ required: false,
+ default: 'info',
+ },
+ buttonSize: {
+ type: String,
+ required: false,
+ default: 'medium',
+ },
+ hasSelectedDesigns: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ modalId: uniqueId('design-deletion-confirmation-'),
+ };
+ },
+ modal: {
+ title: s__('DesignManagement|Delete designs confirmation'),
+ actionPrimary: {
+ text: s__('Delete'),
+ attributes: { variant: 'danger' },
+ },
+ actionCancel: {
+ text: s__('Cancel'),
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center gl-h-full">
+ <gl-modal
+ :modal-id="modalId"
+ :title="$options.modal.title"
+ :action-primary="$options.modal.actionPrimary"
+ :action-cancel="$options.modal.actionCancel"
+ @ok="$emit('deleteSelectedDesigns')"
+ >
+ <p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p>
+ </gl-modal>
+ <gl-button
+ v-gl-modal-directive="modalId"
+ :variant="buttonVariant"
+ :size="buttonSize"
+ :class="buttonClass"
+ :disabled="isDeleting || !hasSelectedDesigns"
+ >
+ <slot></slot>
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/design_destroyer.vue b/app/assets/javascripts/design_management_new/components/design_destroyer.vue
new file mode 100644
index 00000000000..7ae569216f0
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/design_destroyer.vue
@@ -0,0 +1,67 @@
+<script>
+import { ApolloMutation } from 'vue-apollo';
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql';
+import { updateStoreAfterDesignsDelete } from '../utils/cache_update';
+
+export default {
+ components: {
+ ApolloMutation,
+ },
+ props: {
+ filenames: {
+ type: Array,
+ required: true,
+ },
+ },
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ iid: {
+ from: 'issueIid',
+ defaut: '',
+ },
+ },
+ computed: {
+ projectQueryBody() {
+ return {
+ query: getDesignListQuery,
+ variables: { fullPath: this.projectPath, iid: this.iid, atVersion: null },
+ };
+ },
+ },
+ methods: {
+ updateStoreAfterDelete(
+ store,
+ {
+ data: { designManagementDelete },
+ },
+ ) {
+ updateStoreAfterDesignsDelete(
+ store,
+ designManagementDelete,
+ this.projectQueryBody,
+ this.filenames,
+ );
+ },
+ },
+ destroyDesignMutation,
+};
+</script>
+
+<template>
+ <apollo-mutation
+ #default="{ mutate, loading, error }"
+ :mutation="$options.destroyDesignMutation"
+ :variables="{
+ filenames,
+ projectPath,
+ iid,
+ }"
+ :update="updateStoreAfterDelete"
+ v-on="$listeners"
+ >
+ <slot v-bind="{ mutate, loading, error }"></slot>
+ </apollo-mutation>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/design_note_pin.vue b/app/assets/javascripts/design_management_new/components/design_note_pin.vue
new file mode 100644
index 00000000000..0811397fbad
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/design_note_pin.vue
@@ -0,0 +1,61 @@
+<script>
+import { __, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'DesignNotePin',
+ components: {
+ Icon,
+ },
+ props: {
+ position: {
+ type: Object,
+ required: true,
+ },
+ label: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ repositioning: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ isNewNote() {
+ return this.label === null;
+ },
+ pinStyle() {
+ return this.repositioning ? { ...this.position, cursor: 'move' } : this.position;
+ },
+ pinLabel() {
+ return this.isNewNote
+ ? __('Comment form position')
+ : sprintf(__("Comment '%{label}' position"), { label: this.label });
+ },
+ },
+};
+</script>
+
+<template>
+ <button
+ :style="pinStyle"
+ :aria-label="pinLabel"
+ :class="{
+ 'btn-transparent comment-indicator': isNewNote,
+ 'js-image-badge badge badge-pill': !isNewNote,
+ }"
+ class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center"
+ type="button"
+ @mousedown="$emit('mousedown', $event)"
+ @mouseup="$emit('mouseup', $event)"
+ @click="$emit('click', $event)"
+ >
+ <icon v-if="isNewNote" name="image-comment-dark" />
+ <template v-else>
+ {{ label }}
+ </template>
+ </button>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue
new file mode 100644
index 00000000000..4aaf43e3a5b
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue
@@ -0,0 +1,297 @@
+<script>
+import { ApolloMutation } from 'vue-apollo';
+import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import allVersionsMixin from '../../mixins/all_versions';
+import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql';
+import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
+import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
+import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql';
+import DesignNote from './design_note.vue';
+import DesignReplyForm from './design_reply_form.vue';
+import { updateStoreAfterAddDiscussionComment } from '../../utils/cache_update';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
+import ToggleRepliesWidget from './toggle_replies_widget.vue';
+
+export default {
+ components: {
+ ApolloMutation,
+ DesignNote,
+ ReplyPlaceholder,
+ DesignReplyForm,
+ GlIcon,
+ GlLoadingIcon,
+ GlLink,
+ ToggleRepliesWidget,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [allVersionsMixin],
+ props: {
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ noteableId: {
+ type: String,
+ required: true,
+ },
+ designId: {
+ type: String,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ resolvedDiscussionsExpanded: {
+ type: Boolean,
+ required: true,
+ },
+ discussionWithOpenForm: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ activeDiscussion: {
+ query: activeDiscussionQuery,
+ result({ data }) {
+ const discussionId = data.activeDiscussion.id;
+ if (this.discussion.resolved && !this.resolvedDiscussionsExpanded) {
+ return;
+ }
+ // We watch any changes to the active discussion from the design pins and scroll to this discussion if it exists
+ // We don't want scrollIntoView to be triggered from the discussion click itself
+ if (
+ discussionId &&
+ data.activeDiscussion.source === ACTIVE_DISCUSSION_SOURCE_TYPES.pin &&
+ discussionId === this.discussion.notes[0].id
+ ) {
+ this.$el.scrollIntoView({
+ behavior: 'smooth',
+ inline: 'start',
+ });
+ }
+ },
+ },
+ },
+ data() {
+ return {
+ discussionComment: '',
+ isFormRendered: false,
+ activeDiscussion: {},
+ isResolving: false,
+ shouldChangeResolvedStatus: false,
+ areRepliesCollapsed: this.discussion.resolved,
+ };
+ },
+ computed: {
+ mutationPayload() {
+ return {
+ noteableId: this.noteableId,
+ body: this.discussionComment,
+ discussionId: this.discussion.id,
+ };
+ },
+ designVariables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ filenames: [this.$route.params.id],
+ atVersion: this.designsVersion,
+ };
+ },
+ isDiscussionHighlighted() {
+ return this.discussion.notes[0].id === this.activeDiscussion.id;
+ },
+ resolveCheckboxText() {
+ return this.discussion.resolved
+ ? s__('DesignManagement|Unresolve thread')
+ : s__('DesignManagement|Resolve thread');
+ },
+ firstNote() {
+ return this.discussion.notes[0];
+ },
+ discussionReplies() {
+ return this.discussion.notes.slice(1);
+ },
+ areRepliesShown() {
+ return !this.discussion.resolved || !this.areRepliesCollapsed;
+ },
+ resolveIconName() {
+ return this.discussion.resolved ? 'check-circle-filled' : 'check-circle';
+ },
+ isRepliesWidgetVisible() {
+ return this.discussion.resolved && this.discussionReplies.length > 0;
+ },
+ isReplyPlaceholderVisible() {
+ return this.areRepliesShown || !this.discussionReplies.length;
+ },
+ isFormVisible() {
+ return this.isFormRendered && this.discussionWithOpenForm === this.discussion.id;
+ },
+ },
+ methods: {
+ addDiscussionComment(
+ store,
+ {
+ data: { createNote },
+ },
+ ) {
+ updateStoreAfterAddDiscussionComment(
+ store,
+ createNote,
+ getDesignQuery,
+ this.designVariables,
+ this.discussion.id,
+ );
+ },
+ onDone() {
+ this.discussionComment = '';
+ this.hideForm();
+ if (this.shouldChangeResolvedStatus) {
+ this.toggleResolvedStatus();
+ }
+ },
+ onCreateNoteError(err) {
+ this.$emit('createNoteError', err);
+ },
+ hideForm() {
+ this.isFormRendered = false;
+ this.discussionComment = '';
+ },
+ showForm() {
+ this.$emit('openForm', this.discussion.id);
+ this.isFormRendered = true;
+ },
+ toggleResolvedStatus() {
+ this.isResolving = true;
+ this.$apollo
+ .mutate({
+ mutation: toggleResolveDiscussionMutation,
+ variables: { id: this.discussion.id, resolve: !this.discussion.resolved },
+ })
+ .then(({ data }) => {
+ if (data.errors?.length > 0) {
+ this.$emit('resolveDiscussionError', data.errors[0]);
+ }
+ })
+ .catch(err => {
+ this.$emit('resolveDiscussionError', err);
+ })
+ .finally(() => {
+ this.isResolving = false;
+ });
+ },
+ },
+ createNoteMutation,
+};
+</script>
+
+<template>
+ <div class="design-discussion-wrapper">
+ <div
+ class="badge badge-pill gl-display-flex gl-align-items-center gl-justify-content-center"
+ :class="{ resolved: discussion.resolved }"
+ type="button"
+ >
+ {{ discussion.index }}
+ </div>
+ <ul
+ class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none"
+ data-qa-selector="design_discussion_content"
+ >
+ <design-note
+ :note="firstNote"
+ :markdown-preview-path="markdownPreviewPath"
+ :is-resolving="isResolving"
+ :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
+ @error="$emit('updateNoteError', $event)"
+ >
+ <template v-if="discussion.resolvable" #resolveDiscussion>
+ <button
+ v-gl-tooltip
+ :class="{ 'is-active': discussion.resolved }"
+ :title="resolveCheckboxText"
+ :aria-label="resolveCheckboxText"
+ type="button"
+ class="line-resolve-btn note-action-button gl-mr-3"
+ data-testid="resolve-button"
+ @click.stop="toggleResolvedStatus"
+ >
+ <gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" />
+ <gl-loading-icon v-else inline />
+ </button>
+ </template>
+ <template v-if="discussion.resolved" #resolvedStatus>
+ <p class="gl-text-gray-700 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message">
+ {{ __('Resolved by') }}
+ <gl-link
+ class="gl-text-gray-700 gl-text-decoration-none gl-font-sm link-inherit-color"
+ :href="discussion.resolvedBy.webUrl"
+ target="_blank"
+ >{{ discussion.resolvedBy.name }}</gl-link
+ >
+ <time-ago-tooltip :time="discussion.resolvedAt" tooltip-placement="bottom" />
+ </p>
+ </template>
+ </design-note>
+ <toggle-replies-widget
+ v-if="isRepliesWidgetVisible"
+ :collapsed="areRepliesCollapsed"
+ :replies="discussionReplies"
+ @toggle="areRepliesCollapsed = !areRepliesCollapsed"
+ />
+ <design-note
+ v-for="note in discussionReplies"
+ v-show="areRepliesShown"
+ :key="note.id"
+ :note="note"
+ :markdown-preview-path="markdownPreviewPath"
+ :is-resolving="isResolving"
+ :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
+ @error="$emit('updateNoteError', $event)"
+ />
+ <li v-show="isReplyPlaceholderVisible" class="reply-wrapper">
+ <reply-placeholder
+ v-if="!isFormVisible"
+ class="qa-discussion-reply"
+ :button-text="__('Reply...')"
+ @onClick="showForm"
+ />
+ <apollo-mutation
+ v-else
+ #default="{ mutate, loading }"
+ :mutation="$options.createNoteMutation"
+ :variables="{
+ input: mutationPayload,
+ }"
+ :update="addDiscussionComment"
+ @done="onDone"
+ @error="onCreateNoteError"
+ >
+ <design-reply-form
+ v-model="discussionComment"
+ :is-saving="loading"
+ :markdown-preview-path="markdownPreviewPath"
+ @submitForm="mutate"
+ @cancelForm="hideForm"
+ >
+ <template v-if="discussion.resolvable" #resolveCheckbox>
+ <label data-testid="resolve-checkbox">
+ <input v-model="shouldChangeResolvedStatus" type="checkbox" />
+ {{ resolveCheckboxText }}
+ </label>
+ </template>
+ </design-reply-form>
+ </apollo-mutation>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_note.vue b/app/assets/javascripts/design_management_new/components/design_notes/design_note.vue
new file mode 100644
index 00000000000..172e61920ef
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/design_notes/design_note.vue
@@ -0,0 +1,156 @@
+<script>
+import { ApolloMutation } from 'vue-apollo';
+import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import DesignReplyForm from './design_reply_form.vue';
+import { findNoteId } from '../../utils/design_management_utils';
+import { hasErrors } from '../../utils/cache_update';
+
+export default {
+ components: {
+ UserAvatarLink,
+ TimelineEntryItem,
+ TimeAgoTooltip,
+ DesignReplyForm,
+ ApolloMutation,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ noteText: this.note.body,
+ isEditing: false,
+ };
+ },
+ computed: {
+ author() {
+ return this.note.author;
+ },
+ noteAnchorId() {
+ return findNoteId(this.note.id);
+ },
+ isNoteLinked() {
+ return this.$route.hash === `#note_${this.noteAnchorId}`;
+ },
+ mutationPayload() {
+ return {
+ id: this.note.id,
+ body: this.noteText,
+ };
+ },
+ isEditButtonVisible() {
+ return !this.isEditing && this.note.userPermissions.adminNote;
+ },
+ },
+ mounted() {
+ if (this.isNoteLinked) {
+ this.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' });
+ }
+ },
+ methods: {
+ hideForm() {
+ this.isEditing = false;
+ this.noteText = this.note.body;
+ },
+ onDone({ data }) {
+ this.hideForm();
+ if (hasErrors(data.updateNote)) {
+ this.$emit('error', data.errors[0]);
+ }
+ },
+ },
+ updateNoteMutation,
+};
+</script>
+
+<template>
+ <timeline-entry-item :id="`note_${noteAnchorId}`" class="design-note note-form">
+ <user-avatar-link
+ :link-href="author.webUrl"
+ :img-src="author.avatarUrl"
+ :img-alt="author.username"
+ :img-size="40"
+ />
+ <div class="d-flex justify-content-between">
+ <div>
+ <a
+ v-once
+ :href="author.webUrl"
+ class="js-user-link"
+ :data-user-id="author.id"
+ :data-username="author.username"
+ >
+ <span class="note-header-author-name bold">{{ author.name }}</span>
+ <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
+ <span class="note-headline-light">@{{ author.username }}</span>
+ </a>
+ <span class="note-headline-light note-headline-meta">
+ <span class="system-note-message"> <slot></slot> </span>
+ <template v-if="note.createdAt">
+ <span class="system-note-separator"></span>
+ <a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`">
+ <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
+ </a>
+ </template>
+ </span>
+ </div>
+ <div class="gl-display-flex">
+ <slot name="resolveDiscussion"></slot>
+ <button
+ v-if="isEditButtonVisible"
+ v-gl-tooltip
+ type="button"
+ :title="__('Edit comment')"
+ class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
+ @click="isEditing = true"
+ >
+ <gl-icon name="pencil" class="link-highlight" />
+ </button>
+ </div>
+ </div>
+ <template v-if="!isEditing">
+ <div
+ class="note-text js-note-text md"
+ data-qa-selector="note_content"
+ v-html="note.bodyHtml"
+ ></div>
+ <slot name="resolvedStatus"></slot>
+ </template>
+ <apollo-mutation
+ v-else
+ #default="{ mutate, loading }"
+ :mutation="$options.updateNoteMutation"
+ :variables="{
+ input: mutationPayload,
+ }"
+ @error="$emit('error', $event)"
+ @done="onDone"
+ >
+ <design-reply-form
+ v-model="noteText"
+ :is-saving="loading"
+ :markdown-preview-path="markdownPreviewPath"
+ :is-new-comment="false"
+ class="mt-5"
+ @submitForm="mutate"
+ @cancelForm="hideForm"
+ />
+ </apollo-mutation>
+ </timeline-entry-item>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue
new file mode 100644
index 00000000000..969034909f2
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue
@@ -0,0 +1,141 @@
+<script>
+import { GlDeprecatedButton, GlModal } from '@gitlab/ui';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'DesignReplyForm',
+ components: {
+ MarkdownField,
+ GlDeprecatedButton,
+ GlModal,
+ },
+ props: {
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ value: {
+ type: String,
+ required: true,
+ },
+ isSaving: {
+ type: Boolean,
+ required: true,
+ },
+ isNewComment: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ formText: this.value,
+ };
+ },
+ computed: {
+ hasValue() {
+ return this.value.trim().length > 0;
+ },
+ modalSettings() {
+ if (this.isNewComment) {
+ return {
+ title: s__('DesignManagement|Cancel comment confirmation'),
+ okTitle: s__('DesignManagement|Discard comment'),
+ cancelTitle: s__('DesignManagement|Keep comment'),
+ content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'),
+ };
+ }
+ return {
+ title: s__('DesignManagement|Cancel comment update confirmation'),
+ okTitle: s__('DesignManagement|Cancel changes'),
+ cancelTitle: s__('DesignManagement|Keep changes'),
+ content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'),
+ };
+ },
+ buttonText() {
+ return this.isNewComment
+ ? s__('DesignManagement|Comment')
+ : s__('DesignManagement|Save comment');
+ },
+ },
+ mounted() {
+ this.focusInput();
+ },
+ methods: {
+ submitForm() {
+ if (this.hasValue) this.$emit('submitForm');
+ },
+ cancelComment() {
+ if (this.hasValue && this.formText !== this.value) {
+ this.$refs.cancelCommentModal.show();
+ } else {
+ this.$emit('cancelForm');
+ }
+ },
+ focusInput() {
+ this.$refs.textarea.focus();
+ },
+ },
+};
+</script>
+
+<template>
+ <form class="new-note common-note-form" @submit.prevent>
+ <markdown-field
+ :markdown-preview-path="markdownPreviewPath"
+ :can-attach-file="false"
+ :enable-autocomplete="true"
+ :textarea-value="value"
+ markdown-docs-path="/help/user/markdown"
+ class="bordered-box"
+ >
+ <template #textarea>
+ <textarea
+ ref="textarea"
+ :value="value"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ data-qa-selector="note_textarea"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment…')"
+ @input="$emit('input', $event.target.value)"
+ @keydown.meta.enter="submitForm"
+ @keydown.ctrl.enter="submitForm"
+ @keyup.esc.stop="cancelComment"
+ >
+ </textarea>
+ </template>
+ </markdown-field>
+ <slot name="resolveCheckbox"></slot>
+ <div class="note-form-actions gl-display-flex gl-justify-content-space-between">
+ <gl-deprecated-button
+ ref="submitButton"
+ :disabled="!hasValue || isSaving"
+ variant="success"
+ type="submit"
+ data-track-event="click_button"
+ data-qa-selector="save_comment_button"
+ @click="$emit('submitForm')"
+ >
+ {{ buttonText }}
+ </gl-deprecated-button>
+ <gl-deprecated-button ref="cancelButton" @click="cancelComment">{{
+ __('Cancel')
+ }}</gl-deprecated-button>
+ </div>
+ <gl-modal
+ ref="cancelCommentModal"
+ ok-variant="danger"
+ :title="modalSettings.title"
+ :ok-title="modalSettings.okTitle"
+ :cancel-title="modalSettings.cancelTitle"
+ modal-id="cancel-comment-modal"
+ @ok="$emit('cancelForm')"
+ >{{ modalSettings.content }}
+ </gl-modal>
+ </form>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue b/app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue
new file mode 100644
index 00000000000..46c73e3eea8
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlIcon, GlButton, GlLink } from '@gitlab/ui';
+import { __, n__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ name: 'ToggleNotesWidget',
+ components: {
+ GlIcon,
+ GlButton,
+ GlLink,
+ TimeAgoTooltip,
+ },
+ props: {
+ collapsed: {
+ type: Boolean,
+ required: true,
+ },
+ replies: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ lastReply() {
+ return this.replies[this.replies.length - 1];
+ },
+ iconName() {
+ return this.collapsed ? 'chevron-right' : 'chevron-down';
+ },
+ toggleText() {
+ return this.collapsed
+ ? `${this.replies.length} ${n__('reply', 'replies', this.replies.length)}`
+ : __('Collapse replies');
+ },
+ },
+};
+</script>
+
+<template>
+ <li
+ class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3"
+ :class="{ expanded: !collapsed }"
+ data-testid="toggle-comments-wrapper"
+ >
+ <gl-icon :name="iconName" class="gl-ml-3" @click.stop="$emit('toggle')" />
+ <gl-button
+ variant="link"
+ class="toggle-comments-button gl-ml-2 gl-mr-2"
+ @click.stop="$emit('toggle')"
+ >
+ {{ toggleText }}
+ </gl-button>
+ <template v-if="collapsed">
+ <span class="gl-text-gray-700">{{ __('Last reply by') }}</span>
+ <gl-link
+ :href="lastReply.author.webUrl"
+ target="_blank"
+ class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2"
+ >
+ {{ lastReply.author.name }}
+ </gl-link>
+ <time-ago-tooltip
+ :time="lastReply.createdAt"
+ tooltip-placement="bottom"
+ class="gl-text-gray-700"
+ />
+ </template>
+ </li>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/design_overlay.vue b/app/assets/javascripts/design_management_new/components/design_overlay.vue
new file mode 100644
index 00000000000..926e7c74802
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/design_overlay.vue
@@ -0,0 +1,287 @@
+<script>
+import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql';
+import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
+import DesignNotePin from './design_note_pin.vue';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
+
+export default {
+ name: 'DesignOverlay',
+ components: {
+ DesignNotePin,
+ },
+ props: {
+ dimensions: {
+ type: Object,
+ required: true,
+ },
+ position: {
+ type: Object,
+ required: true,
+ },
+ notes: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ currentCommentForm: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ disableCommenting: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ resolvedDiscussionsExpanded: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ apollo: {
+ activeDiscussion: {
+ query: activeDiscussionQuery,
+ },
+ },
+ data() {
+ return {
+ movingNoteNewPosition: null,
+ movingNoteStartPosition: null,
+ activeDiscussion: {},
+ };
+ },
+ computed: {
+ overlayStyle() {
+ const cursor = this.disableCommenting ? 'unset' : undefined;
+
+ return {
+ cursor,
+ width: `${this.dimensions.width}px`,
+ height: `${this.dimensions.height}px`,
+ ...this.position,
+ };
+ },
+ isMovingCurrentComment() {
+ return Boolean(this.movingNoteStartPosition && !this.movingNoteStartPosition.noteId);
+ },
+ currentCommentPositionStyle() {
+ return this.isMovingCurrentComment && this.movingNoteNewPosition
+ ? this.getNotePositionStyle(this.movingNoteNewPosition)
+ : this.getNotePositionStyle(this.currentCommentForm);
+ },
+ },
+ methods: {
+ setNewNoteCoordinates({ x, y }) {
+ this.$emit('openCommentForm', { x, y });
+ },
+ getNoteRelativePosition(position) {
+ const { x, y, width, height } = position;
+ const widthRatio = this.dimensions.width / width;
+ const heightRatio = this.dimensions.height / height;
+ return {
+ left: Math.round(x * widthRatio),
+ top: Math.round(y * heightRatio),
+ };
+ },
+ getNotePositionStyle(position) {
+ const { left, top } = this.getNoteRelativePosition(position);
+ return {
+ left: `${left}px`,
+ top: `${top}px`,
+ };
+ },
+ getMovingNotePositionDelta(e) {
+ let deltaX = 0;
+ let deltaY = 0;
+
+ if (this.movingNoteStartPosition) {
+ const { clientX, clientY } = this.movingNoteStartPosition;
+ deltaX = e.clientX - clientX;
+ deltaY = e.clientY - clientY;
+ }
+
+ return {
+ deltaX,
+ deltaY,
+ };
+ },
+ isMovingNote(noteId) {
+ const movingNoteId = this.movingNoteStartPosition?.noteId;
+ return Boolean(movingNoteId && movingNoteId === noteId);
+ },
+ canMoveNote(note) {
+ const { userPermissions } = note;
+ const { adminNote } = userPermissions || {};
+
+ return Boolean(adminNote);
+ },
+ isPositionInOverlay(position) {
+ const { top, left } = this.getNoteRelativePosition(position);
+ const { height, width } = this.dimensions;
+
+ return top >= 0 && top <= height && left >= 0 && left <= width;
+ },
+ onNewNoteMove(e) {
+ if (!this.isMovingCurrentComment) return;
+
+ const { deltaX, deltaY } = this.getMovingNotePositionDelta(e);
+ const x = this.currentCommentForm.x + deltaX;
+ const y = this.currentCommentForm.y + deltaY;
+
+ const movingNoteNewPosition = {
+ x,
+ y,
+ width: this.dimensions.width,
+ height: this.dimensions.height,
+ };
+
+ if (!this.isPositionInOverlay(movingNoteNewPosition)) {
+ this.onNewNoteMouseup();
+ return;
+ }
+
+ this.movingNoteNewPosition = movingNoteNewPosition;
+ },
+ onExistingNoteMove(e) {
+ const note = this.notes.find(({ id }) => id === this.movingNoteStartPosition.noteId);
+ if (!note || !this.canMoveNote(note)) return;
+
+ const { position } = note;
+ const { width, height } = position;
+ const widthRatio = this.dimensions.width / width;
+ const heightRatio = this.dimensions.height / height;
+
+ const { deltaX, deltaY } = this.getMovingNotePositionDelta(e);
+ const x = position.x * widthRatio + deltaX;
+ const y = position.y * heightRatio + deltaY;
+
+ const movingNoteNewPosition = {
+ x,
+ y,
+ width: this.dimensions.width,
+ height: this.dimensions.height,
+ };
+
+ if (!this.isPositionInOverlay(movingNoteNewPosition)) {
+ this.onExistingNoteMouseup();
+ return;
+ }
+
+ this.movingNoteNewPosition = movingNoteNewPosition;
+ },
+ onNewNoteMouseup() {
+ if (!this.movingNoteNewPosition) return;
+
+ const { x, y } = this.movingNoteNewPosition;
+ this.setNewNoteCoordinates({ x, y });
+ },
+ onExistingNoteMouseup(note) {
+ if (!this.movingNoteStartPosition || !this.movingNoteNewPosition) {
+ this.updateActiveDiscussion(note.id);
+ this.$emit('closeCommentForm');
+ return;
+ }
+
+ const { x, y } = this.movingNoteNewPosition;
+ this.$emit('moveNote', {
+ noteId: this.movingNoteStartPosition.noteId,
+ discussionId: this.movingNoteStartPosition.discussionId,
+ coordinates: { x, y },
+ });
+ },
+ onNoteMousedown({ clientX, clientY }, note) {
+ this.movingNoteStartPosition = {
+ noteId: note?.id,
+ discussionId: note?.discussion.id,
+ clientX,
+ clientY,
+ };
+ },
+ onOverlayMousemove(e) {
+ if (!this.movingNoteStartPosition) return;
+
+ if (this.isMovingCurrentComment) {
+ this.onNewNoteMove(e);
+ } else {
+ this.onExistingNoteMove(e);
+ }
+ },
+ onNoteMouseup(note) {
+ if (!this.movingNoteStartPosition) return;
+
+ if (this.isMovingCurrentComment) {
+ this.onNewNoteMouseup();
+ } else {
+ this.onExistingNoteMouseup(note);
+ }
+
+ this.movingNoteStartPosition = null;
+ this.movingNoteNewPosition = null;
+ },
+ onAddCommentMouseup({ offsetX, offsetY }) {
+ if (this.disableCommenting) return;
+ if (this.activeDiscussion.id) {
+ this.updateActiveDiscussion();
+ }
+
+ this.setNewNoteCoordinates({ x: offsetX, y: offsetY });
+ },
+ updateActiveDiscussion(id) {
+ this.$apollo.mutate({
+ mutation: updateActiveDiscussionMutation,
+ variables: {
+ id,
+ source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
+ },
+ });
+ },
+ isNoteInactive(note) {
+ return this.activeDiscussion.id && this.activeDiscussion.id !== note.id;
+ },
+ designPinClass(note) {
+ return { inactive: this.isNoteInactive(note), resolved: note.resolved };
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="position-absolute image-diff-overlay frame"
+ :style="overlayStyle"
+ @mousemove="onOverlayMousemove"
+ @mouseleave="onNoteMouseup"
+ >
+ <button
+ v-show="!disableCommenting"
+ type="button"
+ class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button"
+ data-qa-selector="design_image_button"
+ @mouseup="onAddCommentMouseup"
+ ></button>
+ <template v-for="note in notes">
+ <design-note-pin
+ v-if="resolvedDiscussionsExpanded || !note.resolved"
+ :key="note.id"
+ :label="note.index"
+ :repositioning="isMovingNote(note.id)"
+ :position="
+ isMovingNote(note.id) && movingNoteNewPosition
+ ? getNotePositionStyle(movingNoteNewPosition)
+ : getNotePositionStyle(note.position)
+ "
+ :class="designPinClass(note)"
+ @mousedown.stop="onNoteMousedown($event, note)"
+ @mouseup.stop="onNoteMouseup(note)"
+ />
+ </template>
+
+ <design-note-pin
+ v-if="currentCommentForm"
+ :position="currentCommentPositionStyle"
+ :repositioning="isMovingCurrentComment"
+ @mousedown.stop="onNoteMousedown"
+ @mouseup.stop="onNoteMouseup"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/design_presentation.vue b/app/assets/javascripts/design_management_new/components/design_presentation.vue
new file mode 100644
index 00000000000..84dbb2809d9
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/design_presentation.vue
@@ -0,0 +1,322 @@
+<script>
+import { throttle } from 'lodash';
+import DesignImage from './image.vue';
+import DesignOverlay from './design_overlay.vue';
+
+const CLICK_DRAG_BUFFER_PX = 2;
+
+export default {
+ components: {
+ DesignImage,
+ DesignOverlay,
+ },
+ props: {
+ image: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imageName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ discussions: {
+ type: Array,
+ required: true,
+ },
+ isAnnotating: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scale: {
+ type: Number,
+ required: false,
+ default: 1,
+ },
+ resolvedDiscussionsExpanded: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ overlayDimensions: null,
+ overlayPosition: null,
+ currentAnnotationPosition: null,
+ zoomFocalPoint: {
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ },
+ initialLoad: true,
+ lastDragPosition: null,
+ isDraggingDesign: false,
+ };
+ },
+ computed: {
+ discussionStartingNotes() {
+ return this.discussions.map(discussion => ({
+ ...discussion.notes[0],
+ index: discussion.index,
+ }));
+ },
+ currentCommentForm() {
+ return (this.isAnnotating && this.currentAnnotationPosition) || null;
+ },
+ presentationStyle() {
+ return {
+ cursor: this.isDraggingDesign ? 'grabbing' : undefined,
+ };
+ },
+ },
+ beforeDestroy() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ presentationViewport.removeEventListener('scroll', this.scrollThrottled, false);
+ },
+ mounted() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ this.scrollThrottled = throttle(() => {
+ this.shiftZoomFocalPoint();
+ }, 400);
+
+ presentationViewport.addEventListener('scroll', this.scrollThrottled, false);
+ },
+ methods: {
+ syncCurrentAnnotationPosition() {
+ if (!this.currentAnnotationPosition) return;
+
+ const widthRatio = this.overlayDimensions.width / this.currentAnnotationPosition.width;
+ const heightRatio = this.overlayDimensions.height / this.currentAnnotationPosition.height;
+ const x = this.currentAnnotationPosition.x * widthRatio;
+ const y = this.currentAnnotationPosition.y * heightRatio;
+
+ this.currentAnnotationPosition = this.getAnnotationPositon({ x, y });
+ },
+ setOverlayDimensions(overlayDimensions) {
+ this.overlayDimensions = overlayDimensions;
+
+ // every time we set overlay dimensions, we need to
+ // update the current annotation as well
+ this.syncCurrentAnnotationPosition();
+ },
+ setOverlayPosition() {
+ if (!this.overlayDimensions) {
+ this.overlayPosition = {};
+ }
+
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ // default to center
+ this.overlayPosition = {
+ left: `calc(50% - ${this.overlayDimensions.width / 2}px)`,
+ top: `calc(50% - ${this.overlayDimensions.height / 2}px)`,
+ };
+
+ // if the overlay overflows, then don't center
+ if (this.overlayDimensions.width > presentationViewport.offsetWidth) {
+ this.overlayPosition.left = '0';
+ }
+ if (this.overlayDimensions.height > presentationViewport.offsetHeight) {
+ this.overlayPosition.top = '0';
+ }
+ },
+ /**
+ * Return a point that represents the center of an
+ * overflowing child element w.r.t it's parent
+ */
+ getViewportCenter() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return {};
+
+ // get height of scroll bars (i.e. the max values for scrollTop, scrollLeft)
+ const scrollBarWidth = presentationViewport.scrollWidth - presentationViewport.offsetWidth;
+ const scrollBarHeight = presentationViewport.scrollHeight - presentationViewport.offsetHeight;
+
+ // determine how many child pixels have been scrolled
+ const xScrollRatio =
+ presentationViewport.scrollLeft > 0 ? presentationViewport.scrollLeft / scrollBarWidth : 0;
+ const yScrollRatio =
+ presentationViewport.scrollTop > 0 ? presentationViewport.scrollTop / scrollBarHeight : 0;
+ const xScrollOffset =
+ (presentationViewport.scrollWidth - presentationViewport.offsetWidth - 0) * xScrollRatio;
+ const yScrollOffset =
+ (presentationViewport.scrollHeight - presentationViewport.offsetHeight - 0) * yScrollRatio;
+
+ const viewportCenterX = presentationViewport.offsetWidth / 2;
+ const viewportCenterY = presentationViewport.offsetHeight / 2;
+ const focalPointX = viewportCenterX + xScrollOffset;
+ const focalPointY = viewportCenterY + yScrollOffset;
+
+ return {
+ x: focalPointX,
+ y: focalPointY,
+ };
+ },
+ /**
+ * Scroll the viewport such that the focal point is positioned centrally
+ */
+ scrollToFocalPoint() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ const scrollX = this.zoomFocalPoint.x - presentationViewport.offsetWidth / 2;
+ const scrollY = this.zoomFocalPoint.y - presentationViewport.offsetHeight / 2;
+
+ presentationViewport.scrollTo(scrollX, scrollY);
+ },
+ scaleZoomFocalPoint() {
+ const { x, y, width, height } = this.zoomFocalPoint;
+ const widthRatio = this.overlayDimensions.width / width;
+ const heightRatio = this.overlayDimensions.height / height;
+
+ this.zoomFocalPoint = {
+ x: Math.round(x * widthRatio * 100) / 100,
+ y: Math.round(y * heightRatio * 100) / 100,
+ ...this.overlayDimensions,
+ };
+ },
+ shiftZoomFocalPoint() {
+ this.zoomFocalPoint = {
+ ...this.getViewportCenter(),
+ ...this.overlayDimensions,
+ };
+ },
+ onImageResize(imageDimensions) {
+ this.setOverlayDimensions(imageDimensions);
+ this.setOverlayPosition();
+
+ this.$nextTick(() => {
+ if (this.initialLoad) {
+ // set focal point on initial load
+ this.shiftZoomFocalPoint();
+ this.initialLoad = false;
+ } else {
+ this.scaleZoomFocalPoint();
+ this.scrollToFocalPoint();
+ }
+ });
+ },
+ getAnnotationPositon(coordinates) {
+ const { x, y } = coordinates;
+ const { width, height } = this.overlayDimensions;
+ return {
+ x: Math.round(x),
+ y: Math.round(y),
+ width: Math.round(width),
+ height: Math.round(height),
+ };
+ },
+ openCommentForm(coordinates) {
+ this.currentAnnotationPosition = this.getAnnotationPositon(coordinates);
+ this.$emit('openCommentForm', this.currentAnnotationPosition);
+ },
+ closeCommentForm() {
+ this.currentAnnotationPosition = null;
+ this.$emit('closeCommentForm');
+ },
+ moveNote({ noteId, discussionId, coordinates }) {
+ const position = this.getAnnotationPositon(coordinates);
+ this.$emit('moveNote', { noteId, discussionId, position });
+ },
+ onPresentationMousedown({ clientX, clientY }) {
+ if (!this.isDesignOverflowing()) return;
+
+ this.lastDragPosition = {
+ x: clientX,
+ y: clientY,
+ };
+ },
+ getDragDelta(clientX, clientY) {
+ return {
+ deltaX: this.lastDragPosition.x - clientX,
+ deltaY: this.lastDragPosition.y - clientY,
+ };
+ },
+ exceedsDragThreshold(clientX, clientY) {
+ const { deltaX, deltaY } = this.getDragDelta(clientX, clientY);
+
+ return Math.abs(deltaX) > CLICK_DRAG_BUFFER_PX || Math.abs(deltaY) > CLICK_DRAG_BUFFER_PX;
+ },
+ shouldDragDesign(clientX, clientY) {
+ return (
+ this.lastDragPosition &&
+ (this.isDraggingDesign || this.exceedsDragThreshold(clientX, clientY))
+ );
+ },
+ onPresentationMousemove({ clientX, clientY }) {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport || !this.shouldDragDesign(clientX, clientY)) return;
+
+ this.isDraggingDesign = true;
+
+ const { scrollLeft, scrollTop } = presentationViewport;
+ const { deltaX, deltaY } = this.getDragDelta(clientX, clientY);
+ presentationViewport.scrollTo(scrollLeft + deltaX, scrollTop + deltaY);
+
+ this.lastDragPosition = {
+ x: clientX,
+ y: clientY,
+ };
+ },
+ onPresentationMouseup() {
+ this.lastDragPosition = null;
+ this.isDraggingDesign = false;
+ },
+ isDesignOverflowing() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return false;
+
+ return (
+ presentationViewport.scrollWidth > presentationViewport.offsetWidth ||
+ presentationViewport.scrollHeight > presentationViewport.offsetHeight
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ ref="presentationViewport"
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+ :style="presentationStyle"
+ @mousedown="onPresentationMousedown"
+ @mousemove="onPresentationMousemove"
+ @mouseup="onPresentationMouseup"
+ @mouseleave="onPresentationMouseup"
+ @touchstart="onPresentationMousedown"
+ @touchmove="onPresentationMousemove"
+ @touchend="onPresentationMouseup"
+ @touchcancel="onPresentationMouseup"
+ >
+ <div class="h-100 w-100 d-flex align-items-center position-relative">
+ <design-image
+ v-if="image"
+ :image="image"
+ :name="imageName"
+ :scale="scale"
+ @resize="onImageResize"
+ />
+ <design-overlay
+ v-if="overlayDimensions && overlayPosition"
+ :dimensions="overlayDimensions"
+ :position="overlayPosition"
+ :notes="discussionStartingNotes"
+ :current-comment-form="currentCommentForm"
+ :disable-commenting="isDraggingDesign"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
+ @openCommentForm="openCommentForm"
+ @closeCommentForm="closeCommentForm"
+ @moveNote="moveNote"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/design_scaler.vue b/app/assets/javascripts/design_management_new/components/design_scaler.vue
new file mode 100644
index 00000000000..55dee74bef5
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/design_scaler.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+const SCALE_STEP_SIZE = 0.2;
+const DEFAULT_SCALE = 1;
+const MIN_SCALE = 1;
+const MAX_SCALE = 2;
+
+export default {
+ components: {
+ GlIcon,
+ },
+ data() {
+ return {
+ scale: DEFAULT_SCALE,
+ };
+ },
+ computed: {
+ disableReset() {
+ return this.scale <= MIN_SCALE;
+ },
+ disableDecrease() {
+ return this.scale === DEFAULT_SCALE;
+ },
+ disableIncrease() {
+ return this.scale >= MAX_SCALE;
+ },
+ },
+ methods: {
+ setScale(scale) {
+ if (scale < MIN_SCALE) {
+ return;
+ }
+
+ this.scale = Math.round(scale * 100) / 100;
+ this.$emit('scale', this.scale);
+ },
+ incrementScale() {
+ this.setScale(this.scale + SCALE_STEP_SIZE);
+ },
+ decrementScale() {
+ this.setScale(this.scale - SCALE_STEP_SIZE);
+ },
+ resetScale() {
+ this.setScale(DEFAULT_SCALE);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="design-scaler btn-group" role="group">
+ <button class="btn" :disabled="disableDecrease" @click="decrementScale">
+ <span class="d-flex-center gl-icon s16">
+ –
+ </span>
+ </button>
+ <button class="btn" :disabled="disableReset" @click="resetScale">
+ <gl-icon name="redo" />
+ </button>
+ <button class="btn" :disabled="disableIncrease" @click="incrementScale">
+ <gl-icon name="plus" />
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/design_sidebar.vue b/app/assets/javascripts/design_management_new/components/design_sidebar.vue
new file mode 100644
index 00000000000..333ad2557e8
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/design_sidebar.vue
@@ -0,0 +1,178 @@
+<script>
+import { s__ } from '~/locale';
+import Cookies from 'js-cookie';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui';
+import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
+import { extractDiscussions, extractParticipants } from '../utils/design_management_utils';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
+import DesignDiscussion from './design_notes/design_discussion.vue';
+import Participants from '~/sidebar/components/participants/participants.vue';
+
+export default {
+ components: {
+ DesignDiscussion,
+ Participants,
+ GlCollapse,
+ GlButton,
+ GlPopover,
+ },
+ props: {
+ design: {
+ type: Object,
+ required: true,
+ },
+ resolvedDiscussionsExpanded: {
+ type: Boolean,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)),
+ discussionWithOpenForm: '',
+ };
+ },
+ computed: {
+ discussions() {
+ return extractDiscussions(this.design.discussions);
+ },
+ issue() {
+ return {
+ ...this.design.issue,
+ webPath: this.design.issue.webPath.substr(1),
+ };
+ },
+ discussionParticipants() {
+ return extractParticipants(this.issue.participants);
+ },
+ resolvedDiscussions() {
+ return this.discussions.filter(discussion => discussion.resolved);
+ },
+ unresolvedDiscussions() {
+ return this.discussions.filter(discussion => !discussion.resolved);
+ },
+ resolvedCommentsToggleIcon() {
+ return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
+ },
+ },
+ methods: {
+ handleSidebarClick() {
+ this.isResolvedCommentsPopoverHidden = true;
+ Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 });
+ this.updateActiveDiscussion();
+ },
+ updateActiveDiscussion(id) {
+ this.$apollo.mutate({
+ mutation: updateActiveDiscussionMutation,
+ variables: {
+ id,
+ source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
+ },
+ });
+ },
+ closeCommentForm() {
+ this.comment = '';
+ this.$emit('closeCommentForm');
+ },
+ updateDiscussionWithOpenForm(id) {
+ this.discussionWithOpenForm = id;
+ },
+ },
+ resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
+ cookieKey: 'hide_design_resolved_comments_popover',
+};
+</script>
+
+<template>
+ <div class="image-notes" @click="handleSidebarClick">
+ <h2 class="gl-font-weight-bold gl-mt-0">
+ {{ issue.title }}
+ </h2>
+ <a
+ class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block"
+ :href="issue.webUrl"
+ >{{ issue.webPath }}</a
+ >
+ <participants
+ :participants="discussionParticipants"
+ :show-participant-label="false"
+ class="gl-mb-4"
+ />
+ <h2
+ v-if="unresolvedDiscussions.length === 0"
+ class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4"
+ data-testid="new-discussion-disclaimer"
+ >
+ {{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }}
+ </h2>
+ <design-discussion
+ v-for="discussion in unresolvedDiscussions"
+ :key="discussion.id"
+ :discussion="discussion"
+ :design-id="$route.params.id"
+ :noteable-id="design.id"
+ :markdown-preview-path="markdownPreviewPath"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
+ :discussion-with-open-form="discussionWithOpenForm"
+ data-testid="unresolved-discussion"
+ @createNoteError="$emit('onDesignDiscussionError', $event)"
+ @updateNoteError="$emit('updateNoteError', $event)"
+ @resolveDiscussionError="$emit('resolveDiscussionError', $event)"
+ @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
+ @openForm="updateDiscussionWithOpenForm"
+ />
+ <template v-if="resolvedDiscussions.length > 0">
+ <gl-button
+ id="resolved-comments"
+ data-testid="resolved-comments"
+ :icon="resolvedCommentsToggleIcon"
+ variant="link"
+ class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4"
+ @click="$emit('toggleResolvedComments')"
+ >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
+ </gl-button>
+ <gl-popover
+ v-if="!isResolvedCommentsPopoverHidden"
+ :show="!isResolvedCommentsPopoverHidden"
+ target="resolved-comments"
+ container="popovercontainer"
+ placement="top"
+ :title="s__('DesignManagement|Resolved Comments')"
+ >
+ <p>
+ {{
+ s__(
+ 'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
+ )
+ }}
+ </p>
+ <a href="#" rel="noopener noreferrer" target="_blank">{{
+ s__('DesignManagement|Learn more about resolving comments')
+ }}</a>
+ </gl-popover>
+ <gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
+ <design-discussion
+ v-for="discussion in resolvedDiscussions"
+ :key="discussion.id"
+ :discussion="discussion"
+ :design-id="$route.params.id"
+ :noteable-id="design.id"
+ :markdown-preview-path="markdownPreviewPath"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
+ :discussion-with-open-form="discussionWithOpenForm"
+ data-testid="resolved-discussion"
+ @error="$emit('onDesignDiscussionError', $event)"
+ @updateNoteError="$emit('updateNoteError', $event)"
+ @openForm="updateDiscussionWithOpenForm"
+ @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
+ />
+ </gl-collapse>
+ </template>
+ <slot name="replyForm"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/image.vue b/app/assets/javascripts/design_management_new/components/image.vue
new file mode 100644
index 00000000000..91b7b576e0c
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/image.vue
@@ -0,0 +1,110 @@
+<script>
+import { throttle } from 'lodash';
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ props: {
+ image: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ scale: {
+ type: Number,
+ required: false,
+ default: 1,
+ },
+ },
+ data() {
+ return {
+ baseImageSize: null,
+ imageStyle: null,
+ imageError: false,
+ };
+ },
+ watch: {
+ scale(val) {
+ this.zoom(val);
+ },
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.resizeThrottled, false);
+ },
+ mounted() {
+ this.onImgLoad();
+
+ this.resizeThrottled = throttle(() => {
+ // NOTE: if imageStyle is set, then baseImageSize
+ // won't change due to resize. We must still emit a
+ // `resize` event so that the parent can handle
+ // resizes appropriately (e.g. for design_overlay)
+ this.setBaseImageSize();
+ }, 400);
+ window.addEventListener('resize', this.resizeThrottled, false);
+ },
+ methods: {
+ onImgLoad() {
+ requestIdleCallback(this.setBaseImageSize, { timeout: 1000 });
+ },
+ onImgError() {
+ this.imageError = true;
+ },
+ setBaseImageSize() {
+ const { contentImg } = this.$refs;
+ if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return;
+
+ this.baseImageSize = {
+ height: contentImg.offsetHeight,
+ width: contentImg.offsetWidth,
+ };
+ this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height });
+ },
+ onResize({ width, height }) {
+ this.$emit('resize', { width, height });
+ },
+ zoom(amount) {
+ if (amount === 1) {
+ this.imageStyle = null;
+ this.$nextTick(() => {
+ this.setBaseImageSize();
+ });
+ return;
+ }
+ const width = this.baseImageSize.width * amount;
+ const height = this.baseImageSize.height * amount;
+
+ this.imageStyle = {
+ width: `${width}px`,
+ height: `${height}px`,
+ };
+
+ this.onResize({ width, height });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="m-auto js-design-image">
+ <gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" />
+ <img
+ v-show="!imageError"
+ ref="contentImg"
+ class="mh-100"
+ :src="image"
+ :alt="name"
+ :style="imageStyle"
+ :class="{ 'img-fluid': !imageStyle }"
+ @error="onImgError"
+ @load="onImgLoad"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/list/item.vue b/app/assets/javascripts/design_management_new/components/list/item.vue
new file mode 100644
index 00000000000..b19aef9c22d
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/list/item.vue
@@ -0,0 +1,174 @@
+<script>
+import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
+import { n__, __ } from '~/locale';
+import { DESIGN_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlIntersectionObserver,
+ GlIcon,
+ Icon,
+ Timeago,
+ },
+ props: {
+ id: {
+ type: [Number, String],
+ required: true,
+ },
+ event: {
+ type: String,
+ required: true,
+ },
+ notesCount: {
+ type: Number,
+ required: true,
+ },
+ image: {
+ type: String,
+ required: true,
+ },
+ filename: {
+ type: String,
+ required: true,
+ },
+ updatedAt: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ isUploading: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ imageV432x230: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ imageLoading: true,
+ imageError: false,
+ wasInView: false,
+ };
+ },
+ computed: {
+ icon() {
+ const normalizedEvent = this.event.toLowerCase();
+ const icons = {
+ creation: {
+ name: 'file-addition-solid',
+ classes: 'text-success-500',
+ tooltip: __('Added in this version'),
+ },
+ modification: {
+ name: 'file-modified-solid',
+ classes: 'text-primary-500',
+ tooltip: __('Modified in this version'),
+ },
+ deletion: {
+ name: 'file-deletion-solid',
+ classes: 'text-danger-500',
+ tooltip: __('Deleted in this version'),
+ },
+ };
+
+ return icons[normalizedEvent] ? icons[normalizedEvent] : {};
+ },
+ notesLabel() {
+ return n__('%d comment', '%d comments', this.notesCount);
+ },
+ imageLink() {
+ return this.wasInView ? this.imageV432x230 || this.image : '';
+ },
+ showLoadingSpinner() {
+ return this.imageLoading || this.isUploading;
+ },
+ showImageErrorIcon() {
+ return this.wasInView && this.imageError;
+ },
+ showImage() {
+ return !this.showLoadingSpinner && !this.showImageErrorIcon;
+ },
+ },
+ methods: {
+ onImageLoad() {
+ this.imageLoading = false;
+ this.imageError = false;
+ },
+ onImageError() {
+ this.imageLoading = false;
+ this.imageError = true;
+ },
+ onAppear() {
+ // do nothing if image has previously
+ // been in view
+ if (this.wasInView) {
+ return;
+ }
+
+ this.wasInView = true;
+ this.imageLoading = true;
+ },
+ },
+ DESIGN_ROUTE_NAME,
+};
+</script>
+
+<template>
+ <router-link
+ :to="{
+ name: $options.DESIGN_ROUTE_NAME,
+ params: { id: filename },
+ query: $route.query,
+ }"
+ class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new"
+ >
+ <div class="card-body p-0 d-flex-center overflow-hidden position-relative">
+ <div v-if="icon.name" class="design-event position-absolute">
+ <span :title="icon.tooltip" :aria-label="icon.tooltip">
+ <icon :name="icon.name" :size="18" :class="icon.classes" />
+ </span>
+ </div>
+ <gl-intersection-observer @appear="onAppear">
+ <gl-loading-icon v-if="showLoadingSpinner" size="md" />
+ <gl-icon
+ v-else-if="showImageErrorIcon"
+ name="media-broken"
+ class="text-secondary"
+ :size="32"
+ />
+ <img
+ v-show="showImage"
+ :src="imageLink"
+ :alt="filename"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ @load="onImageLoad"
+ @error="onImageError"
+ />
+ </gl-intersection-observer>
+ </div>
+ <div class="card-footer d-flex w-100">
+ <div class="d-flex flex-column str-truncated-100">
+ <span class="bold str-truncated-100" data-qa-selector="design_file_name">{{
+ filename
+ }}</span>
+ <span v-if="updatedAt" class="str-truncated-100">
+ {{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" />
+ </span>
+ </div>
+ <div v-if="notesCount" class="ml-auto d-flex align-items-center text-secondary">
+ <icon name="comments" class="ml-1" />
+ <span :aria-label="notesLabel" class="ml-1">
+ {{ notesCount }}
+ </span>
+ </div>
+ </div>
+ </router-link>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/toolbar/index.vue b/app/assets/javascripts/design_management_new/components/toolbar/index.vue
new file mode 100644
index 00000000000..0b51035e83e
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/toolbar/index.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlDeprecatedButton } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import Pagination from './pagination.vue';
+import DeleteButton from '../delete_button.vue';
+import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql';
+import { DESIGNS_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ Icon,
+ Pagination,
+ DeleteButton,
+ GlDeprecatedButton,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ isDeleting: {
+ type: Boolean,
+ required: true,
+ },
+ filename: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedAt: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ updatedBy: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isLatestVersion: {
+ type: Boolean,
+ required: true,
+ },
+ image: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ permissions: {
+ createDesign: false,
+ },
+ };
+ },
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ issueIid: {
+ default: '',
+ },
+ },
+ apollo: {
+ permissions: {
+ query: permissionsQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ };
+ },
+ update: data => data.project.issue.userPermissions,
+ },
+ },
+ computed: {
+ updatedText() {
+ return sprintf(__('Updated %{updated_at} by %{updated_by}'), {
+ updated_at: this.timeFormatted(this.updatedAt),
+ updated_by: this.updatedBy.name,
+ });
+ },
+ canDeleteDesign() {
+ return this.permissions.createDesign;
+ },
+ },
+ DESIGNS_ROUTE_NAME,
+};
+</script>
+
+<template>
+ <header class="d-flex p-2 bg-white align-items-center js-design-header">
+ <router-link
+ :to="{
+ name: $options.DESIGNS_ROUTE_NAME,
+ query: $route.query,
+ }"
+ :aria-label="s__('DesignManagement|Go back to designs')"
+ data-testid="close-design"
+ class="mr-3 text-plain d-flex justify-content-center align-items-center"
+ >
+ <icon :size="18" name="close" />
+ </router-link>
+ <div class="overflow-hidden d-flex align-items-center">
+ <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
+ <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
+ </div>
+ <pagination :id="id" class="ml-auto flex-shrink-0" />
+ <gl-deprecated-button :href="image" class="mr-2">
+ <icon :size="18" name="download" />
+ </gl-deprecated-button>
+ <delete-button
+ v-if="isLatestVersion && canDeleteDesign"
+ :is-deleting="isDeleting"
+ button-variant="danger"
+ @deleteSelectedDesigns="$emit('delete')"
+ >
+ <icon :size="18" name="remove" />
+ </delete-button>
+ </header>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/toolbar/pagination.vue b/app/assets/javascripts/design_management_new/components/toolbar/pagination.vue
new file mode 100644
index 00000000000..bf62a8f66a6
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/toolbar/pagination.vue
@@ -0,0 +1,83 @@
+<script>
+/* global Mousetrap */
+import 'mousetrap';
+import { s__, sprintf } from '~/locale';
+import PaginationButton from './pagination_button.vue';
+import allDesignsMixin from '../../mixins/all_designs';
+import { DESIGN_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ PaginationButton,
+ },
+ mixins: [allDesignsMixin],
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ designsCount() {
+ return this.designs.length;
+ },
+ currentIndex() {
+ return this.designs.findIndex(design => design.filename === this.id);
+ },
+ paginationText() {
+ return sprintf(s__('DesignManagement|%{current_design} of %{designs_count}'), {
+ current_design: this.currentIndex + 1,
+ designs_count: this.designsCount,
+ });
+ },
+ previousDesign() {
+ if (!this.designsCount) return null;
+
+ return this.designs[this.currentIndex - 1];
+ },
+ nextDesign() {
+ if (!this.designsCount) return null;
+
+ return this.designs[this.currentIndex + 1];
+ },
+ },
+ mounted() {
+ Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign));
+ Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign));
+ },
+ beforeDestroy() {
+ Mousetrap.unbind(['left', 'right'], this.navigateToDesign);
+ },
+ methods: {
+ navigateToDesign(design) {
+ if (design) {
+ this.$router.push({
+ name: DESIGN_ROUTE_NAME,
+ params: { id: design.filename },
+ query: this.$route.query,
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="designsCount" class="d-flex align-items-center">
+ {{ paginationText }}
+ <div class="btn-group ml-3 mr-3">
+ <pagination-button
+ :design="previousDesign"
+ :title="s__('DesignManagement|Go to previous design')"
+ icon-name="angle-left"
+ class="js-previous-design"
+ />
+ <pagination-button
+ :design="nextDesign"
+ :title="s__('DesignManagement|Go to next design')"
+ icon-name="angle-right"
+ class="js-next-design"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue b/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue
new file mode 100644
index 00000000000..f00ecefca01
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue
@@ -0,0 +1,48 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import { DESIGN_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ Icon,
+ },
+ props: {
+ design: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ iconName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ designLink() {
+ if (!this.design) return {};
+
+ return {
+ name: DESIGN_ROUTE_NAME,
+ params: { id: this.design.filename },
+ query: this.$route.query,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <router-link
+ :to="designLink"
+ :disabled="!design"
+ :class="{ disabled: !design }"
+ :aria-label="title"
+ class="btn btn-default"
+ >
+ <icon :name="iconName" />
+ </router-link>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/upload/button.vue b/app/assets/javascripts/design_management_new/components/upload/button.vue
new file mode 100644
index 00000000000..de8a38334ac
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/upload/button.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
+
+export default {
+ components: {
+ GlButton,
+ GlLoadingIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ isSaving: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ openFileUpload() {
+ this.$refs.fileUpload.click();
+ },
+ onFileUploadChange(e) {
+ this.$emit('upload', e.target.files);
+ },
+ },
+ VALID_DESIGN_FILE_MIMETYPE,
+};
+</script>
+
+<template>
+ <div>
+ <gl-button
+ v-gl-tooltip.hover
+ :title="
+ s__(
+ 'DesignManagement|Adding a design with the same filename replaces the file in a new version.',
+ )
+ "
+ :disabled="isSaving"
+ variant="success"
+ size="small"
+ @click="openFileUpload"
+ >
+ {{ s__('DesignManagement|Upload designs') }}
+ <gl-loading-icon v-if="isSaving" inline class="ml-1" />
+ </gl-button>
+
+ <input
+ ref="fileUpload"
+ type="file"
+ name="design_file"
+ :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
+ class="hide"
+ multiple
+ @change="onFileUploadChange"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue
new file mode 100644
index 00000000000..7b829d63330
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue
@@ -0,0 +1,136 @@
+<script>
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import createFlash from '~/flash';
+import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql';
+import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages';
+import { isValidDesignFile } from '../../utils/design_management_utils';
+import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ hasDesigns: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dragCounter: 0,
+ isDragDataValid: false,
+ };
+ },
+ computed: {
+ dragging() {
+ return this.dragCounter !== 0;
+ },
+ },
+ methods: {
+ isValidUpload(files) {
+ return files.every(isValidDesignFile);
+ },
+ isValidDragDataType({ dataTransfer }) {
+ return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE));
+ },
+ ondrop({ dataTransfer = {} }) {
+ this.dragCounter = 0;
+ // User already had feedback when dropzone was active, so bail here
+ if (!this.isDragDataValid) {
+ return;
+ }
+
+ const { files } = dataTransfer;
+ if (!this.isValidUpload(Array.from(files))) {
+ createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR);
+ return;
+ }
+
+ this.$emit('change', files);
+ },
+ ondragenter(e) {
+ this.dragCounter += 1;
+ this.isDragDataValid = this.isValidDragDataType(e);
+ },
+ ondragleave() {
+ this.dragCounter -= 1;
+ },
+ openFileUpload() {
+ this.$refs.fileUpload.click();
+ },
+ onDesignInputChange(e) {
+ this.$emit('change', e.target.files);
+ },
+ },
+ uploadDesignMutation,
+ VALID_DESIGN_FILE_MIMETYPE,
+};
+</script>
+
+<template>
+ <div
+ class="w-100 position-relative"
+ @dragstart.prevent.stop
+ @dragend.prevent.stop
+ @dragover.prevent.stop
+ @dragenter.prevent.stop="ondragenter"
+ @dragleave.prevent.stop="ondragleave"
+ @drop.prevent.stop="ondrop"
+ >
+ <slot>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3"
+ @click="openFileUpload"
+ >
+ <div
+ :class="{ 'gl-flex-direction-column': hasDesigns }"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center"
+ data-testid="dropzone-area"
+ >
+ <gl-icon name="upload" :size="24" :class="hasDesigns ? 'gl-mb-2' : 'gl-mr-4'" />
+ <p class="gl-font-weight-bold gl-mb-0">
+ <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} Designs to attach')">
+ <template #link="{ content }">
+ <gl-link class="gl-font-weight-normal" @click.stop="openFileUpload">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ </button>
+
+ <input
+ ref="fileUpload"
+ type="file"
+ name="design_file"
+ :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
+ class="hide"
+ multiple
+ @change="onDesignInputChange"
+ />
+ </slot>
+ <transition name="design-dropzone-fade">
+ <div
+ v-show="dragging"
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ >
+ <div v-show="!isDragDataValid" class="mw-50 text-center">
+ <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Oh no!') }}</h3>
+ <span>{{
+ __(
+ 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.',
+ )
+ }}</span>
+ </div>
+ <div v-show="isDragDataValid" class="mw-50 text-center">
+ <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Incoming!') }}</h3>
+ <span>{{ __('Drop your designs to start your upload.') }}</span>
+ </div>
+ </div>
+ </transition>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue
new file mode 100644
index 00000000000..5299d0ce09e
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue
@@ -0,0 +1,76 @@
+<script>
+import { GlNewDropdown, GlNewDropdownItem } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import allVersionsMixin from '../../mixins/all_versions';
+import { findVersionId } from '../../utils/design_management_utils';
+
+export default {
+ components: {
+ GlNewDropdown,
+ GlNewDropdownItem,
+ },
+ mixins: [allVersionsMixin],
+ computed: {
+ queryVersion() {
+ return this.$route.query.version;
+ },
+ currentVersionIdx() {
+ if (!this.queryVersion) return 0;
+
+ const idx = this.allVersions.findIndex(
+ version => this.findVersionId(version.node.id) === this.queryVersion,
+ );
+
+ // if the currentVersionId isn't a valid version (i.e. not in allVersions)
+ // then return the latest version (index 0)
+ return idx !== -1 ? idx : 0;
+ },
+ currentVersionId() {
+ if (this.queryVersion) return this.queryVersion;
+
+ const currentVersion = this.allVersions[this.currentVersionIdx];
+ return this.findVersionId(currentVersion.node.id);
+ },
+ dropdownText() {
+ if (this.isLatestVersion) {
+ return __('Showing Latest Version');
+ }
+ // allVersions is sorted in reverse chronological order (latest first)
+ const currentVersionNumber = this.allVersions.length - this.currentVersionIdx;
+
+ return sprintf(__('Showing Version #%{versionNumber}'), {
+ versionNumber: currentVersionNumber,
+ });
+ },
+ },
+ methods: {
+ findVersionId,
+ },
+};
+</script>
+
+<template>
+ <gl-new-dropdown :text="dropdownText" size="small" class="design-version-dropdown">
+ <gl-new-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id">
+ <router-link
+ class="d-flex js-version-link"
+ :to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }"
+ >
+ <div class="flex-grow-1 ml-2">
+ <div>
+ <strong
+ >{{ __('Version') }} {{ allVersions.length - index }}
+ <span v-if="findVersionId(version.node.id) === latestVersionId"
+ >({{ __('latest') }})</span
+ >
+ </strong>
+ </div>
+ </div>
+ <i
+ v-if="findVersionId(version.node.id) === currentVersionId"
+ class="fa fa-check pull-right"
+ ></i>
+ </router-link>
+ </gl-new-dropdown-item>
+ </gl-new-dropdown>
+</template>
diff --git a/app/assets/javascripts/design_management_new/constants.js b/app/assets/javascripts/design_management_new/constants.js
new file mode 100644
index 00000000000..21ff361a277
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/constants.js
@@ -0,0 +1,16 @@
+// WARNING: replace this with something
+// more sensical as per https://gitlab.com/gitlab-org/gitlab/issues/118611
+export const VALID_DESIGN_FILE_MIMETYPE = {
+ mimetype: 'image/*',
+ regex: /image\/.+/,
+};
+
+// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
+export const VALID_DATA_TRANSFER_TYPE = 'Files';
+
+export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
+ pin: 'pin',
+ discussion: 'discussion',
+};
+
+export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0'];
diff --git a/app/assets/javascripts/design_management_new/graphql.js b/app/assets/javascripts/design_management_new/graphql.js
new file mode 100644
index 00000000000..fae337aa75b
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql.js
@@ -0,0 +1,45 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { uniqueId } from 'lodash';
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import createDefaultClient from '~/lib/graphql';
+import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
+import typeDefs from './graphql/typedefs.graphql';
+
+Vue.use(VueApollo);
+
+const resolvers = {
+ Mutation: {
+ updateActiveDiscussion: (_, { id = null, source }, { cache }) => {
+ const data = cache.readQuery({ query: activeDiscussionQuery });
+ data.activeDiscussion = {
+ __typename: 'ActiveDiscussion',
+ id,
+ source,
+ };
+ cache.writeQuery({ query: activeDiscussionQuery, data });
+ },
+ },
+};
+
+const defaultClient = createDefaultClient(
+ resolvers,
+ // This config is added temporarily to resolve an issue with duplicate design IDs.
+ // Should be removed as soon as https://gitlab.com/gitlab-org/gitlab/issues/13495 is resolved
+ {
+ cacheConfig: {
+ dataIdFromObject: object => {
+ // eslint-disable-next-line no-underscore-dangle, @gitlab/require-i18n-strings
+ if (object.__typename === 'Design') {
+ return object.id && object.image ? `${object.id}-${object.image}` : uniqueId();
+ }
+ return defaultDataIdFromObject(object);
+ },
+ },
+ typeDefs,
+ },
+);
+
+export default new VueApollo({
+ defaultClient,
+});
diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql
new file mode 100644
index 00000000000..4b1703e41c3
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql
@@ -0,0 +1,24 @@
+#import "./design_note.fragment.graphql"
+#import "./design_list.fragment.graphql"
+#import "./diff_refs.fragment.graphql"
+#import "./discussion_resolved_status.fragment.graphql"
+
+fragment DesignItem on Design {
+ ...DesignListItem
+ fullPath
+ diffRefs {
+ ...DesignDiffRefs
+ }
+ discussions {
+ nodes {
+ id
+ replyId
+ ...ResolvedStatus
+ notes {
+ nodes {
+ ...DesignNote
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql
new file mode 100644
index 00000000000..bc3132f9b42
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql
@@ -0,0 +1,8 @@
+fragment DesignListItem on Design {
+ id
+ event
+ filename
+ notesCount
+ image
+ imageV432x230
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql
new file mode 100644
index 00000000000..26edd2c0be1
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql
@@ -0,0 +1,29 @@
+#import "./diff_refs.fragment.graphql"
+#import "~/graphql_shared/fragments/author.fragment.graphql"
+#import "./note_permissions.fragment.graphql"
+
+fragment DesignNote on Note {
+ id
+ author {
+ ...Author
+ }
+ body
+ bodyHtml
+ createdAt
+ resolved
+ position {
+ diffRefs {
+ ...DesignDiffRefs
+ }
+ x
+ y
+ height
+ width
+ }
+ userPermissions {
+ ...DesignNotePermissions
+ }
+ discussion {
+ id
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql
new file mode 100644
index 00000000000..984a55814b0
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql
@@ -0,0 +1,5 @@
+fragment DesignDiffRefs on DiffRefs {
+ baseSha
+ startSha
+ headSha
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql
new file mode 100644
index 00000000000..7483b508721
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql
@@ -0,0 +1,9 @@
+fragment ResolvedStatus on Discussion {
+ resolvable
+ resolved
+ resolvedAt
+ resolvedBy {
+ name
+ webUrl
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql
new file mode 100644
index 00000000000..c243e39f3d3
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql
@@ -0,0 +1,3 @@
+fragment DesignNotePermissions on NotePermissions {
+ adminNote
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql
new file mode 100644
index 00000000000..7eb40b12f51
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql
@@ -0,0 +1,4 @@
+fragment VersionListItem on DesignVersion {
+ id
+ sha
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql
new file mode 100644
index 00000000000..c8ade328120
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql
@@ -0,0 +1,21 @@
+#import "../fragments/design_note.fragment.graphql"
+
+mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
+ createImageDiffNote(input: $input) {
+ note {
+ ...DesignNote
+ discussion {
+ id
+ replyId
+ notes {
+ edges {
+ node {
+ ...DesignNote
+ }
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql
new file mode 100644
index 00000000000..184ee6955dc
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/design_note.fragment.graphql"
+
+mutation createNote($input: CreateNoteInput!) {
+ createNote(input: $input) {
+ note {
+ ...DesignNote
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql
new file mode 100644
index 00000000000..0b3cf636cdb
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/version.fragment.graphql"
+
+mutation destroyDesign($filenames: [String!]!, $projectPath: ID!, $iid: ID!) {
+ designManagementDelete(input: { projectPath: $projectPath, iid: $iid, filenames: $filenames }) {
+ version {
+ ...VersionListItem
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql
new file mode 100644
index 00000000000..1157fc05d5f
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql
@@ -0,0 +1,17 @@
+#import "../fragments/design_note.fragment.graphql"
+#import "../fragments/discussion_resolved_status.fragment.graphql"
+
+mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) {
+ discussionToggleResolve(input: { id: $id, resolve: $resolve }) {
+ discussion {
+ id
+ ...ResolvedStatus
+ notes {
+ nodes {
+ ...DesignNote
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql
new file mode 100644
index 00000000000..a24b6737159
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updateActiveDiscussion($id: String, $source: String) {
+ updateActiveDiscussion(id: $id, source: $source) @client
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql
new file mode 100644
index 00000000000..5562ca9d89f
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/design_note.fragment.graphql"
+
+mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) {
+ updateImageDiffNote(input: $input) {
+ errors
+ note {
+ ...DesignNote
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql
new file mode 100644
index 00000000000..b995e99fb6a
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/design_note.fragment.graphql"
+
+mutation updateNote($input: UpdateNoteInput!) {
+ updateNote(input: $input) {
+ note {
+ ...DesignNote
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql
new file mode 100644
index 00000000000..d694e6558a0
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql
@@ -0,0 +1,21 @@
+#import "../fragments/design.fragment.graphql"
+
+mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
+ designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) {
+ designs {
+ ...DesignItem
+ versions {
+ edges {
+ node {
+ id
+ sha
+ }
+ }
+ }
+ }
+ skippedDesigns {
+ filename
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql
new file mode 100644
index 00000000000..111023cea68
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql
@@ -0,0 +1,6 @@
+query activeDiscussion {
+ activeDiscussion @client {
+ id
+ source
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql
new file mode 100644
index 00000000000..a87b256dc95
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql
@@ -0,0 +1,10 @@
+query permissions($fullPath: ID!, $iid: String!) {
+ project(fullPath: $fullPath) {
+ id
+ issue(iid: $iid) {
+ userPermissions {
+ createDesign
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql
new file mode 100644
index 00000000000..07a9af55787
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql
@@ -0,0 +1,31 @@
+#import "../fragments/design.fragment.graphql"
+#import "~/graphql_shared/fragments/author.fragment.graphql"
+
+query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [String!]) {
+ project(fullPath: $fullPath) {
+ id
+ issue(iid: $iid) {
+ designCollection {
+ designs(atVersion: $atVersion, filenames: $filenames) {
+ edges {
+ node {
+ ...DesignItem
+ issue {
+ title
+ webPath
+ webUrl
+ participants {
+ edges {
+ node {
+ ...Author
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql
new file mode 100644
index 00000000000..121a50555b3
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql
@@ -0,0 +1,26 @@
+#import "../fragments/design_list.fragment.graphql"
+#import "../fragments/version.fragment.graphql"
+
+query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
+ project(fullPath: $fullPath) {
+ id
+ issue(iid: $iid) {
+ designCollection {
+ designs(atVersion: $atVersion) {
+ edges {
+ node {
+ ...DesignListItem
+ }
+ }
+ }
+ versions {
+ edges {
+ node {
+ ...VersionListItem
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management_new/graphql/typedefs.graphql b/app/assets/javascripts/design_management_new/graphql/typedefs.graphql
new file mode 100644
index 00000000000..fdbad4a90e0
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/graphql/typedefs.graphql
@@ -0,0 +1,12 @@
+type ActiveDiscussion {
+ id: ID
+ source: String
+}
+
+extend type Query {
+ activeDiscussion: ActiveDiscussion
+}
+
+extend type Mutation {
+ updateActiveDiscussion(id: ID!, source: String!): Boolean
+}
diff --git a/app/assets/javascripts/design_management_new/index.js b/app/assets/javascripts/design_management_new/index.js
new file mode 100644
index 00000000000..20c9cacf83f
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/index.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import createRouter from './router';
+import App from './components/app.vue';
+import apolloProvider from './graphql';
+
+export default () => {
+ const el = document.querySelector('.js-design-management-new');
+ const { issueIid, projectPath, issuePath } = el.dataset;
+ const router = createRouter(issuePath);
+
+ apolloProvider.clients.defaultClient.cache.writeData({
+ data: {
+ activeDiscussion: {
+ __typename: 'ActiveDiscussion',
+ id: null,
+ source: null,
+ },
+ },
+ });
+
+ return new Vue({
+ el,
+ router,
+ apolloProvider,
+ provide: {
+ projectPath,
+ issueIid,
+ },
+ render(createElement) {
+ return createElement(App);
+ },
+ });
+};
diff --git a/app/assets/javascripts/design_management_new/mixins/all_designs.js b/app/assets/javascripts/design_management_new/mixins/all_designs.js
new file mode 100644
index 00000000000..f7d6551c46c
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/mixins/all_designs.js
@@ -0,0 +1,49 @@
+import { propertyOf } from 'lodash';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import { extractNodes } from '../utils/design_management_utils';
+import allVersionsMixin from './all_versions';
+import { DESIGNS_ROUTE_NAME } from '../router/constants';
+
+export default {
+ mixins: [allVersionsMixin],
+ apollo: {
+ designs: {
+ query: getDesignListQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ atVersion: this.designsVersion,
+ };
+ },
+ update: data => {
+ const designEdges = propertyOf(data)(['project', 'issue', 'designCollection', 'designs']);
+ if (designEdges) {
+ return extractNodes(designEdges);
+ }
+ return [];
+ },
+ error() {
+ this.error = true;
+ },
+ result() {
+ if (this.$route.query.version && !this.hasValidVersion) {
+ createFlash(
+ s__(
+ 'DesignManagement|Requested design version does not exist. Showing latest version instead',
+ ),
+ );
+ this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } });
+ }
+ },
+ },
+ },
+ data() {
+ return {
+ designs: [],
+ error: false,
+ };
+ },
+};
diff --git a/app/assets/javascripts/design_management_new/mixins/all_versions.js b/app/assets/javascripts/design_management_new/mixins/all_versions.js
new file mode 100644
index 00000000000..99e2ee9561c
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/mixins/all_versions.js
@@ -0,0 +1,59 @@
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import { findVersionId } from '../utils/design_management_utils';
+
+export default {
+ apollo: {
+ allVersions: {
+ query: getDesignListQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ atVersion: null,
+ };
+ },
+ update: data => data.project.issue.designCollection.versions.edges,
+ },
+ },
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ issueIid: {
+ default: '',
+ },
+ },
+ computed: {
+ hasValidVersion() {
+ return (
+ this.$route.query.version &&
+ this.allVersions &&
+ this.allVersions.some(version => version.node.id.endsWith(this.$route.query.version))
+ );
+ },
+ designsVersion() {
+ return this.hasValidVersion
+ ? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}`
+ : null;
+ },
+ latestVersionId() {
+ const latestVersion = this.allVersions[0];
+ return latestVersion && findVersionId(latestVersion.node.id);
+ },
+ isLatestVersion() {
+ if (this.allVersions.length > 0) {
+ return (
+ !this.$route.query.version ||
+ !this.latestVersionId ||
+ this.$route.query.version === this.latestVersionId
+ );
+ }
+ return true;
+ },
+ },
+ data() {
+ return {
+ allVersions: [],
+ };
+ },
+};
diff --git a/app/assets/javascripts/design_management_new/pages/design/index.vue b/app/assets/javascripts/design_management_new/pages/design/index.vue
new file mode 100644
index 00000000000..47f5e3a786f
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/pages/design/index.vue
@@ -0,0 +1,367 @@
+<script>
+import Mousetrap from 'mousetrap';
+import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import { ApolloMutation } from 'vue-apollo';
+import createFlash from '~/flash';
+import { fetchPolicies } from '~/lib/graphql';
+import allVersionsMixin from '../../mixins/all_versions';
+import Toolbar from '../../components/toolbar/index.vue';
+import DesignDestroyer from '../../components/design_destroyer.vue';
+import DesignScaler from '../../components/design_scaler.vue';
+import DesignPresentation from '../../components/design_presentation.vue';
+import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
+import DesignSidebar from '../../components/design_sidebar.vue';
+import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
+import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql';
+import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql';
+import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql';
+import {
+ extractDiscussions,
+ extractDesign,
+ updateImageDiffNoteOptimisticResponse,
+} from '../../utils/design_management_utils';
+import {
+ updateStoreAfterAddImageDiffNote,
+ updateStoreAfterUpdateImageDiffNote,
+} from '../../utils/cache_update';
+import {
+ ADD_DISCUSSION_COMMENT_ERROR,
+ ADD_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_IMAGE_DIFF_NOTE_ERROR,
+ DESIGN_NOT_FOUND_ERROR,
+ DESIGN_VERSION_NOT_EXIST_ERROR,
+ UPDATE_NOTE_ERROR,
+ designDeletionError,
+} from '../../utils/error_messages';
+import { trackDesignDetailView } from '../../utils/tracking';
+import { DESIGNS_ROUTE_NAME } from '../../router/constants';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
+
+export default {
+ components: {
+ ApolloMutation,
+ DesignReplyForm,
+ DesignPresentation,
+ DesignScaler,
+ DesignDestroyer,
+ Toolbar,
+ GlLoadingIcon,
+ GlAlert,
+ DesignSidebar,
+ },
+ mixins: [allVersionsMixin],
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ design: {},
+ comment: '',
+ annotationCoordinates: null,
+ errorMessage: '',
+ scale: 1,
+ resolvedDiscussionsExpanded: false,
+ };
+ },
+ apollo: {
+ design: {
+ query: getDesignQuery,
+ // We want to see cached design version if we have one, and fetch newer version on the background to update discussions
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ variables() {
+ return this.designVariables;
+ },
+ update: data => extractDesign(data),
+ result(res) {
+ this.onDesignQueryResult(res);
+ },
+ error() {
+ this.onQueryError(DESIGN_NOT_FOUND_ERROR);
+ },
+ },
+ },
+ computed: {
+ isFirstLoading() {
+ // We only want to show spinner on initial design load (when opened from a deep link to design)
+ // If we already have cached a design, loading shouldn't be indicated to user
+ return this.$apollo.queries.design.loading && !this.design.filename;
+ },
+ discussions() {
+ if (!this.design.discussions) {
+ return [];
+ }
+ return extractDiscussions(this.design.discussions);
+ },
+ markdownPreviewPath() {
+ return `/${this.projectPath}/preview_markdown?target_type=Issue`;
+ },
+ isSubmitButtonDisabled() {
+ return this.comment.trim().length === 0;
+ },
+ designVariables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ filenames: [this.$route.params.id],
+ atVersion: this.designsVersion,
+ };
+ },
+ mutationPayload() {
+ const { x, y, width, height } = this.annotationCoordinates;
+ return {
+ noteableId: this.design.id,
+ body: this.comment,
+ position: {
+ headSha: this.design.diffRefs.headSha,
+ baseSha: this.design.diffRefs.baseSha,
+ startSha: this.design.diffRefs.startSha,
+ x,
+ y,
+ width,
+ height,
+ paths: {
+ newPath: this.design.fullPath,
+ },
+ },
+ };
+ },
+ isAnnotating() {
+ return Boolean(this.annotationCoordinates);
+ },
+ resolvedDiscussions() {
+ return this.discussions.filter(discussion => discussion.resolved);
+ },
+ },
+ watch: {
+ resolvedDiscussions(val) {
+ if (!val.length) {
+ this.resolvedDiscussionsExpanded = false;
+ }
+ },
+ },
+ mounted() {
+ Mousetrap.bind('esc', this.closeDesign);
+ this.trackEvent();
+ // We need to reset the active discussion when opening a new design
+ this.updateActiveDiscussion();
+ },
+ beforeDestroy() {
+ Mousetrap.unbind('esc', this.closeDesign);
+ },
+ methods: {
+ addImageDiffNoteToStore(
+ store,
+ {
+ data: { createImageDiffNote },
+ },
+ ) {
+ updateStoreAfterAddImageDiffNote(
+ store,
+ createImageDiffNote,
+ getDesignQuery,
+ this.designVariables,
+ );
+ },
+ updateImageDiffNoteInStore(
+ store,
+ {
+ data: { updateImageDiffNote },
+ },
+ ) {
+ return updateStoreAfterUpdateImageDiffNote(
+ store,
+ updateImageDiffNote,
+ getDesignQuery,
+ this.designVariables,
+ );
+ },
+ onMoveNote({ noteId, discussionId, position }) {
+ const discussion = this.discussions.find(({ id }) => id === discussionId);
+ const note = discussion.notes.find(
+ ({ discussion: noteDiscussion }) => noteDiscussion.id === discussionId,
+ );
+
+ const mutationPayload = {
+ optimisticResponse: updateImageDiffNoteOptimisticResponse(note, {
+ position,
+ }),
+ variables: {
+ input: {
+ id: noteId,
+ position,
+ },
+ },
+ mutation: updateImageDiffNoteMutation,
+ update: this.updateImageDiffNoteInStore,
+ };
+
+ return this.$apollo.mutate(mutationPayload).catch(e => this.onUpdateImageDiffNoteError(e));
+ },
+ onDesignQueryResult({ data, loading }) {
+ // On the initial load with cache-and-network policy data is undefined while loading is true
+ // To prevent throwing an error, we don't perform any logic until loading is false
+ if (loading) {
+ return;
+ }
+
+ if (!data || !extractDesign(data)) {
+ this.onQueryError(DESIGN_NOT_FOUND_ERROR);
+ } else if (this.$route.query.version && !this.hasValidVersion) {
+ this.onQueryError(DESIGN_VERSION_NOT_EXIST_ERROR);
+ }
+ },
+ onQueryError(message) {
+ // because we redirect user to /designs (the issue page),
+ // we want to create these flashes on the issue page
+ createFlash(message);
+ this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME });
+ },
+ onError(message, e) {
+ this.errorMessage = message;
+ throw e;
+ },
+ onCreateImageDiffNoteError(e) {
+ this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e);
+ },
+ onUpdateNoteError(e) {
+ this.onError(UPDATE_NOTE_ERROR, e);
+ },
+ onDesignDiscussionError(e) {
+ this.onError(ADD_DISCUSSION_COMMENT_ERROR, e);
+ },
+ onUpdateImageDiffNoteError(e) {
+ this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
+ },
+ onDesignDeleteError(e) {
+ this.onError(designDeletionError({ singular: true }), e);
+ },
+ onResolveDiscussionError(e) {
+ this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
+ },
+ openCommentForm(annotationCoordinates) {
+ this.annotationCoordinates = annotationCoordinates;
+ if (this.$refs.newDiscussionForm) {
+ this.$refs.newDiscussionForm.focusInput();
+ }
+ },
+ closeCommentForm() {
+ this.comment = '';
+ this.annotationCoordinates = null;
+ },
+ closeDesign() {
+ this.$router.push({
+ name: this.$options.DESIGNS_ROUTE_NAME,
+ query: this.$route.query,
+ });
+ },
+ trackEvent() {
+ // TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue
+ trackDesignDetailView(
+ 'issue-design-collection',
+ 'issue',
+ this.$route.query.version || this.latestVersionId,
+ this.isLatestVersion,
+ );
+ },
+ updateActiveDiscussion(id) {
+ this.$apollo.mutate({
+ mutation: updateActiveDiscussionMutation,
+ variables: {
+ id,
+ source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
+ },
+ });
+ },
+ toggleResolvedComments() {
+ this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded;
+ },
+ },
+ createImageDiffNoteMutation,
+ DESIGNS_ROUTE_NAME,
+};
+</script>
+
+<template>
+ <div
+ class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
+ >
+ <gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" />
+ <template v-else>
+ <div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative">
+ <design-destroyer
+ :filenames="[design.filename]"
+ :project-path="projectPath"
+ :iid="issueIid"
+ @done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })"
+ @error="onDesignDeleteError"
+ >
+ <template #default="{ mutate, loading }">
+ <toolbar
+ :id="id"
+ :is-deleting="loading"
+ :is-latest-version="isLatestVersion"
+ v-bind="design"
+ @delete="mutate"
+ />
+ </template>
+ </design-destroyer>
+
+ <div v-if="errorMessage" class="p-3">
+ <gl-alert variant="danger" @dismiss="errorMessage = null">
+ {{ errorMessage }}
+ </gl-alert>
+ </div>
+ <design-presentation
+ :image="design.image"
+ :image-name="design.filename"
+ :discussions="discussions"
+ :is-annotating="isAnnotating"
+ :scale="scale"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
+ @openCommentForm="openCommentForm"
+ @closeCommentForm="closeCommentForm"
+ @moveNote="onMoveNote"
+ />
+
+ <div class="design-scaler-wrapper position-absolute mb-4 d-flex-center">
+ <design-scaler @scale="scale = $event" />
+ </div>
+ </div>
+ <design-sidebar
+ :design="design"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
+ :markdown-preview-path="markdownPreviewPath"
+ @onDesignDiscussionError="onDesignDiscussionError"
+ @onCreateImageDiffNoteError="onCreateImageDiffNoteError"
+ @updateNoteError="onUpdateNoteError"
+ @resolveDiscussionError="onResolveDiscussionError"
+ @toggleResolvedComments="toggleResolvedComments"
+ >
+ <template #replyForm>
+ <apollo-mutation
+ v-if="isAnnotating"
+ #default="{ mutate, loading }"
+ :mutation="$options.createImageDiffNoteMutation"
+ :variables="{
+ input: mutationPayload,
+ }"
+ :update="addImageDiffNoteToStore"
+ @done="closeCommentForm"
+ @error="onCreateImageDiffNoteError"
+ >
+ <design-reply-form
+ ref="newDiscussionForm"
+ v-model="comment"
+ :is-saving="loading"
+ :markdown-preview-path="markdownPreviewPath"
+ @submitForm="mutate"
+ @cancelForm="closeCommentForm"
+ /> </apollo-mutation
+ ></template>
+ </design-sidebar>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/pages/index.vue b/app/assets/javascripts/design_management_new/pages/index.vue
new file mode 100644
index 00000000000..2a100fae280
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/pages/index.vue
@@ -0,0 +1,346 @@
+<script>
+import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { s__, sprintf } from '~/locale';
+import UploadButton from '../components/upload/button.vue';
+import DeleteButton from '../components/delete_button.vue';
+import Design from '../components/list/item.vue';
+import DesignDestroyer from '../components/design_destroyer.vue';
+import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
+import DesignDropzone from '../components/upload/design_dropzone.vue';
+import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
+import permissionsQuery from '../graphql/queries/design_permissions.query.graphql';
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import allDesignsMixin from '../mixins/all_designs';
+import {
+ UPLOAD_DESIGN_ERROR,
+ EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
+ EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
+ designUploadSkippedWarning,
+ designDeletionError,
+} from '../utils/error_messages';
+import { updateStoreAfterUploadDesign } from '../utils/cache_update';
+import {
+ designUploadOptimisticResponse,
+ isValidDesignFile,
+} from '../utils/design_management_utils';
+import { getFilename } from '~/lib/utils/file_upload';
+import { DESIGNS_ROUTE_NAME } from '../router/constants';
+
+const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlAlert,
+ GlButton,
+ UploadButton,
+ Design,
+ DesignDestroyer,
+ DesignVersionDropdown,
+ DeleteButton,
+ DesignDropzone,
+ },
+ mixins: [allDesignsMixin],
+ apollo: {
+ permissions: {
+ query: permissionsQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ };
+ },
+ update: data => data.project.issue.userPermissions,
+ },
+ },
+ data() {
+ return {
+ permissions: {
+ createDesign: false,
+ },
+ filesToBeSaved: [],
+ selectedDesigns: [],
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading;
+ },
+ isSaving() {
+ return this.filesToBeSaved.length > 0;
+ },
+ canCreateDesign() {
+ return this.permissions.createDesign;
+ },
+ showToolbar() {
+ return this.canCreateDesign && this.allVersions.length > 0;
+ },
+ hasDesigns() {
+ return this.designs.length > 0;
+ },
+ hasSelectedDesigns() {
+ return this.selectedDesigns.length > 0;
+ },
+ canDeleteDesigns() {
+ return this.isLatestVersion && this.hasSelectedDesigns;
+ },
+ projectQueryBody() {
+ return {
+ query: getDesignListQuery,
+ variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null },
+ };
+ },
+ selectAllButtonText() {
+ return this.hasSelectedDesigns
+ ? s__('DesignManagement|Deselect all')
+ : s__('DesignManagement|Select all');
+ },
+ isDesignListEmpty() {
+ return !this.isSaving && !this.hasDesigns;
+ },
+ designDropzoneWrapperClass() {
+ return this.isDesignListEmpty
+ ? 'col-12'
+ : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3';
+ },
+ },
+ mounted() {
+ this.toggleOnPasteListener(this.$route.name);
+ },
+ methods: {
+ resetFilesToBeSaved() {
+ this.filesToBeSaved = [];
+ },
+ /**
+ * Determine if a design upload is valid, given [files]
+ * @param {Array<File>} files
+ */
+ isValidDesignUpload(files) {
+ if (!this.canCreateDesign) return false;
+
+ if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
+ createFlash(
+ sprintf(
+ s__(
+ 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
+ ),
+ {
+ upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
+ },
+ ),
+ );
+
+ return false;
+ }
+ return true;
+ },
+ onUploadDesign(files) {
+ // convert to Array so that we have Array methods (.map, .some, etc.)
+ this.filesToBeSaved = Array.from(files);
+ if (!this.isValidDesignUpload(this.filesToBeSaved)) return null;
+
+ const mutationPayload = {
+ optimisticResponse: designUploadOptimisticResponse(this.filesToBeSaved),
+ variables: {
+ files: this.filesToBeSaved,
+ projectPath: this.projectPath,
+ iid: this.issueIid,
+ },
+ context: {
+ hasUpload: true,
+ },
+ mutation: uploadDesignMutation,
+ update: this.afterUploadDesign,
+ };
+
+ return this.$apollo
+ .mutate(mutationPayload)
+ .then(res => this.onUploadDesignDone(res))
+ .catch(() => this.onUploadDesignError());
+ },
+ afterUploadDesign(
+ store,
+ {
+ data: { designManagementUpload },
+ },
+ ) {
+ updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody);
+ },
+ onUploadDesignDone(res) {
+ const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
+ const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
+ if (skippedWarningMessage) {
+ createFlash(skippedWarningMessage, 'warning');
+ }
+
+ // if this upload resulted in a new version being created, redirect user to the latest version
+ if (!this.isLatestVersion) {
+ this.$router.push({ name: DESIGNS_ROUTE_NAME });
+ }
+ this.resetFilesToBeSaved();
+ },
+ onUploadDesignError() {
+ this.resetFilesToBeSaved();
+ createFlash(UPLOAD_DESIGN_ERROR);
+ },
+ changeSelectedDesigns(filename) {
+ if (this.isDesignSelected(filename)) {
+ this.selectedDesigns = this.selectedDesigns.filter(design => design !== filename);
+ } else {
+ this.selectedDesigns.push(filename);
+ }
+ },
+ toggleDesignsSelection() {
+ if (this.hasSelectedDesigns) {
+ this.selectedDesigns = [];
+ } else {
+ this.selectedDesigns = this.designs.map(design => design.filename);
+ }
+ },
+ isDesignSelected(filename) {
+ return this.selectedDesigns.includes(filename);
+ },
+ isDesignToBeSaved(filename) {
+ return this.filesToBeSaved.some(file => file.name === filename);
+ },
+ canSelectDesign(filename) {
+ return this.isLatestVersion && this.canCreateDesign && !this.isDesignToBeSaved(filename);
+ },
+ onDesignDelete() {
+ this.selectedDesigns = [];
+ if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME });
+ },
+ onDesignDeleteError() {
+ const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 });
+ createFlash(errorMessage);
+ },
+ onExistingDesignDropzoneChange(files, existingDesignFilename) {
+ const filesArr = Array.from(files);
+
+ if (filesArr.length > 1) {
+ createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE);
+ return;
+ }
+
+ if (!filesArr.some(({ name }) => existingDesignFilename === name)) {
+ createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE);
+ return;
+ }
+
+ this.onUploadDesign(files);
+ },
+ onDesignPaste(event) {
+ const { clipboardData } = event;
+ const files = Array.from(clipboardData.files);
+ if (clipboardData && files.length > 0) {
+ if (!files.some(isValidDesignFile)) {
+ return;
+ }
+ event.preventDefault();
+ let filename = getFilename(event);
+ if (!filename || filename === 'image.png') {
+ filename = `design_${Date.now()}.png`;
+ }
+ const newFile = new File([files[0]], filename);
+ this.onUploadDesign([newFile]);
+ }
+ },
+ toggleOnPasteListener(route) {
+ if (route === DESIGNS_ROUTE_NAME) {
+ document.addEventListener('paste', this.onDesignPaste);
+ } else {
+ document.removeEventListener('paste', this.onDesignPaste);
+ }
+ },
+ },
+ beforeRouteUpdate(to, from, next) {
+ this.toggleOnPasteListener(to.name);
+ this.selectedDesigns = [];
+ next();
+ },
+ beforeRouteLeave(to, from, next) {
+ this.toggleOnPasteListener(to.name);
+ next();
+ },
+};
+</script>
+
+<template>
+ <div data-testid="designs-root" class="gl-mt-5">
+ <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex">
+ <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full">
+ <div>
+ <span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span>
+ <design-version-dropdown />
+ </div>
+ <div v-show="hasDesigns" class="qa-selector-toolbar gl-display-flex">
+ <gl-button
+ v-if="isLatestVersion"
+ variant="link"
+ size="small"
+ class="gl-mr-2 js-select-all"
+ @click="toggleDesignsSelection"
+ >{{ selectAllButtonText }}
+ </gl-button>
+ <design-destroyer
+ #default="{ mutate, loading }"
+ :filenames="selectedDesigns"
+ @done="onDesignDelete"
+ @error="onDesignDeleteError"
+ >
+ <delete-button
+ v-if="isLatestVersion"
+ :is-deleting="loading"
+ button-variant="danger"
+ button-class="gl-mr-4"
+ button-size="small"
+ :has-selected-designs="hasSelectedDesigns"
+ @deleteSelectedDesigns="mutate()"
+ >
+ {{ s__('DesignManagement|Delete selected') }}
+ <gl-loading-icon v-if="loading" inline class="ml-1" />
+ </delete-button>
+ </design-destroyer>
+ <upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" />
+ </div>
+ </div>
+ </header>
+ <div class="mt-4">
+ <gl-loading-icon v-if="isLoading" size="md" />
+ <gl-alert v-else-if="error" variant="danger" :dismissible="false">
+ {{ __('An error occurred while loading designs. Please try again.') }}
+ </gl-alert>
+ <ol v-else class="list-unstyled row">
+ <span
+ v-if="isDesignListEmpty && !allVersions.length"
+ class="gl-font-weight-bold gl-font-weight-bold gl-ml-5 gl-mb-4"
+ >{{ s__('DesignManagement|Designs') }}</span
+ >
+ <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper">
+ <design-dropzone
+ :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }"
+ :has-designs="hasDesigns"
+ @change="onUploadDesign"
+ />
+ </li>
+ <li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-3 gl-mb-3">
+ <design-dropzone
+ :has-designs="hasDesigns"
+ @change="onExistingDesignDropzoneChange($event, design.filename)"
+ ><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)"
+ /></design-dropzone>
+
+ <input
+ v-if="canSelectDesign(design.filename)"
+ :checked="isDesignSelected(design.filename)"
+ type="checkbox"
+ class="design-checkbox"
+ @change="changeSelectedDesigns(design.filename)"
+ />
+ </li>
+ </ol>
+ </div>
+ <router-view :key="$route.fullPath" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management_new/router/constants.js b/app/assets/javascripts/design_management_new/router/constants.js
new file mode 100644
index 00000000000..dd2ee8d8689
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/router/constants.js
@@ -0,0 +1,2 @@
+export const DESIGNS_ROUTE_NAME = 'designs';
+export const DESIGN_ROUTE_NAME = 'design';
diff --git a/app/assets/javascripts/design_management_new/router/index.js b/app/assets/javascripts/design_management_new/router/index.js
new file mode 100644
index 00000000000..40e2d35bc40
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/router/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import routes from './routes';
+import { DESIGN_ROUTE_NAME } from './constants';
+import { getPageLayoutElement } from '~/design_management_new/utils/design_management_utils';
+import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants';
+
+Vue.use(VueRouter);
+
+export default function createRouter(base) {
+ const router = new VueRouter({
+ base,
+ mode: 'history',
+ routes,
+ });
+ const pageEl = getPageLayoutElement();
+
+ router.beforeEach(({ name }, _, next) => {
+ // apply a fullscreen layout style in Design View (a.k.a design detail)
+ if (pageEl) {
+ if (name === DESIGN_ROUTE_NAME) {
+ pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
+ } else {
+ pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
+ }
+ }
+
+ next();
+ });
+
+ return router;
+}
diff --git a/app/assets/javascripts/design_management_new/router/routes.js b/app/assets/javascripts/design_management_new/router/routes.js
new file mode 100644
index 00000000000..d888b856611
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/router/routes.js
@@ -0,0 +1,29 @@
+import Home from '../pages/index.vue';
+import DesignDetail from '../pages/design/index.vue';
+import { DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants';
+
+export default [
+ {
+ name: DESIGNS_ROUTE_NAME,
+ path: '/',
+ component: Home,
+ alias: '/designs',
+ },
+ {
+ name: DESIGN_ROUTE_NAME,
+ path: '/designs/:id',
+ component: DesignDetail,
+ beforeEnter(
+ {
+ params: { id },
+ },
+ _,
+ next,
+ ) {
+ if (typeof id === 'string') {
+ next();
+ }
+ },
+ props: ({ params: { id } }) => ({ id }),
+ },
+];
diff --git a/app/assets/javascripts/design_management_new/utils/cache_update.js b/app/assets/javascripts/design_management_new/utils/cache_update.js
new file mode 100644
index 00000000000..24b374b79fd
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/utils/cache_update.js
@@ -0,0 +1,276 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+
+import createFlash from '~/flash';
+import { extractCurrentDiscussion, extractDesign } from './design_management_utils';
+import {
+ ADD_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_IMAGE_DIFF_NOTE_ERROR,
+ ADD_DISCUSSION_COMMENT_ERROR,
+ designDeletionError,
+} from './error_messages';
+
+const deleteDesignsFromStore = (store, query, selectedDesigns) => {
+ const data = store.readQuery(query);
+
+ const changedDesigns = data.project.issue.designCollection.designs.edges.filter(
+ ({ node }) => !selectedDesigns.includes(node.filename),
+ );
+ data.project.issue.designCollection.designs.edges = [...changedDesigns];
+
+ store.writeQuery({
+ ...query,
+ data,
+ });
+};
+
+/**
+ * Adds a new version of designs to store
+ *
+ * @param {Object} store
+ * @param {Object} query
+ * @param {Object} version
+ */
+const addNewVersionToStore = (store, query, version) => {
+ if (!version) return;
+
+ const data = store.readQuery(query);
+ const newEdge = { node: version, __typename: 'DesignVersionEdge' };
+
+ data.project.issue.designCollection.versions.edges = [
+ newEdge,
+ ...data.project.issue.designCollection.versions.edges,
+ ];
+
+ store.writeQuery({
+ ...query,
+ data,
+ });
+};
+
+const addDiscussionCommentToStore = (store, createNote, query, queryVariables, discussionId) => {
+ const data = store.readQuery({
+ query,
+ variables: queryVariables,
+ });
+
+ const design = extractDesign(data);
+ const currentDiscussion = extractCurrentDiscussion(design.discussions, discussionId);
+ currentDiscussion.notes.nodes = [...currentDiscussion.notes.nodes, createNote.note];
+
+ design.notesCount += 1;
+ if (
+ !design.issue.participants.edges.some(
+ participant => participant.node.username === createNote.note.author.username,
+ )
+ ) {
+ design.issue.participants.edges = [
+ ...design.issue.participants.edges,
+ {
+ __typename: 'UserEdge',
+ node: {
+ __typename: 'User',
+ ...createNote.note.author,
+ },
+ },
+ ];
+ }
+ store.writeQuery({
+ query,
+ variables: queryVariables,
+ data: {
+ ...data,
+ design: {
+ ...design,
+ },
+ },
+ });
+};
+
+const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) => {
+ const data = store.readQuery({
+ query,
+ variables,
+ });
+ const newDiscussion = {
+ __typename: 'Discussion',
+ id: createImageDiffNote.note.discussion.id,
+ replyId: createImageDiffNote.note.discussion.replyId,
+ resolvable: true,
+ resolved: false,
+ resolvedAt: null,
+ resolvedBy: null,
+ notes: {
+ __typename: 'NoteConnection',
+ nodes: [createImageDiffNote.note],
+ },
+ };
+ const design = extractDesign(data);
+ const notesCount = design.notesCount + 1;
+ design.discussions.nodes = [...design.discussions.nodes, newDiscussion];
+ if (
+ !design.issue.participants.edges.some(
+ participant => participant.node.username === createImageDiffNote.note.author.username,
+ )
+ ) {
+ design.issue.participants.edges = [
+ ...design.issue.participants.edges,
+ {
+ __typename: 'UserEdge',
+ node: {
+ __typename: 'User',
+ ...createImageDiffNote.note.author,
+ },
+ },
+ ];
+ }
+ store.writeQuery({
+ query,
+ variables,
+ data: {
+ ...data,
+ design: {
+ ...design,
+ notesCount,
+ },
+ },
+ });
+};
+
+const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => {
+ const data = store.readQuery({
+ query,
+ variables,
+ });
+
+ const design = extractDesign(data);
+ const discussion = extractCurrentDiscussion(
+ design.discussions,
+ updateImageDiffNote.note.discussion.id,
+ );
+
+ discussion.notes = {
+ ...discussion.notes,
+ nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)],
+ };
+
+ store.writeQuery({
+ query,
+ variables,
+ data: {
+ ...data,
+ design,
+ },
+ });
+};
+
+const addNewDesignToStore = (store, designManagementUpload, query) => {
+ const data = store.readQuery(query);
+
+ const newDesigns = data.project.issue.designCollection.designs.edges.reduce((acc, design) => {
+ if (!acc.find(d => d.filename === design.node.filename)) {
+ acc.push(design.node);
+ }
+
+ return acc;
+ }, designManagementUpload.designs);
+
+ let newVersionNode;
+ const findNewVersions = designManagementUpload.designs.find(design => design.versions);
+
+ if (findNewVersions) {
+ const findNewVersionsEdges = findNewVersions.versions.edges;
+
+ if (findNewVersionsEdges && findNewVersionsEdges.length) {
+ newVersionNode = [findNewVersionsEdges[0]];
+ }
+ }
+
+ const newVersions = [
+ ...(newVersionNode || []),
+ ...data.project.issue.designCollection.versions.edges,
+ ];
+
+ const updatedDesigns = {
+ __typename: 'DesignCollection',
+ designs: {
+ __typename: 'DesignConnection',
+ edges: newDesigns.map(design => ({
+ __typename: 'DesignEdge',
+ node: design,
+ })),
+ },
+ versions: {
+ __typename: 'DesignVersionConnection',
+ edges: newVersions,
+ },
+ };
+
+ data.project.issue.designCollection = updatedDesigns;
+
+ store.writeQuery({
+ ...query,
+ data,
+ });
+};
+
+const onError = (data, message) => {
+ createFlash(message);
+ throw new Error(data.errors);
+};
+
+export const hasErrors = ({ errors = [] }) => errors?.length;
+
+/**
+ * Updates a store after design deletion
+ *
+ * @param {Object} store
+ * @param {Object} data
+ * @param {Object} query
+ * @param {Array} designs
+ */
+export const updateStoreAfterDesignsDelete = (store, data, query, designs) => {
+ if (hasErrors(data)) {
+ onError(data, designDeletionError({ singular: designs.length === 1 }));
+ } else {
+ deleteDesignsFromStore(store, query, designs);
+ addNewVersionToStore(store, query, data.version);
+ }
+};
+
+export const updateStoreAfterAddDiscussionComment = (
+ store,
+ data,
+ query,
+ queryVariables,
+ discussionId,
+) => {
+ if (hasErrors(data)) {
+ onError(data, ADD_DISCUSSION_COMMENT_ERROR);
+ } else {
+ addDiscussionCommentToStore(store, data, query, queryVariables, discussionId);
+ }
+};
+
+export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariables) => {
+ if (hasErrors(data)) {
+ onError(data, ADD_IMAGE_DIFF_NOTE_ERROR);
+ } else {
+ addImageDiffNoteToStore(store, data, query, queryVariables);
+ }
+};
+
+export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => {
+ if (hasErrors(data)) {
+ onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR);
+ } else {
+ updateImageDiffNoteInStore(store, data, query, queryVariables);
+ }
+};
+
+export const updateStoreAfterUploadDesign = (store, data, query) => {
+ if (hasErrors(data)) {
+ onError(data, data.errors[0]);
+ } else {
+ addNewDesignToStore(store, data, query);
+ }
+};
diff --git a/app/assets/javascripts/design_management_new/utils/design_management_utils.js b/app/assets/javascripts/design_management_new/utils/design_management_utils.js
new file mode 100644
index 00000000000..22705cf67a1
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/utils/design_management_utils.js
@@ -0,0 +1,128 @@
+import { uniqueId } from 'lodash';
+import { VALID_DESIGN_FILE_MIMETYPE } from '../constants';
+
+export const isValidDesignFile = ({ type }) =>
+ (type.match(VALID_DESIGN_FILE_MIMETYPE.regex) || []).length > 0;
+
+/**
+ * Returns formatted array that doesn't contain
+ * `edges`->`node` nesting
+ *
+ * @param {Array} elements
+ */
+
+export const extractNodes = elements => elements.edges.map(({ node }) => node);
+
+/**
+ * Returns formatted array of discussions that doesn't contain
+ * `edges`->`node` nesting for child notes
+ *
+ * @param {Array} discussions
+ */
+
+export const extractDiscussions = discussions =>
+ discussions.nodes.map((discussion, index) => ({
+ ...discussion,
+ index: index + 1,
+ notes: discussion.notes.nodes,
+ }));
+
+/**
+ * Returns a discussion with the given id from discussions array
+ *
+ * @param {Array} discussions
+ */
+
+export const extractCurrentDiscussion = (discussions, id) =>
+ discussions.nodes.find(discussion => discussion.id === id);
+
+export const findVersionId = id => (id.match('::Version/(.+$)') || [])[1];
+
+export const findNoteId = id => (id.match('DiffNote/(.+$)') || [])[1];
+
+export const extractDesigns = data => data.project.issue.designCollection.designs.edges;
+
+export const extractDesign = data => (extractDesigns(data) || [])[0]?.node;
+
+/**
+ * Generates optimistic response for a design upload mutation
+ * @param {Array<File>} files
+ */
+export const designUploadOptimisticResponse = files => {
+ const designs = files.map(file => ({
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Design',
+ id: -uniqueId(),
+ image: '',
+ imageV432x230: '',
+ filename: file.name,
+ fullPath: '',
+ notesCount: 0,
+ event: 'NONE',
+ diffRefs: {
+ __typename: 'DiffRefs',
+ baseSha: '',
+ startSha: '',
+ headSha: '',
+ },
+ discussions: {
+ __typename: 'DesignDiscussion',
+ nodes: [],
+ },
+ versions: {
+ __typename: 'DesignVersionConnection',
+ edges: {
+ __typename: 'DesignVersionEdge',
+ node: {
+ __typename: 'DesignVersion',
+ id: -uniqueId(),
+ sha: -uniqueId(),
+ },
+ },
+ },
+ }));
+
+ return {
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Mutation',
+ designManagementUpload: {
+ __typename: 'DesignManagementUploadPayload',
+ designs,
+ skippedDesigns: [],
+ errors: [],
+ },
+ };
+};
+
+/**
+ * Generates optimistic response for a design upload mutation
+ * @param {Array<File>} files
+ */
+export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Mutation',
+ updateImageDiffNote: {
+ __typename: 'UpdateImageDiffNotePayload',
+ note: {
+ ...note,
+ position: {
+ ...note.position,
+ ...position,
+ },
+ },
+ errors: [],
+ },
+});
+
+const normalizeAuthor = author => ({
+ ...author,
+ web_url: author.webUrl,
+ avatar_url: author.avatarUrl,
+});
+
+export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node));
+
+export const getPageLayoutElement = () => document.querySelector('.layout-page');
diff --git a/app/assets/javascripts/design_management_new/utils/error_messages.js b/app/assets/javascripts/design_management_new/utils/error_messages.js
new file mode 100644
index 00000000000..7666c726c2f
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/utils/error_messages.js
@@ -0,0 +1,95 @@
+import { __, s__, n__, sprintf } from '~/locale';
+
+export const ADD_DISCUSSION_COMMENT_ERROR = s__(
+ 'DesignManagement|Could not add a new comment. Please try again.',
+);
+
+export const ADD_IMAGE_DIFF_NOTE_ERROR = s__(
+ 'DesignManagement|Could not create new discussion. Please try again.',
+);
+
+export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__(
+ 'DesignManagement|Could not update discussion. Please try again.',
+);
+
+export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.');
+
+export const UPLOAD_DESIGN_ERROR = s__(
+ 'DesignManagement|Error uploading a new design. Please try again.',
+);
+
+export const UPLOAD_DESIGN_INVALID_FILETYPE_ERROR = __(
+ 'Could not upload your designs as one or more files uploaded are not supported.',
+);
+
+export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.');
+
+export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.');
+
+const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped.');
+
+const ALL_DESIGNS_SKIPPED_MESSAGE = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
+ 'The designs you tried uploading did not change.',
+)}`;
+
+export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __(
+ 'You can only upload one design when dropping onto an existing design.',
+);
+
+export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __(
+ 'You must upload a file with the same file name when dropping onto an existing design.',
+);
+
+const MAX_SKIPPED_FILES_LISTINGS = 5;
+
+const oneDesignSkippedMessage = filename =>
+ `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), {
+ filename,
+ })}`;
+
+/**
+ * Return warning message indicating that some (but not all) uploaded
+ * files were skipped.
+ * @param {Array<{ filename }>} skippedFiles
+ */
+const someDesignsSkippedMessage = skippedFiles => {
+ const designsSkippedMessage = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
+ 'Some of the designs you tried uploading did not change:',
+ )}`;
+
+ const moreText = sprintf(s__(`DesignManagement|and %{moreCount} more.`), {
+ moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS,
+ });
+
+ return `${designsSkippedMessage} ${skippedFiles
+ .slice(0, MAX_SKIPPED_FILES_LISTINGS)
+ .map(({ filename }) => filename)
+ .join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`;
+};
+
+export const designDeletionError = ({ singular = true } = {}) => {
+ const design = singular ? __('a design') : __('designs');
+ return sprintf(s__('Could not delete %{design}. Please try again.'), {
+ design,
+ });
+};
+
+/**
+ * Return warning message, if applicable, that one, some or all uploaded
+ * files were skipped.
+ * @param {Array<{ filename }>} uploadedDesigns
+ * @param {Array<{ filename }>} skippedFiles
+ */
+export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => {
+ if (skippedFiles.length === 0) {
+ return null;
+ }
+
+ if (skippedFiles.length === uploadedDesigns.length) {
+ const { filename } = skippedFiles[0];
+
+ return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length);
+ }
+
+ return someDesignsSkippedMessage(skippedFiles);
+};
diff --git a/app/assets/javascripts/design_management_new/utils/tracking.js b/app/assets/javascripts/design_management_new/utils/tracking.js
new file mode 100644
index 00000000000..b3ecc1453a6
--- /dev/null
+++ b/app/assets/javascripts/design_management_new/utils/tracking.js
@@ -0,0 +1,27 @@
+import Tracking from '~/tracking';
+
+// Tracking Constants
+const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0';
+const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
+const DESIGN_TRACKING_EVENT_NAME = 'view_design';
+
+// eslint-disable-next-line import/prefer-default-export
+export function trackDesignDetailView(
+ referer = '',
+ owner = '',
+ designVersion = 1,
+ latestVersion = false,
+) {
+ Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, {
+ label: DESIGN_TRACKING_EVENT_NAME,
+ context: {
+ schema: DESIGN_TRACKING_CONTEXT_SCHEMA,
+ data: {
+ 'design-version-number': designVersion,
+ 'design-is-current-version': latestVersion,
+ 'internal-object-referrer': referer,
+ 'design-collection-owner': owner,
+ },
+ },
+ });
+}
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 941365d9d1d..1e524882d5f 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -1,6 +1,6 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButtonGroup, GlButton } from '@gitlab/ui';
import Mousetrap from 'mousetrap';
import { __ } from '~/locale';
import createFlash from '~/flash';
@@ -36,6 +36,8 @@ export default {
TreeList,
GlLoadingIcon,
PanelResizer,
+ GlButtonGroup,
+ GlButton,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -94,6 +96,11 @@ export default {
required: false,
default: false,
},
+ viewDiffsFileByFile: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
const treeWidth =
@@ -120,9 +127,18 @@ export default {
emailPatchPath: state => state.diffs.emailPatchPath,
retrievingBatches: state => state.diffs.retrievingBatches,
}),
- ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']),
+ ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion', 'currentDiffFileId']),
...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
+ diffs() {
+ if (!this.viewDiffsFileByFile) {
+ return this.diffFiles;
+ }
+
+ return this.diffFiles.filter((file, i) => {
+ return file.file_hash === this.currentDiffFileId || (i === 0 && !this.currentDiffFileId);
+ });
+ },
canCurrentUserFork() {
return this.currentUser.can_fork === true && this.currentUser.can_create_merge_request;
},
@@ -183,16 +199,22 @@ export default {
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
useSingleDiffStyle: this.glFeatures.singleMrDiffView,
+ viewDiffsFileByFile: this.viewDiffsFileByFile,
});
if (this.shouldShow) {
this.fetchData();
}
- const id = window && window.location && window.location.hash;
+ const id = window?.location?.hash;
- if (id) {
- this.setHighlightedRow(id.slice(1));
+ if (id && id.indexOf('#note') !== 0) {
+ this.setHighlightedRow(
+ id
+ .split('diff-content')
+ .pop()
+ .slice(1),
+ );
}
},
created() {
@@ -236,6 +258,7 @@ export default {
'cacheTreeListWidth',
'scrollToFile',
'toggleShowTreeList',
+ 'navigateToDiffFileIndex',
]),
refetchDiffData() {
this.fetchData(false);
@@ -398,7 +421,7 @@ export default {
class="files d-flex"
>
<div
- v-show="showTreeList"
+ v-if="showTreeList"
:style="{ width: `${treeWidth}px` }"
class="diff-tree-list js-diff-tree-list mr-3"
>
@@ -422,12 +445,31 @@ export default {
<div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div>
<template v-else-if="renderDiffFiles">
<diff-file
- v-for="file in diffFiles"
+ v-for="file in diffs"
:key="file.newPath"
:file="file"
:help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
+ :view-diffs-file-by-file="viewDiffsFileByFile"
/>
+ <div v-if="viewDiffsFileByFile" class="d-flex gl-justify-content-center">
+ <gl-button-group>
+ <gl-button
+ :disabled="currentDiffIndex === 0"
+ data-testid="singleFilePrevious"
+ @click="navigateToDiffFileIndex(currentDiffIndex - 1)"
+ >
+ {{ __('Prev') }}
+ </gl-button>
+ <gl-button
+ :disabled="currentDiffIndex === diffFiles.length - 1"
+ data-testid="singleFileNext"
+ @click="navigateToDiffFileIndex(currentDiffIndex + 1)"
+ >
+ {{ __('Next') }}
+ </gl-button>
+ </gl-button-group>
+ </div>
</template>
<no-changes v-else :changes-empty-state-illustration="changesEmptyStateIllustration" />
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 54852b113ae..00d36c0b978 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -30,6 +30,10 @@ export default {
required: false,
default: '',
},
+ viewDiffsFileByFile: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -154,6 +158,7 @@ export default {
:collapsible="true"
:expanded="!isCollapsed"
:add-merge-request-buttons="true"
+ :view-diffs-file-by-file="viewDiffsFileByFile"
class="js-file-title file-title"
@toggleFile="handleToggle"
@showForkMessage="showForkMessage"
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 61bbf13aa53..5727fbaaf68 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -2,7 +2,6 @@
import { escape } from 'lodash';
import { mapActions, mapGetters } from 'vuex';
import { GlDeprecatedButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
-import { polyfillSticky } from '~/lib/utils/sticky';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -55,6 +54,11 @@ export default {
type: Boolean,
required: true,
},
+ viewDiffsFileByFile: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']),
@@ -124,9 +128,6 @@ export default {
return s__('MRDiff|Show full file');
},
},
- mounted() {
- polyfillSticky(this.$refs.header);
- },
methods: {
...mapActions('diffs', [
'toggleFileDiscussions',
@@ -167,22 +168,17 @@ export default {
:name="collapseIcon"
:size="16"
aria-hidden="true"
- class="diff-toggle-caret append-right-5"
+ class="diff-toggle-caret gl-mr-2"
@click.stop="handleToggleFile"
/>
<a
- v-once
ref="titleWrapper"
- class="append-right-4"
+ :v-once="!viewDiffsFileByFile"
+ class="gl-mr-2"
:href="titleLink"
@click="handleFileNameClick"
>
- <file-icon
- :file-name="filePath"
- :size="18"
- aria-hidden="true"
- css-classes="append-right-5"
- />
+ <file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" />
<span v-if="isFileRenamed">
<strong
v-gl-tooltip
@@ -218,7 +214,7 @@ export default {
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
- <span v-if="isUsingLfs" class="label label-lfs append-right-5"> {{ __('LFS') }} </span>
+ <span v-if="isUsingLfs" class="label label-lfs gl-mr-2"> {{ __('LFS') }} </span>
</div>
<div
diff --git a/app/assets/javascripts/diffs/components/diff_file_row.vue b/app/assets/javascripts/diffs/components/diff_file_row.vue
index c8ba8d6040e..43b669625f4 100644
--- a/app/assets/javascripts/diffs/components/diff_file_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_row.vue
@@ -23,6 +23,11 @@ export default {
type: Boolean,
required: true,
},
+ currentDiffFileId: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
showFileRowStats() {
@@ -33,7 +38,13 @@ export default {
</script>
<template>
- <file-row :file="file" v-bind="$attrs" v-on="$listeners">
+ <file-row
+ :file="file"
+ v-bind="$attrs"
+ :class="{ 'is-active': currentDiffFileId === file.fileHash }"
+ class="diff-file-row"
+ v-on="$listeners"
+ >
<file-row-stats v-if="showFileRowStats" :file="file" class="mr-1" />
<changed-file-icon :file="file" :size="16" :show-tooltip="true" />
</file-row>
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 74305ee69bc..d2f49bd0020 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -8,7 +8,10 @@ import MultilineCommentForm from '../../notes/components/multiline_comment_form.
import autosave from '../../notes/mixins/autosave';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import { DIFF_NOTE_TYPE } from '../constants';
-import { commentLineOptions } from '../../notes/components/multiline_comment_utils';
+import {
+ commentLineOptions,
+ formatLineRange,
+} from '../../notes/components/multiline_comment_utils';
export default {
components: {
@@ -44,8 +47,10 @@ export default {
data() {
return {
commentLineStart: {
- lineCode: this.line.line_code,
+ line_code: this.line.line_code,
type: this.line.type,
+ old_line: this.line.old_line,
+ new_line: this.line.new_line,
},
};
},
@@ -74,19 +79,26 @@ export default {
diffViewType: this.diffViewType,
diffFile: this.diffFile,
linePosition: this.linePosition,
- lineRange: {
- start_line_code: this.commentLineStart.lineCode,
- start_line_type: this.commentLineStart.type,
- end_line_code: this.line.line_code,
- end_line_type: this.line.type,
- },
+ lineRange: formatLineRange(this.commentLineStart, this.line),
};
},
diffFile() {
return this.getDiffFileByHash(this.diffFileHash);
},
commentLineOptions() {
- return commentLineOptions(this.diffFile.highlighted_diff_lines, this.line.line_code);
+ const combineSides = (acc, { left, right }) => {
+ // ignore null values match lines
+ if (left && left.type !== 'match') acc.push(left);
+ // if the line_codes are identically, return to avoid duplicates
+ if (left?.line_code === right?.line_code) return acc;
+ if (right && right.type !== 'match') acc.push(right);
+ return acc;
+ };
+ const side = this.line.type === 'new' ? 'right' : 'left';
+ const lines = this.diffFile.highlighted_diff_lines.length
+ ? this.diffFile.highlighted_diff_lines
+ : this.diffFile.parallel_diff_lines.reduce(combineSides, []);
+ return commentLineOptions(lines, this.line, this.line.line_code, side);
},
},
mounted() {
@@ -136,10 +148,7 @@ export default {
<template>
<div class="content discussion-form discussion-form-container discussion-notes">
- <div
- v-if="glFeatures.multilineComments"
- class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
- >
+ <div v-if="glFeatures.multilineComments" class="gl-mb-3 gl-text-gray-700 gl-pb-3">
<multiline-comment-form
v-model="commentLineStart"
:line="line"
diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue
index 514d26862a3..198113e330a 100644
--- a/app/assets/javascripts/diffs/components/diff_table_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue
@@ -4,15 +4,13 @@ import { GlIcon } from '@gitlab/ui';
import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
import {
- MATCH_LINE_TYPE,
CONTEXT_LINE_TYPE,
LINE_POSITION_RIGHT,
EMPTY_CELL_TYPE,
- OLD_LINE_TYPE,
OLD_NO_NEW_LINE_TYPE,
+ OLD_LINE_TYPE,
NEW_NO_NEW_LINE_TYPE,
LINE_HOVER_CLASS_NAME,
- LINE_UNFOLD_CLASS_NAME,
} from '../constants';
export default {
@@ -29,10 +27,6 @@ export default {
type: String,
required: true,
},
- contextLinesPath: {
- type: String,
- required: true,
- },
isHighlighted: {
type: Boolean,
required: true,
@@ -52,11 +46,6 @@ export default {
required: false,
default: '',
},
- isContentLine: {
- type: Boolean,
- required: false,
- default: false,
- },
isBottom: {
type: Boolean,
required: false,
@@ -68,6 +57,11 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ isCommentButtonRendered: false,
+ };
+ },
computed: {
...mapGetters(['isLoggedIn']),
lineCode() {
@@ -81,13 +75,7 @@ export default {
return `#${this.line.line_code || ''}`;
},
shouldShowCommentButton() {
- return (
- this.isHover &&
- !this.isMatchLine &&
- !this.isContextLine &&
- !this.isMetaLine &&
- !this.hasDiscussions
- );
+ return this.isHover && !this.isContextLine && !this.isMetaLine && !this.hasDiscussions;
},
hasDiscussions() {
return this.line.discussions && this.line.discussions.length > 0;
@@ -99,6 +87,10 @@ export default {
return this.showCommentButton && this.hasDiscussions;
},
shouldRenderCommentButton() {
+ if (!this.isCommentButtonRendered) {
+ return false;
+ }
+
if (this.isLoggedIn && this.showCommentButton) {
const isDiffHead = parseBoolean(getParameterByName('diff_head'));
return !isDiffHead || gon.features?.mergeRefHeadComments;
@@ -106,9 +98,6 @@ export default {
return false;
},
- isMatchLine() {
- return this.line.type === MATCH_LINE_TYPE;
- },
isContextLine() {
return this.line.type === CONTEXT_LINE_TYPE;
},
@@ -126,13 +115,8 @@ export default {
type,
{
hll: this.isHighlighted,
- [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine,
[LINE_HOVER_CLASS_NAME]:
- this.isLoggedIn &&
- this.isHover &&
- !this.isMatchLine &&
- !this.isContextLine &&
- !this.isMetaLine,
+ this.isLoggedIn && this.isHover && !this.isContextLine && !this.isMetaLine,
},
];
},
@@ -140,6 +124,17 @@ export default {
return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line;
},
},
+ mounted() {
+ this.unwatchShouldShowCommentButton = this.$watch('shouldShowCommentButton', newVal => {
+ if (newVal) {
+ this.isCommentButtonRendered = true;
+ this.unwatchShouldShowCommentButton();
+ }
+ });
+ },
+ beforeDestroy() {
+ this.unwatchShouldShowCommentButton();
+ },
methods: {
...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']),
handleCommentButton() {
@@ -151,34 +146,32 @@ export default {
<template>
<td ref="td" :class="classNameMap">
- <div>
- <button
- v-if="shouldRenderCommentButton"
- v-show="shouldShowCommentButton"
- ref="addDiffNoteButton"
- type="button"
- class="add-diff-note js-add-diff-note-button qa-diff-comment"
- title="Add a comment to this line"
- @click="handleCommentButton"
- >
- <gl-icon :size="12" name="comment" />
- </button>
- <a
- v-if="lineNumber"
- ref="lineNumberRef"
- :data-linenumber="lineNumber"
- :href="lineHref"
- @click="setHighlightedRow(lineCode)"
- >
- </a>
- <diff-gutter-avatars
- v-if="shouldShowAvatarsOnGutter"
- :discussions="line.discussions"
- :discussions-expanded="line.discussionsExpanded"
- @toggleLineDiscussions="
- toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded })
- "
- />
- </div>
+ <button
+ v-if="shouldRenderCommentButton"
+ v-show="shouldShowCommentButton"
+ ref="addDiffNoteButton"
+ type="button"
+ class="add-diff-note js-add-diff-note-button qa-diff-comment"
+ title="Add a comment to this line"
+ @click="handleCommentButton"
+ >
+ <gl-icon :size="12" name="comment" />
+ </button>
+ <a
+ v-if="lineNumber"
+ ref="lineNumberRef"
+ :data-linenumber="lineNumber"
+ :href="lineHref"
+ @click="setHighlightedRow(lineCode)"
+ >
+ </a>
+ <diff-gutter-avatars
+ v-if="shouldShowAvatarsOnGutter"
+ :discussions="line.discussions"
+ :discussions-expanded="line.discussionsExpanded"
+ @toggleLineDiscussions="
+ toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded })
+ "
+ />
</td>
</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
index bd99fcb71b8..168e8c6c14e 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -28,10 +28,6 @@ export default {
type: String,
required: true,
},
- contextLinesPath: {
- type: String,
- required: true,
- },
line: {
type: Object,
required: true,
@@ -41,6 +37,11 @@ export default {
required: false,
default: false,
},
+ isCommented: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -51,7 +52,10 @@ export default {
...mapGetters('diffs', ['fileLineCoverage']),
...mapState({
isHighlighted(state) {
- return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow;
+ if (this.isCommented) return true;
+
+ const lineCode = this.line.line_code;
+ return lineCode ? lineCode === state.diffs.highlightedRow : false;
},
}),
isContextLine() {
@@ -106,7 +110,6 @@ export default {
>
<diff-table-cell
:file-hash="fileHash"
- :context-lines-path="contextLinesPath"
:line="line"
:line-type="oldLineType"
:is-bottom="isBottom"
@@ -117,7 +120,6 @@ export default {
/>
<diff-table-cell
:file-hash="fileHash"
- :context-lines-path="contextLinesPath"
:line="line"
:line-type="newLineType"
:is-bottom="isBottom"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index ad72016f03b..e82d06ee385 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -1,10 +1,11 @@
<script>
-import { mapGetters } from 'vuex';
+import { mapGetters, mapState } from 'vuex';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import InlineDraftCommentRow from '~/batch_comments/components/inline_draft_comment_row.vue';
import inlineDiffTableRow from './inline_diff_table_row.vue';
import inlineDiffCommentRow from './inline_diff_comment_row.vue';
import inlineDiffExpansionRow from './inline_diff_expansion_row.vue';
+import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
export default {
components: {
@@ -31,9 +32,19 @@ export default {
},
computed: {
...mapGetters('diffs', ['commitId']),
+ ...mapState({
+ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition,
+ selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover,
+ }),
diffLinesLength() {
return this.diffLines.length;
},
+ commentedLines() {
+ return getCommentedLines(
+ this.selectedCommentPosition || this.selectedCommentPositionHover,
+ this.diffLines,
+ );
+ },
},
userColorScheme: window.gon.user_color_scheme,
};
@@ -65,9 +76,9 @@ export default {
:key="`${line.line_code || index}`"
:file-hash="diffFile.file_hash"
:file-path="diffFile.file_path"
- :context-lines-path="diffFile.context_lines_path"
:line="line"
:is-bottom="index + 1 === diffLinesLength"
+ :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
/>
<inline-diff-comment-row
:key="`icr-${line.line_code || index}`"
diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue
index 94c2695a945..93afa978862 100644
--- a/app/assets/javascripts/diffs/components/no_changes.vue
+++ b/app/assets/javascripts/diffs/components/no_changes.vue
@@ -1,12 +1,12 @@
<script>
import { mapGetters } from 'vuex';
import { escape } from 'lodash';
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
},
props: {
changesEmptyStateIllustration: {
@@ -43,9 +43,9 @@ export default {
<div class="text-content text-center">
<span v-html="emptyStateText"></span>
<div class="text-center">
- <gl-deprecated-button :href="getNoteableData.new_blob_path" variant="success">{{
+ <gl-button :href="getNoteableData.new_blob_path" variant="success" category="primary">{{
__('Create commit')
- }}</gl-deprecated-button>
+ }}</gl-button>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
index 83d803f42b1..ccb32a2a745 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -31,10 +31,6 @@ export default {
type: String,
required: true,
},
- contextLinesPath: {
- type: String,
- required: true,
- },
line: {
type: Object,
required: true,
@@ -44,6 +40,11 @@ export default {
required: false,
default: false,
},
+ isCommented: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -55,6 +56,8 @@ export default {
...mapGetters('diffs', ['fileLineCoverage']),
...mapState({
isHighlighted(state) {
+ if (this.isCommented) return true;
+
const lineCode =
(this.line.left && this.line.left.line_code) ||
(this.line.right && this.line.right.line_code);
@@ -144,7 +147,6 @@ export default {
<template v-if="line.left && !isMatchLineLeft">
<diff-table-cell
:file-hash="fileHash"
- :context-lines-path="contextLinesPath"
:line="line.left"
:line-type="oldLineType"
:is-bottom="isBottom"
@@ -172,7 +174,6 @@ export default {
<template v-if="line.right && !isMatchLineRight">
<diff-table-cell
:file-hash="fileHash"
- :context-lines-path="contextLinesPath"
:line="line.right"
:line-type="newLineType"
:is-bottom="isBottom"
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
index b5fcc50ce26..46a691ad22d 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -1,10 +1,11 @@
<script>
-import { mapGetters } from 'vuex';
+import { mapGetters, mapState } from 'vuex';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import ParallelDraftCommentRow from '~/batch_comments/components/parallel_draft_comment_row.vue';
import parallelDiffTableRow from './parallel_diff_table_row.vue';
import parallelDiffCommentRow from './parallel_diff_comment_row.vue';
import parallelDiffExpansionRow from './parallel_diff_expansion_row.vue';
+import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
export default {
components: {
@@ -31,9 +32,19 @@ export default {
},
computed: {
...mapGetters('diffs', ['commitId']),
+ ...mapState({
+ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition,
+ selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover,
+ }),
diffLinesLength() {
return this.diffLines.length;
},
+ commentedLines() {
+ return getCommentedLines(
+ this.selectedCommentPosition || this.selectedCommentPositionHover,
+ this.diffLines,
+ );
+ },
},
userColorScheme: window.gon.user_color_scheme,
};
@@ -67,9 +78,9 @@ export default {
:key="line.line_code"
:file-hash="diffFile.file_hash"
:file-path="diffFile.file_path"
- :context-lines-path="diffFile.context_lines_path"
:line="line"
:is-bottom="index + 1 === diffLinesLength"
+ :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
/>
<parallel-diff-comment-row
:key="`dcr-${line.line_code || index}`"
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 52611f3c82a..38fbd8e61d4 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -26,7 +26,7 @@ export default {
};
},
computed: {
- ...mapState('diffs', ['tree', 'renderTreeList']),
+ ...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId']),
...mapGetters('diffs', ['allBlobs']),
filteredTreeList() {
const search = this.search.toLowerCase().trim();
@@ -96,6 +96,7 @@ export default {
:level="0"
:hide-file-stats="hideFileStats"
:file-row-component="$options.DiffFileRow"
+ :current-diff-file-id="currentDiffFileId"
@toggleTreeOpen="toggleTreeOpen"
@clickFile="scrollToFile"
/>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 9269dacd582..e3dd882f3dc 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -1,3 +1,7 @@
+// The backend actually uses "hide_whitespace" while the frontend
+// uses "show whitspace" so these values are opposite what you might expect
+export const NO_SHOW_WHITESPACE = '1';
+export const SHOW_WHITESPACE = '0';
export const INLINE_DIFF_VIEW_TYPE = 'inline';
export const PARALLEL_DIFF_VIEW_TYPE = 'parallel';
export const MATCH_LINE_TYPE = 'match';
@@ -20,6 +24,7 @@ export const LINE_SIDE_LEFT = 'left-side';
export const LINE_SIDE_RIGHT = 'right-side';
export const DIFF_VIEW_COOKIE_NAME = 'diff_view';
+export const DIFF_WHITESPACE_COOKIE_NAME = 'diff_whitespace';
export const LINE_HOVER_CLASS_NAME = 'is-over';
export const LINE_UNFOLD_CLASS_NAME = 'unfold js-unfold';
export const CONTEXT_LINE_CLASS_NAME = 'diff-expanded';
@@ -35,7 +40,6 @@ export const MR_TREE_SHOW_KEY = 'mr_tree_show';
export const TREE_TYPE = 'tree';
export const TREE_LIST_STORAGE_KEY = 'mr_diff_tree_list';
-export const WHITESPACE_STORAGE_KEY = 'mr_show_whitespace';
export const TREE_LIST_WIDTH_STORAGE_KEY = 'mr_tree_list_width';
export const INITIAL_TREE_WIDTH = 320;
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index ce48e36bfd7..76ff67ab861 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -1,11 +1,11 @@
import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
-import { getParameterValues } from '~/lib/utils/url_utility';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
import eventHub from '../notes/event_hub';
import diffsApp from './components/app.vue';
-import { TREE_LIST_STORAGE_KEY } from './constants';
+import { TREE_LIST_STORAGE_KEY, DIFF_WHITESPACE_COOKIE_NAME } from './constants';
+import Cookies from 'js-cookie';
export default function initDiffsApp(store) {
const fileFinderEl = document.getElementById('js-diff-file-finder');
@@ -78,6 +78,7 @@ export default function initDiffsApp(store) {
dismissEndpoint: dataset.dismissEndpoint,
showSuggestPopover: parseBoolean(dataset.showSuggestPopover),
showWhitespaceDefault: parseBoolean(dataset.showWhitespaceDefault),
+ viewDiffsFileByFile: parseBoolean(dataset.fileByFileDefault),
};
},
computed: {
@@ -86,15 +87,16 @@ export default function initDiffsApp(store) {
}),
},
created() {
- let hideWhitespace = getParameterValues('w')[0];
const treeListStored = localStorage.getItem(TREE_LIST_STORAGE_KEY);
const renderTreeList = treeListStored !== null ? parseBoolean(treeListStored) : true;
this.setRenderTreeList(renderTreeList);
- if (!hideWhitespace) {
- hideWhitespace = this.showWhitespaceDefault ? '0' : '1';
+
+ // Set whitespace default as per user preferences unless cookie is already set
+ if (!Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)) {
+ const hideWhitespace = this.showWhitespaceDefault ? '0' : '1';
+ this.setShowWhitespace({ showWhitespace: hideWhitespace !== '1' });
}
- this.setShowWhitespace({ showWhitespace: hideWhitespace !== '1' });
},
methods: {
...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']),
@@ -114,7 +116,7 @@ export default function initDiffsApp(store) {
isFluidLayout: this.isFluidLayout,
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
- showWhitespaceDefault: this.showWhitespaceDefault,
+ viewDiffsFileByFile: this.viewDiffsFileByFile,
},
});
},
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index a8d348e1836..d469ed8ee82 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -25,7 +25,6 @@ import {
DIFF_VIEW_COOKIE_NAME,
MR_TREE_SHOW_KEY,
TREE_LIST_STORAGE_KEY,
- WHITESPACE_STORAGE_KEY,
TREE_LIST_WIDTH_STORAGE_KEY,
OLD_LINE_KEY,
NEW_LINE_KEY,
@@ -38,6 +37,9 @@ import {
INLINE_DIFF_LINES_KEY,
PARALLEL_DIFF_LINES_KEY,
DIFFS_PER_PAGE,
+ DIFF_WHITESPACE_COOKIE_NAME,
+ SHOW_WHITESPACE,
+ NO_SHOW_WHITESPACE,
} from '../constants';
import { diffViewerModes } from '~/ide/constants';
@@ -103,7 +105,9 @@ export const fetchDiffFiles = ({ state, commit }) => {
.catch(() => worker.terminate());
};
-export const fetchDiffFilesBatch = ({ commit, state }) => {
+export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
+ const id = window?.location?.hash;
+ const isNoteLink = id.indexOf('#note') === 0;
const urlParams = {
per_page: DIFFS_PER_PAGE,
w: state.showWhitespace ? '0' : '1',
@@ -123,16 +127,36 @@ export const fetchDiffFilesBatch = ({ commit, state }) => {
commit(types.SET_DIFF_DATA_BATCH, { diff_files });
commit(types.SET_BATCH_LOADING, false);
+ if (!isNoteLink && !state.currentDiffFileId) {
+ commit(types.UPDATE_CURRENT_DIFF_FILE_ID, diff_files[0].file_hash);
+ }
+
+ if (isNoteLink) {
+ dispatch('setCurrentDiffFileIdFromNote', id.split('_').pop());
+ }
+
if (!pagination.next_page) {
commit(types.SET_RETRIEVING_BATCHES, false);
+
+ // We need to check that the currentDiffFileId points to a file that exists
+ if (
+ state.currentDiffFileId &&
+ !state.diffFiles.some(f => f.file_hash === state.currentDiffFileId) &&
+ !isNoteLink
+ ) {
+ commit(types.UPDATE_CURRENT_DIFF_FILE_ID, state.diffFiles[0].file_hash);
+ }
+
if (gon.features?.codeNavigation) {
// eslint-disable-next-line promise/catch-or-return,promise/no-nesting
import('~/code_navigation').then(m =>
m.default({
- blobs: state.diffFiles.map(f => ({
- path: f.new_path,
- codeNavigationPath: f.code_navigation_path,
- })),
+ blobs: state.diffFiles
+ .filter(f => f.code_navigation_path)
+ .map(f => ({
+ path: f.new_path,
+ codeNavigationPath: f.code_navigation_path,
+ })),
definitionPathPrefix: state.definitionPathPrefix,
}),
);
@@ -211,9 +235,11 @@ export const setHighlightedRow = ({ commit }, lineCode) => {
// This is adding line discussions to the actual lines in the diff tree
// once for parallel and once for inline mode
export const assignDiscussionsToDiff = (
- { commit, state, rootState },
+ { commit, state, rootState, dispatch },
discussions = rootState.notes.discussions,
) => {
+ const id = window?.location?.hash;
+ const isNoteLink = id.indexOf('#note') === 0;
const diffPositionByLineCode = getDiffPositionByLineCode(
state.diffFiles,
state.useSingleDiffStyle,
@@ -230,6 +256,10 @@ export const assignDiscussionsToDiff = (
});
});
+ if (isNoteLink) {
+ dispatch('setCurrentDiffFileIdFromNote', id.split('_').pop());
+ }
+
Vue.nextTick(() => {
eventHub.$emit('scrollToDiscussion');
});
@@ -448,6 +478,8 @@ export const toggleTreeOpen = ({ commit }, path) => {
};
export const scrollToFile = ({ state, commit }, path) => {
+ if (!state.treeEntries[path]) return;
+
const { fileHash } = state.treeEntries[path];
document.location.hash = fileHash;
@@ -484,11 +516,12 @@ export const setRenderTreeList = ({ commit }, renderTreeList) => {
export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = false }) => {
commit(types.SET_SHOW_WHITESPACE, showWhitespace);
+ const w = showWhitespace ? SHOW_WHITESPACE : NO_SHOW_WHITESPACE;
- localStorage.setItem(WHITESPACE_STORAGE_KEY, showWhitespace);
+ Cookies.set(DIFF_WHITESPACE_COOKIE_NAME, w);
if (pushState) {
- historyPushState(mergeUrlParams({ w: showWhitespace ? '0' : '1' }, window.location.href));
+ historyPushState(mergeUrlParams({ w }, window.location.href));
}
eventHub.$emit('refetchDiffData');
@@ -710,5 +743,22 @@ export function moveToNeighboringCommit({ dispatch, state }, { direction }) {
}
}
+export const setCurrentDiffFileIdFromNote = ({ commit, rootGetters }, noteId) => {
+ const note = rootGetters.notesById[noteId];
+
+ if (!note) return;
+
+ const fileHash = rootGetters.getDiscussion(note.discussion_id).diff_file.file_hash;
+
+ commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash);
+};
+
+export const navigateToDiffFileIndex = ({ commit, state }, index) => {
+ const fileHash = state.diffFiles[index].file_hash;
+ document.location.hash = fileHash;
+
+ commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash);
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 87938ababed..1f165dd4971 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -1,10 +1,17 @@
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
-import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
+import {
+ INLINE_DIFF_VIEW_TYPE,
+ DIFF_VIEW_COOKIE_NAME,
+ DIFF_WHITESPACE_COOKIE_NAME,
+} from '../../constants';
+import { getDefaultWhitespace } from '../utils';
const viewTypeFromQueryString = getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
+const whiteSpaceFromQueryString = getParameterValues('w')[0];
+const whiteSpaceFromCookie = Cookies.get(DIFF_WHITESPACE_COOKIE_NAME);
export default () => ({
isLoading: true,
@@ -29,7 +36,7 @@ export default () => ({
commentForms: [],
highlightedRow: null,
renderTreeList: true,
- showWhitespace: true,
+ showWhitespace: getDefaultWhitespace(whiteSpaceFromQueryString, whiteSpaceFromCookie),
fileFinderVisible: false,
dismissEndpoint: '',
showSuggestPopover: true,
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index d261be1b550..bc85dd0a1d4 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -15,6 +15,8 @@ import {
TREE_TYPE,
INLINE_DIFF_VIEW_TYPE,
PARALLEL_DIFF_VIEW_TYPE,
+ SHOW_WHITESPACE,
+ NO_SHOW_WHITESPACE,
} from '../constants';
export function findDiffFile(files, match, matchKey = 'file_hash') {
@@ -701,3 +703,10 @@ export const allDiscussionWrappersExpanded = diff => {
return discussionsExpanded;
};
+
+export const getDefaultWhitespace = (queryString, cookie) => {
+ // Querystring should override stored cookie value
+ if (queryString) return queryString === SHOW_WHITESPACE;
+ if (cookie === NO_SHOW_WHITESPACE) return false;
+ return true;
+};
diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js
index 020ed6dc867..551ffbabaef 100644
--- a/app/assets/javascripts/editor/editor_lite.js
+++ b/app/assets/javascripts/editor/editor_lite.js
@@ -1,4 +1,4 @@
-import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
+import { editor as monacoEditor, languages as monacoLanguages, Position, Uri } from 'monaco-editor';
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
import languages from '~/ide/lib/languages';
import { defaultEditorOptions } from '~/ide/lib/editor_options';
@@ -70,6 +70,27 @@ export default class Editor {
}
getValue() {
- return this.model.getValue();
+ return this.instance.getValue();
+ }
+
+ setValue(val) {
+ this.instance.setValue(val);
+ }
+
+ focus() {
+ this.instance.focus();
+ }
+
+ navigateFileStart() {
+ this.instance.setPosition(new Position(1, 1));
+ }
+
+ updateOptions(options = {}) {
+ this.instance.updateOptions(options);
+ }
+
+ use(exts = []) {
+ const extensions = Array.isArray(exts) ? exts : [exts];
+ Object.assign(this, ...extensions);
}
}
diff --git a/app/assets/javascripts/editor/editor_markdown_ext.js b/app/assets/javascripts/editor/editor_markdown_ext.js
new file mode 100644
index 00000000000..9d09663e643
--- /dev/null
+++ b/app/assets/javascripts/editor/editor_markdown_ext.js
@@ -0,0 +1,99 @@
+export default {
+ getSelectedText(selection = this.getSelection()) {
+ const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
+ const valArray = this.instance.getValue().split('\n');
+ let text = '';
+ if (startLineNumber === endLineNumber) {
+ text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1);
+ } else {
+ const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1);
+ const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1);
+
+ for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) {
+ text += `${valArray[i]}`;
+ if (i !== k - 1) text += `\n`;
+ }
+ text = text
+ ? [startLineText, text, endLineText].join('\n')
+ : [startLineText, endLineText].join('\n');
+ }
+ return text;
+ },
+
+ getSelection() {
+ return this.instance.getSelection();
+ },
+
+ replaceSelectedText(text, select = undefined) {
+ const forceMoveMarkers = !select;
+ this.instance.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]);
+ },
+
+ moveCursor(dx = 0, dy = 0) {
+ const pos = this.instance.getPosition();
+ pos.column += dx;
+ pos.lineNumber += dy;
+ this.instance.setPosition(pos);
+ },
+
+ /**
+ * Adjust existing selection to select text within the original selection.
+ * - If `selectedText` is not supplied, we fetch selected text with
+ *
+ * ALGORITHM:
+ *
+ * MULTI-LINE SELECTION
+ * 1. Find line that contains `toSelect` text.
+ * 2. Using the index of this line and the position of `toSelect` text in it,
+ * construct:
+ * * newStartLineNumber
+ * * newStartColumn
+ *
+ * SINGLE-LINE SELECTION
+ * 1. Use `startLineNumber` from the current selection as `newStartLineNumber`
+ * 2. Find the position of `toSelect` text in it to get `newStartColumn`
+ *
+ * 3. `newEndLineNumber` — Since this method is supposed to be used with
+ * markdown decorators that are pretty short, the `newEndLineNumber` is
+ * suggested to be assumed the same as the startLine.
+ * 4. `newEndColumn` — pretty obvious
+ * 5. Adjust the start and end positions of the current selection
+ * 6. Re-set selection on the instance
+ *
+ * @param {string} toSelect - New text to select within current selection.
+ * @param {string} selectedText - Currently selected text. It's just a
+ * shortcut: If it's not supplied, we fetch selected text from the instance
+ */
+ selectWithinSelection(toSelect, selectedText) {
+ const currentSelection = this.getSelection();
+ if (currentSelection.isEmpty() || !toSelect) {
+ return;
+ }
+ const text = selectedText || this.getSelectedText(currentSelection);
+ let lineShift;
+ let newStartLineNumber;
+ let newStartColumn;
+
+ const textLines = text.split('\n');
+
+ if (textLines.length > 1) {
+ // Multi-line selection
+ lineShift = textLines.findIndex(line => line.indexOf(toSelect) !== -1);
+ newStartLineNumber = currentSelection.startLineNumber + lineShift;
+ newStartColumn = textLines[lineShift].indexOf(toSelect) + 1;
+ } else {
+ // Single-line selection
+ newStartLineNumber = currentSelection.startLineNumber;
+ newStartColumn = currentSelection.startColumn + text.indexOf(toSelect);
+ }
+
+ const newEndLineNumber = newStartLineNumber;
+ const newEndColumn = newStartColumn + toSelect.length;
+
+ const newSelection = currentSelection
+ .setStartPosition(newStartLineNumber, newStartColumn)
+ .setEndPosition(newEndLineNumber, newEndColumn);
+
+ this.instance.setSelection(newSelection);
+ },
+};
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index 27dff8cf9aa..4567c807c40 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -1,13 +1,63 @@
import { uniq } from 'lodash';
-import emojiMap from 'emojis/digests.json';
import emojiAliases from 'emojis/aliases.json';
+import axios from '../lib/utils/axios_utils';
-export const validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
+import AccessorUtilities from '../lib/utils/accessor';
+
+let emojiMap = null;
+let emojiPromise = null;
+let validEmojiNames = null;
+
+export const EMOJI_VERSION = '1';
+
+const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+
+export function initEmojiMap() {
+ emojiPromise =
+ emojiPromise ||
+ new Promise((resolve, reject) => {
+ if (emojiMap) {
+ resolve(emojiMap);
+ } else if (
+ isLocalStorageAvailable &&
+ window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
+ window.localStorage.getItem('gl-emoji-map')
+ ) {
+ emojiMap = JSON.parse(window.localStorage.getItem('gl-emoji-map'));
+ validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
+ resolve(emojiMap);
+ } else {
+ // We load the JSON file direct from the server
+ // because it can't be loaded from a CDN due to
+ // cross domain problems with JSON
+ axios
+ .get(`${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`)
+ .then(({ data }) => {
+ emojiMap = data;
+ validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
+ resolve(emojiMap);
+ if (isLocalStorageAvailable) {
+ window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION);
+ window.localStorage.setItem('gl-emoji-map', JSON.stringify(emojiMap));
+ }
+ })
+ .catch(err => {
+ reject(err);
+ });
+ }
+ });
+
+ return emojiPromise;
+}
export function normalizeEmojiName(name) {
return Object.prototype.hasOwnProperty.call(emojiAliases, name) ? emojiAliases[name] : name;
}
+export function getValidEmojiNames() {
+ return validEmojiNames;
+}
+
export function isEmojiNameValid(name) {
return validEmojiNames.indexOf(name) >= 0;
}
@@ -36,8 +86,8 @@ export function getEmojiCategoryMap() {
};
Object.keys(emojiMap).forEach(name => {
const emoji = emojiMap[name];
- if (emojiCategoryMap[emoji.category]) {
- emojiCategoryMap[emoji.category].push(name);
+ if (emojiCategoryMap[emoji.c]) {
+ emojiCategoryMap[emoji.c].push(name);
}
});
}
@@ -58,8 +108,9 @@ export function getEmojiInfo(query) {
}
export function emojiFallbackImageSrc(inputName) {
- const { name, digest } = getEmojiInfo(inputName);
- return `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/emoji/${name}-${digest}.png`;
+ const { name } = getEmojiInfo(inputName);
+ return `${gon.asset_host || ''}${gon.relative_url_root ||
+ ''}/-/emojis/${EMOJI_VERSION}/${name}.png`;
}
export function emojiImageTag(name, src) {
@@ -67,36 +118,17 @@ export function emojiImageTag(name, src) {
}
export function glEmojiTag(inputName, options) {
- const opts = { sprite: false, forceFallback: false, ...options };
- const { name, ...emojiInfo } = getEmojiInfo(inputName);
-
- const fallbackImageSrc = emojiFallbackImageSrc(name);
+ const opts = { sprite: false, ...options };
+ const name = normalizeEmojiName(inputName);
const fallbackSpriteClass = `emoji-${name}`;
- const classList = [];
- if (opts.forceFallback && opts.sprite) {
- classList.push('emoji-icon');
- classList.push(fallbackSpriteClass);
- }
- const classAttribute = classList.length > 0 ? `class="${classList.join(' ')}"` : '';
const fallbackSpriteAttribute = opts.sprite
? `data-fallback-sprite-class="${fallbackSpriteClass}"`
: '';
- let contents = emojiInfo.moji;
- if (opts.forceFallback && !opts.sprite) {
- contents = emojiImageTag(name, fallbackImageSrc);
- }
return `
<gl-emoji
- ${classAttribute}
- data-name="${name}"
- data-fallback-src="${fallbackImageSrc}"
${fallbackSpriteAttribute}
- data-unicode-version="${emojiInfo.unicodeVersion}"
- title="${emojiInfo.description}"
- >
- ${contents}
- </gl-emoji>
+ data-name="${name}"></gl-emoji>
`;
}
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index 899d7ec8521..4c6d233c4d2 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -67,12 +67,7 @@ export default {
<template>
<div class="environments-container">
- <gl-loading-icon
- v-if="isLoading"
- size="md"
- class="prepend-top-default"
- label="Loading environments"
- />
+ <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" label="Loading environments" />
<slot name="emptyState"></slot>
diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue
index ca2ac4c3c53..977da12e8a9 100644
--- a/app/assets/javascripts/environments/components/empty_state.vue
+++ b/app/assets/javascripts/environments/components/empty_state.vue
@@ -2,14 +2,6 @@
export default {
name: 'EnvironmentsEmptyState',
props: {
- newPath: {
- type: String,
- required: true,
- },
- canCreateEnvironment: {
- type: Boolean,
- required: true,
- },
helpPath: {
type: String,
required: true,
@@ -28,18 +20,8 @@ export default {
s__(`Environments|Environments are places where
code gets deployed, such as staging or production.`)
}}
- <a :href="helpPath"> {{ s__('Environments|Read more about environments') }} </a>
+ <a :href="helpPath"> {{ s__('Environments|More information') }} </a>
</p>
-
- <div class="text-center">
- <a
- v-if="canCreateEnvironment"
- :href="newPath"
- class="btn btn-success js-new-environment-button"
- >
- {{ s__('Environments|New environment') }}
- </a>
- </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index b8bcca814cd..d26bd14a937 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -159,11 +159,7 @@ export default {
@onChangePage="onChangePage"
>
<template v-if="!isLoading && state.environments.length === 0" #emptyState>
- <empty-state
- :new-path="newEnvironmentPath"
- :help-path="helpPagePath"
- :can-create-environment="canCreateEnvironment"
- />
+ <empty-state :help-path="helpPagePath" />
</template>
</container>
</div>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 380e16c7b71..ab1818e61fa 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -188,7 +188,7 @@ export default {
<template v-if="shouldRenderFolderContent(model)">
<div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`">
- <gl-loading-icon size="md" class="prepend-top-16" />
+ <gl-loading-icon size="md" class="gl-mt-5" />
</div>
<template v-else>
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index 73dc8c02485..f2b464464e9 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -145,7 +145,7 @@ export default {
deleteEnvironment(environment) {
const endpoint = environment.delete_path;
- const mountedToShow = environment.mounted_to_show;
+ const { onSingleEnvironmentPage } = environment;
const errorMessage = s__(
'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.',
);
@@ -153,7 +153,7 @@ export default {
this.service
.deleteAction(endpoint)
.then(() => {
- if (!mountedToShow) {
+ if (!onSingleEnvironmentPage) {
// Reload as a first solution to bust the ETag cache
window.location.reload();
return;
diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js
index 1929ed080a1..d0b68b0c14f 100644
--- a/app/assets/javascripts/environments/mount_show.js
+++ b/app/assets/javascripts/environments/mount_show.js
@@ -15,7 +15,7 @@ export default () => {
data() {
const environment = JSON.parse(JSON.stringify(container.dataset));
environment.delete_path = environment.deletePath;
- environment.mounted_to_show = true;
+ environment.onSingleEnvironmentPage = true;
return {
environment,
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 1e8f5a26125..52444d2c493 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -2,7 +2,7 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import createFlash from '~/flash';
import {
- GlDeprecatedButton,
+ GlButton,
GlFormInput,
GlLink,
GlLoadingIcon,
@@ -33,7 +33,7 @@ const SENTRY_TIMEOUT = 10000;
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
GlFormInput,
GlLink,
GlLoadingIcon,
@@ -106,6 +106,7 @@ export default {
errorPollTimeout: 0,
issueCreationInProgress: false,
isAlertVisible: false,
+ isStacktraceEmptyAlertVisible: true,
closedIssueId: null,
};
},
@@ -119,10 +120,10 @@ export default {
]),
...mapGetters('details', ['stacktrace']),
firstReleaseLink() {
- return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseShortVersion}`;
+ return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseVersion}`;
},
lastReleaseLink() {
- return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseShortVersion}`;
+ return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`;
},
showStacktrace() {
return Boolean(this.stacktrace?.length);
@@ -167,6 +168,9 @@ export default {
resolveBtnLabel() {
return this.errorStatus !== errorStatus.RESOLVED ? __('Resolve') : __('Unresolve');
},
+ showEmptyStacktraceAlert() {
+ return !this.loadingStacktrace && !this.showStacktrace && this.isStacktraceEmptyAlertVisible;
+ },
},
watch: {
error(val) {
@@ -254,6 +258,10 @@ export default {
</gl-sprintf>
</gl-alert>
+ <gl-alert v-if="showEmptyStacktraceAlert" @dismiss="isStacktraceEmptyAlertVisible = false">
+ {{ __('No stack trace for this error') }}
+ </gl-alert>
+
<div class="error-details-header d-flex py-2 justify-content-between">
<div
v-if="!loadingStacktrace && stacktrace"
@@ -271,22 +279,24 @@ export default {
</div>
<div class="error-details-actions">
<div class="d-inline-flex bv-d-sm-down-none">
- <gl-deprecated-button
+ <gl-button
:loading="updatingIgnoreStatus"
data-testid="update-ignore-status-btn"
@click="onIgnoreStatusUpdate"
>
{{ ignoreBtnLabel }}
- </gl-deprecated-button>
- <gl-deprecated-button
- class="btn-outline-info ml-2"
+ </gl-button>
+ <gl-button
+ class="ml-2"
+ category="secondary"
+ variant="info"
:loading="updatingResolveStatus"
data-testid="update-resolve-status-btn"
@click="onResolveStatusUpdate"
>
{{ resolveBtnLabel }}
- </gl-deprecated-button>
- <gl-deprecated-button
+ </gl-button>
+ <gl-button
v-if="error.gitlabIssuePath"
class="ml-2"
data-testid="view_issue_button"
@@ -294,7 +304,7 @@ export default {
variant="success"
>
{{ __('View issue') }}
- </gl-deprecated-button>
+ </gl-button>
<form
ref="sentryIssueForm"
:action="projectIssuesPath"
@@ -309,15 +319,16 @@ export default {
name="issue[sentry_issue_attributes][sentry_issue_identifier]"
/>
<gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" />
- <gl-deprecated-button
+ <gl-button
v-if="!error.gitlabIssuePath"
- class="btn-success"
+ category="primary"
+ variant="success"
:loading="issueCreationInProgress"
data-qa-selector="create_issue_button"
@click="createIssue"
>
{{ __('Create issue') }}
- </gl-deprecated-button>
+ </gl-button>
</form>
</div>
<gl-dropdown
@@ -389,18 +400,18 @@ export default {
<icon name="external-link" class="ml-1 flex-shrink-0" />
</gl-link>
</li>
- <li v-if="error.firstReleaseShortVersion">
+ <li v-if="error.firstReleaseVersion">
<strong class="bold">{{ __('First seen') }}:</strong>
<time-ago-tooltip :time="error.firstSeen" />
<gl-link :href="firstReleaseLink" target="_blank">
- <span>{{ __('Release') }}: {{ error.firstReleaseShortVersion.substr(0, 10) }}</span>
+ <span>{{ __('Release') }}: {{ error.firstReleaseVersion }}</span>
</gl-link>
</li>
- <li v-if="error.lastReleaseShortVersion">
+ <li v-if="error.lastReleaseVersion">
<strong class="bold">{{ __('Last seen') }}:</strong>
<time-ago-tooltip :time="error.lastSeen" />
<gl-link :href="lastReleaseLink" target="_blank">
- <span>{{ __('Release') }}: {{ error.lastReleaseShortVersion.substr(0, 10) }}</span>
+ <span>{{ __('Release') }}: {{ error.lastReleaseVersion }}</span>
</gl-link>
</li>
<li>
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index d806c6934a3..c22f34b5a8d 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -80,14 +80,9 @@ export default {
<div ref="header" class="file-title file-title-flex-parent">
<div class="file-header-content d-flex align-content-center">
<div v-if="hasCode" class="d-inline-block cursor-pointer" @click="toggle()">
- <icon :name="collapseIcon" :size="16" aria-hidden="true" class="append-right-5" />
+ <icon :name="collapseIcon" :size="16" aria-hidden="true" class="gl-mr-2" />
</div>
- <file-icon
- :file-name="filePath"
- :size="18"
- aria-hidden="true"
- css-classes="append-right-5"
- />
+ <file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" />
<strong
v-gl-tooltip
:title="filePath"
diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql
index fa579c94257..593cbf2ae52 100644
--- a/app/assets/javascripts/error_tracking/queries/details.query.graphql
+++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql
@@ -1,29 +1,29 @@
query errorDetails($fullPath: ID!, $errorId: ID!) {
- project(fullPath: $fullPath) {
- sentryErrors {
- detailedError(id: $errorId) {
- id
- sentryId
- title
- userCount
- count
- status
- firstSeen
- lastSeen
- message
- culprit
- tags {
- level
- logger
- }
- externalUrl
- externalBaseUrl
- firstReleaseShortVersion
- lastReleaseShortVersion
- gitlabCommit
- gitlabCommitPath
- gitlabIssuePath
- }
+ project(fullPath: $fullPath) {
+ sentryErrors {
+ detailedError(id: $errorId) {
+ id
+ sentryId
+ title
+ userCount
+ count
+ status
+ firstSeen
+ lastSeen
+ message
+ culprit
+ tags {
+ level
+ logger
}
+ externalUrl
+ externalBaseUrl
+ firstReleaseVersion
+ lastReleaseVersion
+ gitlabCommit
+ gitlabCommitPath
+ gitlabIssuePath
+ }
}
+ }
}
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
index 0be42519092..0de67a8bcc7 100644
--- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -59,14 +59,14 @@ export default {
</div>
<div class="col-4 col-md-3 gl-pl-0">
<loading-button
- class="js-error-tracking-connect prepend-left-5 d-inline-flex"
+ class="js-error-tracking-connect gl-ml-2 d-inline-flex"
:label="isLoadingProjects ? __('Connecting') : __('Connect')"
:loading="isLoadingProjects"
@click="fetchProjects"
/>
<icon
v-show="connectSuccessful"
- class="js-error-tracking-connect-success prepend-left-5 text-success align-middle"
+ class="js-error-tracking-connect-success gl-ml-2 text-success align-middle"
:aria-label="__('Projects Successfully Retrieved')"
name="check-circle"
/>
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index 7e7a2588951..0b9fe969da1 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -9,3 +9,5 @@ export const FILTER_TYPE = {
none: 'none',
any: 'any',
};
+
+export const MAX_HISTORY_SIZE = 5;
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index dad188f6f98..adeea0ed5f6 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -10,7 +10,7 @@ export default class DropdownEmoji extends FilteredSearchDropdown {
super(options);
this.config = {
Ajax: {
- endpoint: `${gon.relative_url_root || ''}/autocomplete/award_emojis`,
+ endpoint: `${gon.relative_url_root || ''}/-/autocomplete/award_emojis`,
method: 'setData',
loadingTemplate: this.loadingTemplate,
onError() {
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index a65c0012b4d..0fb1828fc98 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -5,7 +5,7 @@ export default class DropdownUser extends DropdownAjaxFilter {
constructor(options = {}) {
super({
...options,
- endpoint: '/autocomplete/users.json',
+ endpoint: '/-/autocomplete/users.json',
symbol: '@',
});
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 55a0e91b0f3..108cc8d3a78 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -32,6 +32,7 @@ export default class FilteredSearchManager {
filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys,
stateFiltersSelector = '.issues-state-filters',
placeholder = __('Search or filter results...'),
+ anchor = null,
}) {
this.isGroup = isGroup;
this.isGroupAncestor = isGroupAncestor;
@@ -47,6 +48,7 @@ export default class FilteredSearchManager {
this.filteredSearchTokenKeys = filteredSearchTokenKeys;
this.stateFiltersSelector = stateFiltersSelector;
this.placeholder = placeholder;
+ this.anchor = anchor;
const { multipleAssignees } = this.filteredSearchInput.dataset;
if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) {
@@ -779,7 +781,11 @@ export default class FilteredSearchManager {
paths.push(`search=${sanitized}`);
}
- const parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`;
+ let parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`;
+
+ if (this.anchor) {
+ parameterizedUrl += `#${this.anchor}`;
+ }
if (this.updateObject) {
this.updateObject(parameterizedUrl);
diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
index 963e8fe5df5..be0fb5cac13 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
@@ -6,7 +6,7 @@ export default class FilteredSearchTokenizer {
// Values that start with a double quote must end in a double quote (same for single)
const tokenRegex = new RegExp(
- `(${allowedKeys.join('|')}):(=|!=)?([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`,
+ `(${allowedKeys.join('|')}):(=|!=)?([~%@&]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`,
'g',
);
const tokens = [];
@@ -15,17 +15,19 @@ export default class FilteredSearchTokenizer {
const searchToken =
input
.replace(tokenRegex, (match, key, operator, symbol, v1, v2, v3) => {
+ const prefixedTokens = ['~', '%', '@', '&'];
+ const comparisonTokens = ['!=', '='];
let tokenValue = v1 || v2 || v3;
let tokenSymbol = symbol;
let tokenIndex = '';
let tokenOperator = operator;
- if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') {
+ if (prefixedTokens.includes(tokenValue)) {
tokenSymbol = tokenValue;
tokenValue = '';
}
- if (tokenValue === '!=' || tokenValue === '=') {
+ if (comparisonTokens.includes(tokenValue)) {
tokenOperator = tokenValue;
tokenValue = '';
}
diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
index cdbc9ec84bd..423f123f71c 100644
--- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
+++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
@@ -1,4 +1,6 @@
-import { uniq } from 'lodash';
+import { uniqWith, isEqual } from 'lodash';
+
+import { MAX_HISTORY_SIZE } from '../constants';
class RecentSearchesStore {
constructor(initialState = {}, allowedKeys) {
@@ -17,8 +19,12 @@ class RecentSearchesStore {
}
setRecentSearches(searches = []) {
- const trimmedSearches = searches.map(search => search.trim());
- this.state.recentSearches = uniq(trimmedSearches).slice(0, 5);
+ const trimmedSearches = searches.map(search =>
+ typeof search === 'string' ? search.trim() : search,
+ );
+
+ // Do object equality check to remove duplicates.
+ this.state.recentSearches = uniqWith(trimmedSearches, isEqual).slice(0, MAX_HISTORY_SIZE);
return this.state.recentSearches;
}
}
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index 02caf0851af..b1e6c4142e9 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -7,6 +7,7 @@ import DropdownUtils from '~/filtered_search/dropdown_utils';
import Flash from '~/flash';
import UsersCache from '~/lib/utils/users_cache';
import { __ } from '~/locale';
+import * as Emoji from '~/emoji';
export default class VisualTokenValue {
constructor(tokenValue, tokenType, tokenOperator) {
@@ -137,18 +138,13 @@ export default class VisualTokenValue {
const element = tokenValueElement;
const value = this.tokenValue;
- return (
- import(/* webpackChunkName: 'emoji' */ '../emoji')
- .then(Emoji => {
- if (!Emoji.isEmojiNameValid(value)) {
- return;
- }
+ return Emoji.initEmojiMap().then(() => {
+ if (!Emoji.isEmojiNameValid(value)) {
+ return;
+ }
- container.dataset.originalValue = value;
- element.innerHTML = Emoji.glEmojiTag(value);
- })
- // ignore error and leave emoji name in the search bar
- .catch(() => {})
- );
+ container.dataset.originalValue = value;
+ element.innerHTML = Emoji.glEmojiTag(value);
+ });
}
}
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index f3ce30c942f..36c586ddfd2 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -5,6 +5,7 @@ import SidebarMediator from '~/sidebar/sidebar_mediator';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
import { spriteIcon } from './lib/utils/common_utils';
+import * as Emoji from '~/emoji';
function sanitize(str) {
return str.replace(/<(?:.|\n)*?>/gm, '');
@@ -29,7 +30,7 @@ export function membersBeforeSave(members) {
const imgAvatar = `<img src="${member.avatar_url}" alt="${member.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`;
const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`;
const avatarIcon = member.mentionsDisabled
- ? spriteIcon('notifications-off', 's16 vertical-align-middle prepend-left-5')
+ ? spriteIcon('notifications-off', 's16 vertical-align-middle gl-ml-2')
: '';
return {
@@ -88,7 +89,6 @@ class GfmAutoComplete {
if (this.enableMap.labels) this.setupLabels($input);
if (this.enableMap.snippets) this.setupSnippets($input);
- // We don't instantiate the quick actions autocomplete for note and issue/MR edit forms
$input.filter('[data-supports-quick-actions="true"]').atwho({
at: '/',
alias: 'commands',
@@ -109,8 +109,10 @@ class GfmAutoComplete {
tpl += ' <small class="params"><%- params.join(" ") %></small>';
}
if (value.warning && value.icon && value.icon === 'confidential') {
- tpl +=
- '<small class="description"><em><i class="fa fa-eye-slash" aria-hidden="true"/><%- warning %></em></small>';
+ tpl += `<small class="description gl-display-flex gl-align-items-center">${spriteIcon(
+ 'eye-slash',
+ 's16 gl-mr-2',
+ )}<em><%- warning %></em></small>`;
} else if (value.warning) {
tpl += '<small class="description"><em><%- warning %></em></small>';
} else if (value.description !== '') {
@@ -587,14 +589,12 @@ class GfmAutoComplete {
if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
- import(/* webpackChunkName: 'emoji' */ './emoji')
- .then(({ validEmojiNames, glEmojiTag }) => {
- this.loadData($input, at, validEmojiNames);
- GfmAutoComplete.glEmojiTag = glEmojiTag;
+ Emoji.initEmojiMap()
+ .then(() => {
+ this.loadData($input, at, Emoji.getValidEmojiNames());
+ GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag;
})
- .catch(() => {
- this.isLoadingData[at] = false;
- });
+ .catch(() => {});
} else if (dataSource) {
AjaxCache.retrieve(dataSource, true)
.then(data => {
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index 04301c9ce12..ac4c8d28ee4 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -114,7 +114,7 @@ export default class GlFieldError {
this.state.empty = currentValue === '';
this.state.submitted = true;
this.renderValidity();
- this.form.focusOnFirstInvalid.apply(this.form);
+ this.form.focusInvalid.apply(this.form);
// For UX, wait til after first invalid submission to check each keyup
this.inputElement
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index c4fd719c8d0..ad79483d5ec 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -52,10 +52,23 @@ export default class GlFieldErrors {
});
}
- focusOnFirstInvalid() {
- const firstInvalid = this.state.inputs.filter(
- input => !input.inputDomElement.validity.valid,
- )[0];
- firstInvalid.inputElement.focus();
+ get invalidInputs() {
+ return this.state.inputs.filter(
+ ({
+ inputDomElement: {
+ validity: { valid },
+ },
+ }) => !valid,
+ );
+ }
+
+ get focusedInvalidInput() {
+ return this.invalidInputs.find(({ inputElement }) => inputElement.is(':focus'));
+ }
+
+ focusInvalid() {
+ if (this.focusedInvalidInput) return;
+
+ this.invalidInputs[0].inputElement.focus();
}
}
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 0b7735a7db9..0a1e5490237 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -3,19 +3,22 @@ import autosize from 'autosize';
import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete';
import dropzoneInput from './dropzone_input';
import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown';
+import { disableButtonIfEmptyField } from '~/lib/utils/common_utils';
export default class GLForm {
constructor(form, enableGFM = {}) {
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM };
+
// Disable autocomplete for keywords which do not have dataSources available
const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {};
Object.keys(this.enableGFM).forEach(item => {
- if (item !== 'emojis') {
- this.enableGFM[item] = Boolean(dataSources[item]);
+ if (item !== 'emojis' && !dataSources[item]) {
+ this.enableGFM[item] = false;
}
});
+
// Before we start, we should clean up any previous data for this form
this.destroy();
// Set up the form
@@ -43,7 +46,7 @@ export default class GLForm {
this.form.find('.div-dropzone').remove();
this.form.addClass('gfm-form');
// remove notify commit author checkbox for non-commit notes
- gl.utils.disableButtonIfEmptyField(
+ disableButtonIfEmptyField(
this.form.find('.js-note-text'),
this.form.find('.js-comment-button, .js-note-new-discussion'),
);
@@ -104,4 +107,8 @@ export default class GLForm {
.removeClass('is-focused');
});
}
+
+ get supportsQuickActions() {
+ return Boolean(this.textarea.data('supports-quick-actions'));
+ }
}
diff --git a/app/assets/javascripts/graphql_shared/fragments/user.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/user.fragment.graphql
new file mode 100644
index 00000000000..096bb77ee49
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/user.fragment.graphql
@@ -0,0 +1,7 @@
+fragment User on User {
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+}
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 6b9748bb725..be90ba12678 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -104,7 +104,7 @@ export default {
:class="{ 'project-row-contents': !isGroup }"
class="group-row-contents d-flex align-items-center py-2 pr-3"
>
- <div class="folder-toggle-wrap append-right-4 d-flex align-items-center">
+ <div class="folder-toggle-wrap gl-mr-2 d-flex align-items-center">
<item-caret :is-group-open="group.isOpen" />
<item-type-icon :item-type="group.type" :is-group-open="group.isOpen" />
</div>
@@ -140,7 +140,7 @@ export default {
<item-stats-value
:icon-name="visibilityIcon"
:title="visibilityTooltip"
- css-class="item-visibility d-inline-flex align-items-center gl-mt-3 append-right-4 text-secondary"
+ css-class="item-visibility d-inline-flex align-items-center gl-mt-3 gl-mr-2 text-secondary"
/>
<span v-if="group.permission" class="user-access-role gl-mt-3">
{{ group.permission }}
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index c7acc21378b..c7713cbfafc 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -49,7 +49,7 @@ export default {
<pagination-links
:change="change"
:page-info="pageInfo"
- class="d-flex justify-content-center prepend-top-default"
+ class="d-flex justify-content-center gl-mt-3"
/>
</template>
</div>
diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue
index 18ea4819878..cd3e3de4cb4 100644
--- a/app/assets/javascripts/groups/components/item_caret.vue
+++ b/app/assets/javascripts/groups/components/item_caret.vue
@@ -21,5 +21,5 @@ export default {
</script>
<template>
- <span class="folder-caret append-right-4"> <icon :size="10" :name="iconClass" /> </span>
+ <span class="folder-caret gl-mr-2"> <icon :size="10" :name="iconClass" /> </span>
</template>
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index d151cecf5be..3f9163e924d 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -16,10 +16,11 @@ import Tracking from '~/tracking';
*/
export default function initTodoToggle() {
$(document).on('todo:toggle', (e, count) => {
+ const updatedCount = count || e?.detail?.count || 0;
const $todoPendingCount = $('.todos-count');
- $todoPendingCount.text(highCountTrim(count));
- $todoPendingCount.toggleClass('hidden', count === 0);
+ $todoPendingCount.text(highCountTrim(updatedCount));
+ $todoPendingCount.toggleClass('hidden', updatedCount === 0);
});
}
diff --git a/app/assets/javascripts/helpers/event_hub_factory.js b/app/assets/javascripts/helpers/event_hub_factory.js
index 4d7f7550a94..a9c301e3a93 100644
--- a/app/assets/javascripts/helpers/event_hub_factory.js
+++ b/app/assets/javascripts/helpers/event_hub_factory.js
@@ -1,20 +1,101 @@
-import mitt from 'mitt';
+/**
+ * An event hub with a Vue instance like API
+ *
+ * NOTE: There's an [issue open][4] to eventually remove this when some
+ * coupling in our codebase has been fixed.
+ *
+ * NOTE: This is a derivative work from [mitt][1] v1.2.0 which is licensed by
+ * [MIT License][2] © [Jason Miller][3]
+ *
+ * [1]: https://github.com/developit/mitt
+ * [2]: https://opensource.org/licenses/MIT
+ * [3]: https://jasonformat.com/
+ * [4]: https://gitlab.com/gitlab-org/gitlab/-/issues/223864
+ */
+class EventHub {
+ constructor() {
+ this.$_all = new Map();
+ }
-export default () => {
- const emitter = mitt();
+ dispose() {
+ this.$_all.clear();
+ }
+
+ /**
+ * Register an event handler for the given type.
+ *
+ * @param {string|symbol} type Type of event to listen for
+ * @param {Function} handler Function to call in response to given event
+ */
+ $on(type, handler) {
+ const handlers = this.$_all.get(type);
+ const added = handlers && handlers.push(handler);
+
+ if (!added) {
+ this.$_all.set(type, [handler]);
+ }
+ }
+
+ /**
+ * Remove an event handler or all handlers for the given type.
+ *
+ * @param {string|symbol} type Type of event to unregister `handler`
+ * @param {Function} handler Handler function to remove
+ */
+ $off(type, handler) {
+ const handlers = this.$_all.get(type) || [];
- emitter.once = (event, handler) => {
- const wrappedHandler = evt => {
- handler(evt);
- emitter.off(event, wrappedHandler);
+ const newHandlers = handler ? handlers.filter(x => x !== handler) : [];
+
+ if (newHandlers.length) {
+ this.$_all.set(type, newHandlers);
+ } else {
+ this.$_all.delete(type);
+ }
+ }
+
+ /**
+ * Add an event listener to type but only trigger it once
+ *
+ * @param {string|symbol} type Type of event to listen for
+ * @param {Function} handler Handler function to call in response to event
+ */
+ $once(type, handler) {
+ const wrapHandler = (...args) => {
+ this.$off(type, wrapHandler);
+ handler(...args);
};
- emitter.on(event, wrappedHandler);
- };
+ this.$on(type, wrapHandler);
+ }
- emitter.$on = emitter.on;
- emitter.$once = emitter.once;
- emitter.$off = emitter.off;
- emitter.$emit = emitter.emit;
+ /**
+ * Invoke all handlers for the given type.
+ *
+ * @param {string|symbol} type The event type to invoke
+ * @param {Any} [evt] Any value passed to each handler
+ */
+ $emit(type, ...args) {
+ const handlers = this.$_all.get(type) || [];
- return emitter;
+ handlers.forEach(handler => {
+ handler(...args);
+ });
+ }
+}
+
+/**
+ * Return a Vue like event hub
+ *
+ * - $on
+ * - $off
+ * - $once
+ * - $emit
+ *
+ * Please note, this was once implemented with `mitt`, but since then has been reverted
+ * because of some API issues. https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35074
+ *
+ * We'd like to shy away from using a full fledged Vue instance from this in the future.
+ */
+export default () => {
+ return new EventHub();
};
diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js
index 94a0d38f05f..5f85ee58779 100644
--- a/app/assets/javascripts/helpers/monitor_helper.js
+++ b/app/assets/javascripts/helpers/monitor_helper.js
@@ -65,18 +65,10 @@ const getSeriesLabel = (queryLabel, metricAttributes) => {
*/
// eslint-disable-next-line import/prefer-default-export
export const makeDataSeries = (queryResults, defaultConfig) =>
- queryResults
- .map(result => {
- // NaN values may disrupt avg., max. & min. calculations in the legend, filter them out
- const data = result.values.filter(([, value]) => !Number.isNaN(value));
- if (!data.length) {
- return null;
- }
- const series = { data };
- return {
- ...defaultConfig,
- ...series,
- name: getSeriesLabel(defaultConfig.name, result.metric),
- };
- })
- .filter(series => series !== null);
+ queryResults.map(result => {
+ return {
+ ...defaultConfig,
+ data: result.values,
+ name: getSeriesLabel(defaultConfig.name, result.metric),
+ };
+ });
diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue
index e7f4cd796b5..49744d573da 100644
--- a/app/assets/javascripts/ide/components/branches/item.vue
+++ b/app/assets/javascripts/ide/components/branches/item.vue
@@ -33,7 +33,7 @@ export default {
<template>
<a :href="branchHref" class="btn-link d-flex align-items-center">
- <span class="d-flex append-right-default ide-search-list-current-icon">
+ <span class="d-flex gl-mr-3 ide-search-list-current-icon">
<icon v-if="isActive" :size="18" name="mobile-issue-close" />
</span>
<span>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index 6c563776533..407e4c57cd8 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -70,7 +70,7 @@ export default {
</script>
<template>
- <div class="append-bottom-15 ide-commit-options">
+ <div class="gl-mb-5 ide-commit-options">
<radio-group
:value="$options.commitToCurrentBranch"
:disabled="!canPushToBranch"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
index a13ca0cd138..3ffbcbf99e8 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
@@ -12,7 +12,7 @@ export default {
<div v-if="!lastCommitMsg" class="multi-file-commit-panel-section ide-commit-empty-state">
<div class="ide-commit-empty-state-container">
<div class="svg-content svg-80"><img :src="noChangesStateSvgPath" /></div>
- <div class="append-right-default prepend-left-default">
+ <div class="gl-mr-3 gl-ml-3">
<div class="text-content text-center">
<h4>{{ __('No changes') }}</h4>
<p>{{ __('Edit files in the editor and commit changes here') }}</p>
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 b6fc567f8cc..03304337839 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
@@ -75,7 +75,7 @@ export default {
:title="titleTooltip"
data-container="body"
data-placement="left"
- class="append-bottom-15"
+ class="gl-mb-5"
>
<icon v-once :name="iconName" :size="18" />
</div>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
index 6b0aa5b2b2b..b37c7280a30 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -83,7 +83,7 @@ export default {
<ul class="nav-links">
<li>
{{ __('Commit Message') }}
- <span v-popover="$options.popoverOptions" class="form-text text-muted prepend-left-10">
+ <span v-popover="$options.popoverOptions" class="form-text text-muted gl-ml-3">
<icon name="question" />
</span>
</li>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
index 0812599c25c..cdf49866982 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
@@ -44,7 +44,7 @@ export default {
data-qa-selector="start_new_mr_checkbox"
@change="toggleShouldCreateMR"
/>
- <span class="prepend-left-10 ide-option-label">
+ <span class="gl-ml-3 ide-option-label">
{{ __('Start a new merge request') }}
</span>
</label>
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 a9591805261..aed7b792902 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -66,7 +66,7 @@ export default {
name="commit-action"
@change="updateCommitAction($event.target.value)"
/>
- <span class="prepend-left-10">
+ <span class="gl-ml-3">
<span v-if="label" class="ide-option-label"> {{ label }} </span> <slot v-else></slot>
</span>
</label>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
index 137f8bb18c7..327b0b8172f 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
@@ -13,7 +13,7 @@ export default {
<div class="svg-content svg-80">
<img :src="committedStateSvgPath" :alt="s__('IDE|Successful commit')" />
</div>
- <div class="append-right-default prepend-left-default">
+ <div class="gl-mr-3 gl-ml-3">
<div class="text-content text-center">
<h4>{{ __('All changes are committed') }}</h4>
<p v-html="lastCommitMsg"></p>
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index 51509cd5fe6..f7cf7a5b251 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -76,7 +76,7 @@ export default {
data-container="body"
data-placement="right"
name="file-modified"
- class="prepend-left-5 ide-file-modified"
+ class="gl-ml-2 ide-file-modified"
/>
</span>
<changed-file-icon
diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue
index d459e3b43d3..b6a57d1b6e6 100644
--- a/app/assets/javascripts/ide/components/file_templates/bar.vue
+++ b/app/assets/javascripts/ide/components/file_templates/bar.vue
@@ -48,7 +48,7 @@ export default {
<template>
<div class="d-flex align-items-center ide-file-templates qa-file-templates-bar">
- <strong class="append-right-default"> {{ __('File templates') }} </strong>
+ <strong class="gl-mr-3"> {{ __('File templates') }} </strong>
<dropdown
:data="templateTypes"
:label="selectedTemplateType.name || __('Choose a type...')"
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index e9f84eb8648..55b3eaf9737 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import { modalTypes } from '../constants';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
@@ -24,7 +24,7 @@ export default {
FindFile,
ErrorMessage,
CommitEditorHeader,
- GlDeprecatedButton,
+ GlButton,
GlLoadingIcon,
RightPane,
},
@@ -121,15 +121,16 @@ export default {
)
}}
</p>
- <gl-deprecated-button
+ <gl-button
variant="success"
+ category="primary"
:title="__('New file')"
:aria-label="__('New file')"
data-qa-selector="first_file_button"
@click="createNewFile()"
>
{{ __('New file') }}
- </gl-deprecated-button>
+ </gl-button>
</template>
<gl-loading-icon v-else-if="!currentTree || currentTree.loading" size="md" />
<p v-else>
diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue
index 62dbfea2088..95348711e1d 100644
--- a/app/assets/javascripts/ide/components/ide_review.vue
+++ b/app/assets/javascripts/ide/components/ide_review.vue
@@ -53,7 +53,7 @@ export default {
@click="updateViewer"
/>
</div>
- <div class="prepend-top-5 ide-review-sub-header">
+ <div class="gl-mt-2 ide-review-sub-header">
<template v-if="showLatestChangesText">
{{ __('Latest changes') }}
</template>
diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue
index 92d25709bd5..1354fdc3d98 100644
--- a/app/assets/javascripts/ide/components/ide_status_list.vue
+++ b/app/assets/javascripts/ide/components/ide_status_list.vue
@@ -1,12 +1,17 @@
<script>
import { mapGetters } from 'vuex';
+import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue';
import { getFileEOL } from '../utils';
export default {
components: {
+ GlLink,
TerminalSyncStatusSafe,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
computed: {
...mapGetters(['activeFile']),
activeFileEOL() {
@@ -19,12 +24,14 @@ export default {
<template>
<div class="ide-status-list d-flex">
<template v-if="activeFile">
- <div class="ide-status-file">{{ activeFile.name }}</div>
- <div class="ide-status-file">{{ activeFileEOL }}</div>
- <div v-if="!activeFile.binary" class="ide-status-file">
- {{ activeFile.editorRow }}:{{ activeFile.editorColumn }}
+ <div>
+ <gl-link v-gl-tooltip.hover :href="activeFile.permalink" :title="__('Open in file view')">
+ {{ activeFile.name }}
+ </gl-link>
</div>
- <div class="ide-status-file">{{ activeFile.fileLanguage }}</div>
+ <div>{{ activeFileEOL }}</div>
+ <div v-if="!activeFile.binary">{{ activeFile.editorRow }}:{{ activeFile.editorColumn }}</div>
+ <div>{{ activeFile.fileLanguage }}</div>
</template>
<terminal-sync-status-safe />
</div>
diff --git a/app/assets/javascripts/ide/components/jobs/item.vue b/app/assets/javascripts/ide/components/jobs/item.vue
index be8bf77bba0..db3630bc1d1 100644
--- a/app/assets/javascripts/ide/components/jobs/item.vue
+++ b/app/assets/javascripts/ide/components/jobs/item.vue
@@ -26,7 +26,7 @@ export default {
<template>
<div class="ide-job-item">
- <job-description :job="job" class="append-right-default" />
+ <job-description :job="job" class="gl-mr-3" />
<div class="ml-auto align-self-center">
<button v-if="job.started" type="button" class="btn btn-default btn-sm" @click="clickViewLog">
{{ __('View log') }}
diff --git a/app/assets/javascripts/ide/components/jobs/list.vue b/app/assets/javascripts/ide/components/jobs/list.vue
index b97b7289886..4e0912f3f44 100644
--- a/app/assets/javascripts/ide/components/jobs/list.vue
+++ b/app/assets/javascripts/ide/components/jobs/list.vue
@@ -26,7 +26,7 @@ export default {
<template>
<div>
- <gl-loading-icon v-if="loading && !stages.length" size="lg" class="prepend-top-default" />
+ <gl-loading-icon v-if="loading && !stages.length" size="lg" class="gl-mt-3" />
<template v-else>
<stage
v-for="stage in stages"
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index 169a948c2da..75441e8c1c8 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -56,7 +56,7 @@ export default {
</script>
<template>
- <div class="ide-stage card prepend-top-default">
+ <div class="ide-stage card gl-mt-3">
<div
ref="cardHeader"
:class="{
diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue
index 3f060392686..8b7b8d5a91c 100644
--- a/app/assets/javascripts/ide/components/merge_requests/item.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/item.vue
@@ -40,7 +40,7 @@ export default {
<template>
<a :href="mergeRequestHref" class="btn-link d-flex align-items-center">
- <span class="d-flex append-right-default ide-search-list-current-icon">
+ <span class="d-flex gl-mr-3 ide-search-list-current-icon">
<icon v-if="isActive" :size="18" name="mobile-issue-close" />
</span>
<span>
diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue
index bf2a33be653..af45d88b84a 100644
--- a/app/assets/javascripts/ide/components/merge_requests/list.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/list.vue
@@ -102,7 +102,7 @@ export default {
class="btn-link d-flex align-items-center"
@click.stop="setSearchType(searchType)"
>
- <span class="d-flex append-right-default ide-search-list-current-icon">
+ <span class="d-flex gl-mr-3 ide-search-list-current-icon">
<icon :size="18" name="search" />
</span>
<span>{{ searchType.label }}</span>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index 2798ede5341..b656e35f150 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -64,6 +64,7 @@ export default {
:aria-label="__('Create new file or directory')"
type="button"
class="rounded border-0 d-flex ide-entry-dropdown-toggle"
+ data-qa-selector="dropdown_button"
@click.stop="openDropdown()"
>
<icon name="ellipsis_v" /> <icon name="chevron-down" />
@@ -97,6 +98,7 @@ export default {
class="d-flex"
icon="pencil"
icon-classes="mr-2"
+ data-qa-selector="rename_move_button"
@click="createNewItem($options.modalTypes.rename)"
/>
</li>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index 586d6867ab4..fe0167942b8 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -154,10 +154,7 @@ export default {
data-qa-selector="file_name_field"
:placeholder="placeholder"
/>
- <ul
- v-if="isCreatingNewFile"
- class="file-templates prepend-top-default list-inline qa-template-list"
- >
+ <ul v-if="isCreatingNewFile" class="file-templates gl-mt-3 list-inline qa-template-list">
<li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item">
<button
type="button"
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index 6958a5d2526..6038e92f254 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -7,7 +7,7 @@ import Icon from '../../../vue_shared/components/icon.vue';
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
import Tabs from '../../../vue_shared/components/tabs/tabs';
import Tab from '../../../vue_shared/components/tabs/tab.vue';
-import EmptyState from '../../../pipelines/components/empty_state.vue';
+import EmptyState from '../../../pipelines/components/pipelines_list/empty_state.vue';
import JobsList from '../jobs/list.vue';
import IDEServices from '~/ide/services';
@@ -59,7 +59,7 @@ export default {
<template>
<div class="ide-pipeline">
- <gl-loading-icon v-if="showLoadingIcon" size="lg" class="prepend-top-default" />
+ <gl-loading-icon v-if="showLoadingIcon" size="lg" class="gl-mt-3" />
<template v-else-if="hasLoadedPipeline">
<header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header">
<ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" />
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index a7646083428..ac445a1d9f1 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -185,7 +185,6 @@ export default {
'setFileLanguage',
'setEditorPosition',
'setFileViewMode',
- 'updateViewer',
'removePendingTab',
'triggerFilesChange',
'addTempImage',
@@ -241,7 +240,7 @@ export default {
});
},
setupEditor() {
- if (!this.file || !this.editor.instance) return;
+ if (!this.file || !this.editor.instance || this.file.loading) return;
const head = this.getStagedFile(this.file.path);
diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue
index 9841f1ece48..5dd12e62820 100644
--- a/app/assets/javascripts/ide/components/terminal/empty_state.vue
+++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue
@@ -43,7 +43,7 @@ export default {
<div class="text-center p-3">
<div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div>
<h4>{{ __('Web Terminal') }}</h4>
- <gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<template v-else>
<p>{{ __('Run tests against your code live using the Web Terminal') }}</p>
<p>
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index 4dfc27117c0..6e90968f008 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -8,9 +8,10 @@ import ModelManager from './common/model_manager';
import { editorOptions, defaultEditorOptions, defaultDiffEditorOptions } from './editor_options';
import { themes } from './themes';
import languages from './languages';
+import schemas from './schemas';
import keymap from './keymap.json';
import { clearDomElement } from '~/editor/utils';
-import { registerLanguages } from '../utils';
+import { registerLanguages, registerSchemas } from '../utils';
function setupThemes() {
themes.forEach(theme => {
@@ -45,6 +46,10 @@ export default class Editor {
setupThemes();
registerLanguages(...languages);
+ if (gon.features?.schemaLinting) {
+ registerSchemas(...schemas);
+ }
+
this.debouncedUpdate = debounce(() => {
this.updateDimensions();
}, 200);
diff --git a/app/assets/javascripts/ide/lib/schemas/index.js b/app/assets/javascripts/ide/lib/schemas/index.js
new file mode 100644
index 00000000000..38a2f81921b
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/schemas/index.js
@@ -0,0 +1,4 @@
+import json from './json';
+import yaml from './yaml';
+
+export default [json, yaml];
diff --git a/app/assets/javascripts/ide/lib/schemas/json/index.js b/app/assets/javascripts/ide/lib/schemas/json/index.js
new file mode 100644
index 00000000000..900d5442bec
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/schemas/json/index.js
@@ -0,0 +1,8 @@
+export default {
+ language: 'json',
+ options: {
+ validate: true,
+ enableSchemaRequest: true,
+ schemas: [],
+ },
+};
diff --git a/app/assets/javascripts/ide/lib/schemas/yaml/gitlab_ci.js b/app/assets/javascripts/ide/lib/schemas/yaml/gitlab_ci.js
new file mode 100644
index 00000000000..af20744abb3
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/schemas/yaml/gitlab_ci.js
@@ -0,0 +1,4 @@
+export default {
+ uri: 'https://json.schemastore.org/gitlab-ci',
+ fileMatch: ['*.gitlab-ci.yml'],
+};
diff --git a/app/assets/javascripts/ide/lib/schemas/yaml/index.js b/app/assets/javascripts/ide/lib/schemas/yaml/index.js
new file mode 100644
index 00000000000..e3fc406df4b
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/schemas/yaml/index.js
@@ -0,0 +1,12 @@
+import gitlabCi from './gitlab_ci';
+
+export default {
+ language: 'yaml',
+ options: {
+ validate: true,
+ enableSchemaRequest: true,
+ hover: true,
+ completion: true,
+ schemas: [gitlabCi],
+ },
+};
diff --git a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql
index 2c9013ffa9c..f0b50793226 100644
--- a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql
+++ b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql
@@ -1,8 +1,8 @@
query getUserPermissions($projectPath: ID!) {
project(fullPath: $projectPath) {
userPermissions {
- createMergeRequestIn,
- readMergeRequest,
+ createMergeRequestIn
+ readMergeRequest
pushCode
}
}
diff --git a/app/assets/javascripts/ide/services/gql.js b/app/assets/javascripts/ide/services/gql.js
index 8a7f27328ba..211cc78bd99 100644
--- a/app/assets/javascripts/ide/services/gql.js
+++ b/app/assets/javascripts/ide/services/gql.js
@@ -1,8 +1,21 @@
+import { memoize } from 'lodash';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
-export default createGqClient(
- {},
- {
- fetchPolicy: fetchPolicies.NO_CACHE,
- },
+/**
+ * Returns a memoized client
+ *
+ * We defer creating the client so that importing this module does not cause any side-effects.
+ * Creating the client immediately caused issues with miragejs where the gql client uses the
+ * real fetch() instead of the shimmed one.
+ */
+const getClient = memoize(() =>
+ createGqClient(
+ {},
+ {
+ fetchPolicy: fetchPolicies.NO_CACHE,
+ },
+ ),
);
+
+// eslint-disable-next-line import/prefer-default-export
+export const query = (...args) => getClient().query(...args);
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index 1767d961259..ae4a1ba3db5 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -2,17 +2,15 @@ import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import Api from '~/api';
import getUserPermissions from '../queries/getUserPermissions.query.graphql';
-import gqClient from './gql';
+import { query } from './gql';
const fetchApiProjectData = projectPath => Api.project(projectPath).then(({ data }) => data);
const fetchGqlProjectData = projectPath =>
- gqClient
- .query({
- query: getUserPermissions,
- variables: { projectPath },
- })
- .then(({ data }) => data.project);
+ query({
+ query: getUserPermissions,
+ variables: { projectPath },
+ }).then(({ data }) => data.project);
export default {
getFileData(endpoint) {
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 47f9337a288..c0cb924e749 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -65,7 +65,7 @@ export const getFileData = (
if (file.raw || (file.tempFile && !file.prevPath && !fileDeletedAndReadded))
return Promise.resolve();
- commit(types.TOGGLE_LOADING, { entry: file });
+ commit(types.TOGGLE_LOADING, { entry: file, forceValue: true });
const url = joinPaths(
gon.relative_url_root || '/',
@@ -79,15 +79,15 @@ export const getFileData = (
return service
.getFileData(url)
.then(({ data }) => {
- setPageTitleForFile(state, file);
-
if (data) commit(types.SET_FILE_DATA, { data, file });
if (openFile) commit(types.TOGGLE_FILE_OPEN, path);
- if (makeFileActive) dispatch('setFileActive', path);
- commit(types.TOGGLE_LOADING, { entry: file });
+
+ if (makeFileActive) {
+ setPageTitleForFile(state, file);
+ dispatch('setFileActive', path);
+ }
})
.catch(() => {
- commit(types.TOGGLE_LOADING, { entry: file });
dispatch('setErrorMessage', {
text: __('An error occurred while loading the file.'),
action: payload =>
@@ -95,6 +95,9 @@ export const getFileData = (
actionText: __('Please try again'),
actionPayload: { path, makeFileActive },
});
+ })
+ .finally(() => {
+ commit(types.TOGGLE_LOADING, { entry: file, forceValue: false });
});
};
@@ -106,45 +109,41 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) =
const file = state.entries[path];
const stagedFile = state.stagedFiles.find(f => f.path === path);
- return new Promise((resolve, reject) => {
- const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path);
- service
- .getRawFileData(fileDeletedAndReadded ? stagedFile : file)
- .then(raw => {
- if (!(file.tempFile && !file.prevPath && !fileDeletedAndReadded))
- commit(types.SET_FILE_RAW_DATA, { file, raw, fileDeletedAndReadded });
-
- if (file.mrChange && file.mrChange.new_file === false) {
- const baseSha =
- (getters.currentMergeRequest && getters.currentMergeRequest.baseCommitSha) || '';
-
- service
- .getBaseRawFileData(file, baseSha)
- .then(baseRaw => {
- commit(types.SET_FILE_BASE_RAW_DATA, {
- file,
- baseRaw,
- });
- resolve(raw);
- })
- .catch(e => {
- reject(e);
- });
- } else {
- resolve(raw);
- }
- })
- .catch(() => {
- dispatch('setErrorMessage', {
- text: __('An error occurred while loading the file content.'),
- action: payload =>
- dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)),
- actionText: __('Please try again'),
- actionPayload: { path },
+ const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path);
+ commit(types.TOGGLE_LOADING, { entry: file, forceValue: true });
+ return service
+ .getRawFileData(fileDeletedAndReadded ? stagedFile : file)
+ .then(raw => {
+ if (!(file.tempFile && !file.prevPath && !fileDeletedAndReadded))
+ commit(types.SET_FILE_RAW_DATA, { file, raw, fileDeletedAndReadded });
+
+ if (file.mrChange && file.mrChange.new_file === false) {
+ const baseSha =
+ (getters.currentMergeRequest && getters.currentMergeRequest.baseCommitSha) || '';
+
+ return service.getBaseRawFileData(file, baseSha).then(baseRaw => {
+ commit(types.SET_FILE_BASE_RAW_DATA, {
+ file,
+ baseRaw,
+ });
+ return raw;
});
- reject();
+ }
+ return raw;
+ })
+ .catch(e => {
+ dispatch('setErrorMessage', {
+ text: __('An error occurred while loading the file content.'),
+ action: payload =>
+ dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)),
+ actionText: __('Please try again'),
+ actionPayload: { path },
});
- });
+ throw e;
+ })
+ .finally(() => {
+ commit(types.TOGGLE_LOADING, { entry: file, forceValue: false });
+ });
};
export const changeFileContent = ({ commit, state, getters }, { path, content }) => {
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index fcaf060ef09..3fdfdc5422b 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -16,6 +16,7 @@ export const getMergeRequestsForBranch = (
.getProjectMergeRequests(`${projectId}`, {
source_branch: branchId,
source_project_id: state.projects[projectId].id,
+ state: 'opened',
order_by: 'created_at',
per_page: 1,
})
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index d94adc3760f..ae119c2b1fd 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -1,6 +1,5 @@
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
-export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA';
export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG';
export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index e827aacac13..c64839e5019 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -34,15 +34,6 @@ export default {
panelResizing: resizing,
});
},
- [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) {
- Object.assign(entry.lastCommit, {
- id: lastCommit.commit.id,
- url: lastCommit.commit_path,
- message: lastCommit.commit.message,
- author: lastCommit.commit.author_name,
- updatedAt: lastCommit.commit.authored_date,
- });
- },
[types.SET_LAST_COMMIT_MSG](state, lastCommitMsg) {
Object.assign(state, {
lastCommitMsg,
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 1c5fe9fe9a5..f074e6880d0 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -25,13 +25,6 @@ export const dataStructure = () => ({
changed: false,
staged: false,
lastCommitSha: '',
- lastCommit: {
- id: '',
- url: '',
- message: '',
- updatedAt: '',
- author: '',
- },
rawPath: '',
binary: false,
raw: '',
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index c28a2bd9f1d..9ec7b2c06ce 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -66,7 +66,7 @@ export const trimPathComponents = path =>
.join('/');
export function registerLanguages(def, ...defs) {
- if (defs.length) defs.forEach(lang => registerLanguages(lang));
+ defs.forEach(lang => registerLanguages(lang));
const languageId = def.id;
@@ -75,6 +75,19 @@ export function registerLanguages(def, ...defs) {
languages.setLanguageConfiguration(languageId, def.conf);
}
+export function registerSchemas({ language, options }, ...schemas) {
+ schemas.forEach(schema => registerSchemas(schema));
+
+ const defaults = {
+ json: languages.json.jsonDefaults,
+ yaml: languages.yaml.yamlDefaults,
+ };
+
+ if (defaults[language]) {
+ defaults[language].setDiagnosticsOptions(options);
+ }
+}
+
export const otherSide = side => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT);
export function trimTrailingWhitespace(content) {
diff --git a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue
index 1a9974db727..f673a0e42dc 100644
--- a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue
+++ b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue
@@ -28,7 +28,7 @@ export default {
};
</script>
<template>
- <import-projects-table provider-title="providerTitle">
+ <import-projects-table :provider-title="providerTitle">
<template #actions>
<slot name="actions"></slot>
</template>
diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js
index 2422a1ed2e4..8d8d33f5972 100644
--- a/app/assets/javascripts/import_projects/store/actions.js
+++ b/app/assets/javascripts/import_projects/store/actions.js
@@ -70,8 +70,19 @@ export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo
repoId: repo.id,
}),
)
- .catch(() => {
- createFlash(s__('ImportProjects|Importing the project failed'));
+ .catch(e => {
+ const serverErrorMessage = e?.response?.data?.errors;
+ const flashMessage = serverErrorMessage
+ ? sprintf(
+ s__('ImportProjects|Importing the project failed: %{reason}'),
+ {
+ reason: serverErrorMessage,
+ },
+ false,
+ )
+ : s__('ImportProjects|Importing the project failed');
+
+ createFlash(flashMessage);
commit(types.RECEIVE_IMPORT_ERROR, repo.id);
});
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index d6b519f7eac..f44c5c3d289 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -35,8 +35,8 @@ class ImporterStatus {
const $tr = $btn.closest('tr');
const $targetField = $tr.find('.import-target');
const $namespaceInput = $targetField.find('.js-select-namespace option:selected');
- const id = $tr.attr('id').replace('repo_', '');
const repoData = $tr.data();
+ const id = repoData.id || $tr.attr('id').replace('repo_', '');
let targetNamespace;
let newName;
@@ -63,7 +63,7 @@ class ImporterStatus {
return axios
.post(this.importUrl, attributes)
.then(({ data }) => {
- const job = $(`tr#repo_${id}`);
+ const job = $tr;
job.attr('id', `project_${data.id}`);
job.find('.import-target').html(`<a href="${data.full_path}">${data.full_path}</a>`);
@@ -86,7 +86,7 @@ class ImporterStatus {
.catch(error => {
let details = error;
- const $statusField = $(`#repo_${this.id} .job-status`);
+ const $statusField = $tr.find('.job-status');
$statusField.text(__('Failed'));
if (error.response && error.response.data && error.response.data.errors) {
diff --git a/app/assets/javascripts/incidents_settings/components/alerts_form.vue b/app/assets/javascripts/incidents_settings/components/alerts_form.vue
new file mode 100644
index 00000000000..a394f404ee1
--- /dev/null
+++ b/app/assets/javascripts/incidents_settings/components/alerts_form.vue
@@ -0,0 +1,139 @@
+<script>
+import {
+ GlButton,
+ GlSprintf,
+ GlLink,
+ GlIcon,
+ GlFormGroup,
+ GlFormCheckbox,
+ GlNewDropdown,
+ GlNewDropdownItem,
+} from '@gitlab/ui';
+import {
+ I18N_ALERT_SETTINGS_FORM,
+ NO_ISSUE_TEMPLATE_SELECTED,
+ TAKING_INCIDENT_ACTION_DOCS_LINK,
+ ISSUE_TEMPLATES_DOCS_LINK,
+} from '../constants';
+
+export default {
+ components: {
+ GlButton,
+ GlSprintf,
+ GlLink,
+ GlFormGroup,
+ GlIcon,
+ GlFormCheckbox,
+ GlNewDropdown,
+ GlNewDropdownItem,
+ },
+ inject: ['service', 'alertSettings'],
+ data() {
+ return {
+ templates: [NO_ISSUE_TEMPLATE_SELECTED, ...this.alertSettings.templates],
+ createIssueEnabled: this.alertSettings.createIssue,
+ issueTemplate: this.alertSettings.issueTemplateKey,
+ sendEmailEnabled: this.alertSettings.sendEmail,
+ loading: false,
+ };
+ },
+ i18n: I18N_ALERT_SETTINGS_FORM,
+ TAKING_INCIDENT_ACTION_DOCS_LINK,
+ ISSUE_TEMPLATES_DOCS_LINK,
+ computed: {
+ issueTemplateHeader() {
+ return this.issueTemplate || NO_ISSUE_TEMPLATE_SELECTED.name;
+ },
+ formData() {
+ return {
+ create_issue: this.createIssueEnabled,
+ issue_template_key: this.issueTemplate,
+ send_email: this.sendEmailEnabled,
+ };
+ },
+ },
+ methods: {
+ selectIssueTemplate(templateKey) {
+ this.issueTemplate = templateKey;
+ },
+ isTemplateSelected(templateKey) {
+ return templateKey === this.issueTemplate;
+ },
+ updateAlertsIntegrationSettings() {
+ this.loading = true;
+
+ this.service.updateSettings(this.formData).catch(() => {
+ this.loading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <p>
+ <gl-sprintf :message="$options.i18n.introText">
+ <template #docsLink>
+ <gl-link :href="$options.TAKING_INCIDENT_ACTION_DOCS_LINK" target="_blank">
+ <span>{{ $options.i18n.introLinkText }}</span>
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <form ref="settingsForm" @submit.prevent="updateAlertsIntegrationSettings">
+ <gl-form-group class="gl-pl-0">
+ <gl-form-checkbox v-model="createIssueEnabled" data-qa-selector="create_issue_checkbox">
+ <span>{{ $options.i18n.createIssue.label }}</span>
+ </gl-form-checkbox>
+ </gl-form-group>
+
+ <gl-form-group
+ label-size="sm"
+ label-for="alert-integration-settings-issue-template"
+ class="col-8 col-md-9 gl-px-6"
+ >
+ <label class="gl-display-inline-flex" for="alert-integration-settings-issue-template">
+ {{ $options.i18n.issueTemplate.label }}
+ <gl-link :href="$options.ISSUE_TEMPLATES_DOCS_LINK" target="_blank">
+ <gl-icon name="question" :size="12" />
+ </gl-link>
+ </label>
+ <gl-new-dropdown
+ id="alert-integration-settings-issue-template"
+ data-qa-selector="incident_templates_dropdown"
+ :text="issueTemplateHeader"
+ :block="true"
+ >
+ <gl-new-dropdown-item
+ v-for="template in templates"
+ :key="template.key"
+ data-qa-selector="incident_templates_item"
+ :is-check-item="true"
+ :is-checked="isTemplateSelected(template.key)"
+ @click="selectIssueTemplate(template.key)"
+ >
+ {{ template.name }}
+ </gl-new-dropdown-item>
+ </gl-new-dropdown>
+ </gl-form-group>
+
+ <gl-form-group class="gl-pl-0 gl-mb-5">
+ <gl-form-checkbox v-model="sendEmailEnabled">
+ <span>{{ $options.i18n.sendEmail.label }}</span>
+ </gl-form-checkbox>
+ </gl-form-group>
+
+ <gl-button
+ ref="submitBtn"
+ data-qa-selector="save_changes_button"
+ :disabled="loading"
+ variant="success"
+ type="submit"
+ class="js-no-auto-disable"
+ >
+ {{ $options.i18n.saveBtnLabel }}
+ </gl-button>
+ </form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
new file mode 100644
index 00000000000..0623c275c5a
--- /dev/null
+++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlButton, GlTabs, GlTab } from '@gitlab/ui';
+import AlertsSettingsForm from './alerts_form.vue';
+import PagerDutySettingsForm from './pagerduty_form.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { INTEGRATION_TABS_CONFIG, I18N_INTEGRATION_TABS } from '../constants';
+
+export default {
+ components: {
+ GlButton,
+ GlTabs,
+ GlTab,
+ AlertsSettingsForm,
+ PagerDutySettingsForm,
+ },
+ mixins: [glFeatureFlagMixin()],
+ tabs: INTEGRATION_TABS_CONFIG,
+ i18n: I18N_INTEGRATION_TABS,
+ methods: {
+ isFeatureFlagEnabled(tab) {
+ if (tab.featureFlag) {
+ return this.glFeatures[tab.featureFlag];
+ }
+ return true;
+ },
+ },
+};
+</script>
+
+<template>
+ <section
+ id="incident-management-settings"
+ data-qa-selector="incidents_settings_content"
+ class="settings no-animate qa-incident-management-settings"
+ >
+ <div class="settings-header">
+ <h3 ref="sectionHeader" class="h4">
+ {{ $options.i18n.headerText }}
+ </h3>
+ <gl-button ref="toggleBtn" class="js-settings-toggle">{{
+ $options.i18n.expandBtnLabel
+ }}</gl-button>
+ <p ref="sectionSubHeader">
+ {{ $options.i18n.subHeaderText }}
+ </p>
+ </div>
+
+ <div class="settings-content">
+ <gl-tabs>
+ <gl-tab
+ v-for="(tab, index) in $options.tabs"
+ v-if="tab.active && isFeatureFlagEnabled(tab)"
+ :key="`${tab.title}_${index}`"
+ :title="tab.title"
+ >
+ <component :is="tab.component" class="gl-pt-3" :data-testid="`${tab.component}-tab`" />
+ </gl-tab>
+ </gl-tabs>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
new file mode 100644
index 00000000000..027848db6e9
--- /dev/null
+++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
@@ -0,0 +1,183 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlSprintf,
+ GlLink,
+ GlIcon,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlToggle,
+ GlModal,
+ GlModalDirective,
+} from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { I18N_PAGERDUTY_SETTINGS_FORM, CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK } from '../constants';
+import { isEqual } from 'lodash';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlSprintf,
+ GlLink,
+ GlIcon,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlToggle,
+ GlModal,
+ ClipboardButton,
+ },
+ directives: {
+ 'gl-modal': GlModalDirective,
+ },
+ inject: ['service', 'pagerDutySettings'],
+ data() {
+ return {
+ active: this.pagerDutySettings.active,
+ webhookUrl: this.pagerDutySettings.webhookUrl,
+ loading: false,
+ resettingWebhook: false,
+ webhookUpdateFailed: false,
+ showAlert: false,
+ };
+ },
+ i18n: I18N_PAGERDUTY_SETTINGS_FORM,
+ CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK,
+ computed: {
+ formData() {
+ return {
+ pagerduty_active: this.active,
+ };
+ },
+ isFormUpdated() {
+ return isEqual(this.pagerDutySettings, {
+ active: this.active,
+ webhookUrl: this.webhookUrl,
+ });
+ },
+ isSaveDisabled() {
+ return this.isFormUpdated || this.loading || this.resettingWebhook;
+ },
+ webhookUpdateAlertMsg() {
+ return this.webhookUpdateFailed
+ ? this.$options.i18n.webhookUrl.updateErrMsg
+ : this.$options.i18n.webhookUrl.updateSuccessMsg;
+ },
+ webhookUpdateAlertVariant() {
+ return this.webhookUpdateFailed ? 'danger' : 'success';
+ },
+ },
+ methods: {
+ updatePagerDutyIntegrationSettings() {
+ this.loading = true;
+
+ this.service.updateSettings(this.formData).catch(() => {
+ this.loading = false;
+ });
+ },
+ resetWebhookUrl() {
+ this.resettingWebhook = true;
+
+ this.service
+ .resetWebhookUrl()
+ .then(({ data: { pagerduty_webhook_url: url } }) => {
+ this.webhookUrl = url;
+ this.showAlert = true;
+ this.webhookUpdateFailed = false;
+ })
+ .catch(() => {
+ this.showAlert = true;
+ this.webhookUpdateFailed = true;
+ })
+ .finally(() => {
+ this.resettingWebhook = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert
+ v-if="showAlert"
+ class="gl-mb-3"
+ :variant="webhookUpdateAlertVariant"
+ @dismiss="showAlert = false"
+ >
+ {{ webhookUpdateAlertMsg }}
+ </gl-alert>
+
+ <p>{{ $options.i18n.introText }}</p>
+ <form ref="settingsForm" @submit.prevent="updatePagerDutyIntegrationSettings">
+ <gl-form-group class="col-8 col-md-9 gl-p-0">
+ <gl-toggle
+ id="active"
+ v-model="active"
+ :is-loading="loading"
+ :label="$options.i18n.activeToggle.label"
+ />
+ </gl-form-group>
+
+ <gl-form-group
+ class="col-8 col-md-9 gl-p-0"
+ :label="$options.i18n.webhookUrl.label"
+ label-for="url"
+ label-class="label-bold"
+ >
+ <gl-form-input-group id="url" data-testid="webhook-url" readonly :value="webhookUrl">
+ <template #append>
+ <clipboard-button
+ :text="webhookUrl"
+ :title="$options.i18n.webhookUrl.copyToClipboard"
+ />
+ </template>
+ </gl-form-input-group>
+
+ <div class="gl-text-gray-400 gl-pt-2">
+ <gl-sprintf :message="$options.i18n.webhookUrl.helpText">
+ <template #docsLink>
+ <gl-link
+ :href="$options.CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK"
+ target="_blank"
+ class="gl-display-inline-flex"
+ >
+ <span>{{ $options.i18n.webhookUrl.helpDocsLink }}</span>
+ <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ <gl-button
+ v-gl-modal.resetWebhookModal
+ class="gl-mt-3"
+ :disabled="loading"
+ :loading="resettingWebhook"
+ data-testid="webhook-reset-btn"
+ >
+ {{ $options.i18n.webhookUrl.resetWebhookUrl }}
+ </gl-button>
+ <gl-modal
+ modal-id="resetWebhookModal"
+ :title="$options.i18n.webhookUrl.resetWebhookUrl"
+ :ok-title="$options.i18n.webhookUrl.resetWebhookUrl"
+ ok-variant="danger"
+ @ok="resetWebhookUrl"
+ >
+ {{ $options.i18n.webhookUrl.restKeyInfo }}
+ </gl-modal>
+ </gl-form-group>
+
+ <gl-button
+ ref="submitBtn"
+ :disabled="isSaveDisabled"
+ variant="success"
+ type="submit"
+ class="js-no-auto-disable"
+ >
+ {{ $options.i18n.saveBtnLabel }}
+ </gl-button>
+ </form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js
new file mode 100644
index 00000000000..b443c237f0f
--- /dev/null
+++ b/app/assets/javascripts/incidents_settings/constants.js
@@ -0,0 +1,83 @@
+import { __, s__ } from '~/locale';
+
+/* Integration tabs constants */
+export const INTEGRATION_TABS_CONFIG = [
+ {
+ title: s__('IncidentSettings|Alert integration'),
+ component: 'AlertsSettingsForm',
+ active: true,
+ },
+ {
+ title: s__('IncidentSettings|PagerDuty integration'),
+ component: 'PagerDutySettingsForm',
+ active: true,
+ featureFlag: 'pagerdutyWebhook',
+ },
+ {
+ title: s__('IncidentSettings|Grafana integration'),
+ component: '',
+ active: false,
+ },
+];
+
+export const I18N_INTEGRATION_TABS = {
+ headerText: s__('IncidentSettings|Incidents'),
+ expandBtnLabel: __('Expand'),
+ subHeaderText: s__(
+ 'IncidentSettings|Set up integrations with external tools to help better manage incidents.',
+ ),
+};
+
+/* Alerts integration settings constants */
+
+export const I18N_ALERT_SETTINGS_FORM = {
+ saveBtnLabel: __('Save changes'),
+ introText: __('Action to take when receiving an alert. %{docsLink}'),
+ introLinkText: __('More information.'),
+ createIssue: {
+ label: __('Create an issue. Issues are created for each alert triggered.'),
+ },
+ issueTemplate: {
+ label: __('Issue template (optional)'),
+ },
+ sendEmail: {
+ label: __('Send a separate email notification to Developers.'),
+ },
+};
+
+export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selected') };
+export const TAKING_INCIDENT_ACTION_DOCS_LINK =
+ '/help/user/project/integrations/prometheus#taking-action-on-incidents-ultimate';
+export const ISSUE_TEMPLATES_DOCS_LINK =
+ '/help/user/project/description_templates#creating-issue-templates';
+
+/* PagerDuty integration settings constants */
+
+export const I18N_PAGERDUTY_SETTINGS_FORM = {
+ introText: s__(
+ 'PagerDutySettings|Setting up a webhook with PagerDuty will automatically create a GitLab issue for each PagerDuty incident.',
+ ),
+ activeToggle: {
+ label: s__('PagerDutySettings|Active'),
+ },
+ webhookUrl: {
+ label: s__('PagerDutySettings|Webhook URL'),
+ helpText: s__(
+ 'PagerDutySettings|Create a GitLab issue for each PagerDuty incident by %{docsLink}',
+ ),
+ helpDocsLink: s__('PagerDutySettings|configuring a webhook in PagerDuty'),
+ resetWebhookUrl: s__('PagerDutySettings|Reset webhook URL'),
+ copyToClipboard: __('Copy'),
+ updateErrMsg: s__('PagerDutySettings|Failed to update Webhook URL'),
+ updateSuccessMsg: s__('PagerDutySettings|Webhook URL update was successful'),
+ restKeyInfo: s__(
+ "PagerDutySettings|Resetting the webhook URL for this project will require updating this integration's settings in PagerDuty.",
+ ),
+ },
+ saveBtnLabel: __('Save changes'),
+};
+
+export const CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK = 'https://support.pagerduty.com/docs/webhooks';
+
+/* common constants */
+export const ERROR_MSG = __('There was an error saving your changes.');
diff --git a/app/assets/javascripts/incidents_settings/incidents_settings_service.js b/app/assets/javascripts/incidents_settings/incidents_settings_service.js
new file mode 100644
index 00000000000..bd4f5bb8820
--- /dev/null
+++ b/app/assets/javascripts/incidents_settings/incidents_settings_service.js
@@ -0,0 +1,32 @@
+import axios from '~/lib/utils/axios_utils';
+import { refreshCurrentPage } from '~/lib/utils/url_utility';
+import createFlash from '~/flash';
+import { ERROR_MSG } from './constants';
+
+export default class IncidentsSettingsService {
+ constructor(settingsEndpoint, webhookUpdateEndpoint) {
+ this.settingsEndpoint = settingsEndpoint;
+ this.webhookUpdateEndpoint = webhookUpdateEndpoint;
+ }
+
+ updateSettings(data) {
+ return axios
+ .patch(this.settingsEndpoint, {
+ project: {
+ incident_management_setting_attributes: data,
+ },
+ })
+ .then(() => {
+ refreshCurrentPage();
+ })
+ .catch(({ response }) => {
+ const message = response?.data?.message || '';
+
+ createFlash(`${ERROR_MSG} ${message}`, 'alert');
+ });
+ }
+
+ resetWebhookUrl() {
+ return axios.post(this.webhookUpdateEndpoint);
+ }
+}
diff --git a/app/assets/javascripts/incidents_settings/index.js b/app/assets/javascripts/incidents_settings/index.js
new file mode 100644
index 00000000000..80e7d07feca
--- /dev/null
+++ b/app/assets/javascripts/incidents_settings/index.js
@@ -0,0 +1,46 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import SettingsTabs from './components/incidents_settings_tabs.vue';
+import IncidentsSettingsService from './incidents_settings_service';
+
+export default () => {
+ const el = document.querySelector('.js-incidents-settings');
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ dataset: {
+ operationsSettingsEndpoint,
+ templates,
+ createIssue,
+ issueTemplateKey,
+ sendEmail,
+ pagerdutyActive,
+ pagerdutyWebhookUrl,
+ pagerdutyResetKeyPath,
+ },
+ } = el;
+
+ const service = new IncidentsSettingsService(operationsSettingsEndpoint, pagerdutyResetKeyPath);
+ return new Vue({
+ el,
+ provide: {
+ service,
+ alertSettings: {
+ templates: JSON.parse(templates),
+ createIssue: parseBoolean(createIssue),
+ issueTemplateKey,
+ sendEmail: parseBoolean(sendEmail),
+ },
+ pagerDutySettings: {
+ active: parseBoolean(pagerdutyActive),
+ webhookUrl: pagerdutyWebhookUrl,
+ },
+ },
+ render(createElement) {
+ return createElement(SettingsTabs);
+ },
+ });
+};
diff --git a/app/assets/javascripts/integrations/edit/components/active_toggle.vue b/app/assets/javascripts/integrations/edit/components/active_toggle.vue
index dc89e139320..a3087c8958e 100644
--- a/app/assets/javascripts/integrations/edit/components/active_toggle.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_toggle.vue
@@ -1,4 +1,5 @@
<script>
+import { mapGetters } from 'vuex';
import eventHub from '../event_hub';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { GlFormGroup, GlToggle } from '@gitlab/ui';
@@ -21,6 +22,9 @@ export default {
activated: this.initialActivated,
};
},
+ computed: {
+ ...mapGetters(['isInheriting']),
+ },
mounted() {
// Initialize view
this.$nextTick(() => {
@@ -42,6 +46,7 @@ export default {
v-model="activated"
name="service[active]"
class="gl-display-block gl-line-height-0"
+ :disabled="isInheriting"
@change="onToggle"
/>
</gl-form-group>
@@ -50,7 +55,12 @@ export default {
<div class="form-group row" role="group">
<label for="service[active]" class="col-form-label col-sm-2">{{ __('Active') }}</label>
<div class="col-sm-10 pt-1">
- <gl-toggle v-model="activated" name="service[active]" @change="onToggle" />
+ <gl-toggle
+ v-model="activated"
+ name="service[active]"
+ :disabled="isInheriting"
+ @change="onToggle"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index 29318d6aaa8..6053d11e6da 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -1,4 +1,5 @@
<script>
+import { mapGetters } from 'vuex';
import eventHub from '../event_hub';
import { capitalize, lowerCase, isEmpty } from 'lodash';
import { __, sprintf } from '~/locale';
@@ -59,6 +60,7 @@ export default {
};
},
computed: {
+ ...mapGetters(['isInheriting']),
isCheckbox() {
return this.type === 'checkbox';
},
@@ -106,10 +108,12 @@ export default {
return {
id: this.fieldId,
name: this.fieldName,
+ state: this.valid,
+ readonly: this.isInheriting,
};
},
valid() {
- return !this.required || !isEmpty(this.model) || !this.validated;
+ return !this.required || !isEmpty(this.model) || this.isNonEmptyPassword || !this.validated;
},
},
created() {
@@ -135,15 +139,21 @@ export default {
:label-for="fieldId"
:invalid-feedback="__('This field is required.')"
:state="valid"
- :description="help"
>
+ <template #description>
+ <span v-html="help"></span>
+ </template>
+
<template v-if="isCheckbox">
- <input :name="fieldName" type="hidden" value="false" />
- <gl-form-checkbox v-model="model" v-bind="sharedProps">
+ <input :name="fieldName" type="hidden" :value="model || false" />
+ <gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting">
{{ humanizedTitle }}
</gl-form-checkbox>
</template>
- <gl-form-select v-else-if="isSelect" v-model="model" v-bind="sharedProps" :options="options" />
+ <template v-else-if="isSelect">
+ <input type="hidden" :name="fieldName" :value="model" />
+ <gl-form-select :id="fieldId" v-model="model" :options="options" :disabled="isInheriting" />
+ </template>
<gl-form-textarea
v-else-if="isTextarea"
v-model="model"
@@ -159,6 +169,7 @@ export default {
autocomplete="new-password"
:placeholder="placeholder"
:required="passwordRequired"
+ :data-qa-selector="`${fieldId}_field`"
/>
<gl-form-input
v-else
@@ -167,6 +178,7 @@ export default {
:type="type"
:placeholder="placeholder"
:required="required"
+ :data-qa-selector="`${fieldId}_field`"
/>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index ef7a4d44b20..5088664c3bd 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -1,58 +1,74 @@
<script>
+import { mapState, mapActions, mapGetters } from 'vuex';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+import OverrideDropdown from './override_dropdown.vue';
import ActiveToggle from './active_toggle.vue';
import JiraTriggerFields from './jira_trigger_fields.vue';
+import JiraIssuesFields from './jira_issues_fields.vue';
import TriggerFields from './trigger_fields.vue';
import DynamicField from './dynamic_field.vue';
export default {
name: 'IntegrationForm',
components: {
+ OverrideDropdown,
ActiveToggle,
JiraTriggerFields,
+ JiraIssuesFields,
TriggerFields,
DynamicField,
},
- props: {
- activeToggleProps: {
- type: Object,
- required: true,
- },
- showActive: {
- type: Boolean,
- required: true,
- },
- triggerFieldsProps: {
- type: Object,
- required: true,
- },
- triggerEvents: {
- type: Array,
- required: false,
- default: () => [],
- },
- fields: {
- type: Array,
- required: false,
- default: () => [],
- },
- type: {
- type: String,
- required: true,
- },
- },
+ mixins: [glFeatureFlagsMixin()],
computed: {
+ ...mapGetters(['currentKey', 'propsSource']),
+ ...mapState(['adminState', 'override']),
isJira() {
- return this.type === 'jira';
+ return this.propsSource.type === 'jira';
},
+ showJiraIssuesFields() {
+ return this.isJira && this.glFeatures.jiraIssuesIntegration;
+ },
+ },
+ methods: {
+ ...mapActions(['setOverride']),
},
};
</script>
<template>
<div>
- <active-toggle v-if="showActive" v-bind="activeToggleProps" />
- <jira-trigger-fields v-if="isJira" v-bind="triggerFieldsProps" />
- <trigger-fields v-else-if="triggerEvents.length" :events="triggerEvents" :type="type" />
- <dynamic-field v-for="field in fields" :key="field.name" v-bind="field" />
+ <override-dropdown
+ v-if="adminState !== null"
+ :inherit-from-id="adminState.id"
+ :override="override"
+ @change="setOverride"
+ />
+ <active-toggle
+ v-if="propsSource.showActive"
+ :key="`${currentKey}-active-toggle`"
+ v-bind="propsSource.activeToggleProps"
+ />
+ <jira-trigger-fields
+ v-if="isJira"
+ :key="`${currentKey}-jira-trigger-fields`"
+ v-bind="propsSource.triggerFieldsProps"
+ />
+ <trigger-fields
+ v-else-if="propsSource.triggerEvents.length"
+ :key="`${currentKey}-trigger-fields`"
+ :events="propsSource.triggerEvents"
+ :type="propsSource.type"
+ />
+ <dynamic-field
+ v-for="field in propsSource.fields"
+ :key="`${currentKey}-${field.name}`"
+ v-bind="field"
+ />
+ <jira-issues-fields
+ v-if="showJiraIssuesFields"
+ :key="`${currentKey}-jira-issues-fields`"
+ v-bind="propsSource.jiraIssuesProps"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
new file mode 100644
index 00000000000..5444cd5a712
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -0,0 +1,151 @@
+<script>
+import eventHub from '../event_hub';
+import {
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormInput,
+ GlSprintf,
+ GlLink,
+ GlButton,
+ GlCard,
+} from '@gitlab/ui';
+
+export default {
+ name: 'JiraIssuesFields',
+ components: {
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormInput,
+ GlSprintf,
+ GlLink,
+ GlButton,
+ GlCard,
+ },
+ props: {
+ showJiraIssuesIntegration: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ initialEnableJiraIssues: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ initialProjectKey: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ upgradePlanPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ editProjectPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ enableJiraIssues: this.initialEnableJiraIssues,
+ projectKey: this.initialProjectKey,
+ validated: false,
+ };
+ },
+ computed: {
+ validProjectKey() {
+ return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated;
+ },
+ },
+ created() {
+ eventHub.$on('validateForm', this.validateForm);
+ },
+ beforeDestroy() {
+ eventHub.$off('validateForm', this.validateForm);
+ },
+ methods: {
+ validateForm() {
+ this.validated = true;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-form-group
+ :label="s__('JiraService|View Jira issues in GitLab')"
+ label-for="jira-issue-settings"
+ >
+ <div id="jira-issue-settings">
+ <p>
+ {{
+ s__(
+ 'JiraService|Work on Jira issues without leaving GitLab. Adds a Jira menu to access your list of Jira issues and view any issue as read-only.',
+ )
+ }}
+ </p>
+ <template v-if="showJiraIssuesIntegration">
+ <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" />
+ <gl-form-checkbox v-model="enableJiraIssues">
+ {{ s__('JiraService|Enable Jira issues') }}
+ <template #help>
+ {{
+ s__(
+ 'JiraService|Warning: All GitLab users that have access to this GitLab project will be able to view all issues from the Jira project specified below.',
+ )
+ }}
+ </template>
+ </gl-form-checkbox>
+ </template>
+ <gl-card v-else class="gl-mt-7">
+ <strong>{{ __('This is a Premium feature') }}</strong>
+ <p>{{ __('Upgrade your plan to enable this feature of the Jira Integration.') }}</p>
+ <gl-button
+ v-if="upgradePlanPath"
+ category="primary"
+ variant="info"
+ :href="upgradePlanPath"
+ target="_blank"
+ >
+ {{ __('Upgrade your plan') }}
+ </gl-button>
+ </gl-card>
+ </div>
+ </gl-form-group>
+ <template v-if="showJiraIssuesIntegration">
+ <gl-form-group
+ :label="s__('JiraService|Jira project key')"
+ label-for="service_project_key"
+ :invalid-feedback="__('This field is required.')"
+ :state="validProjectKey"
+ >
+ <gl-form-input
+ id="service_project_key"
+ v-model="projectKey"
+ name="service[project_key]"
+ :placeholder="s__('JiraService|e.g. AB')"
+ :required="enableJiraIssues"
+ :state="validProjectKey"
+ :disabled="!enableJiraIssues"
+ />
+ </gl-form-group>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'JiraService|Displaying Jira issues while leaving the GitLab issue functionality enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="editProjectPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
index 64e5789764f..1d3354c6651 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -1,5 +1,6 @@
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { mapGetters } from 'vuex';
import { s__ } from '~/locale';
import { GlFormGroup, GlFormCheckbox, GlFormRadio } from '@gitlab/ui';
@@ -55,6 +56,7 @@ export default {
};
},
computed: {
+ ...mapGetters(['isInheriting']),
showEnableComments() {
return this.triggerCommit || this.triggerMergeRequest;
},
@@ -73,13 +75,17 @@ export default {
)
"
>
- <input name="service[commit_events]" type="hidden" value="false" />
- <gl-form-checkbox v-model="triggerCommit" name="service[commit_events]">
+ <input name="service[commit_events]" type="hidden" :value="triggerCommit || false" />
+ <gl-form-checkbox v-model="triggerCommit" :disabled="isInheriting">
{{ __('Commit') }}
</gl-form-checkbox>
- <input name="service[merge_requests_events]" type="hidden" value="false" />
- <gl-form-checkbox v-model="triggerMergeRequest" name="service[merge_requests_events]">
+ <input
+ name="service[merge_requests_events]"
+ type="hidden"
+ :value="triggerMergeRequest || false"
+ />
+ <gl-form-checkbox v-model="triggerMergeRequest" :disabled="isInheriting">
{{ __('Merge request') }}
</gl-form-checkbox>
</gl-form-group>
@@ -89,8 +95,12 @@ export default {
:label="s__('Integrations|Comment settings:')"
data-testid="comment-settings"
>
- <input name="service[comment_on_event_enabled]" type="hidden" value="false" />
- <gl-form-checkbox v-model="enableComments" name="service[comment_on_event_enabled]">
+ <input
+ name="service[comment_on_event_enabled]"
+ type="hidden"
+ :value="enableComments || false"
+ />
+ <gl-form-checkbox v-model="enableComments" :disabled="isInheriting">
{{ s__('Integrations|Enable comments') }}
</gl-form-checkbox>
</gl-form-group>
@@ -100,12 +110,18 @@ export default {
:label="s__('Integrations|Comment detail:')"
data-testid="comment-detail"
>
+ <input
+ v-if="isInheriting"
+ name="service[comment_detail]"
+ type="hidden"
+ :value="commentDetail"
+ />
<gl-form-radio
v-for="commentDetailOption in commentDetailOptions"
:key="commentDetailOption.value"
v-model="commentDetail"
:value="commentDetailOption.value"
- name="service[comment_detail]"
+ :disabled="isInheriting"
>
{{ commentDetailOption.label }}
<template #help>
@@ -126,13 +142,17 @@ export default {
}}
</label>
- <input name="service[commit_events]" type="hidden" value="false" />
- <gl-form-checkbox v-model="triggerCommit" name="service[commit_events]">
+ <input name="service[commit_events]" type="hidden" :value="triggerCommit || false" />
+ <gl-form-checkbox v-model="triggerCommit" :disabled="isInheriting">
{{ __('Commit') }}
</gl-form-checkbox>
- <input name="service[merge_requests_events]" type="hidden" value="false" />
- <gl-form-checkbox v-model="triggerMergeRequest" name="service[merge_requests_events]">
+ <input
+ name="service[merge_requests_events]"
+ type="hidden"
+ :value="triggerMergeRequest || false"
+ />
+ <gl-form-checkbox v-model="triggerMergeRequest" :disabled="isInheriting">
{{ __('Merge request') }}
</gl-form-checkbox>
@@ -144,8 +164,12 @@ export default {
<label>
{{ s__('Integrations|Comment settings:') }}
</label>
- <input name="service[comment_on_event_enabled]" type="hidden" value="false" />
- <gl-form-checkbox v-model="enableComments" name="service[comment_on_event_enabled]">
+ <input
+ name="service[comment_on_event_enabled]"
+ type="hidden"
+ :value="enableComments || false"
+ />
+ <gl-form-checkbox v-model="enableComments" :disabled="isInheriting">
{{ s__('Integrations|Enable comments') }}
</gl-form-checkbox>
@@ -153,12 +177,18 @@ export default {
<label>
{{ s__('Integrations|Comment detail:') }}
</label>
+ <input
+ v-if="isInheriting"
+ name="service[comment_detail]"
+ type="hidden"
+ :value="commentDetail"
+ />
<gl-form-radio
v-for="commentDetailOption in commentDetailOptions"
:key="commentDetailOption.value"
v-model="commentDetail"
:value="commentDetailOption.value"
- name="service[comment_detail]"
+ :disabled="isInheriting"
>
{{ commentDetailOption.label }}
<template #help>
diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
new file mode 100644
index 00000000000..0ae2f267434
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
@@ -0,0 +1,63 @@
+<script>
+import { s__ } from '~/locale';
+import { GlNewDropdown, GlNewDropdownItem } from '@gitlab/ui';
+
+const dropdownOptions = [
+ {
+ value: false,
+ text: s__('Integrations|Use instance level settings'),
+ },
+ {
+ value: true,
+ text: s__('Integrations|Use custom settings'),
+ },
+];
+
+export default {
+ dropdownOptions,
+ name: 'OverrideDropdown',
+ components: {
+ GlNewDropdown,
+ GlNewDropdownItem,
+ },
+ props: {
+ inheritFromId: {
+ type: Number,
+ required: true,
+ },
+ override: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selected: dropdownOptions.find(x => x.value === this.override),
+ };
+ },
+ methods: {
+ onClick(option) {
+ this.selected = option;
+ this.$emit('change', option.value);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-py-4 gl-mt-5 gl-mb-6 gl-border-t-1 gl-border-t-solid gl-border-b-1 gl-border-b-solid gl-border-gray-100"
+ >
+ <span>{{ s__('Integrations|This integration has multiple settings available.') }}</span>
+ <input name="service[inherit_from_id]" :value="override ? '' : inheritFromId" type="hidden" />
+ <gl-new-dropdown :text="selected.text">
+ <gl-new-dropdown-item
+ v-for="option in $options.dropdownOptions"
+ :key="option.value"
+ @click="onClick(option)"
+ >
+ {{ option.text }}
+ </gl-new-dropdown-item>
+ </gl-new-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
index 531490ae40c..bb1e0d9d360 100644
--- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
@@ -1,4 +1,5 @@
<script>
+import { mapGetters } from 'vuex';
import { startCase } from 'lodash';
import { __ } from '~/locale';
import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
@@ -32,6 +33,7 @@ export default {
},
},
computed: {
+ ...mapGetters(['isInheriting']),
placeholder() {
return placeholderForType[this.type];
},
@@ -57,8 +59,8 @@ export default {
>
<div id="trigger-fields" class="gl-pt-3">
<gl-form-group v-for="event in events" :key="event.title" :description="event.description">
- <input :name="checkboxName(event.name)" type="hidden" value="false" />
- <gl-form-checkbox v-model="event.value" :name="checkboxName(event.name)">
+ <input :name="checkboxName(event.name)" type="hidden" :value="event.value || false" />
+ <gl-form-checkbox v-model="event.value" :disabled="isInheriting">
{{ startCase(event.title) }}
</gl-form-checkbox>
<gl-form-input
@@ -66,6 +68,7 @@ export default {
v-model="event.field.value"
:name="fieldName(event.field.name)"
:placeholder="placeholder"
+ :readonly="isInheriting"
/>
</gl-form-group>
</div>
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index 21b5ca17951..ea5463832ce 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -1,49 +1,86 @@
import Vue from 'vue';
+import { createStore } from './store';
import { parseBoolean } from '~/lib/utils/common_utils';
import IntegrationForm from './components/integration_form.vue';
-export default el => {
- if (!el) {
- return null;
- }
-
- function parseBooleanInData(data) {
- const result = {};
- Object.entries(data).forEach(([key, value]) => {
- result[key] = parseBoolean(value);
- });
- return result;
- }
+function parseBooleanInData(data) {
+ const result = {};
+ Object.entries(data).forEach(([key, value]) => {
+ result[key] = parseBoolean(value);
+ });
+ return result;
+}
- const { type, commentDetail, triggerEvents, fields, ...booleanAttributes } = el.dataset;
+function parseDatasetToProps(data) {
+ const {
+ id,
+ type,
+ commentDetail,
+ projectKey,
+ upgradePlanPath,
+ editProjectPath,
+ triggerEvents,
+ fields,
+ inheritFromId,
+ ...booleanAttributes
+ } = data;
const {
showActive,
activated,
commitEvents,
mergeRequestEvents,
enableComments,
+ showJiraIssuesIntegration,
+ enableJiraIssues,
} = parseBooleanInData(booleanAttributes);
+ return {
+ activeToggleProps: {
+ initialActivated: activated,
+ },
+ showActive,
+ type,
+ triggerFieldsProps: {
+ initialTriggerCommit: commitEvents,
+ initialTriggerMergeRequest: mergeRequestEvents,
+ initialEnableComments: enableComments,
+ initialCommentDetail: commentDetail,
+ },
+ jiraIssuesProps: {
+ showJiraIssuesIntegration,
+ initialEnableJiraIssues: enableJiraIssues,
+ initialProjectKey: projectKey,
+ upgradePlanPath,
+ editProjectPath,
+ },
+ triggerEvents: JSON.parse(triggerEvents),
+ fields: JSON.parse(fields),
+ inheritFromId: parseInt(inheritFromId, 10),
+ id: parseInt(id, 10),
+ };
+}
+
+export default (el, adminEl) => {
+ if (!el) {
+ return null;
+ }
+
+ const props = parseDatasetToProps(el.dataset);
+
+ const initialState = {
+ adminState: null,
+ customState: props,
+ };
+
+ if (adminEl) {
+ initialState.adminState = Object.freeze(parseDatasetToProps(adminEl.dataset));
+ }
+
return new Vue({
el,
+ store: createStore(initialState),
render(createElement) {
- return createElement(IntegrationForm, {
- props: {
- activeToggleProps: {
- initialActivated: activated,
- },
- showActive,
- type,
- triggerFieldsProps: {
- initialTriggerCommit: commitEvents,
- initialTriggerMergeRequest: mergeRequestEvents,
- initialEnableComments: enableComments,
- initialCommentDetail: commentDetail,
- },
- triggerEvents: JSON.parse(triggerEvents),
- fields: JSON.parse(fields),
- },
- });
+ return createElement(IntegrationForm);
},
});
};
diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js
new file mode 100644
index 00000000000..3decdaab55d
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/store/actions.js
@@ -0,0 +1,4 @@
+import * as types from './mutation_types';
+
+// eslint-disable-next-line import/prefer-default-export
+export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override);
diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js
new file mode 100644
index 00000000000..b68bd668980
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/store/getters.js
@@ -0,0 +1,6 @@
+export const isInheriting = state => (state.adminState === null ? false : !state.override);
+
+export const propsSource = (state, getters) =>
+ getters.isInheriting ? state.adminState : state.customState;
+
+export const currentKey = (state, getters) => (getters.isInheriting ? 'admin' : 'custom');
diff --git a/app/assets/javascripts/integrations/edit/store/index.js b/app/assets/javascripts/integrations/edit/store/index.js
new file mode 100644
index 00000000000..eea5e48780d
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/store/index.js
@@ -0,0 +1,17 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+// eslint-disable-next-line import/prefer-default-export
+export const createStore = (initialState = {}) =>
+ new Vuex.Store({
+ actions,
+ getters,
+ mutations,
+ state: createState(initialState),
+ });
diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js
new file mode 100644
index 00000000000..274afe3fb49
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export const SET_OVERRIDE = 'SET_OVERRIDE';
diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js
new file mode 100644
index 00000000000..8757d415197
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/store/mutations.js
@@ -0,0 +1,7 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_OVERRIDE](state, override) {
+ state.override = override;
+ },
+};
diff --git a/app/assets/javascripts/integrations/edit/store/state.js b/app/assets/javascripts/integrations/edit/store/state.js
new file mode 100644
index 00000000000..95c1a2be500
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/store/state.js
@@ -0,0 +1,9 @@
+export default ({ adminState = null, customState = {} } = {}) => {
+ const override = adminState !== null ? adminState.id !== customState.inheritFromId : false;
+
+ return {
+ override,
+ adminState,
+ customState,
+ };
+};
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
index 8844cbebe85..837409a91ca 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -22,7 +22,10 @@ export default class IntegrationSettingsForm {
init() {
// Init Vue component
- initForm(document.querySelector('.js-vue-integration-settings'));
+ initForm(
+ document.querySelector('.js-vue-integration-settings'),
+ document.querySelector('.js-vue-admin-integration-settings'),
+ );
eventHub.$on('toggle', active => {
this.formActive = active;
this.handleServiceToggle();
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index 01ea3eee16e..d968e9e5235 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -1,7 +1,5 @@
-/* eslint-disable consistent-return, func-names, array-callback-return */
-
import $ from 'jquery';
-import { intersection } from 'lodash';
+import { difference, intersection, union } from 'lodash';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
import { __ } from './locale';
@@ -36,43 +34,6 @@ export default {
return new Flash(__('Issue update failed'));
},
- getSelectedIssues() {
- return this.issues.has('.selected-issuable:checked');
- },
-
- getLabelsFromSelection() {
- const labels = [];
- this.getSelectedIssues().map(function() {
- const labelsData = $(this).data('labels');
- if (labelsData) {
- return labelsData.map(labelId => {
- if (labels.indexOf(labelId) === -1) {
- return labels.push(labelId);
- }
- });
- }
- });
- return labels;
- },
-
- /**
- * Will return only labels that were marked previously and the user has unmarked
- * @return {Array} Label IDs
- */
-
- getUnmarkedIndeterminedLabels() {
- const result = [];
- const labelsToKeep = this.$labelDropdown.data('indeterminate');
-
- this.getLabelsFromSelection().forEach(id => {
- if (labelsToKeep.indexOf(id) === -1) {
- result.push(id);
- }
- });
-
- return result;
- },
-
/**
* Simple form serialization, it will return just what we need
* Returns key/value pairs from form data
@@ -86,40 +47,44 @@ export default {
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
+ health_status: this.form.find('input[name="update[health_status]"]').val(),
+ epic_id: this.form.find('input[name="update[epic_id]"]').val(),
add_label_ids: [],
remove_label_ids: [],
},
};
if (this.willUpdateLabels) {
- formData.update.add_label_ids = this.$labelDropdown.data('marked');
- formData.update.remove_label_ids = this.$labelDropdown.data('unmarked');
+ formData.update.add_label_ids = this.$labelDropdown.data('user-checked');
+ formData.update.remove_label_ids = this.$labelDropdown.data('user-unchecked');
}
return formData;
},
setOriginalDropdownData() {
const $labelSelect = $('.bulk-update .js-label-select');
- const dirtyLabelIds = $labelSelect.data('marked') || [];
- const chosenLabelIds = [...this.getOriginalMarkedIds(), ...dirtyLabelIds];
-
- $labelSelect.data('common', this.getOriginalCommonIds());
- $labelSelect.data('marked', chosenLabelIds);
- $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds());
+ const userCheckedIds = $labelSelect.data('user-checked') || [];
+ const userUncheckedIds = $labelSelect.data('user-unchecked') || [];
+
+ // Common labels plus user checked labels minus user unchecked labels
+ const checkedIdsToShow = difference(
+ union(this.getOriginalCommonIds(), userCheckedIds),
+ userUncheckedIds,
+ );
+
+ // Indeterminate labels minus user checked labels minus user unchecked labels
+ const indeterminateIdsToShow = difference(
+ this.getOriginalIndeterminateIds(),
+ userCheckedIds,
+ userUncheckedIds,
+ );
+
+ $labelSelect.data('marked', checkedIdsToShow);
+ $labelSelect.data('indeterminate', indeterminateIdsToShow);
},
// From issuable's initial bulk selection
getOriginalCommonIds() {
const labelIds = [];
-
- this.getElement('.selected-issuable:checked').each((i, el) => {
- labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
- });
- return intersection.apply(this, labelIds);
- },
-
- // From issuable's initial bulk selection
- getOriginalMarkedIds() {
- const labelIds = [];
this.getElement('.selected-issuable:checked').each((i, el) => {
labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
});
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
index 50562688c53..85c2a370ff3 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -63,6 +63,22 @@ export default class IssuableBulkUpdateSidebar {
new MilestoneSelect();
issueStatusSelect();
subscriptionSelect();
+
+ if (IS_EE) {
+ import('ee/vue_shared/components/sidebar/health_status_select/health_status_bundle')
+ .then(({ default: HealthStatusSelect }) => {
+ HealthStatusSelect();
+ })
+ .catch(() => {});
+ }
+
+ if (IS_EE) {
+ import('ee/vue_shared/components/sidebar/epics_select/epics_select_bundle')
+ .then(({ default: EpicSelect }) => {
+ EpicSelect();
+ })
+ .catch(() => {});
+ }
}
setupBulkUpdateActions() {
diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issuable_suggestions/components/app.vue
index 67d10b797fb..810ca7ac1bd 100644
--- a/app/assets/javascripts/issuable_suggestions/components/app.vue
+++ b/app/assets/javascripts/issuable_suggestions/components/app.vue
@@ -84,7 +84,7 @@ export default {
v-for="(suggestion, index) in issues"
:key="suggestion.id"
:class="{
- 'append-bottom-default': index !== issues.length - 1,
+ 'gl-mb-3': index !== issues.length - 1,
}"
>
<suggestion :suggestion="suggestion" />
diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue
index 51904c64085..dfadb9d2b24 100644
--- a/app/assets/javascripts/issuable_suggestions/components/item.vue
+++ b/app/assets/javascripts/issuable_suggestions/components/item.vue
@@ -75,7 +75,11 @@ export default {
name="eye-slash"
class="suggestion-help-hover mr-1 suggestion-confidential"
/>
- <gl-link :href="suggestion.webUrl" target="_blank" class="suggestion bold str-truncated-100">
+ <gl-link
+ :href="suggestion.webUrl"
+ target="_blank"
+ class="suggestion bold str-truncated-100 gl-text-gray-900!"
+ >
{{ suggestion.title }}
</gl-link>
</div>
diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue
index 947c7518289..b7f4292a126 100644
--- a/app/assets/javascripts/issuables_list/components/issuable.vue
+++ b/app/assets/javascripts/issuables_list/components/issuable.vue
@@ -3,8 +3,11 @@
* This is tightly coupled to projects/issues/_issue.html.haml,
* any changes done to the haml need to be reflected here.
*/
+
+// TODO: need to move this component to graphql - https://gitlab.com/gitlab-org/gitlab/-/issues/221246
import { escape, isNumber } from 'lodash';
-import { GlLink, GlTooltipDirective as GlTooltip, GlSprintf } from '@gitlab/ui';
+import { GlLink, GlTooltipDirective as GlTooltip, GlSprintf, GlLabel, GlIcon } from '@gitlab/ui';
+import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg';
import {
dateInWords,
formatDate,
@@ -16,22 +19,26 @@ import {
import { sprintf, __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import Icon from '~/vue_shared/components/icon.vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
i18n: {
openedAgo: __('opened %{timeAgoString} by %{user}'),
+ openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'),
},
components: {
- Icon,
IssueAssignees,
GlLink,
+ GlLabel,
+ GlIcon,
GlSprintf,
},
directives: {
GlTooltip,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
issuable: {
type: Object,
@@ -55,14 +62,19 @@ export default {
},
},
},
+ data() {
+ return {
+ jiraLogo,
+ };
+ },
computed: {
milestoneLink() {
const { title } = this.issuable.milestone;
return this.issuableLink({ milestone_title: title });
},
- hasLabels() {
- return Boolean(this.issuable.labels && this.issuable.labels.length);
+ scopedLabelsAvailable() {
+ return this.glFeatures.scopedLabels;
},
hasWeight() {
return isNumber(this.issuable.weight);
@@ -82,6 +94,12 @@ export default {
isClosed() {
return this.issuable.state === 'closed';
},
+ isJiraIssue() {
+ return this.issuable.external_tracker === 'jira';
+ },
+ linkTarget() {
+ return this.isJiraIssue ? '_blank' : null;
+ },
issueCreatedToday() {
return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1;
},
@@ -147,14 +165,14 @@ export default {
value: this.issuable.upvotes,
title: __('Upvotes'),
class: 'js-upvotes',
- faicon: 'fa-thumbs-up',
+ icon: 'thumb-up',
},
{
key: 'downvotes',
value: this.issuable.downvotes,
title: __('Downvotes'),
class: 'js-downvotes',
- faicon: 'fa-thumbs-down',
+ icon: 'thumb-down',
},
];
},
@@ -165,16 +183,17 @@ export default {
initUserPopovers([this.$refs.openedAgoByContainer.$el]);
},
methods: {
- labelStyle(label) {
- return {
- backgroundColor: label.color,
- color: label.text_color,
- };
- },
issuableLink(params) {
return mergeUrlParams(params, this.baseUrl);
},
+ isScoped({ name }) {
+ return isScopedLabel({ title: name }) && this.scopedLabelsAvailable;
+ },
labelHref({ name }) {
+ if (this.isJiraIssue) {
+ return this.issuableLink({ 'labels[]': name });
+ }
+
return this.issuableLink({ 'label_name[]': name });
},
onSelect(ev) {
@@ -214,14 +233,23 @@ export default {
<div class="flex-grow-1">
<div class="title">
<span class="issue-title-text">
- <i
+ <gl-icon
v-if="issuable.confidential"
v-gl-tooltip
- class="fa fa-eye-slash"
+ name="eye-slash"
+ class="gl-vertical-align-text-bottom"
+ :size="16"
:title="$options.confidentialTooltipText"
:aria-label="$options.confidentialTooltipText"
- ></i>
- <gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link>
+ />
+ <gl-link :href="issuable.web_url" :target="linkTarget" data-testid="issuable-title">
+ {{ issuable.title }}
+ <gl-icon
+ v-if="isJiraIssue"
+ name="external-link"
+ class="gl-vertical-align-text-bottom"
+ />
+ </gl-link>
</span>
<span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block">
{{ issuable.task_status }}
@@ -229,11 +257,21 @@ export default {
</div>
<div class="issuable-info">
- <span class="js-ref-path">{{ referencePath }}</span>
+ <span class="js-ref-path">
+ <span
+ v-if="isJiraIssue"
+ class="svg-container jira-logo-container"
+ data-testid="jira-logo"
+ v-html="jiraLogo"
+ ></span>
+ {{ referencePath }}
+ </span>
<span data-testid="openedByMessage" class="d-none d-sm-inline-block mr-1">
&middot;
- <gl-sprintf :message="$options.i18n.openedAgo">
+ <gl-sprintf
+ :message="isJiraIssue ? $options.i18n.openedAgoJira : $options.i18n.openedAgo"
+ >
<template #timeAgoString>
<span>{{ issuableCreatedAt }}</span>
</template>
@@ -242,6 +280,7 @@ export default {
ref="openedAgoByContainer"
v-bind="popoverDataAttrs"
:href="issuableAuthor.web_url"
+ :target="linkTarget"
>
{{ issuableAuthor.name }}
</gl-link>
@@ -271,30 +310,29 @@ export default {
{{ dueDateWords }}
</span>
- <span v-if="hasLabels" class="js-labels">
- <gl-link
- v-for="label in issuable.labels"
- :key="label.id"
- class="label-link mr-1"
- :href="labelHref(label)"
- >
- <span
- v-gl-tooltip
- class="badge color-label"
- :style="labelStyle(label)"
- :title="label.description"
- >{{ label.name }}</span
- >
- </gl-link>
- </span>
+ <gl-label
+ v-for="label in issuable.labels"
+ :key="label.id"
+ data-qa-selector="issuable-label"
+ :target="labelHref(label)"
+ :background-color="label.color"
+ :description="label.description"
+ :color="label.text_color"
+ :title="label.name"
+ :scoped="isScoped(label)"
+ size="sm"
+ class="mr-1"
+ >{{ label.name }}</gl-label
+ >
<span
v-if="hasWeight"
v-gl-tooltip
:title="__('Weight')"
class="d-none d-sm-inline-block js-weight"
+ data-testid="weight"
>
- <icon name="weight" class="align-text-bottom" />
+ <gl-icon name="weight" class="align-text-bottom" />
{{ issuable.weight }}
</span>
</div>
@@ -303,7 +341,8 @@ export default {
<!-- Issuable meta -->
<div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center">
<div class="controls d-flex">
- <span v-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span>
+ <span v-if="isJiraIssue" data-testid="issuable-status">{{ issuable.status }}</span>
+ <span v-else-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span>
<issue-assignees
:assignees="issuable.assignees"
@@ -318,23 +357,23 @@ export default {
v-if="meta.value"
:key="meta.key"
v-gl-tooltip
- :class="['d-none d-sm-inline-block ml-2', meta.class]"
+ :class="['d-none d-sm-inline-block ml-2 vertical-align-middle', meta.class]"
:title="meta.title"
>
- <icon v-if="meta.icon" :name="meta.icon" />
- <i v-else :class="['fa', meta.faicon]"></i>
+ <gl-icon v-if="meta.icon" :name="meta.icon" />
{{ meta.value }}
</span>
</template>
<gl-link
+ v-if="!isJiraIssue"
v-gl-tooltip
class="ml-2 js-notes"
:href="`${issuable.web_url}#notes`"
:title="__('Comments')"
:class="{ 'no-comments': hasNoComments }"
>
- <i class="fa fa-comments"></i>
+ <gl-icon name="comments" class="gl-vertical-align-text-bottom" />
{{ userNotesCount }}
</gl-link>
</div>
diff --git a/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue
index 49a89d15c35..cc90d23eda7 100644
--- a/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue
+++ b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue
@@ -1,10 +1,13 @@
<script>
import { GlAlert, GlLabel } from '@gitlab/ui';
+import { last } from 'lodash';
+import { n__ } from '~/locale';
import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql';
import {
calculateJiraImportLabel,
- isFinished,
isInProgress,
+ setFinishedAlertHideMap,
+ shouldShowFinishedAlert,
} from '~/jira_import/utils/jira_import_utils';
export default {
@@ -33,8 +36,6 @@ export default {
},
data() {
return {
- isFinishedAlertShowing: true,
- isInProgressAlertShowing: true,
jiraImport: {},
};
},
@@ -46,36 +47,42 @@ export default {
fullPath: this.projectPath,
};
},
- update: ({ project }) => ({
- isInProgress: isInProgress(project.jiraImportStatus),
- isFinished: isFinished(project.jiraImportStatus),
- label: calculateJiraImportLabel(
+ update: ({ project }) => {
+ const label = calculateJiraImportLabel(
project.jiraImports.nodes,
project.issues.nodes.flatMap(({ labels }) => labels.nodes),
- ),
- }),
+ );
+ return {
+ importedIssuesCount: last(project.jiraImports.nodes)?.importedIssuesCount,
+ label,
+ shouldShowFinishedAlert: shouldShowFinishedAlert(label.title, project.jiraImportStatus),
+ shouldShowInProgressAlert: isInProgress(project.jiraImportStatus),
+ };
+ },
skip() {
return !this.isJiraConfigured || !this.canEdit;
},
},
},
computed: {
+ finishedMessage() {
+ return n__(
+ '%d issue successfully imported with the label',
+ '%d issues successfully imported with the label',
+ this.jiraImport.importedIssuesCount,
+ );
+ },
labelTarget() {
return `${this.issuesPath}?label_name[]=${encodeURIComponent(this.jiraImport.label.title)}`;
},
- shouldShowFinishedAlert() {
- return this.isFinishedAlertShowing && this.jiraImport.isFinished;
- },
- shouldShowInProgressAlert() {
- return this.isInProgressAlertShowing && this.jiraImport.isInProgress;
- },
},
methods: {
hideFinishedAlert() {
- this.isFinishedAlertShowing = false;
+ setFinishedAlertHideMap(this.jiraImport.label.title);
+ this.jiraImport.shouldShowFinishedAlert = false;
},
hideInProgressAlert() {
- this.isInProgressAlertShowing = false;
+ this.jiraImport.shouldShowInProgressAlert = false;
},
},
};
@@ -83,11 +90,16 @@ export default {
<template>
<div class="issuable-list-root">
- <gl-alert v-if="shouldShowInProgressAlert" @dismiss="hideInProgressAlert">
+ <gl-alert v-if="jiraImport.shouldShowInProgressAlert" @dismiss="hideInProgressAlert">
{{ __('Import in progress. Refresh page to see newly added issues.') }}
</gl-alert>
- <gl-alert v-if="shouldShowFinishedAlert" variant="success" @dismiss="hideFinishedAlert">
- {{ __('Issues successfully imported with the label') }}
+
+ <gl-alert
+ v-if="jiraImport.shouldShowFinishedAlert"
+ variant="success"
+ @dismiss="hideFinishedAlert"
+ >
+ {{ finishedMessage }}
<gl-label
:background-color="jiraImport.label.color"
scoped
diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue
index 1c395fd9795..21aeb2ca143 100644
--- a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue
+++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue
@@ -12,8 +12,10 @@ import {
import { __ } from '~/locale';
import initManualOrdering from '~/manual_ordering';
import Issuable from './issuable.vue';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import {
sortOrderMap,
+ availableSortOptionsJira,
RELATIVE_POSITION,
PAGE_SIZE,
PAGE_SIZE_MANUAL,
@@ -29,6 +31,7 @@ export default {
GlPagination,
GlSkeletonLoading,
Issuable,
+ FilteredSearchBar,
},
props: {
canBulkEdit: {
@@ -50,14 +53,25 @@ export default {
type: String,
required: true,
},
+ projectPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
sortKey: {
type: String,
required: false,
default: '',
},
+ type: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
+ availableSortOptionsJira,
filters: {},
isBulkEditing: false,
issuables: [],
@@ -118,6 +132,45 @@ export default {
baseUrl() {
return window.location.href.replace(/(\?.*)?(#.*)?$/, '');
},
+ paginationNext() {
+ return this.page + 1;
+ },
+ paginationPrev() {
+ return this.page - 1;
+ },
+ paginationProps() {
+ const paginationProps = { value: this.page };
+
+ if (this.totalItems) {
+ return {
+ ...paginationProps,
+ perPage: this.itemsPerPage,
+ totalItems: this.totalItems,
+ };
+ }
+
+ return {
+ ...paginationProps,
+ prevPage: this.paginationPrev,
+ nextPage: this.paginationNext,
+ };
+ },
+ isJira() {
+ return this.type === 'jira';
+ },
+ initialFilterValue() {
+ const value = [];
+ const { search } = this.getQueryObject();
+
+ if (search) {
+ value.push(search);
+ }
+ return value;
+ },
+ initialSortBy() {
+ const { sort } = this.getQueryObject();
+ return sort || 'created_desc';
+ },
},
watch: {
selection() {
@@ -222,9 +275,13 @@ export default {
const {
label_name: labels,
milestone_title: milestoneTitle,
+ 'not[label_name]': excludedLabels,
+ 'not[milestone_title]': excludedMilestone,
...filters
} = this.getQueryObject();
+ // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/227880
+
if (milestoneTitle) {
filters.milestone = milestoneTitle;
}
@@ -235,58 +292,104 @@ export default {
filters.state = 'opened';
}
+ if (excludedLabels) {
+ filters['not[labels]'] = excludedLabels;
+ }
+
+ if (excludedMilestone) {
+ filters['not[milestone]'] = excludedMilestone;
+ }
+
Object.assign(filters, sortOrderMap[this.sortKey]);
this.filters = filters;
},
+ refetchIssuables() {
+ const ignored = ['utf8'];
+ const params = omit(this.filters, ignored);
+
+ historyPushState(setUrlParams(params, window.location.href, true, true));
+ this.fetchIssuables();
+ },
+ handleFilter(filters) {
+ let search = null;
+
+ filters.forEach(filter => {
+ if (typeof filter === 'string') {
+ search = filter;
+ }
+ });
+
+ this.filters.search = search;
+ this.page = 1;
+
+ this.refetchIssuables();
+ },
+ handleSort(sort) {
+ this.filters.sort = sort;
+ this.page = 1;
+
+ this.refetchIssuables();
+ },
},
};
</script>
<template>
- <ul v-if="loading" class="content-list">
- <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue gl-px-5! gl-py-5!">
- <gl-skeleton-loading />
- </li>
- </ul>
- <div v-else-if="issuables.length">
- <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light">
- <input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" />
- <strong>{{ __('Select all') }}</strong>
- </div>
- <ul
- class="content-list issuable-list issues-list"
- :class="{ 'manual-ordering': isManualOrdering }"
- >
- <issuable
- v-for="issuable in issuables"
- :key="issuable.id"
- class="pr-3"
- :class="{ 'user-can-drag': isManualOrdering }"
- :issuable="issuable"
- :is-bulk-editing="isBulkEditing"
- :selected="isSelected(issuable.id)"
- :base-url="baseUrl"
- @select="onSelectIssuable"
- />
+ <div>
+ <filtered-search-bar
+ v-if="isJira"
+ :namespace="projectPath"
+ :search-input-placeholder="__('Search Jira issues')"
+ :tokens="[]"
+ :sort-options="availableSortOptionsJira"
+ :initial-filter-value="initialFilterValue"
+ :initial-sort-by="initialSortBy"
+ class="row-content-block"
+ @onFilter="handleFilter"
+ @onSort="handleSort"
+ />
+ <ul v-if="loading" class="content-list">
+ <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue gl-px-5! gl-py-5!">
+ <gl-skeleton-loading />
+ </li>
</ul>
- <div class="mt-3">
- <gl-pagination
- v-if="totalItems"
- :value="page"
- :per-page="itemsPerPage"
- :total-items="totalItems"
- class="justify-content-center"
- @input="onPaginate"
- />
+ <div v-else-if="issuables.length">
+ <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light">
+ <input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" />
+ <strong>{{ __('Select all') }}</strong>
+ </div>
+ <ul
+ class="content-list issuable-list issues-list"
+ :class="{ 'manual-ordering': isManualOrdering }"
+ >
+ <issuable
+ v-for="issuable in issuables"
+ :key="issuable.id"
+ class="pr-3"
+ :class="{ 'user-can-drag': isManualOrdering }"
+ :issuable="issuable"
+ :is-bulk-editing="isBulkEditing"
+ :selected="isSelected(issuable.id)"
+ :base-url="baseUrl"
+ @select="onSelectIssuable"
+ />
+ </ul>
+ <div class="mt-3">
+ <gl-pagination
+ v-bind="paginationProps"
+ class="gl-justify-content-center"
+ @input="onPaginate"
+ />
+ </div>
</div>
+ <gl-empty-state
+ v-else
+ :title="emptyState.title"
+ :description="emptyState.description"
+ :svg-path="emptySvgPath"
+ :primary-button-link="emptyState.primaryLink"
+ :primary-button-text="emptyState.primaryText"
+ />
</div>
- <gl-empty-state
- v-else
- :title="emptyState.title"
- :description="emptyState.description"
- :svg-path="emptySvgPath"
- :primary-button-link="emptyState.primaryLink"
- :primary-button-text="emptyState.primaryText"
- />
</template>
diff --git a/app/assets/javascripts/issuables_list/constants.js b/app/assets/javascripts/issuables_list/constants.js
index 71b9c52c703..f008ba1bf4a 100644
--- a/app/assets/javascripts/issuables_list/constants.js
+++ b/app/assets/javascripts/issuables_list/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
// Maps sort order as it appears in the URL query to API `order_by` and `sort` params.
const PRIORITY = 'priority';
const ASC = 'asc';
@@ -31,3 +33,24 @@ export const sortOrderMap = {
weight_desc: { order_by: WEIGHT, sort: DESC },
weight: { order_by: WEIGHT, sort: ASC },
};
+
+export const availableSortOptionsJira = [
+ {
+ id: 1,
+ title: __('Created date'),
+ sortDirection: {
+ descending: 'created_desc',
+ ascending: 'created_asc',
+ },
+ },
+ {
+ id: 2,
+ title: __('Last updated'),
+ sortDirection: {
+ descending: 'updated_desc',
+ ascending: 'updated_asc',
+ },
+ },
+];
+
+export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js
index 6bfb885a8af..40252c10d5f 100644
--- a/app/assets/javascripts/issuables_list/index.js
+++ b/app/assets/javascripts/issuables_list/index.js
@@ -36,7 +36,7 @@ function mountIssuableListRootApp() {
}
function mountIssuablesListApp() {
- if (!gon.features?.vueIssuablesList) {
+ if (!gon.features?.vueIssuablesList && !gon.features?.jiraIssuesIntegration) {
return;
}
diff --git a/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql
index b62b9b2af60..8f9b888d19b 100644
--- a/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql
+++ b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql
@@ -1,5 +1,3 @@
-#import "~/jira_import/queries/jira_import.fragment.graphql"
-
query($fullPath: ID!) {
project(fullPath: $fullPath) {
issues {
@@ -15,7 +13,8 @@ query($fullPath: ID!) {
jiraImportStatus
jiraImports {
nodes {
- ...JiraImport
+ importedIssuesCount
+ jiraProjectKey
}
}
}
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 252e8e92f5e..a01faeb1c8d 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -11,7 +11,7 @@ import { __ } from './locale';
export default class Issue {
constructor() {
- if ($('a.btn-close').length) this.initIssueBtnEventListeners();
+ if ($('.btn-close, .btn-reopen').length) this.initIssueBtnEventListeners();
if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener();
@@ -32,8 +32,8 @@ export default class Issue {
Issue.initRelatedBranches();
}
- this.closeButtons = $('a.btn-close');
- this.reopenButtons = $('a.btn-reopen');
+ this.closeButtons = $('.btn-close');
+ this.reopenButtons = $('.btn-reopen');
this.initCloseReopenReport();
@@ -103,7 +103,7 @@ export default class Issue {
// NOTE: data attribute seems unnecessary but is actually necessary
return $('.js-issuable-buttons[data-action="close-reopen"]').on(
'click',
- 'a.btn-close, a.btn-reopen, a.btn-close-anyway',
+ '.btn-close, .btn-reopen, .btn-close-anyway',
e => {
e.preventDefault();
e.stopImmediatePropagation();
@@ -120,7 +120,7 @@ export default class Issue {
} else {
this.disableCloseReopenButton($button);
- const url = $button.attr('href');
+ const url = $button.data('endpoint');
return axios
.put(url)
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 09acfd1cfae..bcf5dc2aaaf 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -420,7 +420,7 @@ export default {
<transition name="issuable-header-slide">
<div
v-if="shouldShowStickyHeader"
- class="issue-sticky-header gl-fixed gl-z-index-2 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-200 gl-py-3"
+ class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3"
data-testid="issue-sticky-header"
>
<div
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
index 588ae655de4..4ee44e50d2f 100644
--- a/app/assets/javascripts/issue_show/components/edit_actions.vue
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -63,7 +63,7 @@ export default {
</script>
<template>
- <div class="prepend-top-default append-bottom-default clearfix">
+ <div class="gl-mt-3 gl-mb-3 clearfix">
<button
:class="{ disabled: formState.updateLoading || !isSubmitEnabled }"
:disabled="formState.updateLoading || !isSubmitEnabled"
@@ -81,7 +81,7 @@ export default {
v-if="shouldShowDeleteButton"
:class="{ disabled: deleteLoading }"
:disabled="deleteLoading"
- class="btn btn-danger float-right append-right-default qa-delete-button"
+ class="btn btn-danger float-right gl-mr-3 qa-delete-button"
type="button"
@click="deleteIssuable"
>
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index 35165c9b481..0de0060615b 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -55,7 +55,7 @@ export default {
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
dir="auto"
- data-supports-quick-actions="false"
+ data-supports-quick-actions="true"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="updateIssuable"
diff --git a/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue b/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue
new file mode 100644
index 00000000000..b6816be9eb8
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue
@@ -0,0 +1,28 @@
+<script>
+import { mapState } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ },
+ computed: {
+ ...mapState({
+ confidential: ({ noteableData }) => noteableData.confidential,
+ dicussionLocked: ({ noteableData }) => noteableData.discussion_locked,
+ }),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-inline-block">
+ <div v-if="confidential" class="issuable-warning-icon inline">
+ <icon class="icon" name="eye-slash" data-testid="confidential" />
+ </div>
+
+ <div v-if="dicussionLocked" class="issuable-warning-icon inline">
+ <icon class="icon" name="lock" data-testid="locked" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issue_show/components/pinned_links.vue
index 4b50acceb62..a877aa2ac96 100644
--- a/app/assets/javascripts/issue_show/components/pinned_links.vue
+++ b/app/assets/javascripts/issue_show/components/pinned_links.vue
@@ -1,11 +1,10 @@
<script>
-import { GlLink } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
+import { GlButton } from '@gitlab/ui';
+import { STATUS_PAGE_PUBLISHED, JOIN_ZOOM_MEETING } from '../constants';
export default {
components: {
- Icon,
- GlLink,
+ GlButton,
},
props: {
zoomMeetingUrl: {
@@ -19,32 +18,46 @@ export default {
default: '',
},
},
+ computed: {
+ pinnedLinks() {
+ return [
+ {
+ id: 'publishedIncidentUrl',
+ url: this.publishedIncidentUrl,
+ text: STATUS_PAGE_PUBLISHED,
+ icon: 'tanuki',
+ },
+ {
+ id: 'zoomMeetingUrl',
+ url: this.zoomMeetingUrl,
+ text: JOIN_ZOOM_MEETING,
+ icon: 'brand-zoom',
+ },
+ ];
+ },
+ },
+ methods: {
+ needsPaddingClass(i) {
+ return i < this.pinnedLinks.length - 1;
+ },
+ },
};
</script>
<template>
<div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start">
- <div v-if="publishedIncidentUrl" class="gl-pr-3">
- <gl-link
- :href="publishedIncidentUrl"
- target="_blank"
- class="btn btn-inverted btn-secondary btn-sm text-dark mb-3"
- data-testid="publishedIncidentUrl"
- >
- <icon name="tanuki" :size="14" />
- <strong class="vertical-align-top">{{ __('Published on status page') }}</strong>
- </gl-link>
- </div>
- <div v-if="zoomMeetingUrl">
- <gl-link
- :href="zoomMeetingUrl"
- target="_blank"
- class="btn btn-inverted btn-secondary btn-sm text-dark mb-3"
- data-testid="zoomMeetingUrl"
- >
- <icon name="brand-zoom" :size="14" />
- <strong class="vertical-align-top">{{ __('Join Zoom meeting') }}</strong>
- </gl-link>
- </div>
+ <template v-for="(link, i) in pinnedLinks">
+ <div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }">
+ <gl-button
+ :href="link.url"
+ target="_blank"
+ :icon="link.icon"
+ size="small"
+ class="gl-font-weight-bold gl-mb-5"
+ :data-testid="link.id"
+ >{{ link.text }}</gl-button
+ >
+ </div>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js
index d73cc8cf007..6bc6ed2b372 100644
--- a/app/assets/javascripts/issue_show/constants.js
+++ b/app/assets/javascripts/issue_show/constants.js
@@ -15,3 +15,6 @@ export const IssuableType = {
Epic: 'epic',
MergeRequest: 'merge_request',
};
+
+export const STATUS_PAGE_PUBLISHED = __('Published on status page');
+export const JOIN_ZOOM_MEETING = __('Join Zoom meeting');
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index e170d338408..fe4ff133145 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,6 +1,8 @@
import Vue from 'vue';
import issuableApp from './components/app.vue';
+import IssuableHeaderWarnings from './components/issuable_header_warnings.vue';
import { parseIssuableData } from './utils/parse_data';
+import { store } from '~/notes/stores';
export default function initIssueableApp() {
return new Vue({
@@ -15,3 +17,13 @@ export default function initIssueableApp() {
},
});
}
+
+export function issuableHeaderWarnings() {
+ return new Vue({
+ el: document.getElementById('js-issuable-header-warnings'),
+ store,
+ render(createElement) {
+ return createElement(IssuableHeaderWarnings);
+ },
+ });
+}
diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue
index ef0fc4716dd..6222bd28c9d 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_app.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue
@@ -3,6 +3,7 @@ import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { last } from 'lodash';
import { __ } from '~/locale';
import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql';
+import getJiraUserMappingMutation from '../queries/get_jira_user_mapping.mutation.graphql';
import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql';
import { addInProgressImportToStore } from '../utils/cache_update';
import { isInProgress, extractJiraProjectsOptions } from '../utils/jira_import_utils';
@@ -37,6 +38,10 @@ export default {
type: String,
required: true,
},
+ projectId: {
+ type: String,
+ required: true,
+ },
projectPath: {
type: String,
required: true,
@@ -48,10 +53,12 @@ export default {
},
data() {
return {
+ isSubmitting: false,
jiraImportDetails: {},
+ selectedProject: undefined,
+ userMappings: [],
errorMessage: '',
showAlert: false,
- selectedProject: undefined,
};
},
apollo: {
@@ -89,15 +96,43 @@ export default {
: 'jira-import::KEY-1';
},
},
+ mounted() {
+ if (this.isJiraConfigured) {
+ this.$apollo
+ .mutate({
+ mutation: getJiraUserMappingMutation,
+ variables: {
+ input: {
+ projectPath: this.projectPath,
+ startAt: 1,
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.jiraImportUsers.errors.length) {
+ this.setAlertMessage(data.jiraImportUsers.errors.join('. '));
+ } else {
+ this.userMappings = data.jiraImportUsers.jiraUsers;
+ }
+ })
+ .catch(() => this.setAlertMessage(__('There was an error retrieving the Jira users.')));
+ }
+ },
methods: {
initiateJiraImport(project) {
+ this.isSubmitting = true;
+
this.$apollo
.mutate({
mutation: initiateJiraImportMutation,
variables: {
input: {
- projectPath: this.projectPath,
jiraProjectKey: project,
+ projectPath: this.projectPath,
+ usersMapping: this.userMappings.map(({ gitlabId, jiraAccountId }) => ({
+ gitlabId,
+ jiraAccountId,
+ })),
},
},
update: (store, { data }) =>
@@ -110,7 +145,21 @@ export default {
this.selectedProject = undefined;
}
})
- .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.')));
+ .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.')))
+ .finally(() => {
+ this.isSubmitting = false;
+ });
+ },
+ updateMapping(jiraAccountId, gitlabId, gitlabUsername) {
+ this.userMappings = this.userMappings.map(userMapping =>
+ userMapping.jiraAccountId === jiraAccountId
+ ? {
+ ...userMapping,
+ gitlabId,
+ gitlabUsername,
+ }
+ : userMapping,
+ );
},
setAlertMessage(message) {
this.errorMessage = message;
@@ -155,9 +204,13 @@ export default {
v-else
v-model="selectedProject"
:import-label="importLabel"
+ :is-submitting="isSubmitting"
:issues-path="issuesPath"
:jira-projects="jiraImportDetails.projects"
+ :project-id="projectId"
+ :user-mappings="userMappings"
@initiateJiraImport="initiateJiraImport"
+ @updateMapping="updateMapping"
/>
</div>
</template>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue
index c2fe7b29c28..24bfb49a7d1 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_form.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue
@@ -1,22 +1,61 @@
<script>
-import { GlAvatar, GlButton, GlFormGroup, GlFormSelect, GlLabel } from '@gitlab/ui';
+import {
+ GlButton,
+ GlNewDropdown,
+ GlNewDropdownItem,
+ GlNewDropdownText,
+ GlFormGroup,
+ GlFormSelect,
+ GlIcon,
+ GlLabel,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlTable,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
export default {
name: 'JiraImportForm',
components: {
- GlAvatar,
GlButton,
+ GlNewDropdown,
+ GlNewDropdownItem,
+ GlNewDropdownText,
GlFormGroup,
GlFormSelect,
+ GlIcon,
GlLabel,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlTable,
},
- currentUserAvatarUrl: gon.current_user_avatar_url,
currentUsername: gon.current_username,
+ dropdownLabel: __('The GitLab user to which the Jira user %{jiraDisplayName} will be mapped'),
+ tableConfig: [
+ {
+ key: 'jiraDisplayName',
+ label: __('Jira display name'),
+ },
+ {
+ key: 'arrow',
+ label: '',
+ },
+ {
+ key: 'gitlabUsername',
+ label: __('GitLab username'),
+ },
+ ],
props: {
importLabel: {
type: String,
required: true,
},
+ isSubmitting: {
+ type: Boolean,
+ required: true,
+ },
issuesPath: {
type: String,
required: true,
@@ -25,6 +64,14 @@ export default {
type: Array,
required: true,
},
+ projectId: {
+ type: String,
+ required: true,
+ },
+ userMappings: {
+ type: Array,
+ required: true,
+ },
value: {
type: String,
required: false,
@@ -33,10 +80,53 @@ export default {
},
data() {
return {
+ isFetching: false,
+ searchTerm: '',
selectState: null,
+ users: [],
};
},
+ computed: {
+ shouldShowNoMatchesFoundText() {
+ return !this.isFetching && this.users.length === 0;
+ },
+ },
+ watch: {
+ searchTerm: debounce(function debouncedUserSearch() {
+ this.searchUsers();
+ }, 500),
+ },
+ mounted() {
+ this.searchUsers()
+ .then(data => {
+ this.initialUsers = data;
+ })
+ .catch(() => {});
+ },
methods: {
+ searchUsers() {
+ const params = {
+ active: true,
+ project_id: this.projectId,
+ search: this.searchTerm,
+ };
+
+ this.isFetching = true;
+
+ return axios
+ .get('/-/autocomplete/users.json', { params })
+ .then(({ data }) => {
+ this.users = data;
+ return data;
+ })
+ .finally(() => {
+ this.isFetching = false;
+ });
+ },
+ resetDropdown() {
+ this.searchTerm = '';
+ this.users = this.initialUsers;
+ },
initiateJiraImport(event) {
event.preventDefault();
if (this.value) {
@@ -70,6 +160,7 @@ export default {
>
<gl-form-select
id="jira-project-select"
+ data-qa-selector="jira_project_dropdown"
class="mb-2"
:options="jiraProjects"
:state="selectState"
@@ -79,7 +170,7 @@ export default {
</gl-form-group>
<gl-form-group
- class="row align-items-center"
+ class="row gl-align-items-center gl-mb-6"
:label="__('Issue label')"
label-cols-sm="2"
label-for="jira-project-label"
@@ -93,50 +184,65 @@ export default {
/>
</gl-form-group>
- <hr />
+ <h4 class="gl-mb-4">{{ __('Jira-GitLab user mapping template') }}</h4>
- <p class="offset-md-1">
+ <p>
{{
__(
- "For each Jira issue successfully imported, we'll create a new GitLab issue with the following data:",
+ `Jira users have been matched with similar GitLab users.
+ This can be overwritten by selecting a GitLab user from the dropdown in the "GitLab
+ username" column.
+ If it wasn't possible to match a Jira user with a GitLab user, the dropdown defaults to
+ the user conducting the import.`,
)
}}
</p>
- <gl-form-group
- class="row align-items-center mb-1"
- :label="__('Title')"
- label-cols-sm="2"
- label-for="jira-project-title"
- >
- <p id="jira-project-title" class="mb-2">{{ __('jira.issue.summary') }}</p>
- </gl-form-group>
- <gl-form-group
- class="row align-items-center mb-1"
- :label="__('Reporter')"
- label-cols-sm="2"
- label-for="jira-project-reporter"
- >
- <gl-avatar
- id="jira-project-reporter"
- class="mb-2"
- :src="$options.currentUserAvatarUrl"
- :size="24"
- :aria-label="$options.currentUsername"
- />
- </gl-form-group>
- <gl-form-group
- class="row align-items-center mb-1"
- :label="__('Description')"
- label-cols-sm="2"
- label-for="jira-project-description"
- >
- <p id="jira-project-description" class="mb-2">{{ __('jira.issue.description.content') }}</p>
- </gl-form-group>
+ <gl-table :fields="$options.tableConfig" :items="userMappings" fixed>
+ <template #cell(arrow)>
+ <gl-icon name="arrow-right" :aria-label="__('Will be mapped to')" />
+ </template>
+ <template #cell(gitlabUsername)="data">
+ <gl-new-dropdown
+ :text="data.value || $options.currentUsername"
+ class="w-100"
+ :aria-label="
+ sprintf($options.dropdownLabel, { jiraDisplayName: data.item.jiraDisplayName })
+ "
+ @hide="resetDropdown"
+ >
+ <gl-search-box-by-type v-model.trim="searchTerm" class="m-2" />
+
+ <div v-if="isFetching" class="gl-text-center">
+ <gl-loading-icon />
+ </div>
+
+ <gl-new-dropdown-item
+ v-for="user in users"
+ v-else
+ :key="user.id"
+ @click="$emit('updateMapping', data.item.jiraAccountId, user.id, user.username)"
+ >
+ {{ user.username }} ({{ user.name }})
+ </gl-new-dropdown-item>
+
+ <gl-new-dropdown-text v-show="shouldShowNoMatchesFoundText" class="text-secondary">
+ {{ __('No matches found') }}
+ </gl-new-dropdown-text>
+ </gl-new-dropdown>
+ </template>
+ </gl-table>
<div class="footer-block row-content-block d-flex justify-content-between">
- <gl-button type="submit" category="primary" variant="success" class="js-no-auto-disable">
- {{ __('Next') }}
+ <gl-button
+ type="submit"
+ category="primary"
+ variant="success"
+ class="js-no-auto-disable"
+ :loading="isSubmitting"
+ data-qa-selector="jira_issues_import_button"
+ >
+ {{ __('Continue') }}
</gl-button>
<gl-button :href="issuesPath">{{ __('Cancel') }}</gl-button>
</div>
diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js
index 924cc7e6864..695a237bf50 100644
--- a/app/assets/javascripts/jira_import/index.js
+++ b/app/assets/javascripts/jira_import/index.js
@@ -28,6 +28,7 @@ export default function mountJiraImportApp() {
isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
issuesPath: el.dataset.issuesPath,
jiraIntegrationPath: el.dataset.jiraIntegrationPath,
+ projectId: el.dataset.projectId,
projectPath: el.dataset.projectPath,
setupIllustration: el.dataset.setupIllustration,
},
diff --git a/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql
new file mode 100644
index 00000000000..1f7c52eec58
--- /dev/null
+++ b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql
@@ -0,0 +1,11 @@
+mutation($input: JiraImportUsersInput!) {
+ jiraImportUsers(input: $input) {
+ jiraUsers {
+ jiraAccountId
+ jiraDisplayName
+ jiraEmail
+ gitlabId
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/jira_import/utils/jira_import_utils.js b/app/assets/javascripts/jira_import/utils/jira_import_utils.js
index e82a3f44a29..a1186b087e1 100644
--- a/app/assets/javascripts/jira_import/utils/jira_import_utils.js
+++ b/app/assets/javascripts/jira_import/utils/jira_import_utils.js
@@ -1,4 +1,5 @@
import { last } from 'lodash';
+import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issuables_list/constants';
export const IMPORT_STATE = {
FAILED: 'failed',
@@ -68,3 +69,36 @@ export const calculateJiraImportLabel = (jiraImports, labels) => {
title,
};
};
+
+/**
+ * Calculates whether the Jira import success alert should be shown.
+ *
+ * @param {string} labelTitle - Jira import label, for checking localStorage
+ * @param {string} importStatus - Jira import status
+ * @returns {boolean} - A boolean indicating whether to show the success alert
+ */
+export const shouldShowFinishedAlert = (labelTitle, importStatus) => {
+ const finishedAlertHideMap =
+ JSON.parse(localStorage.getItem(JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY)) || {};
+
+ const shouldHide = finishedAlertHideMap[labelTitle];
+
+ return !shouldHide && isFinished(importStatus);
+};
+
+/**
+ * Updates the localStorage map to permanently hide the Jira import success alert
+ *
+ * @param {string} labelTitle - Jira import label, for checking localStorage
+ */
+export const setFinishedAlertHideMap = labelTitle => {
+ const finishedAlertHideMap =
+ JSON.parse(localStorage.getItem(JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY)) || {};
+
+ finishedAlertHideMap[labelTitle] = true;
+
+ localStorage.setItem(
+ JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY,
+ JSON.stringify(finishedAlertHideMap),
+ );
+};
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index 72a5ff01672..c4f180f200c 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -32,7 +32,7 @@ export default {
block: !isLastBlock,
}"
>
- <p class="append-bottom-5">
+ <p class="gl-mb-2">
<span class="font-weight-bold">{{ __('Commit') }}</span>
<gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue
index c34a3488dbd..c78738221f1 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/environments_block.vue
@@ -274,7 +274,7 @@ export default {
};
</script>
<template>
- <div class="prepend-top-default append-bottom-default js-environment-container">
+ <div class="gl-mt-3 gl-mb-3 js-environment-container">
<div class="environment-information">
<ci-icon :status="iconStatus" />
<p class="inline gl-mb-0" v-html="environment"></p>
diff --git a/app/assets/javascripts/jobs/components/erased_block.vue b/app/assets/javascripts/jobs/components/erased_block.vue
index fc5e022f44a..a6d1b41c275 100644
--- a/app/assets/javascripts/jobs/components/erased_block.vue
+++ b/app/assets/javascripts/jobs/components/erased_block.vue
@@ -27,7 +27,7 @@ export default {
};
</script>
<template>
- <div class="prepend-top-default js-build-erased">
+ <div class="gl-mt-3 js-build-erased">
<div class="erased alert alert-warning">
<template v-if="isErasedByUser">
{{ s__('Job|Job has been erased by') }}
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 0783d1157be..f43a058b5f8 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -17,7 +17,7 @@ import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue';
import Sidebar from './sidebar.vue';
import { sprintf } from '~/locale';
import delayedJobMixin from '../mixins/delayed_job_mixin';
-import { isNewJobLogActive } from '../store/utils';
+import Log from './log/log.vue';
export default {
name: 'JobPageApp',
@@ -28,7 +28,7 @@ export default {
EnvironmentsBlock,
ErasedBlock,
Icon,
- Log: () => (isNewJobLogActive() ? import('./log/log.vue') : import('./job_log.vue')),
+ Log,
LogTopBar,
StuckBlock,
UnmetPrerequisitesBlock,
@@ -270,7 +270,7 @@ export default {
<div
v-if="job.archived"
ref="sticky"
- class="js-archived-job prepend-top-default archived-job"
+ class="js-archived-job gl-mt-3 archived-job"
:class="{ 'sticky-top border-bottom-0': hasTrace }"
>
<icon name="lock" class="align-text-bottom" />
@@ -280,7 +280,7 @@ export default {
<div
v-if="hasTrace"
class="build-trace-container position-relative"
- :class="{ 'prepend-top-default': !job.archived }"
+ :class="{ 'gl-mt-3': !job.archived }"
>
<log-top-bar
:class="{
diff --git a/app/assets/javascripts/jobs/components/job_log.vue b/app/assets/javascripts/jobs/components/job_log.vue
deleted file mode 100644
index 20888c0af42..00000000000
--- a/app/assets/javascripts/jobs/components/job_log.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-<script>
-import { mapState, mapActions } from 'vuex';
-
-export default {
- name: 'JobLog',
- props: {
- trace: {
- type: String,
- required: true,
- },
- isComplete: {
- type: Boolean,
- required: true,
- },
- },
- computed: {
- ...mapState(['isScrolledToBottomBeforeReceivingTrace']),
- },
- updated() {
- this.$nextTick(() => {
- this.handleScrollDown();
- });
- },
- mounted() {
- this.$nextTick(() => {
- this.handleScrollDown();
- });
- },
- methods: {
- ...mapActions(['scrollBottom']),
- /**
- * The job log is sent in HTML, which means we need to use `v-html` to render it
- * Using the updated hook with $nextTick is not enough to wait for the DOM to be updated
- * in this case because it runs before `v-html` has finished running, since there's no
- * Vue binding.
- * In order to scroll the page down after `v-html` has finished, we need to use setTimeout
- */
- handleScrollDown() {
- if (this.isScrolledToBottomBeforeReceivingTrace) {
- setTimeout(() => {
- this.scrollBottom();
- }, 0);
- }
- },
- },
-};
-</script>
-<template>
- <pre class="js-build-trace build-trace qa-build-trace">
- <code class="bash" v-html="trace">
- </code>
-
- <div v-if="!isComplete" class="js-log-animation build-loader-animation">
- <div class="dot"></div>
- <div class="dot"></div>
- <div class="dot"></div>
- </div>
- </pre>
-</template>
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index bcec83a7aee..a68174d8e1d 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -77,7 +77,7 @@ export default {
<gl-link
v-if="rawPath"
:href="rawPath"
- class="js-raw-link text-plain text-underline prepend-left-5"
+ class="js-raw-link text-plain text-underline gl-ml-2"
>{{ s__('Job|Complete Raw') }}</gl-link
>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
index 0c7b78a3da7..55cdfb691f4 100644
--- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue
+++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
@@ -3,7 +3,7 @@ import LogLine from './line.vue';
import LogLineHeader from './line_header.vue';
export default {
- name: 'CollpasibleLogSection',
+ name: 'CollapsibleLogSection',
components: {
LogLine,
LogLineHeader,
diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue
index 33ee84bd4ee..48f669ae8ed 100644
--- a/app/assets/javascripts/jobs/components/log/line.vue
+++ b/app/assets/javascripts/jobs/components/log/line.vue
@@ -2,9 +2,7 @@
import LineNumber from './line_number.vue';
export default {
- components: {
- LineNumber,
- },
+ functional: true,
props: {
line: {
type: Object,
@@ -15,18 +13,28 @@ export default {
required: true,
},
},
+ render(h, { props }) {
+ const { line, path } = props;
+
+ const chars = line.content.map(content => {
+ return h(
+ 'span',
+ {
+ class: ['ws-pre-wrap', content.style],
+ },
+ content.text,
+ );
+ });
+
+ return h('div', { class: 'js-line log-line' }, [
+ h(LineNumber, {
+ props: {
+ lineNumber: line.lineNumber,
+ path,
+ },
+ }),
+ ...chars,
+ ]);
+ },
};
</script>
-
-<template>
- <div class="js-line log-line">
- <line-number :line-number="line.lineNumber" :path="path" />
- <span
- v-for="(content, i) in line.content"
- :key="i"
- :class="content.style"
- class="ws-pre-wrap"
- >{{ content.text }}</span
- >
- </div>
-</template>
diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/jobs/components/log/line_number.vue
index ae96c32874b..7ca9154d2fe 100644
--- a/app/assets/javascripts/jobs/components/log/line_number.vue
+++ b/app/assets/javascripts/jobs/components/log/line_number.vue
@@ -1,10 +1,6 @@
<script>
-import { GlLink } from '@gitlab/ui';
-
export default {
- components: {
- GlLink,
- },
+ functional: true,
props: {
lineNumber: {
type: Number,
@@ -15,41 +11,24 @@ export default {
required: true,
},
},
- computed: {
- /**
- * Builds the url for each line number
- *
- * @returns {String}
- */
- buildLineNumber() {
- return `${this.path}#${this.lineNumberId}`;
- },
- /**
- * Array indexes start with 0, so we add 1
- * to create the line number
- *
- * @returns {Number} the line number
- */
- parsedLineNumber() {
- return this.lineNumber + 1;
- },
+ render(h, { props }) {
+ const { lineNumber, path } = props;
- /**
- * Creates the anchor for each link
- *
- * @returns {String}
- */
- lineNumberId() {
- return `L${this.parsedLineNumber}`;
- },
+ const parsedLineNumber = lineNumber + 1;
+ const lineId = `L${parsedLineNumber}`;
+ const lineHref = `${path}#${lineId}`;
+
+ return h(
+ 'a',
+ {
+ class: 'gl-link d-inline-block text-right line-number flex-shrink-0',
+ attrs: {
+ id: lineId,
+ href: lineHref,
+ },
+ },
+ parsedLineNumber,
+ );
},
};
</script>
-<template>
- <gl-link
- :id="lineNumberId"
- class="d-inline-block text-right line-number flex-shrink-0"
- :href="buildLineNumber"
- >{{ parsedLineNumber }}</gl-link
- >
-</template>
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index f0bdbde0602..0134e5dafe8 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -1,11 +1,11 @@
<script>
import { mapState, mapActions } from 'vuex';
-import CollpasibleLogSection from './collapsible_section.vue';
+import CollapsibleLogSection from './collapsible_section.vue';
import LogLine from './line.vue';
export default {
components: {
- CollpasibleLogSection,
+ CollapsibleLogSection,
LogLine,
},
computed: {
@@ -51,7 +51,7 @@ export default {
<template>
<code class="job-log d-block" data-qa-selector="job_log_content">
<template v-for="(section, index) in trace">
- <collpasible-log-section
+ <collapsible-log-section
v-if="section.isHeader"
:key="`collapsible-${index}`"
:section="section"
diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue
index d4aab5c7faf..d83c598dd48 100644
--- a/app/assets/javascripts/jobs/components/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue
@@ -112,7 +112,7 @@ export default {
<div v-for="variable in variables" :key="variable.id" class="gl-responsive-table-row">
<div class="table-section section-50">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div>
- <div class="table-mobile-content append-right-10">
+ <div class="table-mobile-content gl-mr-3">
<input
:ref="`${$options.inputTypes.key}-${variable.id}`"
v-model="variable.key"
@@ -124,7 +124,7 @@ export default {
<div class="table-section section-50">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div>
- <div class="table-mobile-content append-right-10">
+ <div class="table-mobile-content gl-mr-3">
<input
:ref="`${$options.inputTypes.value}-${variable.id}`"
v-model="variable.secret_value"
@@ -149,7 +149,7 @@ export default {
<div class="gl-responsive-table-row">
<div class="table-section section-50">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div>
- <div class="table-mobile-content append-right-10">
+ <div class="table-mobile-content gl-mr-3">
<input
ref="inputKey"
v-model="key"
@@ -161,7 +161,7 @@ export default {
<div class="table-section section-50">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div>
- <div class="table-mobile-content append-right-10">
+ <div class="table-mobile-content gl-mr-3">
<input
ref="inputSecretValue"
v-model="secretValue"
@@ -172,7 +172,7 @@ export default {
</div>
</div>
</div>
- <div class="d-flex prepend-top-default justify-content-center">
+ <div class="d-flex gl-mt-3 justify-content-center">
<p class="text-muted" v-html="helpText"></p>
</div>
<div class="d-flex justify-content-center">
diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue
index da01269a50c..b69e6f9686f 100644
--- a/app/assets/javascripts/jobs/components/stuck_block.vue
+++ b/app/assets/javascripts/jobs/components/stuck_block.vue
@@ -31,7 +31,7 @@ export default {
s__(`This job is stuck because you don't have
any active runners online or available with any of these tags assigned to them:`)
}}
- <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary append-right-4">
+ <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary gl-mr-2">
{{ tag }}
</span>
</p>
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue
index 1a076249fe7..f55429ecdae 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/trigger_block.vue
@@ -46,7 +46,7 @@ export default {
<p
v-if="trigger.short_token"
class="js-short-token"
- :class="{ 'append-bottom-5': hasVariables, 'gl-mb-0': !hasVariables }"
+ :class="{ 'gl-mb-2': hasVariables, 'gl-mb-0': !hasVariables }"
>
<span class="font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }}
</p>
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index 0ce8dfe4442..4bd8d6f58a6 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -195,7 +195,7 @@ export const receiveTraceError = ({ dispatch }) => {
flash(__('An error occurred while fetching the job log.'));
};
/**
- * When the user clicks a collpasible line in the job
+ * When the user clicks a collapsible line in the job
* log, we commit a mutation to update the state
*
* @param {Object} section
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index 6193d8d34ab..924b811d0d6 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import * as types from './mutation_types';
-import { logLinesParser, updateIncrementalTrace, isNewJobLogActive } from './utils';
+import { logLinesParser, updateIncrementalTrace } from './utils';
export default {
[types.SET_JOB_ENDPOINT](state, endpoint) {
@@ -25,22 +25,16 @@ export default {
}
if (log.append) {
- if (isNewJobLogActive()) {
- state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace;
- } else {
- state.trace += log.html;
- }
+ state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace;
+
state.traceSize += log.size;
} else {
// When the job still does not have a trace
// the trace response will not have a defined
// html or size. We keep the old value otherwise these
// will be set to `null`
- if (isNewJobLogActive()) {
- state.trace = log.lines ? logLinesParser(log.lines) : state.trace;
- } else {
- state.trace = log.html || state.trace;
- }
+ state.trace = log.lines ? logLinesParser(log.lines) : state.trace;
+
state.traceSize = log.size || state.traceSize;
}
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index d76828ad19b..2fe945b2985 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -1,5 +1,3 @@
-import { isNewJobLogActive } from './utils';
-
export default () => ({
jobEndpoint: null,
traceEndpoint: null,
@@ -18,7 +16,7 @@ export default () => ({
// Used to check if we should keep the automatic scroll
isScrolledToBottomBeforeReceivingTrace: true,
- trace: isNewJobLogActive() ? [] : '',
+ trace: [],
isTraceComplete: false,
traceSize: 0,
isTraceSizeVisible: false,
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index 0b28c52a78f..8d6e5aac566 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -11,7 +11,7 @@ export const parseLine = (line = {}, lineNumber) => ({
/**
* When a line has `section_header` set to true, we create a new
* structure to allow to nest the lines that belong to the
- * collpasible section
+ * collapsible section
*
* @param Object line
* @param Number lineNumber
@@ -91,7 +91,7 @@ export const getIncrementalLineNumber = acc => {
* Parses the job log content into a structure usable by the template
*
* For collaspible lines (section_header = true):
- * - creates a new array to hold the lines that are collpasible,
+ * - creates a new array to hold the lines that are collapsible,
* - adds a isClosed property to handle toggle
* - adds a isHeader property to handle template logic
* - adds the section_duration
@@ -177,5 +177,3 @@ export const updateIncrementalTrace = (newLog = [], oldParsed = []) => {
return logLinesParser(newLog, parsedLog);
};
-
-export const isNewJobLogActive = () => gon && gon.features && gon.features.jobLogJson;
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 65d8866fcc3..63c4ad3c410 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -3,7 +3,7 @@
/* global ListLabel */
import $ from 'jquery';
-import { isEqual, escape, sortBy, template } from 'lodash';
+import { difference, isEqual, escape, sortBy, template } from 'lodash';
import { sprintf, s__, __ } from './locale';
import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
@@ -497,7 +497,7 @@ export default class LabelsSelect {
const scopedLabelTemplate = template(
[
- '<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>;">',
+ '<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>; --label-inset-border: inset 0 0 0 2px <%= escapeStr(label.color) %>;">',
linkOpenTag,
spanOpenTag,
'<%- label.title.slice(0, label.title.lastIndexOf("::")) %>',
@@ -526,9 +526,7 @@ export default class LabelsSelect {
[
'<% labels.forEach(function(label){ %>',
'<% if (isScopedLabel(label) && enableScopedLabels) { %>',
- '<span class="d-inline-block position-relative scoped-label-wrapper">',
'<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>',
- '</span>',
'<% } else { %>',
'<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>',
'<% } %>',
@@ -562,45 +560,20 @@ export default class LabelsSelect {
IssuableBulkUpdateActions.willUpdateLabels = true;
}
// eslint-disable-next-line class-methods-use-this
- setDropdownData($dropdown, isMarking, value) {
- const markedIds = $dropdown.data('marked') || [];
- const unmarkedIds = $dropdown.data('unmarked') || [];
- const indeterminateIds = $dropdown.data('indeterminate') || [];
-
- if (isMarking) {
- markedIds.push(value);
+ setDropdownData($dropdown, isChecking, labelId) {
+ let userCheckedIds = $dropdown.data('user-checked') || [];
+ let userUncheckedIds = $dropdown.data('user-unchecked') || [];
- let i = indeterminateIds.indexOf(value);
- if (i > -1) {
- indeterminateIds.splice(i, 1);
- }
-
- i = unmarkedIds.indexOf(value);
- if (i > -1) {
- unmarkedIds.splice(i, 1);
- }
+ if (isChecking) {
+ userCheckedIds = userCheckedIds.concat(labelId);
+ userUncheckedIds = difference(userUncheckedIds, [labelId]);
} else {
- // If marked item (not common) is unmarked
- const i = markedIds.indexOf(value);
- if (i > -1) {
- markedIds.splice(i, 1);
- }
-
- // If an indeterminate item is being unmarked
- if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) {
- unmarkedIds.push(value);
- }
-
- // If a marked item is being unmarked
- // (a marked item could also be a label that is present in all selection)
- if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) {
- unmarkedIds.push(value);
- }
+ userUncheckedIds = userUncheckedIds.concat(labelId);
+ userCheckedIds = difference(userCheckedIds, [labelId]);
}
- $dropdown.data('marked', markedIds);
- $dropdown.data('unmarked', unmarkedIds);
- $dropdown.data('indeterminate', indeterminateIds);
+ $dropdown.data('user-checked', userCheckedIds);
+ $dropdown.data('user-unchecked', userUncheckedIds);
}
// eslint-disable-next-line class-methods-use-this
setOriginalDropdownData($container, $dropdown) {
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
index 75542267f37..aa7fe087678 100644
--- a/app/assets/javascripts/lazy_loader.js
+++ b/app/assets/javascripts/lazy_loader.js
@@ -14,6 +14,10 @@ export default class LazyLoader {
scrollContainer.addEventListener('load', () => this.register());
}
+ static supportsNativeLazyLoading() {
+ return 'loading' in HTMLImageElement.prototype;
+ }
+
static supportsIntersectionObserver() {
return Boolean(window.IntersectionObserver);
}
@@ -23,7 +27,9 @@ export default class LazyLoader {
() => {
const lazyImages = [].slice.call(document.querySelectorAll('.lazy'));
- if (LazyLoader.supportsIntersectionObserver()) {
+ if (LazyLoader.supportsNativeLazyLoading()) {
+ lazyImages.forEach(img => LazyLoader.loadImage(img));
+ } else if (LazyLoader.supportsIntersectionObserver()) {
if (this.intersectionObserver) {
lazyImages.forEach(img => this.intersectionObserver.observe(img));
}
@@ -72,11 +78,14 @@ export default class LazyLoader {
}
register() {
- if (LazyLoader.supportsIntersectionObserver()) {
- this.startIntersectionObserver();
- } else {
- this.startLegacyObserver();
+ if (!LazyLoader.supportsNativeLazyLoading()) {
+ if (LazyLoader.supportsIntersectionObserver()) {
+ this.startIntersectionObserver();
+ } else {
+ this.startLegacyObserver();
+ }
}
+
this.startContentObserver();
this.searchLazyImages();
}
@@ -148,16 +157,12 @@ export default class LazyLoader {
static loadImage(img) {
if (img.getAttribute('data-src')) {
+ img.setAttribute('loading', 'lazy');
let imgUrl = img.getAttribute('data-src');
// Only adding width + height for avatars for now
if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) {
- let targetWidth = null;
- if (img.getAttribute('width')) {
- targetWidth = img.getAttribute('width');
- } else {
- targetWidth = img.width;
- }
- if (targetWidth) imgUrl += `?width=${targetWidth}`;
+ const targetWidth = img.getAttribute('width') || img.width;
+ imgUrl += `?width=${targetWidth}`;
}
img.setAttribute('src', imgUrl);
img.removeAttribute('data-src');
diff --git a/app/assets/javascripts/lib/utils/axios_startup_calls.js b/app/assets/javascripts/lib/utils/axios_startup_calls.js
new file mode 100644
index 00000000000..cb2e8a76c08
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/axios_startup_calls.js
@@ -0,0 +1,52 @@
+import { isEmpty } from 'lodash';
+import { mergeUrlParams } from './url_utility';
+
+// We should probably not couple this utility to `gon.gitlab_url`
+// Also, this would replace occurrences that aren't at the beginning of the string
+const removeGitLabUrl = url => url.replace(gon.gitlab_url, '');
+
+const getFullUrl = req => {
+ const url = removeGitLabUrl(req.url);
+ return mergeUrlParams(req.params || {}, url);
+};
+
+const setupAxiosStartupCalls = axios => {
+ const { startup_calls: startupCalls } = window.gl || {};
+
+ if (!startupCalls || isEmpty(startupCalls)) {
+ return;
+ }
+
+ // TODO: To save performance of future axios calls, we can
+ // remove this interceptor once the "startupCalls" have been loaded
+ axios.interceptors.request.use(req => {
+ const fullUrl = getFullUrl(req);
+
+ const existing = startupCalls[fullUrl];
+
+ if (existing) {
+ // eslint-disable-next-line no-param-reassign
+ req.adapter = () =>
+ existing.fetchCall.then(res => {
+ const fetchHeaders = {};
+ res.headers.forEach((val, key) => {
+ fetchHeaders[key] = val;
+ });
+
+ // eslint-disable-next-line promise/no-nesting
+ return res.json().then(data => ({
+ data,
+ status: res.status,
+ statusText: res.statusText,
+ headers: fetchHeaders,
+ config: req,
+ request: req,
+ }));
+ });
+ }
+
+ return req;
+ });
+};
+
+export default setupAxiosStartupCalls;
diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js
index 4eec5bffc66..9d517f45caa 100644
--- a/app/assets/javascripts/lib/utils/axios_utils.js
+++ b/app/assets/javascripts/lib/utils/axios_utils.js
@@ -1,6 +1,7 @@
import axios from 'axios';
import csrf from './csrf';
import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation';
+import setupAxiosStartupCalls from './axios_startup_calls';
axios.defaults.headers.common[csrf.headerKey] = csrf.token;
// Used by Rails to check if it is a valid XHR request
@@ -14,6 +15,8 @@ axios.interceptors.request.use(config => {
return config;
});
+setupAxiosStartupCalls(axios);
+
// Remove the global counter
axios.interceptors.response.use(
response => {
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index a60748215ab..8bf9a281151 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -53,16 +53,6 @@ export const getCspNonceValue = () => {
return metaTag && metaTag.content;
};
-export const ajaxGet = url =>
- axios
- .get(url, {
- params: { format: 'js' },
- responseType: 'text',
- })
- .then(({ data }) => {
- $.globalEval(data, { nonce: getCspNonceValue() });
- });
-
export const rstrip = val => {
if (val) {
return val.replace(/\s+$/, '');
@@ -105,6 +95,7 @@ export const handleLocationHash = () => {
const topPadding = 8;
const diffFileHeader = document.querySelector('.js-file-title');
const versionMenusContainer = document.querySelector('.mr-version-menus-container');
+ const fixedIssuableTitle = document.querySelector('.issue-sticky-header');
let adjustment = 0;
if (fixedNav) adjustment -= fixedNav.offsetHeight;
@@ -133,6 +124,10 @@ export const handleLocationHash = () => {
adjustment -= versionMenusContainer.offsetHeight;
}
+ if (isInIssuePage()) {
+ adjustment -= fixedIssuableTitle.offsetHeight;
+ }
+
if (isInMRPage()) {
adjustment -= topPadding;
}
@@ -370,34 +365,6 @@ export const insertText = (target, text) => {
target.dispatchEvent(event);
};
-export const nodeMatchesSelector = (node, selector) => {
- const matches =
- Element.prototype.matches ||
- Element.prototype.matchesSelector ||
- Element.prototype.mozMatchesSelector ||
- Element.prototype.msMatchesSelector ||
- Element.prototype.oMatchesSelector ||
- Element.prototype.webkitMatchesSelector;
-
- if (matches) {
- return matches.call(node, selector);
- }
-
- // IE11 doesn't support `node.matches(selector)`
-
- let { parentNode } = node;
-
- if (!parentNode) {
- parentNode = document.createElement('div');
- // eslint-disable-next-line no-param-reassign
- node = node.cloneNode(true);
- parentNode.appendChild(node);
- }
-
- const matchingNodes = parentNode.querySelectorAll(selector);
- return Array.prototype.indexOf.call(matchingNodes, node) !== -1;
-};
-
/**
this will take in the headers from an API response and normalize them
this way we don't run into production issues when nginx gives us lowercased header keys
@@ -413,24 +380,6 @@ export const normalizeHeaders = headers => {
};
/**
- this will take in the getAllResponseHeaders result and normalize them
- this way we don't run into production issues when nginx gives us lowercased header keys
-*/
-export const normalizeCRLFHeaders = headers => {
- const headersObject = {};
- const headersArray = headers.split('\n');
-
- headersArray.forEach(header => {
- const keyValue = header.split(': ');
-
- // eslint-disable-next-line prefer-destructuring
- headersObject[keyValue[0]] = keyValue[1];
- });
-
- return normalizeHeaders(headersObject);
-};
-
-/**
* Parses pagination object string values into numbers.
*
* @param {Object} paginationInformation
@@ -638,13 +587,6 @@ export const setFaviconOverlay = overlayPath => {
);
};
-export const setFavicon = faviconPath => {
- const faviconEl = document.getElementById('favicon');
- if (faviconEl && faviconPath) {
- faviconEl.setAttribute('href', faviconPath);
- }
-};
-
export const resetFavicon = () => {
const faviconEl = document.getElementById('favicon');
@@ -883,35 +825,6 @@ export const searchBy = (query = '', searchSpace = {}) => {
*/
export const isScopedLabel = ({ title = '' }) => title.indexOf('::') !== -1;
-window.gl = window.gl || {};
-window.gl.utils = {
- ...(window.gl.utils || {}),
- getPagePath,
- isInGroupsPage,
- isInProjectPage,
- getProjectSlug,
- getGroupSlug,
- isInIssuePage,
- ajaxGet,
- rstrip,
- updateTooltipTitle,
- disableButtonIfEmptyField,
- handleLocationHash,
- isInViewport,
- parseUrl,
- parseUrlPathname,
- getUrlParamsArray,
- isMetaKey,
- isMetaClick,
- scrollToElement,
- getParameterByName,
- getSelectedFragment,
- insertText,
- nodeMatchesSelector,
- spriteIcon,
- imagePath,
-};
-
// Methods to set and get Cookie
export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 });
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index eb6c9bf7eb6..993d51370ec 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -1,5 +1,7 @@
export const BYTES_IN_KIB = 1024;
export const HIDDEN_CLASS = 'hidden';
+export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
+export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12;
export const DATETIME_RANGE_TYPES = {
fixed: 'fixed',
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 6b69d2febe0..6e02fc1eb91 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -89,13 +89,15 @@ export const getDayName = date =>
* @example
* dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am GMT+0000"
* @param {date} datetime
+ * @param {String} format
+ * @param {Boolean} UTC convert local time to UTC
* @returns {String}
*/
-export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => {
+export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z', utc = false) => {
if (isString(datetime) && datetime.match(/\d+-\d+\d+ /)) {
throw new Error(__('Invalid date'));
}
- return dateFormat(datetime, format);
+ return dateFormat(datetime, format, utc);
};
/**
@@ -425,7 +427,6 @@ export const dayInQuarter = (date, quarter) => {
window.gl = window.gl || {};
window.gl.utils = {
...(window.gl.utils || {}),
- getTimeago,
localTimeAgo,
};
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index 8fa235f8afb..d9b0e8c4476 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -1,3 +1,4 @@
+import { has } from 'lodash';
import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils';
export const addClassIfElementExists = (element, className) => {
@@ -25,3 +26,24 @@ export const toggleContainerClasses = (containerEl, classList) => {
});
}
};
+
+/**
+ * Return a object mapping element dataset names to booleans.
+ *
+ * This is useful for data- attributes whose presense represent
+ * a truthiness, no matter the value of the attribute. The absense of the
+ * attribute represents falsiness.
+ *
+ * This can be useful when Rails-provided boolean-like values are passed
+ * directly to the HAML template, rather than cast to a string.
+ *
+ * @param {HTMLElement} element - The DOM element to inspect
+ * @param {string[]} names - The dataset (i.e., camelCase) names to inspect
+ * @returns {Object.<string, boolean>}
+ */
+export const parseBooleanDataAttributes = ({ dataset }, names) =>
+ names.reduce((acc, name) => {
+ acc[name] = has(dataset, name);
+
+ return acc;
+ }, {});
diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js
index 18f9e2ed846..b1f38429369 100644
--- a/app/assets/javascripts/lib/utils/grammar.js
+++ b/app/assets/javascripts/lib/utils/grammar.js
@@ -20,18 +20,22 @@ export const toNounSeriesText = items => {
if (items.length === 0) {
return '';
} else if (items.length === 1) {
- return items[0];
+ return sprintf(s__(`nounSeries|%{item}`), { item: items[0] }, false);
} else if (items.length === 2) {
- return sprintf(s__('nounSeries|%{firstItem} and %{lastItem}'), {
- firstItem: items[0],
- lastItem: items[1],
- });
+ return sprintf(
+ s__('nounSeries|%{firstItem} and %{lastItem}'),
+ {
+ firstItem: items[0],
+ lastItem: items[1],
+ },
+ false,
+ );
}
return items.reduce((item, nextItem, idx) =>
idx === items.length - 1
- ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem })
- : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }),
+ ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }, false)
+ : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }, false),
);
};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 0dfc144c363..4d25ee9e4bd 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -27,9 +27,28 @@ function lineAfter(text, textarea) {
.split('\n')[0];
}
+function convertMonacoSelectionToAceFormat(sel) {
+ return {
+ start: {
+ row: sel.startLineNumber,
+ column: sel.startColumn,
+ },
+ end: {
+ row: sel.endLineNumber,
+ column: sel.endColumn,
+ },
+ };
+}
+
+function getEditorSelectionRange(editor) {
+ return window.gon.features?.monacoBlobs
+ ? convertMonacoSelectionToAceFormat(editor.getSelection())
+ : editor.getSelectionRange();
+}
+
function editorBlockTagText(text, blockTag, selected, editor) {
const lines = text.split('\n');
- const selectionRange = editor.getSelectionRange();
+ const selectionRange = getEditorSelectionRange(editor);
const shouldRemoveBlock =
lines[selectionRange.start.row - 1] === blockTag &&
lines[selectionRange.end.row + 1] === blockTag;
@@ -90,8 +109,12 @@ function moveCursor({
const endPosition = startPosition + select.length;
return textArea.setSelectionRange(startPosition, endPosition);
} else if (editor) {
- editor.navigateLeft(tag.length - tag.indexOf(select));
- editor.getSelection().selectAWord();
+ if (window.gon.features?.monacoBlobs) {
+ editor.selectWithinSelection(select, tag);
+ } else {
+ editor.navigateLeft(tag.length - tag.indexOf(select));
+ editor.getSelection().selectAWord();
+ }
return;
}
}
@@ -115,7 +138,11 @@ function moveCursor({
}
} else if (editor && editorSelectionStart.row === editorSelectionEnd.row) {
if (positionBetweenTags) {
- editor.navigateLeft(tag.length);
+ if (window.gon.features?.monacoBlobs) {
+ editor.moveCursor(tag.length * -1);
+ } else {
+ editor.navigateLeft(tag.length);
+ }
}
}
}
@@ -140,7 +167,7 @@ export function insertMarkdownText({
let textToInsert;
if (editor) {
- const selectionRange = editor.getSelectionRange();
+ const selectionRange = getEditorSelectionRange(editor);
editorSelectionStart = selectionRange.start;
editorSelectionEnd = selectionRange.end;
@@ -237,7 +264,11 @@ export function insertMarkdownText({
}
if (editor) {
- editor.insert(textToInsert);
+ if (window.gon.features?.monacoBlobs) {
+ editor.replaceSelectedText(textToInsert, select);
+ } else {
+ editor.insert(textToInsert);
+ }
} else {
insertText(textArea, textToInsert);
}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index be3fe1ed620..e2953ce330c 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -1,4 +1,9 @@
-import { isString } from 'lodash';
+import { isString, memoize } from 'lodash';
+
+import {
+ TRUNCATE_WIDTH_DEFAULT_WIDTH,
+ TRUNCATE_WIDTH_DEFAULT_FONT_SIZE,
+} from '~/lib/utils/constants';
/**
* Adds a , to a string composed by numbers, at every 3 chars.
@@ -73,7 +78,79 @@ export const slugifyWithUnderscore = str => slugify(str, '_');
* @param {Number} maxLength
* @returns {String}
*/
-export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`;
+export const truncate = (string, maxLength) => {
+ if (string.length - 1 > maxLength) {
+ return `${string.substr(0, maxLength - 1)}…`;
+ }
+
+ return string;
+};
+
+/**
+ * This function calculates the average char width. It does so by placing a string in the DOM and measuring the width.
+ * NOTE: This will cause a reflow and should be used sparsely!
+ * The default fontFamily is 'sans-serif' and 12px in ECharts, so that is the default basis for calculating the average with.
+ * https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontFamily
+ * https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontSize
+ * @param {Object} options
+ * @param {Number} options.fontSize style to size the text for measurement
+ * @param {String} options.fontFamily style of font family to measure the text with
+ * @param {String} options.chars string of chars to use as a basis for calculating average width
+ * @return {Number}
+ */
+const getAverageCharWidth = memoize(function getAverageCharWidth(options = {}) {
+ const {
+ fontSize = 12,
+ fontFamily = 'sans-serif',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ chars = ' ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
+ } = options;
+ const div = document.createElement('div');
+
+ div.style.fontFamily = fontFamily;
+ div.style.fontSize = `${fontSize}px`;
+ // Place outside of view
+ div.style.position = 'absolute';
+ div.style.left = -1000;
+ div.style.top = -1000;
+
+ div.innerHTML = chars;
+
+ document.body.appendChild(div);
+ const width = div.clientWidth;
+ document.body.removeChild(div);
+
+ return width / chars.length / fontSize;
+});
+
+/**
+ * This function returns a truncated version of `string` if its estimated rendered width is longer than `maxWidth`,
+ * otherwise it will return the original `string`
+ * Inspired by https://bl.ocks.org/tophtucker/62f93a4658387bb61e4510c37e2e97cf
+ * @param {String} string text to truncate
+ * @param {Object} options
+ * @param {Number} options.maxWidth largest rendered width the text may have
+ * @param {Number} options.fontSize size of the font used to render the text
+ * @return {String} either the original string or a truncated version
+ */
+export const truncateWidth = (string, options = {}) => {
+ const {
+ maxWidth = TRUNCATE_WIDTH_DEFAULT_WIDTH,
+ fontSize = TRUNCATE_WIDTH_DEFAULT_FONT_SIZE,
+ } = options;
+ const { truncateIndex } = string.split('').reduce(
+ (memo, char, index) => {
+ let newIndex = index;
+ if (memo.width > maxWidth) {
+ newIndex = memo.truncateIndex;
+ }
+ return { width: memo.width + getAverageCharWidth() * fontSize, truncateIndex: newIndex };
+ },
+ { width: 0, truncateIndex: 0 },
+ );
+
+ return truncate(string, truncateIndex);
+};
/**
* Truncate SHA to 8 characters
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 0472b8cf51f..c6c34b831ee 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -344,9 +344,15 @@ export function objectToQuery(obj) {
* @param {Object} params The query params to be set/updated
* @param {String} url The url to be operated on
* @param {Boolean} clearParams Indicates whether existing query params should be removed or not
+ * @param {Boolean} railsArraySyntax When enabled, changes the array syntax from `keys=` to `keys[]=` according to Rails conventions
* @returns {String} A copy of the original with the updated query params
*/
-export const setUrlParams = (params, url = window.location.href, clearParams = false) => {
+export const setUrlParams = (
+ params,
+ url = window.location.href,
+ clearParams = false,
+ railsArraySyntax = false,
+) => {
const urlObj = new URL(url);
const queryString = urlObj.search;
const searchParams = clearParams ? new URLSearchParams('') : new URLSearchParams(queryString);
@@ -355,11 +361,12 @@ export const setUrlParams = (params, url = window.location.href, clearParams = f
if (params[key] === null || params[key] === undefined) {
searchParams.delete(key);
} else if (Array.isArray(params[key])) {
+ const keyName = railsArraySyntax ? `${key}[]` : key;
params[key].forEach((val, idx) => {
if (idx === 0) {
- searchParams.set(key, val);
+ searchParams.set(keyName, val);
} else {
- searchParams.append(key, val);
+ searchParams.append(keyName, val);
}
});
} else {
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
index 01a4cbd41f6..f37f48aa431 100644
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ b/app/assets/javascripts/logs/components/environment_logs.vue
@@ -8,6 +8,7 @@ import {
GlDropdown,
GlDropdownHeader,
GlDropdownItem,
+ GlDropdownDivider,
GlInfiniteScroll,
} from '@gitlab/ui';
@@ -27,6 +28,7 @@ export default {
GlDropdown,
GlDropdownHeader,
GlDropdownItem,
+ GlDropdownDivider,
GlInfiniteScroll,
LogSimpleFilters,
LogAdvancedFilters,
@@ -55,6 +57,10 @@ export default {
type: String,
required: true,
},
+ clustersPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -63,7 +69,7 @@ export default {
};
},
computed: {
- ...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods']),
+ ...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods', 'managedApps']),
...mapGetters('environmentLogs', ['trace', 'showAdvancedFilters']),
showLoader() {
@@ -85,12 +91,15 @@ export default {
});
this.fetchEnvironments(this.environmentsPath);
+ this.fetchManagedApps(this.clustersPath);
},
methods: {
...mapActions('environmentLogs', [
'setInitData',
'showEnvironment',
+ 'showManagedApp',
'fetchEnvironments',
+ 'fetchManagedApps',
'refreshPodLogs',
'fetchMoreLogsPrepend',
'dismissRequestEnvironmentsError',
@@ -101,6 +110,9 @@ export default {
isCurrentEnvironment(envName) {
return envName === this.environments.current;
},
+ isCurrentManagedApp(appName) {
+ return appName === this.managedApps.current;
+ },
topReached() {
if (!this.logs.isLoading) {
this.fetchMoreLogsPrepend();
@@ -164,12 +176,12 @@ export default {
<div class="flex-grow-0">
<gl-dropdown
id="environments-dropdown"
- :text="environments.current"
+ :text="environments.current || managedApps.current"
:disabled="environments.isLoading"
class="mb-2 gl-h-32 pr-2 d-flex d-md-block js-environments-dropdown"
>
- <gl-dropdown-header class="text-center">
- {{ s__('Environments|Select environment') }}
+ <gl-dropdown-header class="gl-text-center">
+ {{ s__('Environments|Environments') }}
</gl-dropdown-header>
<gl-dropdown-item
v-for="env in environments.options"
@@ -181,7 +193,24 @@ export default {
:class="{ invisible: !isCurrentEnvironment(env.name) }"
name="status_success_borderless"
/>
- <div class="flex-grow-1">{{ env.name }}</div>
+ <div class="gl-flex-grow-1">{{ env.name }}</div>
+ </div>
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-header class="gl-text-center">
+ {{ s__('Environments|Managed apps') }}
+ </gl-dropdown-header>
+ <gl-dropdown-item
+ v-for="app in managedApps.options"
+ :key="app.id"
+ @click="showManagedApp(app.name)"
+ >
+ <div class="gl-display-flex">
+ <gl-icon
+ :class="{ invisible: !isCurrentManagedApp(app.name) }"
+ name="status_success_borderless"
+ />
+ <div class="gl-flex-grow-1">{{ app.name }}</div>
</div>
</gl-dropdown-item>
</gl-dropdown>
@@ -202,7 +231,7 @@ export default {
<log-control-buttons
ref="scrollButtons"
- class="flex-grow-0 pr-2 mb-2 controllers"
+ class="flex-grow-0 pr-2 mb-2 controllers gl-display-inline-flex"
:scroll-down-button-disabled="scrollDownButtonDisabled"
@refresh="refreshPodLogs()"
@scrollDown="scrollDown"
diff --git a/app/assets/javascripts/logs/components/log_control_buttons.vue b/app/assets/javascripts/logs/components/log_control_buttons.vue
index 3f5de4c22e0..e44b5394fa1 100644
--- a/app/assets/javascripts/logs/components/log_control_buttons.vue
+++ b/app/assets/javascripts/logs/components/log_control_buttons.vue
@@ -1,11 +1,9 @@
<script>
-import { GlDeprecatedButton, GlTooltipDirective } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
- Icon,
- GlDeprecatedButton,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -51,14 +49,16 @@ export default {
:title="__('Scroll to top')"
aria-labelledby="scroll-to-top"
>
- <gl-deprecated-button
+ <gl-button
id="scroll-to-top"
- class="btn-blank js-scroll-to-top"
+ class="js-scroll-to-top gl-mr-2 btn-blank"
:aria-label="__('Scroll to top')"
:disabled="scrollUpButtonDisabled"
+ icon="scroll_up"
+ category="primary"
+ variant="default"
@click="handleScrollUp()"
- ><icon name="scroll_up"
- /></gl-deprecated-button>
+ />
</div>
<div
v-if="scrollDownAvailable"
@@ -68,25 +68,28 @@ export default {
:title="__('Scroll to bottom')"
aria-labelledby="scroll-to-bottom"
>
- <gl-deprecated-button
+ <gl-button
id="scroll-to-bottom"
- class="btn-blank js-scroll-to-bottom"
+ class="js-scroll-to-bottom gl-mr-2 btn-blank"
:aria-label="__('Scroll to bottom')"
:v-if="scrollDownAvailable"
:disabled="scrollDownButtonDisabled"
+ icon="scroll_down"
+ category="primary"
+ variant="default"
@click="handleScrollDown()"
- ><icon name="scroll_down"
- /></gl-deprecated-button>
+ />
</div>
- <gl-deprecated-button
+ <gl-button
id="refresh-log"
v-gl-tooltip
- class="ml-1 px-2 js-refresh-log"
+ class="js-refresh-log"
:title="__('Refresh')"
:aria-label="__('Refresh')"
+ icon="retry"
+ category="primary"
+ variant="default"
@click="handleRefreshClick"
- >
- <icon name="retry" />
- </gl-deprecated-button>
+ />
</div>
</template>
diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js
index f83d369c6b8..0cdef53df34 100644
--- a/app/assets/javascripts/logs/constants.js
+++ b/app/assets/javascripts/logs/constants.js
@@ -8,4 +8,10 @@ export const tracking = {
TIME_RANGE_SET: 'time_range_set',
ENVIRONMENT_SELECTED: 'environment_selected',
REFRESH_POD_LOGS: 'refresh_pod_logs',
+ MANAGED_APP_SELECTED: 'managed_app_selected',
+};
+
+export const logExplorerOptions = {
+ environments: 'environments',
+ managedApps: 'managedApps',
};
diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js
index d828e8f8a3e..0edd825b6e9 100644
--- a/app/assets/javascripts/logs/stores/actions.js
+++ b/app/assets/javascripts/logs/stores/actions.js
@@ -2,7 +2,7 @@ import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import { TOKEN_TYPE_POD_NAME, tracking } from '../constants';
+import { TOKEN_TYPE_POD_NAME, tracking, logExplorerOptions } from '../constants';
import trackLogs from '../logs_tracking_helper';
import * as types from './mutation_types';
@@ -25,9 +25,15 @@ const requestUntilData = (url, params) =>
const requestLogsUntilData = ({ commit, state }) => {
const params = {};
- const { logs_api_path } = state.environments.options.find(
- ({ name }) => name === state.environments.current,
- );
+ const type = state.environments.current
+ ? logExplorerOptions.environments
+ : logExplorerOptions.managedApps;
+ const selectedObj = state[type].options.find(({ name }) => name === state[type].current);
+
+ const path =
+ type === logExplorerOptions.environments
+ ? selectedObj.logs_api_path
+ : selectedObj.gitlab_managed_apps_logs_path;
if (state.pods.current) {
params.pod_name = state.pods.current;
@@ -48,7 +54,7 @@ const requestLogsUntilData = ({ commit, state }) => {
params.cursor = state.logs.cursor;
}
- return requestUntilData(logs_api_path, params);
+ return requestUntilData(path, params);
};
/**
@@ -100,6 +106,11 @@ export const showEnvironment = ({ dispatch, commit }, environmentName) => {
dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
};
+export const showManagedApp = ({ dispatch, commit }, managedApp) => {
+ commit(types.SET_MANAGED_APP, managedApp);
+ dispatch('fetchLogs', tracking.MANAGED_APP_SELECTED);
+};
+
export const refreshPodLogs = ({ dispatch, commit }) => {
commit(types.REFRESH_POD_LOGS);
dispatch('fetchLogs', tracking.REFRESH_POD_LOGS);
@@ -124,6 +135,23 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
});
};
+/**
+ * Fetch managed apps data
+ * @param {Object} store
+ * @param {String} clustersPath
+ */
+
+export const fetchManagedApps = ({ commit }, clustersPath) => {
+ return axios
+ .get(clustersPath)
+ .then(({ data }) => {
+ commit(types.RECEIVE_MANAGED_APPS_DATA_SUCCESS, data.clusters);
+ })
+ .catch(() => {
+ commit(types.RECEIVE_MANAGED_APPS_DATA_ERROR);
+ });
+};
+
export const fetchLogs = ({ commit, state }, trackingLabel) => {
commit(types.REQUEST_LOGS_DATA);
diff --git a/app/assets/javascripts/logs/stores/mutation_types.js b/app/assets/javascripts/logs/stores/mutation_types.js
index 9010ec51817..eaa4b13f8bd 100644
--- a/app/assets/javascripts/logs/stores/mutation_types.js
+++ b/app/assets/javascripts/logs/stores/mutation_types.js
@@ -1,5 +1,6 @@
export const SET_PROJECT_ENVIRONMENT = 'SET_PROJECT_ENVIRONMENT';
export const SET_SEARCH = 'SET_SEARCH';
+export const SET_MANAGED_APP = 'SET_MANAGED_APP';
export const SET_TIME_RANGE = 'SET_TIME_RANGE';
export const SHOW_TIME_RANGE_INVALID_WARNING = 'SHOW_TIME_RANGE_INVALID_WARNING';
@@ -12,6 +13,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCC
export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR';
export const HIDE_REQUEST_ENVIRONMENTS_ERROR = 'HIDE_REQUEST_ENVIRONMENTS_ERROR';
+export const RECEIVE_MANAGED_APPS_DATA_SUCCESS = 'RECEIVE_MANAGED_APPS_DATA_SUCCESS';
+export const RECEIVE_MANAGED_APPS_DATA_ERROR = 'RECEIVE_MANAGED_APPS_DATA_ERROR';
+
export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA';
export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS';
export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR';
diff --git a/app/assets/javascripts/logs/stores/mutations.js b/app/assets/javascripts/logs/stores/mutations.js
index 5e1c794c3a9..be22204d88d 100644
--- a/app/assets/javascripts/logs/stores/mutations.js
+++ b/app/assets/javascripts/logs/stores/mutations.js
@@ -32,6 +32,9 @@ export default {
// Clear current pod options
state.pods.current = null;
state.pods.options = [];
+
+ // Clear current managedApps options
+ state.managedApps.current = null;
},
[types.REQUEST_ENVIRONMENTS_DATA](state) {
state.environments.options = [];
@@ -107,4 +110,24 @@ export default {
[types.RECEIVE_PODS_DATA_ERROR](state) {
state.pods.options = [];
},
+ // Managed apps data
+ [types.RECEIVE_MANAGED_APPS_DATA_SUCCESS](state, apps) {
+ state.managedApps.options = apps;
+ state.managedApps.isLoading = false;
+ },
+ [types.RECEIVE_MANAGED_APPS_DATA_ERROR](state) {
+ state.managedApps.options = [];
+ state.managedApps.isLoading = false;
+ state.managedApps.fetchError = true;
+ },
+ [types.SET_MANAGED_APP](state, managedApp) {
+ state.managedApps.current = managedApp;
+
+ // Clear current pod options
+ state.pods.current = null;
+ state.pods.options = [];
+
+ // Clear current environment options
+ state.environments.current = null;
+ },
};
diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js
index 11185c9ccf1..fbe6589dd84 100644
--- a/app/assets/javascripts/logs/stores/state.js
+++ b/app/assets/javascripts/logs/stores/state.js
@@ -31,6 +31,16 @@ export default () => ({
},
/**
+ * Managed apps list information
+ */
+ managedApps: {
+ options: [],
+ isLoading: false,
+ current: null,
+ fetchError: false,
+ },
+
+ /**
* Logs including trace
*/
logs: {
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 5f5fd790f67..3f85295a5ed 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -19,6 +19,7 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility';
// everything else
import loadAwardsHandler from './awards_handler';
+import applyGitLabUIConfig from '@gitlab/ui/dist/config';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Flash, { removeFlashClickListener } from './flash';
import './gl_dropdown';
@@ -32,7 +33,7 @@ import initFrequentItemDropdowns from './frequent_items';
import initBreadcrumbs from './breadcrumb';
import initUsagePingConsent from './usage_ping_consent';
import initPerformanceBar from './performance_bar';
-import initGlobalSearchInput from './global_search_input';
+import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
@@ -42,6 +43,8 @@ import { __ } from './locale';
import 'ee_else_ce/main_ee';
+applyGitLabUIConfig();
+
// expose jQuery as global (TODO: remove these)
window.jQuery = jQuery;
window.$ = jQuery;
@@ -62,12 +65,12 @@ function disableJQueryAnimations() {
}
// Disable jQuery animations
-if (gon && gon.disable_animations) {
+if (gon?.disable_animations) {
disableJQueryAnimations();
}
// inject test utilities if necessary
-if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) {
+if (process.env.NODE_ENV !== 'production' && gon?.test_env) {
disableJQueryAnimations();
import(/* webpackMode: "eager" */ './test_utils/'); // eslint-disable-line no-unused-expressions
}
@@ -110,7 +113,7 @@ function deferredInitialisation() {
initFrequentItemDropdowns();
initPersistentUserCallouts();
- if (document.querySelector('.search')) initGlobalSearchInput();
+ if (document.querySelector('.search')) initSearchAutocomplete();
addSelectOnFocusBehaviour('.js-select-on-focus');
@@ -132,27 +135,6 @@ function deferredInitialisation() {
.fadeOut();
});
- // Initialize select2 selects
- if ($('select.select2').length) {
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(() => {
- $('select.select2').select2({
- width: 'resolve',
- minimumResultsForSearch: 10,
- dropdownAutoWidth: true,
- });
-
- // Close select2 on escape
- $('.js-select2').on('select2-close', () => {
- setTimeout(() => {
- $('.select2-container-active').removeClass('select2-container-active');
- $(':focus').blur();
- }, 1);
- });
- })
- .catch(() => {});
- }
-
const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0;
@@ -179,9 +161,7 @@ function deferredInitialisation() {
document.addEventListener('DOMContentLoaded', () => {
const $body = $('body');
const $document = $(document);
- const $window = $(window);
- const $sidebarGutterToggle = $('.js-sidebar-toggle');
- let bootstrapBreakpoint = bp.getBreakpointSize();
+ const bootstrapBreakpoint = bp.getBreakpointSize();
if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
@@ -199,6 +179,15 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
+ /**
+ * TODO: Apparently we are collapsing the right sidebar on certain screensizes per default
+ * except on issue board pages. Why can't we do it with CSS?
+ *
+ * Proposal: Expose a global sidebar API, which we could import wherever we are manipulating
+ * the visibility of the sidebar.
+ *
+ * Quick fix: Get rid of jQuery for this implementation
+ */
const isBoardsPage = /(projects|groups):boards:show/.test(document.body.dataset.page);
if (!isBoardsPage && (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs')) {
const $rightSidebar = $('aside.right-sidebar');
@@ -225,14 +214,12 @@ document.addEventListener('DOMContentLoaded', () => {
localTimeAgo($('abbr.timeago, .js-timeago'), true);
- // Form submitter
- $('.trigger-submit').on('change', function triggerSubmitCallback() {
- $(this)
- .parents('form')
- .submit();
- });
-
- // Disable form buttons while a form is submitting
+ /**
+ * This disables form buttons while a form is submitting
+ * We do not difinitively know all of the places where this is used
+ *
+ * TODO: Defer execution, migrate to behaviors, and add sentry logging
+ */
$body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) {
const $buttons = $('[type="submit"], .js-disable-on-submit', this).not('.js-no-auto-disable');
switch (e.type) {
@@ -259,7 +246,11 @@ document.addEventListener('DOMContentLoaded', () => {
$('.header-content').toggleClass('menu-expanded');
});
- // Commit show suppressed diff
+ /**
+ * Show suppressed commit diff
+ *
+ * TODO: Move to commit diff pages
+ */
$document.on('click', '.diff-content .js-show-suppressed-diff', function showDiffCallback() {
const $container = $(this).parent();
$container.next('table').show();
@@ -290,39 +281,6 @@ document.addEventListener('DOMContentLoaded', () => {
$(document).trigger('toggle.comments');
});
- $document.on('breakpoint:change', (e, breakpoint) => {
- const breakpointSizes = ['md', 'sm', 'xs'];
- if (breakpointSizes.includes(breakpoint)) {
- const $gutterIcon = $sidebarGutterToggle.find('i');
- if ($gutterIcon.hasClass('fa-angle-double-right')) {
- $sidebarGutterToggle.trigger('click');
- }
-
- const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle');
-
- // Sidebar has an icon which corresponds to collapsing the sidebar
- // only then trigger the click.
- if (sidebarGutterVueToggleEl) {
- const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right');
-
- if (collapseIcon) {
- collapseIcon.click();
- }
- }
- }
- });
-
- function fitSidebarForSize() {
- const oldBootstrapBreakpoint = bootstrapBreakpoint;
- bootstrapBreakpoint = bp.getBreakpointSize();
-
- if (bootstrapBreakpoint !== oldBootstrapBreakpoint) {
- $document.trigger('breakpoint:change', [bootstrapBreakpoint]);
- }
- }
-
- $window.on('resize.app', fitSidebarForSize);
-
$('form.filter-form').on('submit', function filterFormSubmitCallback(event) {
const link = document.createElement('a');
link.href = this.action;
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index d719fd8748d..f220e9e0192 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { disableButtonIfEmptyField } from '~/lib/utils/common_utils';
export default class Members {
constructor() {
@@ -13,7 +14,7 @@ export default class Members {
$('.js-edit-member-form')
.off('ajax:success')
.on('ajax:success', this.formSuccess.bind(this));
- gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
+ disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
}
dropdownClicked(options) {
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 6c794c1d324..a90e4e32d34 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,6 +1,7 @@
/* eslint-disable func-names, no-underscore-dangle, consistent-return */
import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
import { __ } from '~/locale';
import createFlash from '~/flash';
import TaskList from './task_list';
@@ -65,9 +66,17 @@ MergeRequest.prototype.showAllCommits = function() {
MergeRequest.prototype.initMRBtnListeners = function() {
const _this = this;
- return $('a.btn-close, a.btn-reopen').on('click', function(e) {
+ return $('.btn-close, .btn-reopen').on('click', function(e) {
const $this = $(this);
const shouldSubmit = $this.hasClass('btn-comment');
+ if ($this.hasClass('js-btn-issue-action')) {
+ const url = $this.data('endpoint');
+ return axios
+ .put(url)
+ .then(() => window.location.reload())
+ .catch(() => createFlash(__('Something went wrong.')));
+ }
+
if (shouldSubmit && $this.data('submitted')) {
return;
}
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 6c63ab7cf95..e9b7b56a160 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -226,6 +226,8 @@ export default class MergeRequestTabs {
this.resetViewContainer();
this.destroyPipelinesView();
}
+
+ $('.detail-page-description').renderGFM();
} else if (action === this.currentAction) {
// ContentTop is used to handle anything at the top of the page before the main content
const mainContentContainer = document.querySelector('.content-wrapper');
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index 34da5885c97..ac401c6e381 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -218,7 +218,7 @@ export default {
<gl-chart-series-label :color="content.color">
{{ content.name }}
</gl-chart-series-label>
- <div class="prepend-left-32">
+ <div class="gl-ml-7">
{{ yValueFormatted(seriesIndex, content.dataIndex) }}
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
index f6f266dacf3..ddb44f7b1be 100644
--- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue
+++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
@@ -48,7 +48,10 @@ export default {
return this.result.values.map(val => {
const [yLabel] = val;
- return formatDate(new Date(yLabel), { format: formats.shortTime, timezone: this.timezone });
+ return formatDate(new Date(yLabel), {
+ format: formats.shortTime,
+ timezone: this.timezone,
+ });
});
},
result() {
diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js
index f7822e69b1d..42252dd5897 100644
--- a/app/assets/javascripts/monitoring/components/charts/options.js
+++ b/app/assets/javascripts/monitoring/components/charts/options.js
@@ -17,7 +17,9 @@ const defaultTooltipFormat = defaultFormat;
const defaultTooltipPrecision = 3;
// Give enough space for y-axis with units and name.
-const chartGridLeft = 75;
+const chartGridLeft = 63; // larger gap than gitlab-ui's default to fit formatted numbers
+const chartGridRight = 10; // half of the scroll-handle icon for data zoom
+const yAxisNameGap = chartGridLeft - 12; // offset the axis label line-height
// Axis options
@@ -62,7 +64,7 @@ export const getYAxisOptions = ({
precision = defaultYAxisPrecision,
} = {}) => {
return {
- nameGap: 63, // larger gap than gitlab-ui's default to fit with formatted numbers
+ nameGap: yAxisNameGap,
scale: true,
boundaryGap: yAxisBoundaryGap,
@@ -74,11 +76,14 @@ export const getYAxisOptions = ({
};
};
-export const getTimeAxisOptions = ({ timezone = timezones.LOCAL } = {}) => ({
+export const getTimeAxisOptions = ({
+ timezone = timezones.LOCAL,
+ format = formats.shortDateTime,
+} = {}) => ({
name: __('Time'),
type: axisTypes.time,
axisLabel: {
- formatter: date => formatDate(date, { format: formats.shortTime, timezone }),
+ formatter: date => formatDate(date, { format, timezone }),
},
axisPointer: {
snap: false,
@@ -90,7 +95,10 @@ export const getTimeAxisOptions = ({ timezone = timezones.LOCAL } = {}) => ({
/**
* Grid with enough room to display chart.
*/
-export const getChartGrid = ({ left = chartGridLeft } = {}) => ({ left });
+export const getChartGrid = ({ left = chartGridLeft, right = chartGridRight } = {}) => ({
+ left,
+ right,
+});
// Tooltip options
diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
index eee5eaa5eca..106c76a97dc 100644
--- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue
+++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
@@ -1,9 +1,11 @@
<script>
import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { __ } from '~/locale';
import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
import { graphDataValidatorForValues } from '../../utils';
const defaultPrecision = 2;
+const emptyStateMsg = __('No data to display');
export default {
components: {
@@ -21,6 +23,9 @@ export default {
queryInfo() {
return this.graphData.metrics[0];
},
+ queryMetric() {
+ return this.queryInfo.result[0]?.metric;
+ },
queryResult() {
return this.queryInfo.result[0]?.value[1];
},
@@ -33,6 +38,12 @@ export default {
statValue() {
let formatter;
+ // if field is present the metric value is not displayed. Hence
+ // the early exit without formatting.
+ if (this.graphData?.field) {
+ return this.queryMetric?.[this.graphData.field] ?? emptyStateMsg;
+ }
+
if (this.graphData?.maxValue) {
formatter = getFormatter(SUPPORTED_FORMATS.percent);
return formatter(this.queryResult / Number(this.graphData.maxValue), defaultPrecision);
diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
index ac31d107e63..9bcd4419a14 100644
--- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
@@ -6,7 +6,7 @@ import { chartHeight, legendLayoutTypes } from '../../constants';
import { s__ } from '~/locale';
import { graphDataValidatorForValues } from '../../utils';
import { getTimeAxisOptions, axisTypes } from './options';
-import { timezones } from '../../format_date';
+import { formats, timezones } from '../../format_date';
export default {
components: {
@@ -97,7 +97,7 @@ export default {
chartOptions() {
return {
xAxis: {
- ...getTimeAxisOptions({ timezone: this.timezone }),
+ ...getTimeAxisOptions({ timezone: this.timezone, format: formats.shortTime }),
type: this.xAxisType,
},
dataZoom: [this.dataZoomConfig],
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 28af2d8ba77..f2add429a80 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -415,7 +415,7 @@ export default {
<gl-chart-series-label :color="isMultiSeries ? content.color : ''">
{{ content.name }}
</gl-chart-series-label>
- <div class="prepend-left-32">
+ <div class="gl-ml-7">
{{ content.value }}
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
new file mode 100644
index 00000000000..74799002b17
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlButton, GlModal, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { isSafeURL } from '~/lib/utils/url_utility';
+
+export default {
+ components: { GlButton, GlModal, GlSprintf },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ validator: isSafeURL,
+ },
+ addDashboardDocumentationPath: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ cancelHandler() {
+ this.$refs.modal.hide();
+ },
+ },
+ i18n: {
+ titleText: s__('Metrics|Create your dashboard configuration file'),
+ mainText: s__(
+ 'Metrics|To create a new dashboard, add a new YAML file to %{codeStart}.gitlab/dashboards%{codeEnd} at the root of this project.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n.titleText">
+ <p>
+ <gl-sprintf :message="$options.i18n.mainText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <template #modal-footer>
+ <gl-button category="secondary" @click="cancelHandler">{{ s__('Metrics|Cancel') }}</gl-button>
+ <gl-button
+ category="secondary"
+ variant="info"
+ target="_blank"
+ :href="addDashboardDocumentationPath"
+ data-testid="create-dashboard-modal-docs-button"
+ >
+ {{ s__('Metrics|View documentation') }}
+ </gl-button>
+ <gl-button
+ variant="success"
+ data-testid="create-dashboard-modal-repo-button"
+ :href="projectPath"
+ >
+ {{ s__('Metrics|Open repository') }}
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index f54319d283e..bde62275797 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,6 +1,7 @@
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
+import Mousetrap from 'mousetrap';
import { GlIcon, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import DashboardHeader from './dashboard_header.vue';
import DashboardPanel from './dashboard_panel.vue';
@@ -24,7 +25,7 @@ import {
expandedPanelPayloadFromUrl,
convertVariablesForURL,
} from '../utils';
-import { metricStates } from '../constants';
+import { metricStates, keyboardShortcutKeys } from '../constants';
import { defaultTimeRange } from '~/vue_shared/constants';
export default {
@@ -71,6 +72,10 @@ export default {
type: String,
required: true,
},
+ addDashboardDocumentationPath: {
+ type: String,
+ required: true,
+ },
settingsPath: {
type: String,
required: true,
@@ -149,21 +154,25 @@ export default {
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
isRearrangingPanels: false,
originalDocumentTitle: document.title,
+ hoveredPanel: '',
};
},
computed: {
...mapState('monitoringDashboard', [
'dashboard',
'emptyState',
- 'showEmptyState',
'expandedPanel',
'variables',
'links',
'currentDashboard',
+ 'hasDashboardValidationWarnings',
]),
...mapGetters('monitoringDashboard', ['selectedDashboard', 'getMetricStates']),
+ shouldShowEmptyState() {
+ return Boolean(this.emptyState);
+ },
shouldShowVariablesSection() {
- return Object.keys(this.variables).length > 0;
+ return Boolean(this.variables.length);
},
shouldShowLinksSection() {
return Object.keys(this.links).length > 0;
@@ -197,12 +206,29 @@ export default {
selectedDashboard(dashboard) {
this.prependToDocumentTitle(dashboard?.display_name);
},
+ hasDashboardValidationWarnings(hasWarnings) {
+ /**
+ * This watcher is set for future SPA behaviour of the dashboard
+ */
+ if (hasWarnings) {
+ createFlash(
+ s__(
+ 'Metrics|Your dashboard schema is invalid. Edit the dashboard to correct the YAML schema.',
+ ),
+ 'warning',
+ );
+ }
+ },
},
created() {
window.addEventListener('keyup', this.onKeyup);
+
+ Mousetrap.bind(Object.values(keyboardShortcutKeys), this.runShortcut);
},
destroyed() {
window.removeEventListener('keyup', this.onKeyup);
+
+ Mousetrap.unbind(Object.values(keyboardShortcutKeys));
},
mounted() {
if (!this.hasMetrics) {
@@ -254,6 +280,14 @@ export default {
return null;
},
/**
+ * Return true if the entire group is loading.
+ * @param {String} groupKey - Identifier for group
+ * @returns {boolean}
+ */
+ isGroupLoading(groupKey) {
+ return this.groupSingleEmptyState(groupKey) === metricStates.LOADING;
+ },
+ /**
* A group should be not collapsed if any metric is loaded (OK)
*
* @param {String} groupKey - Identifier for group
@@ -302,6 +336,66 @@ export default {
// As a fallback, switch to default time range instead
this.selectedTimeRange = defaultTimeRange;
},
+ isPanelHalfWidth(panelIndex, totalPanels) {
+ /**
+ * A single panel on a row should take the full width of its parent.
+ * All others should have half the width their parent.
+ */
+ const isNumberOfPanelsEven = totalPanels % 2 === 0;
+ const isLastPanel = panelIndex === totalPanels - 1;
+
+ return isNumberOfPanelsEven || !isLastPanel;
+ },
+ /**
+ * TODO: Investigate this to utilize the eventBus from Vue
+ * The intentation behind this cleanup is to allow for better tests
+ * as well as use the correct eventBus facilities that are compatible
+ * with Vue 3
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/225583
+ */
+ //
+ runShortcut(e) {
+ const panel = this.$refs[this.hoveredPanel];
+
+ if (!panel) return;
+
+ const [panelInstance] = panel;
+ let actionToRun = '';
+
+ switch (e.key) {
+ case keyboardShortcutKeys.EXPAND:
+ actionToRun = 'onExpandFromKeyboardShortcut';
+ break;
+
+ case keyboardShortcutKeys.VISIT_LOGS:
+ actionToRun = 'visitLogsPageFromKeyboardShortcut';
+ break;
+
+ case keyboardShortcutKeys.SHOW_ALERT:
+ actionToRun = 'showAlertModalFromKeyboardShortcut';
+ break;
+
+ case keyboardShortcutKeys.DOWNLOAD_CSV:
+ actionToRun = 'downloadCsvFromKeyboardShortcut';
+ break;
+
+ case keyboardShortcutKeys.CHART_COPY:
+ actionToRun = 'copyChartLinkFromKeyboardShotcut';
+ break;
+
+ default:
+ actionToRun = 'onExpandFromKeyboardShortcut';
+ break;
+ }
+
+ panelInstance[actionToRun]();
+ },
+ setHoveredPanel(groupKey, graphIndex) {
+ this.hoveredPanel = `dashboard-panel-${groupKey}-${graphIndex}`;
+ },
+ clearHoveredPanel() {
+ this.hoveredPanel = '';
+ },
},
i18n: {
goBackLabel: s__('Metrics|Go back (Esc)'),
@@ -315,6 +409,7 @@ export default {
v-if="showHeader"
ref="prometheusGraphsHeader"
class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light"
+ :add-dashboard-documentation-path="addDashboardDocumentationPath"
:default-branch="defaultBranch"
:rearrange-panels-available="rearrangePanelsAvailable"
:custom-metrics-available="customMetricsAvailable"
@@ -327,9 +422,9 @@ export default {
@dateTimePickerInvalid="onDateTimePickerInvalid"
@setRearrangingPanels="onSetRearrangingPanels"
/>
- <variables-section v-if="shouldShowVariablesSection && !showEmptyState" />
- <links-section v-if="shouldShowLinksSection && !showEmptyState" />
- <div v-if="!showEmptyState">
+ <template v-if="!shouldShowEmptyState">
+ <variables-section v-if="shouldShowVariablesSection" />
+ <links-section v-if="shouldShowLinksSection" />
<dashboard-panel
v-show="expandedPanel.panel"
ref="expandedPanel"
@@ -364,6 +459,7 @@ export default {
:key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group"
:show-panels="showPanels"
+ :is-loading="isGroupLoading(groupData.key)"
:collapse-group="collapseGroup(groupData.key)"
>
<vue-draggable
@@ -377,8 +473,14 @@ export default {
<div
v-for="(graphData, graphIndex) in groupData.panels"
:key="`dashboard-panel-${graphIndex}`"
- class="col-12 col-lg-6 px-2 mb-2 draggable"
- :class="{ 'draggable-enabled': isRearrangingPanels }"
+ data-testid="dashboard-panel-layout-wrapper"
+ class="col-12 px-2 mb-2 draggable"
+ :class="{
+ 'draggable-enabled': isRearrangingPanels,
+ 'col-lg-6': isPanelHalfWidth(graphIndex, groupData.panels.length),
+ }"
+ @mouseover="setHoveredPanel(groupData.key, graphIndex)"
+ @mouseout="clearHoveredPanel"
>
<div class="position-relative draggable-panel js-draggable-panel">
<div
@@ -392,6 +494,7 @@ export default {
</div>
<dashboard-panel
+ :ref="`dashboard-panel-${groupData.key}-${graphIndex}`"
:settings-path="settingsPath"
:clipboard-text="generatePanelUrl(groupData.group, graphData)"
:graph-data="graphData"
@@ -414,7 +517,7 @@ export default {
</div>
</graph-group>
</div>
- </div>
+ </template>
<empty-state
v-else
:selected-state="emptyState"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index 16a21ae0d3c..fe6ca3a2a07 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -2,12 +2,16 @@
import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import {
+ GlButton,
GlIcon,
GlDeprecatedButton,
GlDropdown,
GlDropdownItem,
GlDropdownHeader,
GlDropdownDivider,
+ GlNewDropdown,
+ GlNewDropdownDivider,
+ GlNewDropdownItem,
GlModal,
GlLoadingIcon,
GlSearchBoxByType,
@@ -22,6 +26,9 @@ import Icon from '~/vue_shared/components/icon.vue';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import DashboardsDropdown from './dashboards_dropdown.vue';
+import RefreshButton from './refresh_button.vue';
+import CreateDashboardModal from './create_dashboard_modal.vue';
+import DuplicateDashboardModal from './duplicate_dashboard_modal.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getAddMetricTrackingOptions, timeRangeToUrl } from '../utils';
@@ -31,6 +38,7 @@ import { timezones } from '../format_date';
export default {
components: {
Icon,
+ GlButton,
GlIcon,
GlDeprecatedButton,
GlDropdown,
@@ -38,12 +46,18 @@ export default {
GlDropdownItem,
GlDropdownHeader,
GlDropdownDivider,
+ GlNewDropdown,
+ GlNewDropdownDivider,
+ GlNewDropdownItem,
GlSearchBoxByType,
GlModal,
CustomMetricsFormFields,
DateTimePicker,
DashboardsDropdown,
+ RefreshButton,
+ DuplicateDashboardModal,
+ CreateDashboardModal,
},
directives: {
GlModal: GlModalDirective,
@@ -93,6 +107,10 @@ export default {
type: Object,
required: true,
},
+ addDashboardDocumentationPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -101,20 +119,30 @@ export default {
},
computed: {
...mapState('monitoringDashboard', [
+ 'emptyState',
'environmentsLoading',
'currentEnvironmentName',
'isUpdatingStarredValue',
- 'showEmptyState',
'dashboardTimezone',
+ 'projectPath',
+ 'canAccessOperationsSettings',
+ 'operationsSettingsPath',
+ 'currentDashboard',
]),
...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']),
+ isOutOfTheBoxDashboard() {
+ return this.selectedDashboard?.out_of_the_box_dashboard;
+ },
+ shouldShowEmptyState() {
+ return Boolean(this.emptyState);
+ },
shouldShowEnvironmentsDropdownNoMatchedMsg() {
return !this.environmentsLoading && this.filteredEnvironments.length === 0;
},
addingMetricsAvailable() {
return (
this.customMetricsAvailable &&
- !this.showEmptyState &&
+ !this.shouldShowEmptyState &&
// Custom metrics only avaialble on system dashboards because
// they are stored in the database. This can be improved. See:
// https://gitlab.com/gitlab-org/gitlab/-/issues/28241
@@ -122,23 +150,29 @@ export default {
);
},
showRearrangePanelsBtn() {
- return !this.showEmptyState && this.rearrangePanelsAvailable;
+ return !this.shouldShowEmptyState && this.rearrangePanelsAvailable;
},
displayUtc() {
return this.dashboardTimezone === timezones.UTC;
},
+ shouldShowActionsMenu() {
+ return Boolean(this.projectPath);
+ },
+ shouldShowSettingsButton() {
+ return this.canAccessOperationsSettings && this.operationsSettingsPath;
+ },
},
methods: {
- ...mapActions('monitoringDashboard', [
- 'filterEnvironments',
- 'fetchDashboardData',
- 'toggleStarredValue',
- ]),
+ ...mapActions('monitoringDashboard', ['filterEnvironments', 'toggleStarredValue']),
selectDashboard(dashboard) {
- const params = {
- dashboard: dashboard.path,
- };
- redirectTo(mergeUrlParams(params, window.location.href));
+ // Once the sidebar See metrics link is updated to the new URL,
+ // this sort of hardcoding will not be necessary.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/229277
+ const baseURL = `${this.projectPath}/-/metrics`;
+ const dashboardPath = encodeURIComponent(
+ dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name,
+ );
+ redirectTo(`${baseURL}/${dashboardPath}`);
},
debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) {
this.filterEnvironments(searchTerm);
@@ -149,9 +183,6 @@ export default {
onDateTimePickerInvalid() {
this.$emit('dateTimePickerInvalid');
},
- refreshDashboard() {
- this.fetchDashboardData();
- },
toggleRearrangingPanels() {
this.$emit('setRearrangingPanels', !this.isRearrangingPanels);
@@ -166,14 +197,27 @@ export default {
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
+ getEnvironmentPath(environment) {
+ // Once the sidebar See metrics link is updated to the new URL,
+ // this sort of hardcoding will not be necessary.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/229277
+ const baseURL = `${this.projectPath}/-/metrics`;
+ const dashboardPath = encodeURIComponent(this.currentDashboard || '');
+ // The environment_metrics_spec.rb requires the URL to not have
+ // slashes. Hence, this additional check.
+ const url = dashboardPath ? `${baseURL}/${dashboardPath}` : baseURL;
+ return mergeUrlParams({ environment }, url);
+ },
},
- addMetric: {
- title: s__('Metrics|Add metric'),
- modalId: 'add-metric',
+ modalIds: {
+ addMetric: 'addMetric',
+ createDashboard: 'createDashboard',
+ duplicateDashboard: 'duplicateDashboard',
},
i18n: {
starDashboard: s__('Metrics|Star dashboard'),
unstarDashboard: s__('Metrics|Unstar dashboard'),
+ addMetric: s__('Metrics|Add metric'),
},
timeRanges,
};
@@ -181,17 +225,20 @@ export default {
<template>
<div ref="prometheusGraphsHeader">
- <div class="mb-2 pr-2 d-flex d-sm-block">
+ <div class="mb-2 mr-2 d-flex d-sm-block">
<dashboards-dropdown
id="monitor-dashboards-dropdown"
data-qa-selector="dashboards_filter_dropdown"
class="flex-grow-1"
toggle-class="dropdown-menu-toggle"
:default-branch="defaultBranch"
+ :modal-id="$options.modalIds.duplicateDashboard"
@selectDashboard="selectDashboard"
/>
</div>
+ <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span>
+
<div class="mb-2 pr-2 d-flex d-sm-block">
<gl-dropdown
id="monitor-environments-dropdown"
@@ -223,7 +270,7 @@ export default {
:key="environment.id"
:active="environment.name === currentEnvironmentName"
active-class="is-active"
- :href="environment.metrics_path"
+ :href="getEnvironmentPath(environment.id)"
>{{ environment.name }}</gl-dropdown-item
>
</div>
@@ -252,16 +299,7 @@ export default {
</div>
<div class="mb-2 pr-2 d-flex d-sm-block">
- <gl-deprecated-button
- ref="refreshDashboardBtn"
- v-gl-tooltip
- class="flex-grow-1"
- variant="default"
- :title="s__('Metrics|Refresh dashboard')"
- @click="refreshDashboard"
- >
- <icon name="retry" />
- </gl-deprecated-button>
+ <refresh-button />
</div>
<div class="flex-grow-1"></div>
@@ -304,17 +342,17 @@ export default {
<div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block">
<gl-deprecated-button
ref="addMetricBtn"
- v-gl-modal="$options.addMetric.modalId"
+ v-gl-modal="$options.modalIds.addMetric"
variant="outline-success"
data-qa-selector="add_metric_button"
class="flex-grow-1"
>
- {{ $options.addMetric.title }}
+ {{ $options.i18n.addMetric }}
</gl-deprecated-button>
<gl-modal
ref="addMetricModal"
- :modal-id="$options.addMetric.modalId"
- :title="$options.addMetric.title"
+ :modal-id="$options.modalIds.addMetric"
+ :title="$options.i18n.addMetric"
>
<form ref="customMetricsForm" :action="customMetricsPath" method="post">
<custom-metrics-form-fields
@@ -353,7 +391,10 @@ export default {
</gl-deprecated-button>
</div>
- <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block">
+ <div
+ v-if="externalDashboardUrl && externalDashboardUrl.length"
+ class="mb-2 mr-2 d-flex d-sm-block"
+ >
<gl-deprecated-button
class="flex-grow-1 js-external-dashboard-link"
variant="primary"
@@ -364,6 +405,63 @@ export default {
{{ __('View full dashboard') }} <icon name="external-link" />
</gl-deprecated-button>
</div>
+
+ <!-- This separator should be displayed only if at least one of the action menu or settings button are displayed -->
+ <span
+ v-if="shouldShowActionsMenu || shouldShowSettingsButton"
+ aria-hidden="true"
+ class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"
+ ></span>
+
+ <div v-if="shouldShowActionsMenu" class="gl-mb-3 gl-mr-3 d-flex d-sm-block">
+ <gl-new-dropdown
+ v-gl-tooltip
+ right
+ class="gl-flex-grow-1"
+ data-testid="actions-menu"
+ :title="s__('Metrics|Create dashboard')"
+ :icon="'plus-square'"
+ >
+ <gl-new-dropdown-item
+ v-gl-modal="$options.modalIds.createDashboard"
+ data-testid="action-create-dashboard"
+ >{{ s__('Metrics|Create new dashboard') }}</gl-new-dropdown-item
+ >
+
+ <create-dashboard-modal
+ data-testid="create-dashboard-modal"
+ :add-dashboard-documentation-path="addDashboardDocumentationPath"
+ :modal-id="$options.modalIds.createDashboard"
+ :project-path="projectPath"
+ />
+
+ <template v-if="isOutOfTheBoxDashboard">
+ <gl-new-dropdown-divider />
+ <gl-new-dropdown-item
+ ref="duplicateDashboardItem"
+ v-gl-modal="$options.modalIds.duplicateDashboard"
+ data-testid="action-duplicate-dashboard"
+ >
+ {{ s__('Metrics|Duplicate current dashboard') }}
+ </gl-new-dropdown-item>
+ </template>
+ </gl-new-dropdown>
+ </div>
+
+ <div v-if="shouldShowSettingsButton" class="mb-2 mr-2 d-flex d-sm-block">
+ <gl-button
+ v-gl-tooltip
+ data-testid="metrics-settings-button"
+ icon="settings"
+ :href="operationsSettingsPath"
+ :title="s__('Metrics|Metrics Settings')"
+ />
+ </div>
</div>
+ <duplicate-dashboard-modal
+ :default-branch="defaultBranch"
+ :modal-id="$options.modalIds.duplicateDashboard"
+ @dashboardDuplicated="selectDashboard"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index 9545a211bbd..3e3c8408de3 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -2,6 +2,7 @@
import { mapState } from 'vuex';
import { pickBy } from 'lodash';
import invalidUrl from '~/lib/utils/invalid_url';
+import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility';
import {
GlResizeObserverDirective,
GlIcon,
@@ -29,7 +30,6 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import AlertWidget from './alert_widget.vue';
import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
-import { isSafeURL } from '~/lib/utils/url_utility';
const events = {
timeRangeZoom: 'timerangezoom',
@@ -132,7 +132,8 @@ export default {
return this.graphData?.title || '';
},
graphDataHasResult() {
- return this.graphData?.metrics?.[0]?.result?.length > 0;
+ const metrics = this.graphData?.metrics || [];
+ return metrics.some(({ result }) => result?.length > 0);
},
graphDataIsLoading() {
const metrics = this.graphData?.metrics || [];
@@ -207,7 +208,17 @@ export default {
return MonitorTimeSeriesChart;
},
isContextualMenuShown() {
- return Boolean(this.graphDataHasResult && !this.basicChartComponent);
+ if (!this.graphDataHasResult) {
+ return false;
+ }
+ // Only a few charts have a contextual menu, support
+ // for more chart types planned at:
+ // https://gitlab.com/groups/gitlab-org/-/epics/3573
+ return (
+ this.isPanelType(panelTypes.AREA_CHART) ||
+ this.isPanelType(panelTypes.LINE_CHART) ||
+ this.isPanelType(panelTypes.SINGLE_STAT)
+ );
},
editCustomMetricLink() {
if (this.graphData.metrics.length > 1) {
@@ -223,13 +234,19 @@ export default {
return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId));
},
alertWidgetAvailable() {
+ const supportsAlerts =
+ this.isPanelType(panelTypes.AREA_CHART) || this.isPanelType(panelTypes.LINE_CHART);
return (
+ supportsAlerts &&
this.prometheusAlertsAvailable &&
this.alertsEndpoint &&
this.graphData &&
this.hasMetricsInDb
);
},
+ alertModalId() {
+ return `alert-modal-${this.graphData.id}`;
+ },
},
mounted() {
this.refreshTitleTooltip();
@@ -268,6 +285,11 @@ export default {
onExpand() {
this.$emit(events.expand);
},
+ onExpandFromKeyboardShortcut() {
+ if (this.isContextualMenuShown) {
+ this.onExpand();
+ }
+ },
setAlerts(alertPath, alertAttributes) {
if (alertAttributes) {
this.$set(this.allAlerts, alertPath, alertAttributes);
@@ -278,18 +300,45 @@ export default {
safeUrl(url) {
return isSafeURL(url) ? url : '#';
},
+ showAlertModal() {
+ this.$root.$emit('bv::show::modal', this.alertModalId);
+ },
+ showAlertModalFromKeyboardShortcut() {
+ if (this.isContextualMenuShown) {
+ this.showAlertModal();
+ }
+ },
+ visitLogsPage() {
+ if (this.logsPathWithTimeRange) {
+ visitUrl(relativePathToAbsolute(this.logsPathWithTimeRange, getBaseURL()));
+ }
+ },
+ visitLogsPageFromKeyboardShortcut() {
+ if (this.isContextualMenuShown) {
+ this.visitLogsPage();
+ }
+ },
+ downloadCsvFromKeyboardShortcut() {
+ if (this.csvText && this.isContextualMenuShown) {
+ this.$refs.downloadCsvLink.$el.firstChild.click();
+ }
+ },
+ copyChartLinkFromKeyboardShotcut() {
+ if (this.clipboardText && this.isContextualMenuShown) {
+ this.$refs.copyChartLink.$el.firstChild.click();
+ }
+ },
},
panelTypes,
};
</script>
<template>
<div v-gl-resize-observer="onResize" class="prometheus-graph">
- <div class="d-flex align-items-center mr-3">
+ <div class="d-flex align-items-center">
<slot name="topLeft"></slot>
<h5
ref="graphTitle"
class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate gl-mr-3"
- tabindex="0"
>
{{ title }}
</h5>
@@ -299,7 +348,7 @@ export default {
<alert-widget
v-if="isContextualMenuShown && alertWidgetAvailable"
class="mx-1"
- :modal-id="`alert-modal-${graphData.id}`"
+ :modal-id="alertModalId"
:alerts-endpoint="alertsEndpoint"
:relevant-queries="graphData.metrics"
:alerts-to-manage="getGraphAlerts(graphData.metrics)"
@@ -314,7 +363,7 @@ export default {
ref="contextualMenu"
data-qa-selector="prometheus_graph_widgets"
>
- <div class="d-flex align-items-center">
+ <div data-testid="dropdown-wrapper" class="d-flex align-items-center">
<gl-dropdown
v-gl-tooltip
toggle-class="shadow-none border-0"
@@ -369,13 +418,13 @@ export default {
</gl-dropdown-item>
<gl-dropdown-item
v-if="alertWidgetAvailable"
- v-gl-modal="`alert-modal-${graphData.id}`"
+ v-gl-modal="alertModalId"
data-qa-selector="alert_widget_menu_item"
>
{{ __('Alerts') }}
</gl-dropdown-item>
- <template v-if="graphData.links.length">
+ <template v-if="graphData.links && graphData.links.length">
<gl-dropdown-divider />
<gl-dropdown-item
v-for="(link, index) in graphData.links"
diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
index 8b86890715f..574f48a72fe 100644
--- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
+++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
@@ -1,19 +1,14 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import {
- GlAlert,
GlIcon,
GlDropdown,
GlDropdownItem,
GlDropdownHeader,
GlDropdownDivider,
GlSearchBoxByType,
- GlModal,
- GlLoadingIcon,
GlModalDirective,
} from '@gitlab/ui';
-import { s__ } from '~/locale';
-import DuplicateDashboardForm from './duplicate_dashboard_form.vue';
const events = {
selectDashboard: 'selectDashboard',
@@ -21,16 +16,12 @@ const events = {
export default {
components: {
- GlAlert,
GlIcon,
GlDropdown,
GlDropdownItem,
GlDropdownHeader,
GlDropdownDivider,
GlSearchBoxByType,
- GlModal,
- GlLoadingIcon,
- DuplicateDashboardForm,
},
directives: {
GlModal: GlModalDirective,
@@ -40,20 +31,21 @@ export default {
type: String,
required: true,
},
+ modalId: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
- alert: null,
- loading: false,
- form: {},
searchTerm: '',
};
},
computed: {
...mapState('monitoringDashboard', ['allDashboards']),
...mapGetters('monitoringDashboard', ['selectedDashboard']),
- isSystemDashboard() {
- return this.selectedDashboard?.system_dashboard;
+ isOutOfTheBoxDashboard() {
+ return this.selectedDashboard?.out_of_the_box_dashboard;
},
selectedDashboardText() {
return this.selectedDashboard?.display_name;
@@ -76,10 +68,6 @@ export default {
nonStarredDashboards() {
return this.filteredDashboards.filter(({ starred }) => !starred);
},
-
- okButtonText() {
- return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate');
- },
},
methods: {
...mapActions('monitoringDashboard', ['duplicateSystemDashboard']),
@@ -89,37 +77,6 @@ export default {
selectDashboard(dashboard) {
this.$emit(events.selectDashboard, dashboard);
},
- ok(bvModalEvt) {
- // Prevent modal from hiding in case submit fails
- bvModalEvt.preventDefault();
-
- this.loading = true;
- this.alert = null;
- this.duplicateSystemDashboard(this.form)
- .then(createdDashboard => {
- this.loading = false;
- this.alert = null;
-
- // Trigger hide modal as submit is successful
- this.$refs.duplicateDashboardModal.hide();
-
- // Dashboards in the default branch become available immediately.
- // Not so in other branches, so we refresh the current dashboard
- const dashboard =
- this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard;
- this.$emit(events.selectDashboard, dashboard);
- })
- .catch(error => {
- this.loading = false;
- this.alert = error;
- });
- },
- hide() {
- this.alert = null;
- },
- formChange(form) {
- this.form = form;
- },
},
};
</script>
@@ -178,32 +135,14 @@ export default {
{{ __('No matching results') }}
</div>
- <template v-if="isSystemDashboard">
+ <!--
+ This Duplicate Dashboard item will be removed from the dashboards dropdown
+ in https://gitlab.com/gitlab-org/gitlab/-/issues/223223
+ -->
+ <template v-if="isOutOfTheBoxDashboard">
<gl-dropdown-divider />
- <gl-modal
- ref="duplicateDashboardModal"
- modal-id="duplicateDashboardModal"
- :title="s__('Metrics|Duplicate dashboard')"
- ok-variant="success"
- @ok="ok"
- @hide="hide"
- >
- <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null">
- {{ alert }}
- </gl-alert>
- <duplicate-dashboard-form
- :dashboard="selectedDashboard"
- :default-branch="defaultBranch"
- @change="formChange"
- />
- <template #modal-ok>
- <gl-loading-icon v-if="loading" inline color="light" />
- {{ okButtonText }}
- </template>
- </gl-modal>
-
- <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'">
+ <gl-dropdown-item v-gl-modal="modalId" data-testid="duplicateDashboardItem">
{{ s__('Metrics|Duplicate dashboard') }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
new file mode 100644
index 00000000000..e64afc01fd9
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
@@ -0,0 +1,95 @@
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import DuplicateDashboardForm from './duplicate_dashboard_form.vue';
+
+const events = {
+ dashboardDuplicated: 'dashboardDuplicated',
+};
+
+export default {
+ components: { GlAlert, GlLoadingIcon, GlModal, DuplicateDashboardForm },
+ props: {
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ alert: null,
+ loading: false,
+ form: {},
+ };
+ },
+ computed: {
+ ...mapGetters('monitoringDashboard', ['selectedDashboard']),
+ okButtonText() {
+ return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate');
+ },
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']),
+ ok(bvModalEvt) {
+ // Prevent modal from hiding in case submit fails
+ bvModalEvt.preventDefault();
+
+ this.loading = true;
+ this.alert = null;
+ this.duplicateSystemDashboard(this.form)
+ .then(createdDashboard => {
+ this.loading = false;
+ this.alert = null;
+
+ // Trigger hide modal as submit is successful
+ this.$refs.duplicateDashboardModal.hide();
+
+ // Dashboards in the default branch become available immediately.
+ // Not so in other branches, so we refresh the current dashboard
+ const dashboard =
+ this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard;
+ this.$emit(events.dashboardDuplicated, dashboard);
+ })
+ .catch(error => {
+ this.loading = false;
+ this.alert = error;
+ });
+ },
+ hide() {
+ this.alert = null;
+ },
+ formChange(form) {
+ this.form = form;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="duplicateDashboardModal"
+ :modal-id="modalId"
+ :title="s__('Metrics|Duplicate dashboard')"
+ ok-variant="success"
+ @ok="ok"
+ @hide="hide"
+ >
+ <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null">
+ {{ alert }}
+ </gl-alert>
+ <duplicate-dashboard-form
+ :dashboard="selectedDashboard"
+ :default-branch="defaultBranch"
+ @change="formChange"
+ />
+ <template #modal-ok>
+ <gl-loading-icon v-if="loading" inline color="light" />
+ {{ okButtonText }}
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue
index d3157b731b2..5e7c9b5d906 100644
--- a/app/assets/javascripts/monitoring/components/empty_state.vue
+++ b/app/assets/javascripts/monitoring/components/empty_state.vue
@@ -1,12 +1,19 @@
<script>
-import { GlEmptyState } from '@gitlab/ui';
+import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { __ } from '~/locale';
+import { dashboardEmptyStates } from '../constants';
export default {
components: {
+ GlLoadingIcon,
GlEmptyState,
},
props: {
+ selectedState: {
+ type: String,
+ required: true,
+ validator: state => Object.values(dashboardEmptyStates).includes(state),
+ },
documentationPath: {
type: String,
required: true,
@@ -21,10 +28,6 @@ export default {
required: false,
default: '',
},
- selectedState: {
- type: String,
- required: true,
- },
emptyGettingStartedSvgPath: {
type: String,
required: true,
@@ -53,52 +56,49 @@ export default {
},
data() {
return {
+ /**
+ * Possible empty states.
+ * Keys in each state must match GlEmptyState props
+ */
states: {
- gettingStarted: {
- svgUrl: this.emptyGettingStartedSvgPath,
+ [dashboardEmptyStates.GETTING_STARTED]: {
+ svgPath: this.emptyGettingStartedSvgPath,
title: __('Get started with performance monitoring'),
description: __(`Stay updated about the performance and health
of your environment by configuring Prometheus to monitor your deployments.`),
- buttonText: __('Install on clusters'),
- buttonPath: this.clustersPath,
+ primaryButtonText: __('Install on clusters'),
+ primaryButtonLink: this.clustersPath,
secondaryButtonText: __('Configure existing installation'),
- secondaryButtonPath: this.settingsPath,
+ secondaryButtonLink: this.settingsPath,
},
- loading: {
- svgUrl: this.emptyLoadingSvgPath,
- title: __('Waiting for performance data'),
- description: __(`Creating graphs uses the data from the Prometheus server.
- If this takes a long time, ensure that data is available.`),
- buttonText: __('View documentation'),
- buttonPath: this.documentationPath,
- secondaryButtonText: '',
- secondaryButtonPath: '',
- },
- noData: {
- svgUrl: this.emptyNoDataSvgPath,
+ [dashboardEmptyStates.NO_DATA]: {
+ svgPath: this.emptyNoDataSvgPath,
title: __('No data found'),
description: __(`You are connected to the Prometheus server, but there is currently
no data to display.`),
- buttonText: __('Configure Prometheus'),
- buttonPath: this.settingsPath,
+ primaryButtonText: __('Configure Prometheus'),
+ primaryButtonLink: this.settingsPath,
secondaryButtonText: '',
- secondaryButtonPath: '',
+ secondaryButtonLink: '',
},
- unableToConnect: {
- svgUrl: this.emptyUnableToConnectSvgPath,
+ [dashboardEmptyStates.UNABLE_TO_CONNECT]: {
+ svgPath: this.emptyUnableToConnectSvgPath,
title: __('Unable to connect to Prometheus server'),
description: __(
'Ensure connectivity is available from the GitLab server to the Prometheus server',
),
- buttonText: __('View documentation'),
- buttonPath: this.documentationPath,
+ primaryButtonText: __('View documentation'),
+ primaryButtonLink: this.documentationPath,
secondaryButtonText: __('Configure Prometheus'),
- secondaryButtonPath: this.settingsPath,
+ secondaryButtonLink: this.settingsPath,
},
},
};
},
computed: {
+ isLoading() {
+ return this.selectedState === dashboardEmptyStates.LOADING;
+ },
currentState() {
return this.states[this.selectedState];
},
@@ -107,14 +107,8 @@ export default {
</script>
<template>
- <gl-empty-state
- :title="currentState.title"
- :description="currentState.description"
- :primary-button-text="currentState.buttonText"
- :primary-button-link="currentState.buttonPath"
- :secondary-button-text="currentState.secondaryButtonText"
- :secondary-button-link="currentState.secondaryButtonPath"
- :svg-path="currentState.svgUrl"
- :compact="compact"
- />
+ <div>
+ <gl-loading-icon v-if="isLoading" size="xl" class="gl-my-9" />
+ <gl-empty-state v-if="currentState" v-bind="currentState" :compact="compact" />
+ </div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index 08fcfa3bc56..ecb8ef4a0d0 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -1,9 +1,10 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
export default {
components: {
- Icon,
+ GlLoadingIcon,
+ GlIcon,
},
props: {
name: {
@@ -15,6 +16,11 @@ export default {
required: false,
default: true,
},
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
/**
* Initial value of collapse on mount.
*/
@@ -52,18 +58,21 @@ export default {
</script>
<template>
- <div v-if="showPanels" ref="graph-group" class="card prometheus-panel" tabindex="0">
+ <div v-if="showPanels" ref="graph-group" class="card prometheus-panel">
<div class="card-header d-flex align-items-center">
<h4 class="flex-grow-1">{{ name }}</h4>
+ <gl-loading-icon v-if="isLoading" name="loading" />
<a
data-testid="group-toggle-button"
+ :aria-label="__('Toggle collapse')"
+ :icon="caretIcon"
role="button"
- class="js-graph-group-toggle gl-text-gray-900"
+ class="js-graph-group-toggle gl-display-flex gl-ml-2 gl-text-gray-900"
tabindex="0"
@click="collapse"
@keyup.enter="collapse"
>
- <icon :size="16" :aria-label="__('Toggle collapse')" :name="caretIcon" />
+ <gl-icon :name="caretIcon" />
</a>
</div>
<div
diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue
new file mode 100644
index 00000000000..5481806c3e0
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/refresh_button.vue
@@ -0,0 +1,163 @@
+<script>
+import { n__, __ } from '~/locale';
+import { mapActions } from 'vuex';
+
+import {
+ GlButtonGroup,
+ GlButton,
+ GlNewDropdown,
+ GlNewDropdownItem,
+ GlNewDropdownDivider,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+
+const makeInterval = (length = 0, unit = 's') => {
+ const shortLabel = `${length}${unit}`;
+ switch (unit) {
+ case 'd':
+ return {
+ interval: length * 24 * 60 * 60 * 1000,
+ shortLabel,
+ label: n__('%d day', '%d days', length),
+ };
+ case 'h':
+ return {
+ interval: length * 60 * 60 * 1000,
+ shortLabel,
+ label: n__('%d hour', '%d hours', length),
+ };
+ case 'm':
+ return {
+ interval: length * 60 * 1000,
+ shortLabel,
+ label: n__('%d minute', '%d minutes', length),
+ };
+ case 's':
+ default:
+ return {
+ interval: length * 1000,
+ shortLabel,
+ label: n__('%d second', '%d seconds', length),
+ };
+ }
+};
+
+export default {
+ components: {
+ GlButtonGroup,
+ GlButton,
+ GlNewDropdown,
+ GlNewDropdownItem,
+ GlNewDropdownDivider,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ data() {
+ return {
+ refreshInterval: null,
+ timeoutId: null,
+ };
+ },
+ computed: {
+ dropdownText() {
+ return this.refreshInterval?.shortLabel ?? __('Off');
+ },
+ },
+ watch: {
+ refreshInterval() {
+ if (this.refreshInterval !== null) {
+ this.startAutoRefresh();
+ } else {
+ this.stopAutoRefresh();
+ }
+ },
+ },
+ destroyed() {
+ this.stopAutoRefresh();
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', ['fetchDashboardData']),
+
+ refresh() {
+ this.fetchDashboardData();
+ },
+ startAutoRefresh() {
+ const schedule = () => {
+ if (this.refreshInterval) {
+ this.timeoutId = setTimeout(this.startAutoRefresh, this.refreshInterval.interval);
+ }
+ };
+
+ this.stopAutoRefresh();
+ if (document.hidden) {
+ // Inactive tab? Skip fetch and schedule again
+ schedule();
+ } else {
+ // Active tab! Fetch data and then schedule when settled
+ // eslint-disable-next-line promise/catch-or-return
+ this.fetchDashboardData().finally(schedule);
+ }
+ },
+ stopAutoRefresh() {
+ clearTimeout(this.timeoutId);
+ this.timeoutId = null;
+ },
+
+ setRefreshInterval(option) {
+ this.refreshInterval = option;
+ },
+ removeRefreshInterval() {
+ this.refreshInterval = null;
+ },
+ isChecked(option) {
+ if (this.refreshInterval) {
+ return option.interval === this.refreshInterval.interval;
+ }
+ return false;
+ },
+ },
+
+ refreshIntervals: [
+ makeInterval(5),
+ makeInterval(10),
+ makeInterval(30),
+ makeInterval(5, 'm'),
+ makeInterval(30, 'm'),
+ makeInterval(1, 'h'),
+ makeInterval(2, 'h'),
+ makeInterval(12, 'h'),
+ makeInterval(1, 'd'),
+ ],
+};
+</script>
+
+<template>
+ <gl-button-group>
+ <gl-button
+ v-gl-tooltip
+ class="gl-flex-grow-1"
+ variant="default"
+ :title="s__('Metrics|Refresh dashboard')"
+ icon="retry"
+ @click="refresh"
+ />
+ <gl-new-dropdown v-gl-tooltip :title="s__('Metrics|Set refresh rate')" :text="dropdownText">
+ <gl-new-dropdown-item
+ :is-check-item="true"
+ :is-checked="refreshInterval === null"
+ @click="removeRefreshInterval()"
+ >{{ __('Off') }}</gl-new-dropdown-item
+ >
+ <gl-new-dropdown-divider />
+ <gl-new-dropdown-item
+ v-for="(option, i) in $options.refreshIntervals"
+ :key="i"
+ :is-check-item="true"
+ :is-checked="isChecked(option)"
+ @click="setRefreshInterval(option)"
+ >{{ option.label }}</gl-new-dropdown-item
+ >
+ </gl-new-dropdown>
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue
index 0ac7c0b80df..4e48292c48d 100644
--- a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue
+++ b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue
@@ -22,29 +22,32 @@ export default {
default: '',
},
options: {
- type: Array,
+ type: Object,
required: true,
},
},
computed: {
- defaultText() {
- const selectedOpt = this.options.find(opt => opt.value === this.value);
+ text() {
+ const selectedOpt = this.options.values?.find(opt => opt.value === this.value);
return selectedOpt?.text || this.value;
},
},
methods: {
onUpdate(value) {
- this.$emit('onUpdate', this.name, value);
+ this.$emit('input', value);
},
},
};
</script>
<template>
<gl-form-group :label="label">
- <gl-dropdown toggle-class="dropdown-menu-toggle" :text="defaultText">
- <gl-dropdown-item v-for="(opt, key) in options" :key="key" @click="onUpdate(opt.value)">{{
- opt.text
- }}</gl-dropdown-item>
+ <gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')">
+ <gl-dropdown-item
+ v-for="val in options.values"
+ :key="val.value"
+ @click="onUpdate(val.value)"
+ >{{ val.text }}</gl-dropdown-item
+ >
</gl-dropdown>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/monitoring/components/variables/text_variable.vue b/app/assets/javascripts/monitoring/components/variables/text_field.vue
index ce0d19760e2..a0418806e5f 100644
--- a/app/assets/javascripts/monitoring/components/variables/text_variable.vue
+++ b/app/assets/javascripts/monitoring/components/variables/text_field.vue
@@ -22,7 +22,7 @@ export default {
},
methods: {
onUpdate(event) {
- this.$emit('onUpdate', this.name, event.target.value);
+ this.$emit('input', event.target.value);
},
},
};
diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue
index 3d1d111d5b3..25d900b07ad 100644
--- a/app/assets/javascripts/monitoring/components/variables_section.vue
+++ b/app/assets/javascripts/monitoring/components/variables_section.vue
@@ -1,13 +1,14 @@
<script>
import { mapState, mapActions } from 'vuex';
-import CustomVariable from './variables/custom_variable.vue';
-import TextVariable from './variables/text_variable.vue';
+import DropdownField from './variables/dropdown_field.vue';
+import TextField from './variables/text_field.vue';
import { setCustomVariablesFromUrl } from '../utils';
+import { VARIABLE_TYPES } from '../constants';
export default {
components: {
- CustomVariable,
- TextVariable,
+ DropdownField,
+ TextField,
},
computed: {
...mapState('monitoringDashboard', ['variables']),
@@ -15,10 +16,9 @@ export default {
methods: {
...mapActions('monitoringDashboard', ['updateVariablesAndFetchData']),
refreshDashboard(variable, value) {
- if (this.variables[variable].value !== value) {
- const changedVariable = { key: variable, value };
+ if (variable.value !== value) {
+ this.updateVariablesAndFetchData({ name: variable.name, value });
// update the Vuex store
- this.updateVariablesAndFetchData(changedVariable);
// the below calls can ideally be moved out of the
// component and into the actions and let the
// mutation respond directly.
@@ -27,27 +27,26 @@ export default {
setCustomVariablesFromUrl(this.variables);
}
},
- variableComponent(type) {
- const types = {
- text: TextVariable,
- custom: CustomVariable,
- };
- return types[type] || TextVariable;
+ variableField(type) {
+ if (type === VARIABLE_TYPES.custom || type === VARIABLE_TYPES.metric_label_values) {
+ return DropdownField;
+ }
+ return TextField;
},
},
};
</script>
<template>
<div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section">
- <div v-for="(variable, key) in variables" :key="key" class="mb-1 pr-2 d-flex d-sm-block">
+ <div v-for="variable in variables" :key="variable.name" class="mb-1 pr-2 d-flex d-sm-block">
<component
- :is="variableComponent(variable.type)"
+ :is="variableField(variable.type)"
class="mb-0 flex-grow-1"
:label="variable.label"
:value="variable.value"
- :name="key"
+ :name="variable.name"
:options="variable.options"
- @onUpdate="refreshDashboard"
+ @input="refreshDashboard(variable, $event)"
/>
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 50330046c99..afeb3318eb9 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -1,5 +1,12 @@
export const PROMETHEUS_TIMEOUT = 120000; // TWO_MINUTES
+export const dashboardEmptyStates = {
+ GETTING_STARTED: 'gettingStarted',
+ LOADING: 'loading',
+ NO_DATA: 'noData',
+ UNABLE_TO_CONNECT: 'unableToConnect',
+};
+
/**
* States and error states in Prometheus Queries (PromQL) for metrics
*/
@@ -208,6 +215,14 @@ export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z';
*/
export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml';
+/**
+ * GitLab provide metrics dashboards that are available to a user once
+ * the Prometheus managed app has been installed, without any extra setup
+ * required. These "out of the box" dashboards are defined under the
+ * `config/prometheus` path.
+ */
+export const OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX = 'config/prometheus/';
+
export const OPERATORS = {
greaterThan: '>',
equalTo: '==',
@@ -230,6 +245,7 @@ export const OPERATORS = {
export const VARIABLE_TYPES = {
custom: 'custom',
text: 'text',
+ metric_label_values: 'metric_label_values',
};
/**
@@ -242,3 +258,17 @@ export const VARIABLE_TYPES = {
* before passing the data to the backend.
*/
export const VARIABLE_PREFIX = 'var-';
+
+/**
+ * All of the actions inside each panel dropdown can be accessed
+ * via keyboard shortcuts than can be activated via mouse hovers
+ * and or focus via tabs.
+ */
+
+export const keyboardShortcutKeys = {
+ EXPAND: 'e',
+ VISIT_LOGS: 'l',
+ SHOW_ALERT: 'a',
+ DOWNLOAD_CSV: 'd',
+ CHART_COPY: 'c',
+};
diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js
index a50d441a09e..c7bc626eb11 100644
--- a/app/assets/javascripts/monitoring/format_date.js
+++ b/app/assets/javascripts/monitoring/format_date.js
@@ -14,6 +14,7 @@ export const timezones = {
export const formats = {
shortTime: 'h:MM TT',
+ shortDateTime: 'm/d h:MM TT',
default: 'dd mmm yyyy, h:MMTT (Z)',
};
diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js
index 08543fa6eb3..307154c9a84 100644
--- a/app/assets/javascripts/monitoring/monitoring_app.js
+++ b/app/assets/javascripts/monitoring/monitoring_app.js
@@ -1,9 +1,8 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { getParameterValues } from '~/lib/utils/url_utility';
import { createStore } from './stores';
import createRouter from './router';
+import { stateAndPropsFromDataset } from './utils';
Vue.use(GlToast);
@@ -11,36 +10,10 @@ export default (props = {}) => {
const el = document.getElementById('prometheus-graphs');
if (el && el.dataset) {
- const [currentDashboard] = getParameterValues('dashboard');
-
- const {
- deploymentsEndpoint,
- dashboardEndpoint,
- dashboardsEndpoint,
- projectPath,
- logsPath,
- currentEnvironmentName,
- dashboardTimezone,
- metricsDashboardBasePath,
- ...dataProps
- } = el.dataset;
-
- const store = createStore({
- currentDashboard,
- deploymentsEndpoint,
- dashboardEndpoint,
- dashboardsEndpoint,
- dashboardTimezone,
- projectPath,
- logsPath,
- currentEnvironmentName,
- });
-
- // HTML attributes are always strings, parse other types.
- dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics);
- dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable);
- dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable);
+ const { metricsDashboardBasePath, ...dataset } = el.dataset;
+ const { initState, dataProps } = stateAndPropsFromDataset(dataset);
+ const store = createStore(initState);
const router = createRouter(metricsDashboardBasePath);
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/monitoring/pages/dashboard_page.vue b/app/assets/javascripts/monitoring/pages/dashboard_page.vue
index 519a20d7be3..df0e2d7f8f6 100644
--- a/app/assets/javascripts/monitoring/pages/dashboard_page.vue
+++ b/app/assets/javascripts/monitoring/pages/dashboard_page.vue
@@ -1,4 +1,5 @@
<script>
+import { mapActions } from 'vuex';
import Dashboard from '../components/dashboard.vue';
export default {
@@ -11,6 +12,16 @@ export default {
required: true,
},
},
+ created() {
+ // This is to support the older URL <project>/-/environments/:env_id/metrics?dashboard=:path
+ // and the new format <project>/-/metrics/:dashboardPath
+ const encodedDashboard = this.$route.query.dashboard || this.$route.params.dashboard;
+ const currentDashboard = encodedDashboard ? decodeURIComponent(encodedDashboard) : null;
+ this.setCurrentDashboard({ currentDashboard });
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', ['setCurrentDashboard']),
+ },
};
</script>
<template>
diff --git a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql
new file mode 100644
index 00000000000..302383512d3
--- /dev/null
+++ b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql
@@ -0,0 +1,18 @@
+query getDashboardValidationWarnings(
+ $projectPath: ID!
+ $environmentName: String
+ $dashboardPath: String!
+) {
+ project(fullPath: $projectPath) {
+ id
+ environments(name: $environmentName) {
+ nodes {
+ name
+ metricsDashboard(path: $dashboardPath) {
+ path
+ schemaValidationWarnings
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js
index acfcd03f928..fedfebe33e9 100644
--- a/app/assets/javascripts/monitoring/router/constants.js
+++ b/app/assets/javascripts/monitoring/router/constants.js
@@ -1,3 +1,4 @@
export const BASE_DASHBOARD_PAGE = 'dashboard';
+export const CUSTOM_DASHBOARD_PAGE = 'custom_dashboard';
export default {};
diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js
index 1e0cc1715a7..4b82791178a 100644
--- a/app/assets/javascripts/monitoring/router/routes.js
+++ b/app/assets/javascripts/monitoring/router/routes.js
@@ -1,6 +1,6 @@
import DashboardPage from '../pages/dashboard_page.vue';
-import { BASE_DASHBOARD_PAGE } from './constants';
+import { BASE_DASHBOARD_PAGE, CUSTOM_DASHBOARD_PAGE } from './constants';
/**
* Because the cluster health page uses the dashboard
@@ -12,7 +12,12 @@ import { BASE_DASHBOARD_PAGE } from './constants';
export default [
{
name: BASE_DASHBOARD_PAGE,
- path: '*',
+ path: '/',
+ component: DashboardPage,
+ },
+ {
+ name: CUSTOM_DASHBOARD_PAGE,
+ path: '/:dashboard(.*)',
component: DashboardPage,
},
];
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 3a9cccec438..a441882a47d 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -12,6 +12,7 @@ import {
import trackDashboardLoad from '../monitoring_tracking_helper';
import getEnvironments from '../queries/getEnvironments.query.graphql';
import getAnnotations from '../queries/getAnnotations.query.graphql';
+import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql';
import statusCodes from '../../lib/utils/http_status';
import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
import { s__, sprintf } from '../../locale';
@@ -20,6 +21,7 @@ import {
PROMETHEUS_TIMEOUT,
ENVIRONMENT_AVAILABLE_STATE,
DEFAULT_DASHBOARD_PATH,
+ VARIABLE_TYPES,
} from '../constants';
function prometheusMetricQueryParams(timeRange) {
@@ -50,15 +52,14 @@ function backOffRequest(makeRequestCallback) {
}, PROMETHEUS_TIMEOUT);
}
-function getPrometheusMetricResult(prometheusEndpoint, params) {
+function getPrometheusQueryData(prometheusEndpoint, params) {
return backOffRequest(() => axios.get(prometheusEndpoint, { params }))
.then(res => res.data)
.then(response => {
if (response.status === 'error') {
throw new Error(response.error);
}
-
- return response.data.result;
+ return response.data;
});
}
@@ -76,10 +77,6 @@ export const setTimeRange = ({ commit }, timeRange) => {
commit(types.SET_TIME_RANGE, timeRange);
};
-export const setVariables = ({ commit }, variables) => {
- commit(types.SET_VARIABLES, variables);
-};
-
export const filterEnvironments = ({ commit, dispatch }, searchTerm) => {
commit(types.SET_ENVIRONMENTS_FILTER, searchTerm);
dispatch('fetchEnvironmentsData');
@@ -100,6 +97,10 @@ export const clearExpandedPanel = ({ commit }) => {
});
};
+export const setCurrentDashboard = ({ commit }, { currentDashboard }) => {
+ commit(types.SET_CURRENT_DASHBOARD, currentDashboard);
+};
+
// All Data
/**
@@ -117,17 +118,27 @@ export const fetchData = ({ dispatch }) => {
// Metrics dashboard
-export const fetchDashboard = ({ state, commit, dispatch }) => {
+export const fetchDashboard = ({ state, commit, dispatch, getters }) => {
dispatch('requestMetricsDashboard');
const params = {};
- if (state.currentDashboard) {
- params.dashboard = state.currentDashboard;
+ if (getters.fullDashboardPath) {
+ params.dashboard = getters.fullDashboardPath;
}
return backOffRequest(() => axios.get(state.dashboardEndpoint, { params }))
.then(resp => resp.data)
- .then(response => dispatch('receiveMetricsDashboardSuccess', { response }))
+ .then(response => {
+ dispatch('receiveMetricsDashboardSuccess', { response });
+ /**
+ * After the dashboard is fetched, there can be non-blocking invalid syntax
+ * in the dashboard file. This call will fetch such syntax warnings
+ * and surface a warning on the UI. If the invalid syntax is blocking,
+ * the `fetchDashboard` returns a 404 with error messages that are displayed
+ * on the UI.
+ */
+ dispatch('fetchDashboardValidationWarnings');
+ })
.catch(error => {
Sentry.captureException(error);
@@ -181,8 +192,12 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => {
return Promise.reject();
}
+ // Time range params must be pre-calculated once for all metrics and options
+ // A subsequent call, may calculate a different time range
const defaultQueryParams = prometheusMetricQueryParams(state.timeRange);
+ dispatch('fetchVariableMetricLabelValues', { defaultQueryParams });
+
const promises = [];
state.dashboard.panelGroups.forEach(group => {
group.panels.forEach(panel => {
@@ -194,7 +209,7 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => {
return Promise.all(promises)
.then(() => {
- const dashboardType = state.currentDashboard === '' ? 'default' : 'custom';
+ const dashboardType = getters.fullDashboardPath === '' ? 'default' : 'custom';
trackDashboardLoad({
label: `${dashboardType}_metrics_dashboard`,
value: getters.metricsWithData().length,
@@ -220,7 +235,7 @@ export const fetchPrometheusMetric = (
queryParams.step = metric.step;
}
- if (Object.keys(state.variables).length > 0) {
+ if (state.variables.length > 0) {
queryParams = {
...queryParams,
...getters.getCustomVariablesParams,
@@ -229,9 +244,9 @@ export const fetchPrometheusMetric = (
commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId });
- return getPrometheusMetricResult(metric.prometheusEndpointPath, queryParams)
- .then(result => {
- commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, result });
+ return getPrometheusQueryData(metric.prometheusEndpointPath, queryParams)
+ .then(data => {
+ commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, data });
})
.catch(error => {
Sentry.captureException(error);
@@ -312,9 +327,9 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE);
};
-export const fetchAnnotations = ({ state, dispatch }) => {
+export const fetchAnnotations = ({ state, dispatch, getters }) => {
const { start } = convertToFixedRange(state.timeRange);
- const dashboardPath = state.currentDashboard || DEFAULT_DASHBOARD_PATH;
+ const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH;
return gqClient
.mutate({
mutation: getAnnotations,
@@ -345,6 +360,46 @@ export const receiveAnnotationsSuccess = ({ commit }, data) =>
commit(types.RECEIVE_ANNOTATIONS_SUCCESS, data);
export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_ANNOTATIONS_FAILURE);
+export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) => {
+ /**
+ * Normally, the default dashboard won't throw any validation warnings.
+ *
+ * However, if a bug sneaks into the default dashboard making it invalid,
+ * this might come handy for our clients
+ */
+ const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH;
+ return gqClient
+ .mutate({
+ mutation: getDashboardValidationWarnings,
+ variables: {
+ projectPath: removeLeadingSlash(state.projectPath),
+ environmentName: state.currentEnvironmentName,
+ dashboardPath,
+ },
+ })
+ .then(resp => resp.data?.project?.environments?.nodes?.[0]?.metricsDashboard)
+ .then(({ schemaValidationWarnings } = {}) => {
+ const hasWarnings = schemaValidationWarnings && schemaValidationWarnings.length !== 0;
+ /**
+ * The payload of the dispatch is a boolean, because at the moment a standard
+ * warning message is shown instead of the warnings the BE returns
+ */
+ dispatch('receiveDashboardValidationWarningsSuccess', hasWarnings || false);
+ })
+ .catch(err => {
+ Sentry.captureException(err);
+ dispatch('receiveDashboardValidationWarningsFailure');
+ createFlash(
+ s__('Metrics|There was an error getting dashboard validation warnings information.'),
+ );
+ });
+};
+
+export const receiveDashboardValidationWarningsSuccess = ({ commit }, hasWarnings) =>
+ commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS, hasWarnings);
+export const receiveDashboardValidationWarningsFailure = ({ commit }) =>
+ commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE);
+
// Dashboard manipulation
export const toggleStarredValue = ({ commit, state, getters }) => {
@@ -416,10 +471,41 @@ export const duplicateSystemDashboard = ({ state }, payload) => {
// Variables manipulation
export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => {
- commit(types.UPDATE_VARIABLES, updatedVariable);
+ commit(types.UPDATE_VARIABLE_VALUE, updatedVariable);
return dispatch('fetchDashboardData');
};
+export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQueryParams }) => {
+ const { start_time, end_time } = defaultQueryParams;
+ const optionsRequests = [];
+
+ state.variables.forEach(variable => {
+ if (variable.type === VARIABLE_TYPES.metric_label_values) {
+ const { prometheusEndpointPath, label } = variable.options;
+
+ const optionsRequest = backOffRequest(() =>
+ axios.get(prometheusEndpointPath, {
+ params: { start_time, end_time },
+ }),
+ )
+ .then(({ data }) => data.data)
+ .then(data => {
+ commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data });
+ })
+ .catch(() => {
+ createFlash(
+ sprintf(s__('Metrics|There was an error getting options for variable "%{name}".'), {
+ name: variable.name,
+ }),
+ );
+ });
+ optionsRequests.push(optionsRequest);
+ }
+ });
+
+ return Promise.all(optionsRequests);
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js
index b7681012472..3aa711a0509 100644
--- a/app/assets/javascripts/monitoring/stores/getters.js
+++ b/app/assets/javascripts/monitoring/stores/getters.js
@@ -1,5 +1,9 @@
import { NOT_IN_DB_PREFIX } from '../constants';
-import { addPrefixToCustomVariableParams, addDashboardMetaDataToLink } from './utils';
+import {
+ addPrefixToCustomVariableParams,
+ addDashboardMetaDataToLink,
+ normalizeCustomDashboardPath,
+} from './utils';
const metricsIdsInPanel = panel =>
panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId);
@@ -10,10 +14,10 @@ const metricsIdsInPanel = panel =>
*
* @param {Object} state
*/
-export const selectedDashboard = state => {
+export const selectedDashboard = (state, getters) => {
const { allDashboards } = state;
return (
- allDashboards.find(d => d.path === state.currentDashboard) ||
+ allDashboards.find(d => d.path === getters.fullDashboardPath) ||
allDashboards.find(d => d.default) ||
null
);
@@ -129,8 +133,8 @@ export const linksWithMetadata = state => {
};
/**
- * Maps an variables object to an array along with stripping
- * the variable prefix.
+ * Maps a variables array to an object for replacement in
+ * prometheus queries.
*
* This method outputs an object in the below format
*
@@ -143,16 +147,29 @@ export const linksWithMetadata = state => {
* user-defined variables coming through the URL and differentiate
* from other variables used for Prometheus API endpoint.
*
- * @param {Object} variables - Custom variables provided by the user
- * @returns {Array} The custom variables array to be send to the API
+ * @param {Object} state - State containing variables provided by the user
+ * @returns {Array} The custom variables object to be send to the API
* in the format of {variables[key1]=value1, variables[key2]=value2}
*/
export const getCustomVariablesParams = state =>
- Object.keys(state.variables).reduce((acc, variable) => {
- acc[addPrefixToCustomVariableParams(variable)] = state.variables[variable]?.value;
+ state.variables.reduce((acc, variable) => {
+ const { name, value } = variable;
+ if (value !== null) {
+ acc[addPrefixToCustomVariableParams(name)] = value;
+ }
return acc;
}, {});
+/**
+ * For a given custom dashboard file name, this method
+ * returns the full file path.
+ *
+ * @param {Object} state
+ * @returns {String} full dashboard path
+ */
+export const fullDashboardPath = state =>
+ normalizeCustomDashboardPath(state.currentDashboard, state.customDashboardBasePath);
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 4593461776b..d408628fc4d 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -2,17 +2,25 @@
export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD';
export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS';
export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE';
-export const SET_VARIABLES = 'SET_VARIABLES';
-export const UPDATE_VARIABLES = 'UPDATE_VARIABLES';
+export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE';
+export const UPDATE_VARIABLE_METRIC_LABEL_VALUES = 'UPDATE_VARIABLE_METRIC_LABEL_VALUES';
export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING';
export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS';
export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE';
+export const SET_CURRENT_DASHBOARD = 'SET_CURRENT_DASHBOARD';
+
// Annotations
export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS';
export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE';
+// Dashboard validation warnings
+export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS =
+ 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS';
+export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE =
+ 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE';
+
// Git project deployments
export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA';
export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS';
@@ -34,7 +42,6 @@ export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
-export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE';
export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER';
export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS';
export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index 2d63fdd6e34..744441c8935 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -1,10 +1,11 @@
import Vue from 'vue';
import { pick } from 'lodash';
import * as types from './mutation_types';
-import { mapToDashboardViewModel, normalizeQueryResult } from './utils';
-import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils';
-import { endpointKeys, initialStateKeys, metricStates } from '../constants';
+import { mapToDashboardViewModel, normalizeQueryResponseData } from './utils';
import httpStatusCodes from '~/lib/utils/http_status';
+import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils';
+import { dashboardEmptyStates, endpointKeys, initialStateKeys, metricStates } from '../constants';
+import { optionsFromSeriesData } from './variable_mapping';
/**
* Locate and return a metric in the dashboard by its id
@@ -57,8 +58,7 @@ export default {
* Dashboard panels structure and global state
*/
[types.REQUEST_METRICS_DASHBOARD](state) {
- state.emptyState = 'loading';
- state.showEmptyState = true;
+ state.emptyState = dashboardEmptyStates.LOADING;
},
[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, dashboardYML) {
const { dashboard, panelGroups, variables, links } = mapToDashboardViewModel(dashboardYML);
@@ -70,12 +70,15 @@ export default {
state.links = links;
if (!state.dashboard.panelGroups.length) {
- state.emptyState = 'noData';
+ state.emptyState = dashboardEmptyStates.NO_DATA;
+ } else {
+ state.emptyState = null;
}
},
[types.RECEIVE_METRICS_DASHBOARD_FAILURE](state, error) {
- state.emptyState = error ? 'unableToConnect' : 'noData';
- state.showEmptyState = true;
+ state.emptyState = error
+ ? dashboardEmptyStates.UNABLE_TO_CONNECT
+ : dashboardEmptyStates.NO_DATA;
},
[types.REQUEST_DASHBOARD_STARRING](state) {
@@ -94,6 +97,10 @@ export default {
state.isUpdatingStarredValue = false;
},
+ [types.SET_CURRENT_DASHBOARD](state, currentDashboard) {
+ state.currentDashboard = currentDashboard;
+ },
+
/**
* Deployments and environments
*/
@@ -126,6 +133,16 @@ export default {
},
/**
+ * Dashboard Validation Warnings
+ */
+ [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS](state, hasDashboardValidationWarnings) {
+ state.hasDashboardValidationWarnings = hasDashboardValidationWarnings;
+ },
+ [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE](state) {
+ state.hasDashboardValidationWarnings = false;
+ },
+
+ /**
* Individual panel/metric results
*/
[types.REQUEST_METRIC_RESULT](state, { metricId }) {
@@ -135,19 +152,18 @@ export default {
metric.state = metricStates.LOADING;
}
},
- [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, result }) {
+ [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) {
const metric = findMetricInDashboard(metricId, state.dashboard);
metric.loading = false;
- state.showEmptyState = false;
- if (!result || result.length === 0) {
+ if (!data.result || data.result.length === 0) {
metric.state = metricStates.NO_DATA;
metric.result = null;
} else {
- const normalizedResults = result.map(normalizeQueryResult);
+ const result = normalizeQueryResponseData(data);
metric.state = metricStates.OK;
- metric.result = Object.freeze(normalizedResults);
+ metric.result = Object.freeze(result);
}
},
[types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) {
@@ -169,11 +185,7 @@ export default {
state.timeRange = timeRange;
},
[types.SET_GETTING_STARTED_EMPTY_STATE](state) {
- state.emptyState = 'gettingStarted';
- },
- [types.SET_NO_DATA_EMPTY_STATE](state) {
- state.showEmptyState = true;
- state.emptyState = 'noData';
+ state.emptyState = dashboardEmptyStates.GETTING_STARTED;
},
[types.SET_ALL_DASHBOARDS](state, dashboards) {
state.allDashboards = dashboards || [];
@@ -192,13 +204,18 @@ export default {
state.expandedPanel.group = group;
state.expandedPanel.panel = panel;
},
- [types.SET_VARIABLES](state, variables) {
- state.variables = variables;
+ [types.UPDATE_VARIABLE_VALUE](state, { name, value }) {
+ const variable = state.variables.find(v => v.name === name);
+ if (variable) {
+ Object.assign(variable, {
+ value,
+ });
+ }
},
- [types.UPDATE_VARIABLES](state, updatedVariable) {
- Object.assign(state.variables[updatedVariable.key], {
- ...state.variables[updatedVariable.key],
- value: updatedVariable.value,
- });
+ [types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](state, { variable, label, data = [] }) {
+ const values = optionsFromSeriesData({ label, data });
+
+ // Add new options with assign to ensure Vue reactivity
+ Object.assign(variable.options, { values });
},
};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index 8000f27c0d5..89738756ffe 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -1,5 +1,6 @@
import invalidUrl from '~/lib/utils/invalid_url';
import { timezones } from '../format_date';
+import { dashboardEmptyStates } from '../constants';
export default () => ({
// API endpoints
@@ -9,11 +10,24 @@ export default () => ({
// Dashboard request parameters
timeRange: null,
+ /**
+ * Currently selected dashboard. For custom dashboards,
+ * this could be the filename or the file path.
+ *
+ * If this is the filename and full path is required,
+ * getters.fullDashboardPath should be used.
+ */
currentDashboard: null,
// Dashboard data
- emptyState: 'gettingStarted',
- showEmptyState: true,
+ hasDashboardValidationWarnings: false,
+
+ /**
+ * {?String} If set, dashboard should display a global
+ * empty state, there is no way to interact (yet)
+ * with the dashboard.
+ */
+ emptyState: dashboardEmptyStates.GETTING_STARTED,
showErrorBanner: true,
isUpdatingStarredValue: false,
dashboard: {
@@ -39,7 +53,7 @@ export default () => ({
* User-defined custom variables are passed
* via the dashboard yml file.
*/
- variables: {},
+ variables: [],
/**
* User-defined custom links are passed
* via the dashboard yml file.
@@ -56,5 +70,16 @@ export default () => ({
// GitLab paths to other pages
projectPath: null,
+ operationsSettingsPath: '',
logsPath: invalidUrl,
+
+ // static paths
+ customDashboardBasePath: '',
+
+ // current user data
+ /**
+ * Flag that denotes if the currently logged user can access
+ * the project Settings -> Operations
+ */
+ canAccessOperationsSettings: false,
});
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 5795e756282..51562593ee8 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -2,11 +2,11 @@ import { slugify } from '~/lib/utils/text_utility';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { NOT_IN_DB_PREFIX, linkTypes } from '../constants';
import { mergeURLVariables, parseTemplatingVariables } from './variable_mapping';
import { DATETIME_RANGE_TYPES } from '~/lib/utils/constants';
import { timeRangeToParams, getRangeType } from '~/lib/utils/datetime_range';
import { isSafeURL, mergeUrlParams } from '~/lib/utils/url_utility';
+import { NOT_IN_DB_PREFIX, linkTypes, OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX } from '../constants';
export const gqClient = createGqClient(
{},
@@ -165,7 +165,7 @@ const mapLinksToViewModel = ({ url = null, title = '', type } = {}) => {
* @param {Object} panel - Metrics panel
* @returns {Object}
*/
-const mapPanelToViewModel = ({
+export const mapPanelToViewModel = ({
id = null,
title = '',
type,
@@ -173,6 +173,7 @@ const mapPanelToViewModel = ({
x_label,
y_label,
y_axis = {},
+ field,
metrics = [],
links = [],
max_value,
@@ -193,6 +194,7 @@ const mapPanelToViewModel = ({
y_label: yAxis.name, // Changing y_label to yLabel is pending https://gitlab.com/gitlab-org/gitlab/issues/207198
yAxis,
xAxis,
+ field,
maxValue: max_value,
links: links.map(mapLinksToViewModel),
metrics: mapToMetricsViewModel(metrics),
@@ -289,49 +291,157 @@ export const mapToDashboardViewModel = ({
}) => {
return {
dashboard,
- variables: mergeURLVariables(parseTemplatingVariables(templating)),
+ variables: mergeURLVariables(parseTemplatingVariables(templating.variables)),
links: links.map(mapLinksToViewModel),
panelGroups: panel_groups.map(mapToPanelGroupViewModel),
};
};
+// Prometheus Results Parsing
+
+const dateTimeFromUnixTime = unixTime => new Date(unixTime * 1000).toISOString();
+
+const mapScalarValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), Number(value)];
+
+// Note: `string` value type is unused as of prometheus 2.19.
+const mapStringValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), value];
+
+/**
+ * Processes a scalar result.
+ *
+ * The corresponding result property has the following format:
+ *
+ * [ <unix_time>, "<scalar_value>" ]
+ *
+ * @param {array} result
+ * @returns {array}
+ */
+const normalizeScalarResult = result => [
+ {
+ metric: {},
+ value: mapScalarValue(result),
+ values: [mapScalarValue(result)],
+ },
+];
+
+/**
+ * Processes a string result.
+ *
+ * The corresponding result property has the following format:
+ *
+ * [ <unix_time>, "<string_value>" ]
+ *
+ * Note: This value type is unused as of prometheus 2.19.
+ *
+ * @param {array} result
+ * @returns {array}
+ */
+const normalizeStringResult = result => [
+ {
+ metric: {},
+ value: mapStringValue(result),
+ values: [mapStringValue(result)],
+ },
+];
+
+/**
+ * Proccesses an instant vector.
+ *
+ * Instant vectors are returned as result type `vector`.
+ *
+ * The corresponding result property has the following format:
+ *
+ * [
+ * {
+ * "metric": { "<label_name>": "<label_value>", ... },
+ * "value": [ <unix_time>, "<sample_value>" ],
+ * "values": [ [ <unix_time>, "<sample_value>" ] ]
+ * },
+ * ...
+ * ]
+ *
+ * `metric` - Key-value pairs object representing metric measured
+ * `value` - The vector result
+ * `values` - An array with a single value representing the result
+ *
+ * This method also adds the matrix version of the vector
+ * by introducing a `values` array with a single element. This
+ * allows charts to default to `values` if needed.
+ *
+ * @param {array} result
+ * @returns {array}
+ */
+const normalizeVectorResult = result =>
+ result.map(({ metric, value }) => {
+ const scalar = mapScalarValue(value);
+ // Add a single element to `values`, to support matrix
+ // style charts.
+ return { metric, value: scalar, values: [scalar] };
+ });
+
/**
- * Processes a single Range vector, part of the result
- * of type `matrix` in the form:
+ * Range vectors are returned as result type matrix.
+ *
+ * The corresponding result property has the following format:
*
* {
* "metric": { "<label_name>": "<label_value>", ... },
+ * "value": [ <unix_time>, "<sample_value>" ],
* "values": [ [ <unix_time>, "<sample_value>" ], ... ]
* },
*
+ * `metric` - Key-value pairs object representing metric measured
+ * `value` - The last (more recent) result
+ * `values` - A range of results for the metric
+ *
* See https://prometheus.io/docs/prometheus/latest/querying/api/#range-vectors
*
- * @param {*} timeSeries
+ * @param {array} result
+ * @returns {object} Normalized result.
*/
-export const normalizeQueryResult = timeSeries => {
- let normalizedResult = {};
-
- if (timeSeries.values) {
- normalizedResult = {
- ...timeSeries,
- values: timeSeries.values.map(([timestamp, value]) => [
- new Date(timestamp * 1000).toISOString(),
- Number(value),
- ]),
- };
- // Check result for empty data
- normalizedResult.values = normalizedResult.values.filter(series => {
- const hasValue = d => !Number.isNaN(d[1]) && (d[1] !== null || d[1] !== undefined);
- return series.find(hasValue);
- });
- } else if (timeSeries.value) {
- normalizedResult = {
- ...timeSeries,
- value: [new Date(timeSeries.value[0] * 1000).toISOString(), Number(timeSeries.value[1])],
+const normalizeResultMatrix = result =>
+ result.map(({ metric, values }) => {
+ const mappedValues = values.map(mapScalarValue);
+ return {
+ metric,
+ value: mappedValues[mappedValues.length - 1],
+ values: mappedValues,
};
- }
+ });
- return normalizedResult;
+/**
+ * Parse response data from a Prometheus Query that comes
+ * in the format:
+ *
+ * {
+ * "resultType": "matrix" | "vector" | "scalar" | "string",
+ * "result": <value>
+ * }
+ *
+ * @see https://prometheus.io/docs/prometheus/latest/querying/api/#expression-query-result-formats
+ *
+ * @param {object} data - Data containing results and result type.
+ * @returns {object} - A result array of metric results:
+ * [
+ * {
+ * metric: { ... },
+ * value: ['2015-07-01T20:10:51.781Z', '1'],
+ * values: [['2015-07-01T20:10:51.781Z', '1'] , ... ],
+ * },
+ * ...
+ * ]
+ *
+ */
+export const normalizeQueryResponseData = data => {
+ const { resultType, result } = data;
+ if (resultType === 'vector') {
+ return normalizeVectorResult(result);
+ } else if (resultType === 'scalar') {
+ return normalizeScalarResult(result);
+ } else if (resultType === 'string') {
+ return normalizeStringResult(result);
+ }
+ return normalizeResultMatrix(result);
};
/**
@@ -345,7 +455,35 @@ export const normalizeQueryResult = timeSeries => {
*
* This is currently only used by getters/getCustomVariablesParams
*
- * @param {String} key Variable key that needs to be prefixed
+ * @param {String} name Variable key that needs to be prefixed
* @returns {String}
*/
-export const addPrefixToCustomVariableParams = key => `variables[${key}]`;
+export const addPrefixToCustomVariableParams = name => `variables[${name}]`;
+
+/**
+ * Normalize custom dashboard paths. This method helps support
+ * metrics dashboard to work with custom dashboard file names instead
+ * of the entire path.
+ *
+ * If dashboard is empty, it is the default dashboard.
+ * If dashboard is set, it usually is a custom dashboard unless
+ * explicitly it is set to default dashboard path.
+ *
+ * @param {String} dashboard dashboard path
+ * @param {String} dashboardPrefix custom dashboard directory prefix
+ * @returns {String} normalized dashboard path
+ */
+export const normalizeCustomDashboardPath = (dashboard, dashboardPrefix = '') => {
+ const currDashboard = dashboard || '';
+ let dashboardPath = `${dashboardPrefix}/${currDashboard}`;
+
+ if (!currDashboard) {
+ dashboardPath = '';
+ } else if (
+ currDashboard.startsWith(dashboardPrefix) ||
+ currDashboard.startsWith(OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX)
+ ) {
+ dashboardPath = currDashboard;
+ }
+ return dashboardPath;
+};
diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js
index c0a8150063b..9245ffdb3b9 100644
--- a/app/assets/javascripts/monitoring/stores/variable_mapping.js
+++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js
@@ -46,7 +46,7 @@ const textAdvancedVariableParser = advTextVar => ({
* @param {Object} custom variable option
* @returns {Object} normalized custom variable options
*/
-const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({
+const normalizeVariableValues = ({ default: defaultOpt = false, text, value = null }) => ({
default: defaultOpt,
text: text || value,
value,
@@ -59,17 +59,19 @@ const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, val
* The default value is the option with default set to true or the first option
* if none of the options have default prop true.
*
- * @param {Object} advVariable advance custom variable
+ * @param {Object} advVariable advanced custom variable
* @returns {Object}
*/
const customAdvancedVariableParser = advVariable => {
- const options = (advVariable?.options?.values ?? []).map(normalizeCustomVariableOptions);
- const defaultOpt = options.find(opt => opt.default === true) || options[0];
+ const values = (advVariable?.options?.values ?? []).map(normalizeVariableValues);
+ const defaultValue = values.find(opt => opt.default === true) || values[0];
return {
type: VARIABLE_TYPES.custom,
label: advVariable.label,
- value: defaultOpt?.value,
- options,
+ options: {
+ values,
+ },
+ value: defaultValue?.value || null,
};
};
@@ -80,7 +82,7 @@ const customAdvancedVariableParser = advVariable => {
* @param {String} opt option from simple custom variable
* @returns {Object}
*/
-const parseSimpleCustomOptions = opt => ({ text: opt, value: opt });
+export const parseSimpleCustomValues = opt => ({ text: opt, value: opt });
/**
* Custom simple variables are rendered as dropdown elements in the dashboard
@@ -95,15 +97,28 @@ const parseSimpleCustomOptions = opt => ({ text: opt, value: opt });
* @returns {Object}
*/
const customSimpleVariableParser = simpleVar => {
- const options = (simpleVar || []).map(parseSimpleCustomOptions);
+ const values = (simpleVar || []).map(parseSimpleCustomValues);
return {
type: VARIABLE_TYPES.custom,
- value: options[0].value,
label: null,
- options: options.map(normalizeCustomVariableOptions),
+ value: values[0].value || null,
+ options: {
+ values: values.map(normalizeVariableValues),
+ },
};
};
+const metricLabelValuesVariableParser = ({ label, options = {} }) => ({
+ type: VARIABLE_TYPES.metric_label_values,
+ label,
+ value: null,
+ options: {
+ prometheusEndpointPath: options.prometheus_endpoint_path || '',
+ label: options.label || null,
+ values: [], // values are initially empty
+ },
+});
+
/**
* Utility method to determine if a custom variable is
* simple or not. If its not simple, it is advanced.
@@ -123,14 +138,16 @@ const isSimpleCustomVariable = customVar => Array.isArray(customVar);
* @return {Function} parser method
*/
const getVariableParser = variable => {
- if (isSimpleCustomVariable(variable)) {
+ if (isString(variable)) {
+ return textSimpleVariableParser;
+ } else if (isSimpleCustomVariable(variable)) {
return customSimpleVariableParser;
- } else if (variable.type === VARIABLE_TYPES.custom) {
- return customAdvancedVariableParser;
} else if (variable.type === VARIABLE_TYPES.text) {
return textAdvancedVariableParser;
- } else if (isString(variable)) {
- return textSimpleVariableParser;
+ } else if (variable.type === VARIABLE_TYPES.custom) {
+ return customAdvancedVariableParser;
+ } else if (variable.type === VARIABLE_TYPES.metric_label_values) {
+ return metricLabelValuesVariableParser;
}
return () => null;
};
@@ -141,29 +158,26 @@ const getVariableParser = variable => {
* for the user to edit. The values from input elements are relayed to
* backend and eventually Prometheus API.
*
- * This method currently is not used anywhere. Once the issue
- * https://gitlab.com/gitlab-org/gitlab/-/issues/214536 is completed,
- * this method will have been used by the monitoring dashboard.
- *
- * @param {Object} templating templating variables from the dashboard yml file
- * @returns {Object} a map of processed templating variables
+ * @param {Object} templating variables from the dashboard yml file
+ * @returns {array} An array of variables to display as inputs
*/
-export const parseTemplatingVariables = ({ variables = {} } = {}) =>
- Object.entries(variables).reduce((acc, [key, variable]) => {
+export const parseTemplatingVariables = (ymlVariables = {}) =>
+ Object.entries(ymlVariables).reduce((acc, [name, ymlVariable]) => {
// get the parser
- const parser = getVariableParser(variable);
+ const parser = getVariableParser(ymlVariable);
// parse the variable
- const parsedVar = parser(variable);
+ const variable = parser(ymlVariable);
// for simple custom variable label is null and it should be
// replace with key instead
- if (parsedVar) {
- acc[key] = {
- ...parsedVar,
- label: parsedVar.label || key,
- };
+ if (variable) {
+ acc.push({
+ ...variable,
+ name,
+ label: variable.label || name,
+ });
}
return acc;
- }, {});
+ }, []);
/**
* Custom variables are defined in the dashboard yml file
@@ -181,23 +195,81 @@ export const parseTemplatingVariables = ({ variables = {} } = {}) =>
* This method can be improved further. See the below issue
* https://gitlab.com/gitlab-org/gitlab/-/issues/217713
*
- * @param {Object} varsFromYML template variables from yml file
+ * @param {array} parsedYmlVariables - template variables from yml file
* @returns {Object}
*/
-export const mergeURLVariables = (varsFromYML = {}) => {
+export const mergeURLVariables = (parsedYmlVariables = []) => {
const varsFromURL = templatingVariablesFromUrl();
- const variables = {};
- Object.keys(varsFromYML).forEach(key => {
- if (Object.prototype.hasOwnProperty.call(varsFromURL, key)) {
- variables[key] = {
- ...varsFromYML[key],
- value: varsFromURL[key],
- };
- } else {
- variables[key] = varsFromYML[key];
+ parsedYmlVariables.forEach(variable => {
+ const { name } = variable;
+ if (Object.prototype.hasOwnProperty.call(varsFromURL, name)) {
+ Object.assign(variable, { value: varsFromURL[name] });
}
});
- return variables;
+ return parsedYmlVariables;
+};
+
+/**
+ * Converts series data to options that can be added to a
+ * variable. Series data is returned from the Prometheus API
+ * `/api/v1/series`.
+ *
+ * Finds a `label` in the series data, so it can be used as
+ * a filter.
+ *
+ * For example, for the arguments:
+ *
+ * {
+ * "label": "job"
+ * "data" : [
+ * {
+ * "__name__" : "up",
+ * "job" : "prometheus",
+ * "instance" : "localhost:9090"
+ * },
+ * {
+ * "__name__" : "up",
+ * "job" : "node",
+ * "instance" : "localhost:9091"
+ * },
+ * {
+ * "__name__" : "process_start_time_seconds",
+ * "job" : "prometheus",
+ * "instance" : "localhost:9090"
+ * }
+ * ]
+ * }
+ *
+ * It returns all the different "job" values:
+ *
+ * [
+ * {
+ * "label": "node",
+ * "value": "node"
+ * },
+ * {
+ * "label": "prometheus",
+ * "value": "prometheus"
+ * }
+ * ]
+ *
+ * @param {options} options object
+ * @param {options.seriesLabel} name of the searched series label
+ * @param {options.data} series data from the series API
+ * @return {array} Options objects with the shape `{ label, value }`
+ *
+ * @see https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers
+ */
+export const optionsFromSeriesData = ({ label, data = [] }) => {
+ const optionsSet = data.reduce((set, seriesObject) => {
+ // Use `new Set` to deduplicate options
+ if (seriesObject[label]) {
+ set.add(seriesObject[label]);
+ }
+ return set;
+ }, new Set());
+
+ return [...optionsSet].map(parseSimpleCustomValues);
};
export default {};
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 4d2927a066e..0c6fcad9dd0 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -5,6 +5,7 @@ import {
removeParams,
updateHistory,
} from '~/lib/utils/url_utility';
+import { parseBoolean } from '~/lib/utils/common_utils';
import {
timeRangeParamNames,
timeRangeFromParams,
@@ -13,6 +14,50 @@ import {
import { VARIABLE_PREFIX } from './constants';
/**
+ * Extracts the initial state and props from HTML dataset
+ * and places them in separate objects to setup bundle.
+ * @param {*} dataset
+ */
+export const stateAndPropsFromDataset = (dataset = {}) => {
+ const {
+ currentDashboard,
+ deploymentsEndpoint,
+ dashboardEndpoint,
+ dashboardsEndpoint,
+ dashboardTimezone,
+ canAccessOperationsSettings,
+ operationsSettingsPath,
+ projectPath,
+ logsPath,
+ currentEnvironmentName,
+ customDashboardBasePath,
+ ...dataProps
+ } = dataset;
+
+ // HTML attributes are always strings, parse other types.
+ dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics);
+ dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable);
+ dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable);
+
+ return {
+ initState: {
+ currentDashboard,
+ deploymentsEndpoint,
+ dashboardEndpoint,
+ dashboardsEndpoint,
+ dashboardTimezone,
+ canAccessOperationsSettings,
+ operationsSettingsPath,
+ projectPath,
+ logsPath,
+ currentEnvironmentName,
+ customDashboardBasePath,
+ },
+ dataProps,
+ };
+};
+
+/**
* List of non time range url parameters
* This will be removed once we add support for free text variables
* via the dashboard yaml files in https://gitlab.com/gitlab-org/gitlab/-/issues/215689
@@ -160,8 +205,10 @@ export const removePrefixFromLabel = label =>
* @returns {Object}
*/
export const convertVariablesForURL = variables =>
- Object.keys(variables || {}).reduce((acc, key) => {
- acc[addPrefixToLabel(key)] = variables[key]?.value;
+ variables.reduce((acc, { name, value }) => {
+ if (value !== null) {
+ acc[addPrefixToLabel(name)] = value;
+ }
return acc;
}, {});
diff --git a/app/assets/javascripts/namespace_storage_limit_alert.js b/app/assets/javascripts/namespace_storage_limit_alert.js
deleted file mode 100644
index 34ad93c127d..00000000000
--- a/app/assets/javascripts/namespace_storage_limit_alert.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Cookies from 'js-cookie';
-
-const handleOnDismiss = ({ currentTarget }) => {
- const {
- dataset: { id, level },
- } = currentTarget;
-
- Cookies.set(`hide_storage_limit_alert_${id}_${level}`, true, { expires: 365 });
-
- const notification = document.querySelector('.js-namespace-storage-alert');
- notification.parentNode.removeChild(notification);
-};
-
-export default () => {
- const alert = document.querySelector('.js-namespace-storage-alert-dismiss');
-
- if (alert) {
- alert.addEventListener('click', handleOnDismiss);
- }
-};
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 6e695de447d..f4982507adb 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1617,7 +1617,7 @@ export default class Notes {
}
tempFormContent = formContent;
- if (this.hasQuickActions(formContent)) {
+ if (this.glForm.supportsQuickActions && this.hasQuickActions(formContent)) {
tempFormContent = this.stripQuickActions(formContent);
hasQuickActions = true;
}
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 16dcde46262..ac93d3df654 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -17,7 +17,7 @@ import {
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import * as constants from '../constants';
import eventHub from '../event_hub';
-import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
+import NoteableWarning from '../../vue_shared/components/notes/noteable_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import loadingButton from '../../vue_shared/components/loading_button.vue';
@@ -28,8 +28,7 @@ import issuableStateMixin from '../mixins/issuable_state';
export default {
name: 'CommentForm',
components: {
- issueWarning,
- epicWarning: () => import('ee_component/vue_shared/components/epic/epic_warning.vue'),
+ NoteableWarning,
noteSignedOutWidget,
discussionLockedWidget,
markdownField,
@@ -126,9 +125,13 @@ export default {
canToggleIssueState() {
return (
this.getNoteableData.current_user.can_update &&
- this.getNoteableData.state !== constants.MERGED
+ this.getNoteableData.state !== constants.MERGED &&
+ !this.closedAndLocked
);
},
+ closedAndLocked() {
+ return !this.isOpen && this.isLocked(this.getNoteableData);
+ },
endpoint() {
return this.getNoteableData.create_note_path;
},
@@ -350,14 +353,15 @@ export default {
<form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form">
<div class="error-alert"></div>
- <issue-warning
- v-if="hasWarning(getNoteableData) && isIssueType"
+ <noteable-warning
+ v-if="hasWarning(getNoteableData)"
:is-locked="isLocked(getNoteableData)"
:is-confidential="isConfidential(getNoteableData)"
- :locked-issue-docs-path="lockedIssueDocsPath"
- :confidential-issue-docs-path="confidentialIssueDocsPath"
+ :noteable-type="noteableType"
+ :locked-noteable-docs-path="lockedIssueDocsPath"
+ :confidential-noteable-docs-path="confidentialIssueDocsPath"
/>
- <epic-warning :is-confidential="isConfidential(getNoteableData)" />
+
<markdown-field
ref="markdownField"
:is-submitting="isSubmitting"
@@ -374,20 +378,18 @@ export default {
dir="auto"
:disabled="isSubmitting"
name="note[note]"
- class="note-textarea js-vue-comment-form js-note-text
-js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
+ class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
data-supports-quick-actions="true"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()"
@keydown.ctrl.enter="handleSave()"
- >
- </textarea>
+ ></textarea>
</markdown-field>
<gl-alert
v-if="isToggleBlockedIssueWarning"
- class="prepend-top-16"
+ class="gl-mt-5"
:title="__('Are you sure you want to close this blocked issue?')"
:primary-button-text="__('Yes, close issue')"
:secondary-button-text="__('Cancel')"
@@ -417,13 +419,11 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
</gl-alert>
<div class="note-form-actions">
<div
- class="btn-group
-append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
+ class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
<button
:disabled="isSubmitButtonDisabled"
- class="btn btn-success js-comment-button js-comment-submit-button
- qa-comment-button"
+ class="btn btn-success js-comment-button js-comment-submit-button qa-comment-button"
type="submit"
:data-track-label="trackingLabel"
data-track-event="click_button"
@@ -440,7 +440,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
data-toggle="dropdown"
:aria-label="__('Open comment type dropdown')"
>
- <i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i>
+ <i aria-hidden="true" class="fa fa-caret-down toggle-icon"></i>
</button>
<ul class="note-type-dropdown dropdown-open-top dropdown-menu">
@@ -450,7 +450,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
class="btn btn-transparent"
@click.prevent="setNoteType('comment')"
>
- <i aria-hidden="true" class="fa fa-check icon"> </i>
+ <i aria-hidden="true" class="fa fa-check icon"></i>
<div class="description">
<strong>{{ __('Comment') }}</strong>
<p>
@@ -470,7 +470,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
class="btn btn-transparent qa-discussion-option"
@click.prevent="setNoteType('discussion')"
>
- <i aria-hidden="true" class="fa fa-check icon"> </i>
+ <i aria-hidden="true" class="fa fa-check icon"></i>
<div class="description">
<strong>{{ __('Start thread') }}</strong>
<p>{{ startDiscussionDescription }}</p>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 0b136549c14..458da5cf67f 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -74,7 +74,7 @@ export default {
},
},
methods: {
- ...mapActions(['toggleDiscussion']),
+ ...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']),
componentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === SYSTEM_NOTE) {
@@ -99,7 +99,11 @@ export default {
<template>
<div class="discussion-notes">
- <ul class="notes">
+ <ul
+ class="notes"
+ @mouseenter="setSelectedCommentPositionHover(discussion.position.line_range)"
+ @mouseleave="setSelectedCommentPositionHover()"
+ >
<template v-if="shouldGroupReplies">
<component
:is="componentName(firstNote)"
@@ -108,6 +112,7 @@ export default {
:commit="commit"
:help-page-path="helpPagePath"
:show-reply-button="userCanReply"
+ :discussion-root="true"
@handleDeleteNote="$emit('deleteNote')"
@startReplying="$emit('startReplying')"
>
@@ -151,6 +156,7 @@ export default {
:note="componentData(note)"
:help-page-path="helpPagePath"
:line="diffLine"
+ :discussion-root="index === 0"
@handleDeleteNote="$emit('deleteNote')"
>
<slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue
index 5fba011a153..bb13eb87af7 100644
--- a/app/assets/javascripts/notes/components/multiline_comment_form.vue
+++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue
@@ -1,4 +1,5 @@
<script>
+import { mapActions } from 'vuex';
import { GlFormSelect, GlSprintf } from '@gitlab/ui';
import { getSymbol, getLineClasses } from './multiline_comment_utils';
@@ -21,19 +22,51 @@ export default {
},
data() {
return {
- commentLineStart: {
- lineCode: this.lineRange ? this.lineRange.start_line_code : this.line.line_code,
- type: this.lineRange ? this.lineRange.start_line_type : this.line.type,
- },
+ commentLineStart: {},
+ commentLineEndType: this.lineRange?.end?.line_type || this.line.type,
};
},
+ computed: {
+ lineNumber() {
+ return this.commentLineOptions[this.commentLineOptions.length - 1].text;
+ },
+ },
+ created() {
+ const line = this.lineRange?.start || this.line;
+
+ this.commentLineStart = {
+ line_code: line.line_code,
+ type: line.type,
+ old_line: line.old_line,
+ new_line: line.new_line,
+ };
+ this.highlightSelection();
+ },
+ destroyed() {
+ this.setSelectedCommentPosition();
+ },
methods: {
+ ...mapActions(['setSelectedCommentPosition']),
getSymbol({ type }) {
return getSymbol(type);
},
getLineClasses(line) {
return getLineClasses(line);
},
+ updateCommentLineStart(value) {
+ this.commentLineStart = value;
+ this.$emit('input', value);
+ this.highlightSelection();
+ },
+ highlightSelection() {
+ const { line_code, new_line, old_line, type } = this.line;
+ const updatedLineRange = {
+ start: { ...this.commentLineStart },
+ end: { line_code, new_line, old_line, type },
+ };
+
+ this.setSelectedCommentPosition(updatedLineRange);
+ },
},
};
</script>
@@ -55,12 +88,12 @@ export default {
:options="commentLineOptions"
size="sm"
class="gl-w-auto gl-vertical-align-baseline"
- @change="$emit('input', $event)"
+ @change="updateCommentLineStart"
/>
</template>
<template #end>
<span :class="getLineClasses(line)">
- {{ getSymbol(line) + (line.new_line || line.old_line) }}
+ {{ lineNumber }}
</span>
</template>
</gl-sprintf>
diff --git a/app/assets/javascripts/notes/components/multiline_comment_utils.js b/app/assets/javascripts/notes/components/multiline_comment_utils.js
index dc9c55e9b30..dbae10c8f6c 100644
--- a/app/assets/javascripts/notes/components/multiline_comment_utils.js
+++ b/app/assets/javascripts/notes/components/multiline_comment_utils.js
@@ -7,11 +7,19 @@ export function getSymbol(type) {
}
function getLineNumber(lineRange, key) {
- if (!lineRange || !key) return '';
- const lineCode = lineRange[`${key}_line_code`] || '';
- const lineType = lineRange[`${key}_line_type`] || '';
- const lines = lineCode.split('_') || [];
- const lineNumber = lineType === 'old' ? lines[1] : lines[2];
+ if (!lineRange || !key || !lineRange[key]) return '';
+ const { new_line: newLine, old_line: oldLine, type } = lineRange[key];
+ const otherKey = key === 'start' ? 'end' : 'start';
+
+ // By default we want to see the "old" or "left side" line number
+ // The exception is if the "end" line is on the "right" side
+ // `otherLineType` is only used if `type` is null to make sure the line
+ // number relfects the "right" side number, if that is the side
+ // the comment form is located on
+ const otherLineType = !type ? lineRange[otherKey]?.type : null;
+ const lineType = type || '';
+ let lineNumber = oldLine;
+ if (lineType === 'new' || otherLineType === 'new') lineNumber = newLine;
return (lineNumber && getSymbol(lineType) + lineNumber) || '';
}
@@ -37,21 +45,67 @@ export function getLineClasses(line) {
];
}
-export function commentLineOptions(diffLines, lineCode) {
- const selectedIndex = diffLines.findIndex(line => line.line_code === lineCode);
+export function commentLineOptions(diffLines, startingLine, lineCode, side = 'left') {
+ const preferredSide = side === 'left' ? 'old_line' : 'new_line';
+ const fallbackSide = preferredSide === 'new_line' ? 'old_line' : 'new_line';
const notMatchType = l => l.type !== 'match';
+ const linesCopy = [...diffLines]; // don't mutate the argument
+ const startingLineCode = startingLine.line_code;
+
+ const currentIndex = linesCopy.findIndex(line => line.line_code === lineCode);
// We're limiting adding comments to only lines above the current line
// to make rendering simpler. Future interations will use a more
// intuitive dragging interface that will make this unnecessary
- const upToSelected = diffLines.slice(0, selectedIndex + 1);
+ const upToSelected = linesCopy.slice(0, currentIndex + 1);
// Only include the lines up to the first "Show unchanged lines" block
// i.e. not a "match" type
const lines = takeRightWhile(upToSelected, notMatchType);
- return lines.map(l => ({
- value: { lineCode: l.line_code, type: l.type },
- text: `${getSymbol(l.type)}${l.new_line || l.old_line}`,
- }));
+ // If the selected line is "hidden" in an unchanged line block
+ // or "above" the current group of lines add it to the array so
+ // that the drop down is not defaulted to empty
+ const selectedIndex = lines.findIndex(line => line.line_code === startingLineCode);
+ if (selectedIndex < 0) lines.unshift(startingLine);
+
+ return lines.map(l => {
+ const { line_code, type, old_line, new_line } = l;
+ return {
+ value: { line_code, type, old_line, new_line },
+ text: `${getSymbol(type)}${l[preferredSide] || l[fallbackSide]}`,
+ };
+ });
+}
+
+export function formatLineRange(start, end) {
+ const extractProps = ({ line_code, type, old_line, new_line }) => ({
+ line_code,
+ type,
+ old_line,
+ new_line,
+ });
+ return {
+ start: extractProps(start),
+ end: extractProps(end),
+ };
+}
+
+export function getCommentedLines(selectedCommentPosition, diffLines) {
+ if (!selectedCommentPosition) {
+ // This structure simplifies the logic that consumes this result
+ // by keeping the returned shape the same and adjusting the bounds
+ // to something unreachable. This way our component logic stays:
+ // "if index between start and end"
+ return {
+ startLine: diffLines.length + 1,
+ endLine: diffLines.length + 1,
+ };
+ }
+
+ const { start, end } = selectedCommentPosition;
+ const startLine = diffLines.findIndex(l => l.line_code === start.line_code);
+ const endLine = diffLines.findIndex(l => l.line_code === end.line_code);
+
+ return { startLine, endLine };
}
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index f1af8be590a..7615b0518b7 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -128,6 +128,9 @@ export default {
isIssue() {
return this.targetType === 'issue';
},
+ canAssign() {
+ return this.getNoteableData.current_user?.can_update && this.isIssue;
+ },
},
methods: {
onEdit() {
@@ -257,23 +260,23 @@ export default {
{{ __('Copy link') }}
</button>
</li>
- <li v-if="canEdit">
+ <li v-if="canAssign">
<button
- class="btn btn-transparent js-note-delete js-note-delete"
+ class="btn-default btn-transparent"
+ data-testid="assign-user"
type="button"
- @click.prevent="onDelete"
+ @click="assignUser"
>
- <span class="text-danger">{{ __('Delete comment') }}</span>
+ {{ displayAssignUserText }}
</button>
</li>
- <li v-if="isIssue">
+ <li v-if="canEdit">
<button
- class="btn-default btn-transparent"
- data-testid="assign-user"
+ class="btn btn-transparent js-note-delete js-note-delete"
type="button"
- @click="assignUser"
+ @click.prevent="onDelete"
>
- {{ displayAssignUserText }}
+ <span class="text-danger">{{ __('Delete comment') }}</span>
</button>
</li>
</ul>
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 795ee10ca0f..24227d55ebf 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -2,7 +2,7 @@
import { mapGetters, mapActions, mapState } from 'vuex';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
-import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
+import NoteableWarning from '../../vue_shared/components/notes/noteable_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
@@ -12,7 +12,7 @@ import { getDraft, updateDraft } from '~/lib/utils/autosave';
export default {
name: 'NoteForm',
components: {
- issueWarning,
+ NoteableWarning,
markdownField,
},
mixins: [issuableStateMixin, resolvable],
@@ -101,6 +101,7 @@ export default {
isResolving: this.resolveDiscussion,
isUnresolving: !this.resolveDiscussion,
resolveAsThread: true,
+ isSubmittingWithKeydown: false,
};
},
computed: {
@@ -241,6 +242,10 @@ export default {
this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
},
onInput() {
+ if (this.isSubmittingWithKeydown) {
+ return;
+ }
+
if (this.autosaveKey) {
const { autosaveKey, updatedNoteBody: text } = this;
updateDraft(autosaveKey, text);
@@ -250,6 +255,7 @@ export default {
if (this.showBatchCommentsActions) {
this.handleAddToReview();
} else {
+ this.isSubmittingWithKeydown = true;
this.handleUpdate();
}
},
@@ -303,12 +309,12 @@ export default {
></div>
<div class="flash-container timeline-content"></div>
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
- <issue-warning
+ <noteable-warning
v-if="hasWarning(getNoteableData)"
:is-locked="isLocked(getNoteableData)"
:is-confidential="isConfidential(getNoteableData)"
- :locked-issue-docs-path="lockedIssueDocsPath"
- :confidential-issue-docs-path="confidentialIssueDocsPath"
+ :locked-noteable-docs-path="lockedIssueDocsPath"
+ :confidential-noteable-docs-path="confidentialIssueDocsPath"
/>
<markdown-field
@@ -404,7 +410,7 @@ export default {
</button>
<button
v-if="discussion.resolvable"
- class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
+ class="btn btn-nr btn-default gl-mr-3 js-comment-resolve-button"
@click.prevent="handleUpdate(true)"
>
{{ resolveButtonTitle }}
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 0e4dd1b9c84..9bf8cffe940 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -21,6 +21,7 @@ import {
getEndLineNumber,
getLineClasses,
commentLineOptions,
+ formatLineRange,
} from './multiline_comment_utils';
import MultilineCommentForm from './multiline_comment_form.vue';
@@ -62,10 +63,15 @@ export default {
default: false,
},
diffLines: {
- type: Object,
+ type: Array,
required: false,
default: null,
},
+ discussionRoot: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -73,10 +79,7 @@ export default {
isDeleting: false,
isRequesting: false,
isResolving: false,
- commentLineStart: {
- line_code: this.line?.line_code,
- type: this.line?.type,
- },
+ commentLineStart: {},
};
},
computed: {
@@ -144,28 +147,46 @@ export default {
return getEndLineNumber(this.lineRange);
},
showMultiLineComment() {
- return (
- this.glFeatures.multilineComments &&
- this.startLineNumber &&
- this.endLineNumber &&
- (this.startLineNumber !== this.endLineNumber || this.isEditing)
- );
+ if (!this.glFeatures.multilineComments || !this.discussionRoot) return false;
+ if (this.isEditing) return true;
+
+ return this.line && this.startLineNumber !== this.endLineNumber;
},
commentLineOptions() {
- if (this.diffLines) {
- return commentLineOptions(this.diffLines, this.line.line_code);
+ if (!this.diffFile || !this.line) return [];
+
+ const sideA = this.line.type === 'new' ? 'right' : 'left';
+ const sideB = sideA === 'left' ? 'right' : 'left';
+ const lines = this.diffFile.highlighted_diff_lines.length
+ ? this.diffFile.highlighted_diff_lines
+ : this.diffFile.parallel_diff_lines.map(l => l[sideA] || l[sideB]);
+ return commentLineOptions(lines, this.commentLineStart, this.line.line_code, sideA);
+ },
+ diffFile() {
+ if (this.commentLineStart.line_code) {
+ const lineCode = this.commentLineStart.line_code.split('_')[0];
+ return this.getDiffFileByHash(lineCode);
}
- const diffFile = this.diffFile || this.getDiffFileByHash(this.targetNoteHash);
- if (!diffFile) return null;
- return commentLineOptions(diffFile.highlighted_diff_lines, this.line.line_code);
+ return null;
},
},
-
created() {
+ const line = this.note.position?.line_range?.start || this.line;
+
+ this.commentLineStart = line
+ ? {
+ line_code: line.line_code,
+ type: line.type,
+ old_line: line.old_line,
+ new_line: line.new_line,
+ }
+ : {};
+
eventHub.$on('enterEditMode', ({ noteId }) => {
if (noteId === this.note.id) {
this.isEditing = true;
+ this.setSelectedCommentPositionHover();
this.scrollToNoteIfNeeded($(this.$el));
}
});
@@ -185,9 +206,11 @@ export default {
'toggleResolveNote',
'scrollToNoteIfNeeded',
'updateAssignees',
+ 'setSelectedCommentPositionHover',
]),
editHandler() {
this.isEditing = true;
+ this.setSelectedCommentPositionHover();
this.$emit('handleEdit');
},
deleteHandler() {
@@ -224,13 +247,11 @@ export default {
formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) {
const position = {
...this.note.position,
- line_range: {
- start_line_code: this.commentLineStart?.lineCode,
- start_line_type: this.commentLineStart?.type,
- end_line_code: this.line?.line_code,
- end_line_type: this.line?.type,
- },
};
+
+ if (this.commentLineStart && this.line)
+ position.line_range = formatLineRange(this.commentLineStart, this.line);
+
this.$emit('handleUpdateNote', {
note: this.note,
noteText,
@@ -246,7 +267,7 @@ export default {
note: {
target_type: this.getNoteableData.targetType,
target_id: this.note.noteable_id,
- note: { note: noteText },
+ note: { note: noteText, position: JSON.stringify(position) },
},
};
this.isRequesting = true;
@@ -266,6 +287,7 @@ export default {
} else {
this.isRequesting = false;
this.isEditing = true;
+ this.setSelectedCommentPositionHover();
this.$nextTick(() => {
const msg = __('Something went wrong while editing your comment. Please try again.');
Flash(msg, 'alert', this.$el);
@@ -317,14 +339,17 @@ export default {
>
<div v-if="showMultiLineComment" data-testid="multiline-comment">
<multiline-comment-form
- v-if="isEditing && commentLineOptions && line"
+ v-if="isEditing && note.position"
v-model="commentLineStart"
:line="line"
:comment-line-options="commentLineOptions"
:line-range="note.position.line_range"
- class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
+ class="gl-mb-3 gl-text-gray-700 gl-pb-3"
/>
- <div v-else class="gl-mb-3 gl-text-gray-700">
+ <div
+ v-else
+ class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
+ >
<gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
<template #startLine>
<span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span>
diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue
index 4a7543819eb..60b531d7597 100644
--- a/app/assets/javascripts/notes/components/sort_discussion.vue
+++ b/app/assets/javascripts/notes/components/sort_discussion.vue
@@ -49,7 +49,10 @@ export default {
</script>
<template>
- <div class="mr-2 d-inline-block align-bottom full-width-mobile">
+ <div
+ data-testid="sort-discussion-filter"
+ class="gl-mr-2 gl-display-inline-block gl-vertical-align-bottom full-width-mobile"
+ >
<local-storage-sync
:value="sortDirection"
:storage-key="storageKey"
diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
index 5930b5f3321..9a2e86aeed2 100644
--- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js
+++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
@@ -4,6 +4,7 @@ import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/const
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { clearDraft } from '~/lib/utils/autosave';
+import { formatLineRange } from '~/notes/components/multiline_comment_utils';
export default {
computed: {
@@ -45,6 +46,9 @@ export default {
});
},
addToReview(note) {
+ const lineRange =
+ (this.line && this.commentLineStart && formatLineRange(this.commentLineStart, this.line)) ||
+ {};
const positionType = this.diffFileCommentForm
? IMAGE_DIFF_POSITION_TYPE
: TEXT_DIFF_POSITION_TYPE;
@@ -60,6 +64,7 @@ export default {
linePosition: this.position,
positionType,
...this.diffFileCommentForm,
+ lineRange,
});
const diffFileHeadSha = this.commit && this?.diffFile?.diff_refs?.head_sha;
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 9281149d9d3..889883a23d0 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -78,8 +78,16 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId)
const isDiffView = window.mrTabs.currentAction === 'diffs';
const targetId = fn(discussionId, isDiffView);
const discussion = self.getDiscussion(targetId);
- jumpToDiscussion(self, discussion);
- self.setCurrentDiscussionId(targetId);
+ const discussionFilePath = discussion.diff_file?.file_path;
+
+ if (discussionFilePath) {
+ self.scrollToFile(discussionFilePath);
+ }
+
+ self.$nextTick(() => {
+ jumpToDiscussion(self, discussion);
+ self.setCurrentDiscussionId(targetId);
+ });
}
export default {
@@ -95,6 +103,7 @@ export default {
},
methods: {
...mapActions(['expandDiscussion', 'setCurrentDiscussionId']),
+ ...mapActions('diffs', ['scrollToFile']),
jumpToNextDiscussion() {
handleDiscussionJump(this, this.nextUnresolvedDiscussionId);
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index a5b006fc301..5b2ab255557 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -13,11 +13,35 @@ import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils';
import { mergeUrlParams } from '../../lib/utils/url_utility';
import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub';
+import updateIssueConfidentialMutation from '~/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql';
import { __, sprintf } from '~/locale';
import Api from '~/api';
let eTagPoll;
+export const updateConfidentialityOnIssue = ({ commit, getters }, { confidential, fullPath }) => {
+ const { iid } = getters.getNoteableData;
+
+ return utils.gqClient
+ .mutate({
+ mutation: updateIssueConfidentialMutation,
+ variables: {
+ input: {
+ projectPath: fullPath,
+ iid: String(iid),
+ confidential,
+ },
+ },
+ })
+ .then(({ data }) => {
+ const {
+ issueSetConfidential: { issue },
+ } = data;
+
+ commit(types.SET_ISSUE_CONFIDENTIAL, issue.confidential);
+ });
+};
+
export const expandDiscussion = ({ commit, dispatch }, data) => {
if (data.discussionId) {
dispatch('diffs/renderFileForDiscussionId', data.discussionId, { root: true });
@@ -32,6 +56,8 @@ export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, d
export const setNoteableData = ({ commit }, data) => commit(types.SET_NOTEABLE_DATA, data);
+export const setConfidentiality = ({ commit }, data) => commit(types.SET_ISSUE_CONFIDENTIAL, data);
+
export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data);
export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data);
@@ -73,6 +99,14 @@ export const setDiscussionSortDirection = ({ commit }, direction) => {
commit(types.SET_DISCUSSIONS_SORT, direction);
};
+export const setSelectedCommentPosition = ({ commit }, position) => {
+ commit(types.SET_SELECTED_COMMENT_POSITION, position);
+};
+
+export const setSelectedCommentPositionHover = ({ commit }, position) => {
+ commit(types.SET_SELECTED_COMMENT_POSITION_HOVER, position);
+};
+
export const removeNote = ({ commit, dispatch, state }, note) => {
const discussion = state.discussions.find(({ id }) => id === note.discussion_id);
@@ -205,7 +239,6 @@ export const closeIssue = ({ commit, dispatch, state }) => {
commit(types.CLOSE_ISSUE);
dispatch('emitStateChangedEvent', data);
dispatch('toggleStateButtonLoading', false);
- dispatch('toggleBlockedIssueWarning', false);
});
};
@@ -377,9 +410,8 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
};
const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => {
- if (resp.notes && resp.notes.length) {
- updateOrCreateNotes({ commit, state, getters, dispatch }, resp.notes);
-
+ if (resp.notes?.length) {
+ dispatch('updateOrCreateNotes', resp.notes);
dispatch('startTaskList');
}
@@ -399,12 +431,12 @@ const getFetchDataParams = state => {
return { endpoint, options };
};
-export const fetchData = ({ commit, state, getters }) => {
+export const fetchData = ({ commit, state, getters, dispatch }) => {
const { endpoint, options } = getFetchDataParams(state);
axios
.get(endpoint, options)
- .then(({ data }) => pollSuccessCallBack(data, commit, state, getters))
+ .then(({ data }) => pollSuccessCallBack(data, commit, state, getters, dispatch))
.catch(() => Flash(__('Something went wrong while fetching latest comments.')));
};
@@ -424,7 +456,7 @@ export const poll = ({ commit, state, getters, dispatch }) => {
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
} else {
- fetchData({ commit, state, getters });
+ dispatch('fetchData');
}
Visibility.change(() => {
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 329bf5e147e..1649e63c61f 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -12,6 +12,15 @@ export default () => ({
lastFetchedAt: null,
currentDiscussionId: null,
batchSuggestionsInfo: [],
+ /**
+ * selectedCommentPosition & selectedCommentPosition structures are the same as `position.line_range`:
+ * {
+ * start: { line_code: string, new_line: number, old_line:number, type: string },
+ * end: { line_code: string, new_line: number, old_line:number, type: string },
+ * }
+ */
+ selectedCommentPosition: null,
+ selectedCommentPositionHover: null,
// View layer
isToggleStateButtonLoading: false,
@@ -26,6 +35,7 @@ export default () => ({
},
userData: {},
noteableData: {
+ discussion_locked: false,
confidential: false, // TODO: Move data like this to Issue Store, should not be apart of notes.
current_user: {},
preview_note_path: 'path/to/preview',
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index 538774ee467..f2236b18beb 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -33,12 +33,15 @@ export const SET_EXPAND_DISCUSSIONS = 'SET_EXPAND_DISCUSSIONS';
export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS';
export const SET_CURRENT_DISCUSSION_ID = 'SET_CURRENT_DISCUSSION_ID';
export const SET_DISCUSSIONS_SORT = 'SET_DISCUSSIONS_SORT';
+export const SET_SELECTED_COMMENT_POSITION = 'SET_SELECTED_COMMENT_POSITION';
+export const SET_SELECTED_COMMENT_POSITION_HOVER = 'SET_SELECTED_COMMENT_POSITION_HOVER';
// Issue
export const CLOSE_ISSUE = 'CLOSE_ISSUE';
export const REOPEN_ISSUE = 'REOPEN_ISSUE';
export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING';
export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING';
+export const SET_ISSUE_CONFIDENTIAL = 'SET_ISSUE_CONFIDENTIAL';
// Description version
export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 2aeadcb2da1..e5f1c11fb35 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -95,6 +95,10 @@ export default {
Object.assign(state, { noteableData: data });
},
+ [types.SET_ISSUE_CONFIDENTIAL](state, data) {
+ state.noteableData.confidential = data;
+ },
+
[types.SET_USER_DATA](state, data) {
Object.assign(state, { userData: data });
},
@@ -304,6 +308,14 @@ export default {
state.discussionSortOrder = sort;
},
+ [types.SET_SELECTED_COMMENT_POSITION](state, position) {
+ state.selectedCommentPosition = position;
+ },
+
+ [types.SET_SELECTED_COMMENT_POSITION_HOVER](state, position) {
+ state.selectedCommentPositionHover = position;
+ },
+
[types.DISABLE_COMMENTS](state, value) {
state.commentsDisabled = value;
},
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
index 97dcd54fe88..10faac0c32b 100644
--- a/app/assets/javascripts/notes/stores/utils.js
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -1,6 +1,7 @@
import AjaxCache from '~/lib/utils/ajax_cache';
import { trimFirstCharOfLineContent } from '~/diffs/store/utils';
import { sprintf, __ } from '~/locale';
+import createGqClient, { fetchPolicies } from '~/lib/graphql';
// factory function because global flag makes RegExp stateful
const createQuickActionsRegex = () => /^\/\w+.*$/gm;
@@ -34,3 +35,10 @@ export const stripQuickActions = note => note.replace(createQuickActionsRegex(),
export const prepareDiffLines = diffLines =>
diffLines.map(line => ({ ...trimFirstCharOfLineContent(line) }));
+
+export const gqClient = createGqClient(
+ {},
+ {
+ fetchPolicy: fetchPolicies.NO_CACHE,
+ },
+);
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
index 674b807edbe..da7f81759ea 100644
--- a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
+++ b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
@@ -32,7 +32,7 @@ export default class AbuseReports {
$messageCellElement.text(originalMessage);
} else {
$messageCellElement.data('messageTruncated', 'true');
- $messageCellElement.text(`${originalMessage.substr(0, MAX_MESSAGE_LENGTH - 3)}...`);
+ $messageCellElement.text(truncate(originalMessage, MAX_MESSAGE_LENGTH));
}
}
}
diff --git a/app/assets/javascripts/pages/admin/clusters/show/index.js b/app/assets/javascripts/pages/admin/clusters/show/index.js
index 8001d2dd1da..ccf631b2c53 100644
--- a/app/assets/javascripts/pages/admin/clusters/show/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/show/index.js
@@ -1,5 +1,7 @@
import ClustersBundle from '~/clusters/clusters_bundle';
+import initClusterHealth from '~/pages/projects/clusters/show/cluster_health';
document.addEventListener('DOMContentLoaded', () => {
new ClustersBundle(); // eslint-disable-line no-new
+ initClusterHealth();
});
diff --git a/app/assets/javascripts/pages/admin/groups/show/index.js b/app/assets/javascripts/pages/admin/groups/show/index.js
index b0cdad627a6..69d219d29f7 100644
--- a/app/assets/javascripts/pages/admin/groups/show/index.js
+++ b/app/assets/javascripts/pages/admin/groups/show/index.js
@@ -1,3 +1,23 @@
-import UsersSelect from '../../../../users_select';
+import Vue from 'vue';
+import UsersSelect from '~/users_select';
+import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
-document.addEventListener('DOMContentLoaded', () => new UsersSelect());
+function mountRemoveMemberModal() {
+ const el = document.querySelector('.js-remove-member-modal');
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render(createComponent) {
+ return createComponent(RemoveMemberModal);
+ },
+ });
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ mountRemoveMemberModal();
+
+ new UsersSelect(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js
index d6b1e747aec..d86c5e2ddb8 100644
--- a/app/assets/javascripts/pages/admin/projects/index.js
+++ b/app/assets/javascripts/pages/admin/projects/index.js
@@ -1,7 +1,25 @@
-import ProjectsList from '../../../projects_list';
-import NamespaceSelect from '../../../namespace_select';
+import Vue from 'vue';
+import ProjectsList from '~/projects_list';
+import NamespaceSelect from '~/namespace_select';
+import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
+
+function mountRemoveMemberModal() {
+ const el = document.querySelector('.js-remove-member-modal');
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render(createComponent) {
+ return createComponent(RemoveMemberModal);
+ },
+ });
+}
document.addEventListener('DOMContentLoaded', () => {
+ mountRemoveMemberModal();
+
new ProjectsList(); // eslint-disable-line no-new
document
diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js
index 5e119454ce1..35c67190b62 100644
--- a/app/assets/javascripts/pages/constants.js
+++ b/app/assets/javascripts/pages/constants.js
@@ -4,4 +4,5 @@ export const FILTERED_SEARCH = {
MERGE_REQUESTS: 'merge_requests',
ISSUES: 'issues',
ADMIN_RUNNERS: 'admin/runners',
+ GROUP_RUNNERS_ANCHOR: 'runners-settings',
};
diff --git a/app/assets/javascripts/pages/groups/clusters/show/index.js b/app/assets/javascripts/pages/groups/clusters/show/index.js
index 8001d2dd1da..ccf631b2c53 100644
--- a/app/assets/javascripts/pages/groups/clusters/show/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/show/index.js
@@ -1,5 +1,7 @@
import ClustersBundle from '~/clusters/clusters_bundle';
+import initClusterHealth from '~/pages/projects/clusters/show/cluster_health';
document.addEventListener('DOMContentLoaded', () => {
new ClustersBundle(); // eslint-disable-line no-new
+ initClusterHealth();
});
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
new file mode 100644
index 00000000000..e146592e134
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -0,0 +1,30 @@
+import Vue from 'vue';
+import Members from 'ee_else_ce/members';
+import memberExpirationDate from '~/member_expiration_date';
+import UsersSelect from '~/users_select';
+import groupsSelect from '~/groups_select';
+import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
+
+function mountRemoveMemberModal() {
+ const el = document.querySelector('.js-remove-member-modal');
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render(createComponent) {
+ return createComponent(RemoveMemberModal);
+ },
+ });
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ groupsSelect();
+ memberExpirationDate();
+ memberExpirationDate('.js-access-expiration-date-groups');
+ mountRemoveMemberModal();
+
+ new Members(); // eslint-disable-line no-new
+ new UsersSelect(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js
deleted file mode 100644
index 0c732922e81..00000000000
--- a/app/assets/javascripts/pages/groups/group_members/index/index.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/* eslint-disable no-new */
-
-import Members from 'ee_else_ce/members';
-import memberExpirationDate from '~/member_expiration_date';
-import UsersSelect from '~/users_select';
-import groupsSelect from '~/groups_select';
-
-document.addEventListener('DOMContentLoaded', () => {
- memberExpirationDate();
- memberExpirationDate('.js-access-expiration-date-groups');
- new Members();
- groupsSelect();
- new UsersSelect();
-});
diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js
index eeaa6527431..d2684b6af59 100644
--- a/app/assets/javascripts/pages/groups/new/group_path_validator.js
+++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js
@@ -12,6 +12,7 @@ const successMessageSelector = '.validation-success';
const pendingMessageSelector = '.validation-pending';
const unavailableMessageSelector = '.validation-error';
const suggestionsMessageSelector = '.gl-path-suggestions';
+const inputGroupSelector = '.input-group';
export default class GroupPathValidator extends InputValidator {
constructor(opts = {}) {
@@ -39,7 +40,7 @@ export default class GroupPathValidator extends InputValidator {
static validateGroupPathInput(inputDomElement) {
const groupPath = inputDomElement.value;
- if (inputDomElement.checkValidity() && groupPath.length > 0) {
+ if (inputDomElement.checkValidity() && groupPath.length > 1) {
GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector);
fetchGroupPathAvailability(groupPath)
@@ -69,9 +70,10 @@ export default class GroupPathValidator extends InputValidator {
}
static setMessageVisibility(inputDomElement, messageSelector, isVisible = true) {
- const messageElement = inputDomElement.parentElement.parentElement.querySelector(
- messageSelector,
- );
+ const messageElement = inputDomElement
+ .closest(inputGroupSelector)
+ .parentElement.querySelector(messageSelector);
+
messageElement.classList.toggle('hide', !isVisible);
}
diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
index 479c82265f2..23283f46a5d 100644
--- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -1,11 +1,20 @@
import initSettingsPanels from '~/settings_panels';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import initVariableList from '~/ci_variable_list';
+import initFilteredSearch from '~/pages/search/init_filtered_search';
+import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners_filtered_search_token_keys';
+import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
initSettingsPanels();
+ initFilteredSearch({
+ page: FILTERED_SEARCH.ADMIN_RUNNERS,
+ filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys,
+ anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR,
+ });
+
if (gon.features.newVariablesUi) {
initVariableList();
} else {
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 85daff3f60f..37b253d7c48 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -8,7 +8,6 @@ import NotificationsForm from '~/notifications_form';
import ProjectsList from '~/projects_list';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import GroupTabs from './group_tabs';
-import initNamespaceStorageLimitAlert from '~/namespace_storage_limit_alert';
export default function initGroupDetails(actionName = 'show') {
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
@@ -28,6 +27,4 @@ export default function initGroupDetails(actionName = 'show') {
if (newGroupChildWrapper) {
new NewGroupChild(newGroupChildWrapper);
}
-
- initNamespaceStorageLimitAlert();
}
diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
index ad003181728..74ab1bc13a9 100644
--- a/app/assets/javascripts/pages/profiles/show/index.js
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -4,6 +4,7 @@ import emojiRegex from 'emoji-regex';
import createFlash from '~/flash';
import EmojiMenu from './emoji_menu';
import { __ } from '~/locale';
+import * as Emoji from '~/emoji';
const defaultStatusEmoji = 'speech_balloon';
@@ -55,8 +56,8 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
- import(/* webpackChunkName: 'emoji' */ '~/emoji')
- .then(Emoji => {
+ Emoji.initEmojiMap()
+ .then(() => {
const emojiMenu = new EmojiMenu(
Emoji,
toggleEmojiMenuButtonSelector,
diff --git a/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js b/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js
new file mode 100644
index 00000000000..382d39645a9
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js
@@ -0,0 +1,18 @@
+import monitoringApp from '~/monitoring/monitoring_app';
+
+export default () => {
+ const el = document.getElementById('prometheus-graphs');
+
+ if (el && el.dataset) {
+ monitoringApp({
+ ...el.dataset,
+ showLegend: false,
+ showHeader: false,
+ showPanels: false,
+ forceSmallGraph: true,
+ smallEmptyState: true,
+ currentEnvironmentName: '',
+ hasMetrics: true,
+ });
+ }
+};
diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js
index 397f9faf6fe..d20e2c19583 100644
--- a/app/assets/javascripts/pages/projects/clusters/show/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/show/index.js
@@ -1,7 +1,9 @@
import ClustersBundle from '~/clusters/clusters_bundle';
import initGkeNamespace from '~/create_cluster/gke_cluster_namespace';
+import initClusterHealth from './cluster_health';
document.addEventListener('DOMContentLoaded', () => {
new ClustersBundle(); // eslint-disable-line no-new
initGkeNamespace();
+ initClusterHealth();
});
diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
index 9f08260c3d6..1415a6f60c8 100644
--- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js
+++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
+import { fetchCommitMergeRequests } from '~/commit_merge_requests';
document.addEventListener('DOMContentLoaded', () => {
new MiniPipelineGraph({
@@ -8,5 +9,6 @@ document.addEventListener('DOMContentLoaded', () => {
}).bindEvents();
// eslint-disable-next-line no-jquery/no-load
$('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
+ fetchCommitMergeRequests();
initPipelines();
});
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
index 9fb07917f9b..e65c18c07a9 100644
--- a/app/assets/javascripts/pages/projects/edit/index.js
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -7,13 +7,20 @@ import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import initFilePickers from '~/file_pickers';
import initProjectLoadingSpinner from '../shared/save_project_loader';
import initProjectPermissionsSettings from '../shared/permissions';
+import initProjectRemoveModal from '~/projects/project_remove_modal';
+import UserCallout from '~/user_callout';
+import initServiceDesk from '~/projects/settings_service_desk';
document.addEventListener('DOMContentLoaded', () => {
initFilePickers();
initConfirmDangerModal();
initSettingsPanels();
+ initProjectRemoveModal();
mountBadgeSettings(PROJECT_BADGE);
+ new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new
+ initServiceDesk();
+
initProjectLoadingSpinner();
initProjectPermissionsSettings();
setupTransferEdit('.js-project-transfer-form', 'select.select2');
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue
new file mode 100644
index 00000000000..77753521342
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlTabs, GlTab, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import createFlash from '~/flash';
+import ForkGroupsListItem from './fork_groups_list_item.vue';
+
+export default {
+ components: {
+ GlTabs,
+ GlTab,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ ForkGroupsListItem,
+ },
+ props: {
+ hasReachedProjectLimit: {
+ type: Boolean,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ namespaces: null,
+ filter: '',
+ };
+ },
+ computed: {
+ filteredNamespaces() {
+ return this.namespaces.filter(n => n.name.toLowerCase().includes(this.filter.toLowerCase()));
+ },
+ },
+
+ mounted() {
+ this.loadGroups();
+ },
+
+ methods: {
+ loadGroups() {
+ axios
+ .get(this.endpoint)
+ .then(response => {
+ this.namespaces = response.data.namespaces;
+ })
+ .catch(() => createFlash(__('There was a problem fetching groups.')));
+ },
+ },
+
+ i18n: {
+ searchPlaceholder: __('Search by name'),
+ },
+};
+</script>
+<template>
+ <gl-tabs class="fork-groups">
+ <gl-tab :title="__('Groups and subgroups')">
+ <gl-loading-icon v-if="!namespaces" size="md" class="gl-mt-3" />
+ <template v-else-if="namespaces.length === 0">
+ <div class="gl-text-center">
+ <div class="h5">{{ __('No available groups to fork the project.') }}</div>
+ <p class="gl-mt-5">
+ {{ __('You must have permission to create a project in a group before forking.') }}
+ </p>
+ </div>
+ </template>
+ <div v-else-if="filteredNamespaces.length === 0" class="gl-text-center gl-mt-3">
+ {{ s__('GroupsTree|No groups matched your search') }}
+ </div>
+ <ul v-else class="groups-list group-list-tree">
+ <fork-groups-list-item
+ v-for="(namespace, index) in filteredNamespaces"
+ :key="index"
+ :group="namespace"
+ :has-reached-project-limit="hasReachedProjectLimit"
+ />
+ </ul>
+ </gl-tab>
+ <template #tabs-end>
+ <gl-search-box-by-type
+ v-if="namespaces && namespaces.length"
+ v-model="filter"
+ :placeholder="$options.i18n.searchPlaceholder"
+ class="gl-align-self-center gl-ml-auto fork-filtered-search"
+ />
+ </template>
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue
new file mode 100644
index 00000000000..792c2f3db34
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue
@@ -0,0 +1,147 @@
+<script>
+import {
+ GlLink,
+ GlButton,
+ GlIcon,
+ GlAvatar,
+ GlTooltipDirective,
+ GlTooltip,
+ GlBadge,
+} from '@gitlab/ui';
+import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/groups/constants';
+import { __ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ components: {
+ GlIcon,
+ GlAvatar,
+ GlBadge,
+ GlButton,
+ GlTooltip,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ group: {
+ type: Object,
+ required: true,
+ },
+ hasReachedProjectLimit: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return { namespaces: null };
+ },
+
+ computed: {
+ rowClass() {
+ return {
+ 'has-description': this.group.description,
+ 'being-removed': this.isGroupPendingRemoval,
+ };
+ },
+ isGroupPendingRemoval() {
+ return this.group.marked_for_deletion;
+ },
+ hasForkedProject() {
+ return Boolean(this.group.forked_project_path);
+ },
+ visibilityIcon() {
+ return VISIBILITY_TYPE_ICON[this.group.visibility];
+ },
+ visibilityTooltip() {
+ return GROUP_VISIBILITY_TYPE[this.group.visibility];
+ },
+ isSelectButtonDisabled() {
+ return this.hasReachedProjectLimit || !this.group.can_create_project;
+ },
+ selectButtonDisabledTooltip() {
+ return this.hasReachedProjectLimit
+ ? this.$options.i18n.hasReachedProjectLimitMessage
+ : this.$options.i18n.insufficientPermissionsMessage;
+ },
+ },
+
+ i18n: {
+ hasReachedProjectLimitMessage: __('You have reached your project limit'),
+ insufficientPermissionsMessage: __(
+ 'You must have permission to create a project in a namespace before forking.',
+ ),
+ },
+
+ csrf,
+};
+</script>
+<template>
+ <li :class="rowClass" class="group-row">
+ <div class="group-row-contents gl-display-flex gl-align-items-center gl-py-3 gl-pr-5">
+ <div class="folder-toggle-wrap gl-mr-2 gl-display-flex gl-align-items-center">
+ <gl-icon name="folder-o" />
+ </div>
+ <gl-link
+ :href="group.relative_path"
+ class="gl-display-none gl-flex-shrink-0 gl-display-sm-flex gl-mr-3"
+ >
+ <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatarUrl" />
+ </gl-link>
+ <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center">
+ <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1">
+ <div class="title gl-display-flex gl-align-items-center gl-flex-wrap gl-mr-3">
+ <gl-link :href="group.relative_path" class="gl-mt-3 gl-mr-3 gl-text-gray-900!">{{
+ group.full_name
+ }}</gl-link>
+ <gl-icon
+ v-gl-tooltip.hover.bottom
+ class="gl-mr-0 gl-inline-flex gl-mt-3 text-secondary"
+ :name="visibilityIcon"
+ :title="visibilityTooltip"
+ />
+ <gl-badge
+ v-if="isGroupPendingRemoval"
+ variant="warning"
+ class="gl-display-none gl-display-sm-flex gl-mt-3 gl-mr-1"
+ >{{ __('pending removal') }}</gl-badge
+ >
+ <span v-if="group.permission" class="user-access-role gl-mt-3">
+ {{ group.permission }}
+ </span>
+ </div>
+ <div v-if="group.description" class="description">
+ <span v-html="group.markdown_description"> </span>
+ </div>
+ </div>
+ <div class="gl-display-flex gl-flex-shrink-0">
+ <gl-button
+ v-if="hasForkedProject"
+ class="gl-h-7 gl-text-decoration-none!"
+ :href="group.forked_project_path"
+ >{{ __('Go to fork') }}</gl-button
+ >
+ <template v-else>
+ <div ref="selectButtonWrapper">
+ <form method="POST" :action="group.fork_path">
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ <gl-button
+ type="submit"
+ class="gl-h-7 gl-text-decoration-none!"
+ :data-qa-name="group.full_name"
+ variant="success"
+ :disabled="isSelectButtonDisabled"
+ >{{ __('Select') }}</gl-button
+ >
+ </form>
+ </div>
+ <gl-tooltip v-if="isSelectButtonDisabled" :target="() => $refs.selectButtonWrapper">
+ {{ selectButtonDisabledTooltip }}
+ </gl-tooltip>
+ </template>
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
index af8fb032c22..39d6df33a85 100644
--- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
+++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
@@ -63,17 +63,19 @@ export default {
selectedDailyCoverageName() {
return this.selectedDailyCoverage?.group_name;
},
- formattedData() {
- if (this.selectedDailyCoverage?.data) {
- return this.selectedDailyCoverage.data.map(value => [
- dateFormat(value.date, 'mmm dd'),
- value.coverage,
- ]);
- }
-
+ sortedData() {
// If the fetching failed, we return an empty array which
// allow the graph to render while empty
- return [];
+ if (!this.selectedDailyCoverage?.data) {
+ return [];
+ }
+
+ return [...this.selectedDailyCoverage.data].sort(
+ (a, b) => new Date(a.date) - new Date(b.date),
+ );
+ },
+ formattedData() {
+ return this.sortedData.map(value => [dateFormat(value.date, 'mmm dd'), value.coverage]);
},
chartData() {
return [
diff --git a/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js b/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js
new file mode 100644
index 00000000000..260ba69b4bc
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js
@@ -0,0 +1,5 @@
+import initIssuablesList from '~/issuables_list';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initIssuablesList();
+});
diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js
new file mode 100644
index 00000000000..72003b61c8a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js
@@ -0,0 +1,30 @@
+/* eslint-disable class-methods-use-this */
+import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
+import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
+
+const AUTHOR_PARAM_KEY = 'author_username';
+
+export default class FilteredSearchServiceDesk extends FilteredSearchManager {
+ constructor(supportBotData) {
+ super({
+ page: 'service_desk',
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
+ });
+
+ this.supportBotData = supportBotData;
+ }
+
+ canEdit(tokenName) {
+ return tokenName !== 'author';
+ }
+
+ modifyUrlParams(paramsArray) {
+ const supportBotParamPair = `${AUTHOR_PARAM_KEY}=${this.supportBotData.username}`;
+ const onlyValidParams = paramsArray.filter(param => param.indexOf(AUTHOR_PARAM_KEY) === -1);
+
+ // unshift ensures author param is always first token element
+ onlyValidParams.unshift(supportBotParamPair);
+
+ return onlyValidParams;
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
new file mode 100644
index 00000000000..56054f5fc80
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
@@ -0,0 +1,11 @@
+import FilteredSearchServiceDesk from './filtered_search';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const supportBotData = JSON.parse(
+ document.querySelector('.js-service-desk-issues').dataset.supportBot,
+ );
+
+ const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData);
+
+ filteredSearchManager.setup();
+});
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 46c9b2fe0af..32f77465347 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -3,7 +3,7 @@ import Issue from '~/issue';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode';
import '~/notes/index';
-import initIssueableApp from '~/issue_show';
+import initIssueableApp, { issuableHeaderWarnings } from '~/issue_show';
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
import initRelatedMergeRequestsApp from '~/related_merge_requests';
import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
@@ -12,15 +12,17 @@ export default function() {
initIssueableApp();
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
+ issuableHeaderWarnings();
- // .js-design-management is currently EE-only.
- // This will be moved to CE as part of https://gitlab.com/gitlab-org/gitlab/-/issues/212566#frontend
- // at which point this conditional can be removed.
- if (document.querySelector('.js-design-management')) {
- import(/* webpackChunkName: 'design_management' */ '~/design_management')
- .then(module => module.default())
- .catch(() => {});
- }
+ import(/* webpackChunkName: 'design_management' */ '~/design_management')
+ .then(module => module.default())
+ .catch(() => {});
+
+ // This will be removed when we remove the `design_management_moved` feature flag
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/223197
+ import(/* webpackChunkName: 'design_management' */ '~/design_management_new')
+ .then(module => module.default())
+ .catch(() => {});
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/metrics_dashboard/index.js b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js
new file mode 100644
index 00000000000..d3028aec313
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js
@@ -0,0 +1,3 @@
+import monitoringApp from '~/monitoring/monitoring_app';
+
+document.addEventListener('DOMContentLoaded', monitoringApp);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index 4efabcb7df3..5ef1f959b2c 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -1,12 +1,19 @@
<script>
-import { GlSprintf, GlLink } from '@gitlab/ui';
+import { GlFormRadio, GlFormRadioGroup, GlLink, GlSprintf } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { getWeekdayNames } from '~/lib/utils/datetime_utility';
+const KEY_EVERY_DAY = 'everyDay';
+const KEY_EVERY_WEEK = 'everyWeek';
+const KEY_EVERY_MONTH = 'everyMonth';
+const KEY_CUSTOM = 'custom';
+
export default {
components: {
- GlSprintf,
+ GlFormRadio,
+ GlFormRadioGroup,
GlLink,
+ GlSprintf,
},
props: {
initialCronInterval: {
@@ -22,6 +29,7 @@ export default {
randomWeekDayIndex: this.generateRandomWeekDayIndex(),
randomDay: this.generateRandomDay(),
inputNameAttribute: 'schedule[cron]',
+ radioValue: this.initialCronInterval ? KEY_CUSTOM : KEY_EVERY_DAY,
cronInterval: this.initialCronInterval,
cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
};
@@ -29,14 +37,11 @@ export default {
computed: {
cronIntervalPresets() {
return {
- everyDay: `0 ${this.randomHour} * * *`,
- everyWeek: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`,
- everyMonth: `0 ${this.randomHour} ${this.randomDay} * *`,
+ [KEY_EVERY_DAY]: `0 ${this.randomHour} * * *`,
+ [KEY_EVERY_WEEK]: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`,
+ [KEY_EVERY_MONTH]: `0 ${this.randomHour} ${this.randomDay} * *`,
};
},
- intervalIsPreset() {
- return Object.values(this.cronIntervalPresets).includes(this.cronInterval);
- },
formattedTime() {
if (this.randomHour > 12) {
return `${this.randomHour - 12}:00pm`;
@@ -45,24 +50,36 @@ export default {
}
return `${this.randomHour}:00am`;
},
+ radioOptions() {
+ return [
+ {
+ value: KEY_EVERY_DAY,
+ text: sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }),
+ },
+ {
+ value: KEY_EVERY_WEEK,
+ text: sprintf(s__('Every week (%{weekday} at %{time})'), {
+ weekday: this.weekday,
+ time: this.formattedTime,
+ }),
+ },
+ {
+ value: KEY_EVERY_MONTH,
+ text: sprintf(s__('Every month (Day %{day} at %{time})'), {
+ day: this.randomDay,
+ time: this.formattedTime,
+ }),
+ },
+ {
+ value: KEY_CUSTOM,
+ text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})'),
+ link: this.cronSyntaxUrl,
+ },
+ ];
+ },
weekday() {
return getWeekdayNames()[this.randomWeekDayIndex];
},
- everyDayText() {
- return sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime });
- },
- everyWeekText() {
- return sprintf(s__('Every week (%{weekday} at %{time})'), {
- weekday: this.weekday,
- time: this.formattedTime,
- });
- },
- everyMonthText() {
- return sprintf(s__('Every month (Day %{day} at %{time})'), {
- day: this.randomDay,
- time: this.formattedTime,
- });
- },
},
watch: {
cronInterval() {
@@ -72,38 +89,18 @@ export default {
gl.pipelineScheduleFieldErrors.updateFormValidityState();
});
},
- },
- // If at the mounting stage the default is still an empty string, we
- // know we are not editing an existing field so we update it so
- // that the default is the first radio option
- mounted() {
- if (this.cronInterval === '') {
- this.cronInterval = this.cronIntervalPresets.everyDay;
- }
+ radioValue: {
+ immediate: true,
+ handler(val) {
+ if (val !== KEY_CUSTOM) {
+ this.cronInterval = this.cronIntervalPresets[val];
+ }
+ },
+ },
},
methods: {
- setCustomInput(e) {
- if (!this.isEditingCustom) {
- this.isEditingCustom = true;
- this.$refs.customInput.click();
- // Because we need to manually trigger the click on the radio btn,
- // it will add a space to update the v-model. If the user is typing
- // and the space is added, it will feel very unituitive so we reset
- // the value to the original
- this.cronInterval = e.target.value;
- }
- if (this.intervalIsPreset) {
- this.isEditingCustom = false;
- }
- },
- toggleCustomInput(shouldEnable) {
- this.isEditingCustom = shouldEnable;
-
- if (shouldEnable) {
- // We need to change the value so other radios don't remain selected
- // because the model (cronInterval) hasn't changed. The server trims it.
- this.cronInterval = `${this.cronInterval} `;
- }
+ onCustomInput() {
+ this.radioValue = KEY_CUSTOM;
},
generateRandomHour() {
return Math.floor(Math.random() * 23);
@@ -119,89 +116,33 @@ export default {
</script>
<template>
- <div class="interval-pattern-form-group">
- <div class="cron-preset-radio-input">
- <input
- id="every-day"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :value="cronIntervalPresets.everyDay"
- class="label-bold"
- type="radio"
- @click="toggleCustomInput(false)"
- />
-
- <label class="label-bold" for="every-day">
- {{ everyDayText }}
- </label>
- </div>
-
- <div class="cron-preset-radio-input">
- <input
- id="every-week"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :value="cronIntervalPresets.everyWeek"
- class="label-bold"
- type="radio"
- @click="toggleCustomInput(false)"
- />
-
- <label class="label-bold" for="every-week">
- {{ everyWeekText }}
- </label>
- </div>
-
- <div class="cron-preset-radio-input">
- <input
- id="every-month"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :value="cronIntervalPresets.everyMonth"
- class="label-bold"
- type="radio"
- @click="toggleCustomInput(false)"
- />
-
- <label class="label-bold" for="every-month">
- {{ everyMonthText }}
- </label>
- </div>
-
- <div class="cron-preset-radio-input">
- <input
- id="custom"
- ref="customInput"
- v-model="cronInterval"
- :name="inputNameAttribute"
- :value="cronInterval"
- class="label-bold"
- type="radio"
- @click="toggleCustomInput(true)"
- />
-
- <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label>
-
- <gl-sprintf :message="__('(%{linkStart}Cron syntax%{linkEnd})')">
- <template #link="{content}">
- <gl-link :href="cronSyntaxUrl" target="_blank" class="gl-font-sm">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </div>
-
- <div class="cron-interval-input-wrapper">
- <input
- id="schedule_cron"
- v-model="cronInterval"
- :placeholder="__('Define a custom pattern with cron syntax')"
- :name="inputNameAttribute"
- class="form-control inline cron-interval-input"
- type="text"
- required="true"
- @input="setCustomInput"
- />
- </div>
+ <div>
+ <gl-form-radio-group v-model="radioValue" :name="inputNameAttribute">
+ <gl-form-radio
+ v-for="option in radioOptions"
+ :key="option.value"
+ :value="option.value"
+ :data-testid="option.value"
+ >
+ <gl-sprintf v-if="option.link" :message="option.text">
+ <template #link="{content}">
+ <gl-link :href="option.link" target="_blank" class="gl-font-sm">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ <template v-else>{{ option.text }}</template>
+ </gl-form-radio>
+ </gl-form-radio-group>
+ <input
+ id="schedule_cron"
+ v-model="cronInterval"
+ :placeholder="__('Define a custom pattern with cron syntax')"
+ :name="inputNameAttribute"
+ class="form-control inline cron-interval-input"
+ type="text"
+ required="true"
+ @input="onCustomInput"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js
index 2c37d7da4a7..bed9a751d4c 100644
--- a/app/assets/javascripts/pages/projects/pipelines/index/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js
@@ -8,7 +8,7 @@ import {
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import PipelinesStore from '../../../../pipelines/stores/pipelines_store';
-import pipelinesComponent from '../../../../pipelines/components/pipelines.vue';
+import pipelinesComponent from '../../../../pipelines/components/pipelines_list/pipelines.vue';
import Translate from '../../../../vue_shared/translate';
Vue.use(Translate);
@@ -40,6 +40,7 @@ document.addEventListener(
props: {
store: this.store,
endpoint: this.dataset.endpoint,
+ pipelineScheduleUrl: this.dataset.pipelineScheduleUrl,
helpPagePath: this.dataset.helpPagePath,
emptyStateSvgPath: this.dataset.emptyStateSvgPath,
errorStateSvgPath: this.dataset.errorStateSvgPath,
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index f39765818e7..e146592e134 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,12 +1,30 @@
+import Vue from 'vue';
import Members from 'ee_else_ce/members';
-import memberExpirationDate from '../../../member_expiration_date';
-import UsersSelect from '../../../users_select';
-import groupsSelect from '../../../groups_select';
+import memberExpirationDate from '~/member_expiration_date';
+import UsersSelect from '~/users_select';
+import groupsSelect from '~/groups_select';
+import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
+
+function mountRemoveMemberModal() {
+ const el = document.querySelector('.js-remove-member-modal');
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render(createComponent) {
+ return createComponent(RemoveMemberModal);
+ },
+ });
+}
document.addEventListener('DOMContentLoaded', () => {
- memberExpirationDate('.js-access-expiration-date-groups');
groupsSelect();
memberExpirationDate();
+ memberExpirationDate('.js-access-expiration-date-groups');
+ mountRemoveMemberModal();
+
new Members(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new
});
diff --git a/app/assets/javascripts/pages/projects/releases/new/index.js b/app/assets/javascripts/pages/projects/releases/new/index.js
new file mode 100644
index 00000000000..0e314aacf8a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/releases/new/index.js
@@ -0,0 +1,7 @@
+import ZenMode from '~/zen_mode';
+import initNewRelease from '~/releases/mount_new';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ZenMode(); // eslint-disable-line no-new
+ initNewRelease();
+});
diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
index 721d4a31fe4..1b9ec44ed4a 100644
--- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -1,13 +1,17 @@
import mountErrorTrackingForm from '~/error_tracking_settings';
+import mountAlertsSettings from '~/alerts_settings';
import mountOperationSettings from '~/operation_settings';
import mountGrafanaIntegration from '~/grafana_integration';
import initSettingsPanels from '~/settings_panels';
+import initIncidentsSettings from '~/incidents_settings';
document.addEventListener('DOMContentLoaded', () => {
+ initIncidentsSettings();
mountErrorTrackingForm();
mountOperationSettings();
mountGrafanaIntegration();
if (!IS_EE) {
initSettingsPanels();
}
+ mountAlertsSettings(document.querySelector('.js-alerts-settings'));
});
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 7181332a1d6..a95f0af46cd 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -426,7 +426,7 @@ export default {
v-if="lfsAvailable"
ref="git-lfs-settings"
:help-path="lfsHelpPath"
- :label="s__('ProjectSettings|Git Large File Storage')"
+ :label="s__('ProjectSettings|Git Large File Storage (LFS)')"
:help-text="
s__('ProjectSettings|Manages large files such as audio, video, and graphics files')
"
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 3c44053e2b2..c65cc3e4c57 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -1,25 +1,18 @@
-import $ from 'jquery';
-import 'jquery.waitforimages';
-
import initBlob from '~/blob_edit/blob_bundle';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import NotificationsForm from '~/notifications_form';
import UserCallout from '~/user_callout';
-import TreeView from '~/tree';
import BlobViewer from '~/blob/viewer/index';
import Activities from '~/activities';
-import { ajaxGet } from '~/lib/utils/common_utils';
-import GpgBadges from '~/gpg_badges';
import initReadMore from '~/read_more';
import leaveByUrl from '~/namespaces/leave_by_url';
import Star from '../../../star';
import notificationsDropdown from '../../../notifications_dropdown';
-import initNamespaceStorageLimitAlert from '~/namespace_storage_limit_alert';
import { showLearnGitLabProjectPopover } from '~/onboarding_issues';
+import initTree from 'ee_else_ce/repository';
document.addEventListener('DOMContentLoaded', () => {
initReadMore();
- initNamespaceStorageLimitAlert();
new Star(); // eslint-disable-line no-new
notificationsDropdown();
new ShortcutsNavigation(); // eslint-disable-line no-new
@@ -31,10 +24,10 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Project show page loads different overview content based on user preferences
- const treeSlider = document.querySelector('#tree-slider');
+ const treeSlider = document.getElementById('js-tree-list');
if (treeSlider) {
- new TreeView(); // eslint-disable-line no-new
initBlob();
+ initTree();
}
if (document.querySelector('.blob-viewer')) {
@@ -45,21 +38,7 @@ document.addEventListener('DOMContentLoaded', () => {
new Activities(); // eslint-disable-line no-new
}
- $(treeSlider).waitForImages(() => {
- ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
- });
-
- GpgBadges.fetch();
leaveByUrl('project');
- if (document.getElementById('js-tree-list')) {
- initBlob();
- import('ee_else_ce/repository')
- .then(m => m.default())
- .catch(e => {
- throw e;
- });
- }
-
showLearnGitLabProjectPopover();
});
diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js
index 0d1d32317fe..78a4ea23f1a 100644
--- a/app/assets/javascripts/pages/projects/tree/show/index.js
+++ b/app/assets/javascripts/pages/projects/tree/show/index.js
@@ -1,53 +1,12 @@
import $ from 'jquery';
-import 'jquery.waitforimages';
-
-import Vue from 'vue';
import initBlob from '~/blob_edit/blob_bundle';
-import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
-import GpgBadges from '~/gpg_badges';
-import TreeView from '../../../../tree';
import ShortcutsNavigation from '../../../../behaviors/shortcuts/shortcuts_navigation';
-import BlobViewer from '../../../../blob/viewer';
import NewCommitForm from '../../../../new_commit_form';
-import { ajaxGet } from '../../../../lib/utils/common_utils';
+import initTree from 'ee_else_ce/repository';
document.addEventListener('DOMContentLoaded', () => {
new ShortcutsNavigation(); // eslint-disable-line no-new
- new TreeView(); // eslint-disable-line no-new
- new BlobViewer(); // eslint-disable-line no-new
new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new
- $('#tree-slider').waitForImages(() =>
- ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath),
- );
-
initBlob();
- const commitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
- const statusLink = document.querySelector('.commit-actions .ci-status-link');
- if (statusLink != null) {
- statusLink.remove();
- // eslint-disable-next-line no-new
- new Vue({
- el: commitPipelineStatusEl,
- components: {
- commitPipelineStatus,
- },
- render(createElement) {
- return createElement('commit-pipeline-status', {
- props: {
- endpoint: commitPipelineStatusEl.dataset.endpoint,
- },
- });
- },
- });
- }
-
- GpgBadges.fetch();
-
- if (document.getElementById('js-tree-list')) {
- import('ee_else_ce/repository')
- .then(m => m.default())
- .catch(e => {
- throw e;
- });
- }
+ initTree();
});
diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js
index e54e32199f0..b331a2bee6a 100644
--- a/app/assets/javascripts/pages/search/init_filtered_search.js
+++ b/app/assets/javascripts/pages/search/init_filtered_search.js
@@ -7,6 +7,7 @@ export default ({
isGroupAncestor,
isGroupDecendent,
stateFiltersSelector,
+ anchor,
}) => {
const filteredSearchEnabled = FilteredSearchManager && document.querySelector('.filtered-search');
if (filteredSearchEnabled) {
@@ -17,6 +18,7 @@ export default ({
isGroupDecendent,
filteredSearchTokenKeys,
stateFiltersSelector,
+ anchor,
});
filteredSearchManager.setup();
}
diff --git a/app/assets/javascripts/pages/sessions/new/length_validator.js b/app/assets/javascripts/pages/sessions/new/length_validator.js
index 3d687ca08cc..92482c81f3c 100644
--- a/app/assets/javascripts/pages/sessions/new/length_validator.js
+++ b/app/assets/javascripts/pages/sessions/new/length_validator.js
@@ -21,11 +21,24 @@ export default class LengthValidator extends InputValidator {
);
const { value } = this.inputDomElement;
- const { maxLengthMessage, maxLength } = this.inputDomElement.dataset;
-
- this.errorMessage = maxLengthMessage;
-
- this.invalidInput = value.length > parseInt(maxLength, 10);
+ const {
+ minLength,
+ minLengthMessage,
+ maxLengthMessage,
+ maxLength,
+ } = this.inputDomElement.dataset;
+
+ this.invalidInput = false;
+
+ if (value.length > parseInt(maxLength, 10)) {
+ this.invalidInput = true;
+ this.errorMessage = maxLengthMessage;
+ }
+
+ if (value.length < parseInt(minLength, 10)) {
+ this.invalidInput = true;
+ this.errorMessage = minLengthMessage;
+ }
this.setValidationStateAndMessage();
}
diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js
index 1048e3b4548..ecb5e677290 100644
--- a/app/assets/javascripts/pages/sessions/new/username_validator.js
+++ b/app/assets/javascripts/pages/sessions/new/username_validator.js
@@ -39,7 +39,7 @@ export default class UsernameValidator extends InputValidator {
static validateUsernameInput(inputDomElement) {
const username = inputDomElement.value;
- if (inputDomElement.checkValidity() && username.length > 0) {
+ if (inputDomElement.checkValidity() && username.length > 1) {
UsernameValidator.setMessageVisibility(inputDomElement, pendingMessageSelector);
UsernameValidator.fetchUsernameAvailability(username)
.then(usernameTaken => {
diff --git a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue
index 580cca49b5e..a7b7d597fb7 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue
@@ -55,13 +55,22 @@ export default {
<template>
<div class="d-inline-block">
- <button v-gl-modal="modalId" type="button" class="btn btn-danger">{{ __('Delete') }}</button>
+ <button
+ v-gl-modal="modalId"
+ type="button"
+ class="btn btn-danger"
+ data-qa-selector="delete_button"
+ >
+ {{ __('Delete') }}
+ </button>
<gl-modal
:title="title"
- :ok-title="s__('WikiPageConfirmDelete|Delete page')"
+ :action-primary="{
+ text: s__('WikiPageConfirmDelete|Delete page'),
+ attributes: { variant: 'danger', 'data-qa-selector': 'confirm_deletion_button' },
+ }"
:modal-id="modalId"
title-tag="h4"
- ok-variant="danger"
@ok="onSubmit"
>
{{ message }}
diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js
index ed67219383b..41d43812b5d 100644
--- a/app/assets/javascripts/pages/shared/wikis/wikis.js
+++ b/app/assets/javascripts/pages/shared/wikis/wikis.js
@@ -1,5 +1,6 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { s__, sprintf } from '~/locale';
+import Tracking from '~/tracking';
const MARKDOWN_LINK_TEXT = {
markdown: '[Link Title](page-slug)',
@@ -8,6 +9,9 @@ const MARKDOWN_LINK_TEXT = {
org: '[[page-slug]]',
};
+const TRACKING_EVENT_NAME = 'view_wiki_page';
+const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-0';
+
export default class Wikis {
constructor() {
this.sidebarEl = document.querySelector('.js-wiki-sidebar');
@@ -57,6 +61,8 @@ export default class Wikis {
window.onbeforeunload = null;
});
}
+
+ Wikis.trackPageView();
}
handleWikiTitleChange(e) {
@@ -97,4 +103,17 @@ export default class Wikis {
classList.remove('right-sidebar-expanded');
}
}
+
+ static trackPageView() {
+ const wikiPageContent = document.querySelector('.js-wiki-page-content[data-tracking-context]');
+ if (!wikiPageContent) return;
+
+ Tracking.event(document.body.dataset.page, TRACKING_EVENT_NAME, {
+ label: TRACKING_EVENT_NAME,
+ context: {
+ schema: TRACKING_CONTEXT_SCHEMA,
+ data: JSON.parse(wikiPageContent.dataset.trackingContext),
+ },
+ });
+ }
}
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index 115b2ff08ac..c22a648d17f 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -45,7 +45,7 @@ export default {
};
</script>
<template>
- <div id="peek-request-selector" data-qa-selector="request_dropdown">
+ <div id="peek-request-selector" data-qa-selector="request_dropdown" class="view">
<select v-model="currentRequestId">
<option
v-for="request in requests"
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index b3068c46bcb..b8a1397d8f6 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -18,17 +18,21 @@ export default class PersistentUserCallout {
init() {
const closeButton = this.container.querySelector('.js-close');
+ const followLink = this.container.querySelector('.js-follow-link');
- if (!closeButton) {
- return;
+ if (closeButton) {
+ this.handleCloseButtonCallout(closeButton);
+ } else if (followLink) {
+ this.handleFollowLinkCallout(followLink);
}
+ }
+ handleCloseButtonCallout(closeButton) {
closeButton.addEventListener('click', event => this.dismiss(event));
if (this.deferLinks) {
this.container.addEventListener('click', event => {
const isDeferredLink = event.target.classList.contains(DEFERRED_LINK_CLASS);
-
if (isDeferredLink) {
const { href, target } = event.target;
@@ -38,6 +42,10 @@ export default class PersistentUserCallout {
}
}
+ handleFollowLinkCallout(followLink) {
+ followLink.addEventListener('click', event => this.registerCalloutWithLink(event));
+ }
+
dismiss(event, deferredLinkOptions = null) {
event.preventDefault();
@@ -58,6 +66,27 @@ export default class PersistentUserCallout {
});
}
+ registerCalloutWithLink(event) {
+ event.preventDefault();
+
+ const { href } = event.currentTarget;
+
+ axios
+ .post(this.dismissEndpoint, {
+ feature_name: this.featureId,
+ })
+ .then(() => {
+ window.location.assign(href);
+ })
+ .catch(() => {
+ Flash(
+ __(
+ 'An error occurred while acknowledging the notification. Refresh the page and try again.',
+ ),
+ );
+ });
+ }
+
static factory(container, options) {
if (!container) {
return undefined;
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 6e292299778..f4fe605f0a2 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -4,6 +4,9 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-recovery-settings-callout',
'.js-users-over-license-callout',
'.js-admin-licensed-user-count-threshold',
+ '.js-buy-pipeline-minutes-notification-callout',
+ '.js-alerts-moved-alert',
+ '.js-token-expiry-callout',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js
index 51b1fb4f4cc..b6a98fdc488 100644
--- a/app/assets/javascripts/pipelines/components/dag/constants.js
+++ b/app/assets/javascripts/pipelines/components/dag/constants.js
@@ -8,3 +8,8 @@ export const DEFAULT = 'default';
export const IS_HIGHLIGHTED = 'dag-highlighted';
export const LINK_SELECTOR = 'dag-link';
export const NODE_SELECTOR = 'dag-node';
+
+/* Annotation types */
+export const ADD_NOTE = 'add';
+export const REMOVE_NOTE = 'remove';
+export const REPLACE_NOTES = 'replace';
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index 6e0d23ef87f..85163a666e2 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -1,19 +1,32 @@
<script>
-import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import DagGraph from './dag_graph.vue';
-import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from './constants';
+import DagAnnotations from './dag_annotations.vue';
+import {
+ DEFAULT,
+ PARSE_FAILURE,
+ LOAD_FAILURE,
+ UNSUPPORTED_DATA,
+ ADD_NOTE,
+ REMOVE_NOTE,
+ REPLACE_NOTES,
+} from './constants';
import { parseData } from './parsing_utils';
export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Dag',
components: {
+ DagAnnotations,
DagGraph,
GlAlert,
GlLink,
GlSprintf,
+ GlEmptyState,
+ GlButton,
},
props: {
graphUrl: {
@@ -21,21 +34,43 @@ export default {
required: false,
default: '',
},
+ emptySvgPath: {
+ type: String,
+ required: true,
+ default: '',
+ },
+ dagDocPath: {
+ type: String,
+ required: true,
+ default: '',
+ },
},
data() {
return {
- showFailureAlert: false,
- showBetaInfo: true,
+ annotationsMap: {},
failureType: null,
graphData: null,
+ showFailureAlert: false,
+ showBetaInfo: true,
+ hasNoDependentJobs: false,
};
},
errorTexts: {
[LOAD_FAILURE]: __('We are currently unable to fetch data for this graph.'),
[PARSE_FAILURE]: __('There was an error parsing the data for this graph.'),
- [UNSUPPORTED_DATA]: __('A DAG must have two dependent jobs to be visualized on this tab.'),
+ [UNSUPPORTED_DATA]: __('DAG visualization requires at least 3 dependent jobs.'),
[DEFAULT]: __('An unknown error occurred while loading this graph.'),
},
+ emptyStateTexts: {
+ title: __('Start using Directed Acyclic Graphs (DAG)'),
+ firstDescription: __(
+ "This pipeline does not use the %{codeStart}needs%{codeEnd} keyword and can't be represented as a directed acyclic graph.",
+ ),
+ secondDescription: __(
+ 'Using %{codeStart}needs%{codeEnd} allows jobs to run before their stage is reached, as soon as their individual dependencies are met, which speeds up your pipelines.',
+ ),
+ button: __('Learn more about job dependencies'),
+ },
computed: {
betaMessage() {
return __(
@@ -66,6 +101,9 @@ export default {
};
}
},
+ shouldDisplayAnnotations() {
+ return !isEmpty(this.annotationsMap);
+ },
shouldDisplayGraph() {
return Boolean(!this.showFailureAlert && this.graphData);
},
@@ -86,6 +124,9 @@ export default {
.catch(() => reportFailure(LOAD_FAILURE));
},
methods: {
+ addAnnotationToMap({ uid, source, target }) {
+ this.$set(this.annotationsMap, uid, { source, target });
+ },
processGraphData(data) {
let parsed;
@@ -96,11 +137,18 @@ export default {
return;
}
- if (parsed.links.length < 2) {
+ if (parsed.links.length === 1) {
this.reportFailure(UNSUPPORTED_DATA);
return;
}
+ // If there are no links, we don't report failure
+ // as it simply means the user does not use job dependencies
+ if (parsed.links.length === 0) {
+ this.hasNoDependentJobs = true;
+ return;
+ }
+
this.graphData = parsed;
},
hideAlert() {
@@ -109,10 +157,28 @@ export default {
hideBetaInfo() {
this.showBetaInfo = false;
},
+ removeAnnotationFromMap({ uid }) {
+ this.$delete(this.annotationsMap, uid);
+ },
reportFailure(type) {
this.showFailureAlert = true;
this.failureType = type;
},
+ updateAnnotation({ type, data }) {
+ switch (type) {
+ case ADD_NOTE:
+ this.addAnnotationToMap(data);
+ break;
+ case REMOVE_NOTE:
+ this.removeAnnotationFromMap(data);
+ break;
+ case REPLACE_NOTES:
+ this.annotationsMap = data;
+ break;
+ default:
+ break;
+ }
+ },
},
};
</script>
@@ -131,6 +197,43 @@ export default {
</template>
</gl-sprintf>
</gl-alert>
- <dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" />
+ <div class="gl-relative">
+ <dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" />
+ <dag-graph
+ v-if="shouldDisplayGraph"
+ :graph-data="graphData"
+ @onFailure="reportFailure"
+ @update-annotation="updateAnnotation"
+ />
+ <gl-empty-state
+ v-else-if="hasNoDependentJobs"
+ :svg-path="emptySvgPath"
+ :title="$options.emptyStateTexts.title"
+ >
+ <template #description>
+ <div class="gl-text-left">
+ <p>
+ <gl-sprintf :message="$options.emptyStateTexts.firstDescription">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>
+ <gl-sprintf :message="$options.emptyStateTexts.secondDescription">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ </template>
+ <template #actions>
+ <gl-button :href="dagDocPath" target="__blank" variant="success">
+ {{ $options.emptyStateTexts.button }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue b/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue
new file mode 100644
index 00000000000..a1500166cdc
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ name: 'DagAnnotations',
+ components: {
+ GlButton,
+ },
+ props: {
+ annotations: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showList: true,
+ };
+ },
+ computed: {
+ linkText() {
+ return this.showList ? __('Hide list') : __('Show list');
+ },
+ shouldShowLink() {
+ return Object.keys(this.annotations).length > 1;
+ },
+ wrapperClasses() {
+ return [
+ 'gl-display-flex',
+ 'gl-flex-direction-column',
+ 'gl-fixed',
+ 'gl-right-1',
+ 'gl-top-66vh',
+ 'gl-w-max-content',
+ 'gl-px-5',
+ 'gl-py-4',
+ 'gl-rounded-base',
+ 'gl-bg-white',
+ ].join(' ');
+ },
+ },
+ methods: {
+ toggleList() {
+ this.showList = !this.showList;
+ },
+ },
+};
+</script>
+<template>
+ <div :class="wrapperClasses">
+ <div v-if="showList">
+ <div
+ v-for="note in annotations"
+ :key="note.uid"
+ class="gl-display-flex gl-align-items-center"
+ >
+ <div
+ data-testid="dag-color-block"
+ class="gl-w-6 gl-h-5"
+ :style="{
+ background: `linear-gradient(0.25turn, ${note.source.color} 40%, ${note.target.color} 60%)`,
+ }"
+ ></div>
+ <div data-testid="dag-note-text" class="gl-px-2 gl-font-base gl-align-items-center">
+ {{ note.source.name }} → {{ note.target.name }}
+ </div>
+ </div>
+ </div>
+
+ <gl-button v-if="shouldShowLink" variant="link" @click="toggleList">{{ linkText }}</gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
index 063ec091e4d..d12baa9617e 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
@@ -1,8 +1,17 @@
<script>
import * as d3 from 'd3';
import { uniqueId } from 'lodash';
-import { LINK_SELECTOR, NODE_SELECTOR, PARSE_FAILURE } from './constants';
import {
+ LINK_SELECTOR,
+ NODE_SELECTOR,
+ PARSE_FAILURE,
+ ADD_NOTE,
+ REMOVE_NOTE,
+ REPLACE_NOTES,
+} from './constants';
+import {
+ currentIsLive,
+ getLiveLinksAsDict,
highlightLinks,
restoreLinks,
toggleLinkHighlight,
@@ -25,6 +34,11 @@ export default {
containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join(
' ',
),
+ hoverFadeClasses: [
+ 'gl-cursor-pointer',
+ 'gl-transition-duration-slow',
+ 'gl-transition-timing-function-ease',
+ ].join(' '),
},
gitLabColorRotation: [
'#e17223',
@@ -50,8 +64,8 @@ export default {
data() {
return {
color: () => {},
- width: 0,
height: 0,
+ width: 0,
};
},
mounted() {
@@ -60,7 +74,7 @@ export default {
try {
countedAndTransformed = this.transformData(this.graphData);
} catch {
- this.$emit('onFailure', PARSE_FAILURE);
+ this.$emit('on-failure', PARSE_FAILURE);
return;
}
@@ -90,17 +104,33 @@ export default {
},
appendLinkInteractions(link) {
+ const { baseOpacity } = this.$options.viewOptions;
return link
- .on('mouseover', highlightLinks)
- .on('mouseout', restoreLinks.bind(null, this.$options.viewOptions.baseOpacity))
- .on('click', toggleLinkHighlight.bind(null, this.$options.viewOptions.baseOpacity));
+ .on('mouseover', (d, idx, collection) => {
+ if (currentIsLive(idx, collection)) {
+ return;
+ }
+ this.$emit('update-annotation', { type: ADD_NOTE, data: d });
+ highlightLinks(d, idx, collection);
+ })
+ .on('mouseout', (d, idx, collection) => {
+ if (currentIsLive(idx, collection)) {
+ return;
+ }
+ this.$emit('update-annotation', { type: REMOVE_NOTE, data: d });
+ restoreLinks(baseOpacity);
+ })
+ .on('click', (d, idx, collection) => {
+ toggleLinkHighlight(baseOpacity, d, idx, collection);
+ this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() });
+ });
},
appendNodeInteractions(node) {
- return node.on(
- 'click',
- togglePathHighlights.bind(null, this.$options.viewOptions.baseOpacity),
- );
+ return node.on('click', (d, idx, collection) => {
+ togglePathHighlights(this.$options.viewOptions.baseOpacity, d, idx, collection);
+ this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() });
+ });
},
appendLabelAsForeignObject(d, i, n) {
@@ -230,7 +260,10 @@ export default {
.attr('id', d => {
return this.createAndAssignId(d, 'uid', LINK_SELECTOR);
})
- .classed(`${LINK_SELECTOR} gl-cursor-pointer`, true);
+ .classed(
+ `${LINK_SELECTOR} gl-transition-property-stroke-opacity ${this.$options.viewOptions.hoverFadeClasses}`,
+ true,
+ );
},
generateNodes(svg, nodeData) {
@@ -242,7 +275,10 @@ export default {
.data(nodeData)
.enter()
.append('line')
- .classed(`${NODE_SELECTOR} gl-cursor-pointer`, true)
+ .classed(
+ `${NODE_SELECTOR} gl-transition-property-stroke ${this.$options.viewOptions.hoverFadeClasses}`,
+ true,
+ )
.attr('id', d => {
return this.createAndAssignId(d, 'uid', NODE_SELECTOR);
})
@@ -260,6 +296,11 @@ export default {
.attr('y2', d => d.y1 - 4);
},
+ initColors() {
+ const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation);
+ return ({ name }) => colorFn(name);
+ },
+
labelNodes(svg, nodeData) {
return svg
.append('g')
@@ -271,11 +312,6 @@ export default {
.each(this.appendLabelAsForeignObject);
},
- initColors() {
- const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation);
- return ({ name }) => colorFn(name);
- },
-
transformData(parsed) {
const baseLayout = createSankey()(parsed);
const cleanedNodes = removeOrphanNodes(baseLayout.nodes);
diff --git a/app/assets/javascripts/pipelines/components/dag/interactions.js b/app/assets/javascripts/pipelines/components/dag/interactions.js
index c9008730c90..e9f3e9f0e2c 100644
--- a/app/assets/javascripts/pipelines/components/dag/interactions.js
+++ b/app/assets/javascripts/pipelines/components/dag/interactions.js
@@ -5,10 +5,20 @@ export const highlightIn = 1;
export const highlightOut = 0.2;
const getCurrent = (idx, collection) => d3.select(collection[idx]);
-const currentIsLive = (idx, collection) => getCurrent(idx, collection).classed(IS_HIGHLIGHTED);
+const getLiveLinks = () => d3.selectAll(`.${LINK_SELECTOR}.${IS_HIGHLIGHTED}`);
const getOtherLinks = () => d3.selectAll(`.${LINK_SELECTOR}:not(.${IS_HIGHLIGHTED})`);
const getNodesNotLive = () => d3.selectAll(`.${NODE_SELECTOR}:not(.${IS_HIGHLIGHTED})`);
+export const getLiveLinksAsDict = () => {
+ return Object.fromEntries(
+ getLiveLinks()
+ .data()
+ .map(d => [d.uid, d]),
+ );
+};
+export const currentIsLive = (idx, collection) =>
+ getCurrent(idx, collection).classed(IS_HIGHLIGHTED);
+
const backgroundLinks = selection => selection.style('stroke-opacity', highlightOut);
const backgroundNodes = selection => selection.attr('stroke', '#f2f2f2');
const foregroundLinks = selection => selection.style('stroke-opacity', highlightIn);
@@ -16,10 +26,10 @@ const foregroundNodes = selection => selection.attr('stroke', d => d.color);
const renewLinks = (selection, baseOpacity) => selection.style('stroke-opacity', baseOpacity);
const renewNodes = selection => selection.attr('stroke', d => d.color);
-const getAllLinkAncestors = node => {
+export const getAllLinkAncestors = node => {
if (node.targetLinks) {
return node.targetLinks.flatMap(n => {
- return [n.uid, ...getAllLinkAncestors(n.source)];
+ return [n, ...getAllLinkAncestors(n.source)];
});
}
@@ -59,8 +69,8 @@ const highlightPath = (parentLinks, parentNodes) => {
backgroundNodes(getNodesNotLive());
/* highlight correct links */
- parentLinks.forEach(id => {
- foregroundLinks(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true);
+ parentLinks.forEach(({ uid }) => {
+ foregroundLinks(d3.select(`#${uid}`)).classed(IS_HIGHLIGHTED, true);
});
/* highlight correct nodes */
@@ -69,9 +79,22 @@ const highlightPath = (parentLinks, parentNodes) => {
});
};
+const restoreNodes = () => {
+ /*
+ When paths are unclicked, they can take down nodes that
+ are still in use for other paths. This checks the live paths and
+ rehighlights their nodes.
+ */
+
+ getLiveLinks().each(d => {
+ foregroundNodes(d3.select(`#${d.source.uid}`)).classed(IS_HIGHLIGHTED, true);
+ foregroundNodes(d3.select(`#${d.target.uid}`)).classed(IS_HIGHLIGHTED, true);
+ });
+};
+
const restorePath = (parentLinks, parentNodes, baseOpacity) => {
- parentLinks.forEach(id => {
- renewLinks(d3.select(`#${id}`), baseOpacity).classed(IS_HIGHLIGHTED, false);
+ parentLinks.forEach(({ uid }) => {
+ renewLinks(d3.select(`#${uid}`), baseOpacity).classed(IS_HIGHLIGHTED, false);
});
parentNodes.forEach(id => {
@@ -86,14 +109,10 @@ const restorePath = (parentLinks, parentNodes, baseOpacity) => {
backgroundLinks(getOtherLinks());
backgroundNodes(getNodesNotLive());
+ restoreNodes();
};
-export const restoreLinks = (baseOpacity, d, idx, collection) => {
- /* in this case, it has just been clicked */
- if (currentIsLive(idx, collection)) {
- return;
- }
-
+export const restoreLinks = baseOpacity => {
/*
if there exist live links, reset to highlight out / pale
otherwise, reset to base
@@ -111,11 +130,12 @@ export const restoreLinks = (baseOpacity, d, idx, collection) => {
export const toggleLinkHighlight = (baseOpacity, d, idx, collection) => {
if (currentIsLive(idx, collection)) {
- restorePath([d.uid], [d.source.uid, d.target.uid], baseOpacity);
+ restorePath([d], [d.source.uid, d.target.uid], baseOpacity);
+ restoreNodes();
return;
}
- highlightPath([d.uid], [d.source.uid, d.target.uid]);
+ highlightPath([d], [d.source.uid, d.target.uid]);
};
export const togglePathHighlights = (baseOpacity, d, idx, collection) => {
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 1ff5b662d18..6b890688a48 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -43,6 +43,7 @@ export default {
data() {
return {
downstreamMarginTop: null,
+ jobName: null,
};
},
computed: {
@@ -91,13 +92,9 @@ export default {
/**
* Calculates the margin top of the clicked downstream pipeline by
* subtracting the clicked downstream pipelines offsetTop by it's parent's
- * offsetTop and then subtracting either 15 (if child) or 30 (if not a child)
- * due to the height of node and stage name margin bottom.
+ * offsetTop and then subtracting 15
*/
- this.downstreamMarginTop = this.calculateMarginTop(
- downstreamNode,
- downstreamNode.classList.contains('child-pipeline') ? 15 : 30,
- );
+ this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15);
/**
* If the expanded trigger is defined and the id is different than the
@@ -120,6 +117,9 @@ export default {
hasUpstream(index) {
return index === 0 && this.hasTriggeredBy;
},
+ setJob(jobName) {
+ this.jobName = jobName;
+ },
},
};
</script>
@@ -172,7 +172,7 @@ export default {
:class="{
'has-upstream prepend-left-64': hasUpstream(index),
'has-only-one-job': hasOnlyOneJob(stage),
- 'append-right-46': shouldAddRightMargin(index),
+ 'gl-mr-26': shouldAddRightMargin(index),
}"
:title="capitalizeStageName(stage.name)"
:groups="stage.groups"
@@ -180,6 +180,7 @@ export default {
:is-first-column="isFirstColumn(index)"
:has-triggered-by="hasTriggeredBy"
:action="stage.status.action"
+ :job-hovered="jobName"
@refreshPipelineGraph="refreshPipelineGraph"
/>
</ul>
@@ -191,6 +192,7 @@ export default {
:project-id="pipelineProjectId"
graph-position="right"
@linkedPipelineClick="handleClickedDownstream"
+ @downstreamHovered="setJob"
/>
<pipeline-graph
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index bfd314e0439..4d72cc55b34 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -31,6 +31,7 @@ import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
*/
export default {
+ hoverClass: 'gl-inset-border-1-blue-500',
components: {
ActionComponent,
JobNameComponent,
@@ -55,6 +56,11 @@ export default {
required: false,
default: Infinity,
},
+ jobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
boundary() {
@@ -95,6 +101,11 @@ export default {
hasAction() {
return this.job.status && this.job.status.action && this.job.status.action.path;
},
+ jobClasses() {
+ return this.job.name === this.jobHovered
+ ? `${this.$options.hoverClass} ${this.cssClassJobName}`
+ : this.cssClassJobName;
+ },
},
methods: {
pipelineActionRequestComplete() {
@@ -120,8 +131,9 @@ export default {
v-else
v-gl-tooltip="{ boundary, placement: 'bottom' }"
:title="tooltipText"
- :class="cssClassJobName"
+ :class="jobClasses"
class="js-job-component-tooltip non-details-job-component"
+ data-testid="job-without-link"
>
<job-name-component :name="job.name" :status="job.status" />
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index 550b9daa521..733553e02c0 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
export default {
directives: {
@@ -28,7 +28,8 @@ export default {
},
computed: {
tooltipText() {
- return `${this.projectName} - ${this.pipelineStatus.label}`;
+ return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label}
+ ${this.sourceJobInfo}`;
},
buttonId() {
return `js-linked-pipeline-${this.pipeline.id}`;
@@ -39,25 +40,32 @@ export default {
projectName() {
return this.pipeline.project.name;
},
+ downstreamTitle() {
+ return this.childPipeline ? __('child-pipeline') : this.pipeline.project.name;
+ },
parentPipeline() {
// Refactor string match when BE returns Upstream/Downstream indicators
return this.projectId === this.pipeline.project.id && this.columnTitle === __('Upstream');
},
childPipeline() {
// Refactor string match when BE returns Upstream/Downstream indicators
- return this.projectId === this.pipeline.project.id && this.columnTitle === __('Downstream');
+ return this.projectId === this.pipeline.project.id && this.isDownstream;
},
label() {
- return this.parentPipeline ? __('Parent') : __('Child');
- },
- childTooltipText() {
- return __('This pipeline was triggered by a parent pipeline');
+ if (this.parentPipeline) {
+ return __('Parent');
+ } else if (this.childPipeline) {
+ return __('Child');
+ }
+ return __('Multi-project');
},
- parentTooltipText() {
- return __('This pipeline triggered a child pipeline');
+ isDownstream() {
+ return this.columnTitle === __('Downstream');
},
- labelToolTipText() {
- return this.label === __('Parent') ? this.parentTooltipText : this.childTooltipText;
+ sourceJobInfo() {
+ return this.isDownstream
+ ? sprintf(__('Created by %{job}'), { job: this.pipeline.source_job.name })
+ : '';
},
},
methods: {
@@ -68,6 +76,12 @@ export default {
hideTooltips() {
this.$root.$emit('bv::hide::tooltip');
},
+ onDownstreamHovered() {
+ this.$emit('downstreamHovered', this.pipeline.source_job.name);
+ },
+ onDownstreamHoverLeave() {
+ this.$emit('downstreamHovered', '');
+ },
},
};
</script>
@@ -76,7 +90,10 @@ export default {
<li
ref="linkedPipeline"
class="linked-pipeline build"
- :class="{ 'child-pipeline': childPipeline }"
+ :class="{ 'downstream-pipeline': isDownstream }"
+ data-qa-selector="child_pipeline"
+ @mouseover="onDownstreamHovered"
+ @mouseleave="onDownstreamHoverLeave"
>
<gl-deprecated-button
:id="buttonId"
@@ -94,15 +111,9 @@ export default {
css-classes="position-top-0"
class="js-linked-pipeline-status"
/>
- <span class="str-truncated align-bottom"> {{ projectName }} &#8226; #{{ pipeline.id }} </span>
- <div v-if="parentPipeline || childPipeline" class="parent-child-label-container">
- <span
- v-gl-tooltip.bottom
- :title="labelToolTipText"
- class="badge badge-primary"
- @mouseover="hideTooltips"
- >{{ label }}</span
- >
+ <span class="str-truncated"> {{ downstreamTitle }} &#8226; #{{ pipeline.id }} </span>
+ <div class="gl-pt-2">
+ <span class="badge badge-primary" data-testid="downstream-pipeline-label">{{ label }}</span>
</div>
</gl-deprecated-button>
</li>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
index e3429184c05..c4dfd3382a2 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -28,7 +28,7 @@ export default {
columnClass() {
const positionValues = {
right: 'prepend-left-64',
- left: 'append-right-32',
+ left: 'gl-mr-7',
};
return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`;
},
@@ -41,6 +41,9 @@ export default {
onPipelineClick(downstreamNode, pipeline, index) {
this.$emit('linkedPipelineClick', pipeline, index, downstreamNode);
},
+ onDownstreamHovered(jobName) {
+ this.$emit('downstreamHovered', jobName);
+ },
},
};
</script>
@@ -61,6 +64,7 @@ export default {
:column-title="columnTitle"
:project-id="projectId"
@pipelineClicked="onPipelineClick($event, pipeline, index)"
+ @downstreamHovered="onDownstreamHovered"
/>
</ul>
</div>
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 bed0ed51d5f..9de6ba819c2 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -36,6 +36,11 @@ export default {
required: false,
default: () => ({}),
},
+ jobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
hasAction() {
@@ -80,6 +85,7 @@ export default {
<job-item
v-if="group.size === 1"
:job="group.jobs[0]"
+ :job-hovered="jobHovered"
css-class-job-name="build-content"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index e7777d0d3af..dff642161db 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -108,7 +108,7 @@ export default {
/>
</ci-header>
- <gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default append-bottom-default" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" />
<gl-modal
:modal-id="$options.DELETE_MODAL_ID"
diff --git a/app/assets/javascripts/pipelines/components/blank_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue
index 6c3a4a27606..6c3a4a27606 100644
--- a/app/assets/javascripts/pipelines/components/blank_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue
diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
index 74ada6a4d15..74ada6a4d15 100644
--- a/app/assets/javascripts/pipelines/components/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
index 4f6c9d2bd90..a66bbb7e5ba 100644
--- a/app/assets/javascripts/pipelines/components/nav_controls.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
@@ -1,6 +1,6 @@
<script>
import { GlDeprecatedButton } from '@gitlab/ui';
-import LoadingButton from '../../vue_shared/components/loading_button.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
export default {
name: 'PipelineNavControls',
diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
index f604edd8859..f604edd8859 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
diff --git a/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
index 740b54cd8e0..35fd9837b3e 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
@@ -26,9 +26,9 @@ export default {
:img-src="user.avatar_url"
:img-size="26"
:tooltip-text="user.name"
- class="prepend-left-default js-pipeline-url-user"
+ class="gl-ml-3 js-pipeline-url-user"
/>
- <span v-else class="prepend-left-default js-pipeline-url-api api">
+ <span v-else class="gl-ml-3 js-pipeline-url-api api">
{{ s__('Pipelines|API') }}
</span>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index 6c977b841af..2905b2ca26f 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -1,6 +1,7 @@
<script>
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import { escape } from 'lodash';
+import { SCHEDULE_ORIGIN } from '../../constants';
import { __, sprintf } from '~/locale';
import popover from '~/vue_shared/directives/popover';
@@ -27,6 +28,10 @@ export default {
type: Object,
required: true,
},
+ pipelineScheduleUrl: {
+ type: String,
+ required: true,
+ },
autoDevopsHelpPath: {
type: String,
required: true,
@@ -36,6 +41,9 @@ export default {
user() {
return this.pipeline.user;
},
+ isScheduled() {
+ return this.pipeline.source === SCHEDULE_ORIGIN;
+ },
popoverOptions() {
return {
html: true,
@@ -61,16 +69,28 @@ export default {
<gl-link
:href="pipeline.path"
class="js-pipeline-url-link js-onboarding-pipeline-item"
+ data-testid="pipeline-url-link"
data-qa-selector="pipeline_url_link"
>
<span class="pipeline-id">#{{ pipeline.id }}</span>
</gl-link>
<div class="label-container">
+ <gl-link v-if="isScheduled" :href="pipelineScheduleUrl" target="__blank">
+ <span
+ v-gl-tooltip
+ :title="__('This pipeline was triggered by a schedule.')"
+ class="badge badge-info"
+ data-testid="pipeline-url-scheduled"
+ >
+ {{ __('Scheduled') }}
+ </span>
+ </gl-link>
<span
v-if="pipeline.flags.latest"
v-gl-tooltip
:title="__('Latest pipeline for the most recent commit on this branch')"
class="js-pipeline-url-latest badge badge-success"
+ data-testid="pipeline-url-latest"
>
{{ __('latest') }}
</span>
@@ -79,6 +99,7 @@ export default {
v-gl-tooltip
:title="pipeline.yaml_errors"
class="js-pipeline-url-yaml badge badge-danger"
+ data-testid="pipeline-url-yaml"
>
{{ __('yaml invalid') }}
</span>
@@ -87,6 +108,7 @@ export default {
v-gl-tooltip
:title="pipeline.failure_reason"
class="js-pipeline-url-failure badge badge-danger"
+ data-testid="pipeline-url-failure"
>
{{ __('error') }}
</span>
@@ -95,10 +117,15 @@ export default {
v-popover="popoverOptions"
tabindex="0"
class="js-pipeline-url-autodevops badge badge-info autodevops-badge"
+ data-testid="pipeline-url-autodevops"
role="button"
>{{ __('Auto DevOps') }}</gl-link
>
- <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning">
+ <span
+ v-if="pipeline.flags.stuck"
+ class="js-pipeline-url-stuck badge badge-warning"
+ data-testid="pipeline-url-stuck"
+ >
{{ __('stuck') }}
</span>
<span
@@ -110,6 +137,7 @@ export default {
)
"
class="js-pipeline-url-detached badge badge-info"
+ data-testid="pipeline-url-detached"
>
{{ __('detached') }}
</span>
diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index dbf29b0c29c..0c531650fd2 100644
--- a/app/assets/javascripts/pipelines/components/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -1,17 +1,18 @@
<script>
import { isEqual } from 'lodash';
-import { __, sprintf, s__ } from '../../locale';
-import createFlash from '../../flash';
-import PipelinesService from '../services/pipelines_service';
-import pipelinesMixin from '../mixins/pipelines';
-import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue';
-import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue';
+import { __, s__ } from '~/locale';
+import createFlash from '~/flash';
+import PipelinesService from '../../services/pipelines_service';
+import pipelinesMixin from '../../mixins/pipelines';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import NavigationControls from './nav_controls.vue';
-import { getParameterByName } from '../../lib/utils/common_utils';
-import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
+import { getParameterByName } from '~/lib/utils/common_utils';
+import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin';
+import Icon from '~/vue_shared/components/icon.vue';
import PipelinesFilteredSearch from './pipelines_filtered_search.vue';
-import { validateParams } from '../utils';
-import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../constants';
+import { validateParams } from '../../utils';
+import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../../constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
@@ -20,6 +21,7 @@ export default {
NavigationTabs,
NavigationControls,
PipelinesFilteredSearch,
+ Icon,
},
mixins: [pipelinesMixin, CIPaginationMixin, glFeatureFlagsMixin()],
props: {
@@ -40,6 +42,11 @@ export default {
type: String,
required: true,
},
+ pipelineScheduleUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
helpPagePath: {
type: String,
required: true,
@@ -115,8 +122,6 @@ export default {
},
scopes: {
all: 'all',
- pending: 'pending',
- running: 'running',
finished: 'finished',
branches: 'branches',
tags: 'tags',
@@ -169,13 +174,8 @@ export default {
},
emptyTabMessage() {
- const { scopes } = this.$options;
- const possibleScopes = [scopes.pending, scopes.running, scopes.finished];
-
- if (possibleScopes.includes(this.scope)) {
- return sprintf(s__('Pipelines|There are currently no %{scope} pipelines.'), {
- scope: this.scope,
- });
+ if (this.scope === this.$options.scopes.finished) {
+ return s__('Pipelines|There are currently no finished pipelines.');
}
return s__('Pipelines|There are currently no pipelines.');
@@ -193,21 +193,8 @@ export default {
isActive: this.scope === 'all',
},
{
- name: __('Pending'),
- scope: scopes.pending,
- count: count.pending,
- isActive: this.scope === 'pending',
- },
- {
- name: __('Running'),
- scope: scopes.running,
- count: count.running,
- isActive: this.scope === 'running',
- },
- {
name: __('Finished'),
scope: scopes.finished,
- count: count.finished,
isActive: this.scope === 'finished',
},
{
@@ -298,8 +285,8 @@ export default {
v-if="shouldRenderTabs || shouldRenderButtons"
class="top-area scrolling-tabs-container inner-page-scroll-tabs"
>
- <div class="fade-left"><i class="fa fa-angle-left" aria-hidden="true"> </i></div>
- <div class="fade-right"><i class="fa fa-angle-right" aria-hidden="true"> </i></div>
+ <div class="fade-left"><icon name="chevron-lg-left" :size="12" /></div>
+ <div class="fade-right"><icon name="chevron-lg-right" :size="12" /></div>
<navigation-tabs
v-if="shouldRenderTabs"
@@ -358,6 +345,7 @@ export default {
<div v-else-if="stateToRender === $options.stateMap.tableList" class="table-holder">
<pipelines-table-component
:pipelines="state.pipelines"
+ :pipeline-schedule-url="pipelineScheduleUrl"
:update-graph-dropdown="updateGraphDropdown"
:auto-devops-help-path="autoDevopsPath"
:view-type="viewType"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
index 7d4276e8d2e..3009ca7a775 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
@@ -5,7 +5,7 @@ import flash from '~/flash';
import { s__, __, sprintf } from '~/locale';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
import Icon from '~/vue_shared/components/icon.vue';
-import eventHub from '../event_hub';
+import eventHub from '../../event_hub';
export default {
directives: {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
index 59c066b2683..59c066b2683 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
diff --git a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
index 0505a8668d1..0505a8668d1 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index d3ba0c97f6b..b8112149778 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective } from '@gitlab/ui';
import PipelinesTableRowComponent from './pipelines_table_row.vue';
import PipelineStopModal from './pipeline_stop_modal.vue';
-import eventHub from '../event_hub';
+import eventHub from '../../event_hub';
/**
* Pipelines Table Component.
@@ -22,6 +22,11 @@ export default {
type: Array,
required: true,
},
+ pipelineScheduleUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
updateGraphDropdown: {
type: Boolean,
required: false,
@@ -91,6 +96,7 @@ export default {
v-for="model in pipelines"
:key="model.id"
:pipeline="model"
+ :pipeline-schedule-url="pipelineScheduleUrl"
:update-graph-dropdown="updateGraphDropdown"
:auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
index 981914dd046..f25994a7506 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
@@ -1,16 +1,16 @@
<script>
-import eventHub from '../event_hub';
+import eventHub from '../../event_hub';
import PipelinesActionsComponent from './pipelines_actions.vue';
import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
-import CiBadge from '../../vue_shared/components/ci_badge_link.vue';
+import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import PipelineStage from './stage.vue';
import PipelineUrl from './pipeline_url.vue';
import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelinesTimeago from './time_ago.vue';
-import CommitComponent from '../../vue_shared/components/commit.vue';
-import LoadingButton from '../../vue_shared/components/loading_button.vue';
-import Icon from '../../vue_shared/components/icon.vue';
-import { PIPELINES_TABLE } from '../constants';
+import CommitComponent from '~/vue_shared/components/commit.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+import { PIPELINES_TABLE } from '../../constants';
/**
* Pipeline table row.
@@ -35,6 +35,11 @@ export default {
type: Object,
required: true,
},
+ pipelineScheduleUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
updateGraphDropdown: {
type: Boolean,
required: false,
@@ -274,7 +279,11 @@ export default {
</div>
</div>
- <pipeline-url :pipeline="pipeline" :auto-devops-help-path="autoDevopsHelpPath" />
+ <pipeline-url
+ :pipeline="pipeline"
+ :pipeline-schedule-url="pipelineScheduleUrl"
+ :auto-devops-help-path="autoDevopsHelpPath"
+ />
<pipeline-triggerer :pipeline="pipeline" />
<div class="table-section section-wrap section-20">
@@ -300,7 +309,8 @@ export default {
<div
v-for="(stage, index) in pipeline.details.stages"
:key="index"
- class="stage-container dropdown js-mini-pipeline-graph"
+ class="stage-container dropdown"
+ data-testid="widget-mini-pipeline-graph"
>
<pipeline-stage
:type="$options.pipelinesTable"
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue
index 569920a4f31..99492bd8357 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue
@@ -14,13 +14,13 @@
import $ from 'jquery';
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '../../locale';
-import Flash from '../../flash';
-import axios from '../../lib/utils/axios_utils';
-import eventHub from '../event_hub';
-import Icon from '../../vue_shared/components/icon.vue';
-import JobItem from './graph/job_item.vue';
-import { PIPELINES_TABLE } from '../constants';
+import { __ } from '~/locale';
+import Flash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import eventHub from '../../event_hub';
+import Icon from '~/vue_shared/components/icon.vue';
+import JobItem from '../graph/job_item.vue';
+import { PIPELINES_TABLE } from '../../constants';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/components/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index 2a23a0f6744..8a01e1fe3f5 100644
--- a/app/assets/javascripts/pipelines/components/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,8 +1,8 @@
<script>
import iconTimerSvg from 'icons/_icon_timer.svg';
-import '../../lib/utils/datetime_utility';
-import tooltip from '../../vue_shared/directives/tooltip';
-import timeagoMixin from '../../vue_shared/mixins/timeago';
+import '~/lib/utils/datetime_utility';
+import tooltip from '~/vue_shared/directives/tooltip';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
directives: {
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
index da14bb2d308..b6eff2931d3 100644
--- a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
@@ -1,7 +1,7 @@
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import Api from '~/api';
-import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants';
+import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants';
import createFlash from '~/flash';
import { debounce } from 'lodash';
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue
index dc43d94f4fd..dc43d94f4fd 100644
--- a/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue
index 7b209c5fa12..64de6d2a053 100644
--- a/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue
@@ -1,7 +1,7 @@
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import Api from '~/api';
-import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants';
+import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants';
import createFlash from '~/flash';
import { debounce } from 'lodash';
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
index 4062a3b11bb..b5aeb3fe9e0 100644
--- a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
@@ -13,7 +13,7 @@ import {
ANY_TRIGGER_AUTHOR,
FETCH_AUTHOR_ERROR_MESSAGE,
FILTER_PIPELINES_SEARCH_DELAY,
-} from '../../constants';
+} from '../../../constants';
export default {
anyTriggerAuthor: ANY_TRIGGER_AUTHOR,
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
index 06ab45adf80..8746784aa57 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -1,10 +1,9 @@
<script>
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import TestSuiteTable from './test_suite_table.vue';
import TestSummary from './test_summary.vue';
import TestSummaryTable from './test_summary_table.vue';
-import store from '~/pipelines/stores/test_reports';
export default {
name: 'TestReports',
@@ -14,24 +13,37 @@ export default {
TestSummary,
TestSummaryTable,
},
- store,
computed: {
- ...mapState(['isLoading', 'selectedSuite', 'testReports']),
+ ...mapState(['hasFullReport', 'isLoading', 'selectedSuiteIndex', 'testReports']),
+ ...mapGetters(['getSelectedSuite']),
showSuite() {
- return this.selectedSuite.total_count > 0;
+ return this.selectedSuiteIndex !== null;
},
showTests() {
const { test_suites: testSuites = [] } = this.testReports;
return testSuites.length > 0;
},
},
+ created() {
+ this.fetchSummary();
+ },
methods: {
- ...mapActions(['setSelectedSuite', 'removeSelectedSuite']),
+ ...mapActions([
+ 'fetchFullReport',
+ 'fetchSummary',
+ 'setSelectedSuiteIndex',
+ 'removeSelectedSuiteIndex',
+ ]),
summaryBackClick() {
- this.removeSelectedSuite();
+ this.removeSelectedSuiteIndex();
},
- summaryTableRowClick(suite) {
- this.setSelectedSuite(suite);
+ summaryTableRowClick(index) {
+ this.setSelectedSuiteIndex(index);
+
+ // Fetch the full report when the user clicks to see more details
+ if (!this.hasFullReport) {
+ this.fetchFullReport();
+ }
},
beforeEnterTransition() {
document.documentElement.style.overflowX = 'hidden';
@@ -45,7 +57,7 @@ export default {
<template>
<div v-if="isLoading">
- <gl-loading-icon size="lg" class="prepend-top-default js-loading-spinner" />
+ <gl-loading-icon size="lg" class="gl-mt-3 js-loading-spinner" />
</div>
<div
@@ -59,7 +71,7 @@ export default {
@after-leave="afterLeaveTransition"
>
<div v-if="showSuite" key="detail" class="w-100 position-absolute slide-enter-to-element">
- <test-summary :report="selectedSuite" show-back @on-back-click="summaryBackClick" />
+ <test-summary :report="getSelectedSuite" show-back @on-back-click="summaryBackClick" />
<test-suite-table />
</div>
@@ -73,7 +85,7 @@ export default {
</div>
<div v-else>
- <div class="row prepend-top-default">
+ <div class="row gl-mt-3">
<div class="col-12">
<p class="js-no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p>
</div>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index be7f27f210d..d57b1466177 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -1,8 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
-import store from '~/pipelines/stores/test_reports';
import { __ } from '~/locale';
+import { GlTooltipDirective } from '@gitlab/ui';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
export default {
@@ -11,7 +11,9 @@ export default {
Icon,
SmartVirtualList,
},
- store,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
heading: {
type: String,
@@ -32,16 +34,16 @@ export default {
<template>
<div>
- <div class="row prepend-top-default">
+ <div class="row gl-mt-3">
<div class="col-12">
<h4>{{ heading }}</h4>
</div>
</div>
- <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-cases-table">
+ <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-cases-table">
<div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray">
<div role="rowheader" class="table-section section-20">
- {{ __('Class') }}
+ {{ __('Suite') }}
</div>
<div role="rowheader" class="table-section section-20">
{{ __('Name') }}
@@ -68,13 +70,25 @@ export default {
class="gl-responsive-table-row rounded align-items-md-start mt-xs-3 js-case-row"
>
<div class="table-section section-20 section-wrap">
- <div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div>
- <div class="table-mobile-content pr-md-1 text-truncate">{{ testCase.classname }}</div>
+ <div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div>
+ <div
+ v-gl-tooltip
+ :title="testCase.classname"
+ class="table-mobile-content pr-md-1 text-truncate"
+ >
+ {{ testCase.classname }}
+ </div>
</div>
<div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div>
- <div class="table-mobile-content pr-md-1 text-truncate">{{ testCase.name }}</div>
+ <div
+ v-gl-tooltip
+ :title="testCase.name"
+ class="table-mobile-content pr-md-1 text-truncate"
+ >
+ {{ testCase.name }}
+ </div>
</div>
<div class="table-section section-10 section-wrap">
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index 67646c537bd..712ac5eb0e5 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -72,7 +72,7 @@ export default {
<gl-deprecated-button
v-if="showBack"
size="sm"
- class="append-right-default js-back-button"
+ class="gl-mr-3 js-back-button"
@click="onBackClick"
>
<icon name="angle-left" />
@@ -85,7 +85,7 @@ export default {
<div class="row mt-2">
<div class="col-4 col-md">
<span class="js-total-tests">{{
- sprintf(s__('TestReports|%{count} jobs'), { count: report.total_count })
+ sprintf(s__('TestReports|%{count} tests'), { count: report.total_count })
}}</span>
</div>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 4dfb67dd8e8..6cfb795595d 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -2,7 +2,6 @@
import { mapGetters } from 'vuex';
import { s__ } from '~/locale';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import store from '~/pipelines/stores/test_reports';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
export default {
@@ -14,12 +13,11 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- store,
props: {
heading: {
type: String,
required: false,
- default: s__('TestReports|Test suites'),
+ default: s__('TestReports|Jobs'),
},
},
computed: {
@@ -29,8 +27,8 @@ export default {
},
},
methods: {
- tableRowClick(suite) {
- this.$emit('row-click', suite);
+ tableRowClick(index) {
+ this.$emit('row-click', index);
},
},
maxShownRows: 20,
@@ -40,16 +38,16 @@ export default {
<template>
<div>
- <div class="row prepend-top-default">
+ <div class="row gl-mt-3">
<div class="col-12">
<h4>{{ heading }}</h4>
</div>
</div>
- <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-suites-table">
+ <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-suites-table">
<div role="row" class="gl-responsive-table-row table-row-header font-weight-bold">
<div role="rowheader" class="table-section section-25 pl-3">
- {{ __('Suite') }}
+ {{ __('Job') }}
</div>
<div role="rowheader" class="table-section section-25">
{{ __('Duration') }}
@@ -84,7 +82,7 @@ export default {
:class="{
'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error,
}"
- @click="tableRowClick(testSuite)"
+ @click="tableRowClick(index)"
>
<div class="table-section section-25">
<div role="rowheader" class="table-mobile-header font-weight-bold">
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index c709f329728..abe5e1060c8 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -7,6 +7,7 @@ export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const ANY_TRIGGER_AUTHOR = 'Any';
export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status'];
export const FILTER_TAG_IDENTIFIER = 'tag';
+export const SCHEDULE_ORIGIN = 'schedule';
export const TestStatus = {
FAILED: 'failed',
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js
index 876b30299fb..7710a96e5fb 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines.js
@@ -1,11 +1,11 @@
import Visibility from 'visibilityjs';
import { GlLoadingIcon } from '@gitlab/ui';
-import { __ } from '../../locale';
-import createFlash from '../../flash';
-import Poll from '../../lib/utils/poll';
-import EmptyState from '../components/empty_state.vue';
-import SvgBlankState from '../components/blank_state.vue';
-import PipelinesTableComponent from '../components/pipelines_table.vue';
+import { __ } from '~/locale';
+import createFlash from '~/flash';
+import Poll from '~/lib/utils/poll';
+import EmptyState from '../components/pipelines_list/empty_state.vue';
+import SvgBlankState from '../components/pipelines_list/blank_state.vue';
+import PipelinesTableComponent from '../components/pipelines_list/pipelines_table.vue';
import eventHub from '../event_hub';
import { CANCEL_REQUEST } from '../constants';
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 90109598542..f1102a9bddf 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -10,8 +10,7 @@ import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
import TestReports from './components/test_reports/test_reports.vue';
-import testReportsStore from './stores/test_reports';
-import axios from '~/lib/utils/axios_utils';
+import createTestReportsStore from './stores/test_reports';
Vue.use(Translate);
@@ -93,15 +92,11 @@ const createPipelineHeaderApp = mediator => {
});
};
-const createPipelinesTabs = dataset => {
+const createPipelinesTabs = testReportsStore => {
const tabsElement = document.querySelector('.pipelines-tabs');
- const testReportsEnabled =
- window.gon && window.gon.features && window.gon.features.junitPipelineView;
-
- if (tabsElement && testReportsEnabled) {
- const fetchReportsAction = 'fetchReports';
- testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint);
+ if (tabsElement) {
+ const fetchReportsAction = 'fetchFullReport';
const isTestTabActive = Boolean(
document.querySelector('.pipelines-tabs > li > a.test-tab.active'),
);
@@ -121,28 +116,35 @@ const createPipelinesTabs = dataset => {
}
};
-const createTestDetails = detailsEndpoint => {
+const createTestDetails = () => {
+ if (!window.gon?.features?.junitPipelineView) {
+ return;
+ }
+
+ const el = document.querySelector('#js-pipeline-tests-detail');
+ const { fullReportEndpoint, summaryEndpoint, countEndpoint } = el?.dataset || {};
+
+ const testReportsStore = createTestReportsStore({
+ fullReportEndpoint,
+ summaryEndpoint: summaryEndpoint || countEndpoint,
+ useBuildSummaryReport: window.gon?.features?.buildReportSummary,
+ });
+
+ if (!window.gon?.features?.buildReportSummary) {
+ createPipelinesTabs(testReportsStore);
+ }
+
// eslint-disable-next-line no-new
new Vue({
- el: '#js-pipeline-tests-detail',
+ el,
components: {
TestReports,
},
+ store: testReportsStore,
render(createElement) {
return createElement('test-reports');
},
});
-
- axios
- .get(detailsEndpoint)
- .then(({ data }) => {
- if (!data.total_count) {
- return;
- }
-
- document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count;
- })
- .catch(() => {});
};
const createDagApp = () => {
@@ -151,7 +153,8 @@ const createDagApp = () => {
}
const el = document.querySelector('#js-pipeline-dag-vue');
- const graphUrl = el?.dataset?.pipelineDataPath;
+ const { pipelineDataPath, emptySvgPath, dagDocPath } = el?.dataset;
+
// eslint-disable-next-line no-new
new Vue({
el,
@@ -161,7 +164,9 @@ const createDagApp = () => {
render(createElement) {
return createElement('dag', {
props: {
- graphUrl,
+ graphUrl: pipelineDataPath,
+ emptySvgPath,
+ dagDocPath,
},
});
},
@@ -175,7 +180,6 @@ export default () => {
createPipelinesDetailApp(mediator);
createPipelineHeaderApp(mediator);
- createPipelinesTabs(dataset);
- createTestDetails(dataset.testReportsCountEndpoint);
+ createTestDetails();
createDagApp();
};
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
index 71d875c1a83..ccacb9f7e97 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
@@ -3,17 +3,42 @@ import * as types from './mutation_types';
import createFlash from '~/flash';
import { s__ } from '~/locale';
-export const setEndpoint = ({ commit }, data) => commit(types.SET_ENDPOINT, data);
+export const fetchSummary = ({ state, commit, dispatch }) => {
+ // If we do this without the build_report_summary feature flag enabled
+ // it causes a race condition for toggleLoading and ruins the loading
+ // state in the application
+ if (state.useBuildSummaryReport) {
+ dispatch('toggleLoading');
+ }
-export const fetchReports = ({ state, commit, dispatch }) => {
+ return axios
+ .get(state.summaryEndpoint)
+ .then(({ data }) => {
+ commit(types.SET_SUMMARY, data);
+
+ if (!state.useBuildSummaryReport) {
+ // Set the tab counter badge to total_count
+ // This is temporary until we can server-side render that count number
+ // (see https://gitlab.com/gitlab-org/gitlab/-/issues/223134)
+ document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count || 0;
+ }
+ })
+ .catch(() => {
+ createFlash(s__('TestReports|There was an error fetching the summary.'));
+ })
+ .finally(() => {
+ if (state.useBuildSummaryReport) {
+ dispatch('toggleLoading');
+ }
+ });
+};
+
+export const fetchFullReport = ({ state, commit, dispatch }) => {
dispatch('toggleLoading');
return axios
- .get(state.endpoint)
- .then(response => {
- const { data } = response;
- commit(types.SET_REPORTS, data);
- })
+ .get(state.fullReportEndpoint)
+ .then(({ data }) => commit(types.SET_REPORTS, data))
.catch(() => {
createFlash(s__('TestReports|There was an error fetching the test reports.'));
})
@@ -22,8 +47,10 @@ export const fetchReports = ({ state, commit, dispatch }) => {
});
};
-export const setSelectedSuite = ({ commit }, data) => commit(types.SET_SELECTED_SUITE, data);
-export const removeSelectedSuite = ({ commit }) => commit(types.SET_SELECTED_SUITE, {});
+export const setSelectedSuiteIndex = ({ commit }, data) =>
+ commit(types.SET_SELECTED_SUITE_INDEX, data);
+export const removeSelectedSuiteIndex = ({ commit }) =>
+ commit(types.SET_SELECTED_SUITE_INDEX, null);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js
index 788c1d32987..877762b77c9 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js
@@ -9,14 +9,12 @@ export const getTestSuites = state => {
}));
};
-export const getSuiteTests = state => {
- const { selectedSuite } = state;
-
- if (selectedSuite.test_cases) {
- return selectedSuite.test_cases.sort(sortTestCases).map(addIconStatus);
- }
+export const getSelectedSuite = state =>
+ state.testReports?.test_suites?.[state.selectedSuiteIndex] || {};
- return [];
+export const getSuiteTests = state => {
+ const { test_cases: testCases = [] } = getSelectedSuite(state);
+ return testCases.sort(sortTestCases).map(addIconStatus);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js
index 318dff5bcb2..88f61b09025 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/index.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js
@@ -7,9 +7,10 @@ import mutations from './mutations';
Vue.use(Vuex);
-export default new Vuex.Store({
- actions,
- getters,
- mutations,
- state,
-});
+export default initialState =>
+ new Vuex.Store({
+ actions,
+ getters,
+ mutations,
+ state: state(initialState),
+ });
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js
index 832e45cf7a1..76405557b51 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js
@@ -1,4 +1,4 @@
-export const SET_ENDPOINT = 'SET_ENDPOINT';
export const SET_REPORTS = 'SET_REPORTS';
-export const SET_SELECTED_SUITE = 'SET_SELECTED_SUITE';
+export const SET_SELECTED_SUITE_INDEX = 'SET_SELECTED_SUITE_INDEX';
+export const SET_SUMMARY = 'SET_SUMMARY';
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
index 349e6ec0469..2531ab1e87c 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
@@ -1,16 +1,16 @@
import * as types from './mutation_types';
export default {
- [types.SET_ENDPOINT](state, endpoint) {
- Object.assign(state, { endpoint });
+ [types.SET_REPORTS](state, testReports) {
+ Object.assign(state, { testReports, hasFullReport: true });
},
- [types.SET_REPORTS](state, testReports) {
- Object.assign(state, { testReports });
+ [types.SET_SELECTED_SUITE_INDEX](state, selectedSuiteIndex) {
+ Object.assign(state, { selectedSuiteIndex });
},
- [types.SET_SELECTED_SUITE](state, selectedSuite) {
- Object.assign(state, { selectedSuite });
+ [types.SET_SUMMARY](state, summary) {
+ Object.assign(state, { testReports: { ...state.testReports, ...summary } });
},
[types.TOGGLE_LOADING](state) {
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js
index 80a0c2a46a0..bcf5c147916 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/state.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js
@@ -1,6 +1,13 @@
-export default () => ({
- endpoint: '',
+export default ({
+ fullReportEndpoint = '',
+ summaryEndpoint = '',
+ useBuildSummaryReport = false,
+}) => ({
+ summaryEndpoint,
+ fullReportEndpoint,
testReports: {},
- selectedSuite: {},
+ selectedSuiteIndex: null,
+ hasFullReport: false,
isLoading: false,
+ useBuildSummaryReport,
});
diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
index 0a52a92ae9d..f0832bd36a5 100644
--- a/app/assets/javascripts/projects/commits/store/actions.js
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -18,7 +18,7 @@ export default {
fetchAuthors({ dispatch, state }, author = null) {
const { projectId } = state;
return axios
- .get(joinPaths(gon.relative_url_root || '', '/autocomplete/users.json'), {
+ .get(joinPaths(gon.relative_url_root || '', '/-/autocomplete/users.json'), {
params: {
project_id: projectId,
active: true,
diff --git a/app/assets/javascripts/projects/components/remove_modal.vue b/app/assets/javascripts/projects/components/remove_modal.vue
new file mode 100644
index 00000000000..37f58efcb30
--- /dev/null
+++ b/app/assets/javascripts/projects/components/remove_modal.vue
@@ -0,0 +1,108 @@
+<script>
+import { GlModal, GlModalDirective, GlSprintf, GlFormInput, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { rstrip } from '~/lib/utils/common_utils';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlFormInput,
+ GlButton,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ confirmPhrase: {
+ type: String,
+ required: true,
+ },
+ warningMessage: {
+ type: String,
+ required: true,
+ },
+ formPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ userInput: null,
+ };
+ },
+ computed: {
+ buttonDisabled() {
+ return rstrip(this.userInput) !== this.confirmPhrase;
+ },
+ csrfToken() {
+ return csrf.token;
+ },
+ },
+ methods: {
+ submitForm() {
+ this.$refs.form.submit();
+ },
+ },
+ strings: {
+ removeProject: __('Remove project'),
+ title: __('Confirmation required'),
+ confirm: __('Confirm'),
+ dataLoss: __(
+ 'This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.',
+ ),
+ confirmText: __('Please type %{phrase_code} to proceed or close this modal to cancel.'),
+ },
+ modalId: 'remove-project-modal',
+};
+</script>
+
+<template>
+ <form ref="form" :action="formPath" method="post">
+ <input type="hidden" name="_method" value="delete" />
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+ <gl-button v-gl-modal="$options.modalId" category="primary" variant="danger">{{
+ $options.strings.removeProject
+ }}</gl-button>
+ <gl-modal
+ ref="removeModal"
+ :modal-id="$options.modalId"
+ size="sm"
+ ok-variant="danger"
+ footer-class="bg-gray-light gl-p-5"
+ >
+ <template #modal-title>{{ $options.strings.title }}</template>
+ <template #modal-footer>
+ <div class="gl-w-full gl-display-flex gl-just-content-start gl-m-0">
+ <gl-button
+ :disabled="buttonDisabled"
+ category="primary"
+ variant="danger"
+ @click="submitForm"
+ >
+ {{ $options.strings.confirm }}
+ </gl-button>
+ </div>
+ </template>
+ <div>
+ <p class="gl-text-red-500 gl-font-weight-bold">{{ warningMessage }}</p>
+ <p class="gl-mb-0">{{ $options.strings.dataLoss }}</p>
+ <p>
+ <gl-sprintf :message="$options.strings.confirmText">
+ <template #phrase_code>
+ <code>{{ confirmPhrase }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-form-input
+ id="confirm_name_input"
+ v-model="userInput"
+ name="confirm_name_input"
+ type="text"
+ />
+ </div>
+ </gl-modal>
+ </form>
+</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue
index d701f238a2e..d726196aadf 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue
@@ -28,7 +28,7 @@ export default {
};
</script>
<template>
- <div class="prepend-top-default">
+ <div class="gl-mt-3">
<p>
<slot></slot>
</p>
diff --git a/app/assets/javascripts/projects/project_remove_modal.js b/app/assets/javascripts/projects/project_remove_modal.js
new file mode 100644
index 00000000000..dbdad1bf6f1
--- /dev/null
+++ b/app/assets/javascripts/projects/project_remove_modal.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import RemoveProjectModal from './components/remove_modal.vue';
+
+export default (selector = '#js-confirm-project-remove') => {
+ const el = document.querySelector(selector);
+
+ if (!el) return;
+
+ const { formPath, confirmPhrase, warningMessage } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(RemoveProjectModal, {
+ props: {
+ confirmPhrase,
+ warningMessage,
+ formPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
new file mode 100644
index 00000000000..d61569fcd6e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -0,0 +1,160 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { __ } from '~/locale';
+import ServiceDeskSetting from './service_desk_setting.vue';
+import ServiceDeskService from '../services/service_desk_service';
+import eventHub from '../event_hub';
+
+export default {
+ name: 'ServiceDeskRoot',
+ components: {
+ GlAlert,
+ ServiceDeskSetting,
+ },
+ props: {
+ initialIsEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ initialIncomingEmail: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ selectedTemplate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ outgoingName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ projectKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ templates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+
+ data() {
+ return {
+ isEnabled: this.initialIsEnabled,
+ incomingEmail: this.initialIncomingEmail,
+ isTemplateSaving: false,
+ isAlertShowing: false,
+ alertVariant: 'danger',
+ alertMessage: '',
+ };
+ },
+
+ created() {
+ eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggled);
+ eventHub.$on('serviceDeskTemplateSave', this.onSaveTemplate);
+
+ this.service = new ServiceDeskService(this.endpoint);
+
+ if (this.isEnabled && !this.incomingEmail) {
+ this.fetchIncomingEmail();
+ }
+ },
+
+ beforeDestroy() {
+ eventHub.$off('serviceDeskEnabledCheckboxToggled', this.onEnableToggled);
+ eventHub.$off('serviceDeskTemplateSave', this.onSaveTemplate);
+ },
+
+ methods: {
+ fetchIncomingEmail() {
+ this.service
+ .fetchIncomingEmail()
+ .then(({ data }) => {
+ const email = data.service_desk_address;
+ if (!email) {
+ throw new Error(__("Response didn't include `service_desk_address`"));
+ }
+
+ this.incomingEmail = email;
+ })
+ .catch(() =>
+ this.showAlert(__('An error occurred while fetching the Service Desk address.')),
+ );
+ },
+
+ onEnableToggled(isChecked) {
+ this.isEnabled = isChecked;
+ this.incomingEmail = '';
+
+ this.service
+ .toggleServiceDesk(isChecked)
+ .then(({ data }) => {
+ const email = data.service_desk_address;
+ if (isChecked && !email) {
+ throw new Error(__("Response didn't include `service_desk_address`"));
+ }
+
+ this.incomingEmail = email;
+ })
+ .catch(() => {
+ const message = isChecked
+ ? __('An error occurred while enabling Service Desk.')
+ : __('An error occurred while disabling Service Desk.');
+
+ this.showAlert(message);
+ });
+ },
+
+ onSaveTemplate({ selectedTemplate, outgoingName, projectKey }) {
+ this.isTemplateSaving = true;
+ this.service
+ .updateTemplate({ selectedTemplate, outgoingName, projectKey }, this.isEnabled)
+ .then(() => this.showAlert(__('Template was successfully saved.'), 'success'))
+ .catch(() =>
+ this.showAlert(
+ __('An error occurred while saving the template. Please check if the template exists.'),
+ ),
+ )
+ .finally(() => {
+ this.isTemplateSaving = false;
+ });
+ },
+
+ showAlert(message, variant = 'danger') {
+ this.isAlertShowing = true;
+ this.alertMessage = message;
+ this.alertVariant = variant;
+ },
+
+ onDismiss() {
+ this.isAlertShowing = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert v-if="isAlertShowing" class="mb-3" :variant="alertVariant" @dismiss="onDismiss">
+ {{ alertMessage }}
+ </gl-alert>
+ <service-desk-setting
+ :is-enabled="isEnabled"
+ :incoming-email="incomingEmail"
+ :initial-selected-template="selectedTemplate"
+ :initial-outgoing-name="outgoingName"
+ :initial-project-key="projectKey"
+ :templates="templates"
+ :is-template-saving="isTemplateSaving"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
new file mode 100644
index 00000000000..43c20fea43e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -0,0 +1,169 @@
+<script>
+import { GlDeprecatedButton, GlFormSelect, GlToggle, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import tooltip from '~/vue_shared/directives/tooltip';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import eventHub from '../event_hub';
+
+export default {
+ name: 'ServiceDeskSetting',
+ directives: {
+ tooltip,
+ },
+ components: {
+ ClipboardButton,
+ GlDeprecatedButton,
+ GlFormSelect,
+ GlToggle,
+ GlLoadingIcon,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ isEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ incomingEmail: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialSelectedTemplate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialOutgoingName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialProjectKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ templates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isTemplateSaving: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ selectedTemplate: this.initialSelectedTemplate,
+ outgoingName: this.initialOutgoingName || __('GitLab Support Bot'),
+ projectKey: this.initialProjectKey,
+ };
+ },
+ computed: {
+ templateOptions() {
+ return [''].concat(this.templates);
+ },
+ hasProjectKeySupport() {
+ return Boolean(this.glFeatures.serviceDeskCustomAddress);
+ },
+ },
+ methods: {
+ onCheckboxToggle(isChecked) {
+ eventHub.$emit('serviceDeskEnabledCheckboxToggled', isChecked);
+ },
+ onSaveTemplate() {
+ eventHub.$emit('serviceDeskTemplateSave', {
+ selectedTemplate: this.selectedTemplate,
+ outgoingName: this.outgoingName,
+ projectKey: this.projectKey,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-toggle
+ id="service-desk-checkbox"
+ :value="isEnabled"
+ class="d-inline-block align-middle mr-1"
+ label="Service desk"
+ label-position="left"
+ @change="onCheckboxToggle"
+ />
+ <label class="align-middle" for="service-desk-checkbox">
+ {{ __('Activate Service Desk') }}
+ </label>
+ <div v-if="isEnabled" class="row mt-3">
+ <div class="col-md-9 mb-0">
+ <strong id="incoming-email-describer" class="d-block mb-1">
+ {{ __('Forward external support email address to') }}
+ </strong>
+ <template v-if="incomingEmail">
+ <div class="input-group">
+ <input
+ ref="service-desk-incoming-email"
+ type="text"
+ class="form-control incoming-email h-auto"
+ :placeholder="__('Incoming email')"
+ :aria-label="__('Incoming email')"
+ aria-describedby="incoming-email-describer"
+ :value="incomingEmail"
+ disabled="true"
+ />
+ <div class="input-group-append">
+ <clipboard-button
+ :title="__('Copy')"
+ :text="incomingEmail"
+ css-class="btn qa-clipboard-button"
+ />
+ </div>
+ </div>
+ </template>
+ <template v-else>
+ <gl-loading-icon :inline="true" />
+ <span class="sr-only">{{ __('Fetching incoming email') }}</span>
+ </template>
+
+ <label for="service-desk-template-select" class="mt-3">
+ {{ __('Template to append to all Service Desk issues') }}
+ </label>
+ <gl-form-select
+ id="service-desk-template-select"
+ v-model="selectedTemplate"
+ :options="templateOptions"
+ />
+ <label for="service-desk-email-from-name" class="mt-3">
+ {{ __('Email display name') }}
+ </label>
+ <input id="service-desk-email-from-name" v-model.trim="outgoingName" class="form-control" />
+ <span class="form-text text-muted">
+ {{ __('Emails sent from Service Desk will have this name') }}
+ </span>
+ <template v-if="hasProjectKeySupport">
+ <label for="service-desk-project-suffix" class="mt-3">
+ {{ __('Project name suffix') }}
+ </label>
+ <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" />
+ <span class="form-text text-muted mb-3">
+ {{
+ __(
+ 'Project name suffix is a user-defined string which will be appended to the project path, and will form the Service Desk email address.',
+ )
+ }}
+ </span>
+ </template>
+ <gl-deprecated-button
+ variant="success"
+ :disabled="isTemplateSaving"
+ @click="onSaveTemplate"
+ >{{ __('Save template') }}</gl-deprecated-button
+ >
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings_service_desk/event_hub.js b/app/assets/javascripts/projects/settings_service_desk/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js
new file mode 100644
index 00000000000..15c077de72e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/index.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import ServiceDeskRoot from './components/service_desk_root.vue';
+
+export default () => {
+ const serviceDeskRootElement = document.querySelector('.js-service-desk-setting-root');
+ if (serviceDeskRootElement) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: serviceDeskRootElement,
+ components: {
+ ServiceDeskRoot,
+ },
+ data() {
+ const { dataset } = serviceDeskRootElement;
+ return {
+ initialIsEnabled: parseBoolean(dataset.enabled),
+ endpoint: dataset.endpoint,
+ incomingEmail: dataset.incomingEmail,
+ selectedTemplate: dataset.selectedTemplate,
+ outgoingName: dataset.outgoingName,
+ projectKey: dataset.projectKey,
+ templates: JSON.parse(dataset.templates),
+ };
+ },
+ render(createElement) {
+ return createElement('service-desk-root', {
+ props: {
+ initialIsEnabled: this.initialIsEnabled,
+ endpoint: this.endpoint,
+ initialIncomingEmail: this.incomingEmail,
+ selectedTemplate: this.selectedTemplate,
+ outgoingName: this.outgoingName,
+ projectKey: this.projectKey,
+ templates: this.templates,
+ },
+ });
+ },
+ });
+ }
+};
diff --git a/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js
new file mode 100644
index 00000000000..d707763c64e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js
@@ -0,0 +1,27 @@
+import axios from '~/lib/utils/axios_utils';
+
+class ServiceDeskService {
+ constructor(endpoint) {
+ this.endpoint = endpoint;
+ }
+
+ fetchIncomingEmail() {
+ return axios.get(this.endpoint);
+ }
+
+ toggleServiceDesk(enable) {
+ return axios.put(this.endpoint, { service_desk_enabled: enable });
+ }
+
+ updateTemplate({ selectedTemplate, outgoingName, projectKey = '' }, isEnabled) {
+ const body = {
+ issue_template_key: selectedTemplate,
+ outgoing_name: outgoingName,
+ project_key: projectKey,
+ service_desk_enabled: isEnabled,
+ };
+ return axios.put(this.endpoint, body);
+ }
+}
+
+export default ServiceDeskService;
diff --git a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
index 15b6a29e5cf..941a05583ad 100644
--- a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
+++ b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
@@ -41,6 +41,11 @@ export default {
type: String,
required: true,
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -88,7 +93,11 @@ export default {
<div class="input-group">
<gl-form-input id="notify-url" :readonly="true" :value="notifyUrl" />
<span class="input-group-append">
- <clipboard-button :text="notifyUrl" :title="$options.copyToClipboard" />
+ <clipboard-button
+ :text="notifyUrl"
+ :title="$options.copyToClipboard"
+ :disabled="disabled"
+ />
</span>
</div>
</gl-form-group>
@@ -100,7 +109,11 @@ export default {
<div class="input-group">
<gl-form-input id="authorization-key" :readonly="true" :value="authorizationKey" />
<span class="input-group-append">
- <clipboard-button :text="authorizationKey" :title="$options.copyToClipboard" />
+ <clipboard-button
+ :text="authorizationKey"
+ :title="$options.copyToClipboard"
+ :disabled="disabled"
+ />
</span>
</div>
</gl-form-group>
@@ -118,13 +131,20 @@ export default {
)
}}
</gl-modal>
- <gl-deprecated-button v-gl-modal.authKeyModal class="js-reset-auth-key">{{
- __('Reset key')
- }}</gl-deprecated-button>
+ <gl-deprecated-button
+ v-gl-modal.authKeyModal
+ class="js-reset-auth-key"
+ :disabled="disabled"
+ >{{ __('Reset key') }}</gl-deprecated-button
+ >
</template>
- <gl-deprecated-button v-else class="js-reset-auth-key" @click="resetKey">{{
- __('Generate key')
- }}</gl-deprecated-button>
+ <gl-deprecated-button
+ v-else
+ :disabled="disabled"
+ class="js-reset-auth-key"
+ @click="resetKey"
+ >{{ __('Generate key') }}</gl-deprecated-button
+ >
</div>
</div>
</template>
diff --git a/app/assets/javascripts/prometheus_alerts/index.js b/app/assets/javascripts/prometheus_alerts/index.js
index a42f19e5245..7efe6ed186b 100644
--- a/app/assets/javascripts/prometheus_alerts/index.js
+++ b/app/assets/javascripts/prometheus_alerts/index.js
@@ -8,7 +8,7 @@ export default () => {
return;
}
- const { authorizationKey, changeKeyUrl, notifyUrl, learnMoreUrl } = el.dataset;
+ const { authorizationKey, changeKeyUrl, notifyUrl, learnMoreUrl, disabled } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
@@ -20,6 +20,7 @@ export default () => {
changeKeyUrl,
notifyUrl,
learnMoreUrl,
+ disabled,
},
});
},
diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue
new file mode 100644
index 00000000000..32e916052c4
--- /dev/null
+++ b/app/assets/javascripts/ref/components/ref_results_section.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlNewDropdownHeader, GlNewDropdownItem, GlBadge, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'RefResultsSection',
+ components: {
+ GlNewDropdownHeader,
+ GlNewDropdownItem,
+ GlBadge,
+ GlIcon,
+ },
+ props: {
+ sectionTitle: {
+ type: String,
+ required: true,
+ },
+
+ totalCount: {
+ type: Number,
+ required: true,
+ },
+
+ /**
+ * An array of object that have the following properties:
+ *
+ * - name (String, required): The name of the ref that will be displayed
+ * - value (String, optional): The value that will be selected when the ref
+ * is selected. If not provided, `name` will be used as the value.
+ * For example, commits use the short SHA for `name`
+ * and long SHA for `value`.
+ * - subtitle (String, optional): Text to render underneath the name.
+ * For example, used to render the commit's title underneath its SHA.
+ * - default (Boolean, optional): Whether or not to render a "default"
+ * indicator next to the item. Used to indicate
+ * the project's default branch.
+ *
+ */
+ items: {
+ type: Array,
+ required: true,
+ validator: items => Array.isArray(items) && items.every(item => item.name),
+ },
+
+ /**
+ * The currently selected ref.
+ * Used to render a check mark by the selected item.
+ * */
+ selectedRef: {
+ type: String,
+ required: false,
+ default: '',
+ },
+
+ /**
+ * An error object that indicates that an error
+ * occurred while fetching items for this section
+ */
+ error: {
+ type: Error,
+ required: false,
+ default: null,
+ },
+
+ /** The message to display if an error occurs */
+ errorMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ totalCountText() {
+ return this.totalCount > 999 ? s__('TotalRefCountIndicator|1000+') : `${this.totalCount}`;
+ },
+ },
+ methods: {
+ showCheck(item) {
+ return item.name === this.selectedRef || item.value === this.selectedRef;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-new-dropdown-header>
+ <div class="gl-display-flex align-items-center" data-testid="section-header">
+ <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span>
+ <gl-badge variant="neutral">{{ totalCountText }}</gl-badge>
+ </div>
+ </gl-new-dropdown-header>
+ <template v-if="error">
+ <div class="gl-display-flex align-items-start text-danger gl-ml-4 gl-mr-4 gl-mb-3">
+ <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" />
+ <span>{{ errorMessage }}</span>
+ </div>
+ </template>
+ <template v-else>
+ <gl-new-dropdown-item
+ v-for="item in items"
+ :key="item.name"
+ @click="$emit('selected', item.value || item.name)"
+ >
+ <div class="gl-display-flex align-items-start">
+ <gl-icon
+ name="mobile-issue-close"
+ class="gl-mr-2 gl-flex-shrink-0"
+ :class="{ 'gl-visibility-hidden': !showCheck(item) }"
+ />
+
+ <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column">
+ <span class="gl-font-monospace">{{ item.name }}</span>
+ <span class="gl-text-gray-600">{{ item.subtitle }}</span>
+ </div>
+
+ <gl-badge v-if="item.default" size="sm" variant="info">{{
+ s__('DefaultBranchLabel|default')
+ }}</gl-badge>
+ </div>
+ </gl-new-dropdown-item>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
new file mode 100644
index 00000000000..012a391a3da
--- /dev/null
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -0,0 +1,186 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import {
+ GlNewDropdown,
+ GlNewDropdownDivider,
+ GlNewDropdownHeader,
+ GlSearchBoxByType,
+ GlSprintf,
+ GlIcon,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import createStore from '../stores';
+import { SEARCH_DEBOUNCE_MS, DEFAULT_I18N } from '../constants';
+import RefResultsSection from './ref_results_section.vue';
+
+export default {
+ name: 'RefSelector',
+ store: createStore(),
+ components: {
+ GlNewDropdown,
+ GlNewDropdownDivider,
+ GlNewDropdownHeader,
+ GlSearchBoxByType,
+ GlSprintf,
+ GlIcon,
+ GlLoadingIcon,
+ RefResultsSection,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ translations: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ query: '',
+ };
+ },
+ computed: {
+ ...mapState({
+ matches: state => state.matches,
+ lastQuery: state => state.query,
+ selectedRef: state => state.selectedRef,
+ }),
+ ...mapGetters(['isLoading', 'isQueryPossiblyASha']),
+ i18n() {
+ return {
+ ...DEFAULT_I18N,
+ ...this.translations,
+ };
+ },
+ showBranchesSection() {
+ return Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error);
+ },
+ showTagsSection() {
+ return Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error);
+ },
+ showCommitsSection() {
+ return Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error);
+ },
+ showNoResults() {
+ return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection;
+ },
+ },
+ created() {
+ this.setProjectId(this.projectId);
+ this.search(this.query);
+ },
+ methods: {
+ ...mapActions(['setProjectId', 'setSelectedRef', 'search']),
+ focusSearchBox() {
+ this.$refs.searchBox.$el.querySelector('input').focus();
+ },
+ onSearchBoxInput: debounce(function search() {
+ this.search(this.query);
+ }, SEARCH_DEBOUNCE_MS),
+ selectRef(ref) {
+ this.setSelectedRef(ref);
+ this.$emit('input', this.selectedRef);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-new-dropdown class="ref-selector" @shown="focusSearchBox">
+ <template slot="button-content">
+ <span class="gl-flex-grow-1 gl-ml-2 gl-text-gray-600" data-testid="button-content">
+ <span v-if="selectedRef" class="gl-font-monospace">{{ selectedRef }}</span>
+ <span v-else>{{ i18n.noRefSelected }}</span>
+ </span>
+ <gl-icon name="chevron-down" />
+ </template>
+
+ <div class="gl-display-flex gl-flex-direction-column ref-selector-dropdown-content">
+ <gl-new-dropdown-header>
+ <span class="gl-text-center gl-display-block">{{ i18n.dropdownHeader }}</span>
+ </gl-new-dropdown-header>
+
+ <gl-new-dropdown-divider />
+
+ <gl-search-box-by-type
+ ref="searchBox"
+ v-model.trim="query"
+ class="gl-m-3"
+ :placeholder="i18n.searchPlaceholder"
+ @input="onSearchBoxInput"
+ />
+
+ <div class="gl-flex-grow-1 gl-overflow-y-auto">
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" />
+
+ <div
+ v-else-if="showNoResults"
+ class="gl-text-center gl-mx-3 gl-py-3"
+ data-testid="no-results"
+ >
+ <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery">
+ <template #query>
+ <b class="gl-word-break-all">{{ lastQuery }}</b>
+ </template>
+ </gl-sprintf>
+
+ <span v-else>{{ i18n.noResults }}</span>
+ </div>
+
+ <template v-else>
+ <template v-if="showBranchesSection">
+ <ref-results-section
+ :section-title="i18n.branches"
+ :total-count="matches.branches.totalCount"
+ :items="matches.branches.list"
+ :selected-ref="selectedRef"
+ :error="matches.branches.error"
+ :error-message="i18n.branchesErrorMessage"
+ data-testid="branches-section"
+ @selected="selectRef($event)"
+ />
+
+ <gl-new-dropdown-divider v-if="showTagsSection || showCommitsSection" />
+ </template>
+
+ <template v-if="showTagsSection">
+ <ref-results-section
+ :section-title="i18n.tags"
+ :total-count="matches.tags.totalCount"
+ :items="matches.tags.list"
+ :selected-ref="selectedRef"
+ :error="matches.tags.error"
+ :error-message="i18n.tagsErrorMessage"
+ data-testid="tags-section"
+ @selected="selectRef($event)"
+ />
+
+ <gl-new-dropdown-divider v-if="showCommitsSection" />
+ </template>
+
+ <template v-if="showCommitsSection">
+ <ref-results-section
+ :section-title="i18n.commits"
+ :total-count="matches.commits.totalCount"
+ :items="matches.commits.list"
+ :selected-ref="selectedRef"
+ :error="matches.commits.error"
+ :error-message="i18n.commitsErrorMessage"
+ data-testid="commits-section"
+ @selected="selectRef($event)"
+ />
+ </template>
+ </template>
+ </div>
+ </div>
+ </gl-new-dropdown>
+</template>
diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js
new file mode 100644
index 00000000000..ca82b951377
--- /dev/null
+++ b/app/assets/javascripts/ref/constants.js
@@ -0,0 +1,19 @@
+import { __ } from '~/locale';
+
+export const X_TOTAL_HEADER = 'x-total';
+
+export const SEARCH_DEBOUNCE_MS = 250;
+
+export const DEFAULT_I18N = Object.freeze({
+ dropdownHeader: __('Select Git revision'),
+ searchPlaceholder: __('Search by Git revision'),
+ noResultsWithQuery: __('No matching results for "%{query}"'),
+ noResults: __('No matching results'),
+ branchesErrorMessage: __('An error occurred while fetching branches. Retry the search.'),
+ tagsErrorMessage: __('An error occurred while fetching tags. Retry the search.'),
+ commitsErrorMessage: __('An error occurred while fetching commits. Retry the search.'),
+ branches: __('Branches'),
+ tags: __('Tags'),
+ commits: __('Commits'),
+ noRefSelected: __('No ref selected'),
+});
diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js
new file mode 100644
index 00000000000..8fcc99cef38
--- /dev/null
+++ b/app/assets/javascripts/ref/stores/actions.js
@@ -0,0 +1,65 @@
+import Api from '~/api';
+import * as types from './mutation_types';
+
+export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId);
+
+export const setSelectedRef = ({ commit }, selectedRef) =>
+ commit(types.SET_SELECTED_REF, selectedRef);
+
+export const search = ({ dispatch, commit }, query) => {
+ commit(types.SET_QUERY, query);
+
+ dispatch('searchBranches');
+ dispatch('searchTags');
+ dispatch('searchCommits');
+};
+
+export const searchBranches = ({ commit, state }) => {
+ commit(types.REQUEST_START);
+
+ Api.branches(state.projectId, state.query)
+ .then(response => {
+ commit(types.RECEIVE_BRANCHES_SUCCESS, response);
+ })
+ .catch(error => {
+ commit(types.RECEIVE_BRANCHES_ERROR, error);
+ })
+ .finally(() => {
+ commit(types.REQUEST_FINISH);
+ });
+};
+
+export const searchTags = ({ commit, state }) => {
+ commit(types.REQUEST_START);
+
+ Api.tags(state.projectId, state.query)
+ .then(response => {
+ commit(types.RECEIVE_TAGS_SUCCESS, response);
+ })
+ .catch(error => {
+ commit(types.RECEIVE_TAGS_ERROR, error);
+ })
+ .finally(() => {
+ commit(types.REQUEST_FINISH);
+ });
+};
+
+export const searchCommits = ({ commit, state, getters }) => {
+ // Only query the Commit API if the search query looks like a commit SHA
+ if (getters.isQueryPossiblyASha) {
+ commit(types.REQUEST_START);
+
+ Api.commit(state.projectId, state.query)
+ .then(response => {
+ commit(types.RECEIVE_COMMITS_SUCCESS, response);
+ })
+ .catch(error => {
+ commit(types.RECEIVE_COMMITS_ERROR, error);
+ })
+ .finally(() => {
+ commit(types.REQUEST_FINISH);
+ });
+ } else {
+ commit(types.RESET_COMMIT_MATCHES);
+ }
+};
diff --git a/app/assets/javascripts/ref/stores/getters.js b/app/assets/javascripts/ref/stores/getters.js
new file mode 100644
index 00000000000..02d4ae8ff91
--- /dev/null
+++ b/app/assets/javascripts/ref/stores/getters.js
@@ -0,0 +1,5 @@
+/** Returns `true` if the query string looks like it could be a commit SHA */
+export const isQueryPossiblyASha = ({ query }) => /^[0-9a-f]{4,40}$/i.test(query);
+
+/** Returns `true` if there is at least one in-progress request */
+export const isLoading = ({ requestCount }) => requestCount > 0;
diff --git a/app/assets/javascripts/ref/stores/index.js b/app/assets/javascripts/ref/stores/index.js
new file mode 100644
index 00000000000..2bebffc19ab
--- /dev/null
+++ b/app/assets/javascripts/ref/stores/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ actions,
+ getters,
+ mutations,
+ state: createState(),
+ });
diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js
new file mode 100644
index 00000000000..9f6195f5f3f
--- /dev/null
+++ b/app/assets/javascripts/ref/stores/mutation_types.js
@@ -0,0 +1,16 @@
+export const SET_PROJECT_ID = 'SET_PROJECT_ID';
+export const SET_SELECTED_REF = 'SET_SELECTED_REF';
+export const SET_QUERY = 'SET_QUERY';
+
+export const REQUEST_START = 'REQUEST_START';
+export const REQUEST_FINISH = 'REQUEST_FINISH';
+
+export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS';
+export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR';
+
+export const RECEIVE_TAGS_SUCCESS = 'RECEIVE_TAGS_SUCCESS';
+export const RECEIVE_TAGS_ERROR = 'RECEIVE_TAGS_ERROR';
+
+export const RECEIVE_COMMITS_SUCCESS = 'RECEIVE_COMMITS_SUCCESS';
+export const RECEIVE_COMMITS_ERROR = 'RECEIVE_COMMITS_ERROR';
+export const RESET_COMMIT_MATCHES = 'RESET_COMMIT_MATCHES';
diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js
new file mode 100644
index 00000000000..73f9d7ee487
--- /dev/null
+++ b/app/assets/javascripts/ref/stores/mutations.js
@@ -0,0 +1,91 @@
+import * as types from './mutation_types';
+import { X_TOTAL_HEADER } from '../constants';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+export default {
+ [types.SET_PROJECT_ID](state, projectId) {
+ state.projectId = projectId;
+ },
+ [types.SET_SELECTED_REF](state, selectedRef) {
+ state.selectedRef = selectedRef;
+ },
+ [types.SET_QUERY](state, query) {
+ state.query = query;
+ },
+
+ [types.REQUEST_START](state) {
+ state.requestCount += 1;
+ },
+ [types.REQUEST_FINISH](state) {
+ state.requestCount -= 1;
+ },
+
+ [types.RECEIVE_BRANCHES_SUCCESS](state, response) {
+ state.matches.branches = {
+ list: convertObjectPropsToCamelCase(response.data).map(b => ({
+ name: b.name,
+ default: b.default,
+ })),
+ totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10),
+ error: null,
+ };
+ },
+ [types.RECEIVE_BRANCHES_ERROR](state, error) {
+ state.matches.branches = {
+ list: [],
+ totalCount: 0,
+ error,
+ };
+ },
+
+ [types.RECEIVE_TAGS_SUCCESS](state, response) {
+ state.matches.tags = {
+ list: convertObjectPropsToCamelCase(response.data).map(b => ({
+ name: b.name,
+ })),
+ totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10),
+ error: null,
+ };
+ },
+ [types.RECEIVE_TAGS_ERROR](state, error) {
+ state.matches.tags = {
+ list: [],
+ totalCount: 0,
+ error,
+ };
+ },
+
+ [types.RECEIVE_COMMITS_SUCCESS](state, response) {
+ const commit = convertObjectPropsToCamelCase(response.data);
+
+ state.matches.commits = {
+ list: [
+ {
+ name: commit.shortId,
+ value: commit.id,
+ subtitle: commit.title,
+ },
+ ],
+ totalCount: 1,
+ error: null,
+ };
+ },
+ [types.RECEIVE_COMMITS_ERROR](state, error) {
+ state.matches.commits = {
+ list: [],
+ totalCount: 0,
+
+ // 404's are expected when the search query doesn't match any commits
+ // and shouldn't be treated as an actual error
+ error: error.response?.status !== httpStatusCodes.NOT_FOUND ? error : null,
+ };
+ },
+ [types.RESET_COMMIT_MATCHES](state) {
+ state.matches.commits = {
+ list: [],
+ totalCount: 0,
+ error: null,
+ };
+ },
+};
diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js
new file mode 100644
index 00000000000..65b9d6449d7
--- /dev/null
+++ b/app/assets/javascripts/ref/stores/state.js
@@ -0,0 +1,24 @@
+export default () => ({
+ projectId: null,
+
+ query: '',
+ matches: {
+ branches: {
+ list: [],
+ totalCount: 0,
+ error: null,
+ },
+ tags: {
+ list: [],
+ totalCount: 0,
+ error: null,
+ },
+ commits: {
+ list: [],
+ totalCount: 0,
+ error: null,
+ },
+ },
+ selectedRef: null,
+ requestCount: 0,
+});
diff --git a/app/assets/javascripts/registry/explorer/components/delete_button.vue b/app/assets/javascripts/registry/explorer/components/delete_button.vue
new file mode 100644
index 00000000000..dab6a26ea16
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/delete_button.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+
+export default {
+ name: 'DeleteButton',
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ tooltipTitle: {
+ type: String,
+ required: true,
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ tooltipDisabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ tooltipConfiguration() {
+ return {
+ disabled: this.tooltipDisabled,
+ title: this.tooltipTitle,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-gl-tooltip="tooltipConfiguration">
+ <gl-button
+ v-gl-tooltip
+ :disabled="disabled"
+ :title="title"
+ :aria-label="title"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ @click="$emit('delete')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue
new file mode 100644
index 00000000000..c4358b83e23
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ props: {
+ icon: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all"
+ >
+ <gl-icon :name="icon" class="gl-mr-4" />
+ <span>
+ <slot></slot>
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
new file mode 100644
index 00000000000..8494967ab57
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import TagsListRow from './tags_list_row.vue';
+import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE } from '../../constants/index';
+
+export default {
+ components: {
+ GlButton,
+ TagsListRow,
+ },
+ props: {
+ tags: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isDesktop: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ i18n: {
+ REMOVE_TAGS_BUTTON_TITLE,
+ TAGS_LIST_TITLE,
+ },
+ data() {
+ return {
+ selectedItems: {},
+ };
+ },
+ computed: {
+ hasSelectedItems() {
+ return this.tags.some(tag => this.selectedItems[tag.name]);
+ },
+ showMultiDeleteButton() {
+ return this.tags.some(tag => tag.destroy_path) && this.isDesktop;
+ },
+ },
+ methods: {
+ updateSelectedItems(name) {
+ this.$set(this.selectedItems, name, !this.selectedItems[name]);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
+ <h5 data-testid="list-title">
+ {{ $options.i18n.TAGS_LIST_TITLE }}
+ </h5>
+
+ <gl-button
+ v-if="showMultiDeleteButton"
+ :disabled="!hasSelectedItems"
+ category="secondary"
+ variant="danger"
+ @click="$emit('delete', selectedItems)"
+ >
+ {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }}
+ </gl-button>
+ </div>
+ <tags-list-row
+ v-for="(tag, index) in tags"
+ :key="tag.path"
+ :tag="tag"
+ :first="index === 0"
+ :last="index === tags.length - 1"
+ :selected="selectedItems[tag.name]"
+ :is-desktop="isDesktop"
+ @select="updateSelectedItems(tag.name)"
+ @delete="$emit('delete', { [tag.name]: true })"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
new file mode 100644
index 00000000000..51ba2337db6
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
@@ -0,0 +1,220 @@
+<script>
+import { GlFormCheckbox, GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import DeleteButton from '../delete_button.vue';
+import ListItem from '../list_item.vue';
+import DetailsRow from './details_row.vue';
+import {
+ REMOVE_TAG_BUTTON_TITLE,
+ DIGEST_LABEL,
+ CREATED_AT_LABEL,
+ REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
+ PUBLISHED_DETAILS_ROW_TEXT,
+ MANIFEST_DETAILS_ROW_TEST,
+ CONFIGURATION_DETAILS_ROW_TEST,
+ MISSING_MANIFEST_WARNING_TOOLTIP,
+ NOT_AVAILABLE_TEXT,
+ NOT_AVAILABLE_SIZE,
+} from '../../constants/index';
+
+export default {
+ components: {
+ GlSprintf,
+ GlFormCheckbox,
+ GlIcon,
+ DeleteButton,
+ ListItem,
+ ClipboardButton,
+ TimeAgoTooltip,
+ DetailsRow,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ tag: {
+ type: Object,
+ required: true,
+ },
+ isDesktop: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ selected: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ i18n: {
+ REMOVE_TAG_BUTTON_TITLE,
+ DIGEST_LABEL,
+ CREATED_AT_LABEL,
+ REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
+ PUBLISHED_DETAILS_ROW_TEXT,
+ MANIFEST_DETAILS_ROW_TEST,
+ CONFIGURATION_DETAILS_ROW_TEST,
+ MISSING_MANIFEST_WARNING_TOOLTIP,
+ },
+ computed: {
+ formattedSize() {
+ return this.tag.total_size ? numberToHumanSize(this.tag.total_size) : NOT_AVAILABLE_SIZE;
+ },
+ layers() {
+ return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : '';
+ },
+ mobileClasses() {
+ return this.isDesktop ? '' : 'mw-s';
+ },
+ shortDigest() {
+ // remove sha256: from the string, and show only the first 7 char
+ return this.tag.digest?.substring(7, 14) ?? NOT_AVAILABLE_TEXT;
+ },
+ publishedDate() {
+ return formatDate(this.tag.created_at, 'isoDate');
+ },
+ publishedTime() {
+ return formatDate(this.tag.created_at, 'hh:MM Z');
+ },
+ formattedRevision() {
+ // to be removed when API response is adjusted
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/225324
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `sha256:${this.tag.revision}`;
+ },
+ tagLocation() {
+ return this.tag.path?.replace(`:${this.tag.name}`, '');
+ },
+ invalidTag() {
+ return !this.tag.digest;
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item v-bind="$attrs" :selected="selected">
+ <template #left-action>
+ <gl-form-checkbox
+ v-if="Boolean(tag.destroy_path)"
+ :disabled="invalidTag"
+ class="gl-m-0"
+ :checked="selected"
+ @change="$emit('select')"
+ />
+ </template>
+ <template #left-primary>
+ <div class="gl-display-flex gl-align-items-center">
+ <div
+ v-gl-tooltip="{ title: tag.name }"
+ data-testid="name"
+ class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
+ :class="mobileClasses"
+ >
+ {{ tag.name }}
+ </div>
+
+ <clipboard-button
+ v-if="tag.location"
+ :title="tag.location"
+ :text="tag.location"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+
+ <gl-icon
+ v-if="invalidTag"
+ v-gl-tooltip="{ title: $options.i18n.MISSING_MANIFEST_WARNING_TOOLTIP }"
+ name="warning"
+ class="gl-text-orange-500 gl-mb-2 gl-ml-2"
+ />
+ </div>
+ </template>
+
+ <template #left-secondary>
+ <span data-testid="size">
+ {{ formattedSize }}
+ <template v-if="formattedSize && layers"
+ >&middot;</template
+ >
+ {{ layers }}
+ </span>
+ </template>
+ <template #right-primary>
+ <span data-testid="time">
+ <gl-sprintf :message="$options.i18n.CREATED_AT_LABEL">
+ <template #timeInfo>
+ <time-ago-tooltip :time="tag.created_at" />
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #right-secondary>
+ <span data-testid="digest">
+ <gl-sprintf :message="$options.i18n.DIGEST_LABEL">
+ <template #imageId>{{ shortDigest }}</template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #right-action>
+ <delete-button
+ :disabled="!tag.destroy_path || invalidTag"
+ :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
+ :tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP"
+ :tooltip-disabled="Boolean(tag.destroy_path)"
+ data-testid="single-delete-button"
+ @delete="$emit('delete')"
+ />
+ </template>
+
+ <template v-if="!invalidTag" #details_published>
+ <details-row icon="clock" data-testid="published-date-detail">
+ <gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT">
+ <template #repositoryPath>
+ <i>{{ tagLocation }}</i>
+ </template>
+ <template #time>
+ {{ publishedTime }}
+ </template>
+ <template #date>
+ {{ publishedDate }}
+ </template>
+ </gl-sprintf>
+ </details-row>
+ </template>
+ <template v-if="!invalidTag" #details_manifest_digest>
+ <details-row icon="log" data-testid="manifest-detail">
+ <gl-sprintf :message="$options.i18n.MANIFEST_DETAILS_ROW_TEST">
+ <template #digest>
+ {{ tag.digest }}
+ </template>
+ </gl-sprintf>
+ <clipboard-button
+ v-if="tag.digest"
+ :title="tag.digest"
+ :text="tag.digest"
+ css-class="btn-default btn-transparent btn-clipboard gl-p-0"
+ />
+ </details-row>
+ </template>
+ <template v-if="!invalidTag" #details_configuration_digest>
+ <details-row icon="cloud-gear" data-testid="configuration-detail">
+ <gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST">
+ <template #digest>
+ {{ formattedRevision }}
+ </template>
+ </gl-sprintf>
+ <clipboard-button
+ v-if="formattedRevision"
+ :title="formattedRevision"
+ :text="formattedRevision"
+ css-class="btn-default btn-transparent btn-clipboard gl-p-0"
+ />
+ </details-row>
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue
deleted file mode 100644
index 81be778e1e5..00000000000
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue
+++ /dev/null
@@ -1,210 +0,0 @@
-<script>
-import { GlTable, GlFormCheckbox, GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { n__ } from '~/locale';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-import {
- LIST_KEY_TAG,
- LIST_KEY_IMAGE_ID,
- LIST_KEY_SIZE,
- LIST_KEY_LAST_UPDATED,
- LIST_KEY_ACTIONS,
- LIST_KEY_CHECKBOX,
- LIST_LABEL_TAG,
- LIST_LABEL_IMAGE_ID,
- LIST_LABEL_SIZE,
- LIST_LABEL_LAST_UPDATED,
- REMOVE_TAGS_BUTTON_TITLE,
- REMOVE_TAG_BUTTON_TITLE,
-} from '../../constants/index';
-
-export default {
- components: {
- GlTable,
- GlFormCheckbox,
- GlButton,
- ClipboardButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- mixins: [timeagoMixin],
- props: {
- tags: {
- type: Array,
- required: false,
- default: () => [],
- },
- isLoading: {
- type: Boolean,
- required: false,
- default: false,
- },
- isDesktop: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- i18n: {
- REMOVE_TAGS_BUTTON_TITLE,
- REMOVE_TAG_BUTTON_TITLE,
- },
- data() {
- return {
- selectedItems: [],
- };
- },
- computed: {
- fields() {
- const tagClass = this.isDesktop ? 'w-25' : '';
- const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end';
- return [
- { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
- {
- key: LIST_KEY_TAG,
- label: LIST_LABEL_TAG,
- class: `${tagClass} js-tag-column`,
- innerClass: tagInnerClass,
- },
- { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
- { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
- { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
- { key: LIST_KEY_ACTIONS, label: '' },
- ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop);
- },
- tagsNames() {
- return this.tags.map(t => t.name);
- },
- selectAllChecked() {
- return this.selectedItems.length === this.tags.length && this.tags.length > 0;
- },
- },
- watch: {
- tagsNames: {
- immediate: false,
- handler(tagsNames) {
- this.selectedItems = this.selectedItems.filter(t => tagsNames.includes(t));
- },
- },
- },
- methods: {
- formatSize(size) {
- return numberToHumanSize(size);
- },
- layers(layers) {
- return layers ? n__('%d layer', '%d layers', layers) : '';
- },
- onSelectAllChange() {
- if (this.selectAllChecked) {
- this.selectedItems = [];
- } else {
- this.selectedItems = this.tags.map(x => x.name);
- }
- },
- updateSelectedItems(name) {
- const delIndex = this.selectedItems.findIndex(x => x === name);
-
- if (delIndex > -1) {
- this.selectedItems.splice(delIndex, 1);
- } else {
- this.selectedItems.push(name);
- }
- },
- },
-};
-</script>
-
-<template>
- <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty :busy="isLoading">
- <template v-if="isDesktop" #head(checkbox)>
- <gl-form-checkbox
- data-testid="mainCheckbox"
- :checked="selectAllChecked"
- @change="onSelectAllChange"
- />
- </template>
- <template #head(actions)>
- <span class="gl-display-flex gl-justify-content-end">
- <gl-button
- v-gl-tooltip
- data-testid="bulkDeleteButton"
- :disabled="!selectedItems || selectedItems.length === 0"
- icon="remove"
- variant="danger"
- :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
- :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
- @click="$emit('delete', selectedItems)"
- />
- </span>
- </template>
-
- <template #cell(checkbox)="{item}">
- <gl-form-checkbox
- data-testid="rowCheckbox"
- :checked="selectedItems.includes(item.name)"
- @change="updateSelectedItems(item.name)"
- />
- </template>
- <template #cell(name)="{item, field}">
- <div data-testid="rowName" :class="[field.innerClass, 'gl-display-flex']">
- <span
- v-gl-tooltip
- data-testid="rowNameText"
- :title="item.name"
- class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
- >
- {{ item.name }}
- </span>
- <clipboard-button
- v-if="item.location"
- data-testid="rowClipboardButton"
- :title="item.location"
- :text="item.location"
- css-class="btn-default btn-transparent btn-clipboard"
- />
- </div>
- </template>
- <template #cell(short_revision)="{value}">
- <span data-testid="rowShortRevision">
- {{ value }}
- </span>
- </template>
- <template #cell(total_size)="{item}">
- <span data-testid="rowSize">
- {{ formatSize(item.total_size) }}
- <template v-if="item.total_size && item.layers">
- &middot;
- </template>
- {{ layers(item.layers) }}
- </span>
- </template>
- <template #cell(created_at)="{value}">
- <span v-gl-tooltip data-testid="rowTime" :title="tooltipTitle(value)">
- {{ timeFormatted(value) }}
- </span>
- </template>
- <template #cell(actions)="{item}">
- <span class="gl-display-flex gl-justify-content-end">
- <gl-button
- data-testid="singleDeleteButton"
- :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
- :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
- :disabled="!item.destroy_path"
- variant="danger"
- icon="remove"
- category="secondary"
- @click="$emit('delete', [item.name])"
- />
- </span>
- </template>
-
- <template #empty>
- <slot name="empty"></slot>
- </template>
- <template #table-busy>
- <slot name="loader"></slot>
- </template>
- </gl-table>
-</template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_item.vue b/app/assets/javascripts/registry/explorer/components/list_item.vue
new file mode 100644
index 00000000000..7b5afe8fd9d
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/list_item.vue
@@ -0,0 +1,128 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+
+export default {
+ name: 'ListItem',
+ components: { GlButton },
+ props: {
+ first: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ last: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ selected: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ isDetailsShown: false,
+ detailsSlots: [],
+ };
+ },
+ computed: {
+ optionalClasses() {
+ return {
+ 'gl-border-t-1': !this.first,
+ 'gl-border-t-2': this.first,
+ 'gl-border-b-1': !this.last,
+ 'gl-border-b-2': this.last,
+ 'disabled-content': this.disabled,
+ 'gl-border-gray-100': !this.selected,
+ 'gl-bg-blue-50 gl-border-blue-200': this.selected,
+ };
+ },
+ },
+ mounted() {
+ this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details_'));
+ },
+ methods: {
+ toggleDetails() {
+ this.isDetailsShown = !this.isDetailsShown;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid"
+ :class="optionalClasses"
+ >
+ <div class="gl-display-flex gl-align-items-center gl-py-4 gl-px-2">
+ <div
+ v-if="$slots['left-action']"
+ class="gl-w-7 gl-display-none gl-display-sm-flex gl-justify-content-start gl-pl-2"
+ >
+ <slot name="left-action"></slot>
+ </div>
+ <div class="gl-display-flex gl-flex-direction-column gl-flex-fill-1">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-text-black-normal gl-font-weight-bold"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <slot name="left-primary"></slot>
+ <gl-button
+ v-if="detailsSlots.length > 0"
+ :selected="isDetailsShown"
+ icon="ellipsis_h"
+ size="small"
+ class="gl-ml-2 gl-display-none gl-display-sm-block"
+ @click="toggleDetails"
+ />
+ </div>
+ <div>
+ <slot name="right-primary"></slot>
+ </div>
+ </div>
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-font-sm gl-text-gray-500"
+ >
+ <div>
+ <slot name="left-secondary"></slot>
+ </div>
+ <div>
+ <slot name="right-secondary"></slot>
+ </div>
+ </div>
+ </div>
+ <div
+ v-if="$slots['right-action']"
+ class="gl-w-9 gl-display-none gl-display-sm-flex gl-justify-content-end gl-pr-2"
+ >
+ <slot name="right-action"></slot>
+ </div>
+ </div>
+ <div class="gl-display-flex">
+ <div class="gl-w-7"></div>
+ <div
+ v-if="isDetailsShown"
+ class="gl-display-flex gl-flex-direction-column gl-flex-fill-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3"
+ >
+ <div
+ v-for="(row, detailIndex) in detailsSlots"
+ :key="detailIndex"
+ class="gl-px-5 gl-py-2"
+ :class="{
+ 'gl-border-gray-100 gl-border-t-solid gl-border-t-1': detailIndex !== 0,
+ }"
+ >
+ <slot :name="row"></slot>
+ </div>
+ </div>
+ <div class="gl-w-9"></div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue
index a29a9bd23c3..80cc392f86a 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue
@@ -18,10 +18,9 @@ export default {
<gl-empty-state
:title="s__('ContainerRegistry|There are no container images available in this group')"
:svg-path="config.noContainersImage"
- class="container-message"
>
<template #description>
- <p class="js-no-container-images-text">
+ <p>
<gl-sprintf
:message="
s__(
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
index 9d48769cbad..65cf51fd1d1 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
@@ -37,7 +37,8 @@ export default {
v-for="(listItem, index) in images"
:key="index"
:item="listItem"
- :show-top-border="index === 0"
+ :first="index === 0"
+ :last="index === images.length - 1"
@delete="$emit('delete', $event)"
/>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
index cd878c38081..2874d89d913 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
@@ -1,7 +1,9 @@
<script>
-import { GlTooltipDirective, GlButton, GlIcon, GlSprintf } from '@gitlab/ui';
+import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui';
import { n__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ListItem from '../list_item.vue';
+import DeleteButton from '../delete_button.vue';
import {
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
@@ -14,9 +16,10 @@ export default {
name: 'ImageListrow',
components: {
ClipboardButton,
- GlButton,
+ DeleteButton,
GlSprintf,
GlIcon,
+ ListItem,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -26,11 +29,6 @@ export default {
type: Object,
required: true,
},
- showTopBorder: {
- type: Boolean,
- default: false,
- required: false,
- },
},
i18n: {
LIST_DELETE_BUTTON_DISABLED,
@@ -62,75 +60,55 @@ export default {
</script>
<template>
- <div
+ <list-item
v-gl-tooltip="{
placement: 'left',
disabled: !item.deleting,
title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
}"
+ v-bind="$attrs"
+ :disabled="item.deleting"
>
- <div
- class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-py-4 "
- :class="{
- 'gl-border-t-solid gl-border-t-1': showTopBorder,
- 'disabled-content': item.deleting,
- }"
- >
- <div class="gl-display-flex gl-flex-direction-column">
- <div class="gl-display-flex gl-align-items-center">
- <router-link
- class="gl-text-black-normal gl-font-weight-bold"
- data-testid="detailsLink"
- :to="{ name: 'details', params: { id: encodedItem } }"
- >
- {{ item.path }}
- </router-link>
- <clipboard-button
- v-if="item.location"
- :disabled="item.deleting"
- :text="item.location"
- :title="item.location"
- css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500"
- />
- <gl-icon
- v-if="item.failedDelete"
- v-gl-tooltip
- :title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE"
- name="warning"
- class="text-warning"
- />
- </div>
- <div class="gl-font-sm gl-text-gray-500">
- <span class="gl-display-flex gl-align-items-center" data-testid="tagsCount">
- <gl-icon name="tag" class="gl-mr-2" />
- <gl-sprintf :message="tagsCountText">
- <template #count>
- {{ item.tags_count }}
- </template>
- </gl-sprintf>
- </span>
- </div>
- </div>
- <div
- v-gl-tooltip="{
- disabled: item.destroy_path,
- title: $options.i18n.LIST_DELETE_BUTTON_DISABLED,
- }"
- class="d-none d-sm-block"
- data-testid="deleteButtonWrapper"
+ <template #left-primary>
+ <router-link
+ class="gl-text-black-normal gl-font-weight-bold"
+ data-testid="detailsLink"
+ :to="{ name: 'details', params: { id: encodedItem } }"
>
- <gl-button
- v-gl-tooltip
- data-testid="deleteImageButton"
- :disabled="disabledDelete"
- :title="$options.i18n.REMOVE_REPOSITORY_LABEL"
- :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL"
- category="secondary"
- variant="danger"
- icon="remove"
- @click="$emit('delete', item)"
- />
- </div>
- </div>
- </div>
+ {{ item.path }}
+ </router-link>
+ <clipboard-button
+ v-if="item.location"
+ :disabled="item.deleting"
+ :text="item.location"
+ :title="item.location"
+ css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500"
+ />
+ <gl-icon
+ v-if="item.failedDelete"
+ v-gl-tooltip="{ title: $options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE }"
+ name="warning"
+ class="gl-text-orange-500"
+ />
+ </template>
+ <template #left-secondary>
+ <span class="gl-display-flex gl-align-items-center" data-testid="tagsCount">
+ <gl-icon name="tag" class="gl-mr-2" />
+ <gl-sprintf :message="tagsCountText">
+ <template #count>
+ {{ item.tags_count }}
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #right-action>
+ <delete-button
+ :title="$options.i18n.REMOVE_REPOSITORY_LABEL"
+ :disabled="disabledDelete"
+ :tooltip-disabled="Boolean(item.destroy_path)"
+ :tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
+ @delete="$emit('delete', item)"
+ />
+ </template>
+ </list-item>
</template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue
index c27d53f4351..35eb0b11e40 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue
@@ -1,5 +1,5 @@
<script>
-import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlEmptyState, GlSprintf, GlLink, GlFormInputGroup, GlFormInput } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -17,6 +17,8 @@ export default {
GlEmptyState,
GlSprintf,
GlLink,
+ GlFormInputGroup,
+ GlFormInput,
},
i18n: {
quickStart: QUICK_START,
@@ -43,10 +45,9 @@ export default {
<gl-empty-state
:title="s__('ContainerRegistry|There are no container images stored for this project')"
:svg-path="config.noContainersImage"
- class="container-message"
>
<template #description>
- <p class="js-no-container-images-text">
+ <p>
<gl-sprintf :message="$options.i18n.introText">
<template #docLink="{content}">
<gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link>
@@ -54,7 +55,7 @@ export default {
</gl-sprintf>
</p>
<h5>{{ $options.i18n.quickStart }}</h5>
- <p class="js-not-logged-in-to-registry-text">
+ <p>
<gl-sprintf :message="$options.i18n.notLoggedInMessage">
<template #twofaDocLink="{content}">
<gl-link :href="config.twoFactorAuthHelpLink" target="_blank">{{ content }}</gl-link>
@@ -66,42 +67,49 @@ export default {
</template>
</gl-sprintf>
</p>
- <div class="input-group append-bottom-10">
- <input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly />
- <span class="input-group-append">
+ <gl-form-input-group class="gl-mb-4">
+ <gl-form-input
+ :value="dockerLoginCommand"
+ readonly
+ type="text"
+ class="gl-font-monospace!"
+ />
+ <template #append>
<clipboard-button
:text="dockerLoginCommand"
:title="$options.i18n.copyLoginTitle"
- class="input-group-text"
+ class="gl-m-0!"
/>
- </span>
- </div>
- <p></p>
- <p>
+ </template>
+ </gl-form-input-group>
+ <p class="gl-mb-4">
{{ $options.i18n.addImageText }}
</p>
-
- <div class="input-group append-bottom-10">
- <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
- <span class="input-group-append">
+ <gl-form-input-group class="gl-mb-4 ">
+ <gl-form-input
+ :value="dockerBuildCommand"
+ readonly
+ type="text"
+ class="gl-font-monospace!"
+ />
+ <template #append>
<clipboard-button
:text="dockerBuildCommand"
:title="$options.i18n.copyBuildTitle"
- class="input-group-text"
+ class="gl-m-0!"
/>
- </span>
- </div>
-
- <div class="input-group">
- <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
- <span class="input-group-append">
+ </template>
+ </gl-form-input-group>
+ <gl-form-input-group>
+ <gl-form-input :value="dockerPushCommand" readonly type="text" class="gl-font-monospace!" />
+ <template #append>
<clipboard-button
:text="dockerPushCommand"
:title="$options.i18n.copyPushTitle"
- class="input-group-text"
+ class="gl-m-0!"
/>
- </span>
- </div>
+ </template>
+ </gl-form-input-group>
</template>
</gl-empty-state>
</template>
diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js
index a1fa995c17f..1dc5882d415 100644
--- a/app/assets/javascripts/registry/explorer/constants/details.js
+++ b/app/assets/javascripts/registry/explorer/constants/details.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
// Translations strings
export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags');
@@ -14,12 +14,20 @@ export const DELETE_TAGS_ERROR_MESSAGE = s__(
export const DELETE_TAGS_SUCCESS_MESSAGE = s__(
'ContainerRegistry|Tags successfully marked for deletion.',
);
-export const LIST_LABEL_TAG = s__('ContainerRegistry|Tag');
-export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID');
-export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size');
-export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated');
+
+export const TAGS_LIST_TITLE = s__('ContainerRegistry|Image tags');
+export const DIGEST_LABEL = s__('ContainerRegistry|Digest: %{imageId}');
+export const CREATED_AT_LABEL = s__('ContainerRegistry|Published %{timeInfo}');
+export const PUBLISHED_DETAILS_ROW_TEXT = s__(
+ 'ContainerRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}',
+);
+export const MANIFEST_DETAILS_ROW_TEST = s__('ContainerRegistry|Manifest digest: %{digest}');
+export const CONFIGURATION_DETAILS_ROW_TEST = s__(
+ 'ContainerRegistry|Configuration digest: %{digest}',
+);
+
export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
-export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags');
+export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected');
export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
`ContainerRegistry|You are about to remove %{item}. Are you sure?`,
);
@@ -36,17 +44,21 @@ export const ADMIN_GARBAGE_COLLECTION_TIP = s__(
'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
);
+export const REMOVE_TAG_BUTTON_DISABLE_TOOLTIP = s__(
+ 'ContainerRegistry|Deletion disabled due to missing or insufficient permissions.',
+);
+
+export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
+ 'ContainerRegistry|Invalid tag: missing manifest digest',
+);
+
+export const NOT_AVAILABLE_TEXT = __('N/A');
+export const NOT_AVAILABLE_SIZE = __('0 bytes');
// Parameters
export const DEFAULT_PAGE = 1;
export const DEFAULT_PAGE_SIZE = 10;
export const GROUP_PAGE_TYPE = 'groups';
-export const LIST_KEY_TAG = 'name';
-export const LIST_KEY_IMAGE_ID = 'short_revision';
-export const LIST_KEY_SIZE = 'total_size';
-export const LIST_KEY_LAST_UPDATED = 'created_at';
-export const LIST_KEY_ACTIONS = 'actions';
-export const LIST_KEY_CHECKBOX = 'checkbox';
export const ALERT_SUCCESS_TAG = 'success_tag';
export const ALERT_DANGER_TAG = 'danger_tag';
export const ALERT_SUCCESS_TAGS = 'success_tags';
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index 598e643ce1a..cf811156704 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -6,7 +6,7 @@ import Tracking from '~/tracking';
import DeleteAlert from '../components/details_page/delete_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.vue';
-import TagsTable from '../components/details_page/tags_table.vue';
+import TagsList from '../components/details_page/tags_list.vue';
import TagsLoader from '../components/details_page/tags_loader.vue';
import EmptyTagsState from '../components/details_page/empty_tags_state.vue';
@@ -24,7 +24,7 @@ export default {
DetailsHeader,
GlPagination,
DeleteModal,
- TagsTable,
+ TagsList,
TagsLoader,
EmptyTagsState,
},
@@ -65,10 +65,8 @@ export default {
},
methods: {
...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']),
- deleteTags(toBeDeletedList) {
- this.itemsToBeDeleted = toBeDeletedList.map(name => ({
- ...this.tags.find(t => t.name === name),
- }));
+ deleteTags(toBeDeleted) {
+ this.itemsToBeDeleted = this.tags.filter(tag => toBeDeleted[tag.name]);
this.track('click_button');
this.$refs.deleteModal.show();
},
@@ -114,24 +112,21 @@ export default {
</script>
<template>
- <div v-gl-resize-observer="handleResize" class="my-3 w-100 slide-enter-to-element">
+ <div v-gl-resize-observer="handleResize" class="gl-my-3 gl-w-full slide-enter-to-element">
<delete-alert
v-model="deleteAlertType"
:garbage-collection-help-page-path="config.garbageCollectionHelpPagePath"
:is-admin="config.isAdmin"
- class="my-2"
+ class="gl-my-2"
/>
<details-header :image-name="imageName" />
- <tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags">
- <template #empty>
- <empty-tags-state :no-containers-image="config.noContainersImage" />
- </template>
- <template #loader>
- <tags-loader v-once />
- </template>
- </tags-table>
+ <tags-loader v-if="isLoading" />
+ <template v-else>
+ <empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" />
+ <tags-list v-else :tags="tags" :is-desktop="isDesktop" @delete="deleteTags" />
+ </template>
<gl-pagination
v-if="!isLoading"
@@ -140,7 +135,7 @@ export default {
:per-page="tagsPagination.perPage"
:total-items="tagsPagination.total"
align="center"
- class="w-100"
+ class="gl-w-full gl-mt-3"
/>
<delete-modal
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index e8a26dc58f2..1d353651c38 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -217,7 +217,6 @@ export default {
:svg-path="config.noContainersImage"
data-testid="emptySearch"
:title="$options.i18n.EMPTY_RESULT_TITLE"
- class="container-message"
>
<template #description>
{{ $options.i18n.EMPTY_RESULT_MESSAGE }}
diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
index b4a59fd0178..2ee7bbef4c6 100644
--- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
+++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
@@ -1,11 +1,16 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { s__ } from '~/locale';
import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants';
import SettingsForm from './settings_form.vue';
+import {
+ UNAVAILABLE_FEATURE_TITLE,
+ UNAVAILABLE_FEATURE_INTRO_TEXT,
+ UNAVAILABLE_USER_FEATURE_TEXT,
+ UNAVAILABLE_ADMIN_FEATURE_TEXT,
+} from '../constants';
export default {
components: {
@@ -15,17 +20,9 @@ export default {
GlLink,
},
i18n: {
- unavailableFeatureTitle: s__(
- `ContainerRegistry|Container Registry tag expiration and retention policy is disabled`,
- ),
- unavailableFeatureIntroText: s__(
- `ContainerRegistry|The Container Registry tag expiration and retention policies for this project have not been enabled.`,
- ),
- unavailableUserFeatureText: s__(`ContainerRegistry|Please contact your administrator.`),
- unavailableAdminFeatureText: s__(
- `ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`,
- ),
- fetchSettingsErrorText: FETCH_SETTINGS_ERROR_MESSAGE,
+ UNAVAILABLE_FEATURE_TITLE,
+ UNAVAILABLE_FEATURE_INTRO_TEXT,
+ FETCH_SETTINGS_ERROR_MESSAGE,
},
data() {
return {
@@ -42,9 +39,7 @@ export default {
return this.isDisabled && !this.fetchSettingsError;
},
unavailableFeatureMessage() {
- return this.isAdmin
- ? this.$options.i18n.unavailableAdminFeatureText
- : this.$options.i18n.unavailableUserFeatureText;
+ return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
},
},
mounted() {
@@ -60,39 +55,24 @@ export default {
<template>
<div>
- <p>
- {{ s__('ContainerRegistry|Tag expiration policy is designed to:') }}
- </p>
- <ul>
- <li>{{ s__('ContainerRegistry|Keep and protect the images that matter most.') }}</li>
- <li>
- {{
- s__(
- "ContainerRegistry|Automatically remove extra images that aren't designed to be kept.",
- )
- }}
- </li>
- </ul>
<settings-form v-if="showSettingForm" />
<template v-else>
<gl-alert
v-if="showDisabledFormMessage"
:dismissible="false"
- :title="$options.i18n.unavailableFeatureTitle"
+ :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE"
variant="tip"
>
- {{ $options.i18n.unavailableFeatureIntroText }}
+ {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }}
<gl-sprintf :message="unavailableFeatureMessage">
<template #link="{ content }">
- <gl-link :href="adminSettingsPath" target="_blank">
- {{ content }}
- </gl-link>
+ <gl-link :href="adminSettingsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
<gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false">
- <gl-sprintf :message="$options.i18n.fetchSettingsErrorText" />
+ <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" />
</gl-alert>
</template>
</div>
diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue
index afd502109bf..f129922c1d2 100644
--- a/app/assets/javascripts/registry/settings/components/settings_form.vue
+++ b/app/assets/javascripts/registry/settings/components/settings_form.vue
@@ -1,13 +1,15 @@
<script>
+import { get } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlCard, GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
import Tracking from '~/tracking';
+import { mapComputed } from '~/vuex_shared/bindings';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
} from '../../shared/constants';
-import { mapComputed } from '~/vuex_shared/bindings';
import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue';
+import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants';
export default {
components: {
@@ -21,12 +23,17 @@ export default {
cols: 3,
align: 'right',
},
+ i18n: {
+ CLEANUP_POLICY_CARD_HEADER,
+ SET_CLEANUP_POLICY_BUTTON,
+ },
data() {
return {
tracking: {
label: 'docker_container_retention_and_expiration_policies',
},
- formIsValid: true,
+ fieldsAreValid: true,
+ apiErrors: null,
};
},
computed: {
@@ -34,7 +41,7 @@ export default {
...mapGetters({ isEdited: 'getIsEdited' }),
...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'),
isSubmitButtonDisabled() {
- return !this.formIsValid || this.isLoading;
+ return !this.fieldsAreValid || this.isLoading;
},
isCancelButtonDisabled() {
return !this.isEdited || this.isLoading;
@@ -44,13 +51,35 @@ export default {
...mapActions(['resetSettings', 'saveSettings']),
reset() {
this.track('reset_form');
+ this.apiErrors = null;
this.resetSettings();
},
+ setApiErrors(response) {
+ const messages = get(response, 'data.message', []);
+
+ this.apiErrors = Object.keys(messages).reduce((acc, curr) => {
+ if (curr.startsWith('container_expiration_policy.')) {
+ const key = curr.replace('container_expiration_policy.', '');
+ acc[key] = get(messages, [curr, 0], '');
+ }
+ return acc;
+ }, {});
+ },
submit() {
this.track('submit_form');
+ this.apiErrors = null;
this.saveSettings()
.then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }))
- .catch(() => this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }));
+ .catch(({ response }) => {
+ this.setApiErrors(response);
+ this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' });
+ });
+ },
+ onModelChange(changePayload) {
+ this.settings = changePayload.newValue;
+ if (this.apiErrors) {
+ this.apiErrors[changePayload.modified] = undefined;
+ }
},
},
};
@@ -60,23 +89,25 @@ export default {
<form ref="form-element" @submit.prevent="submit" @reset.prevent="reset">
<gl-card>
<template #header>
- {{ s__('ContainerRegistry|Tag expiration policy') }}
+ {{ $options.i18n.CLEANUP_POLICY_CARD_HEADER }}
</template>
<template #default>
<expiration-policy-fields
- v-model="settings"
+ :value="settings"
:form-options="formOptions"
:is-loading="isLoading"
- @validated="formIsValid = true"
- @invalidated="formIsValid = false"
+ :api-errors="apiErrors"
+ @validated="fieldsAreValid = true"
+ @invalidated="fieldsAreValid = false"
+ @input="onModelChange"
/>
</template>
<template #footer>
- <div class="d-flex justify-content-end">
+ <div class="gl-display-flex gl-justify-content-end">
<gl-deprecated-button
ref="cancel-button"
type="reset"
- class="mr-2 d-block"
+ class="gl-mr-3 gl-display-block"
:disabled="isCancelButtonDisabled"
>
{{ __('Cancel') }}
@@ -86,10 +117,10 @@ export default {
type="submit"
:disabled="isSubmitButtonDisabled"
variant="success"
- class="d-flex justify-content-center align-items-center js-no-auto-disable"
+ class="gl-display-flex gl-justify-content-center gl-align-items-center js-no-auto-disable"
>
- {{ __('Save expiration policy') }}
- <gl-loading-icon v-if="isLoading" class="ml-2" />
+ {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
+ <gl-loading-icon v-if="isLoading" class="gl-ml-3" />
</gl-deprecated-button>
</div>
</template>
diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js
new file mode 100644
index 00000000000..e790658f491
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/constants.js
@@ -0,0 +1,14 @@
+import { s__, __ } from '~/locale';
+
+export const SET_CLEANUP_POLICY_BUTTON = s__('ContainerRegistry|Set cleanup policy');
+export const CLEANUP_POLICY_CARD_HEADER = s__('ContainerRegistry|Tag expiration policy');
+export const UNAVAILABLE_FEATURE_TITLE = s__(
+ `ContainerRegistry|Cleanup policy for tags is disabled`,
+);
+export const UNAVAILABLE_FEATURE_INTRO_TEXT = s__(
+ `ContainerRegistry|This project's cleanup policy for tags is not enabled.`,
+);
+export const UNAVAILABLE_USER_FEATURE_TEXT = __(`Please contact your administrator.`);
+export const UNAVAILABLE_ADMIN_FEATURE_TEXT = s__(
+ `ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`,
+);
diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
index 04a547db07e..1ff2f6f99e5 100644
--- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
+++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
@@ -34,6 +34,11 @@ export default {
required: false,
default: () => ({}),
},
+ apiErrors: {
+ type: Object,
+ required: false,
+ default: null,
+ },
isLoading: {
type: Boolean,
required: false,
@@ -56,9 +61,8 @@ export default {
},
},
i18n: {
- textAreaInvalidFeedback: TEXT_AREA_INVALID_FEEDBACK,
- enableToggleLabel: ENABLE_TOGGLE_LABEL,
- enableToggleDescription: ENABLE_TOGGLE_DESCRIPTION,
+ ENABLE_TOGGLE_LABEL,
+ ENABLE_TOGGLE_DESCRIPTION,
},
selectList: [
{
@@ -86,7 +90,6 @@ export default {
label: NAME_REGEX_LABEL,
model: 'name_regex',
placeholder: NAME_REGEX_PLACEHOLDER,
- stateVariable: 'nameRegexState',
description: NAME_REGEX_DESCRIPTION,
},
{
@@ -94,7 +97,6 @@ export default {
label: NAME_REGEX_KEEP_LABEL,
model: 'name_regex_keep',
placeholder: NAME_REGEX_KEEP_PLACEHOLDER,
- stateVariable: 'nameKeepRegexState',
description: NAME_REGEX_KEEP_DESCRIPTION,
},
],
@@ -111,16 +113,34 @@ export default {
policyEnabledText() {
return this.enabled ? ENABLED_TEXT : DISABLED_TEXT;
},
- textAreaState() {
+ textAreaValidation() {
+ const nameRegexErrors =
+ this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex);
+ const nameKeepRegexErrors =
+ this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep);
+
return {
- nameRegexState: this.validateNameRegex(this.name_regex),
- nameKeepRegexState: this.validateNameRegex(this.name_regex_keep),
+ /*
+ * The state has this form:
+ * null: gray border, no message
+ * true: green border, no message ( because none is configured)
+ * false: red border, error message
+ * So in this function we keep null if the are no message otherwise we 'invert' the error message
+ */
+ name_regex: {
+ state: nameRegexErrors === null ? null : !nameRegexErrors,
+ message: nameRegexErrors,
+ },
+ name_regex_keep: {
+ state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors,
+ message: nameKeepRegexErrors,
+ },
};
},
fieldsValidity() {
return (
- this.textAreaState.nameRegexState !== false &&
- this.textAreaState.nameKeepRegexState !== false
+ this.textAreaValidation.name_regex.state !== false &&
+ this.textAreaValidation.name_regex_keep.state !== false
);
},
isFormElementDisabled() {
@@ -140,8 +160,11 @@ export default {
},
},
methods: {
- validateNameRegex(value) {
- return value ? value.length <= NAME_REGEX_LENGTH : null;
+ validateRegexLength(value) {
+ if (!value) {
+ return null;
+ }
+ return value.length <= NAME_REGEX_LENGTH ? '' : TEXT_AREA_INVALID_FEEDBACK;
},
idGenerator(id) {
return `${id}_${this.uniqueId}`;
@@ -154,22 +177,22 @@ export default {
</script>
<template>
- <div ref="form-elements" class="lh-2">
+ <div ref="form-elements" class="gl-line-height-20">
<gl-form-group
:id="idGenerator('expiration-policy-toggle-group')"
:label-cols="labelCols"
:label-align="labelAlign"
:label-for="idGenerator('expiration-policy-toggle')"
- :label="$options.i18n.enableToggleLabel"
+ :label="$options.i18n.ENABLE_TOGGLE_LABEL"
>
- <div class="d-flex align-items-start">
+ <div class="gl-display-flex">
<gl-toggle
:id="idGenerator('expiration-policy-toggle')"
v-model="enabled"
:disabled="isLoading"
/>
- <span class="mb-2 ml-1 lh-2">
- <gl-sprintf :message="$options.i18n.enableToggleDescription">
+ <span class="gl-mb-3 gl-ml-3 gl-line-height-20">
+ <gl-sprintf :message="$options.i18n.ENABLE_TOGGLE_DESCRIPTION">
<template #toggleStatus>
<strong>{{ policyEnabledText }}</strong>
</template>
@@ -210,8 +233,8 @@ export default {
:label-cols="labelCols"
:label-align="labelAlign"
:label-for="idGenerator(textarea.name)"
- :state="textAreaState[textarea.stateVariable]"
- :invalid-feedback="$options.i18n.textAreaInvalidFeedback"
+ :state="textAreaValidation[textarea.model].state"
+ :invalid-feedback="textAreaValidation[textarea.model].message"
>
<template #label>
<gl-sprintf :message="textarea.label">
@@ -224,7 +247,7 @@ export default {
:id="idGenerator(textarea.name)"
:value="value[textarea.model]"
:placeholder="textarea.placeholder"
- :state="textAreaState[textarea.stateVariable]"
+ :state="textAreaValidation[textarea.model].state"
:disabled="isFormElementDisabled"
trim
@input="updateModel($event, textarea.model)"
diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js
index 4689d01b1c8..36d55c7610e 100644
--- a/app/assets/javascripts/registry/shared/constants.js
+++ b/app/assets/javascripts/registry/shared/constants.js
@@ -1,29 +1,29 @@
import { s__, __ } from '~/locale';
export const FETCH_SETTINGS_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while fetching the expiration policy.',
+ 'ContainerRegistry|Something went wrong while fetching the cleanup policy.',
);
export const UPDATE_SETTINGS_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while updating the expiration policy.',
+ 'ContainerRegistry|Something went wrong while updating the cleanup policy.',
);
export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__(
- 'ContainerRegistry|Expiration policy successfully saved.',
+ 'ContainerRegistry|Cleanup policy successfully saved.',
);
export const NAME_REGEX_LENGTH = 255;
-export const ENABLED_TEXT = __('enabled');
-export const DISABLED_TEXT = __('disabled');
+export const ENABLED_TEXT = __('Enabled');
+export const DISABLED_TEXT = __('Disabled');
-export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Expiration policy:');
+export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Cleanup policy:');
export const ENABLE_TOGGLE_DESCRIPTION = s__(
- 'ContainerRegistry|Docker tag expiration policy is %{toggleStatus}',
+ 'ContainerRegistry|%{toggleStatus} - Tags matching the patterns defined below will be scheduled for deletion',
);
export const TEXT_AREA_INVALID_FEEDBACK = s__(
- 'ContainerRegistry|The value of this input should be less than 255 characters',
+ 'ContainerRegistry|The value of this input should be less than 256 characters',
);
export const EXPIRATION_INTERVAL_LABEL = s__('ContainerRegistry|Expiration interval:');
@@ -34,12 +34,12 @@ export const NAME_REGEX_LABEL = s__(
);
export const NAME_REGEX_PLACEHOLDER = '.*';
export const NAME_REGEX_DESCRIPTION = s__(
- 'ContainerRegistry|Regular expressions such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}',
+ 'ContainerRegistry|Wildcards such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}',
);
export const NAME_REGEX_KEEP_LABEL = s__(
'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}be preserved:%{italicEnd}',
);
export const NAME_REGEX_KEEP_PLACEHOLDER = '';
export const NAME_REGEX_KEEP_DESCRIPTION = s__(
- 'ContainerRegistry|Regular expressions such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported',
+ 'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported',
);
diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js
index d85a3ad28c2..a7377773842 100644
--- a/app/assets/javascripts/registry/shared/utils.js
+++ b/app/assets/javascripts/registry/shared/utils.js
@@ -11,7 +11,7 @@ export const mapComputedToEvent = (list, root) => {
return this[root][e];
},
set(value) {
- this.$emit('input', { ...this[root], [e]: value });
+ this.$emit('input', { newValue: { ...this[root], [e]: value }, modified: e });
},
};
});
diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
index 05803ba09ab..15e9b8559d4 100644
--- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
@@ -82,7 +82,7 @@ export default {
{{ __('Related merge requests') }}
</span>
<div v-if="totalCount" class="d-inline-flex lh-100 align-middle">
- <div class="mr-count-badge border-width-1px border-style-solid border-color-default">
+ <div class="mr-count-badge gl-display-inline-flex">
<div class="mr-count-badge-count">
<svg class="s16 mr-1 text-secondary">
<icon name="merge-request" class="mr-1 text-secondary" />
diff --git a/app/assets/javascripts/releases/components/app_new.vue b/app/assets/javascripts/releases/components/app_new.vue
new file mode 100644
index 00000000000..563f76b3281
--- /dev/null
+++ b/app/assets/javascripts/releases/components/app_new.vue
@@ -0,0 +1,9 @@
+<script>
+export default {
+ name: 'ReleaseNewApp',
+ components: {},
+};
+</script>
+<template>
+ <div></div>
+</template>
diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue
index d521edcc361..0e65d722952 100644
--- a/app/assets/javascripts/releases/components/app_show.vue
+++ b/app/assets/javascripts/releases/components/app_show.vue
@@ -21,7 +21,7 @@ export default {
};
</script>
<template>
- <div class="prepend-top-default">
+ <div class="gl-mt-3">
<gl-skeleton-loading v-if="isFetchingRelease" />
<release-block v-else-if="!fetchError" :release="release" />
diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue
index 2cc15777343..6468e2ded62 100644
--- a/app/assets/javascripts/releases/components/evidence_block.vue
+++ b/app/assets/javascripts/releases/components/evidence_block.vue
@@ -59,7 +59,7 @@ export default {
<template>
<div>
- <div class="card-text prepend-top-default">
+ <div class="card-text gl-mt-3">
<b>{{ __('Evidence collection') }}</b>
</div>
<div v-for="(evidence, index) in evidences" :key="evidenceTitle(index)" class="mb-2">
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index adb0e69b786..e0061d88ccb 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -108,7 +108,7 @@ export default {
<release-block-assets v-if="shouldRenderAssets" :assets="assets" />
<evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" />
- <div ref="gfm-content" class="card-text prepend-top-default">
+ <div ref="gfm-content" class="card-text gl-mt-3">
<div class="md" v-html="release.descriptionHtml"></div>
</div>
</div>
diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue
index e07646e9a9f..ab29ceb0ce6 100644
--- a/app/assets/javascripts/releases/components/release_block_assets.vue
+++ b/app/assets/javascripts/releases/components/release_block_assets.vue
@@ -4,7 +4,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ASSET_LINK_TYPE } from '../constants';
import { __, s__, sprintf } from '~/locale';
-import { difference } from 'lodash';
+import { difference, get } from 'lodash';
export default {
name: 'ReleaseBlockAssets',
@@ -54,7 +54,7 @@ export default {
sections() {
return [
{
- links: this.assets.sources.map(s => ({
+ links: get(this.assets, 'sources', []).map(s => ({
url: s.url,
name: sprintf(__('Source code (%{fileExtension})'), { fileExtension: s.format }),
})),
@@ -96,7 +96,7 @@ export default {
</script>
<template>
- <div class="card-text prepend-top-default">
+ <div class="card-text gl-mt-3">
<template v-if="glFeatures.releaseAssetLinkType">
<gl-button
data-testid="accordion-button"
@@ -157,7 +157,7 @@ export default {
<ul v-if="assets.links.length" class="pl-0 mb-0 gl-mt-3 list-unstyled js-assets-list">
<li v-for="link in assets.links" :key="link.name" class="gl-mb-3">
<gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.directAssetUrl">
- <icon name="package" class="align-middle append-right-4 align-text-bottom" />
+ <icon name="package" class="align-middle gl-mr-2 align-text-bottom" />
{{ link.name }}
<span v-if="link.external" data-testid="external-link-indicator">{{
__('(external source)')
@@ -174,7 +174,7 @@ export default {
aria-haspopup="true"
aria-expanded="false"
>
- <icon name="doc-code" class="align-top append-right-4" />
+ <icon name="doc-code" class="align-top gl-mr-2" />
{{ __('Source code') }}
<icon name="chevron-down" />
</button>
diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue
index ed49841757a..310fba0fe76 100644
--- a/app/assets/javascripts/releases/components/release_block_header.vue
+++ b/app/assets/javascripts/releases/components/release_block_header.vue
@@ -56,7 +56,7 @@ export default {
v-gl-tooltip
category="primary"
variant="default"
- class="append-right-10 js-edit-button ml-2 pb-2"
+ class="gl-mr-3 js-edit-button ml-2 pb-2"
:title="__('Edit this release')"
:href="editLink"
>
diff --git a/app/assets/javascripts/releases/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue
index a3377ce044a..861c2e11798 100644
--- a/app/assets/javascripts/releases/components/release_block_metadata.vue
+++ b/app/assets/javascripts/releases/components/release_block_metadata.vue
@@ -75,7 +75,7 @@ export default {
<release-block-milestones v-if="shouldRenderMilestones" :milestones="release.milestones" />
- <div class="append-right-4">
+ <div class="gl-mr-2">
&bull;
<span
v-gl-tooltip.bottom
diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
index 4f75e15a149..b16ae400d6b 100644
--- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue
+++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
@@ -126,12 +126,12 @@ export default {
v-gl-tooltip
:title="milestone.description"
:href="milestone.webUrl"
- class="append-right-4"
+ class="gl-mr-2"
>
{{ milestone.title }}
</gl-link>
<template v-if="shouldRenderBullet(index)">
- <span :key="'bullet-' + milestone.id" class="append-right-4">&bull;</span>
+ <span :key="'bullet-' + milestone.id" class="gl-mr-2">&bull;</span>
</template>
<template v-if="shouldRenderShowMoreLink(index)">
<gl-button :key="'more-button-' + milestone.id" variant="link" @click="toggleShowAll">
diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js
new file mode 100644
index 00000000000..eb02c194c59
--- /dev/null
+++ b/app/assets/javascripts/releases/mount_new.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import ReleaseNewApp from './components/app_new.vue';
+import createStore from './stores';
+import createDetailModule from './stores/modules/detail';
+
+export default () => {
+ const el = document.getElementById('js-new-release-page');
+
+ const store = createStore({
+ modules: {
+ detail: createDetailModule(el.dataset),
+ },
+ });
+
+ return new Vue({
+ el,
+ store,
+ render: h => h(ReleaseNewApp),
+ });
+};
diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js
index 6d0d102c719..966c1c00ef5 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/state.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/state.js
@@ -1,17 +1,17 @@
export default ({
projectId,
- tagName,
- releasesPagePath,
markdownDocsPath,
markdownPreviewPath,
updateReleaseApiDocsPath,
releaseAssetsDocsPath,
manageMilestonesPath,
newMilestonePath,
+
+ tagName = null,
+ releasesPagePath = null,
+ defaultBranch = null,
}) => ({
projectId,
- tagName,
- releasesPagePath,
markdownDocsPath,
markdownPreviewPath,
updateReleaseApiDocsPath,
@@ -19,6 +19,10 @@ export default ({
manageMilestonesPath,
newMilestonePath,
+ tagName,
+ releasesPagePath,
+ defaultBranch,
+
/** The Release object */
release: null,
diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
index 653dcced98b..ed4f3c4e0fe 100644
--- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
+++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
@@ -36,13 +36,9 @@ export default {
};
</script>
<template>
- <div class="report-block-list-issue-description prepend-top-5 append-bottom-5">
+ <div class="report-block-list-issue-description gl-mt-2 gl-mb-2">
<div ref="accessibility-issue-description" class="report-block-list-issue-description-text">
- <div
- v-if="isNew"
- ref="accessibility-issue-is-new-badge"
- class="badge badge-danger append-right-5"
- >
+ <div v-if="isNew" ref="accessibility-issue-is-new-badge" class="badge badge-danger gl-mr-2">
{{ s__('AccessibilityReport|New') }}
</div>
<div>
@@ -55,7 +51,7 @@ export default {
)
}}
<gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{
- s__('AccessibilityReport|Learn More')
+ s__('AccessibilityReport|Learn more')
}}</gl-link>
</div>
{{ sprintf(s__('AccessibilityReport|Message: %{message}'), { message: issue.message }) }}
diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
new file mode 100644
index 00000000000..0c758ee2b5c
--- /dev/null
+++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
@@ -0,0 +1,42 @@
+<script>
+/**
+ * Renders Code quality body text
+ * Fixed: [name] in [link]:[line]
+ */
+import ReportLink from '~/reports/components/report_link.vue';
+import { STATUS_SUCCESS } from '~/reports/constants';
+
+export default {
+ name: 'CodequalityIssueBody',
+
+ components: {
+ ReportLink,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ issue: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ isStatusSuccess() {
+ return this.status === STATUS_SUCCESS;
+ },
+ },
+};
+</script>
+<template>
+ <div class="report-block-list-issue-description gl-mt-2 gl-mb-2">
+ <div class="report-block-list-issue-description-text">
+ <template v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</template>
+
+ {{ issue.name }}
+ </div>
+
+ <report-link v-if="issue.path" :issue="issue" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
new file mode 100644
index 00000000000..f3d5b1a80f8
--- /dev/null
+++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
@@ -0,0 +1,83 @@
+<script>
+import { mapState, mapActions, mapGetters } from 'vuex';
+import { componentNames } from '~/reports/components/issue_body';
+import { s__, sprintf } from '~/locale';
+import ReportSection from '~/reports/components/report_section.vue';
+import createStore from './store';
+
+export default {
+ name: 'GroupedCodequalityReportsApp',
+ store: createStore(),
+ components: {
+ ReportSection,
+ },
+ props: {
+ headPath: {
+ type: String,
+ required: true,
+ },
+ headBlobPath: {
+ type: String,
+ required: true,
+ },
+ basePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ baseBlobPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ codequalityHelpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ componentNames,
+ computed: {
+ ...mapState(['newIssues', 'resolvedIssues']),
+ ...mapGetters([
+ 'hasCodequalityIssues',
+ 'codequalityStatus',
+ 'codequalityText',
+ 'codequalityPopover',
+ ]),
+ },
+ created() {
+ this.setPaths({
+ basePath: this.basePath,
+ headPath: this.headPath,
+ baseBlobPath: this.baseBlobPath,
+ headBlobPath: this.headBlobPath,
+ helpPath: this.codequalityHelpPath,
+ });
+
+ this.fetchReports();
+ },
+ methods: {
+ ...mapActions(['fetchReports', 'setPaths']),
+ },
+ loadingText: sprintf(s__('ciReport|Loading %{reportName} report'), {
+ reportName: 'codeclimate',
+ }),
+ errorText: sprintf(s__('ciReport|Failed to load %{reportName} report'), {
+ reportName: 'codeclimate',
+ }),
+};
+</script>
+<template>
+ <report-section
+ :status="codequalityStatus"
+ :loading-text="$options.loadingText"
+ :error-text="$options.errorText"
+ :success-text="codequalityText"
+ :unresolved-issues="newIssues"
+ :resolved-issues="resolvedIssues"
+ :has-issues="hasCodequalityIssues"
+ :component="$options.componentNames.CodequalityIssueBody"
+ :popover-options="codequalityPopover"
+ class="js-codequality-widget mr-widget-border-top mr-report"
+ />
+</template>
diff --git a/app/assets/javascripts/reports/codequality_report/store/actions.js b/app/assets/javascripts/reports/codequality_report/store/actions.js
new file mode 100644
index 00000000000..bf84d27b5ea
--- /dev/null
+++ b/app/assets/javascripts/reports/codequality_report/store/actions.js
@@ -0,0 +1,30 @@
+import axios from '~/lib/utils/axios_utils';
+import * as types from './mutation_types';
+import { parseCodeclimateMetrics, doCodeClimateComparison } from './utils/codequality_comparison';
+
+export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths);
+
+export const fetchReports = ({ state, dispatch, commit }) => {
+ commit(types.REQUEST_REPORTS);
+
+ if (!state.basePath) {
+ return dispatch('receiveReportsError');
+ }
+ return Promise.all([axios.get(state.headPath), axios.get(state.basePath)])
+ .then(results =>
+ doCodeClimateComparison(
+ parseCodeclimateMetrics(results[0].data, state.headBlobPath),
+ parseCodeclimateMetrics(results[1].data, state.baseBlobPath),
+ ),
+ )
+ .then(data => dispatch('receiveReportsSuccess', data))
+ .catch(() => dispatch('receiveReportsError'));
+};
+
+export const receiveReportsSuccess = ({ commit }, data) => {
+ commit(types.RECEIVE_REPORTS_SUCCESS, data);
+};
+
+export const receiveReportsError = ({ commit }) => {
+ commit(types.RECEIVE_REPORTS_ERROR);
+};
diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/reports/codequality_report/store/getters.js
new file mode 100644
index 00000000000..5df58c7f85f
--- /dev/null
+++ b/app/assets/javascripts/reports/codequality_report/store/getters.js
@@ -0,0 +1,58 @@
+import { LOADING, ERROR, SUCCESS } from '../../constants';
+import { sprintf, __, s__, n__ } from '~/locale';
+
+export const hasCodequalityIssues = state =>
+ Boolean(state.newIssues?.length || state.resolvedIssues?.length);
+
+export const codequalityStatus = state => {
+ if (state.isLoading) {
+ return LOADING;
+ }
+ if (state.hasError) {
+ return ERROR;
+ }
+
+ return SUCCESS;
+};
+
+export const codequalityText = state => {
+ const { newIssues, resolvedIssues } = state;
+ const text = [];
+
+ if (!newIssues.length && !resolvedIssues.length) {
+ text.push(s__('ciReport|No changes to code quality'));
+ } else {
+ text.push(s__('ciReport|Code quality'));
+
+ if (resolvedIssues.length) {
+ text.push(n__(' improved on %d point', ' improved on %d points', resolvedIssues.length));
+ }
+
+ if (newIssues.length && resolvedIssues.length) {
+ text.push(__(' and'));
+ }
+
+ if (newIssues.length) {
+ text.push(n__(' degraded on %d point', ' degraded on %d points', newIssues.length));
+ }
+ }
+
+ return text.join('');
+};
+
+export const codequalityPopover = state => {
+ if (state.headPath && !state.basePath) {
+ return {
+ title: s__('ciReport|Base pipeline codequality artifact not found'),
+ content: sprintf(
+ s__('ciReport|%{linkStartTag}Learn more about codequality reports %{linkEndTag}'),
+ {
+ linkStartTag: `<a href="${state.helpPath}" target="_blank" rel="noopener noreferrer">`,
+ linkEndTag: '<i class="fa fa-external-link" aria-hidden="true"></i></a>',
+ },
+ false,
+ ),
+ };
+ }
+ return {};
+};
diff --git a/app/assets/javascripts/reports/codequality_report/store/index.js b/app/assets/javascripts/reports/codequality_report/store/index.js
new file mode 100644
index 00000000000..047964260ad
--- /dev/null
+++ b/app/assets/javascripts/reports/codequality_report/store/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export default initialState =>
+ new Vuex.Store({
+ actions,
+ getters,
+ mutations,
+ state: state(initialState),
+ });
diff --git a/app/assets/javascripts/reports/codequality_report/store/mutation_types.js b/app/assets/javascripts/reports/codequality_report/store/mutation_types.js
new file mode 100644
index 00000000000..c362c973ae1
--- /dev/null
+++ b/app/assets/javascripts/reports/codequality_report/store/mutation_types.js
@@ -0,0 +1,5 @@
+export const SET_PATHS = 'SET_PATHS';
+
+export const REQUEST_REPORTS = 'REQUEST_REPORTS';
+export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS';
+export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR';
diff --git a/app/assets/javascripts/reports/codequality_report/store/mutations.js b/app/assets/javascripts/reports/codequality_report/store/mutations.js
new file mode 100644
index 00000000000..7ef4f3ce2db
--- /dev/null
+++ b/app/assets/javascripts/reports/codequality_report/store/mutations.js
@@ -0,0 +1,24 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_PATHS](state, paths) {
+ state.basePath = paths.basePath;
+ state.headPath = paths.headPath;
+ state.baseBlobPath = paths.baseBlobPath;
+ state.headBlobPath = paths.headBlobPath;
+ state.helpPath = paths.helpPath;
+ },
+ [types.REQUEST_REPORTS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_REPORTS_SUCCESS](state, data) {
+ state.hasError = false;
+ state.isLoading = false;
+ state.newIssues = data.newIssues;
+ state.resolvedIssues = data.resolvedIssues;
+ },
+ [types.RECEIVE_REPORTS_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+};
diff --git a/app/assets/javascripts/reports/codequality_report/store/state.js b/app/assets/javascripts/reports/codequality_report/store/state.js
new file mode 100644
index 00000000000..38ab53b432e
--- /dev/null
+++ b/app/assets/javascripts/reports/codequality_report/store/state.js
@@ -0,0 +1,15 @@
+export default () => ({
+ basePath: null,
+ headPath: null,
+
+ baseBlobPath: null,
+ headBlobPath: null,
+
+ isLoading: false,
+ hasError: false,
+
+ newIssues: [],
+ resolvedIssues: [],
+
+ helpPath: null,
+});
diff --git a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js
new file mode 100644
index 00000000000..eba9e340c4e
--- /dev/null
+++ b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js
@@ -0,0 +1,41 @@
+import CodeQualityComparisonWorker from '../../workers/codequality_comparison_worker';
+
+export const parseCodeclimateMetrics = (issues = [], path = '') => {
+ return issues.map(issue => {
+ const parsedIssue = {
+ ...issue,
+ name: issue.description,
+ };
+
+ if (issue?.location?.path) {
+ let parseCodeQualityUrl = `${path}/${issue.location.path}`;
+ parsedIssue.path = issue.location.path;
+
+ if (issue?.location?.lines?.begin) {
+ parsedIssue.line = issue.location.lines.begin;
+ parseCodeQualityUrl += `#L${issue.location.lines.begin}`;
+ } else if (issue?.location?.positions?.begin?.line) {
+ parsedIssue.line = issue.location.positions.begin.line;
+ parseCodeQualityUrl += `#L${issue.location.positions.begin.line}`;
+ }
+
+ parsedIssue.urlPath = parseCodeQualityUrl;
+ }
+
+ return parsedIssue;
+ });
+};
+
+export const doCodeClimateComparison = (headIssues, baseIssues) => {
+ // Do these comparisons in worker threads to avoid blocking the main thread
+ return new Promise((resolve, reject) => {
+ const worker = new CodeQualityComparisonWorker();
+ worker.addEventListener('message', ({ data }) =>
+ data.newIssues && data.resolvedIssues ? resolve(data) : reject(data),
+ );
+ worker.postMessage({
+ headIssues,
+ baseIssues,
+ });
+ });
+};
diff --git a/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js b/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js
new file mode 100644
index 00000000000..fc55602f95c
--- /dev/null
+++ b/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js
@@ -0,0 +1,28 @@
+import { differenceBy } from 'lodash';
+
+const KEY_TO_FILTER_BY = 'fingerprint';
+
+// eslint-disable-next-line no-restricted-globals
+self.addEventListener('message', e => {
+ const { data } = e;
+
+ if (data === undefined) {
+ return null;
+ }
+
+ const { headIssues, baseIssues } = data;
+
+ if (!headIssues || !baseIssues) {
+ // eslint-disable-next-line no-restricted-globals
+ return self.postMessage({});
+ }
+
+ // eslint-disable-next-line no-restricted-globals
+ self.postMessage({
+ newIssues: differenceBy(headIssues, baseIssues, KEY_TO_FILTER_BY),
+ resolvedIssues: differenceBy(baseIssues, headIssues, KEY_TO_FILTER_BY),
+ });
+
+ // eslint-disable-next-line no-restricted-globals
+ return self.close();
+});
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
index a670cad5f9f..b8a8cb940e7 100644
--- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
@@ -6,7 +6,9 @@ import ReportSection from './report_section.vue';
import SummaryRow from './summary_row.vue';
import IssuesList from './issues_list.vue';
import Modal from './modal.vue';
+import { GlButton } from '@gitlab/ui';
import createStore from '../store';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { summaryTextBuilder, reportTextBuilder, statusIcon } from '../store/utils';
export default {
@@ -17,12 +19,19 @@ export default {
SummaryRow,
IssuesList,
Modal,
+ GlButton,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
endpoint: {
type: String,
required: true,
},
+ pipelinePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
componentNames,
computed: {
@@ -43,6 +52,12 @@ export default {
return summaryTextBuilder(s__('Reports|Test summary'), this.summary);
},
+ testTabURL() {
+ return `${this.pipelinePath}/test_report`;
+ },
+ showViewFullReport() {
+ return Boolean(this.glFeatures.junitPipelineView) && this.pipelinePath.length;
+ },
},
created() {
this.setEndpoint(this.endpoint);
@@ -98,6 +113,16 @@ export default {
:has-issues="reports.length > 0"
class="mr-widget-section grouped-security-reports mr-report"
>
+ <template v-if="showViewFullReport" #actionButtons>
+ <gl-button
+ :href="testTabURL"
+ icon="external-link"
+ data-testid="group-test-reports-full-link"
+ class="gl-mr-3"
+ >
+ {{ s__('ciReport|View full report') }}
+ </gl-button>
+ </template>
<template #body>
<div class="mr-widget-grouped-section report-block">
<template v-for="(report, i) in reports">
diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js
index e106e60951b..1e6dc4f8b78 100644
--- a/app/assets/javascripts/reports/components/issue_body.js
+++ b/app/assets/javascripts/reports/components/issue_body.js
@@ -1,12 +1,15 @@
import TestIssueBody from './test_issue_body.vue';
import AccessibilityIssueBody from '../accessibility_report/components/accessibility_issue_body.vue';
+import CodequalityIssueBody from '../codequality_report/components/codequality_issue_body.vue';
export const components = {
AccessibilityIssueBody,
+ CodequalityIssueBody,
TestIssueBody,
};
export const componentNames = {
AccessibilityIssueBody: AccessibilityIssueBody.name,
+ CodequalityIssueBody: CodequalityIssueBody.name,
TestIssueBody: TestIssueBody.name,
};
diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue
index 51062cd7928..1b47d03aa01 100644
--- a/app/assets/javascripts/reports/components/report_item.vue
+++ b/app/assets/javascripts/reports/components/report_item.vue
@@ -52,7 +52,7 @@ export default {
v-if="showReportSectionStatusIcon"
:status="status"
:status-icon-size="statusIconSize"
- class="append-right-default"
+ class="gl-mr-3"
/>
<component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" />
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 68956fc6d2b..63af8a5a9ac 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -91,6 +91,11 @@ export default {
required: false,
default: undefined,
},
+ shouldEmitToggleEvent: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
@@ -157,6 +162,9 @@ export default {
},
methods: {
toggleCollapsed() {
+ if (this.shouldEmitToggleEvent) {
+ this.$emit('toggleEvent');
+ }
this.isCollapsed = !this.isCollapsed;
},
},
@@ -171,7 +179,7 @@ export default {
<div>
{{ headerText }}
<slot :name="slotName"></slot>
- <popover v-if="hasPopover" :options="popoverOptions" class="prepend-left-5" />
+ <popover v-if="hasPopover" :options="popoverOptions" class="gl-ml-2" />
</div>
<slot name="subHeading"></slot>
</div>
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index b9fc902cd3a..3232c0edf96 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -21,7 +21,8 @@ export default {
props: {
summary: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
statusIcon: {
type: String,
@@ -45,7 +46,7 @@ export default {
</script>
<template>
<div class="report-block-list-issue report-block-list-issue-parent align-items-center">
- <div class="report-block-list-icon append-right-default">
+ <div class="report-block-list-icon gl-mr-3">
<gl-loading-icon
v-if="statusIcon === 'loading'"
css-class="report-block-list-loading-icon"
@@ -58,8 +59,8 @@ export default {
class="report-block-list-issue-description-text"
data-testid="test-summary-row-description"
>
- {{ summary
- }}<span v-if="popoverOptions" class="text-nowrap"
+ <slot name="summary">{{ summary }}</slot
+ ><span v-if="popoverOptions" class="text-nowrap"
>&nbsp;<popover v-if="popoverOptions" :options="popoverOptions" class="align-top" />
</span>
</div>
diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue
index c41238070b1..4e0631740d8 100644
--- a/app/assets/javascripts/reports/components/test_issue_body.vue
+++ b/app/assets/javascripts/reports/components/test_issue_body.vue
@@ -25,14 +25,14 @@ export default {
};
</script>
<template>
- <div class="report-block-list-issue-description prepend-top-5 append-bottom-5">
+ <div class="report-block-list-issue-description gl-mt-2 gl-mb-2">
<div class="report-block-list-issue-description-text" data-testid="test-issue-body-description">
<button
type="button"
class="btn-link btn-blank text-left break-link vulnerability-name-button"
@click="openModal({ issue })"
>
- <div v-if="isNew" class="badge badge-danger append-right-5">{{ s__('New') }}</div>
+ <div v-if="isNew" class="badge badge-danger gl-mr-2">{{ s__('New') }}</div>
{{ issue.name }}
</button>
</div>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index c8549180a25..5e0ad7acdfd 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -80,7 +80,7 @@ export default {
<table-header v-once />
<tbody>
<parent-row
- v-show="showParentRow"
+ v-if="showParentRow"
:commit-ref="escapedRef"
:path="path"
:loading-path="loadingPath"
@@ -97,6 +97,7 @@ export default {
:path="entry.flatPath"
:type="entry.type"
:url="entry.webUrl"
+ :mode="entry.mode"
:submodule-tree-url="entry.treeUrl"
:lfs-oid="entry.lfsOid"
:loading-path="loadingPath"
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index d5363016335..615e329f415 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -66,6 +66,11 @@ export default {
type: String,
required: true,
},
+ mode: {
+ type: String,
+ required: false,
+ default: '',
+ },
type: {
type: String,
required: true,
@@ -140,6 +145,7 @@ export default {
>
<file-icon
:file-name="fullPath"
+ :file-mode="mode"
:folder="isFolder"
:submodule="isSubmodule"
:loading="path === loadingPath"
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 7b34e9ef60d..59ba1caa8c9 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -5,7 +5,6 @@ import FileTable from './table/index.vue';
import getRefMixin from '../mixins/get_ref';
import getFiles from '../queries/getFiles.query.graphql';
import getProjectPath from '../queries/getProjectPath.query.graphql';
-import getVueFileListLfsBadge from '../queries/getVueFileListLfsBadge.query.graphql';
import FilePreview from './preview/index.vue';
import { readmeFile } from '../utils/readme';
@@ -21,9 +20,6 @@ export default {
projectPath: {
query: getProjectPath,
},
- vueFileListLfsBadge: {
- query: getVueFileListLfsBadge,
- },
},
props: {
path: {
@@ -47,7 +43,6 @@ export default {
blobs: [],
},
isLoadingFiles: false,
- vueFileListLfsBadge: false,
};
},
computed: {
@@ -82,7 +77,6 @@ export default {
path: this.path || '/',
nextPageCursor: this.nextPageCursor,
pageSize: PAGE_SIZE,
- vueLfsEnabled: this.vueFileListLfsBadge,
},
})
.then(({ data }) => {
diff --git a/app/assets/javascripts/repository/components/web_ide_link.vue b/app/assets/javascripts/repository/components/web_ide_link.vue
new file mode 100644
index 00000000000..6549d5a3878
--- /dev/null
+++ b/app/assets/javascripts/repository/components/web_ide_link.vue
@@ -0,0 +1,47 @@
+<script>
+import TreeActionLink from './tree_action_link.vue';
+import { __ } from '~/locale';
+import { webIDEUrl } from '~/lib/utils/url_utility';
+
+export default {
+ components: {
+ TreeActionLink,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ refSha: {
+ type: String,
+ required: true,
+ },
+ canPushCode: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ forkPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ showLinkToFork() {
+ return !this.canPushCode && this.forkPath;
+ },
+ text() {
+ return this.showLinkToFork ? __('Edit fork in Web IDE') : __('Web IDE');
+ },
+ path() {
+ const path = this.showLinkToFork ? this.forkPath : this.projectPath;
+ return webIDEUrl(`/${path}/edit/${this.refSha}/-/${this.$route.params.path || ''}`);
+ },
+ },
+};
+</script>
+
+<template>
+ <tree-action-link :path="path" :text="text" data-qa-selector="web_ide_button" />
+</template>
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
index 6640b636597..450a1571165 100644
--- a/app/assets/javascripts/repository/graphql.js
+++ b/app/assets/javascripts/repository/graphql.js
@@ -30,7 +30,7 @@ const defaultClient = createDefaultClient(
},
readme(_, { url }) {
return axios
- .get(url, { params: { viewer: 'rich', format: 'json' } })
+ .get(url, { params: { format: 'json', viewer: 'rich' } })
.then(({ data }) => ({ ...data, __typename: 'ReadmeFile' }));
},
},
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 6528e283372..4f80ab4ff5d 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -4,18 +4,26 @@ import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import LastCommit from './components/last_commit.vue';
import TreeActionLink from './components/tree_action_link.vue';
+import WebIdeLink from './components/web_ide_link.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
import apolloProvider from './graphql';
import { setTitle } from './utils/title';
import { updateFormAction } from './utils/dom';
import { parseBoolean } from '../lib/utils/common_utils';
-import { webIDEUrl } from '../lib/utils/url_utility';
import { __ } from '../locale';
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
const { dataset } = el;
- const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset;
+ const {
+ canPushCode,
+ projectPath,
+ projectShortPath,
+ forkPath,
+ ref,
+ escapedRef,
+ fullName,
+ } = dataset;
const router = createRouter(projectPath, escapedRef);
apolloProvider.clients.defaultClient.cache.writeData({
@@ -24,7 +32,6 @@ export default function setupVueRepositoryList() {
projectShortPath,
ref,
escapedRef,
- vueFileListLfsBadge: gon.features?.vueFileListLfsBadge || false,
commits: [],
},
});
@@ -118,11 +125,12 @@ export default function setupVueRepositoryList() {
el: webIdeLinkEl,
router,
render(h) {
- return h(TreeActionLink, {
+ return h(WebIdeLink, {
props: {
- path: webIDEUrl(`/${projectPath}/edit/${ref}/-/${this.$route.params.path || ''}`),
- text: __('Web IDE'),
- cssClass: 'qa-web-ide-button',
+ projectPath,
+ refSha: ref,
+ forkPath,
+ canPushCode: parseBoolean(canPushCode),
},
});
},
diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql
index 01ad72ef752..feb89df0492 100644
--- a/app/assets/javascripts/repository/queries/getFiles.query.graphql
+++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql
@@ -14,7 +14,6 @@ query getFiles(
$ref: String!
$pageSize: Int!
$nextPageCursor: String
- $vueLfsEnabled: Boolean = false
) {
project(fullPath: $projectPath) {
repository {
@@ -46,8 +45,9 @@ query getFiles(
edges {
node {
...TreeEntry
+ mode
webUrl
- lfsOid @include(if: $vueLfsEnabled)
+ lfsOid
}
}
pageInfo {
diff --git a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql
deleted file mode 100644
index eb21c1e73d8..00000000000
--- a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-query getVueFileListLfsBadge {
- vueFileListLfsBadge @client
-}
diff --git a/app/assets/javascripts/global_search_input.js b/app/assets/javascripts/search_autocomplete.js
index a7c121259d4..05e0b9e7089 100644
--- a/app/assets/javascripts/global_search_input.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,8 +1,10 @@
/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
import $ from 'jquery';
-import { throttle } from 'lodash';
+import { escape, throttle } from 'lodash';
import { s__, __, sprintf } from '~/locale';
+import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
+import axios from './lib/utils/axios_utils';
import {
isInGroupsPage,
isInProjectPage,
@@ -65,11 +67,15 @@ function setSearchOptions() {
}
}
-export class GlobalSearchInput {
- constructor({ wrap } = {}) {
+export class SearchAutocomplete {
+ constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) {
setSearchOptions();
this.bindEventContext();
this.wrap = wrap || $('.search');
+ this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts');
+ this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath');
+ this.projectId = projectId || (this.optsEl.data('autocompleteProjectId') || '');
+ this.projectRef = projectRef || (this.optsEl.data('autocompleteProjectRef') || '');
this.dropdown = this.wrap.find('.dropdown');
this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle');
this.dropdownMenu = this.dropdown.find('.dropdown-menu');
@@ -86,7 +92,7 @@ export class GlobalSearchInput {
// Only when user is logged in
if (gon.current_user_id) {
- this.createGlobalSearchInput();
+ this.createAutocomplete();
}
this.bindEvents();
@@ -111,7 +117,7 @@ export class GlobalSearchInput {
return (this.originalState = this.serializeState());
}
- createGlobalSearchInput() {
+ createAutocomplete() {
return this.searchInput.glDropdown({
filterInputBlur: false,
filterable: true,
@@ -143,17 +149,116 @@ export class GlobalSearchInput {
if (glDropdownInstance) {
glDropdownInstance.filter.options.callback(contents);
}
- this.enableDropdown();
+ this.enableAutocomplete();
}
return;
}
- const options = this.scopedSearchOptions(term);
+ // Prevent multiple ajax calls
+ if (this.loadingSuggestions) {
+ return;
+ }
- callback(options);
+ this.loadingSuggestions = true;
+
+ return axios
+ .get(this.autocompletePath, {
+ params: {
+ project_id: this.projectId,
+ project_ref: this.projectRef,
+ term,
+ },
+ })
+ .then(response => {
+ const options = this.scopedSearchOptions(term);
+
+ // List results
+ let lastCategory = null;
+ for (let i = 0, len = response.data.length; i < len; i += 1) {
+ const suggestion = response.data[i];
+ // Add group header before list each group
+ if (lastCategory !== suggestion.category) {
+ options.push({ type: 'separator' });
+ options.push({
+ type: 'header',
+ content: suggestion.category,
+ });
+ lastCategory = suggestion.category;
+ }
+
+ // Add the suggestion
+ options.push({
+ id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
+ icon: this.getAvatar(suggestion),
+ category: suggestion.category,
+ text: suggestion.label,
+ url: suggestion.url,
+ });
+ }
- this.highlightFirstRow();
- this.setScrollFade();
+ callback(options);
+
+ this.loadingSuggestions = false;
+ this.highlightFirstRow();
+ this.setScrollFade();
+ })
+ .catch(() => {
+ this.loadingSuggestions = false;
+ });
+ }
+
+ getCategoryContents() {
+ const userName = gon.current_username;
+ const { projectOptions, groupOptions, dashboardOptions } = gl;
+
+ // Get options
+ let options;
+ if (isInProjectPage() && projectOptions) {
+ options = projectOptions[getProjectSlug()];
+ } else if (isInGroupsPage() && groupOptions) {
+ options = groupOptions[getGroupSlug()];
+ } else if (dashboardOptions) {
+ options = dashboardOptions;
+ }
+
+ const { issuesPath, mrPath, name, issuesDisabled } = options;
+ const baseItems = [];
+
+ if (name) {
+ baseItems.push({
+ type: 'header',
+ content: `${name}`,
+ });
+ }
+
+ const issueItems = [
+ {
+ text: s__('SearchAutocomplete|Issues assigned to me'),
+ url: `${issuesPath}/?assignee_username=${userName}`,
+ },
+ {
+ text: s__("SearchAutocomplete|Issues I've created"),
+ url: `${issuesPath}/?author_username=${userName}`,
+ },
+ ];
+ const mergeRequestItems = [
+ {
+ text: s__('SearchAutocomplete|Merge requests assigned to me'),
+ url: `${mrPath}/?assignee_username=${userName}`,
+ },
+ {
+ text: s__("SearchAutocomplete|Merge requests I've created"),
+ url: `${mrPath}/?author_username=${userName}`,
+ },
+ ];
+
+ let items;
+ if (issuesDisabled) {
+ items = baseItems.concat(mergeRequestItems);
+ } else {
+ items = baseItems.concat(...issueItems, ...mergeRequestItems);
+ }
+ return items;
}
// Add option to proceed with the search for each
@@ -238,7 +343,7 @@ export class GlobalSearchInput {
});
}
- enableDropdown() {
+ enableAutocomplete() {
this.setScrollFade();
// No need to enable anything if user is not logged in
@@ -255,7 +360,7 @@ export class GlobalSearchInput {
}
onSearchInputChange() {
- this.enableDropdown();
+ this.enableAutocomplete();
}
onSearchInputKeyUp(e) {
@@ -264,7 +369,7 @@ export class GlobalSearchInput {
this.restoreOriginalState();
break;
case KEYCODE.ENTER:
- this.disableDropdown();
+ this.disableAutocomplete();
break;
default:
}
@@ -317,7 +422,7 @@ export class GlobalSearchInput {
return results;
}
- disableDropdown() {
+ disableAutocomplete() {
if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('js-autocomplete-disabled');
this.dropdownToggle.dropdown('toggle');
@@ -333,8 +438,16 @@ export class GlobalSearchInput {
onClick(item, $el, e) {
if (window.location.pathname.indexOf(item.url) !== -1) {
if (!e.metaKey) e.preventDefault();
+ /* eslint-disable-next-line @gitlab/require-i18n-strings */
+ if (item.category === 'Projects') {
+ this.projectInputEl.val(item.id);
+ }
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (item.category === 'Groups') {
+ this.groupInputEl.val(item.id);
+ }
$el.removeClass('is-active');
- this.disableDropdown();
+ this.disableAutocomplete();
return this.searchInput.val('').focus();
}
}
@@ -343,58 +456,20 @@ export class GlobalSearchInput {
this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0);
}
- getCategoryContents() {
- const userName = gon.current_username;
- const { projectOptions, groupOptions, dashboardOptions } = gl;
-
- // Get options
- let options;
- if (isInProjectPage() && projectOptions) {
- options = projectOptions[getProjectSlug()];
- } else if (isInGroupsPage() && groupOptions) {
- options = groupOptions[getGroupSlug()];
- } else if (dashboardOptions) {
- options = dashboardOptions;
+ getAvatar(item) {
+ if (!Object.hasOwnProperty.call(item, 'avatar_url')) {
+ return false;
}
- const { issuesPath, mrPath, name, issuesDisabled } = options;
- const baseItems = [];
-
- if (name) {
- baseItems.push({
- type: 'header',
- content: `${name}`,
- });
- }
+ const { label, id } = item;
+ const avatarUrl = item.avatar_url;
+ const avatar = avatarUrl
+ ? `<img class="search-item-avatar" src="${avatarUrl}" />`
+ : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle(
+ escape(label),
+ )}</div>`;
- const issueItems = [
- {
- text: s__('SearchAutocomplete|Issues assigned to me'),
- url: `${issuesPath}/?assignee_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Issues I've created"),
- url: `${issuesPath}/?author_username=${userName}`,
- },
- ];
- const mergeRequestItems = [
- {
- text: s__('SearchAutocomplete|Merge requests assigned to me'),
- url: `${mrPath}/?assignee_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Merge requests I've created"),
- url: `${mrPath}/?author_username=${userName}`,
- },
- ];
-
- let items;
- if (issuesDisabled) {
- items = baseItems.concat(mergeRequestItems);
- } else {
- items = baseItems.concat(...issueItems, ...mergeRequestItems);
- }
- return items;
+ return avatar;
}
isScrolledUp() {
@@ -420,6 +495,6 @@ export class GlobalSearchInput {
}
}
-export default function initGlobalSearchInput(opts) {
- return new GlobalSearchInput(opts);
+export default function initSearchAutocomplete(opts) {
+ return new SearchAutocomplete(opts);
}
diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue
index 089e0550583..c46dfb66afe 100644
--- a/app/assets/javascripts/serverless/components/environment_row.vue
+++ b/app/assets/javascripts/serverless/components/environment_row.vue
@@ -54,7 +54,7 @@ export default {
<div class="folder-toggle-wrap d-flex align-items-center">
<item-caret :is-group-open="isOpen" />
</div>
- <div class="group-text flex-grow title namespace-title prepend-left-default">
+ <div class="group-text flex-grow title namespace-title gl-ml-3">
{{ envName }}
</div>
</div>
diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue
index 2ac57ac5bcb..53c78b93254 100644
--- a/app/assets/javascripts/serverless/components/function_details.vue
+++ b/app/assets/javascripts/serverless/components/function_details.vue
@@ -71,7 +71,7 @@ export default {
<template>
<section id="serverless-function-details">
<h3 class="serverless-function-name">{{ name }}</h3>
- <div class="append-bottom-default serverless-function-description">
+ <div class="gl-mb-3 serverless-function-description">
<div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div>
</div>
<url :uri="funcUrl" />
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 2b1291ac70f..8fa48134f1f 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -75,11 +75,7 @@ export default {
<template>
<section id="serverless-functions" class="flex-grow">
- <gl-loading-icon
- v-if="checkingInstalled"
- size="lg"
- class="prepend-top-default append-bottom-default"
- />
+ <gl-loading-icon v-if="checkingInstalled" size="lg" class="gl-mt-3 gl-mb-3" />
<div v-else-if="isInstalled">
<div v-if="hasFunctionData">
@@ -95,11 +91,7 @@ export default {
</ul>
</div>
</template>
- <gl-loading-icon
- v-if="isLoading"
- size="lg"
- class="prepend-top-default append-bottom-default js-functions-loader"
- />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3 js-functions-loader" />
</div>
<div v-else class="empty-state js-empty-state">
<div class="text-content">
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index fd1f9eae152..d5ae9b04090 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -8,6 +8,7 @@ import { __, s__ } from '~/locale';
import Api from '~/api';
import eventHub from './event_hub';
import EmojiMenuInModal from './emoji_menu_in_modal';
+import * as Emoji from '~/emoji';
const emojiMenuClass = 'js-modal-status-emoji-menu';
@@ -64,8 +65,8 @@ export default {
const emojiAutocomplete = new GfmAutoComplete();
emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true });
- import(/* webpackChunkName: 'emoji' */ '~/emoji')
- .then(Emoji => {
+ Emoji.initEmojiMap()
+ .then(() => {
if (this.emoji) {
this.emojiTag = Emoji.glEmojiTag(this.emoji);
}
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 550a1be1e64..0987603cafd 100644
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -1,5 +1,5 @@
<script>
-import { mapState } from 'vuex';
+import { mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import Flash from '~/flash';
import tooltip from '~/vue_shared/directives/tooltip';
@@ -18,6 +18,10 @@ export default {
},
mixins: [recaptchaModalImplementor],
props: {
+ fullPath: {
+ required: true,
+ type: String,
+ },
isEditable: {
required: true,
type: Boolean,
@@ -42,16 +46,24 @@ export default {
},
},
created() {
+ eventHub.$on('updateConfidentialAttribute', this.updateConfidentialAttribute);
eventHub.$on('closeConfidentialityForm', this.toggleForm);
},
beforeDestroy() {
+ eventHub.$off('updateConfidentialAttribute', this.updateConfidentialAttribute);
eventHub.$off('closeConfidentialityForm', this.toggleForm);
},
methods: {
+ ...mapActions(['setConfidentiality']),
toggleForm() {
this.edit = !this.edit;
},
- updateConfidentialAttribute(confidential) {
+ closeForm() {
+ this.edit = false;
+ },
+ updateConfidentialAttribute() {
+ // TODO: rm when FF is defaulted to on.
+ const confidential = !this.confidential;
this.service
.update('issue', { confidential })
.then(({ data }) => this.checkForSpam(data))
@@ -97,12 +109,8 @@ export default {
>
</div>
<div class="value sidebar-item-value hide-collapsed">
- <edit-form
- v-if="edit"
- :is-confidential="confidential"
- :update-confidential-attribute="updateConfidentialAttribute"
- />
- <div v-if="!confidential" class="no-value sidebar-item-value">
+ <edit-form v-if="edit" :is-confidential="confidential" :full-path="fullPath" />
+ <div v-if="!confidential" class="no-value sidebar-item-value" data-testid="not-confidential">
<icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" />
{{ __('Not confidential') }}
</div>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
index 0ecbf934c25..9dd4f04acdb 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
@@ -11,9 +11,9 @@ export default {
required: true,
type: Boolean,
},
- updateConfidentialAttribute: {
+ fullPath: {
required: true,
- type: Function,
+ type: String,
},
},
computed: {
@@ -37,10 +37,7 @@ export default {
<div>
<p v-if="!isConfidential" v-html="confidentialityOnWarning"></p>
<p v-else v-html="confidentialityOffWarning"></p>
- <edit-form-buttons
- :is-confidential="isConfidential"
- :update-confidential-attribute="updateConfidentialAttribute"
- />
+ <edit-form-buttons :full-path="fullPath" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
index e106afea9f5..80928649a03 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
@@ -1,35 +1,60 @@
<script>
import $ from 'jquery';
-import eventHub from '../../event_hub';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import Flash from '~/flash';
+import eventHub from '../../event_hub';
export default {
+ components: {
+ GlLoadingIcon,
+ },
+ mixins: [glFeatureFlagsMixin()],
props: {
- isConfidential: {
- required: true,
- type: Boolean,
- },
- updateConfidentialAttribute: {
+ fullPath: {
required: true,
- type: Function,
+ type: String,
},
},
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
computed: {
+ ...mapState({ confidential: ({ noteableData }) => noteableData.confidential }),
toggleButtonText() {
- return this.isConfidential ? __('Turn Off') : __('Turn On');
- },
- updateConfidentialBool() {
- return !this.isConfidential;
+ if (this.isLoading) {
+ return __('Applying');
+ }
+
+ return this.confidential ? __('Turn Off') : __('Turn On');
},
},
methods: {
+ ...mapActions(['updateConfidentialityOnIssue']),
closeForm() {
eventHub.$emit('closeConfidentialityForm');
$(this.$el).trigger('hidden.gl.dropdown');
},
submitForm() {
- this.closeForm();
- this.updateConfidentialAttribute(this.updateConfidentialBool);
+ this.isLoading = true;
+ const confidential = !this.confidential;
+
+ if (this.glFeatures.confidentialApolloSidebar) {
+ this.updateConfidentialityOnIssue({ confidential, fullPath: this.fullPath })
+ .catch(() => {
+ Flash(__('Something went wrong trying to change the confidentiality of this issue'));
+ })
+ .finally(() => {
+ this.closeForm();
+ this.isLoading = false;
+ });
+ } else {
+ eventHub.$emit('updateConfidentialAttribute');
+ }
},
},
};
@@ -37,15 +62,17 @@ export default {
<template>
<div class="sidebar-item-warning-message-actions">
- <button type="button" class="btn btn-default append-right-10" @click="closeForm">
+ <button type="button" class="btn btn-default gl-mr-3" @click="closeForm">
{{ __('Cancel') }}
</button>
<button
type="button"
class="btn btn-close"
data-testid="confidential-toggle"
+ :disabled="isLoading"
@click.prevent="submitForm"
>
+ <gl-loading-icon v-if="isLoading" inline />
{{ toggleButtonText }}
</button>
</div>
diff --git a/app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql b/app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql
new file mode 100644
index 00000000000..2459aa346c9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql
@@ -0,0 +1,7 @@
+mutation updateIssueConfidential($input: IssueSetConfidentialInput!) {
+ issueSetConfidential(input: $input) {
+ issue {
+ confidential
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
index f88bde624b4..2e85ded8ade 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -41,7 +41,7 @@ export default {
<template>
<div class="sidebar-item-warning-message-actions">
- <button type="button" class="btn btn-default append-right-10" @click="closeForm">
+ <button type="button" class="btn btn-default gl-mr-3" @click="closeForm">
{{ __('Cancel') }}
</button>
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index e371091fc53..2c108835c36 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -11,6 +11,7 @@ import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptio
import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
import { store } from '~/notes/stores';
+import { isInIssuePage } from '~/lib/utils/common_utils';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -43,7 +44,7 @@ function mountAssigneesComponent(mediator) {
projectPath: fullPath,
field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'),
- issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
+ issuableType: isInIssuePage() ? 'issue' : 'merge_request',
},
}),
});
@@ -52,20 +53,30 @@ function mountAssigneesComponent(mediator) {
function mountConfidentialComponent(mediator) {
const el = document.getElementById('js-confidential-entry-point');
+ const { fullPath, iid } = getSidebarOptions();
+
if (!el) return;
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
- const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar);
-
- new ConfidentialComp({
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
store,
- propsData: {
- isEditable: initialData.is_editable,
- service: mediator.service,
+ components: {
+ ConfidentialIssueSidebar,
},
- }).$mount(el);
+ render: createElement =>
+ createElement('confidential-issue-sidebar', {
+ props: {
+ iid: String(iid),
+ fullPath,
+ isEditable: initialData.is_editable,
+ service: mediator.service,
+ },
+ }),
+ });
}
function mountLockComponent(mediator) {
@@ -83,7 +94,7 @@ function mountLockComponent(mediator) {
isLocked: initialData.is_locked,
isEditable: initialData.is_editable,
mediator,
- issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
+ issuableType: isInIssuePage() ? 'issue' : 'merge_request',
},
}).$mount(el);
}
diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql
index 8cc68f6ea9a..2aff7da4605 100644
--- a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql
@@ -1,6 +1,6 @@
-query ($fullPath: ID!, $iid: String!) {
- project (fullPath: $fullPath) {
- issue (iid: $iid) {
+query($fullPath: ID!, $iid: String!) {
+ project(fullPath: $fullPath) {
+ issue(iid: $iid) {
iid
}
}
diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql
index 8cc68f6ea9a..2aff7da4605 100644
--- a/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql
@@ -1,6 +1,6 @@
-query ($fullPath: ID!, $iid: String!) {
- project (fullPath: $fullPath) {
- issue (iid: $iid) {
+query($fullPath: ID!, $iid: String!) {
+ project(fullPath: $fullPath) {
+ issue(iid: $iid) {
iid
}
}
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index a6651515e47..c01f9524ca8 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -3,9 +3,8 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import Flash from '~/flash';
import { __, sprintf } from '~/locale';
-import axios from '~/lib/utils/axios_utils';
import TitleField from '~/vue_shared/components/form/title.vue';
-import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
@@ -15,6 +14,9 @@ import {
SNIPPET_VISIBILITY_PRIVATE,
SNIPPET_CREATE_MUTATION_ERROR,
SNIPPET_UPDATE_MUTATION_ERROR,
+ SNIPPET_BLOB_ACTION_CREATE,
+ SNIPPET_BLOB_ACTION_UPDATE,
+ SNIPPET_BLOB_ACTION_MOVE,
} from '../constants';
import SnippetBlobEdit from './snippet_blob_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
@@ -53,17 +55,25 @@ export default {
},
data() {
return {
- blob: {},
- fileName: '',
- content: '',
- isContentLoading: true,
+ blobsActions: {},
isUpdating: false,
newSnippet: false,
};
},
computed: {
+ getActionsEntries() {
+ return Object.values(this.blobsActions);
+ },
+ allBlobsHaveContent() {
+ const entries = this.getActionsEntries;
+ return entries.length > 0 && !entries.find(action => !action.content);
+ },
+ allBlobChangesRegistered() {
+ const entries = this.getActionsEntries;
+ return entries.length > 0 && !entries.find(action => action.action === '');
+ },
updatePrevented() {
- return this.snippet.title === '' || this.content === '' || this.isUpdating;
+ return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating;
},
isProjectSnippet() {
return Boolean(this.projectPath);
@@ -74,8 +84,7 @@ export default {
title: this.snippet.title,
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
- fileName: this.fileName,
- content: this.content,
+ files: this.getActionsEntries.filter(entry => entry.action !== ''),
};
},
saveButtonLabel() {
@@ -97,9 +106,57 @@ export default {
return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`;
},
},
+ created() {
+ window.addEventListener('beforeunload', this.onBeforeUnload);
+ },
+ destroyed() {
+ window.removeEventListener('beforeunload', this.onBeforeUnload);
+ },
methods: {
- updateFileName(newName) {
- this.fileName = newName;
+ onBeforeUnload(e = {}) {
+ const returnValue = __('Are you sure you want to lose unsaved changes?');
+
+ if (!this.allBlobChangesRegistered) return undefined;
+
+ Object.assign(e, { returnValue });
+ return returnValue;
+ },
+ updateBlobActions(args = {}) {
+ // `_constants` is the internal prop that
+ // should not be sent to the mutation. Hence we filter it out from
+ // the argsToUpdateAction that is the data-basis for the mutation.
+ const { _constants: blobConstants, ...argsToUpdateAction } = args;
+ const { previousPath, filePath, content } = argsToUpdateAction;
+ let actionEntry = this.blobsActions[blobConstants.id] || {};
+ let tunedActions = {
+ action: '',
+ previousPath,
+ };
+
+ if (this.newSnippet) {
+ // new snippet, hence new blob
+ tunedActions = {
+ action: SNIPPET_BLOB_ACTION_CREATE,
+ previousPath: '',
+ };
+ } else if (previousPath && filePath) {
+ // renaming of a blob + renaming & content update
+ const renamedToOriginal = filePath === blobConstants.originalPath;
+ tunedActions = {
+ action: renamedToOriginal ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE,
+ previousPath: !renamedToOriginal ? blobConstants.originalPath : '',
+ };
+ } else if (content !== blobConstants.originalContent) {
+ // content update only
+ tunedActions = {
+ action: SNIPPET_BLOB_ACTION_UPDATE,
+ previousPath: '',
+ };
+ }
+
+ actionEntry = { ...actionEntry, ...argsToUpdateAction, ...tunedActions };
+
+ this.$set(this.blobsActions, blobConstants.id, actionEntry);
},
flashAPIFailure(err) {
const defaultErrorMsg = this.newSnippet
@@ -111,24 +168,9 @@ export default {
onNewSnippetFetched() {
this.newSnippet = true;
this.snippet = this.$options.newSnippetSchema;
- this.blob = this.snippet.blob;
- this.isContentLoading = false;
},
onExistingSnippetFetched() {
this.newSnippet = false;
- const { blob } = this.snippet;
- this.blob = blob;
- this.fileName = blob.name;
- const baseUrl = getBaseURL();
- const url = joinPaths(baseUrl, blob.rawPath);
-
- axios
- .get(url)
- .then(res => {
- this.content = res.data;
- this.isContentLoading = false;
- })
- .catch(e => this.flashAPIFailure(e));
},
onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.edges.length === 0) {
@@ -172,6 +214,7 @@ export default {
if (errors.length) {
this.flashAPIFailure(errors[0]);
} else {
+ this.originalContent = this.content;
redirectTo(baseObj.snippet.webUrl);
}
})
@@ -184,7 +227,6 @@ export default {
title: '',
description: '',
visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
- blob: {},
},
};
</script>
@@ -215,12 +257,16 @@ export default {
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
/>
- <snippet-blob-edit
- v-model="content"
- :file-name="fileName"
- :is-loading="isContentLoading"
- @name-change="updateFileName"
- />
+ <template v-if="blobs.length">
+ <snippet-blob-edit
+ v-for="blob in blobs"
+ :key="blob.name"
+ :blob="blob"
+ @blob-updated="updateBlobActions"
+ />
+ </template>
+ <snippet-blob-edit v-else @blob-updated="updateBlobActions" />
+
<snippet-visibility-edit
v-model="snippet.visibilityLevel"
:help-link="visibilityHelpLink"
diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue
index bc0034d397e..0779e87e6b6 100644
--- a/app/assets/javascripts/snippets/components/show.vue
+++ b/app/assets/javascripts/snippets/components/show.vue
@@ -1,19 +1,27 @@
<script>
+import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
import SnippetHeader from './snippet_header.vue';
import SnippetTitle from './snippet_title.vue';
import SnippetBlob from './snippet_blob_view.vue';
import { GlLoadingIcon } from '@gitlab/ui';
import { getSnippetMixin } from '../mixins/snippets';
+import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants';
export default {
components: {
+ BlobEmbeddable,
SnippetHeader,
SnippetTitle,
GlLoadingIcon,
SnippetBlob,
},
mixins: [getSnippetMixin],
+ computed: {
+ embeddable() {
+ return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC;
+ },
+ },
};
</script>
<template>
@@ -27,7 +35,10 @@ export default {
<template v-else>
<snippet-header :snippet="snippet" />
<snippet-title :snippet="snippet" />
- <snippet-blob :snippet="snippet" />
+ <blob-embeddable v-if="embeddable" class="gl-mb-5" :url="snippet.webUrl" />
+ <div v-for="blob in blobs" :key="blob.path">
+ <snippet-blob :snippet="snippet" :blob="blob" />
+ </div>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
index 62c29b0c7cd..3c2dbfff6e1 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
@@ -2,6 +2,17 @@
import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue';
import BlobContentEdit from '~/blob/components/blob_edit_content.vue';
import { GlLoadingIcon } from '@gitlab/ui';
+import { getBaseURL, joinPaths } from '~/lib/utils/url_utility';
+import axios from '~/lib/utils/axios_utils';
+import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants';
+import Flash from '~/flash';
+import { sprintf } from '~/locale';
+
+function localId() {
+ return Math.floor((1 + Math.random()) * 0x10000)
+ .toString(16)
+ .substring(1);
+}
export default {
components: {
@@ -11,20 +22,70 @@ export default {
},
inheritAttrs: false,
props: {
- value: {
- type: String,
+ blob: {
+ type: Object,
required: false,
- default: '',
+ default: null,
+ validator: ({ rawPath }) => Boolean(rawPath),
},
- fileName: {
- type: String,
- required: false,
- default: '',
+ },
+ data() {
+ return {
+ id: localId(),
+ filePath: this.blob?.path || '',
+ previousPath: '',
+ originalPath: this.blob?.path || '',
+ content: this.blob?.content || '',
+ originalContent: '',
+ isContentLoading: this.blob,
+ };
+ },
+ watch: {
+ filePath(filePath, previousPath) {
+ this.previousPath = previousPath;
+ this.notifyAboutUpdates({ previousPath });
},
- isLoading: {
- type: Boolean,
- required: false,
- default: true,
+ content() {
+ this.notifyAboutUpdates();
+ },
+ },
+ mounted() {
+ if (this.blob) {
+ this.fetchBlobContent();
+ }
+ },
+ methods: {
+ notifyAboutUpdates(args = {}) {
+ const { filePath, previousPath } = args;
+ this.$emit('blob-updated', {
+ filePath: filePath || this.filePath,
+ previousPath: previousPath || this.previousPath,
+ content: this.content,
+ _constants: {
+ originalPath: this.originalPath,
+ originalContent: this.originalContent,
+ id: this.id,
+ },
+ });
+ },
+ fetchBlobContent() {
+ const baseUrl = getBaseURL();
+ const url = joinPaths(baseUrl, this.blob.rawPath);
+
+ axios
+ .get(url)
+ .then(res => {
+ this.originalContent = res.data;
+ this.content = res.data;
+ })
+ .catch(e => this.flashAPIFailure(e))
+ .finally(() => {
+ this.isContentLoading = false;
+ });
+ },
+ flashAPIFailure(err) {
+ Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }));
+ this.isContentLoading = false;
},
},
};
@@ -33,23 +94,14 @@ export default {
<div class="form-group file-editor">
<label>{{ s__('Snippets|File') }}</label>
<div class="file-holder snippet">
- <blob-header-edit
- :value="fileName"
- data-qa-selector="file_name_field"
- @input="$emit('name-change', $event)"
- />
+ <blob-header-edit v-model="filePath" data-qa-selector="file_name_field" />
<gl-loading-icon
- v-if="isLoading"
+ v-if="isContentLoading"
:label="__('Loading snippet')"
size="lg"
class="loading-animation prepend-top-20 append-bottom-20"
/>
- <blob-content-edit
- v-else
- :value="value"
- :file-name="fileName"
- @input="$emit('input', $event)"
- />
+ <blob-content-edit v-else v-model="content" :file-name="filePath" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
index 7472aff3318..afd038eef58 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
@@ -1,6 +1,4 @@
<script>
-import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
-import { SNIPPET_VISIBILITY_PUBLIC } from '../constants';
import BlobHeader from '~/blob/components/blob_header.vue';
import BlobContent from '~/blob/components/blob_content.vue';
import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
@@ -16,7 +14,6 @@ import {
export default {
components: {
- BlobEmbeddable,
BlobHeader,
BlobContent,
CloneDropdownButton,
@@ -49,21 +46,19 @@ export default {
type: Object,
required: true,
},
+ blob: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
- blob: this.snippet.blob,
blobContent: '',
activeViewerType:
- this.snippet.blob?.richViewer && !window.location.hash
- ? RICH_BLOB_VIEWER
- : SIMPLE_BLOB_VIEWER,
+ this.blob?.richViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER,
};
},
computed: {
- embeddable() {
- return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC;
- },
isContentLoading() {
return this.$apollo.queries.blobContent.loading;
},
@@ -92,33 +87,30 @@ export default {
};
</script>
<template>
- <div>
- <blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" />
- <article class="file-holder snippet-file-content">
- <blob-header
- :blob="blob"
- :active-viewer-type="viewer.type"
- :has-render-error="hasRenderError"
- @viewer-changed="switchViewer"
- >
- <template #actions>
- <clone-dropdown-button
- v-if="canBeCloned"
- class="mr-2"
- :ssh-link="snippet.sshUrlToRepo"
- :http-link="snippet.httpUrlToRepo"
- data-qa-selector="clone_button"
- />
- </template>
- </blob-header>
- <blob-content
- :loading="isContentLoading"
- :content="blobContent"
- :active-viewer="viewer"
- :blob="blob"
- @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery"
- @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer"
- />
- </article>
- </div>
+ <article class="file-holder snippet-file-content">
+ <blob-header
+ :blob="blob"
+ :active-viewer-type="viewer.type"
+ :has-render-error="hasRenderError"
+ @viewer-changed="switchViewer"
+ >
+ <template #actions>
+ <clone-dropdown-button
+ v-if="canBeCloned"
+ class="gl-mr-3"
+ :ssh-link="snippet.sshUrlToRepo"
+ :http-link="snippet.httpUrlToRepo"
+ data-qa-selector="clone_button"
+ />
+ </template>
+ </blob-header>
+ <blob-content
+ :loading="isContentLoading"
+ :content="blobContent"
+ :active-viewer="viewer"
+ :blob="blob"
+ @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery"
+ @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer"
+ />
+ </article>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index 2a06296cb15..707e2b0ea30 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -65,14 +65,17 @@ export default {
};
},
computed: {
+ snippetHasBinary() {
+ return Boolean(this.snippet.blobs.find(blob => blob.binary));
+ },
personalSnippetActions() {
return [
{
condition: this.snippet.userPermissions.updateSnippet,
text: __('Edit'),
href: this.editLink,
- disabled: this.snippet.blob.binary,
- title: this.snippet.blob.binary
+ disabled: this.snippetHasBinary,
+ title: this.snippetHasBinary
? __('Snippets with non-text files can only be edited via Git.')
: undefined,
},
@@ -163,7 +166,7 @@ export default {
<div class="detail-page-header">
<div class="detail-page-header-body">
<div
- class="snippet-box has-tooltip d-flex align-items-center append-right-5 mb-1"
+ class="snippet-box has-tooltip d-flex align-items-center gl-mr-2 mb-1"
data-qa-selector="snippet_container"
:title="snippetVisibilityLevelDescription"
data-container="body"
diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js
index b3abc73557c..99ee698408d 100644
--- a/app/assets/javascripts/snippets/constants.js
+++ b/app/assets/javascripts/snippets/constants.js
@@ -25,3 +25,8 @@ export const SNIPPET_VISIBILITY = {
export const SNIPPET_CREATE_MUTATION_ERROR = __("Can't create snippet: %{err}");
export const SNIPPET_UPDATE_MUTATION_ERROR = __("Can't update snippet: %{err}");
+export const SNIPPET_BLOB_CONTENT_FETCH_ERROR = __("Can't fetch content for the blob: %{err}");
+
+export const SNIPPET_BLOB_ACTION_CREATE = 'create';
+export const SNIPPET_BLOB_ACTION_UPDATE = 'update';
+export const SNIPPET_BLOB_ACTION_MOVE = 'move';
diff --git a/app/assets/javascripts/snippets/fragments/project.fragment.graphql b/app/assets/javascripts/snippets/fragments/project.fragment.graphql
index 7d65789c67b..64bb2315c1b 100644
--- a/app/assets/javascripts/snippets/fragments/project.fragment.graphql
+++ b/app/assets/javascripts/snippets/fragments/project.fragment.graphql
@@ -1,6 +1,6 @@
-fragment Project on Snippet {
+fragment SnippetProject on Snippet {
project {
fullPath
webUrl
}
-} \ No newline at end of file
+}
diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
index e7765dfd8ba..2cca71708ca 100644
--- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
+++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
@@ -11,7 +11,7 @@ fragment SnippetBase on Snippet {
webUrl
httpUrlToRepo
sshUrlToRepo
- blob {
+ blobs {
binary
name
path
diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js
index 837c41cdf6b..91331cdf339 100644
--- a/app/assets/javascripts/snippets/mixins/snippets.js
+++ b/app/assets/javascripts/snippets/mixins/snippets.js
@@ -1,5 +1,7 @@
import GetSnippetQuery from '../queries/snippet.query.graphql';
+const blobsDefault = [];
+
export const getSnippetMixin = {
apollo: {
snippet: {
@@ -11,6 +13,7 @@ export const getSnippetMixin = {
},
update: data => data.snippets.edges[0]?.node,
result(res) {
+ this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault;
if (this.onSnippetFetch) {
this.onSnippetFetch(res);
}
@@ -27,6 +30,7 @@ export const getSnippetMixin = {
return {
snippet: {},
newSnippet: false,
+ blobs: blobsDefault,
};
},
computed: {
diff --git a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql
index 0c829cbdee6..f43d53661f4 100644
--- a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql
@@ -1,5 +1,5 @@
mutation DeleteSnippet($id: ID!) {
- destroySnippet(input: {id: $id}) {
+ destroySnippet(input: { id: $id }) {
errors
}
-} \ No newline at end of file
+}
diff --git a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql b/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql
index 288bd0889bf..03c81460fb5 100644
--- a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql
+++ b/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql
@@ -4,4 +4,4 @@ query CanCreateProjectSnippet($fullPath: ID!) {
createSnippet
}
}
-} \ No newline at end of file
+}
diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql
index c58a5168ba3..b23ab862439 100644
--- a/app/assets/javascripts/snippets/queries/snippet.query.graphql
+++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql
@@ -7,7 +7,7 @@ query GetSnippetQuery($ids: [ID!]) {
edges {
node {
...SnippetBase
- ...Project
+ ...SnippetProject
author {
...Author
}
diff --git a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql b/app/assets/javascripts/snippets/queries/userPermissions.query.graphql
index f5b97b3d0f0..c3e5519e266 100644
--- a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql
+++ b/app/assets/javascripts/snippets/queries/userPermissions.query.graphql
@@ -4,4 +4,4 @@ query CanCreatePersonalSnippet {
createSnippet
}
}
-} \ No newline at end of file
+}
diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue
index e9efef40632..84a16f327d9 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_area.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue
@@ -5,6 +5,8 @@ import EditHeader from './edit_header.vue';
import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue';
import parseSourceFile from '~/static_site_editor/services/parse_source_file';
import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants';
+import { DEFAULT_IMAGE_UPLOAD_PATH } from '../constants';
+import imageRepository from '../image_repository';
export default {
components: {
@@ -31,46 +33,47 @@ export default {
required: false,
default: '',
},
+ imageRoot: {
+ type: String,
+ required: false,
+ default: DEFAULT_IMAGE_UPLOAD_PATH,
+ validator: prop => prop.endsWith('/'),
+ },
},
data() {
return {
saveable: false,
parsedSource: parseSourceFile(this.content),
editorMode: EDITOR_TYPES.wysiwyg,
+ isModified: false,
};
},
+ imageRepository: imageRepository(),
computed: {
editableContent() {
- return this.parsedSource.editable;
- },
- editableKey() {
- return this.isWysiwygMode ? 'body' : 'raw';
+ return this.parsedSource.content(this.isWysiwygMode);
},
isWysiwygMode() {
return this.editorMode === EDITOR_TYPES.wysiwyg;
},
- modified() {
- return this.isWysiwygMode
- ? this.parsedSource.isModifiedBody()
- : this.parsedSource.isModifiedRaw();
- },
},
methods: {
- syncSource() {
- if (this.isWysiwygMode) {
- this.parsedSource.syncBody();
- return;
- }
-
- this.parsedSource.syncRaw();
+ onInputChange(newVal) {
+ this.parsedSource.sync(newVal, this.isWysiwygMode);
+ this.isModified = this.parsedSource.isModified();
},
onModeChange(mode) {
this.editorMode = mode;
- this.syncSource();
+ this.$refs.editor.resetInitialValue(this.editableContent);
+ },
+ onUploadImage({ file, imageUrl }) {
+ this.$options.imageRepository.add(file, imageUrl);
},
onSubmit() {
- this.syncSource();
- this.$emit('submit', { content: this.editableContent.raw });
+ this.$emit('submit', {
+ content: this.parsedSource.content(),
+ images: this.$options.imageRepository.getAll(),
+ });
},
},
};
@@ -79,16 +82,20 @@ export default {
<div class="d-flex flex-grow-1 flex-column h-100">
<edit-header class="py-2" :title="title" />
<rich-content-editor
- v-model="editableContent[editableKey]"
+ ref="editor"
+ :content="editableContent"
:initial-edit-type="editorMode"
+ :image-root="imageRoot"
class="mb-9 h-100"
@modeChange="onModeChange"
+ @input="onInputChange"
+ @uploadImage="onUploadImage"
/>
- <unsaved-changes-confirm-dialog :modified="modified" />
+ <unsaved-changes-confirm-dialog :modified="isModified" />
<publish-toolbar
class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full"
:return-url="returnUrl"
- :saveable="modified"
+ :saveable="isModified"
:saving-changes="savingChanges"
@submit="onSubmit"
/>
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
index 947347922f2..49db9ab7ca5 100644
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ b/app/assets/javascripts/static_site_editor/constants.js
@@ -19,3 +19,5 @@ export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor');
export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit';
export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request';
export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor';
+
+export const DEFAULT_IMAGE_UPLOAD_PATH = 'source/images/uploads/';
diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql
index 2840d419966..cd130aa7dbb 100644
--- a/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql
@@ -1,5 +1,5 @@
mutation submitContentChanges($input: SubmitContentChangesInput) {
- submitContentChanges(input: $input) @client {
+ submitContentChanges(input: $input) @client {
branch
commit
mergeRequest
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
index fdbf4459aee..946d80efff0 100644
--- a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
@@ -3,7 +3,7 @@ query appData {
isSupportedContent
project
sourcePath
- username,
+ username
returnUrl
}
}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
index e36d244ae57..cfe30c601ed 100644
--- a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
@@ -1,6 +1,6 @@
query sourceContent($project: ID!, $sourcePath: String!) {
project(fullPath: $project) {
- fullPath,
+ fullPath
file(path: $sourcePath) @client {
title
content
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
index 6c4e3a4d973..0cb26f88785 100644
--- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
@@ -3,10 +3,10 @@ import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql';
const submitContentChangesResolver = (
_,
- { input: { project: projectId, username, sourcePath, content } },
+ { input: { project: projectId, username, sourcePath, content, images } },
{ cache },
) => {
- return submitContentChanges({ projectId, username, sourcePath, content }).then(
+ return submitContentChanges({ projectId, username, sourcePath, content, images }).then(
savedContentMeta => {
cache.writeQuery({
query: savedContentMetaQuery,
diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
index 59da2e27144..78cc1746cdb 100644
--- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
@@ -22,7 +22,7 @@ type AppData {
username: String!
}
-type SubmitContentChangesInput {
+input SubmitContentChangesInput {
project: String!
sourcePath: String!
content: String!
diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js
new file mode 100644
index 00000000000..541d581bda8
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/image_repository.js
@@ -0,0 +1,20 @@
+import { __ } from '~/locale';
+import Flash from '~/flash';
+import { getBinary } from './services/image_service';
+
+const imageRepository = () => {
+ const images = new Map();
+ const flash = message => new Flash(message);
+
+ const add = (file, url) => {
+ getBinary(file)
+ .then(content => images.set(url, content))
+ .catch(() => flash(__('Something went wrong while inserting your image. Please try again.')));
+ };
+
+ const getAll = () => images;
+
+ return { add, getAll };
+};
+
+export default imageRepository;
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
index a1314c8a478..156b815e07a 100644
--- a/app/assets/javascripts/static_site_editor/pages/home.vue
+++ b/app/assets/javascripts/static_site_editor/pages/home.vue
@@ -67,11 +67,11 @@ export default {
onDismissError() {
this.submitChangesError = null;
},
- onSubmit({ content }) {
+ onSubmit({ content, images }) {
this.content = content;
- this.submitChanges();
+ this.submitChanges(images);
},
- submitChanges() {
+ submitChanges(images) {
this.isSavingChanges = true;
this.$apollo
@@ -83,6 +83,7 @@ export default {
username: this.appData.username,
sourcePath: this.appData.sourcePath,
content: this.content,
+ images,
},
},
})
diff --git a/app/assets/javascripts/static_site_editor/services/image_service.js b/app/assets/javascripts/static_site_editor/services/image_service.js
new file mode 100644
index 00000000000..edc69d0579a
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/services/image_service.js
@@ -0,0 +1,9 @@
+// eslint-disable-next-line import/prefer-default-export
+export const getBinary = file => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => resolve(reader.result.split(',')[1]);
+ reader.onerror = error => reject(error);
+ });
+};
diff --git a/app/assets/javascripts/static_site_editor/services/parse_source_file.js b/app/assets/javascripts/static_site_editor/services/parse_source_file.js
index f32c693411f..126dfe81b90 100644
--- a/app/assets/javascripts/static_site_editor/services/parse_source_file.js
+++ b/app/assets/javascripts/static_site_editor/services/parse_source_file.js
@@ -22,33 +22,43 @@ const parseSourceFile = raw => {
return buildPayload(source, '', '', source);
};
- const computedRaw = () => `${editable.header}${editable.spacing}${editable.body}`;
-
- const syncBody = () => {
+ const syncEditable = () => {
/*
We re-parse as markdown editing could have added non-body changes (preFrontMatter, frontMatter, or spacing).
- Re-parsing additionally gets us the desired body that was extracted from the mutated editable.raw
- Additionally we intentionally mutate the existing editable's key values as opposed to reassigning the object itself so consumers of the potentially reactive property stay in sync.
+ Re-parsing additionally gets us the desired body that was extracted from the potentially mutated editable.raw
*/
- Object.assign(editable, parse(editable.raw));
+ editable = parse(editable.raw);
+ };
+
+ const syncBodyToRaw = () => {
+ editable.raw = `${editable.header}${editable.spacing}${editable.body}`;
+ };
+
+ const sync = (newVal, isBodyToRaw) => {
+ const editableKey = isBodyToRaw ? 'body' : 'raw';
+ editable[editableKey] = newVal;
+
+ if (isBodyToRaw) {
+ syncBodyToRaw();
+ }
+
+ syncEditable();
};
- const syncRaw = () => {
- editable.raw = computedRaw();
+ const content = (isBody = false) => {
+ const editableKey = isBody ? 'body' : 'raw';
+ return editable[editableKey];
};
- const isModifiedRaw = () => initial.raw !== editable.raw;
- const isModifiedBody = () => initial.raw !== computedRaw();
+ const isModified = () => initial.raw !== editable.raw;
initial = parse(raw);
editable = parse(raw);
return {
- editable,
- isModifiedRaw,
- isModifiedBody,
- syncRaw,
- syncBody,
+ content,
+ isModified,
+ sync,
};
};
diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
index fce7c1f918f..da62d3fa4fc 100644
--- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
@@ -21,7 +21,32 @@ const createBranch = (projectId, branch) =>
throw new Error(SUBMIT_CHANGES_BRANCH_ERROR);
});
-const commitContent = (projectId, message, branch, sourcePath, content) => {
+const createImageActions = (images, markdown) => {
+ const actions = [];
+
+ if (!markdown) {
+ return actions;
+ }
+
+ images.forEach((imageContent, filePath) => {
+ const imageExistsInMarkdown = path => new RegExp(`!\\[([^[\\]\\n]*)\\](\\(${path})\\)`); // matches the image markdown syntax: ![<any-string-except-newline>](<path>)
+
+ if (imageExistsInMarkdown(filePath).test(markdown)) {
+ actions.push(
+ convertObjectPropsToSnakeCase({
+ encoding: 'base64',
+ action: 'create',
+ content: imageContent,
+ filePath,
+ }),
+ );
+ }
+ });
+
+ return actions;
+};
+
+const commitContent = (projectId, message, branch, sourcePath, content, images) => {
Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT);
return Api.commitMultiple(
@@ -35,6 +60,7 @@ const commitContent = (projectId, message, branch, sourcePath, content) => {
filePath: sourcePath,
content,
}),
+ ...createImageActions(images, content),
],
}),
).catch(() => {
@@ -62,7 +88,7 @@ const createMergeRequest = (
});
};
-const submitContentChanges = ({ username, projectId, sourcePath, content }) => {
+const submitContentChanges = ({ username, projectId, sourcePath, content, images }) => {
const branch = generateBranchName(username);
const mergeRequestTitle = sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), {
sourcePath,
@@ -73,7 +99,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content }) => {
.then(({ data: { web_url: url } }) => {
Object.assign(meta, { branch: { label: branch, url } });
- return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content);
+ return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content, images);
})
.then(({ data: { short_id: label, web_url: url } }) => {
Object.assign(meta, { commit: { label, url } });
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index bde00d72620..290de55e6f9 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -1,5 +1,7 @@
import Vue from 'vue';
+import sanitize from 'sanitize-html';
+
import UsersCache from './lib/utils/users_cache';
import UserPopover from './vue_shared/components/user_popover/user_popover.vue';
@@ -38,6 +40,7 @@ const populateUserInfo = user => {
name: userData.name,
location: userData.location,
bio: userData.bio,
+ bioHtml: sanitize(userData.bio_html),
workInformation: userData.work_information,
loaded: true,
});
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 2dbe5a8171e..f72de8c2f4d 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -21,8 +21,8 @@ function UsersSelect(currentUser, els, options = {}) {
const $els = $(els || '.js-user-search');
this.users = this.users.bind(this);
this.user = this.user.bind(this);
- this.usersPath = '/autocomplete/users.json';
- this.userPath = '/autocomplete/users/:id.json';
+ this.usersPath = '/-/autocomplete/users.json';
+ this.userPath = '/-/autocomplete/users/:id.json';
if (currentUser != null) {
if (typeof currentUser === 'object') {
this.currentUser = currentUser;
@@ -263,7 +263,7 @@ function UsersSelect(currentUser, els, options = {}) {
const userId = parseInt(input.value, 10);
const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset;
return {
- avatar_url: avatarUrl || avatar_url,
+ avatar_url: avatarUrl || avatar_url || gon.default_avatar_url,
id: userId,
name,
username,
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
new file mode 100644
index 00000000000..0f9d1b8395b
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
@@ -0,0 +1,212 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import eventHub from '../../event_hub';
+import approvalsMixin from '../../mixins/approvals';
+import MrWidgetContainer from '../mr_widget_container.vue';
+import MrWidgetIcon from '../mr_widget_icon.vue';
+import ApprovalsSummary from './approvals_summary.vue';
+import ApprovalsSummaryOptional from './approvals_summary_optional.vue';
+import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages';
+
+export default {
+ name: 'MRWidgetApprovals',
+ components: {
+ MrWidgetContainer,
+ MrWidgetIcon,
+ ApprovalsSummary,
+ ApprovalsSummaryOptional,
+ GlButton,
+ },
+ mixins: [approvalsMixin],
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ isOptionalDefault: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ approveDefault: {
+ type: Function,
+ required: false,
+ default: null,
+ },
+ modalId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ requirePasswordToApprove: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ fetchingApprovals: true,
+ hasApprovalAuthError: false,
+ isApproving: false,
+ };
+ },
+ computed: {
+ isBasic() {
+ return this.mr.approvalsWidgetType === 'base';
+ },
+ isApproved() {
+ return Boolean(this.approvals.approved);
+ },
+ isOptional() {
+ return this.isOptionalDefault !== null ? this.isOptionalDefault : !this.approvedBy.length;
+ },
+ hasAction() {
+ return Boolean(this.action);
+ },
+ approvals() {
+ return this.mr.approvals || {};
+ },
+ approvedBy() {
+ return this.approvals.approved_by ? this.approvals.approved_by.map(x => x.user) : [];
+ },
+ userHasApproved() {
+ return Boolean(this.approvals.user_has_approved);
+ },
+ userCanApprove() {
+ return Boolean(this.approvals.user_can_approve);
+ },
+ showApprove() {
+ return !this.userHasApproved && this.userCanApprove && this.mr.isOpen;
+ },
+ showUnapprove() {
+ return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged';
+ },
+ approvalText() {
+ return this.isApproved && this.approvedBy.length > 0
+ ? s__('mrWidget|Approve additionally')
+ : s__('mrWidget|Approve');
+ },
+ action() {
+ // Use the default approve action, only if we aren't using the auth component for it
+ if (this.showApprove) {
+ return {
+ text: this.approvalText,
+ category: this.isApproved ? 'secondary' : 'primary',
+ variant: 'info',
+ action: () => this.approve(),
+ };
+ } else if (this.showUnapprove) {
+ return {
+ text: s__('mrWidget|Revoke approval'),
+ variant: 'warning',
+ category: 'secondary',
+ action: () => this.unapprove(),
+ };
+ }
+
+ return null;
+ },
+ },
+ created() {
+ this.refreshApprovals()
+ .then(() => {
+ this.fetchingApprovals = false;
+ })
+ .catch(() => createFlash(FETCH_ERROR));
+ },
+ methods: {
+ approve() {
+ if (this.requirePasswordToApprove) {
+ this.$root.$emit('bv::show::modal', this.modalId);
+ return;
+ }
+
+ this.updateApproval(
+ () => this.service.approveMergeRequest(),
+ () => createFlash(APPROVE_ERROR),
+ );
+ },
+ approveWithAuth(data) {
+ this.updateApproval(
+ () => this.service.approveMergeRequestWithAuth(data),
+ error => {
+ if (error && error.response && error.response.status === 401) {
+ this.hasApprovalAuthError = true;
+ return;
+ }
+ createFlash(APPROVE_ERROR);
+ },
+ );
+ },
+ unapprove() {
+ this.updateApproval(
+ () => this.service.unapproveMergeRequest(),
+ () => createFlash(UNAPPROVE_ERROR),
+ );
+ },
+ updateApproval(serviceFn, errFn) {
+ this.isApproving = true;
+ this.clearError();
+ return serviceFn()
+ .then(data => {
+ this.mr.setApprovals(data);
+ eventHub.$emit('MRWidgetUpdateRequested');
+ this.$emit('updated');
+ })
+ .catch(errFn)
+ .then(() => {
+ this.isApproving = false;
+ });
+ },
+ },
+ FETCH_LOADING,
+};
+</script>
+<template>
+ <mr-widget-container>
+ <div class="js-mr-approvals d-flex align-items-start align-items-md-center">
+ <mr-widget-icon name="approval" />
+ <div v-if="fetchingApprovals">{{ $options.FETCH_LOADING }}</div>
+ <template v-else>
+ <gl-button
+ v-if="action"
+ :variant="action.variant"
+ :category="action.category"
+ :loading="isApproving"
+ class="mr-3"
+ data-qa-selector="approve_button"
+ @click="action.action"
+ >
+ {{ action.text }}
+ </gl-button>
+ <approvals-summary-optional
+ v-if="isOptional"
+ :can-approve="hasAction"
+ :help-path="mr.approvalsHelpPath"
+ />
+ <approvals-summary
+ v-else
+ :approved="isApproved"
+ :approvals-left="approvals.approvals_left || 0"
+ :rules-left="approvals.approvalRuleNamesLeft"
+ :approvers="approvedBy"
+ />
+ <slot
+ :is-approving="isApproving"
+ :approve-with-auth="approveWithAuth"
+ :hasApproval-auth-error="hasApprovalAuthError"
+ ></slot>
+ </template>
+ </div>
+ <template #footer>
+ <slot name="footer"></slot>
+ </template>
+ </mr-widget-container>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
new file mode 100644
index 00000000000..fb342a5d340
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
@@ -0,0 +1,70 @@
+<script>
+import { n__, sprintf } from '~/locale';
+import { toNounSeriesText } from '~/lib/utils/grammar';
+import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
+import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages';
+
+export default {
+ components: {
+ UserAvatarList,
+ },
+ props: {
+ approved: {
+ type: Boolean,
+ required: true,
+ },
+ approvalsLeft: {
+ type: Number,
+ required: true,
+ },
+ rulesLeft: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ approvers: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ message() {
+ if (this.approved) {
+ return APPROVED_MESSAGE;
+ }
+
+ if (!this.rulesLeft.length) {
+ return n__('Requires approval.', 'Requires %d more approvals.', this.approvalsLeft);
+ }
+
+ return sprintf(
+ n__(
+ 'Requires approval from %{names}.',
+ 'Requires %{count} more approvals from %{names}.',
+ this.approvalsLeft,
+ ),
+ {
+ names: toNounSeriesText(this.rulesLeft),
+ count: this.approvalsLeft,
+ },
+ false,
+ );
+ },
+ hasApprovers() {
+ return Boolean(this.approvers.length);
+ },
+ },
+ APPROVED_MESSAGE,
+};
+</script>
+
+<template>
+ <div data-qa-selector="approvals_summary_content">
+ <strong>{{ message }}</strong>
+ <template v-if="hasApprovers">
+ <span>{{ s__('mrWidget|Approved by') }}</span>
+ <user-avatar-list class="d-inline-block align-middle" :items="approvers" />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue
new file mode 100644
index 00000000000..66af0c5a83e
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import {
+ OPTIONAL,
+ OPTIONAL_CAN_APPROVE,
+} from '~/vue_merge_request_widget/components/approvals/messages';
+
+export default {
+ components: {
+ GlLink,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ canApprove: {
+ type: Boolean,
+ required: true,
+ },
+ helpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ message() {
+ return this.canApprove ? OPTIONAL_CAN_APPROVE : OPTIONAL;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="d-flex align-items-center">
+ <span class="text-muted">{{ message }}</span>
+ <gl-link
+ v-if="canApprove && helpPath"
+ v-gl-tooltip
+ :href="helpPath"
+ :title="__('About this feature')"
+ target="_blank"
+ class="d-flex-center pl-1"
+ >
+ <icon name="question" />
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js
new file mode 100644
index 00000000000..1d9368f71aa
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js
@@ -0,0 +1,11 @@
+import { __, s__ } from '~/locale';
+
+export const FETCH_LOADING = __('Checking approval status');
+export const FETCH_ERROR = s__(
+ 'mrWidget|An error occurred while retrieving approval data for this merge request.',
+);
+export const APPROVE_ERROR = s__('mrWidget|An error occurred while submitting your approval.');
+export const UNAPPROVE_ERROR = s__('mrWidget|An error occurred while removing your approval.');
+export const APPROVED_MESSAGE = s__('mrWidget|Merge request approved.');
+export const OPTIONAL_CAN_APPROVE = s__('mrWidget|No approval required; you can still approve');
+export const OPTIONAL = s__('mrWidget|No approval required');
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue
index 78dc28ee92b..cd4e31e0dae 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue
@@ -9,7 +9,7 @@ export default {
</script>
<template>
- <div class="prepend-top-default">
+ <div class="gl-mt-3">
<div class="mr-widget-heading p-3">
<gl-skeleton-loader :width="577" :height="12">
<rect width="86" height="12" rx="2" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
index 294871ca5c2..24174c29d51 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -1,11 +1,11 @@
<script>
-import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
GlLoadingIcon,
Icon,
},
@@ -58,16 +58,17 @@ export default {
</div>
<template v-else>
- <gl-deprecated-button
- class="btn-blank btn s32 square append-right-default"
+ <button
+ class="btn-blank btn s32 square gl-mr-3"
+ type="button"
:aria-label="ariaLabel"
:disabled="isLoading"
@click="toggleCollapsed"
>
<gl-loading-icon v-if="isLoading" />
<icon v-else :name="arrowIconName" class="js-icon" />
- </gl-deprecated-button>
- <gl-deprecated-button
+ </button>
+ <gl-button
variant="link"
class="js-title"
:disabled="isLoading"
@@ -76,7 +77,7 @@ export default {
>
<template v-if="isCollapsed">{{ title }}</template>
<template v-else>{{ __('Collapse') }}</template>
- </gl-deprecated-button>
+ </gl-button>
</template>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue
index 84937aa9510..598b08f4c16 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue
@@ -27,7 +27,7 @@ export default {
return this.author.webUrl || this.author.web_url;
},
avatarUrl() {
- return this.author.avatarUrl || this.author.avatar_url;
+ return this.author.avatarUrl || this.author.avatar_url || gl.mrWidgetData.defaultAvatarUrl;
},
},
};
@@ -40,6 +40,6 @@ export default {
class="author-link inline"
>
<img :src="avatarUrl" class="avatar avatar-inline s16" />
- <span v-if="showAuthorName" class="author"> {{ author.name }} </span>
+ <span v-if="showAuthorName" class="author">{{ author.name }}</span>
</a>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue
new file mode 100644
index 00000000000..fd999540f4a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue
@@ -0,0 +1,72 @@
+<script>
+import { __ } from '~/locale';
+import { GlButton, GlCollapse, GlIcon } from '@gitlab/ui';
+
+/**
+ * Renders header section with icon and expand button
+ * Renders expanable content section with grey background
+ */
+export default {
+ name: 'MrWidgetExpanableSection',
+ components: {
+ GlButton,
+ GlCollapse,
+ GlIcon,
+ },
+ props: {
+ iconName: {
+ type: String,
+ required: false,
+ default: 'status_warning',
+ },
+ },
+ data() {
+ return {
+ contentIsVisible: false,
+ };
+ },
+ computed: {
+ collapseButtonText() {
+ if (this.contentIsVisible) {
+ return __('Collapse');
+ }
+
+ return __('Expand');
+ },
+ },
+ methods: {
+ updateContentVisibility() {
+ this.contentIsVisible = !this.contentIsVisible;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="mr-widget-body gl-display-flex">
+ <span
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-mr-3 gl-align-self-start gl-mt-1"
+ >
+ <gl-icon :name="iconName" :size="24" />
+ </span>
+
+ <div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column gl-md-flex-direction-row">
+ <slot name="header"></slot>
+
+ <div>
+ <gl-button @click="updateContentVisibility">
+ {{ collapseButtonText }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+
+ <gl-collapse
+ :visible="contentIsVisible"
+ class="gl-bg-gray-10 gl-border-t-solid gl-border-gray-100 gl-border-1"
+ >
+ <slot name="content"></slot>
+ </gl-collapse>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index 0464c4b9c15..897f706290d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -1,4 +1,5 @@
<script>
+import Mousetrap from 'mousetrap';
import { escape } from 'lodash';
import { n__, s__, sprintf } from '~/locale';
import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility';
@@ -74,10 +75,21 @@ export default {
: '';
},
},
+ mounted() {
+ Mousetrap.bind('b', this.copyBranchName);
+ },
+ beforeDestroy() {
+ Mousetrap.unbind('b');
+ },
+ methods: {
+ copyBranchName() {
+ this.$refs.copyBranchNameButton.$el.click();
+ },
+ },
};
</script>
<template>
- <div class="d-flex mr-source-target append-bottom-default">
+ <div class="d-flex mr-source-target gl-mb-3">
<mr-widget-icon name="git-merge" />
<div class="git-merge-container d-flex">
<div class="normal">
@@ -89,6 +101,7 @@ export default {
class="label-branch label-truncate js-source-branch"
v-html="mr.sourceBranchLink"
/><clipboard-button
+ ref="copyBranchNameButton"
:text="branchNameClipboardData"
:title="__('Copy branch name')"
css-class="btn-default btn-transparent btn-clipboard"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
index 57d4d8b7ae6..e1659d9a167 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
@@ -13,7 +13,7 @@ export default {
</script>
<template>
- <div class="circle-icon-container append-right-default align-self-start align-self-lg-center">
+ <div class="circle-icon-container gl-mr-3 align-self-start align-self-lg-center">
<icon :name="name" :size="24" />
</div>
</template>
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 6df53311ef0..a096eb1a1fe 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,21 +1,22 @@
<script>
/* eslint-disable vue/require-default-prop */
-import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline';
-import { sprintf, s__ } from '~/locale';
-import PipelineStage from '~/pipelines/components/stage.vue';
+import { s__ } from '~/locale';
+import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
export default {
name: 'MRWidgetPipeline',
components: {
- PipelineStage,
CiIcon,
- Icon,
- TooltipOnTruncate,
GlLink,
+ GlLoadingIcon,
+ GlIcon,
+ GlSprintf,
+ PipelineStage,
+ TooltipOnTruncate,
LinkedPipelinesMiniList: () =>
import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
},
@@ -54,7 +55,11 @@ export default {
type: String,
required: false,
},
- troubleshootingDocsPath: {
+ mrTroubleshootingDocsPath: {
+ type: String,
+ required: true,
+ },
+ ciTroubleshootingDocsPath: {
type: String,
required: true,
},
@@ -64,10 +69,7 @@ export default {
return this.pipeline && Object.keys(this.pipeline).length > 0;
},
hasCIError() {
- return (this.hasCi && !this.ciStatus) || this.hasPipelineMustSucceedConflict;
- },
- hasPipelineMustSucceedConflict() {
- return !this.hasCi && this.pipelineMustSucceed;
+ return this.hasPipeline && !this.ciStatus;
},
status() {
return this.pipeline.details && this.pipeline.details.status
@@ -82,22 +84,6 @@ export default {
hasCommitInfo() {
return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0;
},
- errorText() {
- if (this.hasPipelineMustSucceedConflict) {
- return s__('Pipeline|No pipeline has been run for this commit.');
- }
-
- return sprintf(
- s__(
- 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.',
- ),
- {
- linkStart: `<a href="${this.troubleshootingDocsPath}">`,
- linkEnd: '</a>',
- },
- false,
- );
- },
isTriggeredByMergeRequest() {
return Boolean(this.pipeline.merge_request);
},
@@ -118,31 +104,69 @@ export default {
return '';
},
},
+ errorText: s__(
+ 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.',
+ ),
+ monitoringPipelineText: s__('Pipeline|Checking pipeline status.'),
};
</script>
<template>
- <div class="ci-widget media js-ci-widget">
- <template v-if="!hasPipeline || hasCIError">
- <div class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error">
- <icon :size="24" name="status_failed_borderless" />
+ <div class="ci-widget media">
+ <template v-if="hasCIError">
+ <gl-icon name="status_failed" class="gl-text-red-500" :size="24" />
+ <div
+ class="gl-flex-fill-1 gl-ml-5"
+ tabindex="0"
+ role="text"
+ :aria-label="$options.errorText"
+ data-testid="ci-error-message"
+ >
+ <gl-sprintf :message="$options.errorText">
+ <template #link="{content}">
+ <gl-link :href="mrTroubleshootingDocsPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </template>
+ <template v-else-if="!hasPipeline">
+ <gl-loading-icon size="md" />
+ <div class="gl-flex-fill-1 gl-display-flex gl-ml-5" data-testid="monitoring-pipeline-message">
+ <span tabindex="0" role="text" :aria-label="$options.monitoringPipelineText">
+ <gl-sprintf :message="$options.monitoringPipelineText" />
+ </span>
+ <gl-link
+ :href="ciTroubleshootingDocsPath"
+ target="_blank"
+ class="gl-display-flex gl-align-items-center gl-ml-2"
+ tabindex="0"
+ >
+ <gl-icon
+ name="question"
+ :small="12"
+ tabindex="0"
+ role="text"
+ :aria-label="__('Link to go to GitLab pipeline documentation')"
+ />
+ </gl-link>
</div>
- <div class="media-body prepend-left-default" v-html="errorText"></div>
</template>
<template v-else-if="hasPipeline">
- <a :href="status.details_path" class="align-self-start append-right-default">
+ <a :href="status.details_path" class="align-self-start gl-mr-3">
<ci-icon :status="status" :size="24" :borderless="true" class="add-border" />
</a>
<div class="ci-widget-container d-flex">
<div class="ci-widget-content">
<div class="media-body">
<div
- class="font-weight-bold js-pipeline-info-container"
+ class="gl-font-weight-bold"
+ data-testid="pipeline-info-container"
data-qa-selector="merge_request_pipeline_info_content"
>
{{ pipeline.details.name }}
<gl-link
:href="pipeline.path"
- class="pipeline-id font-weight-normal pipeline-number"
+ class="pipeline-id gl-font-weight-normal pipeline-number"
+ data-testid="pipeline-id"
data-qa-selector="pipeline_link"
>#{{ pipeline.id }}</gl-link
>
@@ -151,7 +175,8 @@ export default {
{{ s__('Pipeline|for') }}
<gl-link
:href="pipeline.commit.commit_path"
- class="commit-sha js-commit-link font-weight-normal"
+ class="commit-sha gl-font-weight-normal"
+ data-testid="commit-link"
>{{ pipeline.commit.short_id }}</gl-link
>
</template>
@@ -160,18 +185,18 @@ export default {
<tooltip-on-truncate
:title="sourceBranch"
truncate-target="child"
- class="label-branch label-truncate font-weight-normal"
+ class="label-branch label-truncate gl-font-weight-normal"
v-html="sourceBranchLink"
/>
</template>
</div>
- <div v-if="pipeline.coverage" class="coverage">
+ <div v-if="pipeline.coverage" class="coverage" data-testid="pipeline-coverage">
{{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}%
<span
v-if="pipelineCoverageDelta"
- class="js-pipeline-coverage-delta"
:class="coverageDeltaClass"
+ data-testid="pipeline-coverage-delta"
>
({{ pipelineCoverageDelta }}%)
</span>
@@ -189,13 +214,13 @@ export default {
:class="{
'has-downstream': hasDownstream(i),
}"
- class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages"
+ class="stage-container dropdown mr-widget-pipeline-stages"
+ data-testid="widget-mini-pipeline-graph"
>
<pipeline-stage :stage="stage" />
</div>
</template>
</span>
-
<linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" />
</span>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index 8fba0e2981f..5c307b5ff0c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -82,7 +82,8 @@ export default {
:pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds"
:source-branch="branch"
:source-branch-link="branchLink"
- :troubleshooting-docs-path="mr.troubleshootingDocsPath"
+ :mr-troubleshooting-docs-path="mr.mrTroubleshootingDocsPath"
+ :ci-troubleshooting-docs-path="mr.ciTroubleshootingDocsPath"
/>
<template #footer>
<div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index d0df8309dc7..82566682bca 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -33,7 +33,7 @@ export default {
</script>
<template>
<div class="d-flex align-self-start">
- <div class="square s24 h-auto d-flex-center append-right-default">
+ <div class="square s24 h-auto d-flex-center gl-mr-3">
<div v-if="isLoading" class="mr-widget-icon d-inline-flex">
<gl-loading-icon size="md" class="mr-loading-icon d-inline-flex" />
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
index 9942861d9e4..de01821a292 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
@@ -1,22 +1,31 @@
<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
+import { GlLink, GlSprintf, GlButton } from '@gitlab/ui';
import MrWidgetIcon from './mr_widget_icon.vue';
-import PipelineTourState from './states/mr_widget_pipeline_tour.vue';
+import Tracking from '~/tracking';
+import { s__ } from '~/locale';
+
+const trackingMixin = Tracking.mixin();
+const TRACK_LABEL = 'no_pipeline_noticed';
export default {
name: 'MRWidgetSuggestPipeline',
iconName: 'status_notfound',
- popoverTarget: 'suggest-popover',
- popoverContainer: 'suggest-pipeline',
- trackLabel: 'no_pipeline_noticed',
+ trackLabel: TRACK_LABEL,
linkTrackValue: 30,
linkTrackEvent: 'click_link',
+ showTrackValue: 10,
+ showTrackEvent: 'click_button',
+ helpContent: s__(
+ `mrWidget|Use %{linkStart}CI pipelines to test your code%{linkEnd} by simply adding a GitLab CI configuration file to your project. It only takes a minute to make your code more secure and robust.`,
+ ),
+ helpURL: 'https://about.gitlab.com/blog/2019/07/12/guide-to-ci-cd-pipelines/',
components: {
GlLink,
GlSprintf,
+ GlButton,
MrWidgetIcon,
- PipelineTourState,
},
+ mixins: [trackingMixin],
props: {
pipelinePath: {
type: String,
@@ -31,45 +40,89 @@ export default {
required: true,
},
},
+ computed: {
+ tracking() {
+ return {
+ label: TRACK_LABEL,
+ property: this.humanAccess,
+ };
+ },
+ },
+ mounted() {
+ this.track();
+ },
};
</script>
<template>
- <div :id="$options.popoverContainer" class="d-flex mr-pipeline-suggest append-bottom-default">
- <mr-widget-icon :name="$options.iconName" />
- <div :id="$options.popoverTarget">
- <gl-sprintf
- :message="
- s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd}
+ <div class="mr-widget-body mr-pipeline-suggest gl-mb-3">
+ <div class="gl-display-flex gl-align-items-center">
+ <mr-widget-icon :name="$options.iconName" />
+ <div>
+ <gl-sprintf
+ :message="
+ s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd}
%{addPipelineLinkStart}Add the .gitlab-ci.yml file%{addPipelineLinkEnd}
to create one.`)
- "
- >
- <template #prefixToLink="{content}">
+ "
+ >
+ <template #prefixToLink="{content}">
+ <strong>
+ {{ content }}
+ </strong>
+ </template>
+ <template #addPipelineLink="{content}">
+ <gl-link
+ :href="pipelinePath"
+ class="gl-ml-1"
+ data-testid="add-pipeline-link"
+ :data-track-property="humanAccess"
+ :data-track-value="$options.linkTrackValue"
+ :data-track-event="$options.linkTrackEvent"
+ :data-track-label="$options.trackLabel"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-5 order-md-last col-12 gl-mt-5 mt-md-n3 svg-content svg-225">
+ <img data-testid="pipeline-image" :src="pipelineSvgPath" />
+ </div>
+ <div class="col-md-7 order-md-first col-12">
+ <div class="ml-6 gl-pt-5">
<strong>
- {{ content }}
+ {{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }}
</strong>
- </template>
- <template #addPipelineLink="{content}">
- <gl-link
+ <p class="gl-mt-2">
+ <gl-sprintf :message="$options.helpContent">
+ <template #link="{ content }">
+ <gl-link
+ data-testid="help"
+ :href="$options.helpURL"
+ target="_blank"
+ class="font-size-inherit"
+ >{{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-button
+ data-testid="ok"
+ category="primary"
+ class="gl-mt-2"
+ variant="info"
:href="pipelinePath"
- class="ml-2 js-add-pipeline-path"
:data-track-property="humanAccess"
- :data-track-value="$options.linkTrackValue"
- :data-track-event="$options.linkTrackEvent"
+ :data-track-value="$options.showTrackValue"
+ :data-track-event="$options.showTrackEvent"
:data-track-label="$options.trackLabel"
>
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- <pipeline-tour-state
- :pipeline-path="pipelinePath"
- :pipeline-svg-path="pipelineSvgPath"
- :human-access="humanAccess"
- :popover-target="$options.popoverTarget"
- :popover-container="$options.popoverContainer"
- :track-label="$options.trackLabel"
- />
+ {{ __('Show me how to add a pipeline') }}
+ </gl-button>
+ </div>
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue
deleted file mode 100644
index 2ef5e81b36b..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue
+++ /dev/null
@@ -1,139 +0,0 @@
-<script>
-import { __ } from '~/locale';
-import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
-import flash from '~/flash';
-import Poll from '~/lib/utils/poll';
-
-export default {
- name: 'MRWidgetTerraformPlan',
- components: {
- GlIcon,
- GlLink,
- GlLoadingIcon,
- GlSprintf,
- },
- props: {
- endpoint: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- loading: true,
- plans: {},
- };
- },
- computed: {
- addNum() {
- return Number(this.plan.create);
- },
- changeNum() {
- return Number(this.plan.update);
- },
- deleteNum() {
- return Number(this.plan.delete);
- },
- logUrl() {
- return this.plan.job_path;
- },
- plan() {
- const firstPlanKey = Object.keys(this.plans)[0];
- return this.plans[firstPlanKey] ?? {};
- },
- validPlanValues() {
- return this.addNum + this.changeNum + this.deleteNum >= 0;
- },
- },
- created() {
- this.fetchPlans();
- },
- methods: {
- fetchPlans() {
- this.loading = true;
-
- const poll = new Poll({
- resource: {
- fetchPlans: () => axios.get(this.endpoint),
- },
- data: this.endpoint,
- method: 'fetchPlans',
- successCallback: ({ data }) => {
- this.plans = data;
-
- if (Object.keys(this.plan).length) {
- this.loading = false;
- poll.stop();
- }
- },
- errorCallback: () => {
- this.plans = {};
- this.loading = false;
- flash(__('An error occurred while loading terraform report'));
- },
- });
-
- poll.makeRequest();
- },
- },
-};
-</script>
-
-<template>
- <section class="mr-widget-section">
- <div class="mr-widget-body media d-flex flex-row">
- <span class="append-right-default align-self-start align-self-lg-center">
- <gl-icon name="status_warning" :size="24" />
- </span>
-
- <div class="d-flex flex-fill flex-column flex-md-row">
- <div class="terraform-mr-plan-text normal d-flex flex-column flex-lg-row">
- <p class="m-0 pr-1">{{ __('A terraform report was generated in your pipelines.') }}</p>
-
- <gl-loading-icon v-if="loading" size="md" />
-
- <p v-else-if="validPlanValues" class="m-0">
- <gl-sprintf
- :message="
- __(
- 'Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete',
- )
- "
- >
- <template #addNum>
- <strong>{{ addNum }}</strong>
- </template>
-
- <template #changeNum>
- <strong>{{ changeNum }}</strong>
- </template>
-
- <template #deleteNum>
- <strong>{{ deleteNum }}</strong>
- </template>
- </gl-sprintf>
- </p>
-
- <p v-else class="m-0">{{ __('Changes are unknown') }}</p>
- </div>
-
- <div class="terraform-mr-plan-actions">
- <gl-link
- v-if="logUrl"
- :href="logUrl"
- target="_blank"
- data-track-event="click_terraform_mr_plan_button"
- data-track-label="mr_widget_terraform_mr_plan_button"
- data-track-property="terraform_mr_plan_button"
- class="btn btn-sm js-terraform-report-link"
- rel="noopener"
- >
- {{ __('View full log') }}
- <gl-icon name="external-link" />
- </gl-link>
- </div>
- </div>
- </div>
- </section>
-</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
index acd8037cfb2..44bdc4a3be8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
@@ -29,7 +29,7 @@ export default {
<textarea
:id="inputId"
:value="value"
- class="form-control js-gfm-input append-bottom-default commit-message-edit"
+ class="form-control js-gfm-input gl-mb-3 commit-message-edit"
dir="auto"
required="required"
rows="7"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
index e4f4032776b..d52e6d38ac6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
@@ -83,7 +83,7 @@ export default {
<gl-deprecated-button
:aria-label="ariaLabel"
variant="blank"
- class="commit-edit-toggle square s24 append-right-default"
+ class="commit-edit-toggle square s24 gl-mr-3"
@click.stop="toggle()"
>
<icon :name="collapseIcon" :size="16" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index 92848e86e76..f02e0ac84da 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -87,7 +87,7 @@ export default {
<status-icon status="success" />
<div class="media-body">
<h4 class="d-flex align-items-start">
- <span class="append-right-10">
+ <span class="gl-mr-3">
<span class="js-status-text-before-author">{{ statusTextBeforeAuthor }}</span>
<mr-widget-author :author="mr.setToAutoMergeBy" />
<span class="js-status-text-after-author">{{ statusTextAfterAuthor }}</span>
@@ -113,9 +113,7 @@ export default {
{{ s__('mrWidget|The source branch will be deleted') }}
</p>
<p v-else class="d-flex align-items-start">
- <span class="append-right-10">{{
- s__('mrWidget|The source branch will not be deleted')
- }}</span>
+ <span class="gl-mr-3">{{ s__('mrWidget|The source branch will not be deleted') }}</span>
<a
v-if="canRemoveSourceBranch"
:disabled="isRemovingSourceBranch"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
index a5e3115397a..e02be6dc2f7 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -12,7 +12,7 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="loading" />
<div class="media-body space-children">
- <span class="bold"> {{ s__('mrWidget|Checking ability to merge automatically…') }} </span>
+ <span class="bold"> {{ s__('mrWidget|Checking if merge request can be merged…') }} </span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue
deleted file mode 100644
index f6bfb178437..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue
+++ /dev/null
@@ -1,136 +0,0 @@
-<script>
-import { GlPopover, GlDeprecatedButton } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
-import Cookies from 'js-cookie';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import Tracking from '~/tracking';
-
-const trackingMixin = Tracking.mixin();
-
-const cookieKey = 'suggest_pipeline_dismissed';
-
-export default {
- name: 'MRWidgetPipelineTour',
- dismissTrackValue: 20,
- showTrackValue: 10,
- trackEvent: 'click_button',
- components: {
- GlPopover,
- GlDeprecatedButton,
- Icon,
- },
- mixins: [trackingMixin],
- props: {
- pipelinePath: {
- type: String,
- required: true,
- },
- pipelineSvgPath: {
- type: String,
- required: true,
- },
- humanAccess: {
- type: String,
- required: true,
- },
- popoverTarget: {
- type: String,
- required: true,
- },
- popoverContainer: {
- type: String,
- required: true,
- },
- trackLabel: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- popoverDismissed: parseBoolean(Cookies.get(cookieKey)),
- tracking: {
- label: this.trackLabel,
- property: this.humanAccess,
- },
- };
- },
- mounted() {
- this.trackOnShow();
- },
- methods: {
- trackOnShow() {
- if (!this.popoverDismissed) {
- this.track();
- }
- },
- dismissPopover() {
- this.popoverDismissed = true;
- Cookies.set(cookieKey, this.popoverDismissed, { expires: 365 });
- },
- },
-};
-</script>
-<template>
- <gl-popover
- v-if="!popoverDismissed"
- show
- :target="popoverTarget"
- :container="popoverContainer"
- placement="rightbottom"
- >
- <template #title>
- <button
- class="btn-blank float-right mt-1"
- type="button"
- :aria-label="__('Close')"
- :data-track-property="humanAccess"
- :data-track-value="$options.dismissTrackValue"
- :data-track-event="$options.trackEvent"
- :data-track-label="trackLabel"
- @click="dismissPopover"
- >
- <icon name="close" aria-hidden="true" />
- </button>
- {{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }}
- </template>
- <div class="svg-content svg-150 pt-1">
- <img :src="pipelineSvgPath" />
- </div>
- <p>
- {{
- s__(
- 'mrWidget|Detect issues before deployment with a CI pipeline that continuously tests your code. We created a quick guide that will show you how to create one. Make your code more secure and more robust in just a minute.',
- )
- }}
- </p>
- <gl-deprecated-button
- ref="ok"
- category="primary"
- class="mt-2 mb-0"
- variant="info"
- block
- :href="pipelinePath"
- :data-track-property="humanAccess"
- :data-track-value="$options.showTrackValue"
- :data-track-event="$options.trackEvent"
- :data-track-label="trackLabel"
- >
- {{ __('Show me how') }}
- </gl-deprecated-button>
- <gl-deprecated-button
- ref="no-thanks"
- category="secondary"
- class="mt-2 mb-0"
- variant="info"
- block
- :data-track-property="humanAccess"
- :data-track-value="$options.dismissTrackValue"
- :data-track-event="$options.trackEvent"
- :data-track-label="trackLabel"
- @click="dismissPopover"
- >
- {{ __("No thanks, don't show this again") }}
- </gl-deprecated-button>
- </gl-popover>
-</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 82be5eeb5ff..cc43135f50a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -45,7 +45,8 @@ export default {
isMakingRequest: false,
isMergingImmediately: false,
commitMessage: this.mr.commitMessage,
- squashBeforeMerge: this.mr.squash,
+ squashBeforeMerge: this.mr.squashIsSelected,
+ isSquashReadOnly: this.mr.squashIsReadonly,
successSvg,
warningSvg,
squashCommitMessage: this.mr.squashCommitMessage,
@@ -106,7 +107,12 @@ export default {
return this.isMergeButtonDisabled;
},
shouldShowSquashBeforeMerge() {
- const { commitsCount, enableSquashBeforeMerge } = this.mr;
+ const { commitsCount, enableSquashBeforeMerge, squashIsReadonly, squashIsSelected } = this.mr;
+
+ if (squashIsReadonly && !squashIsSelected) {
+ return false;
+ }
+
return enableSquashBeforeMerge && commitsCount > 1;
},
shouldShowMergeControls() {
@@ -344,21 +350,24 @@ export default {
v-if="shouldShowSquashBeforeMerge"
v-model="squashBeforeMerge"
:help-path="mr.squashBeforeMergeHelpPath"
- :is-disabled="isMergeButtonDisabled"
+ :is-disabled="isSquashReadOnly"
/>
</template>
<template v-else>
<div class="bold js-resolve-mr-widget-items-message">
- <gl-sprintf
+ <div
v-if="hasPipelineMustSucceedConflict"
- :message="pipelineMustSucceedConflictText"
+ class="gl-display-flex gl-align-items-center"
>
- <template #link="{ content }">
- <gl-link :href="mr.pipelineMustSucceedDocsPath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
+ <gl-sprintf :message="pipelineMustSucceedConflictText" />
+ <gl-link
+ :href="mr.pipelineMustSucceedDocsPath"
+ target="_blank"
+ class="gl-display-flex gl-ml-2"
+ >
+ <gl-icon name="question" />
+ </gl-link>
+ </div>
<gl-sprintf v-else :message="mergeDisabledText" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index 5305894873f..efd58341a2d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -1,6 +1,7 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
+import { __ } from '~/locale';
export default {
components: {
@@ -25,12 +26,22 @@ export default {
default: false,
},
},
+ computed: {
+ tooltipTitle() {
+ return this.isDisabled ? __('Required in this project.') : false;
+ },
+ },
};
</script>
<template>
<div class="inline">
- <label>
+ <label
+ v-tooltip
+ :class="{ 'gl-text-gray-600': isDisabled }"
+ data-testid="squashLabel"
+ :data-title="tooltipTitle"
+ >
<input
:checked="value"
:disabled="isDisabled"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue
new file mode 100644
index 00000000000..f6e21dc1ec1
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue
@@ -0,0 +1,140 @@
+<script>
+import { n__ } from '~/locale';
+import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import MrWidgetExpanableSection from '../mr_widget_expandable_section.vue';
+import Poll from '~/lib/utils/poll';
+import TerraformPlan from './terraform_plan.vue';
+
+export default {
+ name: 'MRWidgetTerraformContainer',
+ components: {
+ GlSkeletonLoading,
+ GlSprintf,
+ MrWidgetExpanableSection,
+ TerraformPlan,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ loading: true,
+ plansObject: {},
+ poll: null,
+ };
+ },
+ computed: {
+ inValidPlanCountText() {
+ if (this.numberOfInvalidPlans === 0) {
+ return null;
+ }
+
+ return n__(
+ 'Terraform|%{number} Terraform report failed to generate',
+ 'Terraform|%{number} Terraform reports failed to generate',
+ this.numberOfInvalidPlans,
+ );
+ },
+ numberOfInvalidPlans() {
+ return Object.values(this.plansObject).filter(plan => plan.tf_report_error).length;
+ },
+ numberOfPlans() {
+ return Object.keys(this.plansObject).length;
+ },
+ numberOfValidPlans() {
+ return this.numberOfPlans - this.numberOfInvalidPlans;
+ },
+ validPlanCountText() {
+ if (this.numberOfValidPlans === 0) {
+ return null;
+ }
+
+ return n__(
+ 'Terraform|%{number} Terraform report was generated in your pipelines',
+ 'Terraform|%{number} Terraform reports were generated in your pipelines',
+ this.numberOfValidPlans,
+ );
+ },
+ },
+ created() {
+ this.fetchPlans();
+ },
+ beforeDestroy() {
+ this.poll.stop();
+ },
+ methods: {
+ fetchPlans() {
+ this.loading = true;
+
+ this.poll = new Poll({
+ resource: {
+ fetchPlans: () => axios.get(this.endpoint),
+ },
+ data: this.endpoint,
+ method: 'fetchPlans',
+ successCallback: ({ data }) => {
+ this.plansObject = data;
+
+ if (this.numberOfPlans > 0) {
+ this.loading = false;
+ this.poll.stop();
+ }
+ },
+ errorCallback: () => {
+ this.plansObject = { bad_plan: { tf_report_error: 'api_error' } };
+ this.loading = false;
+ this.poll.stop();
+ },
+ });
+
+ this.poll.makeRequest();
+ },
+ },
+};
+</script>
+
+<template>
+ <section class="mr-widget-section">
+ <div v-if="loading" class="mr-widget-body">
+ <gl-skeleton-loading />
+ </div>
+
+ <mr-widget-expanable-section v-else>
+ <template #header>
+ <div
+ data-testid="terraform-header-text"
+ class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column"
+ >
+ <p v-if="validPlanCountText" class="gl-m-0">
+ <gl-sprintf :message="validPlanCountText">
+ <template #number>
+ <strong>{{ numberOfValidPlans }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p v-if="inValidPlanCountText" class="gl-m-0">
+ <gl-sprintf :message="inValidPlanCountText">
+ <template #number>
+ <strong>{{ numberOfInvalidPlans }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ </template>
+
+ <template #content>
+ <terraform-plan
+ v-for="(plan, key) in plansObject"
+ :key="key"
+ :plan="plan"
+ class="mr-widget-body"
+ />
+ </template>
+ </mr-widget-expanable-section>
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue
new file mode 100644
index 00000000000..dc16d46dd8e
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue
@@ -0,0 +1,111 @@
+<script>
+import { s__ } from '~/locale';
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+
+export default {
+ name: 'TerraformPlan',
+ components: {
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ plan: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ addNum() {
+ return Number(this.plan.create);
+ },
+ changeNum() {
+ return Number(this.plan.update);
+ },
+ deleteNum() {
+ return Number(this.plan.delete);
+ },
+ iconType() {
+ return this.validPlanValues ? 'doc-changes' : 'warning';
+ },
+ reportChangeText() {
+ if (this.validPlanValues) {
+ return s__(
+ 'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete',
+ );
+ }
+
+ return s__('Terraform|Generating the report caused an error.');
+ },
+ reportHeaderText() {
+ if (this.validPlanValues) {
+ return this.plan.job_name
+ ? s__('Terraform|The Terraform report %{name} was generated in your pipelines.')
+ : s__('Terraform|A Terraform report was generated in your pipelines.');
+ }
+
+ return this.plan.job_name
+ ? s__('Terraform|The Terraform report %{name} failed to generate.')
+ : s__('Terraform|A Terraform report failed to generate.');
+ },
+ validPlanValues() {
+ return this.addNum + this.changeNum + this.deleteNum >= 0;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex">
+ <span
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-mr-3 gl-align-self-start gl-mt-1"
+ >
+ <gl-icon :name="iconType" :size="18" data-testid="change-type-icon" />
+ </span>
+
+ <div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column flex-md-row">
+ <div class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column">
+ <p class="gl-m-0 gl-pr-1">
+ <gl-sprintf :message="reportHeaderText">
+ <template #name>
+ <strong>{{ plan.job_name }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p class="gl-m-0">
+ <gl-sprintf :message="reportChangeText">
+ <template #addNum>
+ <strong>{{ addNum }}</strong>
+ </template>
+
+ <template #changeNum>
+ <strong>{{ changeNum }}</strong>
+ </template>
+
+ <template #deleteNum>
+ <strong>{{ deleteNum }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+
+ <div>
+ <gl-link
+ v-if="plan.job_path"
+ :href="plan.job_path"
+ target="_blank"
+ data-testid="terraform-report-link"
+ data-track-event="click_terraform_mr_plan_button"
+ data-track-label="mr_widget_terraform_mr_plan_button"
+ data-track-property="terraform_mr_plan_button"
+ class="btn btn-sm"
+ rel="noopener"
+ >
+ {{ __('View full log') }}
+ <gl-icon name="external-link" />
+ </gl-link>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index 6f6d145815e..1002bb728a0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -1,3 +1,4 @@
+export const SUCCESS = 'success';
export const WARNING = 'warning';
export const DANGER = 'danger';
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 7a9ef7e496e..068829912bf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -1,15 +1,23 @@
import Vue from 'vue';
import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue';
import Translate from '../vue_shared/translate';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
Vue.use(Translate);
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
export default () => {
if (gl.mrWidget) return;
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
+ gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url;
- const vm = new Vue(MrWidgetOptions);
+ const vm = new Vue({ ...MrWidgetOptions, apolloProvider });
window.gl.mrWidget = {
checkStatus: vm.checkStatus,
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
new file mode 100644
index 00000000000..e50555ca875
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
@@ -0,0 +1,19 @@
+import { hideFlash } from '~/flash';
+
+export default {
+ methods: {
+ clearError() {
+ this.$emit('clearError');
+ this.hasApprovalAuthError = false;
+ const flashEl = document.querySelector('.flash-alert');
+ if (flashEl) {
+ hideFlash(flashEl);
+ }
+ },
+ refreshApprovals() {
+ return this.service.fetchApprovals().then(data => {
+ this.mr.setApprovals(data);
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index 39fa5e465b8..319b6c333f4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -2,7 +2,7 @@ import { __ } from '~/locale';
export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
export const PIPELINE_MUST_SUCCEED_CONFLICT_TEXT = __(
- 'Pipelines must succeed for merge requests to be eligible to merge. Please enable pipelines for this project to continue. For more information, see the %{linkStart}documentation.%{linkEnd}',
+ 'A CI/CD pipeline must run and be successful before merge.',
);
export default {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 265ff81f39f..cff85fe232d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -2,6 +2,7 @@
import { isEmpty } from 'lodash';
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
+import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project';
@@ -36,7 +37,8 @@ import CheckingState from './components/states/mr_widget_checking.vue';
import eventHub from './event_hub';
import notify from '~/lib/utils/notify';
import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue';
-import TerraformPlan from './components/mr_widget_terraform_plan.vue';
+import TerraformPlan from './components/terraform/mr_widget_terraform_container.vue';
+import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue';
import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
import { setFaviconOverlay } from '../lib/utils/common_utils';
import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
@@ -75,9 +77,11 @@ export default {
'mr-widget-auto-merge-failed': AutoMergeFailed,
'mr-widget-rebase': RebaseState,
SourceBranchRemovalStatus,
+ GroupedCodequalityReportsApp,
GroupedTestReportsApp,
TerraformPlan,
GroupedAccessibilityReportsApp,
+ MrWidgetApprovals,
},
props: {
mrData: {
@@ -96,6 +100,9 @@ export default {
};
},
computed: {
+ shouldRenderApprovals() {
+ return this.mr.state !== 'nothingToMerge';
+ },
componentName() {
return stateMaps.stateToComponentMap[this.mr.state];
},
@@ -111,6 +118,9 @@ export default {
shouldSuggestPipelines() {
return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath;
},
+ shouldRenderCodeQuality() {
+ return this.mr?.codeclimate?.head_path;
+ },
shouldRenderRelatedLinks() {
return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState;
},
@@ -216,6 +226,9 @@ export default {
mergeRequestCachedWidgetPath: store.mergeRequestCachedWidgetPath,
mergeActionsContentPath: store.mergeActionsContentPath,
rebasePath: store.rebasePath,
+ apiApprovalsPath: store.apiApprovalsPath,
+ apiApprovePath: store.apiApprovePath,
+ apiUnapprovePath: store.apiUnapprovePath,
};
},
createService(store) {
@@ -365,7 +378,7 @@ export default {
};
</script>
<template>
- <div v-if="mr" class="mr-state-widget prepend-top-default">
+ <div v-if="mr" class="mr-state-widget gl-mt-3">
<mr-widget-header :mr="mr" />
<mr-widget-suggest-pipeline
v-if="shouldSuggestPipelines"
@@ -379,11 +392,27 @@ export default {
class="mr-widget-workflow"
:mr="mr"
/>
+ <mr-widget-approvals
+ v-if="shouldRenderApprovals"
+ class="mr-widget-workflow"
+ :mr="mr"
+ :service="service"
+ />
<div class="mr-section-container mr-widget-workflow">
+ <grouped-codequality-reports-app
+ v-if="shouldRenderCodeQuality"
+ :base-path="mr.codeclimate.base_path"
+ :head-path="mr.codeclimate.head_path"
+ :head-blob-path="mr.headBlobPath"
+ :base-blob-path="mr.baseBlobPath"
+ :codequality-help-path="mr.codequalityHelpPath"
+ />
+
<grouped-test-reports-app
v-if="mr.testResultsPath"
class="js-reports-container"
:endpoint="mr.testResultsPath"
+ :pipeline-path="mr.pipeline.path"
/>
<terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
index c620023a6d6..ee9e3cc6d08 100644
--- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
+++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
@@ -3,6 +3,10 @@ import axios from '../../lib/utils/axios_utils';
export default class MRWidgetService {
constructor(endpoints) {
this.endpoints = endpoints;
+
+ this.apiApprovalsPath = endpoints.apiApprovalsPath;
+ this.apiApprovePath = endpoints.apiApprovePath;
+ this.apiUnapprovePath = endpoints.apiUnapprovePath;
}
merge(data) {
@@ -54,6 +58,18 @@ export default class MRWidgetService {
return axios.post(this.endpoints.rebasePath);
}
+ fetchApprovals() {
+ return axios.get(this.apiApprovalsPath).then(res => res.data);
+ }
+
+ approveMergeRequest() {
+ return axios.post(this.apiApprovePath).then(res => res.data);
+ }
+
+ unapproveMergeRequest() {
+ return axios.post(this.apiUnapprovePath).then(res => res.data);
+ }
+
static executeInlineAction(url) {
return axios.post(url);
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index a2ee0bc3ca1..44e8167d6a3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -11,12 +11,12 @@ export default function deviseState(data) {
return stateKey.checking;
} else if (data.has_conflicts) {
return stateKey.conflicts;
- } else if (data.work_in_progress) {
- return stateKey.workInProgress;
} else if (this.shouldBeRebased) {
return stateKey.rebase;
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return stateKey.pipelineFailed;
+ } else if (data.work_in_progress) {
+ return stateKey.workInProgress;
} else if (this.hasMergeableDiscussionsState) {
return stateKey.unresolvedDiscussions;
} else if (this.isPipelineBlocked) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index d61e122d612..8b9799d9775 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -9,12 +9,19 @@ export default class MergeRequestStore {
this.sha = data.diff_head_sha;
this.gitlabLogo = data.gitlabLogo;
+ this.apiApprovalsPath = data.api_approvals_path;
+ this.apiApprovePath = data.api_approve_path;
+ this.apiUnapprovePath = data.api_unapprove_path;
+ this.hasApprovalsAvailable = data.has_approvals_available;
+
this.setPaths(data);
this.setData(data);
}
setData(data, isRebased) {
+ this.initApprovals();
+
if (isRebased) {
this.sha = data.diff_head_sha;
}
@@ -22,7 +29,10 @@ export default class MergeRequestStore {
const pipelineStatus = data.pipeline ? data.pipeline.details.status : null;
this.squash = data.squash;
+ this.squashIsEnabledByDefault = data.squash_enabled_by_default;
+ this.squashIsReadonly = data.squash_readonly;
this.enableSquashBeforeMerge = this.enableSquashBeforeMerge || true;
+ this.squashIsSelected = data.squash_readonly ? data.squash_on_merge : data.squash;
this.iid = data.iid;
this.title = data.title;
@@ -49,6 +59,7 @@ export default class MergeRequestStore {
this.squashCommitMessage = data.default_squash_commit_message;
this.rebaseInProgress = data.rebase_in_progress;
this.mergeRequestDiffsPath = data.diffs_path;
+ this.approvalsWidgetType = data.approvals_widget_type;
if (data.issues_links) {
const links = data.issues_links;
@@ -160,7 +171,8 @@ export default class MergeRequestStore {
setPaths(data) {
// Paths are set on the first load of the page and not auto-refreshed
this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path;
- this.troubleshootingDocsPath = data.troubleshooting_docs_path;
+ this.mrTroubleshootingDocsPath = data.mr_troubleshooting_docs_path;
+ this.ciTroubleshootingDocsPath = data.ci_troubleshooting_docs_path;
this.pipelineMustSucceedDocsPath = data.pipeline_must_succeed_docs_path;
this.mergeRequestBasicPath = data.merge_request_basic_path;
this.mergeRequestWidgetPath = data.merge_request_widget_path;
@@ -177,10 +189,18 @@ export default class MergeRequestStore {
this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
+ this.approvalsHelpPath = data.approvals_help_path;
this.mergeRequestAddCiConfigPath = data.merge_request_add_ci_config_path;
this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path;
this.humanAccess = data.human_access;
this.newPipelinePath = data.new_project_pipeline_path;
+
+ // codeclimate
+ const blobPath = data.blob_path || {};
+ this.headBlobPath = blobPath.head_path || '';
+ this.baseBlobPath = blobPath.base_path || '';
+ this.codequalityHelpPath = data.codequality_help_path;
+ this.codeclimate = data.codeclimate;
}
get isNothingToMergeState() {
@@ -240,4 +260,14 @@ export default class MergeRequestStore {
return undefined;
}
+
+ initApprovals() {
+ this.isApproved = this.isApproved || false;
+ this.approvals = this.approvals || null;
+ }
+
+ setApprovals(data) {
+ this.approvals = data;
+ this.isApproved = data.approved || false;
+ }
}
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 9f6f3d2d63a..d6f591ccca1 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -261,7 +261,7 @@ export default {
</li>
</template>
<li v-else class="dropdown-menu-empty-item">
- <div class="append-right-default prepend-left-default gl-mt-3 gl-mb-3">
+ <div class="gl-mr-3 gl-ml-3 gl-mt-3 gl-mb-3">
<template v-if="loading">
{{ __('Loading...') }}
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
index 590501a975a..79c62cd9938 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
@@ -88,7 +88,7 @@ export default {
>
</span>
</strong>
- <span class="diff-changed-file-path prepend-top-5">
+ <span class="diff-changed-file-path gl-mt-2">
<span
v-for="(char, charIndex) in pathWithEllipsis.split('')"
:key="charIndex + char"
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
index b084ebdf774..7484486d6b4 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import getIconForFile from './file_icon/file_icon_map';
+import { FILE_SYMLINK_MODE } from '../constants';
/* This is a re-usable vue component for rendering a svg sprite
icon
@@ -24,6 +25,11 @@ export default {
type: String,
required: true,
},
+ fileMode: {
+ type: String,
+ required: false,
+ default: '',
+ },
folder: {
type: Boolean,
@@ -60,8 +66,12 @@ export default {
},
},
computed: {
+ isSymlink() {
+ return this.fileMode === FILE_SYMLINK_MODE;
+ },
spriteHref() {
const iconName = this.submodule ? 'folder-git' : getIconForFile(this.fileName) || 'file';
+
return `${gon.sprite_file_icons}#${iconName}`;
},
folderIconName() {
@@ -75,13 +85,11 @@ export default {
</script>
<template>
<span>
- <svg v-if="!loading && !folder" :class="[iconSizeClass, cssClasses]">
- <use v-bind="{ 'xlink:href': spriteHref }" /></svg
- ><gl-icon
- v-if="!loading && folder"
- :name="folderIconName"
- :size="size"
- class="folder-icon"
- /><gl-loading-icon v-if="loading" :inline="true" />
+ <gl-loading-icon v-if="loading" :inline="true" />
+ <gl-icon v-else-if="isSymlink" name="symlink" :size="size" />
+ <svg v-else-if="!folder" :class="[iconSizeClass, cssClasses]">
+ <use v-bind="{ 'xlink:href': spriteHref }" />
+ </svg>
+ <gl-icon v-else :name="folderIconName" :size="size" class="folder-icon" />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 0cc96309a92..0952e37e46e 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -118,7 +118,12 @@ export default {
@mouseleave="$emit('mouseleave', $event)"
>
<div class="file-row-name-container">
- <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated">
+ <span
+ ref="textOutput"
+ :style="levelIndentation"
+ class="file-row-name str-truncated"
+ data-qa-selector="file_name_content"
+ >
<file-icon
class="file-row-icon"
:class="{ 'text-secondary': file.type === 'tree' }"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index a858ffdbed5..04090213218 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -83,6 +83,7 @@ export default {
return {
initialRender: true,
recentSearchesPromise: null,
+ recentSearches: [],
filterValue: this.initialFilterValue,
selectedSortOption,
selectedSortDirection,
@@ -98,6 +99,15 @@ export default {
{},
);
},
+ tokenTitles() {
+ return this.tokens.reduce(
+ (tokenSymbols, token) => ({
+ ...tokenSymbols,
+ [token.type]: token.title,
+ }),
+ {},
+ );
+ },
sortDirectionIcon() {
return this.selectedSortDirection === SortDirection.ascending
? 'sort-lowest'
@@ -112,11 +122,10 @@ export default {
watch: {
/**
* GlFilteredSearch currently doesn't emit any event when
- * search field is cleared, but we still want our parent
- * component to know that filters were cleared and do
- * necessary data refetch, so this watcher is basically
- * a dirty hack/workaround to identify if filter input
- * was cleared. :(
+ * tokens are manually removed from search field so we'd
+ * never know when user actually clears all the tokens.
+ * This watcher listens for updates to `filterValue` on
+ * such instances. :(
*/
filterValue(value) {
const [firstVal] = value;
@@ -172,11 +181,9 @@ export default {
this.recentSearchesStore.state.recentSearches.concat(searches),
);
this.recentSearchesService.save(resultantSearches);
+ this.recentSearches = resultantSearches;
});
},
- getRecentSearches() {
- return this.recentSearchesStore?.state.recentSearches;
- },
handleSortOptionClick(sortBy) {
this.selectedSortOption = sortBy;
this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]);
@@ -188,26 +195,22 @@ export default {
: SortDirection.ascending;
this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
},
+ handleHistoryItemSelected(filters) {
+ this.$emit('onFilter', filters);
+ },
+ handleClearHistory() {
+ const resultantSearches = this.recentSearchesStore.setRecentSearches([]);
+ this.recentSearchesService.save(resultantSearches);
+ this.recentSearches = [];
+ },
handleFilterSubmit(filters) {
if (this.recentSearchesStorageKey) {
this.recentSearchesPromise
.then(() => {
if (filters.length) {
- const searchTokens = filters.map(filter => {
- // check filter was plain text search
- if (typeof filter === 'string') {
- return filter;
- }
- // filter was a token.
- return `${filter.type}:${filter.value.operator}${this.tokenSymbols[filter.type]}${
- filter.value.data
- }`;
- });
-
- const resultantSearches = this.recentSearchesStore.addRecentSearch(
- searchTokens.join(' '),
- );
+ const resultantSearches = this.recentSearchesStore.addRecentSearch(filters);
this.recentSearchesService.save(resultantSearches);
+ this.recentSearches = resultantSearches;
}
})
.catch(() => {
@@ -226,10 +229,24 @@ export default {
v-model="filterValue"
:placeholder="searchInputPlaceholder"
:available-tokens="tokens"
- :history-items="getRecentSearches()"
+ :history-items="recentSearches"
class="flex-grow-1"
+ @history-item-selected="handleHistoryItemSelected"
+ @clear-history="handleClearHistory"
@submit="handleFilterSubmit"
- />
+ >
+ <template #history-item="{ historyItem }">
+ <template v-for="(token, index) in historyItem">
+ <span v-if="typeof token === 'string'" :key="index" class="gl-px-1">"{{ token }}"</span>
+ <span v-else :key="`${token.type}-${token.value.data}`" class="gl-px-1">
+ <span v-if="tokenTitles[token.type]"
+ >{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span
+ >
+ <strong>{{ tokenSymbols[token.type] }}{{ token.value.data }}</strong>
+ </span>
+ </template>
+ </template>
+ </gl-filtered-search>
<gl-button-group class="sort-dropdown-container d-flex">
<gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100">
<gl-dropdown-item
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index 412bfa5aa7f..d50649d2581 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -46,6 +46,16 @@ export default {
return this.authors.find(author => author.username.toLowerCase() === this.currentValue);
},
},
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.authors.length) {
+ this.fetchAuthorBySearchTerm(this.value.data);
+ }
+ },
+ },
+ },
methods: {
fetchAuthorBySearchTerm(searchTerm) {
const fetchPromise = this.config.fetchPath
@@ -89,9 +99,9 @@ export default {
<span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span>
</template>
<template #suggestions>
- <gl-filtered-search-suggestion :value="$options.anyAuthor">{{
- __('Any')
- }}</gl-filtered-search-suggestion>
+ <gl-filtered-search-suggestion :value="$options.anyAuthor">
+ {{ __('Any') }}
+ </gl-filtered-search-suggestion>
<gl-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index a7fba5e760b..0ef4f1eda27 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -3,18 +3,19 @@ import { escape } from 'lodash';
import Tribute from 'tributejs';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
/**
* Creates the HTML template for each row of the mentions dropdown.
*
- * @param original An object from the array returned from the `autocomplete_sources/members` API
- * @returns {string} An HTML template
+ * @param original - An object from the array returned from the `autocomplete_sources/members` API
+ * @returns {string} - An HTML template
*/
function menuItemTemplate({ original }) {
const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
- gl-display-inline-flex gl-align-items-center gl-justify-content-center`;
+ gl-display-inline-flex! gl-align-items-center gl-justify-content-center`;
const avatarTag = original.avatar_url
? `<img
@@ -48,6 +49,7 @@ export default {
},
data() {
return {
+ assignees: undefined,
members: undefined,
};
},
@@ -76,19 +78,37 @@ export default {
*/
getMembers(inputText, processValues) {
if (this.members) {
- processValues(this.members);
+ processValues(this.getFilteredMembers());
} else if (this.dataSources.members) {
axios
.get(this.dataSources.members)
.then(response => {
this.members = response.data;
- processValues(response.data);
+ processValues(this.getFilteredMembers());
})
.catch(() => {});
} else {
processValues([]);
}
},
+ getFilteredMembers() {
+ const fullText = this.$slots.default[0].elm.value;
+
+ if (!this.assignees) {
+ this.assignees =
+ SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || [];
+ }
+
+ if (fullText.startsWith('/assign @')) {
+ return this.members.filter(member => !this.assignees.includes(member.username));
+ }
+
+ if (fullText.startsWith('/unassign @')) {
+ return this.members.filter(member => this.assignees.includes(member.username));
+ }
+
+ return this.members;
+ },
},
render(createElement) {
return createElement('div', this.$slots.default);
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
index df6fadf10cd..e14f6a04d3c 100644
--- a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
@@ -52,6 +52,14 @@ export default {
// $root.$emit is a workaround because other b-modal approaches don't work yet with gl-modal
this.$root.$emit('bv::hide::modal', this.modalId);
},
+ cancel() {
+ this.$emit('cancel');
+ this.syncHide();
+ },
+ ok() {
+ this.$emit('ok');
+ this.syncHide();
+ },
},
};
</script>
@@ -65,5 +73,6 @@ export default {
@hidden="syncHide"
>
<slot></slot>
+ <slot slot="modal-footer" name="modal-footer" :ok="ok" :cancel="cancel"></slot>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index 63de1e009fd..caf13bc898b 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -82,7 +82,7 @@ export default {
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
- class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0"
+ class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0"
:aria-label="__('Confidential')"
/>
<a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a>
diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue
index 3508c557289..59ce632c4a2 100644
--- a/app/assets/javascripts/vue_shared/components/loading_button.vue
+++ b/app/assets/javascripts/vue_shared/components/loading_button.vue
@@ -47,7 +47,7 @@ export default {
v-if="loading"
:inline="true"
:class="{
- 'append-right-5': label,
+ 'gl-mr-2': label,
}"
class="js-loading-button-icon"
/>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 0e05f4a4622..f954b8eb4f4 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -4,21 +4,25 @@ import '~/behaviors/markdown/render_gfm';
import { unescape } from 'lodash';
import { __, sprintf } from '~/locale';
import { stripHtml } from '~/lib/utils/text_utility';
-import Flash from '../../../flash';
-import GLForm from '../../../gl_form';
-import markdownHeader from './header.vue';
-import markdownToolbar from './toolbar.vue';
-import icon from '../icon.vue';
+import Flash from '~/flash';
+import GLForm from '~/gl_form';
+import MarkdownHeader from './header.vue';
+import MarkdownToolbar from './toolbar.vue';
+import Icon from '../icon.vue';
+import GlMentions from '~/vue_shared/components/gl_mentions.vue';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import axios from '~/lib/utils/axios_utils';
export default {
components: {
- markdownHeader,
- markdownToolbar,
- icon,
+ GlMentions,
+ MarkdownHeader,
+ MarkdownToolbar,
+ Icon,
Suggestions,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
isSubmitting: {
type: Boolean,
@@ -159,12 +163,10 @@ export default {
},
},
mounted() {
- /*
- GLForm class handles all the toolbar buttons
- */
+ // GLForm class handles all the toolbar buttons
return new GLForm($(this.$refs['gl-form']), {
emojis: this.enableAutocomplete,
- members: this.enableAutocomplete,
+ members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
issues: this.enableAutocomplete,
mergeRequests: this.enableAutocomplete,
epics: this.enableAutocomplete,
@@ -229,7 +231,7 @@ export default {
<template>
<div
ref="gl-form"
- :class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }"
+ :class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }"
class="js-vue-markdown-field md-area position-relative"
>
<markdown-header
@@ -243,7 +245,10 @@ export default {
/>
<div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop">
- <slot name="textarea"></slot>
+ <gl-mentions v-if="glFeatures.tributeAutocomplete">
+ <slot name="textarea"></slot>
+ </gl-mentions>
+ <slot v-else name="textarea"></slot>
<a
class="zen-control zen-control-leave js-zen-leave gl-text-gray-700"
href="#"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index aa1abb5adb6..049f5e71849 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -89,14 +89,13 @@ export default {
<div class="md-header">
<ul class="nav-links clearfix">
<li :class="{ active: !previewMarkdown }" class="md-header-tab">
- <button class="js-write-link" tabindex="-1" type="button" @click="writeMarkdownTab($event)">
+ <button class="js-write-link" type="button" @click="writeMarkdownTab($event)">
{{ __('Write') }}
</button>
</li>
<li :class="{ active: previewMarkdown }" class="md-header-tab">
<button
class="js-preview-link js-md-preview-button"
- tabindex="-1"
type="button"
@click="previewMarkdownTab($event)"
>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index 6dac448d5de..13c42d35b04 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -68,6 +68,7 @@ export default {
:is-applying-batch="suggestion.is_applying_batch"
:batch-suggestions-count="batchSuggestionsCount"
:help-page-path="helpPagePath"
+ :inapplicable-reason="suggestion.inapplicable_reason"
@apply="applySuggestion"
@applyBatch="applySuggestionBatch"
@addToBatch="addSuggestionToBatch"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index e26ff51e01e..4de80e9b4c2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -38,6 +38,11 @@ export default {
type: String,
required: true,
},
+ inapplicableReason: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -52,14 +57,7 @@ export default {
return this.isApplyingSingle || this.isApplyingBatch;
},
tooltipMessage() {
- return this.canApply
- ? __('This also resolves the discussion')
- : __("Can't apply as this line has changed or the suggestion already matches its content.");
- },
- tooltipMessageBatch() {
- return !this.canBeBatched
- ? __("Suggestions that change line count can't be added to batches, yet.")
- : this.tooltipMessage;
+ return this.canApply ? __('This also resolves this thread') : this.inapplicableReason;
},
isDisableButton() {
return this.isApplying || !this.canApply;
@@ -129,15 +127,14 @@ export default {
</gl-deprecated-button>
</div>
<div v-else class="d-flex align-items-center">
- <span v-if="canBeBatched" v-gl-tooltip.viewport="tooltipMessageBatch" tabindex="0">
- <gl-deprecated-button
- class="btn-inverted js-add-to-batch-btn btn-grouped"
- :disabled="isDisableButton"
- @click="addSuggestionToBatch"
- >
- {{ __('Add suggestion to batch') }}
- </gl-deprecated-button>
- </span>
+ <gl-deprecated-button
+ v-if="canBeBatched && !isDisableButton"
+ class="btn-inverted js-add-to-batch-btn btn-grouped"
+ :disabled="isDisableButton"
+ @click="addSuggestionToBatch"
+ >
+ {{ __('Add suggestion to batch') }}
+ </gl-deprecated-button>
<span v-gl-tooltip.viewport="tooltipMessage" tabindex="0">
<gl-deprecated-button
class="btn-inverted js-apply-btn btn-grouped"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 330785c9319..5d47aed9643 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -61,7 +61,7 @@ export default {
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
<template>
- <gl-icon name="media" :size="16" />
+ <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" />
</template>
<span class="attaching-file-message"></span>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
@@ -71,7 +71,7 @@ export default {
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
<template>
- <gl-icon name="media" :size="16" />
+ <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" />
</template>
</span>
<span class="uploading-error-message"></span>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index 94f78c0c085..f37dd9e171c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -64,7 +64,6 @@ export default {
:aria-label="buttonTitle"
type="button"
class="toolbar-btn js-md"
- tabindex="-1"
data-container="body"
@click="() => $emit('click')"
>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
index cb3cd18e5a7..f986b105f20 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
@@ -8,6 +8,12 @@ function buildDocsLinkStart(path) {
return `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`;
}
+const NoteableTypeText = {
+ Issue: __('issue'),
+ Epic: __('epic'),
+ MergeRequest: __('merge request'),
+};
+
export default {
components: {
icon,
@@ -24,12 +30,18 @@ export default {
default: false,
required: false,
},
- lockedIssueDocsPath: {
+ noteableType: {
+ type: String,
+ required: false,
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ default: 'Issue',
+ },
+ lockedNoteableDocsPath: {
type: String,
required: false,
default: '',
},
- confidentialIssueDocsPath: {
+ confidentialNoteableDocsPath: {
type: String,
required: false,
default: '',
@@ -45,19 +57,33 @@ export default {
isLockedAndConfidential() {
return this.isConfidential && this.isLocked;
},
+ noteableTypeText() {
+ return NoteableTypeText[this.noteableType];
+ },
confidentialAndLockedDiscussionText() {
return sprintf(
__(
- 'This issue is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.',
+ 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.',
),
{
- confidentialLinkStart: buildDocsLinkStart(this.confidentialIssueDocsPath),
- lockedLinkStart: buildDocsLinkStart(this.lockedIssueDocsPath),
+ noteableTypeText: this.noteableTypeText,
+ confidentialLinkStart: buildDocsLinkStart(this.confidentialNoteableDocsPath),
+ lockedLinkStart: buildDocsLinkStart(this.lockedNoteableDocsPath),
linkEnd: '</a>',
},
false,
);
},
+ confidentialContextText() {
+ return sprintf(__('This is a confidential %{noteableTypeText}.'), {
+ noteableTypeText: this.noteableTypeText,
+ });
+ },
+ lockedContextText() {
+ return sprintf(__('This %{noteableTypeText} is locked.'), {
+ noteableTypeText: this.noteableTypeText,
+ });
+ },
},
};
</script>
@@ -73,19 +99,15 @@ export default {
</span>
<span v-else-if="isConfidential" ref="confidential">
- {{ __('This is a confidential issue.') }}
+ {{ confidentialContextText }}
{{ __('People without permission will never get a notification.') }}
- <gl-link :href="confidentialIssueDocsPath" target="_blank">
- {{ __('Learn more') }}
- </gl-link>
+ <gl-link :href="confidentialNoteableDocsPath" target="_blank">{{ __('Learn more') }}</gl-link>
</span>
<span v-else-if="isLocked" ref="locked">
- {{ __('This issue is locked.') }}
+ {{ lockedContextText }}
{{ __('Only project members can comment.') }}
- <gl-link :href="lockedIssueDocsPath" target="_blank">
- {{ __('Learn more') }}
- </gl-link>
+ <gl-link :href="lockedNoteableDocsPath" target="_blank">{{ __('Learn more') }}</gl-link>
</span>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index b6271a95008..fe57d4f29ca 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -122,7 +122,7 @@ export default {
></div>
<div v-if="hasMoreCommits" class="flex-list">
<div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded">
- <icon :name="toggleIcon" :size="8" class="append-right-5" />
+ <icon :name="toggleIcon" :size="8" class="gl-mr-2" />
<span>{{ __('Toggle commit list') }}</span>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
index 29a4a90a59f..5f2a66ee0b7 100644
--- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
+++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
@@ -20,7 +20,7 @@ export default {
Here is an example `change` method:
change(pagenum) {
- gl.utils.visitUrl(`?page=${pagenum}`);
+ visitUrl(`?page=${pagenum}`);
},
*/
change: {
@@ -64,7 +64,7 @@ export default {
<template>
<gl-pagination
v-if="showPagination"
- class="justify-content-center prepend-top-default"
+ class="justify-content-center gl-mt-3"
v-bind="$attrs"
:value="pageInfo.page"
:per-page="pageInfo.perPage"
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index 3d52f4176db..e053a9ddaa6 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -8,30 +8,25 @@ import { truncateNamespace } from '~/lib/utils/text_utility';
export default {
name: 'ProjectListItem',
- components: {
- Icon,
- ProjectAvatar,
- GlDeprecatedButton,
- },
+ components: { Icon, ProjectAvatar, GlDeprecatedButton },
props: {
project: {
type: Object,
required: true,
- validator: p => Number.isFinite(p.id) && isString(p.name) && isString(p.name_with_namespace),
- },
- selected: {
- type: Boolean,
- required: true,
- },
- matcher: {
- type: String,
- required: false,
- default: '',
+ validator: p =>
+ (Number.isFinite(p.id) || isString(p.id)) &&
+ isString(p.name) &&
+ (isString(p.name_with_namespace) || isString(p.nameWithNamespace)),
},
+ selected: { type: Boolean, required: true },
+ matcher: { type: String, required: false, default: '' },
},
computed: {
+ projectNameWithNamespace() {
+ return this.project.nameWithNamespace || this.project.name_with_namespace;
+ },
truncatedNamespace() {
- return truncateNamespace(this.project.name_with_namespace);
+ return truncateNamespace(this.projectNameWithNamespace);
},
highlightedProjectName() {
return highlight(this.project.name, this.matcher);
@@ -50,7 +45,7 @@ export default {
@click="onClick"
>
<icon
- class="prepend-left-10 append-right-10 flex-shrink-0 position-top-0 js-selected-icon"
+ class="gl-ml-3 gl-mr-3 flex-shrink-0 position-top-0 js-selected-icon"
:class="{ 'js-selected visible': selected, 'js-unselected invisible': !selected }"
name="mobile-issue-close"
/>
@@ -58,7 +53,7 @@ export default {
<div class="d-flex flex-wrap project-namespace-name-container">
<div
v-if="truncatedNamespace"
- :title="project.name_with_namespace"
+ :title="projectNameWithNamespace"
class="text-secondary text-truncate js-project-namespace"
>
{{ truncatedNamespace }}
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
index 15a5ce85046..0b91588a006 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
@@ -41,7 +41,8 @@ export default {
},
totalResults: {
type: Number,
- required: true,
+ required: false,
+ default: 0,
},
},
data() {
@@ -87,6 +88,7 @@ export default {
type="search"
class="mb-3"
autofocus
+ data-qa-selector="project_search_field"
@input="onInput"
/>
<div class="d-flex flex-column">
@@ -106,6 +108,7 @@ export default {
:project="project"
:matcher="searchQuery"
class="js-project-list-item"
+ data-qa-selector="project_list_item"
@click="projectClicked(project)"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
new file mode 100644
index 00000000000..88d1b15aee3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
@@ -0,0 +1,78 @@
+<script>
+import { GlFormCheckbox, GlModal } from '@gitlab/ui';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import csrf from '~/lib/utils/csrf';
+import { __ } from '~/locale';
+
+export default {
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ csrf,
+ components: {
+ GlFormCheckbox,
+ GlModal,
+ },
+ data() {
+ return {
+ modalData: {},
+ };
+ },
+ computed: {
+ isAccessRequest() {
+ return parseBoolean(this.modalData.isAccessRequest);
+ },
+ actionText() {
+ return this.isAccessRequest ? __('Deny access request') : __('Remove member');
+ },
+ actionPrimary() {
+ return {
+ text: this.actionText,
+ attributes: {
+ variant: 'danger',
+ },
+ };
+ },
+ },
+ mounted() {
+ document.addEventListener('click', this.handleClick);
+ },
+ beforeDestroy() {
+ document.removeEventListener('click', this.handleClick);
+ },
+ methods: {
+ handleClick(event) {
+ const removeButton = event.target.closest('.js-remove-member-button');
+ if (removeButton) {
+ this.modalData = removeButton.dataset;
+ this.$refs.modal.show();
+ }
+ },
+ submitForm() {
+ this.$refs.form.submit();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="remove-member-modal"
+ :action-cancel="$options.actionCancel"
+ :action-primary="actionPrimary"
+ :title="actionText"
+ data-qa-selector="remove_member_modal_content"
+ @primary="submitForm"
+ >
+ <form ref="form" :action="modalData.memberPath" method="post">
+ <p data-testid="modal-message">{{ modalData.message }}</p>
+
+ <input ref="method" type="hidden" name="_method" value="delete" />
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <gl-form-checkbox v-if="!isAccessRequest" name="unassign_issuables">
+ {{ __('Also unassign this user from related issues and merge requests') }}
+ </gl-form-checkbox>
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js b/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js
new file mode 100644
index 00000000000..edc5ffb7b77
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js
@@ -0,0 +1,6 @@
+export const DEFAULT_RX = 0.4;
+export const DEFAULT_BAR_WIDTH = 6;
+export const DEFAULT_LABEL_WIDTH = 4;
+export const DEFAULT_LABEL_HEIGHT = 5;
+export const BAR_HEIGHTS = [5, 7, 9, 14, 21, 35, 50, 80];
+export const GRID_YS = [30, 60, 90];
diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue
new file mode 100644
index 00000000000..306fa61780f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+import {
+ DEFAULT_RX,
+ DEFAULT_BAR_WIDTH,
+ DEFAULT_LABEL_WIDTH,
+ DEFAULT_LABEL_HEIGHT,
+ BAR_HEIGHTS,
+ GRID_YS,
+} from './constants';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+ props: {
+ barWidth: {
+ type: Number,
+ default: DEFAULT_BAR_WIDTH,
+ required: false,
+ },
+ labelWidth: {
+ type: Number,
+ default: DEFAULT_LABEL_WIDTH,
+ required: false,
+ },
+ labelHeight: {
+ type: Number,
+ default: DEFAULT_LABEL_HEIGHT,
+ required: false,
+ },
+ rx: {
+ type: Number,
+ default: DEFAULT_RX,
+ required: false,
+ },
+ // skeleton-loader will generate a unique key if not defined
+ uniqueKey: {
+ type: String,
+ default: undefined,
+ required: false,
+ },
+ },
+ computed: {
+ labelCentering() {
+ return (this.barWidth - this.labelWidth) / 2;
+ },
+ },
+ methods: {
+ getBarXPosition(index) {
+ const numberOfBars = this.$options.BAR_HEIGHTS.length;
+ const numberOfSpaces = numberOfBars + 1;
+ const spaceBetweenBars = (100 - numberOfSpaces * this.barWidth) / numberOfBars;
+
+ return (0.5 + index) * (this.barWidth + spaceBetweenBars);
+ },
+ },
+ BAR_HEIGHTS,
+ GRID_YS,
+};
+</script>
+<template>
+ <gl-skeleton-loader :unique-key="uniqueKey">
+ <rect
+ v-for="(y, index) in $options.GRID_YS"
+ :key="`grid-${index}`"
+ data-testid="skeleton-chart-grid"
+ x="0"
+ :y="`${y}%`"
+ width="100%"
+ height="1px"
+ />
+ <rect
+ v-for="(height, index) in $options.BAR_HEIGHTS"
+ :key="`bar-${index}`"
+ data-testid="skeleton-chart-bar"
+ :x="`${getBarXPosition(index)}%`"
+ :y="`${90 - height}%`"
+ :width="`${barWidth}%`"
+ :height="`${height}%`"
+ :rx="`${rx}%`"
+ />
+ <rect
+ v-for="(height, index) in $options.BAR_HEIGHTS"
+ :key="`label-${index}`"
+ data-testid="skeleton-chart-label"
+ :x="`${labelCentering + getBarXPosition(index)}%`"
+ :y="`${100 - labelHeight}%`"
+ :width="`${labelWidth}%`"
+ :height="`${labelHeight}%`"
+ :rx="`${rx}%`"
+ />
+ </gl-skeleton-loader>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
index 1566c2c784b..dd1da847001 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
@@ -1,5 +1,6 @@
import { __ } from '~/locale';
-import { generateToolbarItem } from './editor_service';
+import { generateToolbarItem } from './services/editor_service';
+import buildCustomHTMLRenderer from './services/build_custom_renderer';
export const CUSTOM_EVENTS = {
openAddImageModal: 'gl_openAddImageModal',
@@ -31,6 +32,7 @@ const TOOLBAR_ITEM_CONFIGS = [
export const EDITOR_OPTIONS = {
toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)),
+ customHTMLRenderer: buildCustomHTMLRenderer(),
};
export const EDITOR_TYPES = {
@@ -41,3 +43,7 @@ export const EDITOR_TYPES = {
export const EDITOR_HEIGHT = '100%';
export const EDITOR_PREVIEW_STYLE = 'horizontal';
+
+export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 };
+
+export const MAX_FILE_SIZE = 2097152; // 2Mb
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
new file mode 100644
index 00000000000..0a444b2295d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
@@ -0,0 +1,147 @@
+<script>
+import { isSafeURL } from '~/lib/utils/url_utility';
+import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
+import { __ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { IMAGE_TABS } from '../../constants';
+import UploadImageTab from './upload_image_tab.vue';
+
+export default {
+ components: {
+ UploadImageTab,
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlTabs,
+ GlTab,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ imageRoot: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ file: null,
+ urlError: null,
+ imageUrl: null,
+ description: null,
+ tabIndex: IMAGE_TABS.UPLOAD_TAB,
+ uploadImageTab: null,
+ };
+ },
+ modalTitle: __('Image Details'),
+ okTitle: __('Insert'),
+ urlTabTitle: __('By URL'),
+ urlLabel: __('Image URL'),
+ descriptionLabel: __('Description'),
+ uploadTabTitle: __('Upload file'),
+ computed: {
+ altText() {
+ return this.description;
+ },
+ },
+ methods: {
+ show() {
+ this.file = null;
+ this.urlError = null;
+ this.imageUrl = null;
+ this.description = null;
+ this.tabIndex = IMAGE_TABS.UPLOAD_TAB;
+
+ this.$refs.modal.show();
+ },
+ onOk(event) {
+ if (this.glFeatures.sseImageUploads && this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
+ this.submitFile(event);
+ return;
+ }
+ this.submitURL(event);
+ },
+ setFile(file) {
+ this.file = file;
+ },
+ submitFile(event) {
+ const { file, altText } = this;
+ const { uploadImageTab } = this.$refs;
+
+ uploadImageTab.validateFile();
+
+ if (uploadImageTab.fileError) {
+ event.preventDefault();
+ return;
+ }
+
+ const imageUrl = `${this.imageRoot}${file.name}`;
+
+ this.$emit('addImage', { imageUrl, file, altText: altText || file.name });
+ },
+ submitURL(event) {
+ if (!this.validateUrl()) {
+ event.preventDefault();
+ return;
+ }
+
+ const { imageUrl, altText } = this;
+
+ this.$emit('addImage', { imageUrl, altText: altText || imageUrl });
+ },
+ validateUrl() {
+ if (!isSafeURL(this.imageUrl)) {
+ this.urlError = __('Please provide a valid URL');
+ this.$refs.urlInput.$el.focus();
+ return false;
+ }
+
+ return true;
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="add-image-modal"
+ :title="$options.modalTitle"
+ :ok-title="$options.okTitle"
+ @ok="onOk"
+ >
+ <gl-tabs v-if="glFeatures.sseImageUploads" v-model="tabIndex">
+ <!-- Upload file Tab -->
+ <gl-tab :title="$options.uploadTabTitle">
+ <upload-image-tab ref="uploadImageTab" @input="setFile" />
+ </gl-tab>
+
+ <!-- By URL Tab -->
+ <gl-tab :title="$options.urlTabTitle">
+ <gl-form-group
+ class="gl-mt-5 gl-mb-3"
+ :label="$options.urlLabel"
+ label-for="url-input"
+ :state="!Boolean(urlError)"
+ :invalid-feedback="urlError"
+ >
+ <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
+ </gl-form-group>
+ </gl-tab>
+ </gl-tabs>
+
+ <gl-form-group
+ v-else
+ class="gl-mt-5 gl-mb-3"
+ :label="$options.urlLabel"
+ label-for="url-input"
+ :state="!Boolean(urlError)"
+ :invalid-feedback="urlError"
+ >
+ <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
+ </gl-form-group>
+
+ <!-- Description Input -->
+ <gl-form-group :label="$options.descriptionLabel" label-for="description-input">
+ <gl-form-input id="description-input" ref="descriptionInput" v-model="description" />
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue
new file mode 100644
index 00000000000..739f8b502c9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue
@@ -0,0 +1,56 @@
+<script>
+import { __ } from '~/locale';
+import { GlFormGroup } from '@gitlab/ui';
+import { MAX_FILE_SIZE } from '../../constants';
+
+export default {
+ components: {
+ GlFormGroup,
+ },
+ data() {
+ return {
+ file: null,
+ fileError: null,
+ };
+ },
+ fileLabel: __('Select file'),
+ methods: {
+ onInput(event) {
+ [this.file] = event.target.files;
+
+ this.validateFile();
+
+ if (!this.fileError) {
+ this.$emit('input', this.file);
+ }
+ },
+ validateFile() {
+ this.fileError = null;
+
+ if (!this.file) {
+ this.fileError = __('Please choose a file');
+ } else if (this.file.size > MAX_FILE_SIZE) {
+ this.fileError = __('Maximum file size is 2MB. Please select a smaller file.');
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group
+ class="gl-mt-5 gl-mb-3"
+ :label="$options.fileLabel"
+ label-for="file-input"
+ :state="!Boolean(fileError)"
+ :invalid-feedback="fileError"
+ >
+ <input
+ id="file-input"
+ ref="fileInput"
+ class="gl-mt-3 gl-mb-2"
+ type="file"
+ accept="image/*"
+ @input="onInput"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue
deleted file mode 100644
index 40063065926..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue
+++ /dev/null
@@ -1,74 +0,0 @@
-<script>
-import { isSafeURL } from '~/lib/utils/url_utility';
-import { GlModal, GlFormGroup, GlFormInput } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlModal,
- GlFormGroup,
- GlFormInput,
- },
- data() {
- return {
- error: null,
- imageUrl: null,
- altText: null,
- modalTitle: __('Image Details'),
- okTitle: __('Insert'),
- urlLabel: __('Image URL'),
- descriptionLabel: __('Description'),
- };
- },
- methods: {
- show() {
- this.error = null;
- this.imageUrl = null;
- this.altText = null;
-
- this.$refs.modal.show();
- },
- onOk(event) {
- if (!this.isValid()) {
- event.preventDefault();
- return;
- }
-
- const { imageUrl, altText } = this;
-
- this.$emit('addImage', { imageUrl, altText: altText || __('image') });
- },
- isValid() {
- if (!isSafeURL(this.imageUrl)) {
- this.error = __('Please provide a valid URL');
- this.$refs.urlInput.$el.focus();
- return false;
- }
-
- return true;
- },
- },
-};
-</script>
-<template>
- <gl-modal
- ref="modal"
- modal-id="add-image-modal"
- :title="modalTitle"
- :ok-title="okTitle"
- @ok="onOk"
- >
- <gl-form-group
- :label="urlLabel"
- label-for="url-input"
- :state="!Boolean(error)"
- :invalid-feedback="error"
- >
- <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
- </gl-form-group>
-
- <gl-form-group :label="descriptionLabel" label-for="description-input">
- <gl-form-input id="description-input" ref="descriptionInput" v-model="altText" />
- </gl-form-group>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
index 5c310fc059b..baeb98bec75 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
@@ -2,7 +2,7 @@
import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
-import AddImageModal from './modals/add_image_modal.vue';
+import AddImageModal from './modals/add_image/add_image_modal.vue';
import {
EDITOR_OPTIONS,
EDITOR_TYPES,
@@ -12,11 +12,12 @@ import {
} from './constants';
import {
+ registerHTMLToMarkdownRenderer,
addCustomEventListener,
removeCustomEventListener,
addImage,
getMarkdown,
-} from './editor_service';
+} from './services/editor_service';
export default {
components: {
@@ -27,7 +28,7 @@ export default {
AddImageModal,
},
props: {
- value: {
+ content: {
type: String,
required: true,
},
@@ -51,6 +52,11 @@ export default {
required: false,
default: EDITOR_PREVIEW_STYLE,
},
+ imageRoot: {
+ type: String,
+ required: true,
+ validator: prop => prop.endsWith('/'),
+ },
},
data() {
return {
@@ -66,51 +72,48 @@ export default {
return this.$refs.editor;
},
},
- watch: {
- value(newVal) {
- const isSameMode = this.previousMode === this.editorApi.currentMode;
- if (!isSameMode) {
- /*
- The ToastUI Editor consumes its content via the `initial-value` prop and then internally
- manages changes. If we desire the `v-model` to work as expected, we need to manually call
- `setMarkdown`. However, if we do this in each v-model change we'll continually prevent
- the editor from internally managing changes. Thus we use the `previousMode` flag as
- confirmation to actually update its internals. This is initially designed so that front
- matter is excluded from editing in wysiwyg mode, but included in markdown mode.
- */
- this.editorInstance.invoke('setMarkdown', newVal);
- this.previousMode = this.editorApi.currentMode;
- }
- },
- },
beforeDestroy() {
- removeCustomEventListener(
- this.editorApi,
- CUSTOM_EVENTS.openAddImageModal,
- this.onOpenAddImageModal,
- );
-
- this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
+ this.removeListeners();
},
methods: {
+ addListeners(editorApi) {
+ addCustomEventListener(editorApi, CUSTOM_EVENTS.openAddImageModal, this.onOpenAddImageModal);
+
+ editorApi.eventManager.listen('changeMode', this.onChangeMode);
+ },
+ removeListeners() {
+ removeCustomEventListener(
+ this.editorApi,
+ CUSTOM_EVENTS.openAddImageModal,
+ this.onOpenAddImageModal,
+ );
+
+ this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
+ },
+ resetInitialValue(newVal) {
+ this.editorInstance.invoke('setMarkdown', newVal);
+ },
onContentChanged() {
this.$emit('input', getMarkdown(this.editorInstance));
},
onLoad(editorApi) {
this.editorApi = editorApi;
- addCustomEventListener(
- this.editorApi,
- CUSTOM_EVENTS.openAddImageModal,
- this.onOpenAddImageModal,
- );
+ registerHTMLToMarkdownRenderer(editorApi);
- this.editorApi.eventManager.listen('changeMode', this.onChangeMode);
+ this.addListeners(editorApi);
},
onOpenAddImageModal() {
this.$refs.addImageModal.show();
},
- onAddImage(image) {
+ onAddImage({ imageUrl, altText, file }) {
+ const image = { imageUrl, altText };
+
+ if (file) {
+ this.$emit('uploadImage', { file, imageUrl });
+ // TODO - ensure that the actual repo URL for the image is used in Markdown mode
+ }
+
addImage(this.editorInstance, image);
},
onChangeMode(newMode) {
@@ -123,7 +126,7 @@ export default {
<div>
<toast-editor
ref="editor"
- :initial-value="value"
+ :initial-value="content"
:options="editorOptions"
:preview-style="previewStyle"
:initial-edit-type="initialEditType"
@@ -131,6 +134,6 @@ export default {
@change="onContentChanged"
@load="onLoad"
/>
- <add-image-modal ref="addImageModal" @addImage="onAddImage" />
+ <add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
new file mode 100644
index 00000000000..70d29b5b3df
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
@@ -0,0 +1,68 @@
+import renderBlockHtml from './renderers/render_html_block';
+import renderKramdownList from './renderers/render_kramdown_list';
+import renderKramdownText from './renderers/render_kramdown_text';
+import renderIdentifierInstanceText from './renderers/render_identifier_instance_text';
+import renderIdentifierParagraph from './renderers/render_identifier_paragraph';
+import renderEmbeddedRubyText from './renderers/render_embedded_ruby_text';
+import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline';
+
+const htmlInlineRenderers = [renderFontAwesomeHtmlInline];
+const htmlBlockRenderers = [renderBlockHtml];
+const listRenderers = [renderKramdownList];
+const paragraphRenderers = [renderIdentifierParagraph];
+const textRenderers = [renderKramdownText, renderEmbeddedRubyText, renderIdentifierInstanceText];
+
+const executeRenderer = (renderers, node, context) => {
+ const availableRenderer = renderers.find(renderer => renderer.canRender(node, context));
+
+ return availableRenderer ? availableRenderer.render(node, context) : context.origin();
+};
+
+const buildCustomRendererFunctions = (customRenderers, defaults) => {
+ const customTypes = Object.keys(customRenderers).filter(type => !defaults[type]);
+ const customEntries = customTypes.map(type => {
+ const fn = (node, context) => executeRenderer(customRenderers[type], node, context);
+ return [type, fn];
+ });
+
+ return Object.fromEntries(customEntries);
+};
+
+const buildCustomHTMLRenderer = (
+ customRenderers = { htmlBlock: [], htmlInline: [], list: [], paragraph: [], text: [] },
+) => {
+ const defaults = {
+ htmlBlock(node, context) {
+ const allHtmlBlockRenderers = [...customRenderers.htmlBlock, ...htmlBlockRenderers];
+
+ return executeRenderer(allHtmlBlockRenderers, node, context);
+ },
+ htmlInline(node, context) {
+ const allHtmlInlineRenderers = [...customRenderers.htmlInline, ...htmlInlineRenderers];
+
+ return executeRenderer(allHtmlInlineRenderers, node, context);
+ },
+ list(node, context) {
+ const allListRenderers = [...customRenderers.list, ...listRenderers];
+
+ return executeRenderer(allListRenderers, node, context);
+ },
+ paragraph(node, context) {
+ const allParagraphRenderers = [...customRenderers.paragraph, ...paragraphRenderers];
+
+ return executeRenderer(allParagraphRenderers, node, context);
+ },
+ text(node, context) {
+ const allTextRenderers = [...customRenderers.text, ...textRenderers];
+
+ return executeRenderer(allTextRenderers, node, context);
+ },
+ };
+
+ return {
+ ...buildCustomRendererFunctions(customRenderers, defaults),
+ ...defaults,
+ };
+};
+
+export default buildCustomHTMLRenderer;
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
new file mode 100644
index 00000000000..ed04765c871
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
@@ -0,0 +1,53 @@
+import { defaults, repeat } from 'lodash';
+
+const DEFAULTS = {
+ subListIndentSpaces: 4,
+};
+
+const countIndentSpaces = text => {
+ const matches = text.match(/^\s+/m);
+
+ return matches ? matches[0].length : 0;
+};
+
+const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => {
+ const { subListIndentSpaces } = defaults(formattingPreferences, DEFAULTS);
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ const sublistNode = 'LI OL, LI UL';
+
+ return {
+ TEXT_NODE(node) {
+ return baseRenderer.getSpaceControlled(
+ baseRenderer.trim(baseRenderer.getSpaceCollapsedText(node.nodeValue)),
+ node,
+ );
+ },
+ /*
+ * This converter overwrites the default indented list converter
+ * to allow us to parameterize the number of indent spaces for
+ * sublists.
+ *
+ * See the original implementation in
+ * https://github.com/nhn/tui.editor/blob/master/libs/to-mark/src/renderer.basic.js#L161
+ */
+ [sublistNode](node, subContent) {
+ const baseResult = baseRenderer.convert(node, subContent);
+ // Default to 1 to prevent possible divide by 0
+ const firstLevelIndentSpacesCount = countIndentSpaces(baseResult) || 1;
+ const reindentedList = baseResult
+ .split('\n')
+ .map(line => {
+ const itemIndentSpacesCount = countIndentSpaces(line);
+ const nestingLevel = Math.ceil(itemIndentSpacesCount / firstLevelIndentSpacesCount);
+ const indentSpaces = repeat(' ', subListIndentSpaces * nestingLevel);
+
+ return line.replace(/^ +/, indentSpaces);
+ })
+ .join('\n');
+
+ return reindentedList;
+ },
+ };
+};
+
+export default buildHTMLToMarkdownRender;
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
index 278cd50a947..6436dcaae64 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
-import ToolbarItem from './toolbar_item.vue';
+import ToolbarItem from '../toolbar_item.vue';
+import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
const buildWrapper = propsData => {
const instance = new Vue({
@@ -40,3 +41,16 @@ export const removeCustomEventListener = (editorApi, event, handler) =>
export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown');
+
+/**
+ * This function allow us to extend Toast UI HTML to Markdown renderer. It is
+ * a temporary measure because Toast UI does not provide an API
+ * to achieve this goal.
+ */
+export const registerHTMLToMarkdownRenderer = editorApi => {
+ const { renderer } = editorApi.toMarkOptions;
+
+ Object.assign(editorApi.toMarkOptions, {
+ renderer: renderer.constructor.factory(renderer, buildHtmlToMarkdownRenderer(renderer)),
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
new file mode 100644
index 00000000000..d96cadafdbb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
@@ -0,0 +1,63 @@
+const buildToken = (type, tagName, props) => {
+ return { type, tagName, ...props };
+};
+
+const TAG_TYPES = {
+ block: 'div',
+ inline: 'a',
+};
+
+// Open helpers (singular and multiple)
+
+const buildUneditableOpenToken = (tagType = TAG_TYPES.block) =>
+ buildToken('openTag', tagType, {
+ attributes: { contenteditable: false },
+ classNames: [
+ 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed',
+ ],
+ });
+
+export const buildUneditableOpenTokens = (token, tagType = TAG_TYPES.block) => {
+ return [buildUneditableOpenToken(tagType), token];
+};
+
+// Close helpers (singular and multiple)
+
+export const buildUneditableCloseToken = (tagType = TAG_TYPES.block) =>
+ buildToken('closeTag', tagType);
+
+export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) => {
+ return [token, buildUneditableCloseToken(tagType)];
+};
+
+// Complete helpers (open plus close)
+
+export const buildTextToken = content => buildToken('text', null, { content });
+
+export const buildUneditableTokens = token => {
+ return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()];
+};
+
+export const buildUneditableInlineTokens = token => {
+ return [
+ ...buildUneditableOpenTokens(token, TAG_TYPES.inline),
+ buildUneditableCloseToken(TAG_TYPES.inline),
+ ];
+};
+
+export const buildUneditableHtmlAsTextTokens = node => {
+ /*
+ Toast UI internally appends ' data-tomark-pass ' attribute flags so it can target certain
+ nested nodes for internal use during Markdown <=> WYSIWYG conversions. In our case, we want
+ to prevent HTML being rendered completely in WYSIWYG mode and thus we use a `text` vs. `html`
+ type when building the token. However, in doing so, we need to strip out the ` data-tomark-pass `
+ to prevent their persistence within the `text` content as the user did not intend these as edits.
+
+ https://github.com/nhn/tui.editor/blob/cc54ec224fc3a4b6e5a2b19a71650959f41adc0e/apps/editor/src/js/convertor.js#L72
+ */
+ const regex = / data-tomark-pass /gm;
+ const content = node.literal.replace(regex, '');
+ const htmlAsTextToken = buildToken('text', null, { content });
+
+ return [buildUneditableOpenToken(), htmlAsTextToken, buildUneditableCloseToken()];
+};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js
new file mode 100644
index 00000000000..494057fc75b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js
@@ -0,0 +1,13 @@
+import { buildUneditableTokens } from './build_uneditable_token';
+
+const embeddedRubyRegex = /(^<%.+%>$)/;
+
+const canRender = ({ literal }) => {
+ return embeddedRubyRegex.test(literal);
+};
+
+const render = (_, { origin }) => {
+ return buildUneditableTokens(origin());
+};
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js
new file mode 100644
index 00000000000..572f6e3cf9d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js
@@ -0,0 +1,11 @@
+import { buildUneditableInlineTokens } from './build_uneditable_token';
+
+const fontAwesomeRegexOpen = /<i class="fa.+>/;
+
+const canRender = ({ literal }) => {
+ return fontAwesomeRegexOpen.test(literal);
+};
+
+const render = (_, { origin }) => buildUneditableInlineTokens(origin());
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
new file mode 100644
index 00000000000..b179ca61dba
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
@@ -0,0 +1,9 @@
+import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token';
+
+const canRender = ({ type }) => {
+ return type === 'htmlBlock';
+};
+
+const render = node => buildUneditableHtmlAsTextTokens(node);
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js
new file mode 100644
index 00000000000..a9c3dfcd728
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js
@@ -0,0 +1,40 @@
+import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_token';
+
+/*
+Use case examples:
+- Majority: two bracket pairs, back-to-back, each with content (including spaces)
+ - `[environment terraform plans][terraform]`
+ - `[an issue labelled `~"master:broken"`][broken-master-issues]`
+- Minority: two bracket pairs the latter being empty or only one pair with content (including spaces)
+ - `[this link][]`
+ - `[this link]`
+
+Regexp notes:
+ - `(?:\[.+?\]){1}`: Always one bracket pair with content (including spaces)
+ - `(?:\[\]|\[.+?\])?`: Optional second pair that may or may not contain content (including spaces)
+ - `(?!:)`: Never followed by a `:` which is reserved for identifier definition syntax (`[identifier]: /the-link`)
+ - Each of the three parts is non-captured, but the match as a whole is captured
+*/
+const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g;
+
+const isIdentifierInstance = literal => {
+ // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448)
+ identifierInstanceRegex.lastIndex = 0;
+ return identifierInstanceRegex.test(literal);
+};
+
+const canRender = ({ literal }) => isIdentifierInstance(literal);
+
+const tokenize = text => {
+ const matches = text.split(identifierInstanceRegex);
+ const tokens = matches.map(match => {
+ const token = buildTextToken(match);
+ return isIdentifierInstance(match) ? buildUneditableInlineTokens(token) : token;
+ });
+
+ return tokens.flat();
+};
+
+const render = (_, { origin }) => tokenize(origin().content);
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js
new file mode 100644
index 00000000000..f5b4502ea3c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js
@@ -0,0 +1,16 @@
+import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token';
+
+const identifierRegex = /(^\[.+\]: .+)/;
+
+const isIdentifier = text => {
+ return identifierRegex.test(text);
+};
+
+const canRender = (node, context) => {
+ return isIdentifier(context.getChildrenText(node));
+};
+
+const render = (_, { entering, origin }) =>
+ entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js
new file mode 100644
index 00000000000..491a26c81d0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js
@@ -0,0 +1,27 @@
+import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token';
+
+const isKramdownTOC = ({ type, literal }) => type === 'text' && literal === 'TOC';
+
+const canRender = node => {
+ let targetNode = node;
+ while (targetNode !== null) {
+ const { firstChild } = targetNode;
+ const isLeaf = firstChild === null;
+ if (isLeaf) {
+ if (isKramdownTOC(targetNode)) {
+ return true;
+ }
+
+ break;
+ }
+
+ targetNode = targetNode.firstChild;
+ }
+
+ return false;
+};
+
+const render = (_, { entering, origin }) =>
+ entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js
new file mode 100644
index 00000000000..01384699e4f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js
@@ -0,0 +1,13 @@
+import { buildUneditableTokens } from './build_uneditable_token';
+
+const kramdownRegex = /(^{:.+}$)/;
+
+const canRender = ({ literal }) => {
+ return kramdownRegex.test(literal);
+};
+
+const render = (_, { origin }) => {
+ return buildUneditableTokens(origin());
+};
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
index 30f7e6a5980..1be5284fa9c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
@@ -1,7 +1,11 @@
<script>
import { __, s__, sprintf } from '~/locale';
+import { GlIcon } from '@gitlab/ui';
export default {
+ components: {
+ GlIcon,
+ },
props: {
abilityName: {
type: String,
@@ -72,6 +76,10 @@ export default {
data-toggle="dropdown"
>
<span class="dropdown-toggle-text"> {{ dropdownToggleText }} </span>
- <i aria-hidden="true" class="fa fa-chevron-down" data-hidden="true"> </i>
+ <gl-icon
+ name="chevron-down"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-700"
+ :size="16"
+ />
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
index bf51fa3dc38..f0a846c4924 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
@@ -1,5 +1,11 @@
<script>
-export default {};
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+};
</script>
<template>
@@ -10,13 +16,13 @@ export default {};
class="dropdown-input-field"
type="search"
/>
- <i aria-hidden="true" class="fa fa-search dropdown-input-search" data-hidden="true"> </i>
- <i
- aria-hidden="true"
- class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
- data-hidden="true"
- role="button"
- >
- </i>
+ <gl-icon
+ name="search"
+ class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-500 gl-pointer-events-none"
+ />
+ <gl-icon
+ name="close"
+ class="dropdown-input-clear js-dropdown-input-clear gl-absolute gl-top-3 gl-right-5 gl-text-gray-700"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
index e94e7d46f85..746e38e98e8 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
@@ -1,6 +1,7 @@
export const DropdownVariant = {
Sidebar: 'sidebar',
Standalone: 'standalone',
+ Embedded: 'embedded',
};
export const LIST_BUFFER_SIZE = 5;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
index f45c14f8344..cf77aa37d14 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
@@ -8,12 +8,16 @@ export default {
GlIcon,
},
computed: {
- ...mapGetters(['dropdownButtonText', 'isDropdownVariantStandalone']),
+ ...mapGetters([
+ 'dropdownButtonText',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
},
methods: {
...mapActions(['toggleDropdownContents']),
handleButtonClick(e) {
- if (this.isDropdownVariantStandalone) {
+ if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
this.toggleDropdownContents();
e.stopPropagation();
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
index ba8d8391952..94671f8a109 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
@@ -88,12 +88,16 @@ export default {
@click.prevent="handleColorClick(color)"
/>
</div>
- <div class="color-input-container d-flex">
+ <div class="color-input-container gl-display-flex">
<span
class="dropdown-label-color-preview position-relative position-relative d-inline-block"
:style="{ backgroundColor: selectedColor }"
></span>
- <gl-form-input v-model.trim="selectedColor" :placeholder="__('Use custom color #FF0000')" />
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ :placeholder="__('Use custom color #FF0000')"
+ />
</div>
</div>
<div class="dropdown-actions clearfix pt-2 px-2">
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index af16088b6b9..ef506d00d9a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -36,7 +36,7 @@ export default {
'footerCreateLabelTitle',
'footerManageLabelTitle',
]),
- ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar']),
+ ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
visibleLabels() {
if (this.searchKey) {
return this.labels.filter(label =>
@@ -126,16 +126,19 @@ export default {
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
<gl-loading-icon
v-if="labelsFetchInProgress"
- class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100"
+ class="labels-fetch-loading position-absolute gl-display-flex gl-align-items-center w-100 h-100"
size="md"
/>
- <div v-if="isDropdownVariantSidebar" class="dropdown-title d-flex align-items-center pt-0 pb-2">
+ <div
+ v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ >
<span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="small"
- class="dropdown-header-button p-0"
+ class="dropdown-header-button gl-p-0!"
icon="close"
@click="toggleDropdownContents"
/>
@@ -165,17 +168,21 @@ export default {
</li>
</smart-virtual-list>
</div>
- <div v-if="isDropdownVariantSidebar" class="dropdown-footer">
+ <div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer">
<ul class="list-unstyled">
<li v-if="allowLabelCreate">
<gl-link
- class="d-flex w-100 flex-row text-break-word label-item"
+ class="gl-display-flex w-100 flex-row text-break-word label-item"
@click="toggleDropdownContentsCreateView"
- >{{ footerCreateLabelTitle }}</gl-link
>
+ {{ footerCreateLabelTitle }}
+ </gl-link>
</li>
<li>
- <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">
+ <gl-link
+ :href="labelsManagePath"
+ class="gl-display-flex flex-row text-break-word label-item"
+ >
{{ footerManageLabelTitle }}
</gl-link>
</li>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index f38b66fdfdf..258a87e62b9 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -74,6 +74,11 @@ export default {
required: false,
default: '',
},
+ dropdownButtonText: {
+ type: String,
+ required: false,
+ default: __('Label'),
+ },
labelsListTitle: {
type: String,
required: false,
@@ -97,7 +102,11 @@ export default {
},
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
- ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone']),
+ ...mapGetters([
+ 'isDropdownVariantSidebar',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
dropdownButtonVisible() {
return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
},
@@ -116,6 +125,7 @@ export default {
allowLabelCreate: this.allowLabelCreate,
allowMultiselect: this.allowMultiselect,
allowScopedLabels: this.allowScopedLabels,
+ dropdownButtonText: this.dropdownButtonText,
selectedLabels: this.selectedLabels,
labelsFetchPath: this.labelsFetchPath,
labelsManagePath: this.labelsManagePath,
@@ -200,7 +210,10 @@ export default {
<template>
<div
class="labels-select-wrapper position-relative"
- :class="{ 'is-standalone': isDropdownVariantStandalone }"
+ :class="{
+ 'is-standalone': isDropdownVariantStandalone,
+ 'is-embedded': isDropdownVariantEmbedded,
+ }"
>
<template v-if="isDropdownVariantSidebar">
<dropdown-value-collapsed
@@ -221,7 +234,7 @@ export default {
ref="dropdownContents"
/>
</template>
- <template v-if="isDropdownVariantStandalone">
+ <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
<dropdown-button v-show="dropdownButtonVisible" />
<dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
index c39222959a9..e035a866048 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
@@ -13,7 +13,7 @@ export const dropdownButtonText = (state, getters) => {
: state.selectedLabels;
if (!selectedLabels.length) {
- return __('Label');
+ return state.dropdownButtonText || __('Label');
} else if (selectedLabels.length > 1) {
return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
firstLabelName: selectedLabels[0].title,
@@ -44,5 +44,12 @@ export const isDropdownVariantSidebar = state => state.variant === DropdownVaria
*/
export const isDropdownVariantStandalone = state => state.variant === DropdownVariant.Standalone;
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `embedded`
+ * @param {object} state
+ */
+export const isDropdownVariantEmbedded = state => state.variant === DropdownVariant.Embedded;
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
index 6a6c0b4c0ee..3f3358d4805 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
@@ -6,6 +6,7 @@ export default () => ({
labelsCreateTitle: '',
footerCreateLabelTitle: '',
footerManageLabelTitle: '',
+ dropdownButtonText: '',
// Paths
namespace: '',
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 595baeeb14f..bd35d3fead9 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -4,8 +4,11 @@ import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
+const MAX_SKELETON_LINES = 4;
+
export default {
name: 'UserPopover',
+ maxSkeletonLines: MAX_SKELETON_LINES,
components: {
Icon,
GlPopover,
@@ -22,11 +25,6 @@ export default {
required: true,
default: null,
},
- loaded: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
statusHtml() {
@@ -42,14 +40,8 @@ export default {
return '';
},
- nameIsLoading() {
- return !this.user.name;
- },
- workInformationIsLoading() {
- return !this.user.loaded && this.user.workInformation === null;
- },
- locationIsLoading() {
- return !this.user.loaded && this.user.location === null;
+ userIsLoading() {
+ return !this.user?.loaded;
},
},
};
@@ -58,54 +50,46 @@ export default {
<template>
<!-- 200ms delay so not every mouseover triggers Popover -->
<gl-popover :target="target" :delay="200" boundary="viewport" triggers="hover" placement="top">
- <div class="user-popover d-flex">
- <div class="p-1 flex-shrink-1">
- <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" />
+ <div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover">
+ <div class="gl-p-2 flex-shrink-1">
+ <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="gl-mr-3!" />
</div>
- <div class="p-1 w-100">
- <h5 class="m-0">
- <span v-if="user.name">{{ user.name }}</span>
- <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" />
- </h5>
- <div class="text-secondary mb-2">
- <span v-if="user.username">@{{ user.username }}</span>
- <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" />
- </div>
- <div class="text-secondary">
- <div v-if="user.bio" class="d-flex mb-1">
- <icon name="profile" class="category-icon flex-shrink-0" />
- <span ref="bio" class="ml-1">{{ user.bio }}</span>
- </div>
- <div v-if="user.workInformation" class="d-flex mb-1">
- <icon
- v-show="!workInformationIsLoading"
- name="work"
- class="category-icon flex-shrink-0"
- />
- <span ref="workInformation" class="ml-1">{{ user.workInformation }}</span>
- </div>
- <gl-skeleton-loading
- v-if="workInformationIsLoading"
- :lines="1"
- class="animation-container-small mb-1"
- />
- </div>
- <div class="js-location text-secondary d-flex">
- <icon
- v-show="!locationIsLoading && user.location"
- name="location"
- class="category-icon flex-shrink-0"
- />
- <span v-if="user.location" class="ml-1">{{ user.location }}</span>
+ <div class="gl-p-2 gl-w-full">
+ <template v-if="userIsLoading">
+ <!-- `gl-skeleton-loading` does not support equal length lines -->
+ <!-- This can be migrated to `gl-skeleton-loader` when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/872 is completed -->
<gl-skeleton-loading
- v-if="locationIsLoading"
+ v-for="n in $options.maxSkeletonLines"
+ :key="n"
:lines="1"
- class="animation-container-small mb-1"
+ class="animation-container-small gl-mb-2"
/>
- </div>
- <div v-if="statusHtml" class="js-user-status mt-2">
- <span v-html="statusHtml"></span>
- </div>
+ </template>
+ <template v-else>
+ <div class="gl-mb-3">
+ <h5 class="gl-m-0">
+ {{ user.name }}
+ </h5>
+ <span class="gl-text-gray-700">@{{ user.username }}</span>
+ </div>
+ <div class="gl-text-gray-700">
+ <div v-if="user.bio" class="gl-display-flex gl-mb-2">
+ <icon name="profile" class="gl-text-gray-600 gl-flex-shrink-0" />
+ <span ref="bio" class="ml-1" v-html="user.bioHtml"></span>
+ </div>
+ <div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
+ <icon name="work" class="gl-text-gray-600 gl-flex-shrink-0" />
+ <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
+ </div>
+ </div>
+ <div v-if="user.location" class="js-location gl-text-gray-700 gl-display-flex">
+ <icon name="location" class="gl-text-gray-600 flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.location }}</span>
+ </div>
+ <div v-if="statusHtml" class="js-user-status gl-mt-3">
+ <span v-html="statusHtml"></span>
+ </div>
+ </template>
</div>
</div>
</gl-popover>
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index 63ce4212717..235beb1f22d 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -6,6 +6,8 @@ const INTERVALS = {
day: 'day',
};
+export const FILE_SYMLINK_MODE = '120000';
+
export const timeRanges = [
{
label: __('30 minutes'),
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index cc4d13db150..41fb62c28e6 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -11,30 +11,30 @@
// like a table or typography then make changes in the framework/ directory.
// If you need to add unique style that should affect only one page - use pages/
// directory.
-@import "@gitlab/at.js/dist/css/jquery.atwho";
-@import "dropzone/dist/basic";
-@import "select2/select2";
+@import '@gitlab/at.js/dist/css/jquery.atwho';
+@import 'dropzone/dist/basic';
+@import 'select2/select2';
// GitLab UI framework
-@import "framework";
+@import 'framework';
// Font icons
-@import "font-awesome";
+@import 'font-awesome';
// Page specific styles (issues, projects etc):
-@import "pages/**/*";
+@import 'pages/**/*';
// Component specific styles, will be moved to gitlab-ui
-@import "components/**/*";
+@import 'components/**/*';
// Vendors specific styles
-@import "vendors/**/*";
+@import 'vendors/**/*';
// Styles for JS behaviors.
-@import "behaviors";
+@import 'behaviors';
// EE-only stylesheets
-@import "application_ee";
+@import 'application_ee';
// CSS util classes
/**
@@ -42,7 +42,12 @@
Please check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss
to see the available utility classes.
**/
-@import "utilities";
+@import 'utilities';
// Gitlab UI util classes
-@import "@gitlab/ui/src/scss/utilities";
+@import '@gitlab/ui/src/scss/utilities';
+
+/* print styles */
+@media print {
+ @import 'print';
+}
diff --git a/app/assets/stylesheets/application_dark.scss b/app/assets/stylesheets/application_dark.scss
index 72196d71969..e55141e15df 100644
--- a/app/assets/stylesheets/application_dark.scss
+++ b/app/assets/stylesheets/application_dark.scss
@@ -1,3 +1,3 @@
-@import "./themes/dark";
+@import './themes/dark';
-@import "./application";
+@import './application';
diff --git a/app/assets/stylesheets/behaviors.scss b/app/assets/stylesheets/behaviors.scss
index e3ca7f6373a..120a139ff3d 100644
--- a/app/assets/stylesheets/behaviors.scss
+++ b/app/assets/stylesheets/behaviors.scss
@@ -16,6 +16,7 @@
.js-toggler-container {
.turn-on { display: block; }
.turn-off { display: none; }
+
&.on {
.turn-on { display: none; }
.turn-off { display: block; }
@@ -23,6 +24,6 @@
}
// Hide element if Vue is still working on rendering it fully.
-[v-cloak="true"] {
+[v-cloak='true'] {
display: none !important;
}
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index a6d56819140..aac32e7fb2d 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -6,7 +6,7 @@ $brand-info: $blue-500;
$brand-warning: $orange-500;
$brand-danger: $red-500;
-$border-radius-base: 3px !default;
+$border-radius-base: $gl-border-radius-base;
$modal-body-bg: $white;
$input-border: $border-color;
@@ -23,7 +23,7 @@ body,
// Override default font size used in non-csslab UI
// Use rem to keep default font-size at 14px on body so 1rem still
// fits 8px grid, but also allow users to change browser font size
- font-size: .875rem;
+ font-size: 0.875rem;
}
legend {
@@ -32,11 +32,12 @@ legend {
}
button,
-html [type="button"],
-[type="reset"],
-[type="submit"],
-[role="button"] {
+html [type='button'],
+[type='reset'],
+[type='submit'],
+[role='button'] {
// Override bootstrap reboot
+ /* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-appearance: inherit;
cursor: pointer;
}
@@ -77,7 +78,7 @@ h5,
font-size: $gl-font-size;
}
-input[type="file"] {
+input[type='file'] {
// Bootstrap 4 file input height is taller by default
// which makes them look ugly
line-height: 1;
@@ -314,8 +315,8 @@ input[type=color].form-control {
.toggle-sidebar-button {
.collapse-text,
- .icon-angle-double-left,
- .icon-angle-double-right {
+ .icon-chevron-double-lg-left,
+ .icon-chevron-double-lg-right {
color: $gl-text-color-secondary;
}
}
diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss
index 380b2280490..33f03fb5949 100644
--- a/app/assets/stylesheets/components/design_management/design.scss
+++ b/app/assets/stylesheets/components/design_management/design.scss
@@ -10,7 +10,7 @@
}
.design-pin {
- transition: opacity 0.5s ease;
+ transition: opacity $gl-transition-duration-medium $general-hover-transition-curve;
&.inactive {
@include gl-opacity-5;
@@ -98,7 +98,7 @@
&::before {
content: '';
- border-left: 1px solid $gray-200;
+ border-left: 1px solid $gray-100;
position: absolute;
left: 28px;
top: -18px;
@@ -108,6 +108,9 @@
.design-note {
padding: $gl-padding;
list-style: none;
+ transition: background $gl-transition-duration-medium $general-hover-transition-curve;
+ border-top-left-radius: $border-radius-default; // same border radius used by .bordered-box
+ border-top-right-radius: $border-radius-default;
a {
color: inherit;
@@ -146,11 +149,12 @@
}
.design-dropzone-border {
- border: 2px dashed $gray-200;
+ border: 2px dashed $gray-100;
}
.design-dropzone-card {
- transition: border $general-hover-transition-duration $general-hover-transition-curve;
+ transition: border $gl-transition-duration-medium $general-hover-transition-curve;
+ color: $gl-text-color;
&:focus,
&:active {
diff --git a/app/assets/stylesheets/components/design_management/design_list_item.scss b/app/assets/stylesheets/components/design_management/design_list_item.scss
index aacb1f91e59..b7f6b2026fe 100644
--- a/app/assets/stylesheets/components/design_management/design_list_item.scss
+++ b/app/assets/stylesheets/components/design_management/design_list_item.scss
@@ -17,3 +17,8 @@
height: 230px;
}
}
+
+// This is temporary class to be removed after feature flag removal: https://gitlab.com/gitlab-org/gitlab/-/issues/223197
+.design-list-item-new {
+ height: 210px;
+}
diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss
index 1e78781f4b8..f870948cc4f 100644
--- a/app/assets/stylesheets/components/popover.scss
+++ b/app/assets/stylesheets/components/popover.scss
@@ -1,6 +1,6 @@
.popover {
max-width: $popover-max-width;
- border: 1px solid $gray-200;
+ border: 1px solid $gray-100;
box-shadow: $popover-box-shadow;
font-size: $gl-font-size-small;
@@ -50,7 +50,7 @@
* due to the box-shadow include in our custom styles.
*/
> .arrow::before {
- border-top-color: $gray-200;
+ border-top-color: $gray-100;
bottom: 1px;
}
@@ -61,7 +61,7 @@
.bs-popover-bottom {
> .arrow::before {
- border-bottom-color: $gray-200;
+ border-bottom-color: $gray-100;
}
> .popover-header::before {
@@ -70,11 +70,11 @@
}
.bs-popover-right > .arrow::before {
- border-right-color: $gray-200;
+ border-right-color: $gray-100;
}
.bs-popover-left > .arrow::before {
- border-left-color: $gray-200;
+ border-left-color: $gray-100;
}
.popover-header {
@@ -100,45 +100,6 @@
}
}
-.onboarding-popover {
- box-shadow: 0 2px 4px $dropdown-shadow-color;
- max-width: 280px;
-
- .popover-body {
- font-size: $gl-font-size;
- line-height: $gl-line-height;
- padding: $gl-padding;
- }
-
- .popover-header {
- display: none;
- }
-
- .accept-mr-label {
- background-color: $accepting-mr-label-color;
- color: $white;
- }
-}
-
-/**
-* user_popover component
-*/
-.user-popover {
- padding: $gl-padding-8;
- line-height: $gl-line-height;
-
- .category-icon {
- color: $gray-600;
- }
-}
-
-.onboarding-welcome-page {
- .popover {
- min-width: auto;
- max-width: 40%;
- }
-}
-
.suggest-gitlab-ci-yml {
margin-top: -1em;
diff --git a/app/assets/stylesheets/components/ref_selector.scss b/app/assets/stylesheets/components/ref_selector.scss
new file mode 100644
index 00000000000..970a7b967ee
--- /dev/null
+++ b/app/assets/stylesheets/components/ref_selector.scss
@@ -0,0 +1,17 @@
+.ref-selector {
+ & &-dropdown-content {
+ // Setting a max height is necessary to allow the dropdown's content
+ // to control where and how scrollbars appear.
+ // This content is limited to the max-height of the dropdown
+ // ($dropdown-max-height-lg) minus the additional padding
+ // on the top and bottom (2 * $gl-padding-8)
+ max-height: $dropdown-max-height-lg - 2 * $gl-padding-8;
+ }
+
+ .dropdown-menu.show {
+ // Make the dropdown a little wider and longer than usual
+ // since it contains quite a bit of content.
+ width: 20rem;
+ max-height: $dropdown-max-height-lg;
+ }
+}
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
index 956f34f7a8b..dd749b4df1a 100644
--- a/app/assets/stylesheets/components/related_items_list.scss
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -59,10 +59,6 @@ $item-remove-button-space: 42px;
flex-basis: 100%;
font-size: $gl-font-size-small;
- &.mr-title {
- font-weight: $gl-font-weight-bold;
- }
-
.sortable-link {
color: $gray-900;
}
@@ -77,10 +73,6 @@ $item-remove-button-space: 42px;
overflow: hidden;
white-space: nowrap;
}
-
- .health-label-short {
- display: none;
- }
}
.item-body,
@@ -89,10 +81,6 @@ $item-remove-button-space: 42px;
max-width: 0;
}
- .health-label-long {
- display: none;
- }
-
.status {
&-at-risk {
color: $red-500;
@@ -158,19 +146,16 @@ $item-remove-button-space: 42px;
max-width: $item-milestone-max-width;
.ic-clock {
- color: $gl-text-color-secondary;
margin-right: $gl-padding-4;
}
}
.item-weight {
max-width: $item-weight-max-width;
-
- .ic-weight {
- color: $gl-text-color-secondary;
- }
}
+ .item-milestone .ic-clock,
+ .item-weight .ic-weight,
.item-due-date .ic-calendar {
color: $gl-text-color-secondary;
}
@@ -314,10 +299,6 @@ $item-remove-button-space: 42px;
max-width: 100px;
}
}
-
- .health-label-long {
- display: none;
- }
}
/* Large devices (large desktops, 1200px and up) */
@@ -331,10 +312,6 @@ $item-remove-button-space: 42px;
}
}
- .health-label-long {
- display: none;
- }
-
.item-contents {
overflow: hidden;
}
@@ -376,7 +353,7 @@ $item-remove-button-space: 42px;
}
.health-label-long {
- display: initial;
+ display: block;
}
}
}
diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss
index bedd06ec9a1..8d31b386d9e 100644
--- a/app/assets/stylesheets/components/rich_content_editor.scss
+++ b/app/assets/stylesheets/components/rich_content_editor.scss
@@ -2,30 +2,45 @@
* Overrides styles from ToastUI editor
*/
-// Toolbar buttons
-.tui-editor-defaultUI-toolbar .toolbar-button {
- color: $gl-gray-600;
- border: 0;
-
- &:hover,
- &:active {
- color: $blue-500;
+.tui-editor-defaultUI {
+
+ // Toolbar buttons
+ .tui-editor-defaultUI-toolbar .toolbar-button {
+ color: $gl-gray-600;
border: 0;
+
+ &:hover,
+ &:active {
+ color: $blue-500;
+ border: 0;
+ }
}
-}
-// Contextual menu's & popups
-.tui-editor-defaultUI .tui-popup-wrapper {
- @include gl-overflow-hidden;
- @include gl-rounded-base;
- @include gl-border-gray-400;
+ // Contextual menu's & popups
+ .tui-popup-wrapper {
+ @include gl-overflow-hidden;
+ @include gl-rounded-base;
+ @include gl-border-gray-400;
- hr {
- @include gl-m-0;
- @include gl-bg-gray-400;
+ hr {
+ @include gl-m-0;
+ @include gl-bg-gray-400;
+ }
+
+ button {
+ @include gl-text-gray-800;
+ }
}
- button {
- @include gl-text-gray-800;
+ /**
+ * Overrides styles from ToastUI's Code Mirror (markdown mode) editor.
+ * Toast UI internally overrides some of these using the `.tui-md-` prefix.
+ * https://codemirror.net/doc/manual.html#styling
+ */
+
+ .te-md-container .CodeMirror * {
+ @include gl-font-monospace;
+ @include gl-font-size-monospace;
+ @include gl-line-height-20;
}
}
diff --git a/app/assets/stylesheets/disable_animations.scss b/app/assets/stylesheets/disable_animations.scss
index e65b49c36f3..799c6e80ec9 100644
--- a/app/assets/stylesheets/disable_animations.scss
+++ b/app/assets/stylesheets/disable_animations.scss
@@ -1,4 +1,5 @@
* {
+ /* stylelint-disable property-no-vendor-prefix */
-o-transition: none !important;
-moz-transition: none !important;
-ms-transition: none !important;
@@ -9,6 +10,7 @@
-o-animation: none !important;
-ms-animation: none !important;
animation: none !important;
+ /* stylelint-enable property-no-vendor-prefix */
}
// Disable sticky changes bar for tests
diff --git a/app/assets/stylesheets/emoji_sprites.scss b/app/assets/stylesheets/emoji_sprites.scss
index 8f6134c474b..01d13b30d2b 100644
--- a/app/assets/stylesheets/emoji_sprites.scss
+++ b/app/assets/stylesheets/emoji_sprites.scss
@@ -1,5384 +1,7176 @@
// 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;
}
@@ -5391,6 +7183,7 @@
height: 20px;
width: 20px;
+ /* stylelint-disable media-feature-name-no-vendor-prefix */
@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),
@@ -5400,4 +7193,5 @@
background-image: image-url('emoji@2x.png');
background-size: 860px 840px;
}
+ /* stylelint-enable media-feature-name-no-vendor-prefix */
}
diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss
index 89029a58d1e..f4519841ce3 100644
--- a/app/assets/stylesheets/errors.scss
+++ b/app/assets/stylesheets/errors.scss
@@ -15,9 +15,9 @@ $header-color: #456;
body {
color: $body-color;
text-align: center;
- font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
margin: auto;
- font-size: .875rem;
+ font-size: 0.875rem;
}
h1 {
@@ -105,7 +105,6 @@ a {
}
@include media-breakpoint-up(sm) {
-
li {
display: inline-block;
padding-bottom: 0;
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 338a8c5497c..413e0dde535 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -68,6 +68,6 @@
@import 'framework/read_more';
@import 'framework/flex_grid';
@import 'framework/system_messages';
-@import "framework/spinner";
+@import 'framework/spinner';
@import 'framework/card';
@import 'framework/editor-lite';
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index 0eab86ff7ea..86e701604b5 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -179,7 +179,7 @@
&.user-authored {
cursor: default;
background-color: $gray-light;
- border-color: $gray-200;
+ border-color: $gray-100;
color: $gl-text-color-disabled;
gl-emoji {
diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss
index 534ada08b85..f7836213e5c 100644
--- a/app/assets/stylesheets/framework/broadcast_messages.scss
+++ b/app/assets/stylesheets/framework/broadcast_messages.scss
@@ -28,7 +28,7 @@
max-width: 300px;
width: auto;
background: $white;
- border: 1px solid $gray-200;
+ border: 1px solid $gray-100;
box-shadow: 0 1px 2px 0 rgba($black, 0.1);
border-radius: $border-radius-default;
z-index: 999;
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index f47d0cab31f..fd5b3f74c4a 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -171,7 +171,7 @@
@include btn-green;
}
- &.btn-inverted {
+ &.btn-inverted:not(.disabled):not(:disabled) {
&.btn-success {
@include btn-outline($white, $green-600, $green-500, $green-100, $green-700, $green-500, $green-200, $green-600, $green-800);
}
@@ -501,18 +501,19 @@
// All disabled buttons, regardless of color, type, etc
%disabled {
- background-color: $gray-light !important;
- border-color: $gray-200 !important;
- color: $gl-text-color-disabled !important;
- opacity: 1 !important;
- cursor: default !important;
+ background-color: $gray-light;
+ border-color: $gray-100;
+ color: $gl-text-color-disabled;
+ opacity: 1;
+ text-decoration: none;
+ cursor: default;
&.cursor-not-allowed {
- cursor: not-allowed !important;
+ cursor: not-allowed;
}
i {
- color: $gl-text-color-disabled !important;
+ color: $gl-text-color-disabled;
}
}
@@ -526,6 +527,10 @@ fieldset[disabled] .btn,
&:hover {
@extend %disabled;
}
+
+ &.btn-link {
+ background-color: transparent;
+ }
}
[readonly] {
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 849ca4a79f8..1abb7a9c06f 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -396,35 +396,16 @@ img.emoji {
🚨 Do not use these classes — they are deprecated and being removed. 🚨
See https://gitlab.com/gitlab-org/gitlab/-/issues/217418 for more details.
**/
-.prepend-top-5 { margin-top: 5px; }
.prepend-top-10 { margin-top: 10px; }
.prepend-top-15 { margin-top: 15px; }
-.prepend-top-default { margin-top: $gl-padding !important; }
-.prepend-top-16 { margin-top: 16px; }
.prepend-top-20 { margin-top: 20px; }
-.prepend-left-5 { margin-left: 5px; }
-.prepend-left-10 { margin-left: 10px; }
.prepend-left-15 { margin-left: 15px; }
-.prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; }
-.prepend-left-32 { margin-left: 32px; }
.prepend-left-64 { margin-left: 64px; }
-.append-right-2 { margin-right: 2px; }
-.append-right-4 { margin-right: 4px; }
-.append-right-5 { margin-right: 5px; }
-.append-right-10 { margin-right: 10px; }
.append-right-15 { margin-right: 15px; }
-.append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; }
-.append-right-32 { margin-right: 32px; }
-.append-right-48 { margin-right: 48px; }
-.prepend-right-32 { margin-right: 32px; }
-.append-bottom-5 { margin-bottom: 5px; }
.append-bottom-10 { margin-bottom: 10px; }
-.append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; }
-.append-bottom-default { margin-bottom: $gl-padding; }
-.prepend-bottom-32 { margin-bottom: 32px; }
.ml-10 { margin-left: 4.5rem; }
.inline { display: inline-block; }
.center { text-align: center; }
@@ -560,41 +541,6 @@ img.emoji {
}
}
-.onboarding-helper-container {
- bottom: 40px;
- right: 40px;
- font-size: $gl-font-size-small;
- background: $gray-50;
- width: 200px;
- border-radius: 24px;
- box-shadow: 0 2px 4px $issue-boards-card-shadow;
- z-index: 10000;
-
- .collapsible {
- max-height: 0;
- transition: max-height 0.5s cubic-bezier(0, 1, 0, 1);
- }
-
- &.expanded {
- border-bottom-right-radius: $border-radius-default;
- border-bottom-left-radius: $border-radius-default;
-
- .collapsible {
- max-height: 1000px;
- transition: max-height 1s ease-in-out;
- }
- }
-
- .avatar {
- border-color: darken($gray-normal, 10%);
-
- img {
- width: 32px;
- height: 32px;
- }
- }
-}
-
.gl-font-sm { font-size: $gl-font-size-small; }
.gl-font-lg { font-size: $gl-font-size-large; }
.gl-font-base { font-size: $gl-font-size-14; }
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index e4bee01f61f..7004bcc121d 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -98,11 +98,11 @@
width: $contextual-sidebar-collapsed-width - 1px;
.collapse-text,
- .icon-angle-double-left {
+ .icon-chevron-double-lg-left {
display: none;
}
- .icon-angle-double-right {
+ .icon-chevron-double-lg-right {
display: block;
margin: 0;
}
@@ -381,7 +381,7 @@
margin-right: 8px;
}
- .icon-angle-double-right {
+ .icon-chevron-double-lg-right {
display: none;
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 485a4879c43..32c276ea6d2 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -639,9 +639,12 @@
display: none;
cursor: pointer;
pointer-events: all;
- right: 22px;
- top: 9px;
+ top: $gl-padding-8;
font-size: 14px;
+
+ &:not(.gl-icon) {
+ right: 22px;
+ }
}
&.has-value {
@@ -1084,8 +1087,20 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.color-input-container {
.dropdown-label-color-preview {
- border: 1px solid $gray-200;
+ border: 1px solid $gray-100;
border-right: 0;
+
+ &[style] {
+ border-color: transparent;
+ }
+ }
+ }
+}
+
+.bulk-update {
+ .dropdown-toggle-text {
+ &.is-default {
+ color: $gl-text-color;
}
}
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index eef6d9031f8..8fd507a45bb 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -500,16 +500,27 @@ span.idiff {
border: transparent;
}
-.code-navigation {
- border-bottom: 1px $gray-darkest dashed;
+.code-navigation-line:hover {
+ .code-navigation {
+ border-bottom: 1px $gray-darkest dashed;
- &:hover {
- border-bottom-color: $almost-black;
+ &:hover {
+ border-bottom-color: $almost-black;
+ }
}
}
-.code-navigation-popover {
- max-width: 450px;
+.code-navigation-popover.popover {
+ max-width: calc(min(#{px-to-rem(560px)}, calc(100vw - #{$gl-padding-32})));
+}
+
+.code-navigation-popover-container {
+ max-height: px-to-rem(320px);
+}
+
+.code-navigation-popover .code {
+ padding-left: $grid-size * 3;
+ text-indent: -$grid-size * 2;
}
.tree-item-link {
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 9bba5c0614a..8f209d2d99a 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -26,6 +26,12 @@
margin-right: 6px;
}
+ .bulk-update {
+ .filter-item {
+ margin-right: 0;
+ }
+ }
+
.sort-filter {
display: inline-block;
float: right;
@@ -152,7 +158,7 @@
.filtered-search-token .selected,
.filtered-search-term .selected {
.name {
- background-color: $gray-200;
+ background-color: $gray-100;
}
.operator {
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index 44c8ace9040..ec8d5806345 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -4,6 +4,8 @@ textarea {
input {
border-radius: $border-radius-base;
+ color: $gl-text-color;
+ background-color: $input-bg;
}
input[type='text'].danger {
@@ -126,10 +128,6 @@ label {
display: inline;
}
-.wiki-content {
- margin-top: 35px;
-}
-
.form-control::placeholder {
color: $gl-text-color-tertiary;
}
diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss
index 8d5afe1d312..288849ba438 100644
--- a/app/assets/stylesheets/framework/gitlab_theme.scss
+++ b/app/assets/stylesheets/framework/gitlab_theme.scss
@@ -74,19 +74,6 @@
}
}
- &:focus:hover,
- &:focus {
- &.header-user-dropdown-toggle .header-user-notification-dot {
- border-color: $white;
- }
- }
-
- &:hover {
- &.header-user-dropdown-toggle .header-user-notification-dot {
- border-color: $nav-svg-color + 33;
- }
- }
-
&:hover,
&:focus {
@include media-breakpoint-up(sm) {
@@ -96,6 +83,10 @@
svg {
fill: currentColor;
}
+
+ &.header-user-dropdown-toggle .header-user-notification-dot {
+ border-color: $nav-svg-color + 33;
+ }
}
}
@@ -109,6 +100,10 @@
fill: $nav-svg-color;
}
}
+
+ &.header-user-dropdown-toggle .header-user-notification-dot {
+ border-color: $white;
+ }
}
.impersonated-user,
@@ -171,7 +166,7 @@
color: $sidebar-text;
}
- svg {
+ .nav-icon-container svg {
fill: $sidebar-text;
}
}
@@ -347,7 +342,7 @@ body {
.navbar-toggler,
.navbar-toggler:hover {
color: $gray-700;
- border-left: 1px solid $gray-200;
+ border-left: 1px solid $gray-100;
}
}
}
@@ -365,7 +360,7 @@ body {
.search-input-wrap {
.search-icon {
- fill: $gray-200;
+ fill: $gray-100;
}
.search-input {
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 2c7e9428ef1..50628c7de82 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -570,9 +570,9 @@
}
.header-user-notification-dot {
- background-color: $orange-500;
- height: 10px;
- width: 10px;
+ background-color: $orange-300;
+ height: 12px;
+ width: 12px;
right: 8px;
top: -8px;
}
diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss
index 2c9397d363c..0fae1c7d235 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -20,7 +20,7 @@
width: 100%;
}
- $image-widths: 80 130 150 250 306 394 430;
+ $image-widths: 80 130 150 225 250 306 394 430;
@each $width in $image-widths {
&.svg-#{$width} {
img,
diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss
index 385b29f8bbe..4d5032ac674 100644
--- a/app/assets/stylesheets/framework/issue_box.scss
+++ b/app/assets/stylesheets/framework/issue_box.scss
@@ -30,6 +30,7 @@
}
&.status-box-issue-closed,
+ &.status-box-alert-resolved,
&.status-box-mr-merged {
background-color: $blue-500;
}
diff --git a/app/assets/stylesheets/framework/job_log.scss b/app/assets/stylesheets/framework/job_log.scss
index 1a26c0283e5..2448be1bca3 100644
--- a/app/assets/stylesheets/framework/job_log.scss
+++ b/app/assets/stylesheets/framework/job_log.scss
@@ -5,7 +5,7 @@
font-size: 13px;
word-break: break-all;
word-wrap: break-word;
- color: $gl-text-color-inverted;
+ color: color-yiq($builds-trace-bg);
border-radius: $border-radius-small;
min-height: 42px;
background-color: $builds-trace-bg;
diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss
index b0bfc4f47ff..510969e149a 100644
--- a/app/assets/stylesheets/framework/memory_graph.scss
+++ b/app/assets/stylesheets/framework/memory_graph.scss
@@ -1,4 +1,4 @@
.memory-graph-container {
background: $white;
- border: 1px solid $gray-200;
+ border: 1px solid $gray-100;
}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 52da1b9abfc..918ca448c21 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -137,7 +137,8 @@
transition-duration: 0.3s;
}
- .fa {
+ .fa,
+ svg {
position: relative;
top: 5px;
font-size: 18px;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index bd262b65dc3..f85efc63645 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -313,7 +313,7 @@
right: 0;
text-align: right;
- .fa {
+ svg {
right: 5px;
}
}
@@ -323,7 +323,7 @@
left: 0;
text-align: left;
- .fa {
+ svg {
left: 5px;
}
}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 1131248dd3f..9b33ed1b630 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -214,7 +214,7 @@
.health-status {
.dropdown-body {
.health-divider {
- border-top-color: $gray-200;
+ border-top-color: $gray-100;
}
.dropdown-item:not(.health-dropdown-item) {
diff --git a/app/assets/stylesheets/framework/stacked_progress_bar.scss b/app/assets/stylesheets/framework/stacked_progress_bar.scss
index 0a57a74eafc..2d16fdf4ee7 100644
--- a/app/assets/stylesheets/framework/stacked_progress_bar.scss
+++ b/app/assets/stylesheets/framework/stacked_progress_bar.scss
@@ -36,7 +36,7 @@
}
.status-neutral {
- background-color: $gray-200;
+ background-color: $gray-100;
color: $gl-gray-dark;
&:hover {
diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss
index 4f66d6bf354..10796f319bf 100644
--- a/app/assets/stylesheets/framework/system_messages.scss
+++ b/app/assets/stylesheets/framework/system_messages.scss
@@ -94,7 +94,8 @@
margin-bottom: 16px;
}
- .boards-list {
+ .boards-list,
+ .board-swimlanes {
height: calc(100vh - #{$header-height + $breadcrumb-min-height + $performance-bar-height + $system-footer-height + $gl-padding-32});
}
}
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index ff6ac87db76..1504f3ee50f 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -27,7 +27,13 @@
.timeline-entry {
color: $gl-text-color;
- background-color: $white;
+
+ // [dark-theme]: only give background color to actual notes
+ // in the timeline, the note form textarea has a background
+ // of it's own
+ &:not(.note-form) {
+ background-color: $white;
+ }
.timeline-entry-inner {
position: relative;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 6e07a2b5de1..b5b86b807a6 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -89,7 +89,7 @@
background-color: $gray-10;
border-width: 1px;
border-style: solid;
- border-color: $gray-200 $gray-200 $gray-400;
+ border-color: $gray-100 $gray-100 $gray-400;
border-image: none;
border-radius: 3px;
box-shadow: 0 -1px 0 $gray-400 inset;
@@ -181,7 +181,7 @@
background-color: $white;
td {
- border-color: $gray-200;
+ border-color: $gray-100;
}
}
@@ -611,7 +611,7 @@ pre {
word-wrap: break-word;
color: $gl-text-color;
background-color: $gray-light;
- border: 1px solid $gray-200;
+ border: 1px solid $gray-100;
border-radius: $border-radius-small;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 1536c5c3022..265dceb3c61 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -113,29 +113,29 @@ $gl-gray-600: #666 !default;
$gl-gray-700: #555 !default;
$gl-gray-800: #333 !default;
-$green-50: #f1fdf6 !default;
-$green-100: #dcf5e7 !default;
-$green-200: #263a2e !default;
-$green-300: #75d09b !default;
-$green-400: #37b96d !default;
-$green-500: #1aaa55 !default;
-$green-600: #168f48 !default;
-$green-700: #12753a !default;
-$green-800: #0e5a2d !default;
+$green-50: #ecf4ee !default;
+$green-100: #c3e6cd !default;
+$green-200: #91d4a8 !default;
+$green-300: #52b87a !default;
+$green-400: #2da160 !default;
+$green-500: #108548 !default;
+$green-600: #217645 !default;
+$green-700: #24663b !default;
+$green-800: #0d532a !default;
$green-900: #0a4020 !default;
$green-950: #072b15 !default;
-$blue-50: #f6fafe !default;
-$blue-100: #e4f0fb !default;
-$blue-200: #b8d6f4 !default;
-$blue-300: #73afea !default;
-$blue-400: #418cd8 !default;
-$blue-500: #1f78d1 !default;
-$blue-600: #1b69b6 !default;
-$blue-700: #17599c !default;
-$blue-800: #134a81 !default;
-$blue-900: #0f3b66 !default;
-$blue-950: #0a2744 !default;
+$blue-50: #e9f3fc !default;
+$blue-100: #cbe2f9 !default;
+$blue-200: #9dc7f1 !default;
+$blue-300: #63a6e9 !default;
+$blue-400: #428fdc !default;
+$blue-500: #1f75cb !default;
+$blue-600: #1068bf !default;
+$blue-700: #0b5cad !default;
+$blue-800: #064787 !default;
+$blue-900: #033464 !default;
+$blue-950: #002850 !default;
$orange-50: #fffaf4 !default;
$orange-100: #fff1de !default;
@@ -164,14 +164,14 @@ $red-950: #4d0a00 !default;
$gray-10: #fafafa !default;
$gray-50: #f0f0f0 !default;
$gray-100: #dbdbdb !default;
-$gray-200: #dfdfdf !default;
+$gray-200: #bfbfbf !default;
$gray-300: #ccc !default;
$gray-400: #bababa !default;
$gray-500: #a7a7a7 !default;
$gray-600: #919191 !default;
$gray-700: #707070 !default;
$gray-800: #4f4f4f !default;
-$gray-900: #2e2e2e !default;
+$gray-900: #303030 !default;
$gray-950: #1f1f1f !default;
$greens: (
@@ -333,7 +333,7 @@ $border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor);
/*
* UI elements
*/
-$border-color: $gray-200;
+$border-color: $gray-100;
$shadow-color: $t-gray-a-08;
$well-expand-item: #e8f2f7;
$well-inner-border: #eef0f2;
@@ -479,9 +479,9 @@ $gl-btn-active-gradient: inset 0 2px 3px $gl-btn-active-background;
$added: #63c363;
$deleted: #f77;
$line-added: #ecfdf0;
-$line-added-dark: #c7f0d2;
+$line-added-dark: #c7f0d2 !default;
$line-removed: #fbe9eb;
-$line-removed-dark: #fac5cd;
+$line-removed-dark: #fac5cd !default;
$line-number-old: #f9d7dc;
$line-number-new: #ddfbe6;
$line-number-select: #fbf2da;
@@ -711,7 +711,6 @@ $input-lg-width: 320px;
*/
$document-index-color: #888;
$help-shortcut-header-color: #333;
-$accepting-mr-label-color: #69d100;
/*
* Issues
@@ -868,7 +867,7 @@ $priority-label-empty-state-width: 114px;
Popovers
*/
$popover-max-width: 384px;
-$popover-box-shadow: 0 2px 3px 1px $gray-200;
+$popover-box-shadow: 0 2px 3px 1px $gray-100;
/*
Issues Analytics
diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss
index c7a50bdb5a3..acfda718e77 100644
--- a/app/assets/stylesheets/framework/variables_overrides.scss
+++ b/app/assets/stylesheets/framework/variables_overrides.scss
@@ -5,23 +5,23 @@
$secondary: $gray-light;
$input-disabled-bg: $gray-light;
-$input-border-color: $gray-200;
+$input-border-color: $gray-100;
$input-color: $gl-text-color;
$input-font-size: $gl-font-size;
$font-family-sans-serif: $regular-font;
$font-family-monospace: $monospace-font;
$btn-line-height: 20px;
$table-accent-bg: $gray-light;
-$table-border-color: $gray-200;
+$table-border-color: $gray-100;
$card-border-color: $border-color;
-$card-cap-bg: $gray-light;
+$card-cap-bg: $gray-light !default;
$success: $green-500;
$info: $blue-500;
$warning: $orange-500;
$danger: $red-500;
$zindex-modal-backdrop: 1040;
$nav-divider-margin-y: ($grid-size / 2);
-$dropdown-divider-bg: $gray-200;
+$dropdown-divider-bg: $gray-100;
$dropdown-item-padding-y: 8px;
$dropdown-item-padding-x: 12px;
$popover-max-width: 300px;
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 5ab762a5104..8d965ea4309 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -1,6 +1,6 @@
/* https://github.com/MozMorris/tomorrow-pygments */
-@import "../common";
+@import '../common';
/*
* Dark syntax colors
@@ -223,11 +223,20 @@ $dark-il: #de935f;
.cs { color: $dark-cs; } /* Comment.Special */
.gd { color: $dark-gd; } /* Generic.Deleted */
.ge { font-style: italic; } /* Generic.Emph */
- .gh { color: $dark-gh; font-weight: $gl-font-weight-bold; } /* Generic.Heading */
+ .gh { /* Generic.Heading */
+ color: $dark-gh;
+ font-weight: $gl-font-weight-bold;
+ }
.gi { color: $dark-gi; } /* Generic.Inserted */
- .gp { color: $dark-gp; font-weight: $gl-font-weight-bold; } /* Generic.Prompt */
+ .gp { /* Generic.Prompt */
+ color: $dark-gp;
+ font-weight: $gl-font-weight-bold;
+ }
.gs { font-weight: $gl-font-weight-bold; } /* Generic.Strong */
- .gu { color: $dark-gu; font-weight: $gl-font-weight-bold; } /* Generic.Subheading */
+ .gu { /* Generic.Subheading */
+ color: $dark-gu;
+ font-weight: $gl-font-weight-bold;
+ }
.kc { color: $dark-kc; } /* Keyword.Constant */
.kd { color: $dark-kd; } /* Keyword.Declaration */
.kn { color: $dark-kn; } /* Keyword.Namespace */
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 348ef69cc4f..5ef2b9dcc36 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -1,6 +1,6 @@
/* https://github.com/richleland/pygments-css/blob/master/monokai.css */
-@import "../common";
+@import '../common';
/*
* Monokai Colors
@@ -211,7 +211,10 @@ $monokai-gi: #a6e22e;
.hll { background-color: $monokai-hll; }
.c { color: $monokai-c; } /* Comment */
- .err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */
+ .err { /* Error */
+ color: $monokai-err-color;
+ background-color: $monokai-err-bg;
+ }
.k { color: $monokai-k; } /* Keyword */
.l { color: $monokai-l; } /* Literal */
.n { color: $monokai-n; } /* Name */
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index 2fc5d7f7a85..fb548a00526 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -2,7 +2,7 @@
* None Syntax Colors
*/
-@import "../common";
+@import '../common';
@mixin match-line {
color: $black-transparent;
@@ -10,7 +10,7 @@
}
.code.none {
- // Line numbers
+ // Line numbers
.line-numbers,
.diff-line-num {
background-color: $gray-light;
@@ -44,7 +44,6 @@
$none-expanded-bg: #e0e0e0;
.line_holder {
-
&.match .line_content,
.new-nonewline.line_content,
.old-nonewline.line_content {
@@ -149,12 +148,12 @@
background-color: $white-normal;
}
- // Search result highlight
+ // Search result highlight
span.highlight_word {
background-color: $white-normal;
}
- // Links to URLs, emails, or dependencies
+ // Links to URLs, emails, or dependencies
.line a {
color: $gl-text-color;
text-decoration: underline;
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index f5b36480f18..190a6e6156a 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -1,6 +1,6 @@
/* https://gist.github.com/qguv/7936275 */
-@import "../common";
+@import '../common';
/*
* Solarized dark colors
@@ -244,13 +244,19 @@ $solarized-dark-il: #2aa198;
.c1 { color: $solarized-dark-c1; } /* Comment.Single */
.cs { color: $solarized-dark-cs; } /* Comment.Special */
.gd { color: $solarized-dark-gd; } /* Generic.Deleted */
- .ge { color: $solarized-dark-ge; font-style: italic; } /* Generic.Emph */
+ .ge { /* Generic.Emph */
+ color: $solarized-dark-ge;
+ font-style: italic;
+ }
.gr { color: $solarized-dark-gr; } /* Generic.Error */
.gh { color: $solarized-dark-gh; } /* Generic.Heading */
.gi { color: $solarized-dark-gi; } /* Generic.Inserted */
.go { color: $solarized-dark-go; } /* Generic.Output */
.gp { color: $solarized-dark-gp; } /* Generic.Prompt */
- .gs { color: $solarized-dark-gs; font-weight: $gl-font-weight-bold; } /* Generic.Strong */
+ .gs { /* Generic.Strong */
+ color: $solarized-dark-gs;
+ font-weight: $gl-font-weight-bold;
+ }
.gu { color: $solarized-dark-gu; } /* Generic.Subheading */
.gt { color: $solarized-dark-gt; } /* Generic.Traceback */
.kc { color: $solarized-dark-kc; } /* Keyword.Constant */
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index 993370642c3..71d8dd06834 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -1,6 +1,6 @@
/* https://gist.github.com/qguv/7936275 */
-@import "../common";
+@import '../common';
/*
* Solarized light syntax colors
@@ -252,13 +252,19 @@ $solarized-light-il: #2aa198;
.c1 { color: $solarized-light-c1; } /* Comment.Single */
.cs { color: $solarized-light-cs; } /* Comment.Special */
.gd { color: $solarized-light-gd; } /* Generic.Deleted */
- .ge { color: $solarized-light-ge; font-style: italic; } /* Generic.Emph */
+ .ge { /* Generic.Emph */
+ color: $solarized-light-ge;
+ font-style: italic;
+ }
.gr { color: $solarized-light-gr; } /* Generic.Error */
.gh { color: $solarized-light-gh; } /* Generic.Heading */
.gi { color: $solarized-light-gi; } /* Generic.Inserted */
.go { color: $solarized-light-go; } /* Generic.Output */
.gp { color: $solarized-light-gp; } /* Generic.Prompt */
- .gs { color: $solarized-light-gs; font-weight: $gl-font-weight-bold; } /* Generic.Strong */
+ .gs { /* Generic.Strong */
+ color: $solarized-light-gs;
+ font-weight: $gl-font-weight-bold;
+ }
.gu { color: $solarized-light-gu; } /* Generic.Subheading */
.gt { color: $solarized-light-gt; } /* Generic.Traceback */
.kc { color: $solarized-light-kc; } /* Keyword.Constant */
diff --git a/app/assets/stylesheets/highlight/themes/white.scss b/app/assets/stylesheets/highlight/themes/white.scss
index 7239086f649..6362dd734f6 100644
--- a/app/assets/stylesheets/highlight/themes/white.scss
+++ b/app/assets/stylesheets/highlight/themes/white.scss
@@ -1,3 +1,3 @@
.code.white {
- @import "../white_base";
+ @import '../white_base';
}
diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss
index f7d93870a25..f188b29a113 100644
--- a/app/assets/stylesheets/mailer.scss
+++ b/app/assets/stylesheets/mailer.scss
@@ -6,12 +6,12 @@
// stylelint-disable color-hex-length
$mailer-font: 'Helvetica Neue', Helvetica, Arial, sans-serif;
-$mailer-text-color: #333333;
+$mailer-text-color: #333;
$mailer-bg-color: #fafafa;
$mailer-link-color: #3777b0;
-$mailer-link-muted-color: #333333;
+$mailer-link-muted-color: #333;
$mailer-line-cell-bg-color: #6b4fbb;
-$mailer-wrapper-cell-bg-color: #ffffff;
+$mailer-wrapper-cell-bg-color: #fff;
$mailer-wrapper-cell-border-color: #ededed;
$mailer-header-footer-text-color: #5c5c5c;
diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
index 2b82b2226c6..a8d10ea1a29 100644
--- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
@@ -146,7 +146,7 @@
}
pre {
- border-color: var(--ide-border-color-alt, $gray-200);
+ border-color: var(--ide-border-color-alt, $gray-100);
code {
background-color: var(--ide-border-color, inherit);
@@ -216,7 +216,7 @@
color: var(--ide-text-color, $gl-text-color);
&:hover {
- background-color: var(--ide-input-border, $gray-200);
+ background-color: var(--ide-input-border, $gray-100);
}
}
@@ -300,8 +300,8 @@
}
.divider {
- background-color: var(--ide-dropdown-hover-background, $gray-200);
- border-color: var(--ide-dropdown-hover-background, $gray-200);
+ background-color: var(--ide-dropdown-hover-background, $gray-100);
+ border-color: var(--ide-dropdown-hover-background, $gray-100);
}
li > a:not(.disable-hover):hover,
@@ -316,7 +316,7 @@
.dropdown-title,
.dropdown-input {
- border-color: var(--ide-dropdown-hover-background, $gray-200) !important;
+ border-color: var(--ide-dropdown-hover-background, $gray-100) !important;
}
.btn-primary,
@@ -356,7 +356,7 @@
.btn[disabled] {
background-color: var(--ide-btn-default-background, $gray-light) !important;
- border: 1px solid var(--ide-btn-disabled-border, $gray-200) !important;
+ border: 1px solid var(--ide-btn-disabled-border, $gray-100) !important;
color: var(--ide-btn-disabled-color, $gl-text-color-disabled) !important;
}
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 9c92f891834..a07755724dd 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -145,7 +145,7 @@ $ide-commit-header-height: 48px;
}
&:not([disabled]):hover {
- background-color: var(--ide-input-border, $gray-200);
+ background-color: var(--ide-input-border, $gray-100);
}
&:not([disabled]):focus {
@@ -251,10 +251,6 @@ $ide-commit-header-height: 48px;
padding-left: $gl-padding;
}
}
-
-.ide-status-file {
- text-align: right;
-}
// Not great, but this is to deal with our current output
.multi-file-preview-holder {
height: 100%;
@@ -400,7 +396,7 @@ $ide-commit-header-height: 48px;
}
&:active {
- background: var(--ide-background, $gray-200);
+ background: var(--ide-background, $gray-100);
}
&.is-active {
@@ -571,7 +567,7 @@ $ide-commit-header-height: 48px;
&:focus {
color: var(--ide-text-color, $gl-text-color);
- background-color: var(--ide-background-hover, $gray-200);
+ background-color: var(--ide-background-hover, $gray-100);
}
&.active {
@@ -1050,7 +1046,7 @@ $ide-commit-header-height: 48px;
background-color: var(--ide-background, $gray-50);
&:hover {
- background-color: var(--ide-file-row-btn-hover-background, $gray-200);
+ background-color: var(--ide-file-row-btn-hover-background, $gray-100);
}
&:active,
@@ -1101,7 +1097,7 @@ $ide-commit-header-height: 48px;
&:focus {
outline: 0;
box-shadow: none;
- border-color: var(--ide-border-color, $gray-200);
+ border-color: var(--ide-border-color, $gray-100);
}
}
@@ -1144,7 +1140,7 @@ $ide-commit-header-height: 48px;
}
.file-row:active {
- background: var(--ide-background, $gray-200);
+ background: var(--ide-background, $gray-100);
}
.file-row.is-active {
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
index a58a0ed9475..0ef0834d8db 100644
--- a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
@@ -47,4 +47,4 @@
--ide-animation-gradient-1: var(--ide-file-row-btn-hover-background);
--ide-animation-gradient-2: var(--ide-dropdown-hover-background);
- }
+}
diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/pages/alert_management/details.scss
index 591a26e5941..73a4af00c5a 100644
--- a/app/assets/stylesheets/pages/alert_management/details.scss
+++ b/app/assets/stylesheets/pages/alert_management/details.scss
@@ -61,13 +61,13 @@
&.is-active {
&:last-child {
- border-bottom: 1px solid $gray-200;
+ border-bottom: 1px solid $gray-100;
}
}
}
}
.note-header-info {
- margin-top: 1px;
+ @include gl-mt-1;
}
}
diff --git a/app/assets/stylesheets/pages/alert_management/list.scss b/app/assets/stylesheets/pages/alert_management/list.scss
index c1ea9b7604a..e420209b1fc 100644
--- a/app/assets/stylesheets/pages/alert_management/list.scss
+++ b/app/assets/stylesheets/pages/alert_management/list.scss
@@ -1,4 +1,8 @@
.alert-management-list {
+ .new-alert {
+ background-color: $issues-today-bg;
+ }
+
// these styles need to be deleted once GlTable component looks in GitLab same as in @gitlab/ui
table {
color: $gray-700;
@@ -8,14 +12,9 @@
outline: none;
}
- > :not([aria-sort='none']).b-table-sort-icon-left:hover::before {
- content: '' !important;
- }
-
td,
th {
- // TODO: There is no gl-pl-9 utlity for this padding, to be done and then removed.
- padding-left: 1.25rem;
+ @include gl-pl-9;
@include gl-py-5;
@include gl-outline-none;
@include gl-relative;
@@ -26,24 +25,8 @@
font-weight: $gl-font-weight-bold;
color: $gl-gray-600;
- &:hover::before {
- left: 3%;
- top: 34%;
- @include gl-absolute;
- content: url("data:image/svg+xml,%3Csvg \
- xmlns='http://www.w3.org/2000/svg' \
- width='14' height='14' viewBox='0 0 16 \
- 16'%3E%3Cpath fill='%23BABABA' fill-rule='evenodd' \
- d='M11.707085,11.7071 L7.999975,15.4142 L4.292875,11.7071 \
- C3.902375,11.3166 3.902375,10.6834 \
- 4.292875,10.2929 C4.683375,9.90237 \
- 5.316575,9.90237 5.707075,10.2929 \
- L6.999975,11.5858 L6.999975,2 C6.999975,1.44771 \
- 7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771 \
- 8.999975,2 L8.999975,11.5858 L10.292865,10.2929 \
- C10.683395,9.90237 11.316555,9.90237 11.707085,10.2929 \
- C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 \
- Z'/%3E%3C/svg%3E%0A");
+ &[aria-sort='none']:hover {
+ background-image: url('data:image/svg+xml, %3csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="4 0 8 16"%3e %3cpath style="fill: %23BABABA;" fill-rule="evenodd" d="M11.707085,11.7071 L7.999975,15.4142 L4.292875,11.7071 C3.902375,11.3166 3.902375, 10.6834 4.292875,10.2929 C4.683375,9.90237 5.316575,9.90237 5.707075,10.2929 L6.999975, 11.5858 L6.999975,2 C6.999975,1.44771 7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771 8.999975,2 L8.999975,11.5858 L10.292865,10.2929 C10.683395 ,9.90237 11.316555,9.90237 11.707085,10.2929 C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 Z"/%3e %3c/svg%3e');
}
}
}
@@ -74,7 +57,7 @@
content: none !important;
}
- div {
+ div:not(.dropdown-title) {
width: 100% !important;
padding: 0 !important;
}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 3e680c59910..049660220df 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -45,7 +45,8 @@
}
}
-.boards-list {
+.boards-list,
+.board-swimlanes {
height: calc(100vh - #{$issue-board-list-difference-xs});
overflow-x: scroll;
min-height: 200px;
@@ -82,7 +83,6 @@
}
.board-title-caret {
- cursor: pointer;
border-radius: $border-radius-default;
line-height: $gl-spacing-scale-5;
height: $gl-spacing-scale-5;
@@ -109,7 +109,6 @@
.board-title {
flex-direction: column;
height: 100%;
- padding: $gl-padding-8 0;
}
.board-title-caret {
@@ -203,8 +202,7 @@
flex-grow: 1;
}
-.board-delete {
- color: $gray-darkest;
+.board-delete.gl-button {
background-color: transparent;
outline: 0;
@@ -579,7 +577,10 @@
}
}
-.board-epics-swimlanes {
+.board-swimlanes {
overflow-x: auto;
- min-height: 600px;
+}
+
+.board-header-collapsed-info-icon:hover {
+ color: $gray-900;
}
diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/pages/branches.scss
index e1715b8e1bf..3c49cc54ac4 100644
--- a/app/assets/stylesheets/pages/branches.scss
+++ b/app/assets/stylesheets/pages/branches.scss
@@ -23,7 +23,7 @@
.bar {
height: 4px;
- background-color: $gl-gray-200;
+ background-color: $gl-gray-100;
}
.count {
@@ -34,7 +34,7 @@
.graph-separator {
width: $graph-separator-width;
height: 18px;
- background-color: $gl-gray-200;
+ background-color: $gl-gray-100;
}
}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index f50d4bc736e..02c42d5b779 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -236,7 +236,7 @@
.trigger-variables-table-cell {
font-size: $gl-font-size-small;
line-height: $gl-line-height;
- border: 1px solid $gray-200;
+ border: 1px solid $gray-100;
padding: $gl-padding-4 6px;
width: 50%;
vertical-align: top;
diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss
deleted file mode 100644
index b88bd78cf3d..00000000000
--- a/app/assets/stylesheets/pages/container_registry.scss
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Container Registry
- */
-
-.container-message {
- span .btn {
- margin: 0;
- }
-}
-
-.container-image {
- border-bottom: 1px solid $white-normal;
-}
-
-.container-image-head {
- padding: 0 16px;
- line-height: 4em;
-
- .btn-link {
- padding: 0;
-
- &:focus {
- outline: none;
- }
- }
-}
-
-.table.tags {
- margin-bottom: 0;
-
- .registry-image-row {
- .check {
- padding-right: $gl-padding;
- width: 5%;
- }
-
- .action-buttons {
- opacity: 0;
- }
-
- &:hover {
- .action-buttons {
- opacity: 1;
- }
- }
- }
-}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 98d74a9aaa2..fd5b3ff1dd8 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -935,11 +935,10 @@ table.code {
}
}
-.files:not([data-can-create-note='true']) .frame {
+.files:not([data-can-create-note]) .frame {
cursor: auto;
}
-.frame,
.frame.click-to-comment,
.btn-transparent.image-diff-overlay-add-comment {
position: relative;
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index eb9684c7b3c..fd11d0e3a69 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -45,6 +45,7 @@
display: block;
float: left;
margin-right: 10px;
+ max-width: 250px;
}
.new-file-name,
@@ -139,10 +140,6 @@
clear: both;
}
}
-
- .editor-ref {
- max-width: 250px;
- }
}
}
diff --git a/app/assets/stylesheets/pages/environment_logs.scss b/app/assets/stylesheets/pages/environment_logs.scss
index 81cec14062f..03993e5321d 100644
--- a/app/assets/stylesheets/pages/environment_logs.scss
+++ b/app/assets/stylesheets/pages/environment_logs.scss
@@ -31,10 +31,6 @@
width: 160px;
}
}
-
- .controllers {
- @include build-controllers(16px, flex-end, false, 2, inline);
- }
}
.log-lines,
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index b1e849143b0..a7d0d4259ea 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -115,20 +115,6 @@
font-size: 0;
margin-bottom: -5px;
}
-
- .scoped-label-wrapper {
- > a {
- max-width: 100%;
- }
-
- .color-label {
- padding-right: $gl-padding-24;
- }
-
- .scoped-label {
- right: 12px;
- }
- }
}
.assignee {
@@ -396,7 +382,7 @@
overflow: hidden;
&:hover {
- background-color: $gray-200;
+ background-color: $gray-100;
}
&.issuable-sidebar-header {
@@ -983,10 +969,6 @@
vertical-align: sub;
}
-.suggestion-item a {
- color: initial;
-}
-
.suggestion-confidential {
color: $orange-600;
}
diff --git a/app/assets/stylesheets/pages/issues/issue_count_badge.scss b/app/assets/stylesheets/pages/issues/issue_count_badge.scss
index 569f323abd8..f2283e02ad2 100644
--- a/app/assets/stylesheets/pages/issues/issue_count_badge.scss
+++ b/app/assets/stylesheets/pages/issues/issue_count_badge.scss
@@ -1,7 +1,5 @@
.issue-count-badge,
.mr-count-badge {
- display: inline-flex;
- border-radius: $border-radius-base;
padding: 5px $gl-padding-8;
}
diff --git a/app/assets/stylesheets/pages/issues/issues_list.scss b/app/assets/stylesheets/pages/issues/issues_list.scss
new file mode 100644
index 00000000000..c0af7a6af6d
--- /dev/null
+++ b/app/assets/stylesheets/pages/issues/issues_list.scss
@@ -0,0 +1,5 @@
+.svg-container.jira-logo-container {
+ svg {
+ vertical-align: text-bottom;
+ }
+}
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index c3bac053a0a..73d2c3ca2f8 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -134,6 +134,11 @@
}
}
+.label-description-wrapper {
+ margin-right: 8px;
+ margin-left: 8px;
+}
+
.prioritized-labels {
margin-bottom: 30px;
@@ -310,7 +315,6 @@
width: 200px;
flex-shrink: 0;
- .scoped-label-wrapper,
.gl-label {
line-height: $gl-line-height;
}
@@ -386,7 +390,7 @@
order: 3;
width: 100%;
- > .append-right-default.prepend-left-default {
+ > .label-description-wrapper {
margin-left: 0;
margin-right: 0;
}
@@ -415,40 +419,6 @@
color: $indigo-300;
}
-.scoped-label-wrapper {
- max-width: 100%;
- vertical-align: top;
-
- .badge {
- text-overflow: ellipsis;
- overflow-x: hidden;
- }
-
- &.label-link .color-label a {
- color: inherit;
- }
-
- .color-label {
- padding-right: $gl-padding-24;
- max-width: 100%;
- }
-
- .scoped-label {
- position: absolute;
- top: 4px;
- right: 8px;
- padding: 0;
- margin: 0;
- line-height: $gl-line-height;
- }
-
- &.board-label {
- .scoped-label {
- top: 1px;
- }
- }
-}
-
.gl-label-scoped {
box-shadow: 0 0 0 2px currentColor inset;
@@ -456,29 +426,3 @@
box-shadow: 0 0 0 1px inset;
}
}
-
-// Label inside title of Delete Label Modal
-.modal-header .page-title {
- .scoped-label-wrapper {
- .scoped-label {
- line-height: 20px;
- }
-
- span.color-label {
- padding-right: $gl-padding-24;
- }
- }
-}
-
-// Don't hide the overflow in system messages
-.system-note-message,
-.issuable-details,
-.md-preview-holder,
-.referenced-commands,
-.note-body {
- .scoped-label-wrapper {
- .badge {
- overflow: initial;
- }
- }
-}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 1e5e6da4e6c..5cf2d847405 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -64,7 +64,7 @@ $mr-widget-min-height: 69px;
background-color: $gray-light;
&.clickable:hover {
- background-color: $gl-gray-200;
+ background-color: $gl-gray-100;
cursor: pointer;
}
}
@@ -75,7 +75,7 @@ $mr-widget-min-height: 69px;
&::before {
content: '';
- border-left: 1px solid $gray-200;
+ border-left: 1px solid $gray-100;
position: absolute;
left: 28px;
top: -17px;
@@ -162,10 +162,6 @@ $mr-widget-min-height: 69px;
.btn {
font-size: $gl-font-size;
- &[disabled] {
- opacity: 0.3;
- }
-
&.dropdown-toggle {
.fa {
color: inherit;
@@ -401,6 +397,16 @@ $mr-widget-min-height: 69px;
}
}
}
+
+ &.mr-pipeline-suggest {
+ border-radius: $border-radius-default;
+ line-height: 20px;
+ border: 1px solid $border-color;
+
+ .circle-icon-container {
+ color: $gl-text-color-quaternary;
+ }
+ }
}
.mr-widget-help {
@@ -600,26 +606,6 @@ $mr-widget-min-height: 69px;
}
}
-.mr-pipeline-suggest {
- flex-wrap: wrap;
- border-radius: $border-radius-default;
- padding: $gl-padding;
- border: 1px solid $border-color;
- min-height: $mr-widget-min-height;
-
- @include media-breakpoint-up(md) {
- align-items: center;
- }
-
- .circle-icon-container {
- color: $gl-text-color-quaternary;
- }
-
- .popover {
- z-index: 240;
- }
-}
-
.card-new-merge-request {
.card-header {
padding: 5px 10px;
@@ -1050,3 +1036,7 @@ $mr-widget-min-height: 69px;
}
}
}
+
+.diff-file-row.is-active {
+ background-color: $gray-50;
+}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index c3f3dbc223b..3a210d66420 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -62,7 +62,8 @@
background-color: $white;
&.is-focused {
- @extend .form-control:focus;
+ border-color: $input-focus-border-color;
+ box-shadow: $input-focus-box-shadow;
.comment-toolbar,
.nav-links {
@@ -359,14 +360,6 @@ table {
}
}
-.toolbar-button-icon {
- position: relative;
- top: 1px;
- margin-right: $gl-padding-4;
- color: inherit;
- font-size: 16px;
-}
-
.toolbar-text {
font-size: 14px;
line-height: 16px;
@@ -489,6 +482,7 @@ table {
border: 0;
font-size: 14px;
line-height: 16px;
+ vertical-align: initial;
&:hover,
&:focus {
@@ -498,6 +492,10 @@ table {
text-decoration: underline;
}
}
+
+ .gl-icon:not(:last-child) {
+ margin-right: 0;
+ }
}
.markdown-selector {
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index e8cdfd717c0..40f0104a2bf 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -10,6 +10,7 @@ $note-form-margin-left: 72px;
top: 0;
bottom: 0;
left: $left;
+ height: calc(100% - 20px);
}
}
@@ -185,8 +186,8 @@ $note-form-margin-left: 72px;
padding: $gl-padding;
.dummy-avatar {
- background-color: $gl-gray-200;
- border: 1px solid darken($gl-gray-200, 25%);
+ background-color: $gl-gray-100;
+ border: 1px solid darken($gl-gray-100, 25%);
}
.note-headline-light,
@@ -254,10 +255,6 @@ $note-form-margin-left: 72px;
}
&.is-loading {
- .fa-smile-o {
- display: none;
- }
-
.fa-spinner {
display: inline-block;
}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 43d766db9e0..57ad9abef4b 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -253,13 +253,6 @@
}
.stage-cell {
- &.table-section {
- @include media-breakpoint-up(md) {
- min-width: 160px; /* Hack alert: Without this the mini graph pipeline won't work properly*/
- margin-right: -4px;
- }
- }
-
.mini-pipeline-graph-dropdown-toggle {
svg {
height: $ci-action-icon-size;
@@ -816,7 +809,7 @@
&.ci-status-icon-created,
&.ci-status-icon-skipped {
- @include mini-pipeline-graph-color($white, $gray-200, $gray-300, $gray-500, $gray-600, $gray-700);
+ @include mini-pipeline-graph-color($white, $gray-100, $gray-300, $gray-500, $gray-600, $gray-700);
}
}
@@ -1108,7 +1101,3 @@ button.mini-pipeline-graph-dropdown-toggle {
.progress-bar.bg-primary {
background-color: $blue-500 !important;
}
-
-.parent-child-label-container {
- padding-top: $gl-padding-4;
-}
diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss
index 3bab84af492..12386fa66ec 100644
--- a/app/assets/stylesheets/pages/profiles/preferences.scss
+++ b/app/assets/stylesheets/pages/profiles/preferences.scss
@@ -19,11 +19,6 @@
$ui-light-bg: #dfdfdf;
$ui-dark-mode-bg: #1f1f1f;
- label {
- margin: 0 $gl-padding-32 $gl-padding 0;
- text-align: center;
- }
-
.preview {
font-size: 0;
height: 48px;
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index 26db1fb9f58..6461d09bb47 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -34,7 +34,7 @@
.draggable {
&.draggable-enabled {
.draggable-panel {
- border: $gray-200 1px solid;
+ border: $gray-100 1px solid;
border-radius: $border-radius-default;
margin: -1px;
cursor: grab;
diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss
index dc3811bab65..66d2f76c558 100644
--- a/app/assets/stylesheets/pages/runners.scss
+++ b/app/assets/stylesheets/pages/runners.scss
@@ -45,8 +45,7 @@
color: $gl-text-color-secondary;
}
- .fa-pause,
- .fa-play {
+ .fa-pause {
font-size: 11px;
}
}
diff --git a/app/assets/stylesheets/pages/service_desk.scss b/app/assets/stylesheets/pages/service_desk.scss
new file mode 100644
index 00000000000..34ab5eb1b74
--- /dev/null
+++ b/app/assets/stylesheets/pages/service_desk.scss
@@ -0,0 +1,7 @@
+.service-desk-issues {
+ .non-empty-state {
+ text-align: left;
+ padding-bottom: $gl-padding-top;
+ border-bottom: 1px solid $border-color;
+ }
+}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index d26c07ce51b..f1df9099d82 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -347,7 +347,7 @@
.btn-clipboard {
background-color: $white;
- border: 1px solid $gray-200;
+ border: 1px solid $gray-100;
}
.deploy-token-help-block {
diff --git a/app/assets/stylesheets/pages/sherlock.scss b/app/assets/stylesheets/pages/sherlock.scss
index 2bf0bedb1f5..55b0b5295af 100644
--- a/app/assets/stylesheets/pages/sherlock.scss
+++ b/app/assets/stylesheets/pages/sherlock.scss
@@ -13,10 +13,8 @@ table .sherlock-code {
}
.sherlock-line-samples-table {
- margin-bottom: 0 !important;
-
- thead tr th,
- tbody tr td {
+ thead th,
+ tbody td {
font-size: 13px !important;
text-align: right;
padding: 0 10px !important;
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index 640968ff678..8c4bfdf68cc 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/pages/wiki.scss
@@ -147,3 +147,7 @@ ul.wiki-pages-list.content-list {
}
}
}
+
+.empty-state-wiki .text-content {
+ max-width: 490px; // Widen to allow for the Confluence button
+}
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index 4eef4d361a1..daeab80d373 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -61,7 +61,7 @@
padding: 4px 6px;
font-family: Consolas, 'Liberation Mono', Courier, monospace;
line-height: 1;
- color: $gl-gray-200;
+ color: $gl-gray-100;
border-radius: 3px;
box-shadow: 0 1px 0 $perf-bar-bucket-box-shadow-from,
inset 0 1px 2px $perf-bar-bucket-box-shadow-to;
diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss
index d410a16a1d9..e5d5ed0d48f 100644
--- a/app/assets/stylesheets/snippets.scss
+++ b/app/assets/stylesheets/snippets.scss
@@ -1,8 +1,8 @@
-@import "framework/variables";
+@import 'framework/variables';
.gitlab-embed-snippets {
- @import "highlight/embedded";
- @import "framework/images";
+ @import 'highlight/embedded';
+ @import 'framework/images';
$border-style: 1px solid $border-color;
@@ -15,6 +15,7 @@
.gl-snippet-icon {
display: inline-block;
+ /* stylelint-disable-next-line function-url-quotes */
background: url(asset_path('ext_snippet_icons/ext_snippet_icons.png')) no-repeat;
overflow: hidden;
text-align: left;
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index 1f2a7645495..e2b4d6b8e7a 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -103,6 +103,8 @@ $input-focus-bg: $gray-100;
$input-color: $gray-900;
$input-group-addon-bg: $gray-900;
+$card-cap-bg: $gray-50;
+
$tooltip-bg: $gray-800;
$tooltip-color: $gray-10;
@@ -115,6 +117,14 @@ $secondary: $gray-600;
$issues-today-bg: #333838;
$issues-today-border: #333a40;
+$yiq-text-dark: $gray-50;
+$yiq-text-light: $gray-950;
+
+// Commit Diff Colors
+$line-added-dark: $green-200;
+$line-removed-dark: $red-200;
+
+// Misc component overrides that should live elsewhere
.gl-label {
filter: brightness(0.9) contrast(1.1);
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 176d64272c2..38842ec167e 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -43,6 +43,7 @@
@for $i from 1 through 12 {
#{'.tab-width-#{$i}'} {
+ /* stylelint-disable-next-line property-no-vendor-prefix */
-moz-tab-size: $i;
tab-size: $i;
}
@@ -100,3 +101,23 @@
.gl-pl-7 {
padding-left: $gl-spacing-scale-7;
}
+
+.gl-transition-property-stroke-opacity {
+ transition-property: stroke-opacity;
+}
+
+.gl-transition-property-stroke {
+ transition-property: stroke;
+}
+
+.gl-top-66vh {
+ top: 66vh;
+}
+
+// Remove when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/871
+// gets fixed on GitLab UI
+.gl-sm-w-auto\! {
+ @media (min-width: $breakpoint-sm) {
+ width: auto !important;
+ }
+}
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 94c82c25357..41a6616d10c 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -227,6 +227,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:raw_blob_request_limit,
:namespace_storage_size_limit,
:issues_create_limit,
+ :default_branch_name,
disabled_oauth_sign_in_sources: [],
import_sources: [],
repository_storages: [],
diff --git a/app/controllers/admin/clusters_controller.rb b/app/controllers/admin/clusters_controller.rb
index 5b1902fad51..9a642e53d86 100644
--- a/app/controllers/admin/clusters_controller.rb
+++ b/app/controllers/admin/clusters_controller.rb
@@ -10,6 +10,11 @@ class Admin::ClustersController < Clusters::ClustersController
def clusterable
@clusterable ||= InstanceClusterablePresenter.fabricate(Clusters::Instance.new, current_user: current_user)
end
-end
-Admin::ClustersController.prepend_if_ee('EE::Admin::ClustersController')
+ def metrics_dashboard_params
+ {
+ cluster: cluster,
+ cluster_type: :admin
+ }
+ end
+end
diff --git a/app/controllers/admin/jobs_controller.rb b/app/controllers/admin/jobs_controller.rb
index a3a18a115e9..7b50a45a9cd 100644
--- a/app/controllers/admin/jobs_controller.rb
+++ b/app/controllers/admin/jobs_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::JobsController < Admin::ApplicationController
+ BUILDS_PER_PAGE = 30
+
def index
# We need all builds for tabs counters
@all_builds = Ci::JobsFinder.new(current_user: current_user).execute
@@ -8,7 +10,7 @@ class Admin::JobsController < Admin::ApplicationController
@scope = params[:scope]
@builds = Ci::JobsFinder.new(current_user: current_user, params: params).execute
@builds = @builds.eager_load_everything
- @builds = @builds.page(params[:page]).per(30)
+ @builds = @builds.page(params[:page]).per(BUILDS_PER_PAGE).without_count
end
def cancel_all
diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb
index 08ef992e604..e0137accd2d 100644
--- a/app/controllers/admin/services_controller.rb
+++ b/app/controllers/admin/services_controller.rb
@@ -4,13 +4,18 @@ class Admin::ServicesController < Admin::ApplicationController
include ServiceParams
before_action :service, only: [:edit, :update]
+ before_action :whitelist_query_limiting, only: [:index]
+ before_action only: :edit do
+ push_frontend_feature_flag(:integration_form_refactor, default_enabled: true)
+ end
def index
@services = Service.find_or_create_templates.sort_by(&:title)
+ @existing_instance_types = Service.instances.pluck(:type) # rubocop: disable CodeReuse/ActiveRecord
end
def edit
- unless service.present?
+ if service.nil? || Service.instance_exists_for?(service.type)
redirect_to admin_application_settings_services_path,
alert: "Service is unknown or it doesn't exist"
end
@@ -34,4 +39,8 @@ class Admin::ServicesController < Admin::ApplicationController
@service ||= Service.find_by(id: params[:id], template: true)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/220357')
+ end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 79a164a5574..2595b646964 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -22,6 +22,7 @@ class ApplicationController < ActionController::Base
include Impersonation
include Gitlab::Logging::CloudflareHelper
include Gitlab::Utils::StrongMemoize
+ include ControllerWithFeatureCategory
before_action :authenticate_user!, except: [:route_not_found]
before_action :enforce_terms!, if: :should_enforce_terms?
@@ -305,7 +306,7 @@ class ApplicationController < ActionController::Base
return if session[:impersonator_id] || !current_user&.allow_password_authentication?
if current_user&.password_expired?
- return redirect_to new_profile_password_path
+ redirect_to new_profile_password_path
end
end
@@ -329,13 +330,6 @@ class ApplicationController < ActionController::Base
end
end
- def event_filter
- @event_filter ||=
- EventFilter.new(params[:event_filter].presence || cookies[:event_filter]).tap do |new_event_filter|
- cookies[:event_filter] = new_event_filter.filter
- end
- end
-
# JSON for infinite scroll via Pager object
def pager_json(partial, count, locals = {})
html = render_to_string(
@@ -370,7 +364,7 @@ class ApplicationController < ActionController::Base
def require_email
if current_user && current_user.temp_oauth_email? && session[:impersonator_id].nil?
- return redirect_to profile_path, notice: _('Please complete your profile with email address')
+ redirect_to profile_path, notice: _('Please complete your profile with email address')
end
end
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 0df201ab506..99fa17e202a 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -4,10 +4,6 @@ class AutocompleteController < ApplicationController
skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches]
def users
- project = Autocomplete::ProjectFinder
- .new(current_user, params)
- .execute
-
group = Autocomplete::GroupFinder
.new(current_user, project, params)
.execute
@@ -50,8 +46,20 @@ class AutocompleteController < ApplicationController
end
end
+ def deploy_keys_with_owners
+ deploy_keys = DeployKeys::CollectKeysService.new(project, current_user).execute
+
+ render json: DeployKeySerializer.new.represent(deploy_keys, { with_owner: true, user: current_user })
+ end
+
private
+ def project
+ @project ||= Autocomplete::ProjectFinder
+ .new(current_user, params)
+ .execute
+ end
+
def target_branch_params
params.permit(:group_id, :project_id).select { |_, v| v.present? }
end
diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb
index ac008165c16..e0d1f313fc7 100644
--- a/app/controllers/chaos_controller.rb
+++ b/app/controllers/chaos_controller.rb
@@ -45,7 +45,6 @@ class ChaosController < ActionController::Base
unless Devise.secure_compare(chaos_secret_configured, chaos_secret_request)
render plain: "To experience chaos, please set a valid `X-Chaos-Secret` header or `token` param",
status: :unauthorized
- return
end
end
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 46dec5f3287..2e8b3d764ca 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -2,6 +2,8 @@
class Clusters::ClustersController < Clusters::BaseController
include RoutableActions
+ include Metrics::Dashboard::PrometheusApiProxy
+ include MetricsDashboard
before_action :cluster, only: [:cluster_status, :show, :update, :destroy, :clear_cache]
before_action :generate_gcp_authorize_url, only: [:new]
@@ -290,6 +292,29 @@ class Clusters::ClustersController < Clusters::BaseController
@gcp_cluster = cluster.present(current_user: current_user)
end
+ def proxyable
+ cluster.cluster
+ end
+
+ # During first iteration of dashboard variables implementation
+ # cluster health case was omitted. Existing service for now is tied to
+ # environment, which is not always present for cluster health dashboard.
+ # It is planned to break coupling to environment https://gitlab.com/gitlab-org/gitlab/-/issues/213833.
+ # It is also planned to move cluster health to metrics dashboard section https://gitlab.com/gitlab-org/gitlab/-/issues/220214
+ # but for now I've used dummy class to stub variable substitution service, as there are no variables
+ # in cluster health dashboard
+ def proxy_variable_substitution_service
+ @empty_service ||= Class.new(BaseService) do
+ def initialize(proxyable, params)
+ @proxyable, @params = proxyable, params
+ end
+
+ def execute
+ success(params: @params)
+ end
+ end
+ end
+
def user_cluster
cluster = Clusters::BuildService.new(clusterable.subject).execute
cluster.build_platform_kubernetes
diff --git a/app/controllers/concerns/controller_with_feature_category.rb b/app/controllers/concerns/controller_with_feature_category.rb
new file mode 100644
index 00000000000..f8985cf0950
--- /dev/null
+++ b/app/controllers/concerns/controller_with_feature_category.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module ControllerWithFeatureCategory
+ extend ActiveSupport::Concern
+ include Gitlab::ClassAttributes
+
+ class_methods do
+ def feature_category(category, config = {})
+ validate_config!(config)
+
+ category_config = Config.new(category, config[:only], config[:except], config[:if], config[:unless])
+ # Add the config to the beginning. That way, the last defined one takes precedence.
+ feature_category_configuration.unshift(category_config)
+ end
+
+ def feature_category_for_action(action)
+ category_config = feature_category_configuration.find { |config| config.matches?(action) }
+
+ category_config&.category || superclass_feature_category_for_action(action)
+ end
+
+ private
+
+ def validate_config!(config)
+ invalid_keys = config.keys - [:only, :except, :if, :unless]
+ if invalid_keys.any?
+ raise ArgumentError, "unknown arguments: #{invalid_keys} "
+ end
+
+ if config.key?(:only) && config.key?(:except)
+ raise ArgumentError, "cannot configure both `only` and `except`"
+ end
+ end
+
+ def feature_category_configuration
+ class_attributes[:feature_category_config] ||= []
+ end
+
+ def superclass_feature_category_for_action(action)
+ return unless superclass.respond_to?(:feature_category_for_action)
+
+ superclass.feature_category_for_action(action)
+ end
+ end
+end
diff --git a/app/controllers/concerns/controller_with_feature_category/config.rb b/app/controllers/concerns/controller_with_feature_category/config.rb
new file mode 100644
index 00000000000..624691ee4f6
--- /dev/null
+++ b/app/controllers/concerns/controller_with_feature_category/config.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module ControllerWithFeatureCategory
+ class Config
+ attr_reader :category
+
+ def initialize(category, only, except, if_proc, unless_proc)
+ @category = category.to_sym
+ @only, @except = only&.map(&:to_s), except&.map(&:to_s)
+ @if_proc, @unless_proc = if_proc, unless_proc
+ end
+
+ def matches?(action)
+ included?(action) && !excluded?(action) &&
+ if_proc?(action) && !unless_proc?(action)
+ end
+
+ private
+
+ attr_reader :only, :except, :if_proc, :unless_proc
+
+ def if_proc?(action)
+ if_proc.nil? || if_proc.call(action)
+ end
+
+ def unless_proc?(action)
+ unless_proc.present? && unless_proc.call(action)
+ end
+
+ def included?(action)
+ only.nil? || only.include?(action)
+ end
+
+ def excluded?(action)
+ except.present? && except.include?(action)
+ end
+ end
+end
diff --git a/app/controllers/concerns/filters_events.rb b/app/controllers/concerns/filters_events.rb
new file mode 100644
index 00000000000..c82d0318fd3
--- /dev/null
+++ b/app/controllers/concerns/filters_events.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module FiltersEvents
+ def event_filter
+ @event_filter ||= new_event_filter.tap { |ef| cookies[:event_filter] = ef.filter }
+ end
+
+ private
+
+ def new_event_filter
+ active_filter = params[:event_filter].presence || cookies[:event_filter]
+ EventFilter.new(active_filter)
+ end
+end
diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb
index cc9db7936e8..46febc44807 100644
--- a/app/controllers/concerns/integrations_actions.rb
+++ b/app/controllers/concerns/integrations_actions.rb
@@ -8,6 +8,9 @@ module IntegrationsActions
before_action :not_found, unless: :integrations_enabled?
before_action :integration, only: [:edit, :update, :test]
+ before_action only: :edit do
+ push_frontend_feature_flag(:integration_form_refactor, default_enabled: true)
+ end
end
def edit
@@ -51,9 +54,8 @@ module IntegrationsActions
end
def integration
- # Using instance variable `@service` still required as it's used in ServiceParams
- # and app/views/shared/_service_settings.html.haml. Should be removed once
- # those 2 are refactored to use `@integration`.
+ # Using instance variable `@service` still required as it's used in ServiceParams.
+ # Should be removed once that is refactored to use `@integration`.
@integration = @service ||= find_or_initialize_integration(params[:id]) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 98fa8202e25..c4dbce00593 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -110,9 +110,13 @@ module IssuableActions
def bulk_update
result = Issuable::BulkUpdateService.new(parent, current_user, bulk_update_params).execute(resource_name)
- quantity = result[:count]
- render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
+ if result.success?
+ quantity = result.payload[:count]
+ render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
+ elsif result.error?
+ render json: { errors: result.message }, status: result.http_status
+ end
end
# rubocop:disable CodeReuse/ActiveRecord
@@ -193,13 +197,13 @@ module IssuableActions
def authorize_destroy_issuable!
unless can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable)
- return access_denied!
+ access_denied!
end
end
def authorize_admin_issuable!
unless can?(current_user, :"admin_#{resource_name}", parent)
- return access_denied!
+ access_denied!
end
end
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 9ef067e8797..4f61e5ed711 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -81,34 +81,36 @@ module IssuableCollections
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def finder_options
- params[:state] = default_state if params[:state].blank?
-
- options = {
- scope: params[:scope],
- state: params[:state],
- confidential: Gitlab::Utils.to_boolean(params[:confidential]),
- sort: set_sort_order
- }
-
- # Used by view to highlight active option
- @sort = options[:sort]
-
- # When a user looks for an exact iid, we do not filter by search but only by iid
- if params[:search] =~ /^#(?<iid>\d+)\z/
- options[:iids] = Regexp.last_match[:iid]
- params[:search] = nil
+ strong_memoize(:finder_options) do
+ params[:state] = default_state if params[:state].blank?
+
+ options = {
+ scope: params[:scope],
+ state: params[:state],
+ confidential: Gitlab::Utils.to_boolean(params[:confidential]),
+ sort: set_sort_order
+ }
+
+ # Used by view to highlight active option
+ @sort = options[:sort]
+
+ # When a user looks for an exact iid, we do not filter by search but only by iid
+ if params[:search] =~ /^#(?<iid>\d+)\z/
+ options[:iids] = Regexp.last_match[:iid]
+ params[:search] = nil
+ end
+
+ if @project
+ options[:project_id] = @project.id
+ options[:attempt_project_search_optimizations] = true
+ elsif @group
+ options[:group_id] = @group.id
+ options[:include_subgroups] = true
+ options[:attempt_group_search_optimizations] = true
+ end
+
+ params.permit(finder_type.valid_params).merge(options)
end
-
- if @project
- options[:project_id] = @project.id
- options[:attempt_project_search_optimizations] = true
- elsif @group
- options[:group_id] = @group.id
- options[:include_subgroups] = true
- options[:attempt_group_search_optimizations] = true
- end
-
- params.permit(finder_type.valid_params).merge(options)
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
@@ -147,7 +149,10 @@ module IssuableCollections
when 'Issue'
common_attributes + [:project, project: :namespace]
when 'MergeRequest'
- common_attributes + [:target_project, :latest_merge_request_diff, source_project: :route, head_pipeline: :project, target_project: :namespace]
+ common_attributes + [
+ :target_project, :latest_merge_request_diff, :approvals, :approved_by_users,
+ source_project: :route, head_pipeline: :project, target_project: :namespace
+ ]
end
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
diff --git a/app/controllers/concerns/known_sign_in.rb b/app/controllers/concerns/known_sign_in.rb
index c0b9605de58..cacc7e4628f 100644
--- a/app/controllers/concerns/known_sign_in.rb
+++ b/app/controllers/concerns/known_sign_in.rb
@@ -2,19 +2,34 @@
module KnownSignIn
include Gitlab::Utils::StrongMemoize
+ include CookiesHelper
+
+ KNOWN_SIGN_IN_COOKIE = :known_sign_in
+ KNOWN_SIGN_IN_COOKIE_EXPIRY = 14.days
private
def verify_known_sign_in
- return unless current_user
+ return unless Gitlab::CurrentSettings.notify_on_unknown_sign_in? && current_user
+
+ notify_user unless known_device? || known_remote_ip?
- notify_user unless known_remote_ip?
+ update_cookie
end
def known_remote_ip?
known_ip_addresses.include?(request.remote_ip)
end
+ def known_device?
+ cookies.encrypted[KNOWN_SIGN_IN_COOKIE] == current_user.id
+ end
+
+ def update_cookie
+ set_secure_cookie(KNOWN_SIGN_IN_COOKIE, current_user.id,
+ type: COOKIE_TYPE_ENCRYPTED, httponly: true, expires: KNOWN_SIGN_IN_COOKIE_EXPIRY)
+ end
+
def sessions
strong_memoize(:session) do
ActiveSession.list(current_user).reject(&:is_impersonated)
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 4ab02005b45..8c7f156f7f8 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -31,7 +31,10 @@ module MembershipActions
def destroy
member = membershipable.members_and_requesters.find(params[:id])
- Members::DestroyService.new(current_user).execute(member)
+ # !! is used in case unassign_issuables contains empty string which would result in nil
+ unassign_issuables = !!ActiveRecord::Type::Boolean.new.cast(params.delete(:unassign_issuables))
+
+ Members::DestroyService.new(current_user).execute(member, unassign_issuables: unassign_issuables)
respond_to do |format|
format.html do
diff --git a/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb b/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb
new file mode 100644
index 00000000000..e0e3f628cc5
--- /dev/null
+++ b/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Metrics::Dashboard::PrometheusApiProxy
+ extend ActiveSupport::Concern
+ include RenderServiceResults
+
+ included do
+ before_action :authorize_read_prometheus!, only: [:prometheus_proxy]
+ end
+
+ def prometheus_proxy
+ variable_substitution_result =
+ proxy_variable_substitution_service.new(proxyable, permit_params).execute
+
+ if variable_substitution_result[:status] == :error
+ return error_response(variable_substitution_result)
+ end
+
+ prometheus_result = Prometheus::ProxyService.new(
+ proxyable,
+ proxy_method,
+ proxy_path,
+ variable_substitution_result[:params]
+ ).execute
+
+ return continue_polling_response if prometheus_result.nil?
+ return error_response(prometheus_result) if prometheus_result[:status] == :error
+
+ success_response(prometheus_result)
+ end
+
+ private
+
+ def proxyable
+ raise NotImplementedError, "#{self.class} must implement method: #{__callee__}"
+ end
+
+ def proxy_variable_substitution_service
+ raise NotImplementedError, "#{self.class} must implement method: #{__callee__}"
+ end
+
+ def permit_params
+ params.permit!
+ end
+
+ def proxy_method
+ request.method
+ end
+
+ def proxy_path
+ params[:proxy_path]
+ end
+end
diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb
index 1aea0e294a5..28d0692d748 100644
--- a/app/controllers/concerns/metrics_dashboard.rb
+++ b/app/controllers/concerns/metrics_dashboard.rb
@@ -13,7 +13,7 @@ module MetricsDashboard
result = dashboard_finder.find(
project_for_dashboard,
current_user,
- metrics_dashboard_params.to_h.symbolize_keys
+ decoded_params
)
if result
@@ -41,7 +41,7 @@ module MetricsDashboard
end
def amend_dashboard(dashboard)
- project_dashboard = project_for_dashboard && !dashboard[:system_dashboard]
+ project_dashboard = project_for_dashboard && !dashboard[:out_of_the_box_dashboard]
dashboard[:can_edit] = project_dashboard ? can_edit?(dashboard) : false
dashboard[:project_blob_path] = project_dashboard ? dashboard_project_blob_path(dashboard) : nil
@@ -114,4 +114,14 @@ module MetricsDashboard
json: result.slice(:all_dashboards, :message, :status)
}
end
+
+ def decoded_params
+ params = metrics_dashboard_params
+
+ if params[:dashboard_path]
+ params[:dashboard_path] = CGI.unescape(params[:dashboard_path])
+ end
+
+ params
+ end
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index d3dfb1813e4..f4fc7decb60 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -5,6 +5,11 @@ module NotesActions
include Gitlab::Utils::StrongMemoize
extend ActiveSupport::Concern
+ # last_fetched_at is an integer number of microseconds, which is the same
+ # precision as PostgreSQL "timestamp" fields. It's important for them to have
+ # identical precision for accurate pagination
+ MICROSECOND = 1_000_000
+
included do
before_action :set_polling_interval_header, only: [:index]
before_action :require_noteable!, only: [:index, :create]
@@ -13,30 +18,20 @@ module NotesActions
end
def index
- notes_json = { notes: [], last_fetched_at: Time.current.to_i }
-
- notes = notes_finder
- .execute
- .inc_relations_for_view
-
- if notes_filter != UserPreference::NOTES_FILTERS[:only_comments]
- notes =
- ResourceEvents::MergeIntoNotesService
- .new(noteable, current_user, last_fetched_at: last_fetched_at)
- .execute(notes)
- end
-
+ notes, meta = gather_notes
notes = prepare_notes_for_rendering(notes)
notes = notes.select { |n| n.readable_by?(current_user) }
-
- notes_json[:notes] =
+ notes =
if use_note_serializer?
note_serializer.represent(notes)
else
notes.map { |note| note_json(note) }
end
- render json: notes_json
+ # We know there's more data, so tell the frontend to poll again after 1ms
+ set_polling_interval_header(interval: 1) if meta[:more]
+
+ render json: meta.merge(notes: notes)
end
# rubocop:disable Gitlab/ModuleWithInstanceVariables
@@ -101,6 +96,48 @@ module NotesActions
private
+ # Lower bound (last_fetched_at as specified in the request) is already set in
+ # the finder. Here, we select between returning all notes since then, or a
+ # page's worth of notes.
+ def gather_notes
+ if Feature.enabled?(:paginated_notes, project)
+ gather_some_notes
+ else
+ gather_all_notes
+ end
+ end
+
+ def gather_all_notes
+ now = Time.current
+ notes = merge_resource_events(notes_finder.execute.inc_relations_for_view)
+
+ [notes, { last_fetched_at: (now.to_i * MICROSECOND) + now.usec }]
+ end
+
+ def gather_some_notes
+ paginator = Gitlab::UpdatedNotesPaginator.new(
+ notes_finder.execute.inc_relations_for_view,
+ last_fetched_at: last_fetched_at
+ )
+
+ notes = paginator.notes
+
+ # Fetch all the synthetic notes in the same time range as the real notes.
+ # Although we don't limit the number, their text is under our control so
+ # should be fairly cheap to process.
+ notes = merge_resource_events(notes, fetch_until: paginator.next_fetched_at)
+
+ [notes, paginator.metadata]
+ end
+
+ def merge_resource_events(notes, fetch_until: nil)
+ return notes if notes_filter == UserPreference::NOTES_FILTERS[:only_comments]
+
+ ResourceEvents::MergeIntoNotesService
+ .new(noteable, current_user, last_fetched_at: last_fetched_at, fetch_until: fetch_until)
+ .execute(notes)
+ end
+
def note_html(note)
render_to_string(
"shared/notes/_note",
@@ -226,11 +263,11 @@ module NotesActions
end
def update_note_params
- params.require(:note).permit(:note)
+ params.require(:note).permit(:note, :position)
end
- def set_polling_interval_header
- Gitlab::PollingInterval.set_header(response, interval: 6_000)
+ def set_polling_interval_header(interval: 6000)
+ Gitlab::PollingInterval.set_header(response, interval: interval)
end
def noteable
@@ -242,7 +279,14 @@ module NotesActions
end
def last_fetched_at
- request.headers['X-Last-Fetched-At']
+ strong_memoize(:last_fetched_at) do
+ microseconds = request.headers['X-Last-Fetched-At'].to_i
+
+ seconds = microseconds / MICROSECOND
+ frac = microseconds % MICROSECOND
+
+ Time.zone.at(seconds, frac)
+ end
end
def notes_filter
diff --git a/app/controllers/concerns/renders_member_access.rb b/app/controllers/concerns/renders_member_access.rb
index 955ac1a1bc8..745830181c1 100644
--- a/app/controllers/concerns/renders_member_access.rb
+++ b/app/controllers/concerns/renders_member_access.rb
@@ -7,12 +7,6 @@ module RendersMemberAccess
groups
end
- def prepare_projects_for_rendering(projects)
- preload_max_member_access_for_collection(Project, projects)
-
- projects
- end
-
private
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/concerns/renders_projects_list.rb b/app/controllers/concerns/renders_projects_list.rb
new file mode 100644
index 00000000000..be45c676ad6
--- /dev/null
+++ b/app/controllers/concerns/renders_projects_list.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module RendersProjectsList
+ def prepare_projects_for_rendering(projects)
+ preload_max_member_access_for_collection(Project, projects)
+
+ # Call the forks count method on every project, so the BatchLoader would load them all at
+ # once when the entities are rendered
+ projects.each(&:forks_count)
+
+ projects
+ end
+end
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index e78fa8f8250..a19c43a227a 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -22,8 +22,8 @@ module ServiceParams
:comment_on_event_enabled,
:comment_detail,
:confidential_issues_events,
+ :confluence_url,
:default_irc_uri,
- :description,
:device,
:disable_diffs,
:drone_url,
@@ -31,6 +31,7 @@ module ServiceParams
:external_wiki_url,
:google_iap_service_account_json,
:google_iap_audience_client_id,
+ :inherit_from_id,
# We're using `issues_events` and `merge_requests_events`
# in the view so we still need to explicitly state them
# here. `Service#event_names` would only give
@@ -61,7 +62,6 @@ module ServiceParams
:sound,
:subdomain,
:teamcity_url,
- :title,
:token,
:type,
:url,
diff --git a/app/controllers/concerns/snippets/blobs_actions.rb b/app/controllers/concerns/snippets/blobs_actions.rb
new file mode 100644
index 00000000000..db56ce8f193
--- /dev/null
+++ b/app/controllers/concerns/snippets/blobs_actions.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Snippets::BlobsActions
+ extend ActiveSupport::Concern
+
+ include Gitlab::Utils::StrongMemoize
+ include ExtractsRef
+ include Snippets::SendBlob
+
+ included do
+ before_action :authorize_read_snippet!, only: [:raw]
+ before_action :ensure_repository
+ before_action :ensure_blob
+ end
+
+ def raw
+ send_snippet_blob(snippet, blob)
+ end
+
+ private
+
+ def repository_container
+ snippet
+ end
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def blob
+ strong_memoize(:blob) do
+ assign_ref_vars
+
+ next unless @commit
+
+ @repo.blob_at(@commit.id, @path)
+ end
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ def ensure_blob
+ render_404 unless blob
+ end
+
+ def ensure_repository
+ unless snippet.repo_exists?
+ Gitlab::AppLogger.error(message: "Snippet raw blob attempt with no repo", snippet: snippet.id)
+
+ respond_422
+ end
+ end
+
+ def snippet_id
+ params[:snippet_id]
+ end
+end
diff --git a/app/controllers/concerns/snippets/send_blob.rb b/app/controllers/concerns/snippets/send_blob.rb
new file mode 100644
index 00000000000..4f432430aaa
--- /dev/null
+++ b/app/controllers/concerns/snippets/send_blob.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Snippets::SendBlob
+ include SendsBlob
+
+ def send_snippet_blob(snippet, blob)
+ workhorse_set_content_type!
+
+ send_blob(
+ snippet.repository,
+ blob,
+ inline: content_disposition == 'inline',
+ allow_caching: snippet.public?
+ )
+ end
+
+ private
+
+ def content_disposition
+ @disposition ||= params[:inline] == 'false' ? 'attachment' : 'inline'
+ end
+end
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index 51fc12398d9..048b18c5c61 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -2,11 +2,13 @@
module SnippetsActions
extend ActiveSupport::Concern
- include SendsBlob
+
include RendersNotes
include RendersBlob
include PaginatedCollection
include Gitlab::NoteableMetadata
+ include Snippets::SendBlob
+ include SnippetsSort
included do
skip_before_action :verify_authenticity_token,
@@ -25,6 +27,10 @@ module SnippetsActions
render 'edit'
end
+ # This endpoint is being replaced by Snippets::BlobController#raw
+ # Support for old raw links will be maintainted via this action but
+ # it will only return the first blob found,
+ # see: https://gitlab.com/gitlab-org/gitlab/-/issues/217775
def raw
workhorse_set_content_type!
@@ -39,12 +45,7 @@ module SnippetsActions
filename: Snippet.sanitized_file_name(blob.name)
)
else
- send_blob(
- snippet.repository,
- blob,
- inline: content_disposition == 'inline',
- allow_caching: snippet.public?
- )
+ send_snippet_blob(snippet, blob)
end
end
@@ -106,10 +107,6 @@ module SnippetsActions
private
- def content_disposition
- @disposition ||= params[:inline] == 'false' ? 'attachment' : 'inline'
- end
-
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def blob
return unless snippet
diff --git a/app/controllers/concerns/snippets_sort.rb b/app/controllers/concerns/snippets_sort.rb
new file mode 100644
index 00000000000..f122c843af7
--- /dev/null
+++ b/app/controllers/concerns/snippets_sort.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module SnippetsSort
+ extend ActiveSupport::Concern
+
+ def sort_param
+ params[:sort].presence || 'updated_desc'
+ end
+end
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index 7eef12fadfe..a5182000f5b 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module WikiActions
+ include DiffHelper
+ include PreviewMarkdown
include SendsBlob
include Gitlab::Utils::StrongMemoize
extend ActiveSupport::Concern
@@ -11,16 +13,23 @@ module WikiActions
before_action :authorize_admin_wiki!, only: :destroy
before_action :wiki
- before_action :page, only: [:show, :edit, :update, :history, :destroy]
+ before_action :page, only: [:show, :edit, :update, :history, :destroy, :diff]
before_action :load_sidebar, except: [:pages]
+ before_action :set_content_class
before_action only: [:show, :edit, :update] do
@valid_encoding = valid_encoding?
end
before_action only: [:edit, :update], unless: :valid_encoding? do
- redirect_to wiki_page_path(wiki, page)
+ if params[:id].present?
+ redirect_to wiki_page_path(wiki, page || params[:id])
+ else
+ redirect_to wiki_path(wiki)
+ end
end
+
+ helper_method :view_file_button, :diff_file_html_data
end
def new
@@ -133,6 +142,19 @@ module WikiActions
# rubocop:enable Gitlab/ModuleWithInstanceVariables
# rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def diff
+ return render_404 unless page
+
+ apply_diff_view_cookie!
+
+ @diffs = page.diffs(diff_options)
+ @diff_notes_disabled = true
+
+ render 'shared/wikis/diff'
+ end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def destroy
WikiPages::DestroyService.new(container: container, current_user: current_user).execute(page)
@@ -203,7 +225,7 @@ module WikiActions
def page_params
keys = [:id]
- keys << :version_id if params[:action] == 'show'
+ keys << :version_id if %w[show diff].include?(params[:action])
params.values_at(*keys)
end
@@ -229,4 +251,25 @@ module WikiActions
wiki.repository.blob_at(commit.id, params[:id])
end
end
+
+ def set_content_class
+ @content_class = 'limit-container-width' unless fluid_layout # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ # Override CommitsHelper#view_file_button
+ def view_file_button(commit_sha, *args)
+ path = wiki_page_path(wiki, page, version_id: page.version.id)
+
+ helpers.link_to(path, class: 'btn') do
+ helpers.raw(_('View page @ ')) + helpers.content_tag(:span, Commit.truncate_sha(commit_sha), class: 'commit-sha')
+ end
+ end
+
+ # Override DiffHelper#diff_file_html_data
+ def diff_file_html_data(_project, _diff_file_path, diff_commit_id)
+ {
+ blob_diff_path: wiki_page_path(wiki, page, action: :diff, version_id: diff_commit_id),
+ view: diff_view
+ }
+ end
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 25c48fadf49..ad64b6c4f94 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -3,9 +3,10 @@
class Dashboard::ProjectsController < Dashboard::ApplicationController
include ParamsBackwardCompatibility
include RendersMemberAccess
- include OnboardingExperimentHelper
+ include RendersProjectsList
include SortingHelper
include SortingPreference
+ include FiltersEvents
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
before_action :set_non_archived_param
diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb
index aa09fcdbe61..a8ca3dbd0e7 100644
--- a/app/controllers/dashboard/snippets_controller.rb
+++ b/app/controllers/dashboard/snippets_controller.rb
@@ -3,6 +3,7 @@
class Dashboard::SnippetsController < Dashboard::ApplicationController
include PaginatedCollection
include Gitlab::NoteableMetadata
+ include SnippetsSort
skip_cross_project_access_check :index
@@ -11,7 +12,7 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController
.new(current_user, author: current_user)
.execute
- @snippets = SnippetsFinder.new(current_user, author: current_user, scope: params[:scope])
+ @snippets = SnippetsFinder.new(current_user, author: current_user, scope: params[:scope], sort: sort_param)
.execute
.page(params[:page])
.inc_author
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 8a8064b24c2..db40b0bed77 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -3,11 +3,14 @@
class Dashboard::TodosController < Dashboard::ApplicationController
include ActionView::Helpers::NumberHelper
include PaginatedCollection
+ include Analytics::UniqueVisitsHelper
before_action :authorize_read_project!, only: :index
before_action :authorize_read_group!, only: :index
before_action :find_todos, only: [:index, :destroy_all]
+ track_unique_visits :index, target_id: 'u_analytics_todos'
+
def index
@sort = params[:sort]
@todos = @todos.page(params[:page])
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index dd9e6488bc5..07cc31fb7d3 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -2,6 +2,7 @@
class DashboardController < Dashboard::ApplicationController
include IssuableCollectionsAction
+ include FiltersEvents
prepend_before_action(only: [:issues]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) }
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index 705a586d614..f1f41e67a4c 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -4,6 +4,7 @@ class Explore::ProjectsController < Explore::ApplicationController
include PageLimiter
include ParamsBackwardCompatibility
include RendersMemberAccess
+ include RendersProjectsList
include SortingHelper
include SortingPreference
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index 84c8d7ada43..9c2e361e92f 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -30,25 +30,25 @@ class Groups::ApplicationController < ApplicationController
def authorize_admin_group!
unless can?(current_user, :admin_group, group)
- return render_404
+ render_404
end
end
def authorize_create_deploy_token!
unless can?(current_user, :create_deploy_token, group)
- return render_404
+ render_404
end
end
def authorize_destroy_deploy_token!
unless can?(current_user, :destroy_deploy_token, group)
- return render_404
+ render_404
end
end
def authorize_admin_group_member!
unless can?(current_user, :admin_group_member, group)
- return render_403
+ render_403
end
end
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index c618ee8566a..23d4f0d24e9 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -8,7 +8,6 @@ class Groups::BoardsController < Groups::ApplicationController
before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:multi_select_board, default_enabled: true)
- push_frontend_feature_flag(:sfc_issue_boards, default_enabled: true)
push_frontend_feature_flag(:boards_with_swimlanes, group, default_enabled: false)
end
diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb
index 2165dee45fb..33bfc24885f 100644
--- a/app/controllers/groups/clusters_controller.rb
+++ b/app/controllers/groups/clusters_controller.rb
@@ -17,6 +17,12 @@ class Groups::ClustersController < Clusters::ClustersController
def group
@group ||= find_routable!(Group, params[:group_id] || params[:id])
end
-end
-Groups::ClustersController.prepend_if_ee('EE::Groups::ClustersController')
+ def metrics_dashboard_params
+ {
+ cluster: cluster,
+ cluster_type: :group,
+ group: group
+ }
+ end
+end
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index 635c248024e..edebffe2912 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -23,9 +23,13 @@ class Groups::RunnersController < Groups::ApplicationController
end
def destroy
- @runner.destroy
+ if @runner.belongs_to_more_than_one_project?
+ redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found, alert: _('Runner was not deleted because it is assigned to multiple projects.')
+ else
+ @runner.destroy
- redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found
+ redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found
+ end
end
def resume
@@ -47,7 +51,9 @@ class Groups::RunnersController < Groups::ApplicationController
private
def runner
- @runner ||= @group.runners.find(params[:id])
+ @runner ||= Ci::RunnersFinder.new(current_user: current_user, group: @group, params: {}).execute
+ .except(:limit, :offset)
+ .find(params[:id])
end
def runner_params
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 18f336eae78..bf3a38ce57b 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -11,7 +11,15 @@ module Groups
end
before_action :define_variables, only: [:show]
+ NUMBER_OF_RUNNERS_PER_PAGE = 4
+
def show
+ runners_finder = Ci::RunnersFinder.new(current_user: current_user, group: @group, params: params)
+ # We need all runners for count
+ @all_group_runners = runners_finder.execute.except(:limit, :offset)
+ @group_runners = runners_finder.execute.page(params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
+
+ @sort = runners_finder.sort_key
end
def update
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index 11e3cfb01e4..02b015e8e53 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -9,7 +9,7 @@ module Groups
def show
respond_to do |format|
format.json do
- render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) }
+ render status: :ok, json: { variables: ::Ci::GroupVariableSerializer.new.represent(@group.variables) }
end
end
end
@@ -29,7 +29,7 @@ module Groups
private
def render_group_variables
- render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) }
+ render status: :ok, json: { variables: ::Ci::GroupVariableSerializer.new.represent(@group.variables) }
end
def render_error
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index fba374dbb44..2162d397da3 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -7,6 +7,7 @@ class GroupsController < Groups::ApplicationController
include PreviewMarkdown
include RecordUserLastActivity
include SendFileUpload
+ include FiltersEvents
extend ::Gitlab::Utils::Override
respond_to :html
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index 2bf7bdd1ae0..2c17f5b5542 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -8,6 +8,7 @@ class IdeController < ApplicationController
before_action do
push_frontend_feature_flag(:build_service_proxy)
+ push_frontend_feature_flag(:schema_linting)
end
def index
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index afdea4f7c9d..bc05030f8af 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -30,7 +30,7 @@ class Import::BaseController < ApplicationController
end
def incompatible_repos
- []
+ raise NotImplementedError
end
def provider_name
@@ -87,15 +87,6 @@ class Import::BaseController < ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
- def find_jobs(import_type)
- current_user.created_projects
- .with_import_state
- .where(import_type: import_type)
- .to_json(only: [:id], methods: [:import_status])
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
# deprecated: being replaced by app/services/import/base_service.rb
def find_or_create_namespace(names, owner)
names = params[:target_namespace].presence || names
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 4886aeb5e3f..0ffd9ef8bdd 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -22,23 +22,8 @@ class Import::BitbucketController < Import::BaseController
redirect_to status_import_bitbucket_url
end
- # rubocop: disable CodeReuse/ActiveRecord
def status
- return super if Feature.enabled?(:new_import_ui)
-
- bitbucket_client = Bitbucket::Client.new(credentials)
- repos = bitbucket_client.repos(filter: sanitized_filter_param)
- @repos, @incompatible_repos = repos.partition { |repo| repo.valid? }
-
- @already_added_projects = find_already_added_projects('bitbucket')
- already_added_projects_names = @already_added_projects.pluck(:import_source)
-
- @repos.to_a.reject! { |repo| already_added_projects_names.include?(repo.full_name) }
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def jobs
- render json: find_jobs('bitbucket')
+ super
end
def realtime_changes
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
index 9aa8110257d..bee78cb3283 100644
--- a/app/controllers/import/bitbucket_server_controller.rb
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -7,6 +7,7 @@ class Import::BitbucketServerController < Import::BaseController
before_action :verify_bitbucket_server_import_enabled
before_action :bitbucket_auth, except: [:new, :configure]
+ before_action :normalize_import_params, only: [:create]
before_action :validate_import_params, only: [:create]
rescue_from BitbucketServer::Connection::ConnectionError, with: :bitbucket_connection_error
@@ -34,48 +35,25 @@ class Import::BitbucketServerController < Import::BaseController
return render json: { errors: _("Project %{project_repo} could not be found") % { project_repo: "#{@project_key}/#{@repo_slug}" } }, status: :unprocessable_entity
end
- project_name = params[:new_name].presence || repo.name
- namespace_path = params[:new_namespace].presence || current_user.username
- target_namespace = find_or_create_namespace(namespace_path, current_user)
+ result = Import::BitbucketServerService.new(client, current_user, params).execute(credentials)
- if current_user.can?(:create_projects, target_namespace)
- project = Gitlab::BitbucketServerImport::ProjectCreator.new(@project_key, @repo_slug, repo, project_name, target_namespace, current_user, credentials).execute
-
- if project.persisted?
- render json: ProjectSerializer.new.represent(project, serializer: :import)
- else
- render json: { errors: project_save_error(project) }, status: :unprocessable_entity
- end
+ if result[:status] == :success
+ render json: ProjectSerializer.new.represent(result[:project], serializer: :import)
else
- render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity
+ render json: { errors: result[:message] }, status: result[:http_status]
end
end
def configure
session[personal_access_token_key] = params[:personal_access_token]
- session[bitbucket_server_username_key] = params[:bitbucket_username]
+ session[bitbucket_server_username_key] = params[:bitbucket_server_username]
session[bitbucket_server_url_key] = params[:bitbucket_server_url]
redirect_to status_import_bitbucket_server_path
end
- # rubocop: disable CodeReuse/ActiveRecord
def status
- return super if Feature.enabled?(:new_import_ui)
-
- @collection = client.repos(page_offset: page_offset, limit: limit_per_page, filter: sanitized_filter_param)
- @repos, @incompatible_repos = @collection.partition { |repo| repo.valid? }
-
- # Use the import URL to filter beyond what BaseService#find_already_added_projects
- @already_added_projects = filter_added_projects('bitbucket_server', @repos.map(&:browse_url))
- already_added_projects_names = @already_added_projects.pluck(:import_source)
-
- @repos.reject! { |repo| already_added_projects_names.include?(repo.browse_url) }
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def jobs
- render json: find_jobs('bitbucket_server')
+ super
end
def realtime_changes
@@ -126,9 +104,15 @@ class Import::BitbucketServerController < Import::BaseController
@bitbucket_repos ||= client.repos(page_offset: page_offset, limit: limit_per_page, filter: sanitized_filter_param).to_a
end
+ def normalize_import_params
+ project_key, repo_slug = params[:repo_id].split('/')
+ params[:bitbucket_server_project] = project_key
+ params[:bitbucket_server_repo] = repo_slug
+ end
+
def validate_import_params
- @project_key = params[:project]
- @repo_slug = params[:repository]
+ @project_key = params[:bitbucket_server_project]
+ @repo_slug = params[:bitbucket_server_repo]
return render_validation_error('Missing project key') unless @project_key.present? && @repo_slug.present?
return render_validation_error('Missing repository slug') unless @repo_slug.present?
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index 91779a5d6cc..a34bc9c953f 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -50,14 +50,7 @@ class Import::FogbugzController < Import::BaseController
return redirect_to new_import_fogbugz_path
end
- return super if Feature.enabled?(:new_import_ui)
-
- @repos = client.repos
-
- @already_added_projects = find_already_added_projects('fogbugz')
- already_added_projects_names = @already_added_projects.pluck(:import_source)
-
- @repos.reject! { |repo| already_added_projects_names.include? repo.name }
+ super
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -65,10 +58,6 @@ class Import::FogbugzController < Import::BaseController
super
end
- def jobs
- render json: find_jobs('fogbugz')
- end
-
def create
repo = client.repo(params[:repo_id])
fb_session = { uri: session[:fogbugz_uri], token: session[:fogbugz_token] }
@@ -96,6 +85,11 @@ class Import::FogbugzController < Import::BaseController
end
# rubocop: enable CodeReuse/ActiveRecord
+ override :incompatible_repos
+ def incompatible_repos
+ []
+ end
+
override :provider_name
def provider_name
:fogbugz
diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb
index 42c23fb29a7..efeff8439e4 100644
--- a/app/controllers/import/gitea_controller.rb
+++ b/app/controllers/import/gitea_controller.rb
@@ -21,15 +21,17 @@ class Import::GiteaController < Import::GithubController
super
end
- private
+ protected
- def host_key
- :"#{provider}_host_url"
+ override :provider_name
+ def provider_name
+ :gitea
end
- override :provider
- def provider
- :gitea
+ private
+
+ def host_key
+ :"#{provider_name}_host_url"
end
override :provider_url
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 097edcd6075..ac6b8c06d66 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Import::GithubController < Import::BaseController
+ extend ::Gitlab::Utils::Override
+
include ImportHelper
include ActionView::Helpers::SanitizeHelper
@@ -34,18 +36,11 @@ class Import::GithubController < Import::BaseController
# Improving in https://gitlab.com/gitlab-org/gitlab-foss/issues/55585
client_repos
- respond_to do |format|
- format.json do
- render json: { imported_projects: serialized_imported_projects,
- provider_repos: serialized_provider_repos,
- namespaces: serialized_namespaces }
- end
- format.html
- end
+ super
end
def create
- result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider)
+ result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider_name)
if result[:status] == :success
render json: serialized_imported_projects(result[:project])
@@ -55,44 +50,51 @@ class Import::GithubController < Import::BaseController
end
def realtime_changes
- Gitlab::PollingInterval.set_header(response, interval: 3_000)
-
- render json: already_added_projects.to_json(only: [:id], methods: [:import_status])
+ super
end
- private
+ protected
- def import_params
- params.permit(permitted_import_params)
- end
+ # rubocop: disable CodeReuse/ActiveRecord
+ override :importable_repos
+ def importable_repos
+ already_added_projects_names = already_added_projects.pluck(:import_source)
- def permitted_import_params
- [:repo_id, :new_name, :target_namespace]
+ client_repos.reject { |repo| already_added_projects_names.include?(repo.full_name) }
end
+ # rubocop: enable CodeReuse/ActiveRecord
- def serialized_imported_projects(projects = already_added_projects)
- ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url)
+ override :incompatible_repos
+ def incompatible_repos
+ []
end
- def serialized_provider_repos
- repos = client_repos.reject { |repo| already_added_project_names.include? repo.full_name }
- Import::ProviderRepoSerializer.new(current_user: current_user).represent(repos, provider: provider, provider_url: provider_url)
+ override :provider_name
+ def provider_name
+ :github
end
- def serialized_namespaces
- NamespaceSerializer.new.represent(namespaces)
+ override :provider_url
+ def provider_url
+ strong_memoize(:provider_url) do
+ provider = Gitlab::Auth::OAuth::Provider.config_for('github')
+
+ provider&.dig('url').presence || 'https://github.com'
+ end
end
- def already_added_projects
- @already_added_projects ||= filtered(find_already_added_projects(provider))
+ private
+
+ def import_params
+ params.permit(permitted_import_params)
end
- def already_added_project_names
- @already_added_projects_names ||= already_added_projects.pluck(:import_source) # rubocop:disable CodeReuse/ActiveRecord
+ def permitted_import_params
+ [:repo_id, :new_name, :target_namespace]
end
- def namespaces
- current_user.manageable_groups_with_routes
+ def serialized_imported_projects(projects = already_added_projects)
+ ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url)
end
def expire_etag_cache
@@ -118,29 +120,29 @@ class Import::GithubController < Import::BaseController
end
def import_enabled?
- __send__("#{provider}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend
+ __send__("#{provider_name}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend
end
def realtime_changes_path
- public_send("realtime_changes_import_#{provider}_path", format: :json) # rubocop:disable GitlabSecurity/PublicSend
+ public_send("realtime_changes_import_#{provider_name}_path", format: :json) # rubocop:disable GitlabSecurity/PublicSend
end
def new_import_url
- public_send("new_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
+ public_send("new_import_#{provider_name}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
def status_import_url
- public_send("status_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
+ public_send("status_import_#{provider_name}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
def callback_import_url
- public_send("users_import_#{provider}_callback_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
+ public_send("users_import_#{provider_name}_callback_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
def provider_unauthorized
session[access_token_key] = nil
redirect_to new_import_url,
- alert: "Access denied to your #{Gitlab::ImportSources.title(provider.to_s)} account."
+ alert: "Access denied to your #{Gitlab::ImportSources.title(provider_name.to_s)} account."
end
def provider_rate_limit(exception)
@@ -151,29 +153,16 @@ class Import::GithubController < Import::BaseController
end
def access_token_key
- :"#{provider}_access_token"
+ :"#{provider_name}_access_token"
end
def access_params
{ github_access_token: session[access_token_key] }
end
- # The following methods are overridden in subclasses
- def provider
- :github
- end
-
- def provider_url
- strong_memoize(:provider_url) do
- provider = Gitlab::Auth::OAuth::Provider.config_for('github')
-
- provider&.dig('url').presence || 'https://github.com'
- end
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def logged_in_with_provider?
- current_user.identities.exists?(provider: provider)
+ current_user.identities.exists?(provider: provider_name)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -202,12 +191,6 @@ class Import::GithubController < Import::BaseController
def filter_attribute
:name
end
-
- def filtered(collection)
- return collection unless sanitized_filter_param
-
- collection.select { |item| item[filter_attribute].include?(sanitized_filter_param) }
- end
end
Import::GithubController.prepend_if_ee('EE::Import::GithubController')
diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb
index a95a67e208c..cc68eb02741 100644
--- a/app/controllers/import/gitlab_controller.rb
+++ b/app/controllers/import/gitlab_controller.rb
@@ -16,21 +16,8 @@ class Import::GitlabController < Import::BaseController
redirect_to status_import_gitlab_url
end
- # rubocop: disable CodeReuse/ActiveRecord
def status
- return super if Feature.enabled?(:new_import_ui)
-
- @repos = client.projects(starting_page: 1, page_limit: MAX_PROJECT_PAGES, per_page: PER_PAGE_PROJECTS)
-
- @already_added_projects = find_already_added_projects('gitlab')
- already_added_projects_names = @already_added_projects.pluck(:import_source)
-
- @repos = @repos.to_a.reject { |repo| already_added_projects_names.include? repo["path_with_namespace"] }
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def jobs
- render json: find_jobs('gitlab')
+ super
end
def create
@@ -63,6 +50,11 @@ class Import::GitlabController < Import::BaseController
end
# rubocop: enable CodeReuse/ActiveRecord
+ override :incompatible_repos
+ def incompatible_repos
+ []
+ end
+
override :provider_name
def provider_name
:gitlab
diff --git a/app/controllers/instance_statistics/cohorts_controller.rb b/app/controllers/instance_statistics/cohorts_controller.rb
index 4b4e39db2e1..0de62a56b01 100644
--- a/app/controllers/instance_statistics/cohorts_controller.rb
+++ b/app/controllers/instance_statistics/cohorts_controller.rb
@@ -1,8 +1,12 @@
# frozen_string_literal: true
class InstanceStatistics::CohortsController < InstanceStatistics::ApplicationController
+ include Analytics::UniqueVisitsHelper
+
before_action :authenticate_usage_ping_enabled_or_admin!
+ track_unique_visits :index, target_id: 'i_analytics_cohorts'
+
def index
if Gitlab::CurrentSettings.usage_ping_enabled
cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do
diff --git a/app/controllers/instance_statistics/dev_ops_score_controller.rb b/app/controllers/instance_statistics/dev_ops_score_controller.rb
index 238f7fa7707..b98a1bf7f99 100644
--- a/app/controllers/instance_statistics/dev_ops_score_controller.rb
+++ b/app/controllers/instance_statistics/dev_ops_score_controller.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class InstanceStatistics::DevOpsScoreController < InstanceStatistics::ApplicationController
+ include Analytics::UniqueVisitsHelper
+
+ track_unique_visits :index, target_id: 'i_analytics_dev_ops_score'
+
# rubocop: disable CodeReuse/ActiveRecord
def index
@metric = DevOpsScore::Metric.order(:created_at).last&.present
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index a78d87eceea..5bd9ac7f275 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -1,12 +1,17 @@
# frozen_string_literal: true
class InvitesController < ApplicationController
+ include Gitlab::Utils::StrongMemoize
+
before_action :member
skip_before_action :authenticate_user!, only: :decline
+ helper_method :member?, :current_user_matches_invite?
+
respond_to :html
def show
+ accept if skip_invitation_prompt?
end
def accept
@@ -38,6 +43,20 @@ class InvitesController < ApplicationController
private
+ def skip_invitation_prompt?
+ !member? && current_user_matches_invite?
+ end
+
+ def current_user_matches_invite?
+ @member.invite_email == current_user.email
+ end
+
+ def member?
+ strong_memoize(:is_member) do
+ @member.source.users.include?(current_user)
+ end
+ end
+
def member
return @member if defined?(@member)
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index 2c3e60d12b7..6532501733a 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -17,6 +17,8 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
before_action :add_gon_variables
before_action :load_scopes, only: [:index, :create, :edit, :update]
+ around_action :set_locale
+
helper_method :can?
layout 'profile'
@@ -70,4 +72,8 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
params[:owner] = current_user
end
end
+
+ def set_locale(&block)
+ Gitlab::I18n.with_user_locale(current_user, &block)
+ end
end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 4c595313cb6..706a4843117 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -200,7 +200,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def fail_login(user)
error_message = user.errors.full_messages.to_sentence
- return redirect_to omniauth_error_path(oauth['provider'], error: error_message)
+ redirect_to omniauth_error_path(oauth['provider'], error: error_message)
end
def fail_auth0_login
diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb
index b9cb71ae89a..99e1b9027fa 100644
--- a/app/controllers/profiles/keys_controller.rb
+++ b/app/controllers/profiles/keys_controller.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class Profiles::KeysController < Profiles::ApplicationController
- skip_before_action :authenticate_user!, only: [:get_keys]
-
def index
@keys = current_user.keys.order_id_desc
@key = Key.new
@@ -33,25 +31,6 @@ class Profiles::KeysController < Profiles::ApplicationController
end
end
- # Get all keys of a user(params[:username]) in a text format
- # Helpful for sysadmins to put in respective servers
- def get_keys
- if params[:username].present?
- begin
- user = UserFinder.new(params[:username]).find_by_username
- if user.present?
- render plain: user.all_ssh_keys.join("\n")
- else
- return render_404
- end
- rescue => e
- render html: e.message
- end
- else
- return render_404
- end
- end
-
private
def key_params
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index f1c07cd9a1d..30f25e8fdaa 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -40,14 +40,18 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
params.require(:personal_access_token).permit(:name, :expires_at, scopes: [])
end
- # rubocop: disable CodeReuse/ActiveRecord
def set_index_vars
@scopes = Gitlab::Auth.available_scopes_for(current_user)
@inactive_personal_access_tokens = finder(state: 'inactive').execute
- @active_personal_access_tokens = finder(state: 'active').execute.order(:expires_at)
+ @active_personal_access_tokens = active_personal_access_tokens
@new_personal_access_token = PersonalAccessToken.redis_getdel(current_user.id)
end
- # rubocop: enable CodeReuse/ActiveRecord
+
+ def active_personal_access_tokens
+ finder(state: 'active', sort: 'expires_at_asc').execute
+ end
end
+
+Profiles::PersonalAccessTokensController.prepend_if_ee('EE::Profiles::PersonalAccessTokensController')
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 1477d79c911..8653fe3b6ed 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -48,6 +48,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:time_display_relative,
:time_format_in_24h,
:show_whitespace_in_diffs,
+ :view_diffs_file_by_file,
:tab_width,
:sourcegraph_enabled,
:render_whitespace_in_code
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index b1f285f76d7..518d414be1b 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -42,7 +42,7 @@ class Projects::ApplicationController < ApplicationController
def authorize_action!(action)
unless can?(current_user, action, project)
- return access_denied!
+ access_denied!
end
end
@@ -81,10 +81,6 @@ class Projects::ApplicationController < ApplicationController
end
end
- def apply_diff_view_cookie!
- set_secure_cookie(:diff_view, params.delete(:view), permanent: true) if params[:view].present?
- end
-
def require_pages_enabled!
not_found unless @project.pages_available?
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 14dca1bdc30..7f14522e61b 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -9,6 +9,7 @@ class Projects::BlobController < Projects::ApplicationController
include ActionView::Helpers::SanitizeHelper
include RedirectsForMissingPathOnTree
include SourcegraphDecorator
+ include DiffHelper
prepend_before_action :authenticate_user!, only: [:edit]
@@ -129,7 +130,7 @@ class Projects::BlobController < Projects::ApplicationController
end
end
- return redirect_to_tree_root_for_missing_path(@project, @ref, @path)
+ redirect_to_tree_root_for_missing_path(@project, @ref, @path)
end
end
@@ -207,14 +208,14 @@ class Projects::BlobController < Projects::ApplicationController
def set_last_commit_sha
@last_commit_sha = Gitlab::Git::Commit
- .last_for_path(@repository, @ref, @path).sha
+ .last_for_path(@repository, @ref, @path, literal_pathspec: true).sha
end
def show_html
environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
environment_params[:find_latest] = true
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
- @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path)
+ @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path, literal_pathspec: true)
@code_navigation_path = Gitlab::CodeNavigationPath.new(@project, @blob.commit_id).full_json_path_for(@blob.path)
render 'show'
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 8fa823e0be1..db05da0bb7f 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -9,7 +9,6 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:multi_select_board, default_enabled: true)
- push_frontend_feature_flag(:sfc_issue_boards, default_enabled: true)
end
private
diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb
index b50afa12da0..73b3eb9c205 100644
--- a/app/controllers/projects/ci/lints_controller.rb
+++ b/app/controllers/projects/ci/lints_controller.rb
@@ -14,7 +14,7 @@ class Projects::Ci::LintsController < Projects::ApplicationController
@errors = result.errors
if result.valid?
- @config_processor = result.content
+ @config_processor = result.config
@stages = @config_processor.stages
@builds = @config_processor.builds
@jobs = @config_processor.jobs
diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb
index 079d30127d6..8acf5235c1a 100644
--- a/app/controllers/projects/clusters_controller.rb
+++ b/app/controllers/projects/clusters_controller.rb
@@ -23,6 +23,13 @@ class Projects::ClustersController < Clusters::ClustersController
def repository
@repository ||= project.repository
end
-end
-Projects::ClustersController.prepend_if_ee('EE::Projects::ClustersController')
+ def metrics_dashboard_params
+ params.permit(:embedded, :group, :title, :y_label).merge(
+ {
+ cluster: cluster,
+ cluster_type: :project
+ }
+ )
+ end
+end
diff --git a/app/controllers/projects/confluences_controller.rb b/app/controllers/projects/confluences_controller.rb
new file mode 100644
index 00000000000..d563b34a362
--- /dev/null
+++ b/app/controllers/projects/confluences_controller.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Projects::ConfluencesController < Projects::ApplicationController
+ before_action :ensure_confluence
+
+ def show
+ end
+
+ private
+
+ def ensure_confluence
+ render_404 unless project.has_confluence?
+ end
+end
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index f13c75ac4cc..898d888c978 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -4,10 +4,13 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
include ActionView::Helpers::DateHelper
include ActionView::Helpers::TextHelper
include CycleAnalyticsParams
+ include Analytics::UniqueVisitsHelper
before_action :whitelist_query_limiting, only: [:show]
before_action :authorize_read_cycle_analytics!
+ track_unique_visits :show, target_id: 'p_analytics_valuestream'
+
def show
@cycle_analytics = ::CycleAnalytics::ProjectLevel.new(@project, options: options(cycle_analytics_project_params))
diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb
index 766e2f86ea2..1344cf775e4 100644
--- a/app/controllers/projects/deployments_controller.rb
+++ b/app/controllers/projects/deployments_controller.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class Projects::DeploymentsController < Projects::ApplicationController
- before_action :authorize_read_environment!
before_action :authorize_read_deployment!
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb
index 98fcc594d6e..f0bb5360f84 100644
--- a/app/controllers/projects/environments/prometheus_api_controller.rb
+++ b/app/controllers/projects/environments/prometheus_api_controller.rb
@@ -1,51 +1,17 @@
# frozen_string_literal: true
class Projects::Environments::PrometheusApiController < Projects::ApplicationController
- include RenderServiceResults
+ include Metrics::Dashboard::PrometheusApiProxy
- before_action :authorize_read_prometheus!
- before_action :environment
-
- def proxy
- variable_substitution_result =
- variable_substitution_service.new(environment, permit_params).execute
-
- if variable_substitution_result[:status] == :error
- return error_response(variable_substitution_result)
- end
-
- prometheus_result = Prometheus::ProxyService.new(
- environment,
- proxy_method,
- proxy_path,
- variable_substitution_result[:params]
- ).execute
-
- return continue_polling_response if prometheus_result.nil?
- return error_response(prometheus_result) if prometheus_result[:status] == :error
-
- success_response(prometheus_result)
- end
+ before_action :proxyable
private
- def variable_substitution_service
- Prometheus::ProxyVariableSubstitutionService
- end
-
- def permit_params
- params.permit!
- end
-
- def environment
- @environment ||= project.environments.find(params[:id])
+ def proxyable
+ @proxyable ||= project.environments.find(params[:id])
end
- def proxy_method
- request.method
- end
-
- def proxy_path
- params[:proxy_path]
+ def proxy_variable_substitution_service
+ Prometheus::ProxyVariableSubstitutionService
end
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 4d774123ef1..d5da24a76de 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class Projects::EnvironmentsController < Projects::ApplicationController
+ # Metrics dashboard code is getting decoupled from environments and is being moved
+ # into app/controllers/projects/metrics_dashboard_controller.rb
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/226002 for more details.
+
include MetricsDashboard
layout 'project'
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index ebc81976529..b93f6384e0c 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -3,6 +3,7 @@
class Projects::ForksController < Projects::ApplicationController
include ContinueParams
include RendersMemberAccess
+ include RendersProjectsList
include Gitlab::Utils::StrongMemoize
# Authorize
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index a8b90f8685f..9b889f9e837 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -2,12 +2,15 @@
class Projects::GraphsController < Projects::ApplicationController
include ExtractsPath
+ include Analytics::UniqueVisitsHelper
# Authorize
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_read_repository_graphs!
+ track_unique_visits :charts, target_id: 'p_analytics_repo'
+
def show
respond_to do |format|
format.html
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index 67a7daf8445..deba71c9dd3 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -5,7 +5,8 @@ class Projects::ImportsController < Projects::ApplicationController
include ImportUrlParams
# Authorize
- before_action :authorize_admin_project!
+ before_action :authorize_admin_project!, only: [:new, :create]
+ before_action :require_namespace_project_creation_permission, only: :show
before_action :require_no_repo, only: [:new, :create]
before_action :redirect_if_progress, only: [:new, :create]
before_action :redirect_if_no_import, only: :show
@@ -51,6 +52,10 @@ class Projects::ImportsController < Projects::ApplicationController
end
end
+ def require_namespace_project_creation_permission
+ render_404 unless current_user.can?(:admin_project, @project) || current_user.can?(:create_projects, @project.namespace)
+ end
+
def redirect_if_progress
if @project.import_in_progress?
redirect_to project_import_path(@project)
diff --git a/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb b/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb
new file mode 100644
index 00000000000..dac1640dd08
--- /dev/null
+++ b/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Projects
+ module IncidentManagement
+ class PagerDutyIncidentsController < Projects::ApplicationController
+ respond_to :json
+
+ skip_before_action :verify_authenticity_token
+ skip_before_action :project
+
+ prepend_before_action :project_without_auth
+
+ def create
+ result = webhook_processor.execute(params[:token])
+
+ head result.http_status
+ end
+
+ private
+
+ def project_without_auth
+ @project ||= Project
+ .find_by_full_path("#{params[:namespace_id]}/#{params[:project_id]}")
+ end
+
+ def webhook_processor
+ ::IncidentManagement::PagerDuty::ProcessWebhookService.new(project, nil, payload)
+ end
+
+ def payload
+ @payload ||= params.permit![:pager_duty_incident].to_h
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 693329848de..12b5a538bc9 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -11,11 +11,11 @@ class Projects::IssuesController < Projects::ApplicationController
include RecordUserLastActivity
def issue_except_actions
- %i[index calendar new create bulk_update import_csv export_csv]
+ %i[index calendar new create bulk_update import_csv export_csv service_desk]
end
def set_issuables_index_only_actions
- %i[index calendar]
+ %i[index calendar service_desk]
end
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
@@ -46,10 +46,17 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
+ push_frontend_feature_flag(:tribute_autocomplete, @project)
+ push_frontend_feature_flag(:vue_issuables_list, project)
end
before_action only: :show do
push_frontend_feature_flag(:real_time_issue_sidebar, @project)
+ push_frontend_feature_flag(:confidential_apollo_sidebar, @project)
+ end
+
+ before_action only: :index do
+ push_frontend_feature_flag(:scoped_labels, @project)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
@@ -216,6 +223,11 @@ class Projects::IssuesController < Projects::ApplicationController
redirect_to project_issues_path(project)
end
+ def service_desk
+ @issues = @issuables # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ @users.push(User.support_bot) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
protected
def sorting_field
@@ -313,6 +325,17 @@ class Projects::IssuesController < Projects::ApplicationController
private
+ def finder_options
+ options = super
+
+ return options unless service_desk?
+
+ options.reject! { |key| key == 'author_username' || key == 'author_id' }
+ options[:author_id] = User.support_bot
+
+ options
+ end
+
def branch_link(branch)
project_compare_path(project, from: project.default_branch, to: branch[:name])
end
@@ -330,6 +353,10 @@ class Projects::IssuesController < Projects::ApplicationController
def rate_limiter
::Gitlab::ApplicationRateLimiter
end
+
+ def service_desk?
+ action_name == 'service_desk'
+ end
end
Projects::IssuesController.prepend_if_ee('EE::Projects::IssuesController')
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index e1f6cbe3dca..3f7f8da3478 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -11,9 +11,6 @@ class Projects::JobsController < Projects::ApplicationController
before_action :authorize_erase_build!, only: [:erase]
before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize]
before_action :verify_api_request!, only: :terminal_websocket_authorize
- before_action only: [:show] do
- push_frontend_feature_flag(:job_log_json, project, default_enabled: true)
- end
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
@@ -55,15 +52,10 @@ class Projects::JobsController < Projects::ApplicationController
format.json do
build.trace.being_watched!
- # TODO: when the feature flag is removed we should not pass
- # content_format to serialize method.
- content_format = Feature.enabled?(:job_log_json, @project, default_enabled: true) ? :json : :html
-
build_trace = Ci::BuildTrace.new(
build: @build,
stream: stream,
- state: params[:state],
- content_format: content_format)
+ state: params[:state])
render json: BuildTraceSerializer
.new(project: @project, current_user: @current_user)
diff --git a/app/controllers/projects/logs_controller.rb b/app/controllers/projects/logs_controller.rb
index ba509235417..b9027b3a2cb 100644
--- a/app/controllers/projects/logs_controller.rb
+++ b/app/controllers/projects/logs_controller.rb
@@ -2,15 +2,16 @@
module Projects
class LogsController < Projects::ApplicationController
+ include ::Gitlab::Utils::StrongMemoize
+
before_action :authorize_read_pod_logs!
- before_action :environment
before_action :ensure_deployments, only: %i(k8s elasticsearch)
def index
- if environment.nil?
- render :empty_logs
- else
+ if environment || cluster
render :index
+ else
+ render :empty_logs
end
end
@@ -39,8 +40,9 @@ module Projects
end
end
- def index_params
- params.permit(:environment_name)
+ # cluster is selected either via environment or directly by id
+ def cluster_params
+ params.permit(:environment_name, :cluster_id)
end
def k8s_params
@@ -52,22 +54,36 @@ module Projects
end
def environment
- @environment ||= if index_params.key?(:environment_name)
- EnvironmentsFinder.new(project, current_user, name: index_params[:environment_name]).find.first
- else
- project.default_environment
- end
+ strong_memoize(:environment) do
+ if cluster_params.key?(:environment_name)
+ EnvironmentsFinder.new(project, current_user, name: cluster_params[:environment_name]).find.first
+ else
+ project.default_environment
+ end
+ end
end
def cluster
- environment.deployment_platform&.cluster
+ strong_memoize(:cluster) do
+ if gitlab_managed_apps_logs?
+ clusters = ClusterAncestorsFinder.new(project, current_user).execute
+ clusters.find { |cluster| cluster.id == cluster_params[:cluster_id].to_i }
+ else
+ environment&.deployment_platform&.cluster
+ end
+ end
end
def namespace
- environment.deployment_namespace
+ if gitlab_managed_apps_logs?
+ Gitlab::Kubernetes::Helm::NAMESPACE
+ else
+ environment.deployment_namespace
+ end
end
def ensure_deployments
+ return if gitlab_managed_apps_logs?
return if cluster && namespace.present?
render status: :bad_request, json: {
@@ -75,5 +91,9 @@ module Projects
message: _('Environment does not have deployments')
}
end
+
+ def gitlab_managed_apps_logs?
+ cluster_params.key?(:cluster_id)
+ end
end
end
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index b7e99cb7ed0..0bb4e0fb5ee 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -48,12 +48,9 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
end
def set_pipeline_variables
- @pipelines =
- if can?(current_user, :read_pipeline, @merge_request.source_project)
- @merge_request.all_pipelines
- else
- Ci::Pipeline.none
- end
+ @pipelines = Ci::PipelinesForMergeRequestFinder
+ .new(@merge_request, current_user)
+ .execute
end
def close_merge_request_if_no_source_project
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index 28aa1b300aa..3e077c1af37 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -32,13 +32,13 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
end
def pipelines
- @pipelines = @merge_request.all_pipelines
+ @pipelines = Ci::PipelinesForMergeRequestFinder.new(@merge_request, current_user).execute
Gitlab::PollingInterval.set_header(response, interval: 10_000)
render json: {
pipelines: PipelineSerializer
- .new(project: @project, current_user: @current_user)
+ .new(project: @project, current_user: current_user)
.represent(@pipelines)
}
end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 1bf143c9a91..98b0abc89e9 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -8,6 +8,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
before_action :commit
before_action :define_diff_vars
before_action :define_diff_comment_vars, except: [:diffs_batch, :diffs_metadata]
+ before_action :update_diff_discussion_positions!
around_action :allow_gitaly_ref_name_caching
@@ -171,4 +172,12 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
@notes.concat(draft_notes)
end
+
+ def update_diff_discussion_positions!
+ return unless Feature.enabled?(:merge_ref_head_comments, @merge_request.target_project, default_enabled: true)
+ return unless Feature.enabled?(:merge_red_head_comments_position_on_demand, @merge_request.target_project, default_enabled: true)
+ return if @merge_request.has_any_diff_note_positions?
+
+ Discussions::CaptureDiffNotePositionsService.new(@merge_request).execute
+ end
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 6c1ffc35276..e65e5531b88 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -35,15 +35,23 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true)
push_frontend_feature_flag(:multiline_comments, @project)
push_frontend_feature_flag(:file_identifier_hash)
- push_frontend_feature_flag(:batch_suggestions, @project)
+ push_frontend_feature_flag(:batch_suggestions, @project, default_enabled: true)
end
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, @project.group)
+ push_frontend_feature_flag(:junit_pipeline_view, @project.group)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
+ feature_category :source_code_management,
+ unless: -> (action) { action.ends_with?("_reports") }
+ feature_category :code_testing,
+ only: [:test_reports, :coverage_reports, :terraform_reports]
+ feature_category :accessibility_testing,
+ only: [:accessibility_reports]
+
def index
@merge_requests = @issuables
@@ -76,7 +84,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar')
@current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json
@show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs
+ @file_by_file_default = Feature.enabled?(:view_diffs_file_by_file) && current_user&.view_diffs_file_by_file
@coverage_path = coverage_reports_project_merge_request_path(@project, @merge_request, format: :json) if @merge_request.has_coverage_reports?
+ @endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request)
set_pipeline_variables
@@ -108,8 +118,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
# or from cache if already merged
@commits =
set_commits_for_rendering(
- @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch),
- commits_count: @merge_request.commits_count
+ @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch).with_markdown_cache,
+ commits_count: @merge_request.commits_count
)
render json: { html: view_to_html_string('projects/merge_requests/_commits') }
@@ -178,7 +188,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def update
- @merge_request = ::MergeRequests::UpdateService.new(project, current_user, merge_request_params).execute(@merge_request)
+ @merge_request = ::MergeRequests::UpdateService.new(project, current_user, merge_request_update_params).execute(@merge_request)
respond_to do |format|
format.html do
@@ -312,6 +322,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
private
+ def merge_request_update_params
+ merge_request_params.merge!(params.permit(:merge_request_diff_head_sha))
+ end
+
def head_pipeline
strong_memoize(:head_pipeline) do
pipeline = @merge_request.head_pipeline
@@ -422,6 +436,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def authorize_read_actual_head_pipeline!
return render_404 unless can?(current_user, :read_build, merge_request.actual_head_pipeline)
end
+
+ def endpoint_metadata_url(project, merge_request)
+ params = request.query_parameters
+ params[:view] = cookies[:diff_view] if params[:view].blank? && cookies[:diff_view].present?
+
+ diffs_metadata_project_json_merge_request_path(project, merge_request, 'json', params)
+ end
end
Projects::MergeRequestsController.prepend_if_ee('EE::Projects::MergeRequestsController')
diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb
new file mode 100644
index 00000000000..235ee1dfbf2
--- /dev/null
+++ b/app/controllers/projects/metrics_dashboard_controller.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+module Projects
+ class MetricsDashboardController < Projects::ApplicationController
+ # Metrics dashboard code is in the process of being decoupled from environments
+ # and is getting moved to this controller. Some code may be duplicated from
+ # app/controllers/projects/environments_controller.rb
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/226002 for more details.
+
+ before_action :authorize_metrics_dashboard!
+ before_action do
+ push_frontend_feature_flag(:prometheus_computed_alerts)
+ end
+
+ def show
+ if environment
+ render 'projects/environments/metrics'
+ else
+ render_404
+ end
+ end
+
+ private
+
+ def environment
+ @environment ||=
+ if params[:environment]
+ project.environments.find(params[:environment])
+ else
+ project.default_environment
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/pipelines/application_controller.rb b/app/controllers/projects/pipelines/application_controller.rb
new file mode 100644
index 00000000000..92887750813
--- /dev/null
+++ b/app/controllers/projects/pipelines/application_controller.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+# Abstract class encapsulating common logic for creating new controllers in a pipeline context
+
+module Projects
+ module Pipelines
+ class ApplicationController < Projects::ApplicationController
+ include Gitlab::Utils::StrongMemoize
+
+ before_action :pipeline
+ before_action :authorize_read_pipeline!
+
+ private
+
+ def pipeline
+ strong_memoize(:pipeline) do
+ project.all_pipelines.find(params[:pipeline_id]).tap do |pipeline|
+ render_404 unless can?(current_user, :read_pipeline, pipeline)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/pipelines/stages_controller.rb b/app/controllers/projects/pipelines/stages_controller.rb
new file mode 100644
index 00000000000..ce08b49ce9f
--- /dev/null
+++ b/app/controllers/projects/pipelines/stages_controller.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Projects
+ module Pipelines
+ class StagesController < Projects::Pipelines::ApplicationController
+ before_action :authorize_update_pipeline!
+
+ def play_manual
+ ::Ci::PlayManualStageService
+ .new(@project, current_user, pipeline: pipeline)
+ .execute(stage)
+
+ respond_to do |format|
+ format.json do
+ render json: StageSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(stage)
+ end
+ end
+ end
+
+ private
+
+ def stage
+ @pipeline_stage ||= pipeline.find_stage_by_name!(params[:stage_name])
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb
new file mode 100644
index 00000000000..f03274bf32e
--- /dev/null
+++ b/app/controllers/projects/pipelines/tests_controller.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module Projects
+ module Pipelines
+ class TestsController < Projects::Pipelines::ApplicationController
+ before_action :validate_feature_flag!
+ before_action :authorize_read_build!
+ before_action :builds, only: [:show]
+
+ def summary
+ respond_to do |format|
+ format.json do
+ render json: TestReportSummarySerializer
+ .new(project: project, current_user: @current_user)
+ .represent(pipeline.test_report_summary)
+ end
+ end
+ end
+
+ def show
+ respond_to do |format|
+ format.json do
+ render json: TestSuiteSerializer
+ .new(project: project, current_user: @current_user)
+ .represent(test_suite, details: true)
+ end
+ end
+ end
+
+ private
+
+ def validate_feature_flag!
+ render_404 unless Feature.enabled?(:build_report_summary, project)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def builds
+ pipeline.latest_builds.where(id: build_params)
+ end
+
+ def build_params
+ return [] unless params[:build_ids]
+
+ params[:build_ids].split(",")
+ end
+
+ def test_suite
+ if builds.present?
+ builds.map do |build|
+ build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
+ end.sum
+ else
+ render_404
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 0b6c0db211e..d8e11ddd423 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -2,6 +2,7 @@
class Projects::PipelinesController < Projects::ApplicationController
include ::Gitlab::Utils::StrongMemoize
+ include Analytics::UniqueVisitsHelper
before_action :whitelist_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts]
@@ -12,14 +13,20 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
push_frontend_feature_flag(:junit_pipeline_view, project)
+ push_frontend_feature_flag(:build_report_summary, project)
push_frontend_feature_flag(:filter_pipelines_search, project, default_enabled: true)
- push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: false)
+ push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: true)
push_frontend_feature_flag(:pipelines_security_report_summary, project)
end
before_action :ensure_pipeline, only: [:show]
+ # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596
+ before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? }
+
around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
+ track_unique_visits :charts, target_id: 'p_analytics_pipelines'
+
wrap_parameters Ci::Pipeline
POLLING_INTERVAL = 10_000
@@ -31,9 +38,6 @@ class Projects::PipelinesController < Projects::ApplicationController
.page(params[:page])
.per(30)
- @running_count = limited_pipelines_count(project, 'running')
- @pending_count = limited_pipelines_count(project, 'pending')
- @finished_count = limited_pipelines_count(project, 'finished')
@pipelines_count = limited_pipelines_count(project)
respond_to do |format|
@@ -44,10 +48,7 @@ class Projects::PipelinesController < Projects::ApplicationController
render json: {
pipelines: serialize_pipelines,
count: {
- all: @pipelines_count,
- running: @running_count,
- pending: @pending_count,
- finished: @finished_count
+ all: @pipelines_count
}
}
end
@@ -186,7 +187,7 @@ class Projects::PipelinesController < Projects::ApplicationController
format.json do
render json: TestReportSerializer
.new(current_user: @current_user)
- .represent(pipeline_test_report, project: project)
+ .represent(pipeline_test_report, project: project, details: true)
end
end
end
@@ -226,6 +227,12 @@ class Projects::PipelinesController < Projects::ApplicationController
render_404 unless pipeline
end
+ def redirect_for_legacy_scope_filter
+ return unless %w[running pending].include?(params[:scope])
+
+ redirect_to url_for(safe_params.except(:scope).merge(status: safe_params[:scope])), status: :moved_permanently
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def pipeline
@pipeline ||= if params[:id].blank? && params[:latest]
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index a2581e72257..db770d3e438 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -11,10 +11,6 @@ class Projects::RefsController < Projects::ApplicationController
before_action :assign_ref_vars
before_action :authorize_download_code!
- before_action only: [:logs_tree] do
- push_frontend_feature_flag(:vue_file_list_lfs_badge, default_enabled: true)
- end
-
def switch
respond_to do |format|
format.html do
@@ -57,22 +53,11 @@ class Projects::RefsController < Projects::ApplicationController
render json: logs
end
-
- # Deprecated due to https://gitlab.com/gitlab-org/gitlab/-/issues/36863
- # Will be removed soon https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29895
- format.js do
- @logs, _ = tree_summary.summarize
- @more_log_url = more_url(tree_summary.next_offset) if tree_summary.more?
- end
end
end
private
- def more_url(offset)
- logs_file_project_ref_path(@project, @ref, @path, offset: offset)
- end
-
def validate_ref_id
return not_found! if params[:id].present? && params[:id] !~ Gitlab::PathRegex.git_reference_regex
end
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index d3285b64dab..d58755c2655 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -13,6 +13,7 @@ class Projects::ReleasesController < Projects::ApplicationController
push_frontend_feature_flag(:release_asset_link_type, project, default_enabled: true)
end
before_action :authorize_update_release!, only: %i[edit update]
+ before_action :authorize_create_release!, only: :new
def index
respond_to do |format|
@@ -25,11 +26,11 @@ class Projects::ReleasesController < Projects::ApplicationController
def show
return render_404 unless Feature.enabled?(:release_show_page, project, default_enabled: true)
+ end
- respond_to do |format|
- format.html do
- render :show
- end
+ def new
+ unless Feature.enabled?(:new_release_page, project)
+ redirect_to(new_project_tag_path(@project))
end
end
@@ -37,22 +38,12 @@ class Projects::ReleasesController < Projects::ApplicationController
redirect_to link.url
end
- protected
+ private
def releases
ReleasesFinder.new(@project, current_user).execute
end
- def edit
- respond_to do |format|
- format.html do
- render :edit
- end
- end
- end
-
- private
-
def authorize_update_release!
access_denied! unless can?(current_user, :update_release, release)
end
diff --git a/app/controllers/projects/service_desk_controller.rb b/app/controllers/projects/service_desk_controller.rb
new file mode 100644
index 00000000000..bcd190bbc2c
--- /dev/null
+++ b/app/controllers/projects/service_desk_controller.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class Projects::ServiceDeskController < Projects::ApplicationController
+ before_action :authorize_admin_project!
+
+ def show
+ json_response
+ end
+
+ def update
+ Projects::UpdateService.new(project, current_user, { service_desk_enabled: params[:service_desk_enabled] }).execute
+
+ result = ServiceDeskSettings::UpdateService.new(project, current_user, setting_params).execute
+
+ if result[:status] == :success
+ json_response
+ else
+ render json: { message: result[:message] }, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def setting_params
+ params.permit(:issue_template_key, :outgoing_name, :project_key)
+ end
+
+ def json_response
+ respond_to do |format|
+ service_desk_settings = project.service_desk_setting
+
+ service_desk_attributes =
+ {
+ service_desk_address: project.service_desk_address,
+ service_desk_enabled: project.service_desk_enabled,
+ issue_template_key: service_desk_settings&.issue_template_key,
+ template_file_missing: service_desk_settings&.issue_template_missing?,
+ outgoing_name: service_desk_settings&.outgoing_name,
+ project_key: service_desk_settings&.project_key
+ }
+
+ format.json { render json: service_desk_attributes }
+ end
+ end
+end
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 710ad546e64..6b7e253595c 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -12,7 +12,8 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :set_deprecation_notice_for_prometheus_service, only: [:edit, :update]
before_action :redirect_deprecated_prometheus_service, only: [:update]
before_action only: :edit do
- push_frontend_feature_flag(:integration_form_refactor)
+ push_frontend_feature_flag(:integration_form_refactor, default_enabled: true)
+ push_frontend_feature_flag(:jira_issues_integration, @project, { default_enabled: true })
end
respond_to :html
@@ -20,17 +21,19 @@ class Projects::ServicesController < Projects::ApplicationController
layout "project_settings"
def edit
+ @admin_integration = Service.instance_for(service.type)
end
def update
@service.attributes = service_params[:service]
+ @service.inherit_from_id = nil if service_params[:service][:inherit_from_id].blank?
saved = @service.save(context: :manual_change)
respond_to do |format|
format.html do
if saved
- target_url = safe_redirect_path(params[:redirect_to]).presence || project_settings_integrations_path(@project)
+ target_url = safe_redirect_path(params[:redirect_to]).presence || edit_project_service_path(@project, @service)
redirect_to target_url, notice: success_message
else
render 'edit'
@@ -60,7 +63,7 @@ class Projects::ServicesController < Projects::ApplicationController
return { error: true, message: _('Validations failed.'), service_response: @service.errors.full_messages.join(','), test_failed: false }
end
- result = Integrations::Test::ProjectService.new(@service, current_user, params[:event]).execute
+ result = ::Integrations::Test::ProjectService.new(@service, current_user, params[:event]).execute
unless result[:success]
return { error: true, message: _('Test failed.'), service_response: result[:message].to_s, test_failed: true }
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index c2292511e0f..d7a6f1b0139 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -6,13 +6,13 @@ module Projects
before_action :authorize_admin_operations!
before_action :authorize_read_prometheus_alerts!, only: [:reset_alerting_token]
- respond_to :json, only: [:reset_alerting_token]
+ before_action do
+ push_frontend_feature_flag(:pagerduty_webhook, project)
+ end
- helper_method :error_tracking_setting
+ respond_to :json, only: [:reset_alerting_token, :reset_pagerduty_token]
- def show
- render locals: { prometheus_service: prometheus_service }
- end
+ helper_method :error_tracking_setting
def update
result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute
@@ -42,14 +42,29 @@ module Projects
end
end
+ def reset_pagerduty_token
+ result = ::Projects::Operations::UpdateService
+ .new(project, current_user, pagerduty_token_params)
+ .execute
+
+ if result[:status] == :success
+ pagerduty_token = project.incident_management_setting&.pagerduty_token
+ webhook_url = project_incidents_pagerduty_url(project, token: pagerduty_token)
+
+ render json: { pagerduty_webhook_url: webhook_url, pagerduty_token: pagerduty_token }
+ else
+ render json: {}, status: :unprocessable_entity
+ end
+ end
+
private
def alerting_params
{ alerting_setting_attributes: { regenerate_token: true } }
end
- def prometheus_service
- project.find_or_initialize_service(::PrometheusService.to_param)
+ def pagerduty_token_params
+ { incident_management_setting_attributes: { regenerate_token: true } }
end
def render_update_response(result)
diff --git a/app/controllers/projects/snippets/blobs_controller.rb b/app/controllers/projects/snippets/blobs_controller.rb
new file mode 100644
index 00000000000..148fc7c96f8
--- /dev/null
+++ b/app/controllers/projects/snippets/blobs_controller.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class Projects::Snippets::BlobsController < Projects::Snippets::ApplicationController
+ include Snippets::BlobsActions
+end
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index 5ee6abef804..49840e847f2 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -15,11 +15,11 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
before_action :authorize_admin_snippet!, only: [:destroy]
def index
- @snippet_counts = Snippets::CountService
+ @snippet_counts = ::Snippets::CountService
.new(current_user, project: @project)
.execute
- @snippets = SnippetsFinder.new(current_user, project: @project, scope: params[:scope])
+ @snippets = SnippetsFinder.new(current_user, project: @project, scope: params[:scope], sort: sort_param)
.execute
.page(params[:page])
.inc_author
@@ -35,7 +35,7 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
def create
create_params = snippet_params.merge(spammable_params)
- service_response = Snippets::CreateService.new(project, current_user, create_params).execute
+ service_response = ::Snippets::CreateService.new(project, current_user, create_params).execute
@snippet = service_response.payload[:snippet]
handle_repository_error(:new)
diff --git a/app/controllers/projects/stages_controller.rb b/app/controllers/projects/stages_controller.rb
deleted file mode 100644
index c8db5b1277f..00000000000
--- a/app/controllers/projects/stages_controller.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-class Projects::StagesController < Projects::PipelinesController
- before_action :authorize_update_pipeline!
-
- def play_manual
- ::Ci::PlayManualStageService
- .new(@project, current_user, pipeline: pipeline)
- .execute(stage)
-
- respond_to do |format|
- format.json do
- render json: StageSerializer
- .new(project: @project, current_user: @current_user)
- .represent(stage)
- end
- end
- end
-
- private
-
- def stage
- @pipeline_stage ||= pipeline.find_stage_by_name!(params[:stage_name])
- end
-end
diff --git a/app/controllers/projects/static_site_editor_controller.rb b/app/controllers/projects/static_site_editor_controller.rb
index 74f28c3da67..9ec50ff8196 100644
--- a/app/controllers/projects/static_site_editor_controller.rb
+++ b/app/controllers/projects/static_site_editor_controller.rb
@@ -9,6 +9,9 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
prepend_before_action :authenticate_user!, only: [:show]
before_action :assign_ref_and_path, only: [:show]
before_action :authorize_edit_tree!, only: [:show]
+ before_action do
+ push_frontend_feature_flag(:sse_image_uploads)
+ end
def show
@config = Gitlab::StaticSiteEditor::Config.new(@repository, @ref, @path, params[:return_url])
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 9cb345724cc..638e1a05c18 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -15,26 +15,14 @@ class Projects::TreeController < Projects::ApplicationController
before_action :authorize_download_code!
before_action :authorize_edit_tree!, only: [:create_dir]
- before_action only: [:show] do
- push_frontend_feature_flag(:vue_file_list_lfs_badge, default_enabled: true)
- end
-
def show
- return render_404 unless @repository.commit(@ref)
+ return render_404 unless @commit
if tree.entries.empty?
if @repository.blob_at(@commit.id, @path)
- return redirect_to project_blob_path(@project, File.join(@ref, @path))
+ redirect_to project_blob_path(@project, File.join(@ref, @path))
elsif @path.present?
- return redirect_to_tree_root_for_missing_path(@project, @ref, @path)
- end
- end
-
- respond_to do |format|
- format.html do
- lfs_blob_ids if Feature.disabled?(:vue_file_list, @project, default_enabled: true)
-
- @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit
+ redirect_to_tree_root_for_missing_path(@project, @ref, @path)
end
end
end
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index 1dffc57fcf0..2cc030d18fc 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -6,7 +6,7 @@ class Projects::VariablesController < Projects::ApplicationController
def show
respond_to do |format|
format.json do
- render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) }
+ render status: :ok, json: { variables: ::Ci::VariableSerializer.new.represent(@project.variables) }
end
end
end
@@ -26,7 +26,7 @@ class Projects::VariablesController < Projects::ApplicationController
private
def render_variables
- render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) }
+ render status: :ok, json: { variables: ::Ci::VariableSerializer.new.represent(@project.variables) }
end
def render_error
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 85e643aa212..d0aa733cadb 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -2,7 +2,6 @@
class Projects::WikisController < Projects::ApplicationController
include WikiActions
- include PreviewMarkdown
alias_method :container, :project
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index f0ddd62e996..a5666cb70ac 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -8,6 +8,7 @@ class ProjectsController < Projects::ApplicationController
include SendFileUpload
include RecordUserLastActivity
include ImportUrlParams
+ include FiltersEvents
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
@@ -21,7 +22,6 @@ class ProjectsController < Projects::ApplicationController
before_action :assign_ref_vars, if: -> { action_name == 'show' && repo_exists? }
before_action :tree,
if: -> { action_name == 'show' && repo_exists? && project_view_files? }
- before_action :lfs_blob_ids, if: :show_blob_ids?, only: :show
before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export]
before_action :present_project, only: [:edit]
before_action :authorize_download_code!, only: [:refs]
@@ -38,6 +38,7 @@ class ProjectsController < Projects::ApplicationController
before_action only: [:new, :create] do
frontend_experimentation_tracking_data(:new_create_project_ui, 'click_tab')
push_frontend_feature_flag(:new_create_project_ui) if experiment_enabled?(:new_create_project_ui)
+ push_frontend_feature_flag(:service_desk_custom_address, @project)
end
layout :determine_layout
@@ -301,10 +302,6 @@ class ProjectsController < Projects::ApplicationController
private
- def show_blob_ids?
- repo_exists? && project_view_files? && Feature.disabled?(:vue_file_list, @project, default_enabled: true)
- end
-
# Render project landing depending of which features are available
# So if page is not available in the list it renders the next page
#
@@ -395,6 +392,7 @@ class ProjectsController < Projects::ApplicationController
:initialize_with_readme,
:autoclose_referenced_issues,
:suggestion_commit_message,
+ :service_desk_enabled,
project_feature_attributes: %i[
builds_access_level
@@ -409,6 +407,7 @@ class ProjectsController < Projects::ApplicationController
],
project_setting_attributes: %i[
show_default_award_emojis
+ squash_option
]
]
end
diff --git a/app/controllers/registrations/experience_levels_controller.rb b/app/controllers/registrations/experience_levels_controller.rb
index 515d6b3f9aa..97239b1bbac 100644
--- a/app/controllers/registrations/experience_levels_controller.rb
+++ b/app/controllers/registrations/experience_levels_controller.rb
@@ -33,12 +33,13 @@ module Registrations
def hide_advanced_issues
return unless current_user.user_preference.novice?
+ return unless learn_gitlab.available?
- settings = cookies[:onboarding_issues_settings]
- return unless settings
+ Boards::UpdateService.new(learn_gitlab.project, current_user, label_ids: [learn_gitlab.label.id]).execute(learn_gitlab.board)
+ end
- modified_settings = Gitlab::Json.parse(settings).merge(hideAdvanced: true)
- cookies[:onboarding_issues_settings] = modified_settings.to_json
+ def learn_gitlab
+ @learn_gitlab ||= LearnGitlab.new(current_user)
end
end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 6ab2924a8b5..b1c1fe3ba74 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -64,8 +64,8 @@ class RegistrationsController < Devise::RegistrationsController
if result[:status] == :success
track_experiment_event(:signup_flow, 'end') # We want this event to be tracked when the user is _in_ the experimental group
- track_experiment_event(:onboarding_issues, 'signed_up') if ::Gitlab.com? && !helpers.in_subscription_flow? && !helpers.in_invitation_flow?
- return redirect_to new_users_sign_up_group_path if experiment_enabled?(:onboarding_issues) && !helpers.in_subscription_flow? && !helpers.in_invitation_flow?
+ track_experiment_event(:onboarding_issues, 'signed_up') if ::Gitlab.com? && show_onboarding_issues_experiment?
+ return redirect_to new_users_sign_up_group_path if experiment_enabled?(:onboarding_issues) && show_onboarding_issues_experiment?
set_flash_message! :notice, :signed_up
redirect_to path_for_signed_in_user(current_user)
@@ -210,6 +210,10 @@ class RegistrationsController < Devise::RegistrationsController
'devise'
end
end
+
+ def show_onboarding_issues_experiment?
+ !helpers.in_subscription_flow? && !helpers.in_invitation_flow? && !helpers.in_oauth_flow?
+ end
end
RegistrationsController.prepend_if_ee('EE::RegistrationsController')
diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb
index 24452f9a188..14469877e14 100644
--- a/app/controllers/root_controller.rb
+++ b/app/controllers/root_controller.rb
@@ -13,10 +13,15 @@ class RootController < Dashboard::ProjectsController
before_action :redirect_unlogged_user, if: -> { current_user.nil? }
before_action :redirect_logged_user, if: -> { current_user.present? }
+ # We only need to load the projects when the user is logged in but did not
+ # configure a dashboard. In which case we render projects. We can do that straight
+ # from the #index action.
+ skip_before_action :projects
def index
# n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/40260
Gitlab::GitalyClient.allow_n_plus_1_calls do
+ projects
super
end
end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 217f08dd648..ff6d9350a5c 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -51,6 +51,21 @@ class SearchController < ApplicationController
render json: { count: count }
end
+ # rubocop: disable CodeReuse/ActiveRecord
+ def autocomplete
+ term = params[:term]
+
+ if params[:project_id].present?
+ @project = Project.find_by(id: params[:project_id])
+ @project = nil unless can?(current_user, :read_project, @project)
+ end
+
+ @ref = params[:project_ref] if params[:project_ref].present?
+
+ render json: search_autocomplete_opts(term).to_json
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
private
def preload_method
diff --git a/app/controllers/snippets/blobs_controller.rb b/app/controllers/snippets/blobs_controller.rb
new file mode 100644
index 00000000000..d7c4bbcf8f2
--- /dev/null
+++ b/app/controllers/snippets/blobs_controller.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Snippets::BlobsController < Snippets::ApplicationController
+ include Snippets::BlobsActions
+
+ skip_before_action :authenticate_user!, only: [:raw]
+end
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 87d87390e57..e68b821459d 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -21,7 +21,7 @@ class SnippetsController < Snippets::ApplicationController
if params[:username].present?
@user = UserFinder.new(params[:username]).find_by_username!
- @snippets = SnippetsFinder.new(current_user, author: @user, scope: params[:scope])
+ @snippets = SnippetsFinder.new(current_user, author: @user, scope: params[:scope], sort: sort_param)
.execute
.page(params[:page])
.inc_author
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 5ee97885071..95ea31fa977 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -3,6 +3,7 @@
class UsersController < ApplicationController
include RoutableActions
include RendersMemberAccess
+ include RendersProjectsList
include ControllerWithCrossProjectAccessCheck
include Gitlab::NoteableMetadata
@@ -36,6 +37,12 @@ class UsersController < ApplicationController
end
end
+ # Get all keys of a user(params[:username]) in a text format
+ # Helpful for sysadmins to put in respective servers
+ def ssh_keys
+ render plain: user.all_ssh_keys.join("\n")
+ end
+
def activity
respond_to do |format|
format.html { render 'show' }
diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb
index 8001c70a9b2..2eee90a512a 100644
--- a/app/finders/branches_finder.rb
+++ b/app/finders/branches_finder.rb
@@ -5,11 +5,15 @@ class BranchesFinder < GitRefsFinder
super(repository, params)
end
- def execute
- branches = repository.branches_sorted_by(sort)
- branches = by_search(branches)
- branches = by_names(branches)
- branches
+ def execute(gitaly_pagination: false)
+ if gitaly_pagination && names.blank? && search.blank?
+ repository.branches_sorted_by(sort, pagination_params)
+ else
+ branches = repository.branches_sorted_by(sort)
+ branches = by_search(branches)
+ branches = by_names(branches)
+ branches
+ end
end
private
@@ -18,6 +22,18 @@ class BranchesFinder < GitRefsFinder
@params[:names].presence
end
+ def per_page
+ @params[:per_page].presence
+ end
+
+ def page_token
+ "#{Gitlab::Git::BRANCH_REF_PREFIX}#{@params[:page_token]}" if @params[:page_token]
+ end
+
+ def pagination_params
+ { limit: per_page, page_token: page_token }
+ end
+
def by_names(branches)
return branches unless names
diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb
index 9e71e92b456..7347a83d294 100644
--- a/app/finders/ci/pipelines_finder.rb
+++ b/app/finders/ci/pipelines_finder.rb
@@ -71,7 +71,7 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def by_status(items)
- return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status])
+ return items unless Ci::HasStatus::AVAILABLE_STATUSES.include?(params[:status])
items.where(status: params[:status])
end
diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb
index c01a68d6749..93d139652b9 100644
--- a/app/finders/ci/pipelines_for_merge_request_finder.rb
+++ b/app/finders/ci/pipelines_for_merge_request_finder.rb
@@ -7,14 +7,29 @@ module Ci
EVENT = 'merge_request_event'
- def initialize(merge_request)
+ def initialize(merge_request, current_user)
@merge_request = merge_request
+ @current_user = current_user
end
- attr_reader :merge_request
+ attr_reader :merge_request, :current_user
- delegate :commit_shas, :source_project, :source_branch, to: :merge_request
+ delegate :commit_shas, :target_project, :source_project, :source_branch, to: :merge_request
+ # Fetch all pipelines that the user can read.
+ def execute
+ if can_read_pipeline_in_target_project? && can_read_pipeline_in_source_project?
+ all
+ elsif can_read_pipeline_in_source_project?
+ all.for_project(merge_request.source_project)
+ elsif can_read_pipeline_in_target_project?
+ all.for_project(merge_request.target_project)
+ else
+ Ci::Pipeline.none
+ end
+ end
+
+ # Fetch all pipelines without permission check.
def all
strong_memoize(:all_pipelines) do
next Ci::Pipeline.none unless source_project
@@ -35,13 +50,13 @@ module Ci
def pipelines_using_cte
cte = Gitlab::SQL::CTE.new(:shas, merge_request.all_commits.select(:sha))
- source_pipelines_join = cte.table[:sha].eq(Ci::Pipeline.arel_table[:source_sha])
- source_pipelines = filter_by(triggered_by_merge_request, cte, source_pipelines_join)
- detached_pipelines = filter_by_sha(triggered_by_merge_request, cte)
+ source_sha_join = cte.table[:sha].eq(Ci::Pipeline.arel_table[:source_sha])
+ merged_result_pipelines = filter_by(triggered_by_merge_request, cte, source_sha_join)
+ detached_merge_request_pipelines = filter_by_sha(triggered_by_merge_request, cte)
pipelines_for_branch = filter_by_sha(triggered_for_branch, cte)
Ci::Pipeline.with(cte.to_arel) # rubocop: disable CodeReuse/ActiveRecord
- .from_union([source_pipelines, detached_pipelines, pipelines_for_branch])
+ .from_union([merged_result_pipelines, detached_merge_request_pipelines, pipelines_for_branch])
end
def filter_by_sha(pipelines, cte)
@@ -65,8 +80,7 @@ module Ci
# NOTE: this method returns only parent merge request pipelines.
# Child merge request pipelines have a different source.
def triggered_by_merge_request
- source_project.ci_pipelines
- .where(source: :merge_request_event, merge_request: merge_request) # rubocop: disable CodeReuse/ActiveRecord
+ Ci::Pipeline.triggered_by_merge_request(merge_request)
end
def triggered_for_branch
@@ -86,5 +100,17 @@ module Ci
pipelines.order(Arel.sql(query)) # rubocop: disable CodeReuse/ActiveRecord
end
+
+ def can_read_pipeline_in_target_project?
+ strong_memoize(:can_read_pipeline_in_target_project) do
+ Ability.allowed?(current_user, :read_pipeline, target_project)
+ end
+ end
+
+ def can_read_pipeline_in_source_project?
+ strong_memoize(:can_read_pipeline_in_source_project) do
+ Ability.allowed?(current_user, :read_pipeline, source_project)
+ end
+ end
end
end
diff --git a/app/finders/ci/runner_jobs_finder.rb b/app/finders/ci/runner_jobs_finder.rb
index ffcdb407e7e..9dc3c2a2427 100644
--- a/app/finders/ci/runner_jobs_finder.rb
+++ b/app/finders/ci/runner_jobs_finder.rb
@@ -21,7 +21,7 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def by_status(items)
- return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status])
+ return items unless Ci::HasStatus::AVAILABLE_STATUSES.include?(params[:status])
items.where(status: params[:status])
end
diff --git a/app/finders/ci/variables_finder.rb b/app/finders/ci/variables_finder.rb
new file mode 100644
index 00000000000..d933643ffb2
--- /dev/null
+++ b/app/finders/ci/variables_finder.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Ci
+ class VariablesFinder
+ attr_reader :project, :params
+
+ def initialize(project, params)
+ @project, @params = project, params
+
+ raise ArgumentError, 'Please provide params[:key]' if params[:key].blank?
+ end
+
+ def execute
+ variables = project.variables
+ variables = by_key(variables)
+ variables = by_environment_scope(variables)
+ variables
+ end
+
+ private
+
+ def by_key(variables)
+ variables.by_key(params[:key])
+ end
+
+ def by_environment_scope(variables)
+ environment_scope = params.dig(:filter, :environment_scope)
+ environment_scope.present? ? variables.by_environment_scope(environment_scope) : variables
+ end
+ end
+end
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
index 004fbc4cd22..4c619f3d7ea 100644
--- a/app/finders/events_finder.rb
+++ b/app/finders/events_finder.rb
@@ -54,17 +54,10 @@ class EventsFinder
if current_user && scope == 'all'
EventCollection.new(current_user.authorized_projects).all_project_events
else
- # EventCollection is responsible for applying the feature flag
- apply_feature_flags(source.events)
+ source.events
end
end
- def apply_feature_flags(events)
- return events if ::Feature.enabled?(:wiki_events)
-
- events.not_wiki_page
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def by_current_user_access(events)
events.merge(Project.public_or_visible_to_user(current_user))
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index dd8b2f29425..5f24b15156c 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -19,6 +19,9 @@
# personal: boolean
# search: string
# non_archived: boolean
+# with_issues_enabled: boolean
+# with_merge_requests_enabled: boolean
+# min_access_level: int
#
class GroupProjectsFinder < ProjectsFinder
DEFAULT_PROJECTS_LIMIT = 100
@@ -42,6 +45,12 @@ class GroupProjectsFinder < ProjectsFinder
private
+ def filter_projects(collection)
+ projects = super
+ projects = by_feature_availability(projects)
+ projects
+ end
+
def limit(collection)
limit = options[:limit]
@@ -49,35 +58,37 @@ class GroupProjectsFinder < ProjectsFinder
end
def init_collection
- projects = if current_user
- collection_with_user
- else
- collection_without_user
- end
+ projects =
+ if only_shared?
+ [shared_projects]
+ elsif only_owned?
+ [owned_projects]
+ else
+ [owned_projects, shared_projects]
+ end
+
+ projects.map! do |project_relation|
+ filter_by_visibility(project_relation)
+ end
union(projects)
end
- def collection_with_user
- if only_shared?
- [shared_projects.public_or_visible_to_user(current_user)]
- elsif only_owned?
- [owned_projects.public_or_visible_to_user(current_user)]
- else
- [
- owned_projects.public_or_visible_to_user(current_user),
- shared_projects.public_or_visible_to_user(current_user)
- ]
- end
+ def by_feature_availability(projects)
+ projects = projects.with_issues_available_for_user(current_user) if params[:with_issues_enabled].present?
+ projects = projects.with_merge_requests_available_for_user(current_user) if params[:with_merge_requests_enabled].present?
+ projects
end
- def collection_without_user
- if only_shared?
- [shared_projects.public_only]
- elsif only_owned?
- [owned_projects.public_only]
+ def filter_by_visibility(relation)
+ if current_user
+ if min_access_level?
+ relation.visible_to_user_and_access_level(current_user, params[:min_access_level])
+ else
+ relation.public_or_visible_to_user(current_user)
+ end
else
- [shared_projects.public_only, owned_projects.public_only]
+ relation.public_only
end
end
diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb
index 5b48d0817e3..8a194f34f74 100644
--- a/app/finders/issuable_finder/params.rb
+++ b/app/finders/issuable_finder/params.rb
@@ -110,7 +110,9 @@ class IssuableFinder
def group
strong_memoize(:group) do
- if params[:group_id].present?
+ if params[:group_id].is_a?(Group)
+ params[:group_id]
+ elsif params[:group_id].present?
Group.find(params[:group_id])
else
nil
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 72695a9d501..2b2e6b377b4 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -24,6 +24,7 @@
# created_before: datetime
# updated_after: datetime
# updated_before: datetime
+# confidential: boolean
#
class IssuesFinder < IssuableFinder
CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER
diff --git a/app/finders/issues_finder/params.rb b/app/finders/issues_finder/params.rb
index cd92b79265d..668d969f7c0 100644
--- a/app/finders/issues_finder/params.rb
+++ b/app/finders/issues_finder/params.rb
@@ -27,19 +27,14 @@ class IssuesFinder
end
def user_can_see_all_confidential_issues?
- return @user_can_see_all_confidential_issues if defined?(@user_can_see_all_confidential_issues)
-
- return @user_can_see_all_confidential_issues = false if current_user.blank?
- return @user_can_see_all_confidential_issues = true if current_user.can_read_all_resources?
-
- @user_can_see_all_confidential_issues =
- if project? && project
- project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL
- elsif group
- group.max_member_access_for_user(current_user) >= CONFIDENTIAL_ACCESS_LEVEL
+ strong_memoize(:user_can_see_all_confidential_issues) do
+ parent = project? ? project : group
+ if parent
+ Ability.allowed?(current_user, :read_confidential_issues, parent)
else
- false
+ Ability.allowed?(current_user, :read_all_resources)
end
+ end
end
def user_cannot_see_confidential_issues?
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index 8e57014f66e..1a3f011d9eb 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -158,13 +158,16 @@ class NotesFinder
end
# Notes changed since last fetch
- # Uses overlapping intervals to avoid worrying about race conditions
def since_fetch_at(notes)
return notes unless @params[:last_fetched_at]
# Default to 0 to remain compatible with old clients
- last_fetched_at = Time.at(@params.fetch(:last_fetched_at, 0).to_i)
- notes.updated_after(last_fetched_at - FETCH_OVERLAP)
+ last_fetched_at = @params.fetch(:last_fetched_at, Time.at(0))
+
+ # Use overlapping intervals to avoid worrying about race conditions
+ last_fetched_at -= FETCH_OVERLAP
+
+ notes.updated_after(last_fetched_at)
end
def notes_filter?
diff --git a/app/finders/packages/composer/packages_finder.rb b/app/finders/packages/composer/packages_finder.rb
new file mode 100644
index 00000000000..e63b2ee03fa
--- /dev/null
+++ b/app/finders/packages/composer/packages_finder.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+module Packages
+ module Composer
+ class PackagesFinder < Packages::GroupPackagesFinder
+ def initialize(current_user, group, params = {})
+ @current_user = current_user
+ @group = group
+ @params = params
+ end
+
+ def execute
+ packages_for_group_projects.composer.preload_composer
+ end
+ end
+ end
+end
diff --git a/app/finders/packages/conan/package_file_finder.rb b/app/finders/packages/conan/package_file_finder.rb
new file mode 100644
index 00000000000..edf35388a36
--- /dev/null
+++ b/app/finders/packages/conan/package_file_finder.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Packages
+ module Conan
+ class PackageFileFinder < ::Packages::PackageFileFinder
+ private
+
+ def package_files
+ files = super
+ files = by_conan_file_type(files)
+ files = by_conan_package_reference(files)
+ files
+ end
+
+ def by_conan_file_type(files)
+ return files unless params[:conan_file_type]
+
+ files.with_conan_file_type(params[:conan_file_type])
+ end
+
+ def by_conan_package_reference(files)
+ return files unless params[:conan_package_reference]
+
+ files.with_conan_package_reference(params[:conan_package_reference])
+ end
+ end
+ end
+end
diff --git a/app/finders/packages/conan/package_finder.rb b/app/finders/packages/conan/package_finder.rb
new file mode 100644
index 00000000000..26e9182f4e1
--- /dev/null
+++ b/app/finders/packages/conan/package_finder.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Packages
+ module Conan
+ class PackageFinder
+ attr_reader :current_user, :query
+
+ def initialize(current_user, params)
+ @current_user = current_user
+ @query = params[:query]
+ end
+
+ def execute
+ packages_for_current_user.with_name_like(query).order_name_asc if query
+ end
+
+ private
+
+ def packages
+ Packages::Package.conan
+ end
+
+ def packages_for_current_user
+ packages.for_projects(projects_visible_to_current_user)
+ end
+
+ def projects_visible_to_current_user
+ ::Project.public_or_visible_to_user(current_user)
+ end
+ end
+ end
+end
diff --git a/app/finders/packages/go/module_finder.rb b/app/finders/packages/go/module_finder.rb
new file mode 100644
index 00000000000..ed8bd5599d9
--- /dev/null
+++ b/app/finders/packages/go/module_finder.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Packages
+ module Go
+ class ModuleFinder
+ include Gitlab::Golang
+
+ attr_reader :project, :module_name
+
+ def initialize(project, module_name)
+ module_name = Pathname.new(module_name).cleanpath.to_s
+
+ @project = project
+ @module_name = module_name
+ end
+
+ def execute
+ return if @module_name.blank? || !@module_name.start_with?(local_module_prefix)
+
+ module_path = @module_name[local_module_prefix.length..].split('/')
+ project_path = project.full_path.split('/')
+ module_project_path = module_path.shift(project_path.length)
+ return unless module_project_path == project_path
+
+ Packages::Go::Module.new(@project, @module_name, module_path.join('/'))
+ end
+ end
+ end
+end
diff --git a/app/finders/packages/go/version_finder.rb b/app/finders/packages/go/version_finder.rb
new file mode 100644
index 00000000000..8e2fab8ba35
--- /dev/null
+++ b/app/finders/packages/go/version_finder.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Packages
+ module Go
+ class VersionFinder
+ include Gitlab::Golang
+
+ attr_reader :mod
+
+ def initialize(mod)
+ @mod = mod
+ end
+
+ def execute
+ @mod.project.repository.tags
+ .filter { |tag| semver_tag? tag }
+ .map { |tag| @mod.version_by(ref: tag) }
+ .filter { |ver| ver.valid? }
+ end
+
+ def find(target)
+ case target
+ when String
+ if pseudo_version? target
+ semver = parse_semver(target)
+ commit = pseudo_version_commit(@mod.project, semver)
+ Packages::Go::ModuleVersion.new(@mod, :pseudo, commit, name: target, semver: semver)
+ else
+ @mod.version_by(ref: target)
+ end
+
+ when Gitlab::Git::Ref
+ @mod.version_by(ref: target)
+
+ when ::Commit, Gitlab::Git::Commit
+ @mod.version_by(commit: target)
+
+ else
+ raise ArgumentError.new 'not a valid target'
+ end
+ end
+ end
+ end
+end
diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb
new file mode 100644
index 00000000000..ffc8c35fbcc
--- /dev/null
+++ b/app/finders/packages/group_packages_finder.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Packages
+ class GroupPackagesFinder
+ attr_reader :current_user, :group, :params
+
+ InvalidPackageTypeError = Class.new(StandardError)
+
+ def initialize(current_user, group, params = { exclude_subgroups: false, order_by: 'created_at', sort: 'asc' })
+ @current_user = current_user
+ @group = group
+ @params = params
+ end
+
+ def execute
+ return ::Packages::Package.none unless group
+
+ packages_for_group_projects
+ end
+
+ private
+
+ def packages_for_group_projects
+ packages = ::Packages::Package
+ .for_projects(group_projects_visible_to_current_user)
+ .processed
+ .has_version
+ .sort_by_attribute("#{params[:order_by]}_#{params[:sort]}")
+
+ packages = filter_by_package_type(packages)
+ packages = filter_by_package_name(packages)
+ packages
+ end
+
+ def group_projects_visible_to_current_user
+ ::Project
+ .in_namespace(groups)
+ .public_or_visible_to_user(current_user, Gitlab::Access::REPORTER)
+ .with_project_feature
+ .select { |project| Ability.allowed?(current_user, :read_package, project) }
+ end
+
+ def package_type
+ params[:package_type].presence
+ end
+
+ def groups
+ return [group] if exclude_subgroups?
+
+ group.self_and_descendants
+ end
+
+ def exclude_subgroups?
+ params[:exclude_subgroups]
+ end
+
+ def filter_by_package_type(packages)
+ return packages unless package_type
+ raise InvalidPackageTypeError unless Package.package_types.key?(package_type)
+
+ packages.with_package_type(package_type)
+ end
+
+ def filter_by_package_name(packages)
+ return packages unless params[:package_name].present?
+
+ packages.search_by_name(params[:package_name])
+ end
+ end
+end
diff --git a/app/finders/packages/maven/package_finder.rb b/app/finders/packages/maven/package_finder.rb
new file mode 100644
index 00000000000..775db12adb7
--- /dev/null
+++ b/app/finders/packages/maven/package_finder.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+module Packages
+ module Maven
+ class PackageFinder
+ attr_reader :path, :current_user, :project, :group
+
+ def initialize(path, current_user, project: nil, group: nil)
+ @path = path
+ @current_user = current_user
+ @project = project
+ @group = group
+ end
+
+ def execute
+ packages_with_path.last
+ end
+
+ def execute!
+ packages_with_path.last!
+ end
+
+ private
+
+ def base
+ if project
+ packages_for_a_single_project
+ elsif group
+ packages_for_multiple_projects
+ else
+ packages
+ end
+ end
+
+ def packages_with_path
+ base.only_maven_packages_with_path(path)
+ end
+
+ # Produces a query that returns all packages.
+ def packages
+ ::Packages::Package.all
+ end
+
+ # Produces a query that retrieves packages from a single project.
+ def packages_for_a_single_project
+ project.packages
+ end
+
+ # Produces a query that retrieves packages from multiple projects that
+ # the current user can view within a group.
+ def packages_for_multiple_projects
+ ::Packages::Package.for_projects(projects_visible_to_current_user)
+ end
+
+ # Returns the projects that the current user can view within a group.
+ def projects_visible_to_current_user
+ ::Project
+ .in_namespace(group.self_and_descendants.select(:id))
+ .public_or_visible_to_user(current_user)
+ end
+ end
+ end
+end
diff --git a/app/finders/packages/npm/package_finder.rb b/app/finders/packages/npm/package_finder.rb
new file mode 100644
index 00000000000..8599fd07e7f
--- /dev/null
+++ b/app/finders/packages/npm/package_finder.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+module Packages
+ module Npm
+ class PackageFinder
+ attr_reader :project, :package_name
+
+ delegate :find_by_version, to: :execute
+
+ def initialize(project, package_name)
+ @project = project
+ @package_name = package_name
+ end
+
+ def execute
+ packages
+ end
+
+ private
+
+ def packages
+ project.packages
+ .npm
+ .with_name(package_name)
+ .last_of_each_version
+ .preload_files
+ end
+ end
+ end
+end
diff --git a/app/finders/packages/nuget/package_finder.rb b/app/finders/packages/nuget/package_finder.rb
new file mode 100644
index 00000000000..e6fb6712d47
--- /dev/null
+++ b/app/finders/packages/nuget/package_finder.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+module Packages
+ module Nuget
+ class PackageFinder
+ MAX_PACKAGES_COUNT = 50
+
+ def initialize(project, package_name:, package_version: nil, limit: MAX_PACKAGES_COUNT)
+ @project = project
+ @package_name = package_name
+ @package_version = package_version
+ @limit = limit
+ end
+
+ def execute
+ packages.limit_recent(@limit)
+ end
+
+ private
+
+ def packages
+ result = @project.packages
+ .nuget
+ .has_version
+ .processed
+ .with_name_like(@package_name)
+ result = result.with_version(@package_version) if @package_version.present?
+ result
+ end
+ end
+ end
+end
diff --git a/app/finders/packages/package_file_finder.rb b/app/finders/packages/package_file_finder.rb
new file mode 100644
index 00000000000..d015f4adfa6
--- /dev/null
+++ b/app/finders/packages/package_file_finder.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+class Packages::PackageFileFinder
+ attr_reader :package, :file_name, :params
+
+ def initialize(package, file_name, params = {})
+ @package = package
+ @file_name = file_name
+ @params = params
+ end
+
+ def execute
+ package_files.last
+ end
+
+ def execute!
+ package_files.last!
+ end
+
+ private
+
+ def package_files
+ files = package.package_files
+
+ files = by_file_name(files)
+
+ files
+ end
+
+ def by_file_name(files)
+ if params[:with_file_name_like]
+ files.with_file_name_like(file_name)
+ else
+ files.with_file_name(file_name)
+ end
+ end
+end
diff --git a/app/finders/packages/package_finder.rb b/app/finders/packages/package_finder.rb
new file mode 100644
index 00000000000..0e911491da2
--- /dev/null
+++ b/app/finders/packages/package_finder.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+module Packages
+ class PackageFinder
+ def initialize(project, package_id)
+ @project = project
+ @package_id = package_id
+ end
+
+ def execute
+ @project
+ .packages
+ .processed
+ .find(@package_id)
+ end
+ end
+end
diff --git a/app/finders/packages/packages_finder.rb b/app/finders/packages/packages_finder.rb
new file mode 100644
index 00000000000..c533cb266a2
--- /dev/null
+++ b/app/finders/packages/packages_finder.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Packages
+ class PackagesFinder
+ attr_reader :params, :project
+
+ def initialize(project, params = {})
+ @project = project
+ @params = params
+
+ params[:order_by] ||= 'created_at'
+ params[:sort] ||= 'asc'
+ end
+
+ def execute
+ packages = project.packages.processed.has_version
+ packages = filter_by_package_type(packages)
+ packages = filter_by_package_name(packages)
+ packages = order_packages(packages)
+ packages
+ end
+
+ private
+
+ def filter_by_package_type(packages)
+ return packages unless params[:package_type]
+
+ packages.with_package_type(params[:package_type])
+ end
+
+ def filter_by_package_name(packages)
+ return packages unless params[:package_name]
+
+ packages.search_by_name(params[:package_name])
+ end
+
+ def order_packages(packages)
+ packages.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}")
+ end
+ end
+end
diff --git a/app/finders/packages/tags_finder.rb b/app/finders/packages/tags_finder.rb
new file mode 100644
index 00000000000..020b3d8072a
--- /dev/null
+++ b/app/finders/packages/tags_finder.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+class Packages::TagsFinder
+ attr_reader :project, :package_name, :params
+
+ delegate :find_by_name, to: :execute
+
+ def initialize(project, package_name, params = {})
+ @project = project
+ @package_name = package_name
+ @params = params
+ end
+
+ def execute
+ packages = project.packages
+ .with_name(package_name)
+ packages = packages.with_package_type(package_type) if package_type.present?
+
+ Packages::Tag.for_packages(packages)
+ end
+
+ private
+
+ def package_type
+ params[:package_type]
+ end
+end
diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb
index 7b15a3b0c10..e3d5f2ae8de 100644
--- a/app/finders/personal_access_tokens_finder.rb
+++ b/app/finders/personal_access_tokens_finder.rb
@@ -51,6 +51,8 @@ class PersonalAccessTokensFinder
tokens.active
when 'inactive'
tokens.inactive
+ when 'active_or_expired'
+ tokens.not_revoked.expired.or(tokens.active)
else
tokens
end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 8846ff54eb2..7c7cd87a7c1 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -23,6 +23,7 @@
# min_access_level: integer
# last_activity_after: datetime
# last_activity_before: datetime
+# repository_storage: string
#
class ProjectsFinder < UnionFinder
include CustomAttributesFilter
@@ -75,6 +76,7 @@ class ProjectsFinder < UnionFinder
collection = by_deleted_status(collection)
collection = by_last_activity_after(collection)
collection = by_last_activity_before(collection)
+ collection = by_repository_storage(collection)
collection
end
@@ -197,6 +199,14 @@ class ProjectsFinder < UnionFinder
end
end
+ def by_repository_storage(items)
+ if params[:repository_storage].present?
+ items.where(repository_storage: params[:repository_storage]) # rubocop: disable CodeReuse/ActiveRecord
+ else
+ items
+ end
+ end
+
def sort(items)
params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.projects_order_id_desc
end
diff --git a/app/finders/resource_milestone_event_finder.rb b/app/finders/resource_milestone_event_finder.rb
index 7af34f0a4bc..f3b779c8f77 100644
--- a/app/finders/resource_milestone_event_finder.rb
+++ b/app/finders/resource_milestone_event_finder.rb
@@ -1,69 +1,56 @@
# frozen_string_literal: true
class ResourceMilestoneEventFinder
- include FinderMethods
-
- MAX_PER_PAGE = 100
-
- attr_reader :params, :current_user, :eventable
-
- def initialize(current_user, eventable, params = {})
+ def initialize(current_user, eventable)
@current_user = current_user
@eventable = eventable
- @params = params
end
+ # Returns the ResourceMilestoneEvents of the eventable
+ # visible to the user.
+ #
+ # @return ResourceMilestoneEvent::ActiveRecord_AssociationRelation
def execute
- Kaminari.paginate_array(visible_events)
+ eventable.resource_milestone_events.include_relations
+ .where(milestone_id: readable_milestone_ids) # rubocop: disable CodeReuse/ActiveRecord
end
private
- def visible_events
- @visible_events ||= visible_to_user(events)
- end
+ attr_reader :current_user, :eventable
- def events
- @events ||= eventable.resource_milestone_events.include_relations.page(page).per(per_page)
- end
+ def readable_milestone_ids
+ readable_milestones = events_milestones.select do |milestone|
+ parent_availabilities[key_for_parent(milestone.parent)]
+ end
- def visible_to_user(events)
- events.select { |event| visible_for_user?(event) }
+ readable_milestones.map(&:id).uniq
end
- def visible_for_user?(event)
- milestone = event_milestones[event.milestone_id]
- return if milestone.blank?
+ # rubocop: disable CodeReuse/ActiveRecord
+ def events_milestones
+ @events_milestones ||= Milestone.where(id: unique_milestone_ids_from_events)
+ .includes(:project, :group)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
- parent = milestone.parent
- parent_availabilities[key_for_parent(parent)]
+ def relevant_milestone_parents
+ events_milestones.map(&:parent).uniq
end
def parent_availabilities
- @parent_availabilities ||= relevant_parents.to_h do |parent|
+ @parent_availabilities ||= relevant_milestone_parents.to_h do |parent|
[key_for_parent(parent), Ability.allowed?(current_user, :read_milestone, parent)]
end
end
- def key_for_parent(parent)
- "#{parent.class.name}_#{parent.id}"
- end
-
- def event_milestones
- @milestones ||= events.map(&:milestone).uniq.to_h do |milestone|
- [milestone.id, milestone]
- end
- end
-
- def relevant_parents
- @relevant_parents ||= event_milestones.map { |_id, milestone| milestone.parent }
+ # rubocop: disable CodeReuse/ActiveRecord
+ def unique_milestone_ids_from_events
+ eventable.resource_milestone_events.select(:milestone_id).distinct
end
+ # rubocop: enable CodeReuse/ActiveRecord
- def per_page
- [params[:per_page], MAX_PER_PAGE].compact.min
- end
-
- def page
- params[:page] || 1
+ def key_for_parent(parent)
+ "#{parent.class.name}_#{parent.id}"
end
end
diff --git a/app/finders/resource_state_event_finder.rb b/app/finders/resource_state_event_finder.rb
new file mode 100644
index 00000000000..7f4ac3332cd
--- /dev/null
+++ b/app/finders/resource_state_event_finder.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class ResourceStateEventFinder
+ include FinderMethods
+
+ def initialize(current_user, eventable)
+ @current_user = current_user
+ @eventable = eventable
+ end
+
+ def execute
+ return ResourceStateEvent.none unless can_read_eventable?
+
+ eventable.resource_state_events.includes(:user) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def can_read_eventable?
+ return unless eventable
+
+ Ability.allowed?(current_user, read_ability, eventable)
+ end
+
+ private
+
+ attr_reader :current_user, :eventable
+
+ def read_ability
+ :"read_#{eventable.class.to_ability_name}"
+ end
+end
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index 4f63810423b..941abb70400 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -43,7 +43,7 @@ class SnippetsFinder < UnionFinder
include Gitlab::Utils::StrongMemoize
attr_accessor :current_user, :params
- delegate :explore, :only_personal, :only_project, :scope, to: :params
+ delegate :explore, :only_personal, :only_project, :scope, :sort, to: :params
def initialize(current_user = nil, params = {})
@current_user = current_user
@@ -69,7 +69,9 @@ class SnippetsFinder < UnionFinder
items = init_collection
items = by_ids(items)
- items.with_optional_visibility(visibility_from_scope).fresh
+ items = items.with_optional_visibility(visibility_from_scope)
+
+ items.order_by(sort_param)
end
private
@@ -115,7 +117,7 @@ class SnippetsFinder < UnionFinder
queries << snippets_of_authorized_projects if current_user
end
- find_union(queries, Snippet)
+ prepared_union(queries)
end
def snippets_for_a_single_project
@@ -202,6 +204,21 @@ class SnippetsFinder < UnionFinder
params[:project].is_a?(Project) ? params[:project] : Project.find_by_id(params[:project])
end
end
+
+ def sort_param
+ sort.presence || 'id_desc'
+ end
+
+ def prepared_union(queries)
+ return Snippet.none if queries.empty?
+ return queries.first if queries.length == 1
+
+ # The queries are going to be part of a global `where`
+ # therefore we only need to retrieve the `id` column
+ # which will speed the query
+ queries.map! { |rel| rel.select(:id) }
+ Snippet.id_in(find_union(queries, Snippet))
+ end
end
SnippetsFinder.prepend_if_ee('EE::SnippetsFinder')
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index 672bbd52b07..a2054f73c9d 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -11,7 +11,7 @@
# author_id: integer
# project_id; integer
# state: 'pending' (default) or 'done'
-# type: 'Issue' or 'MergeRequest'
+# type: 'Issue' or 'MergeRequest' or ['Issue', 'MergeRequest']
#
class TodosFinder
@@ -40,13 +40,14 @@ class TodosFinder
def execute
return Todo.none if current_user.nil?
+ raise ArgumentError, invalid_type_message unless valid_types?
items = current_user.todos
items = by_action_id(items)
items = by_action(items)
items = by_author(items)
items = by_state(items)
- items = by_type(items)
+ items = by_types(items)
items = by_group(items)
# Filtering by project HAS TO be the last because we use
# the project IDs yielded by the todos query thus far
@@ -123,12 +124,16 @@ class TodosFinder
end
end
- def type?
- type.present? && self.class.todo_types.include?(type)
+ def types
+ @types ||= Array(params[:type]).reject(&:blank?)
end
- def type
- params[:type]
+ def valid_types?
+ types.all? { |type| self.class.todo_types.include?(type) }
+ end
+
+ def invalid_type_message
+ _("Unsupported todo type passed. Supported todo types are: %{todo_types}") % { todo_types: self.class.todo_types.to_a.join(', ') }
end
def sort(items)
@@ -193,9 +198,9 @@ class TodosFinder
items.with_states(params[:state])
end
- def by_type(items)
- if type?
- items.for_type(type)
+ def by_types(items)
+ if types.any?
+ items.for_type(types)
else
items
end
diff --git a/app/graphql/mutations/alert_management/alerts/todo/create.rb b/app/graphql/mutations/alert_management/alerts/todo/create.rb
new file mode 100644
index 00000000000..3dba96e43f1
--- /dev/null
+++ b/app/graphql/mutations/alert_management/alerts/todo/create.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ module Alerts
+ module Todo
+ class Create < Base
+ graphql_name 'AlertTodoCreate'
+
+ def resolve(args)
+ alert = authorized_find!(project_path: args[:project_path], iid: args[:iid])
+ result = ::AlertManagement::Alerts::Todo::CreateService.new(alert, current_user).execute
+
+ prepare_response(result)
+ end
+
+ private
+
+ def prepare_response(result)
+ {
+ alert: result.payload[:alert],
+ todo: result.payload[:todo],
+ errors: result.error? ? [result.message] : []
+ }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb
index 7fcca63db51..0de4b9409e4 100644
--- a/app/graphql/mutations/alert_management/base.rb
+++ b/app/graphql/mutations/alert_management/base.rb
@@ -18,6 +18,11 @@ module Mutations
null: true,
description: "The alert after mutation"
+ field :todo,
+ Types::TodoType,
+ null: true,
+ description: "The todo after mutation"
+
field :issue,
Types::IssueType,
null: true,
diff --git a/app/graphql/mutations/alert_management/update_alert_status.rb b/app/graphql/mutations/alert_management/update_alert_status.rb
index d820124d26f..ed61555fbd6 100644
--- a/app/graphql/mutations/alert_management/update_alert_status.rb
+++ b/app/graphql/mutations/alert_management/update_alert_status.rb
@@ -19,8 +19,8 @@ module Mutations
private
def update_status(alert, status)
- ::AlertManagement::UpdateAlertStatusService
- .new(alert, current_user, status)
+ ::AlertManagement::Alerts::UpdateService
+ .new(alert, current_user, status: status)
.execute
end
diff --git a/app/graphql/mutations/award_emojis/add.rb b/app/graphql/mutations/award_emojis/add.rb
index 85f3eb065bb..856fdd5fb14 100644
--- a/app/graphql/mutations/award_emojis/add.rb
+++ b/app/graphql/mutations/award_emojis/add.rb
@@ -3,7 +3,7 @@
module Mutations
module AwardEmojis
class Add < Base
- graphql_name 'AddAwardEmoji'
+ graphql_name 'AwardEmojiAdd'
def resolve(args)
awardable = authorized_find!(id: args[:awardable_id])
diff --git a/app/graphql/mutations/award_emojis/remove.rb b/app/graphql/mutations/award_emojis/remove.rb
index f8a3d0ce390..c654688c6dc 100644
--- a/app/graphql/mutations/award_emojis/remove.rb
+++ b/app/graphql/mutations/award_emojis/remove.rb
@@ -3,7 +3,7 @@
module Mutations
module AwardEmojis
class Remove < Base
- graphql_name 'RemoveAwardEmoji'
+ graphql_name 'AwardEmojiRemove'
def resolve(args)
awardable = authorized_find!(id: args[:awardable_id])
diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb
index 22eab4812a1..a7714e695d2 100644
--- a/app/graphql/mutations/award_emojis/toggle.rb
+++ b/app/graphql/mutations/award_emojis/toggle.rb
@@ -3,7 +3,7 @@
module Mutations
module AwardEmojis
class Toggle < Base
- graphql_name 'ToggleAwardEmoji'
+ graphql_name 'AwardEmojiToggle'
field :toggledOn, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates the status of the emoji. ' \
diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb
index 33f3f33a440..68e7853a9b1 100644
--- a/app/graphql/mutations/base_mutation.rb
+++ b/app/graphql/mutations/base_mutation.rb
@@ -7,6 +7,8 @@ module Mutations
ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance'
+ field_class ::Types::BaseField
+
field :errors, [GraphQL::STRING_TYPE],
null: false,
description: 'Errors encountered during execution of the mutation.'
diff --git a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
index 13a56f2e709..0fe2d09de6d 100644
--- a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
+++ b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb
@@ -9,30 +9,31 @@ module Mutations
end
def resolve_issuable(type:, parent_path:, iid:)
- parent = resolve_issuable_parent(type, parent_path)
- key = type == :merge_request ? :iids : :iid
- args = { key => iid.to_s }
+ parent = ::Gitlab::Graphql::Lazy.force(resolve_issuable_parent(type, parent_path))
+ return unless parent.present?
- resolver = issuable_resolver(type, parent, context)
- ready, early_return = resolver.ready?(**args)
-
- return early_return unless ready
-
- resolver.resolve(**args)
+ finder = issuable_finder(type, iids: [iid])
+ Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).find_all.first
end
private
- def issuable_resolver(type, parent, context)
- resolver_class = "Resolvers::#{type.to_s.classify.pluralize}Resolver".constantize
-
- resolver_class.single.new(object: parent, context: context, field: nil)
+ def issuable_finder(type, args)
+ case type
+ when :merge_request
+ MergeRequestsFinder.new(current_user, args)
+ when :issue
+ IssuesFinder.new(current_user, args)
+ else
+ raise "Unsupported type: #{type}"
+ end
end
def resolve_issuable_parent(type, parent_path)
+ return unless parent_path.present?
return unless type == :issue || type == :merge_request
- resolve_project(full_path: parent_path) if parent_path.present?
+ resolve_project(full_path: parent_path)
end
end
end
diff --git a/app/graphql/mutations/container_expiration_policies/update.rb b/app/graphql/mutations/container_expiration_policies/update.rb
index c210571c6ca..4bff04bb705 100644
--- a/app/graphql/mutations/container_expiration_policies/update.rb
+++ b/app/graphql/mutations/container_expiration_policies/update.rb
@@ -34,6 +34,16 @@ module Mutations
required: false,
description: copy_field_description(Types::ContainerExpirationPolicyType, :keep_n)
+ argument :name_regex,
+ Types::UntrustedRegexp,
+ required: false,
+ description: copy_field_description(Types::ContainerExpirationPolicyType, :name_regex)
+
+ argument :name_regex_keep,
+ Types::UntrustedRegexp,
+ required: false,
+ description: copy_field_description(Types::ContainerExpirationPolicyType, :name_regex_keep)
+
field :container_expiration_policy,
Types::ContainerExpirationPolicyType,
null: true,
diff --git a/app/graphql/mutations/issues/set_locked.rb b/app/graphql/mutations/issues/set_locked.rb
new file mode 100644
index 00000000000..63a8483067a
--- /dev/null
+++ b/app/graphql/mutations/issues/set_locked.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Issues
+ class SetLocked < Base
+ graphql_name 'IssueSetLocked'
+
+ argument :locked,
+ GraphQL::BOOLEAN_TYPE,
+ required: true,
+ description: 'Whether or not to lock discussion on the issue'
+
+ def resolve(project_path:, iid:, locked:)
+ issue = authorized_find!(project_path: project_path, iid: iid)
+
+ ::Issues::UpdateService.new(issue.project, current_user, discussion_locked: locked)
+ .execute(issue)
+
+ {
+ issue: issue,
+ errors: errors_on_object(issue)
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/jira_import/start.rb b/app/graphql/mutations/jira_import/start.rb
index 3df26d33711..eda28059272 100644
--- a/app/graphql/mutations/jira_import/start.rb
+++ b/app/graphql/mutations/jira_import/start.rb
@@ -21,12 +21,17 @@ module Mutations
argument :jira_project_name, GraphQL::STRING_TYPE,
required: false,
description: 'Project name of the importer Jira project'
+ argument :users_mapping,
+ [Types::JiraUsersMappingInputType],
+ required: false,
+ description: 'The mapping of Jira to GitLab users'
- def resolve(project_path:, jira_project_key:)
+ def resolve(project_path:, jira_project_key:, users_mapping:)
project = authorized_find!(full_path: project_path)
+ mapping = users_mapping.to_ary.map { |map| map.to_hash }
service_response = ::JiraImport::StartImportService
- .new(context[:current_user], project, jira_project_key)
+ .new(context[:current_user], project, jira_project_key, mapping)
.execute
jira_import = service_response.success? ? service_response.payload[:import_data] : nil
diff --git a/app/graphql/mutations/merge_requests/update.rb b/app/graphql/mutations/merge_requests/update.rb
new file mode 100644
index 00000000000..b583fdfca9b
--- /dev/null
+++ b/app/graphql/mutations/merge_requests/update.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Mutations
+ module MergeRequests
+ class Update < Base
+ graphql_name 'MergeRequestUpdate'
+
+ description 'Update attributes of a merge request'
+
+ argument :title, GraphQL::STRING_TYPE,
+ required: false,
+ description: copy_field_description(Types::MergeRequestType, :title)
+
+ argument :target_branch, GraphQL::STRING_TYPE,
+ required: false,
+ description: copy_field_description(Types::MergeRequestType, :target_branch)
+
+ argument :description, GraphQL::STRING_TYPE,
+ required: false,
+ description: copy_field_description(Types::MergeRequestType, :description)
+
+ def resolve(args)
+ merge_request = authorized_find!(args.slice(:project_path, :iid))
+ attributes = args.slice(:title, :description, :target_branch).compact
+
+ ::MergeRequests::UpdateService
+ .new(merge_request.project, current_user, attributes)
+ .execute(merge_request)
+
+ errors = errors_on_object(merge_request)
+
+ {
+ merge_request: merge_request.reset,
+ errors: errors
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/notes/create/base.rb b/app/graphql/mutations/notes/create/base.rb
index cf9f74a63d8..f081eac368e 100644
--- a/app/graphql/mutations/notes/create/base.rb
+++ b/app/graphql/mutations/notes/create/base.rb
@@ -18,6 +18,11 @@ module Mutations
required: true,
description: copy_field_description(Types::Notes::NoteType, :body)
+ argument :confidential,
+ GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: 'The confidentiality flag of a note. Default is false.'
+
def resolve(args)
noteable = authorized_find!(id: args[:noteable_id])
@@ -40,7 +45,8 @@ module Mutations
def create_note_params(noteable, args)
{
noteable: noteable,
- note: args[:body]
+ note: args[:body],
+ confidential: args[:confidential]
}
end
end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index e1022358c09..89c21486a74 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -21,7 +21,7 @@ module Mutations
description: 'File name of the snippet'
argument :content, GraphQL::STRING_TYPE,
- required: true,
+ required: false,
description: 'Content of the snippet'
argument :description, GraphQL::STRING_TYPE,
@@ -40,6 +40,10 @@ module Mutations
required: false,
description: 'The paths to files uploaded in the snippet description'
+ argument :files, [Types::Snippets::FileInputType],
+ description: "The snippet files to create",
+ required: false
+
def resolve(args)
project_path = args.delete(:project_path)
@@ -49,13 +53,9 @@ module Mutations
raise_resource_not_available_error!
end
- # We need to rename `uploaded_files` into `files` because
- # it's the expected key param
- args[:files] = args.delete(:uploaded_files)
-
service_response = ::Snippets::CreateService.new(project,
- context[:current_user],
- args).execute
+ context[:current_user],
+ create_params(args)).execute
snippet = service_response.payload[:snippet]
@@ -82,6 +82,18 @@ module Mutations
def can_create_personal_snippet?
Ability.allowed?(context[:current_user], :create_snippet)
end
+
+ def create_params(args)
+ args.tap do |create_args|
+ # We need to rename `files` into `snippet_actions` because
+ # it's the expected key param
+ create_args[:snippet_actions] = create_args.delete(:files)&.map(&:to_h)
+
+ # We need to rename `uploaded_files` into `files` because
+ # it's the expected key param
+ create_args[:files] = create_args.delete(:uploaded_files)
+ end
+ end
end
end
end
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index b6bdcb9b67b..8890158b0df 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -30,12 +30,16 @@ module Mutations
description: 'The visibility level of the snippet',
required: false
+ argument :files, [Types::Snippets::FileInputType],
+ description: 'The snippet files to update',
+ required: false
+
def resolve(args)
snippet = authorized_find!(id: args.delete(:id))
result = ::Snippets::UpdateService.new(snippet.project,
- context[:current_user],
- args).execute(snippet)
+ context[:current_user],
+ update_params(args)).execute(snippet)
snippet = result.payload[:snippet]
{
@@ -47,7 +51,15 @@ module Mutations
private
def ability_name
- "update"
+ 'update'
+ end
+
+ def update_params(args)
+ args.tap do |update_args|
+ # We need to rename `files` into `snippet_actions` because
+ # it's the expected key param
+ update_args[:snippet_actions] = update_args.delete(:files)&.map(&:to_h)
+ end
end
end
end
diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb
index d30d1bcbcf0..8b53658ddd5 100644
--- a/app/graphql/mutations/todos/mark_all_done.rb
+++ b/app/graphql/mutations/todos/mark_all_done.rb
@@ -10,8 +10,13 @@ module Mutations
field :updated_ids,
[GraphQL::ID_TYPE],
null: false,
+ deprecated: { reason: 'Use todos', milestone: '13.2' },
description: 'Ids of the updated todos'
+ field :todos, [::Types::TodoType],
+ null: false,
+ description: 'Updated todos'
+
def resolve
authorize!(current_user)
@@ -19,6 +24,7 @@ module Mutations
{
updated_ids: map_to_global_ids(updated_ids),
+ todos: Todo.id_in(updated_ids),
errors: []
}
end
diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb
index e95651b232f..c5e2750768c 100644
--- a/app/graphql/mutations/todos/restore_many.rb
+++ b/app/graphql/mutations/todos/restore_many.rb
@@ -14,7 +14,12 @@ module Mutations
field :updated_ids, [GraphQL::ID_TYPE],
null: false,
- description: 'The ids of the updated todo items'
+ description: 'The ids of the updated todo items',
+ deprecated: { reason: 'Use todos', milestone: '13.2' }
+
+ field :todos, [::Types::TodoType],
+ null: false,
+ description: 'Updated todos'
def resolve(ids:)
check_update_amount_limit!(ids)
@@ -24,6 +29,7 @@ module Mutations
{
updated_ids: gids_of(updated_ids),
+ todos: Todo.id_in(updated_ids),
errors: errors_on_objects(todos)
}
end
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 7daff68c069..791c6eab42f 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -83,5 +83,10 @@ module Resolvers
def current_user
context[:current_user]
end
+
+ # Overridden in sub-classes (see .single, .last)
+ def select_result(results)
+ results
+ end
end
end
diff --git a/app/graphql/resolvers/ci_configuration/sast_resolver.rb b/app/graphql/resolvers/ci_configuration/sast_resolver.rb
new file mode 100644
index 00000000000..e8c42076ea2
--- /dev/null
+++ b/app/graphql/resolvers/ci_configuration/sast_resolver.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require "json"
+
+module Resolvers
+ module CiConfiguration
+ class SastResolver < BaseResolver
+ SAST_UI_SCHEMA_PATH = 'app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json'
+
+ type ::Types::CiConfiguration::Sast::Type, null: true
+
+ def resolve(**args)
+ Gitlab::Json.parse(File.read(Rails.root.join(SAST_UI_SCHEMA_PATH)))
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
index a2140728a27..7ed88be52b9 100644
--- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb
+++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
@@ -11,16 +11,10 @@ module ResolvesMergeRequests
end
def resolve_with_lookahead(**args)
- args[:iids] = Array.wrap(args[:iids]) if args[:iids]
- args.compact!
+ mr_finder = MergeRequestsFinder.new(current_user, args.compact)
+ finder = Gitlab::Graphql::Loaders::IssuableLoader.new(project, mr_finder)
- if project && args.keys == [:iids]
- batch_load_merge_requests(args[:iids])
- else
- args[:project_id] ||= project
-
- apply_lookahead(MergeRequestsFinder.new(current_user, args).execute)
- end.then(&(single? ? :first : :itself))
+ select_result(finder.batching_find_all { |query| apply_lookahead(query) })
end
def ready?(**args)
@@ -35,22 +29,6 @@ module ResolvesMergeRequests
private
- def batch_load_merge_requests(iids)
- iids.map { |iid| batch_load(iid) }.select(&:itself) # .compact doesn't work on BatchLoader
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def batch_load(iid)
- BatchLoader::GraphQL.for(iid.to_s).batch(key: project) do |iids, loader, args|
- query = args[:key].merge_requests.where(iid: iids)
-
- apply_lookahead(query).each do |mr|
- loader.call(mr.iid.to_s, mr)
- end
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def unconditional_includes
[:target_project]
end
diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb
index 4e9a17f1e17..1b916a89796 100644
--- a/app/graphql/resolvers/environments_resolver.rb
+++ b/app/graphql/resolvers/environments_resolver.rb
@@ -8,7 +8,7 @@ module Resolvers
argument :search, GraphQL::STRING_TYPE,
required: false,
- description: 'Search query'
+ description: 'Search query for environment name'
argument :states, [GraphQL::STRING_TYPE],
required: false,
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index f103da07666..9d0535a208f 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -44,7 +44,7 @@ module Resolvers
description: 'Issues closed after this date'
argument :search, GraphQL::STRING_TYPE,
required: false,
- description: 'Search query for finding issues by title or description'
+ description: 'Search query for issue title or description'
argument :sort, Types::IssueSortEnum,
description: 'Sort issues by this criteria',
required: false,
@@ -63,18 +63,13 @@ module Resolvers
parent = object.respond_to?(:sync) ? object.sync : object
return Issue.none if parent.nil?
- if parent.is_a?(Group)
- args[:group_id] = parent.id
- else
- args[:project_id] = parent.id
- end
-
# Will need to be be made group & namespace aware with
# https://gitlab.com/gitlab-org/gitlab-foss/issues/54520
- args[:iids] ||= [args[:iid]].compact
- args[:attempt_project_search_optimizations] = args[:search].present?
+ args[:iids] ||= [args.delete(:iid)].compact if args[:iid]
+ args[:attempt_project_search_optimizations] = true if args[:search].present?
- issues = IssuesFinder.new(context[:current_user], args).execute
+ finder = IssuesFinder.new(current_user, args)
+ issues = Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all
if non_stable_cursor_sort?(args[:sort])
# Certain complex sorts are not supported by the stable cursor pagination yet.
@@ -97,3 +92,5 @@ module Resolvers
end
end
end
+
+Resolvers::IssuesResolver.prepend_if_ee('::EE::Resolvers::IssuesResolver')
diff --git a/app/graphql/resolvers/last_commit_resolver.rb b/app/graphql/resolvers/last_commit_resolver.rb
index 7a433d6556f..dd89c322617 100644
--- a/app/graphql/resolvers/last_commit_resolver.rb
+++ b/app/graphql/resolvers/last_commit_resolver.rb
@@ -9,7 +9,7 @@ module Resolvers
def resolve(**args)
# Ensure merge commits can be returned by sending nil to Gitaly instead of '/'
path = tree.path == '/' ? nil : tree.path
- commit = Gitlab::Git::Commit.last_for_path(tree.repository, tree.sha, path)
+ commit = Gitlab::Git::Commit.last_for_path(tree.repository, tree.sha, path, literal_pathspec: true)
::Commit.new(commit, tree.repository.project) if commit
end
diff --git a/app/graphql/resolvers/milestone_resolver.rb b/app/graphql/resolvers/milestone_resolver.rb
index 6c6513e0ee4..bcfbc63c31f 100644
--- a/app/graphql/resolvers/milestone_resolver.rb
+++ b/app/graphql/resolvers/milestone_resolver.rb
@@ -52,7 +52,7 @@ module Resolvers
end
def group_parameters(args)
- return { group_ids: parent.id } unless include_descendants?(args)
+ return { group_ids: parent.id } unless args[:include_descendants].present?
{
group_ids: parent.self_and_descendants.public_or_visible_to_user(current_user).select(:id),
@@ -60,10 +60,6 @@ module Resolvers
}
end
- def include_descendants?(args)
- args[:include_descendants].present? && Feature.enabled?(:group_milestone_descendants, parent)
- end
-
def group_projects
GroupProjectsFinder.new(
group: parent,
diff --git a/app/graphql/resolvers/packages_resolver.rb b/app/graphql/resolvers/packages_resolver.rb
new file mode 100644
index 00000000000..519fb87183e
--- /dev/null
+++ b/app/graphql/resolvers/packages_resolver.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class PackagesResolver < BaseResolver
+ type Types::PackageType, null: true
+
+ def resolve(**args)
+ return unless packages_available?
+
+ ::Packages::PackagesFinder.new(object).execute
+ end
+
+ private
+
+ def packages_available?
+ ::Gitlab.config.packages.enabled
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects/jira_projects_resolver.rb b/app/graphql/resolvers/projects/jira_projects_resolver.rb
index a8c3768df41..2dc712128cc 100644
--- a/app/graphql/resolvers/projects/jira_projects_resolver.rb
+++ b/app/graphql/resolvers/projects/jira_projects_resolver.rb
@@ -13,11 +13,10 @@ module Resolvers
def resolve(name: nil, **args)
authorize!(project)
- response, start_cursor, end_cursor = jira_projects(name: name, **compute_pagination_params(args))
- end_cursor = nil if !!response.payload[:is_last]
+ response = jira_projects(name: name)
if response.success?
- Gitlab::Graphql::ExternallyPaginatedArray.new(start_cursor, end_cursor, *response.payload[:projects])
+ response.payload[:projects]
else
raise Gitlab::Graphql::Errors::BaseError, response.message
end
@@ -35,41 +34,10 @@ module Resolvers
jira_service&.project
end
- def compute_pagination_params(params)
- after_cursor = Base64.decode64(params[:after].to_s)
- before_cursor = Base64.decode64(params[:before].to_s)
+ def jira_projects(name:)
+ args = { query: name }.compact
- # differentiate between 0 cursor and nil or invalid cursor that decodes into zero.
- after_index = after_cursor.to_i == 0 && after_cursor != "0" ? nil : after_cursor.to_i
- before_index = before_cursor.to_i == 0 && before_cursor != "0" ? nil : before_cursor.to_i
-
- if after_index.present? && before_index.present?
- if after_index >= before_index
- { start_at: 0, limit: 0 }
- else
- { start_at: after_index + 1, limit: before_index - after_index - 1 }
- end
- elsif after_index.present?
- { start_at: after_index + 1, limit: nil }
- elsif before_index.present?
- { start_at: 0, limit: before_index - 1 }
- else
- { start_at: 0, limit: nil }
- end
- end
-
- def jira_projects(name:, start_at:, limit:)
- args = { query: name, start_at: start_at, limit: limit }.compact
-
- response = Jira::Requests::Projects.new(project.jira_service, args).execute
-
- return [response, nil, nil] if response.error?
-
- projects = response.payload[:projects]
- start_cursor = start_at == 0 ? nil : Base64.encode64((start_at - 1).to_s)
- end_cursor = Base64.encode64((start_at + projects.size - 1).to_s)
-
- [response, start_cursor, end_cursor]
+ Jira::Requests::Projects::ListService.new(project.jira_service, args).execute
end
end
end
diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb
index 068546cd39f..f75f591b381 100644
--- a/app/graphql/resolvers/projects_resolver.rb
+++ b/app/graphql/resolvers/projects_resolver.rb
@@ -10,7 +10,7 @@ module Resolvers
argument :search, GraphQL::STRING_TYPE,
required: false,
- description: 'Search criteria'
+ description: 'Search query for project name, path, or description'
def resolve(**args)
ProjectsFinder
diff --git a/app/graphql/resolvers/release_resolver.rb b/app/graphql/resolvers/release_resolver.rb
index 9bae8b8cd13..1edcc8c70b5 100644
--- a/app/graphql/resolvers/release_resolver.rb
+++ b/app/graphql/resolvers/release_resolver.rb
@@ -15,6 +15,8 @@ module Resolvers
end
def resolve(tag_name:)
+ return unless Feature.enabled?(:graphql_release_data, project, default_enabled: true)
+
ReleasesFinder.new(
project,
current_user,
diff --git a/app/graphql/resolvers/releases_resolver.rb b/app/graphql/resolvers/releases_resolver.rb
index b2afbb92684..85892c2abeb 100644
--- a/app/graphql/resolvers/releases_resolver.rb
+++ b/app/graphql/resolvers/releases_resolver.rb
@@ -12,6 +12,8 @@ module Resolvers
end
def resolve(**args)
+ return unless Feature.enabled?(:graphql_release_data, project, default_enabled: true)
+
ReleasesFinder.new(
project,
current_user
diff --git a/app/graphql/types/alert_management/alert_sort_enum.rb b/app/graphql/types/alert_management/alert_sort_enum.rb
index 3faac9ce53c..51e7bef0a7f 100644
--- a/app/graphql/types/alert_management/alert_sort_enum.rb
+++ b/app/graphql/types/alert_management/alert_sort_enum.rb
@@ -16,10 +16,10 @@ module Types
value 'UPDATED_TIME_DESC', 'Created time by descending order', value: :updated_at_desc
value 'EVENT_COUNT_ASC', 'Events count by ascending order', value: :event_count_asc
value 'EVENT_COUNT_DESC', 'Events count by descending order', value: :event_count_desc
- value 'SEVERITY_ASC', 'Severity by ascending order', value: :severity_asc
- value 'SEVERITY_DESC', 'Severity by descending order', value: :severity_desc
- value 'STATUS_ASC', 'Status by ascending order', value: :status_asc
- value 'STATUS_DESC', 'Status by descending order', value: :status_desc
+ value 'SEVERITY_ASC', 'Severity from less critical to more critical', value: :severity_asc
+ value 'SEVERITY_DESC', 'Severity from more critical to less critical', value: :severity_desc
+ value 'STATUS_ASC', 'Status by order: Ignored > Resolved > Acknowledged > Triggered', value: :status_asc
+ value 'STATUS_DESC', 'Status by order: Triggered > Acknowledged > Resolved > Ignored', value: :status_desc
end
end
end
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
index 8215ccb152c..089d2426158 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -91,6 +91,12 @@ module Types
null: true,
description: 'Assignees of the alert'
+ field :metrics_dashboard_url,
+ GraphQL::STRING_TYPE,
+ null: true,
+ description: 'URL for metrics embed for the alert',
+ resolve: -> (alert, _args, _context) { alert.present.metrics_dashboard_url }
+
def notes
object.ordered_notes
end
diff --git a/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb b/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb
new file mode 100644
index 00000000000..ccd1c7dd0eb
--- /dev/null
+++ b/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ module CiConfiguration
+ module Sast
+ # rubocop: disable Graphql/AuthorizeTypes
+ class AnalyzersEntityType < BaseObject
+ graphql_name 'SastCiConfigurationAnalyzersEntity'
+ description 'Represents an analyzer entity in SAST CI configuration'
+
+ field :name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the analyzer.'
+
+ field :label, GraphQL::STRING_TYPE, null: true,
+ description: 'Analyzer label used in the config UI.'
+
+ field :enabled, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates whether an analyzer is enabled.'
+
+ field :description, GraphQL::STRING_TYPE, null: true,
+ description: 'Analyzer description that is displayed on the form.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci_configuration/sast/entity_type.rb b/app/graphql/types/ci_configuration/sast/entity_type.rb
new file mode 100644
index 00000000000..b61b582ad20
--- /dev/null
+++ b/app/graphql/types/ci_configuration/sast/entity_type.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Types
+ module CiConfiguration
+ module Sast
+ # rubocop: disable Graphql/AuthorizeTypes
+ class EntityType < BaseObject
+ graphql_name 'SastCiConfigurationEntity'
+ description 'Represents an entity in SAST CI configuration'
+
+ field :field, GraphQL::STRING_TYPE, null: true,
+ description: 'CI keyword of entity.'
+
+ field :label, GraphQL::STRING_TYPE, null: true,
+ description: 'Label for entity used in the form.'
+
+ field :type, GraphQL::STRING_TYPE, null: true,
+ description: 'Type of the field value.'
+
+ field :options, ::Types::CiConfiguration::Sast::OptionsEntityType.connection_type, null: true,
+ description: 'Different possible values of the field.'
+
+ field :default_value, GraphQL::STRING_TYPE, null: true,
+ description: 'Default value that is used if value is empty.'
+
+ field :description, GraphQL::STRING_TYPE, null: true,
+ description: 'Entity description that is displayed on the form.'
+
+ field :value, GraphQL::STRING_TYPE, null: true,
+ description: 'Current value of the entity.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci_configuration/sast/options_entity_type.rb b/app/graphql/types/ci_configuration/sast/options_entity_type.rb
new file mode 100644
index 00000000000..86d104a7fda
--- /dev/null
+++ b/app/graphql/types/ci_configuration/sast/options_entity_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ module CiConfiguration
+ module Sast
+ # rubocop: disable Graphql/AuthorizeTypes
+ class OptionsEntityType < BaseObject
+ graphql_name 'SastCiConfigurationOptionsEntity'
+ description 'Represents an entity for options in SAST CI configuration'
+
+ field :label, GraphQL::STRING_TYPE, null: true,
+ description: 'Label of option entity.'
+
+ field :value, GraphQL::STRING_TYPE, null: true,
+ description: 'Value of option entity.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci_configuration/sast/type.rb b/app/graphql/types/ci_configuration/sast/type.rb
new file mode 100644
index 00000000000..35d11584ac7
--- /dev/null
+++ b/app/graphql/types/ci_configuration/sast/type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module CiConfiguration
+ module Sast
+ # rubocop: disable Graphql/AuthorizeTypes
+ class Type < BaseObject
+ graphql_name 'SastCiConfiguration'
+ description 'Represents a CI configuration of SAST'
+
+ field :global, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true,
+ description: 'List of global entities related to SAST configuration.'
+
+ field :pipeline, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true,
+ description: 'List of pipeline entities related to SAST configuration.'
+
+ field :analyzers, ::Types::CiConfiguration::Sast::AnalyzersEntityType.connection_type, null: true,
+ description: 'List of analyzers entities attached to SAST configuration.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/container_expiration_policy_type.rb b/app/graphql/types/container_expiration_policy_type.rb
index da53dbcbd39..f19aa964377 100644
--- a/app/graphql/types/container_expiration_policy_type.rb
+++ b/app/graphql/types/container_expiration_policy_type.rb
@@ -14,8 +14,8 @@ module Types
field :older_than, Types::ContainerExpirationPolicyOlderThanEnum, null: true, description: 'Tags older that this will expire'
field :cadence, Types::ContainerExpirationPolicyCadenceEnum, null: false, description: 'This container expiration policy schedule'
field :keep_n, Types::ContainerExpirationPolicyKeepEnum, null: true, description: 'Number of tags to retain'
- field :name_regex, GraphQL::STRING_TYPE, null: true, description: 'Tags with names matching this regex pattern will expire'
- field :name_regex_keep, GraphQL::STRING_TYPE, null: true, description: 'Tags with names matching this regex pattern will be preserved'
+ field :name_regex, Types::UntrustedRegexp, null: true, description: 'Tags with names matching this regex pattern will expire'
+ field :name_regex_keep, Types::UntrustedRegexp, null: true, description: 'Tags with names matching this regex pattern will be preserved'
field :next_run_at, Types::TimeType, null: true, description: 'Next time that this container expiration policy will get executed'
end
end
diff --git a/app/graphql/types/deprecated_mutations.rb b/app/graphql/types/deprecated_mutations.rb
new file mode 100644
index 00000000000..a4336fa3ef3
--- /dev/null
+++ b/app/graphql/types/deprecated_mutations.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ module DeprecatedMutations
+ extend ActiveSupport::Concern
+
+ prepended do
+ mount_aliased_mutation 'AddAwardEmoji',
+ Mutations::AwardEmojis::Add,
+ deprecated: { reason: 'Use awardEmojiAdd', milestone: '13.2' }
+ mount_aliased_mutation 'RemoveAwardEmoji',
+ Mutations::AwardEmojis::Remove,
+ deprecated: { reason: 'Use awardEmojiRemove', milestone: '13.2' }
+ mount_aliased_mutation 'ToggleAwardEmoji',
+ Mutations::AwardEmojis::Toggle,
+ deprecated: { reason: 'Use awardEmojiToggle', milestone: '13.2' }
+ end
+ end
+end
diff --git a/app/graphql/types/diff_stats_summary_type.rb b/app/graphql/types/diff_stats_summary_type.rb
new file mode 100644
index 00000000000..956400fd21b
--- /dev/null
+++ b/app/graphql/types/diff_stats_summary_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ # Types that use DiffStatsType should have their own authorization
+ class DiffStatsSummaryType < BaseObject
+ graphql_name 'DiffStatsSummary'
+
+ description 'Aggregated summary of changes'
+
+ field :additions, GraphQL::INT_TYPE, null: false,
+ description: 'Number of lines added'
+ field :deletions, GraphQL::INT_TYPE, null: false,
+ description: 'Number of lines deleted'
+ field :changes, GraphQL::INT_TYPE, null: false,
+ description: 'Number of lines changed'
+ field :file_count, GraphQL::INT_TYPE, null: false,
+ description: 'Number of files changed'
+
+ def changes
+ object[:additions] + object[:deletions]
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/diff_stats_type.rb b/app/graphql/types/diff_stats_type.rb
new file mode 100644
index 00000000000..6c79a4c389d
--- /dev/null
+++ b/app/graphql/types/diff_stats_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ # Types that use DiffStatsType should have their own authorization
+ class DiffStatsType < BaseObject
+ graphql_name 'DiffStats'
+
+ description 'Changes to a single file'
+
+ field :path, GraphQL::STRING_TYPE, null: false,
+ description: 'File path, relative to repository root'
+ field :additions, GraphQL::INT_TYPE, null: false,
+ description: 'Number of lines added to this file'
+ field :deletions, GraphQL::INT_TYPE, null: false,
+ description: 'Number of lines deleted from this file'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
index 124398f28e7..8bdd8afcbff 100644
--- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
+++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
@@ -76,9 +76,15 @@ module Types
description: 'Commit the error was last seen'
field :first_release_short_version, GraphQL::STRING_TYPE,
null: true,
- description: 'Release version the error was first seen'
+ description: 'Release short version the error was first seen'
field :last_release_short_version, GraphQL::STRING_TYPE,
null: true,
+ description: 'Release short version the error was last seen'
+ field :first_release_version, GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Release version the error was first seen'
+ field :last_release_version, GraphQL::STRING_TYPE,
+ null: true,
description: 'Release version the error was last seen'
field :gitlab_commit, GraphQL::STRING_TYPE,
null: true,
diff --git a/app/graphql/types/error_tracking/sentry_error_collection_type.rb b/app/graphql/types/error_tracking/sentry_error_collection_type.rb
index 121146133cb..f423fcb1b9f 100644
--- a/app/graphql/types/error_tracking/sentry_error_collection_type.rb
+++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb
@@ -17,7 +17,7 @@ module Types
resolver: Resolvers::ErrorTracking::SentryErrorsResolver do
argument :search_term,
String,
- description: 'Search term for the Sentry error.',
+ description: 'Search query for the Sentry error details',
required: false
argument :sort,
String,
diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb
new file mode 100644
index 00000000000..a3964ba83e1
--- /dev/null
+++ b/app/graphql/types/global_id_type.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module Types
+ class GlobalIDType < BaseScalar
+ graphql_name 'GlobalID'
+ description 'A global identifier'
+
+ # @param value [GID]
+ # @return [String]
+ def self.coerce_result(value, _ctx)
+ ::Gitlab::GlobalId.as_global_id(value).to_s
+ end
+
+ # @param value [String]
+ # @return [GID]
+ def self.coerce_input(value, _ctx)
+ gid = GlobalID.parse(value)
+ raise GraphQL::CoercionError, "#{value.inspect} is not a valid Global ID" if gid.nil?
+ raise GraphQL::CoercionError, "#{value.inspect} is not a Gitlab Global ID" unless gid.app == GlobalID.app
+
+ gid
+ end
+
+ # Construct a restricted type, that can only be inhabited by an ID of
+ # a given model class.
+ def self.[](model_class)
+ @id_types ||= {}
+
+ @id_types[model_class] ||= Class.new(self) do
+ graphql_name "#{model_class.name.gsub(/::/, '')}ID"
+ description "Identifier of #{model_class.name}"
+
+ self.define_singleton_method(:to_s) do
+ graphql_name
+ end
+
+ self.define_singleton_method(:inspect) do
+ graphql_name
+ end
+
+ self.define_singleton_method(:coerce_result) do |gid, ctx|
+ global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_class.name)
+
+ if suitable?(global_id)
+ global_id.to_s
+ else
+ raise GraphQL::CoercionError, "Expected a #{model_class.name} ID, got #{global_id}"
+ end
+ end
+
+ self.define_singleton_method(:suitable?) do |gid|
+ gid&.model_class&.ancestors&.include?(model_class)
+ end
+
+ self.define_singleton_method(:coerce_input) do |string, ctx|
+ gid = super(string, ctx)
+ raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of #{model_class.name}" unless suitable?(gid)
+
+ gid
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/issue_connection_type.rb b/app/graphql/types/issue_connection_type.rb
new file mode 100644
index 00000000000..beed392f01a
--- /dev/null
+++ b/app/graphql/types/issue_connection_type.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class IssueConnectionType < GraphQL::Types::Relay::BaseConnection
+ field :count, Integer, null: false,
+ description: 'Total count of collection'
+
+ def count
+ object.items.size
+ end
+ end
+end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 73219ca9e1e..9baa0018999 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -4,6 +4,8 @@ module Types
class IssueType < BaseObject
graphql_name 'Issue'
+ connection_type_class(Types::IssueConnectionType)
+
implements(Types::Notes::NoteableType)
authorize :read_issue
@@ -12,6 +14,8 @@ module Types
present_using IssuePresenter
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: "ID of the issue"
field :iid, GraphQL::ID_TYPE, null: false,
description: "Internal ID of the issue"
field :title, GraphQL::STRING_TYPE, null: false,
diff --git a/app/graphql/types/jira_user_type.rb b/app/graphql/types/jira_user_type.rb
index 8aa21ce669b..999526a920e 100644
--- a/app/graphql/types/jira_user_type.rb
+++ b/app/graphql/types/jira_user_type.rb
@@ -13,7 +13,11 @@ module Types
field :jira_email, GraphQL::STRING_TYPE, null: true,
description: 'Email of the Jira user, returned only for users with public emails'
field :gitlab_id, GraphQL::INT_TYPE, null: true,
- description: 'Id of the matched GitLab user'
+ description: 'ID of the matched GitLab user'
+ field :gitlab_username, GraphQL::STRING_TYPE, null: true,
+ description: 'Username of the matched GitLab user'
+ field :gitlab_name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the matched GitLab user'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/jira_users_mapping_input_type.rb b/app/graphql/types/jira_users_mapping_input_type.rb
new file mode 100644
index 00000000000..61cf1474493
--- /dev/null
+++ b/app/graphql/types/jira_users_mapping_input_type.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class JiraUsersMappingInputType < BaseInputObject
+ graphql_name 'JiraUsersMappingInputType'
+
+ argument :jira_account_id,
+ GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Jira account id of the user'
+ argument :gitlab_id,
+ GraphQL::INT_TYPE,
+ required: false,
+ description: 'Id of the GitLab user'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index cb4ff7ea0c5..c194b467363 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -54,6 +54,13 @@ module Types
description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS)'
field :diff_head_sha, GraphQL::STRING_TYPE, null: true,
description: 'Diff head SHA of the merge request'
+ field :diff_stats, [Types::DiffStatsType], null: true, calls_gitaly: true,
+ description: 'Details about which files were changed in this merge request' do
+ argument :path, GraphQL::STRING_TYPE, required: false, description: 'A specific file-path'
+ end
+
+ field :diff_stats_summary, Types::DiffStatsSummaryType, null: true, calls_gitaly: true,
+ description: 'Summary of which files were changed in this merge request'
field :merge_commit_sha, GraphQL::STRING_TYPE, null: true,
description: 'SHA of the merge request commit (set once merged)'
field :user_notes_count, GraphQL::INT_TYPE, null: true,
@@ -134,5 +141,24 @@ module Types
end
field :task_completion_status, Types::TaskCompletionStatus, null: false,
description: Types::TaskCompletionStatus.description
+
+ def diff_stats(path: nil)
+ stats = Array.wrap(object.diff_stats&.to_a)
+
+ if path.present?
+ stats.select { |s| s.path == path }
+ else
+ stats
+ end
+ end
+
+ def diff_stats_summary
+ nil_stats = { additions: 0, deletions: 0, file_count: 0 }
+ return nil_stats unless object.diff_stats.present?
+
+ object.diff_stats.each_with_object(nil_stats) do |status, hash|
+ hash.merge!(additions: status.additions, deletions: status.deletions, file_count: 1) { |_, x, y| x + y }
+ end
+ end
end
end
diff --git a/app/graphql/types/milestone_stats_type.rb b/app/graphql/types/milestone_stats_type.rb
new file mode 100644
index 00000000000..ef533af59e7
--- /dev/null
+++ b/app/graphql/types/milestone_stats_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ class MilestoneStatsType < BaseObject
+ graphql_name 'MilestoneStats'
+ description 'Contains statistics about a milestone'
+
+ authorize :read_milestone
+
+ field :total_issues_count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of issues associated with the milestone'
+
+ field :closed_issues_count, GraphQL::INT_TYPE, null: true,
+ description: 'Number of closed issues associated with the milestone'
+ end
+end
diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb
index 99bd6e819d6..ca606c9da44 100644
--- a/app/graphql/types/milestone_type.rb
+++ b/app/graphql/types/milestone_type.rb
@@ -9,6 +9,8 @@ module Types
authorize :read_milestone
+ alias_method :milestone, :object
+
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the milestone'
@@ -47,5 +49,14 @@ module Types
field :subgroup_milestone, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates if milestone is at subgroup level',
method: :subgroup_milestone?
+
+ field :stats, Types::MilestoneStatsType, null: true,
+ description: 'Milestone statistics'
+
+ def stats
+ return unless Feature.enabled?(:graphql_milestone_stats, milestone.project || milestone.group, default_enabled: true)
+
+ milestone
+ end
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 8874c56dfdb..49d51b626b2 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -10,6 +10,7 @@ module Types
mount_mutation Mutations::AlertManagement::CreateAlertIssue
mount_mutation Mutations::AlertManagement::UpdateAlertStatus
mount_mutation Mutations::AlertManagement::Alerts::SetAssignees
+ mount_mutation Mutations::AlertManagement::Alerts::Todo::Create
mount_mutation Mutations::AwardEmojis::Add
mount_mutation Mutations::AwardEmojis::Remove
mount_mutation Mutations::AwardEmojis::Toggle
@@ -17,9 +18,11 @@ module Types
mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::Discussions::ToggleResolve
mount_mutation Mutations::Issues::SetConfidential
+ mount_mutation Mutations::Issues::SetLocked
mount_mutation Mutations::Issues::SetDueDate
mount_mutation Mutations::Issues::Update
mount_mutation Mutations::MergeRequests::Create
+ mount_mutation Mutations::MergeRequests::Update
mount_mutation Mutations::MergeRequests::SetLabels
mount_mutation Mutations::MergeRequests::SetLocked
mount_mutation Mutations::MergeRequests::SetMilestone
@@ -56,4 +59,5 @@ module Types
end
end
+::Types::MutationType.prepend(::Types::DeprecatedMutations)
::Types::MutationType.prepend_if_ee('::EE::Types::MutationType')
diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb
index 1714284a5cf..fbdf049b755 100644
--- a/app/graphql/types/namespace_type.rb
+++ b/app/graphql/types/namespace_type.rb
@@ -38,3 +38,5 @@ module Types
resolver: ::Resolvers::NamespaceProjectsResolver
end
end
+
+Types::NamespaceType.prepend_if_ee('EE::Types::NamespaceType')
diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb
index 8755b4ccad5..5d41f0032bd 100644
--- a/app/graphql/types/notes/note_type.rb
+++ b/app/graphql/types/notes/note_type.rb
@@ -27,6 +27,8 @@ module Types
field :system, GraphQL::BOOLEAN_TYPE,
null: false,
description: 'Indicates whether this note was created by the system or by a user'
+ field :system_note_icon_name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the icon corresponding to a system note'
field :body, GraphQL::STRING_TYPE,
null: false,
@@ -46,6 +48,10 @@ module Types
field :confidential, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if this note is confidential',
method: :confidential?
+
+ def system_note_icon_name
+ SystemNoteHelper.system_note_icon_name(object) if object.system?
+ end
end
end
end
diff --git a/app/graphql/types/package_type.rb b/app/graphql/types/package_type.rb
new file mode 100644
index 00000000000..0604bf827a5
--- /dev/null
+++ b/app/graphql/types/package_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ class PackageType < BaseObject
+ graphql_name 'Package'
+ description 'Represents a package'
+ authorize :read_package
+
+ field :id, GraphQL::ID_TYPE, null: false, description: 'The ID of the package'
+ field :name, GraphQL::STRING_TYPE, null: false, description: 'The name of the package'
+ field :created_at, Types::TimeType, null: false, description: 'The created date'
+ field :updated_at, Types::TimeType, null: false, description: 'The update date'
+ field :version, GraphQL::STRING_TYPE, null: true, description: 'The version of the package'
+ field :package_type, Types::PackageTypeEnum, null: false, description: 'The type of the package'
+ end
+end
diff --git a/app/graphql/types/package_type_enum.rb b/app/graphql/types/package_type_enum.rb
new file mode 100644
index 00000000000..bc03b8f5f8b
--- /dev/null
+++ b/app/graphql/types/package_type_enum.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Types
+ class PackageTypeEnum < BaseEnum
+ ::Packages::Package.package_types.keys.each do |package_type|
+ value package_type.to_s.upcase, "Packages from the #{package_type} package manager", value: package_type.to_s
+ end
+ end
+end
diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb
index e1546d31e89..b3916e42e92 100644
--- a/app/graphql/types/project_statistics_type.rb
+++ b/app/graphql/types/project_statistics_type.rb
@@ -21,5 +21,7 @@ module Types
description: 'Packages size of the project'
field :wiki_size, GraphQL::FLOAT_TYPE, null: true,
description: 'Wiki size of the project'
+ field :snippets_size, GraphQL::FLOAT_TYPE, null: true,
+ description: 'Snippets size of the project'
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index bbfb7fc4f20..2251a0f4e0c 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -60,6 +60,12 @@ module Types
field :merge_requests_ff_only_enabled, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.'
+ field :service_desk_enabled, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates if the project has service desk enabled.'
+
+ field :service_desk_address, GraphQL::STRING_TYPE, null: true,
+ description: 'E-mail address of the service desk.'
+
field :avatar_url, GraphQL::STRING_TYPE, null: true, calls_gitaly: true,
description: 'URL to avatar image file of the project',
resolve: -> (project, args, ctx) do
@@ -153,12 +159,20 @@ module Types
description: 'Environments of the project',
resolver: Resolvers::EnvironmentsResolver
+ field :sast_ci_configuration, ::Types::CiConfiguration::Sast::Type, null: true,
+ description: 'SAST CI configuration for the project',
+ resolver: ::Resolvers::CiConfiguration::SastResolver
+
field :issue,
Types::IssueType,
null: true,
description: 'A single issue of the project',
resolver: Resolvers::IssuesResolver.single
+ field :packages, Types::PackageType.connection_type, null: true,
+ description: 'Packages of the project',
+ resolver: Resolvers::PackagesResolver
+
field :pipelines,
Types::Ci::PipelineType.connection_type,
null: true,
@@ -243,15 +257,14 @@ module Types
Types::ReleaseType.connection_type,
null: true,
description: 'Releases of the project',
- resolver: Resolvers::ReleasesResolver,
- feature_flag: :graphql_release_data
+ resolver: Resolvers::ReleasesResolver
field :release,
Types::ReleaseType,
null: true,
description: 'A single release of the project',
resolver: Resolvers::ReleasesResolver.single,
- feature_flag: :graphql_release_data
+ authorize: :download_code
field :container_expiration_policy,
Types::ContainerExpirationPolicyType,
diff --git a/app/graphql/types/projects/services/jira_service_type.rb b/app/graphql/types/projects/services/jira_service_type.rb
index e81963f752d..8bf85a14cbf 100644
--- a/app/graphql/types/projects/services/jira_service_type.rb
+++ b/app/graphql/types/projects/services/jira_service_type.rb
@@ -15,7 +15,7 @@ module Types
null: true,
connection: false,
extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension],
- description: 'List of Jira projects fetched through Jira REST API',
+ description: 'List of all Jira projects fetched through Jira REST API',
resolver: Resolvers::Projects::JiraProjectsResolver
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 362e4004b73..b4cbd96bfdb 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -61,10 +61,6 @@ module Types
description: 'Text to echo back',
resolver: Resolvers::EchoResolver
- field :user, Types::UserType, null: true,
- description: 'Find a user on this instance',
- resolver: Resolvers::UserResolver
-
def design_management
DesignManagementObject.new(nil)
end
diff --git a/app/graphql/types/release_link_type.rb b/app/graphql/types/release_asset_link_type.rb
index 070f14a90df..21f1bd50cff 100644
--- a/app/graphql/types/release_link_type.rb
+++ b/app/graphql/types/release_asset_link_type.rb
@@ -1,8 +1,9 @@
# frozen_string_literal: true
module Types
- class ReleaseLinkType < BaseObject
- graphql_name 'ReleaseLink'
+ class ReleaseAssetLinkType < BaseObject
+ graphql_name 'ReleaseAssetLink'
+ description 'Represents an asset link associated with a release'
authorize :read_release
@@ -12,7 +13,7 @@ module Types
description: 'Name of the link'
field :url, GraphQL::STRING_TYPE, null: true,
description: 'URL of the link'
- field :link_type, Types::ReleaseLinkTypeEnum, null: true,
+ field :link_type, Types::ReleaseAssetLinkTypeEnum, null: true,
description: 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`'
field :external, GraphQL::BOOLEAN_TYPE, null: true, method: :external?,
description: 'Indicates the link points to an external resource'
diff --git a/app/graphql/types/release_link_type_enum.rb b/app/graphql/types/release_asset_link_type_enum.rb
index b364855833f..01862ada56d 100644
--- a/app/graphql/types/release_link_type_enum.rb
+++ b/app/graphql/types/release_asset_link_type_enum.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
module Types
- class ReleaseLinkTypeEnum < BaseEnum
- graphql_name 'ReleaseLinkType'
+ class ReleaseAssetLinkTypeEnum < BaseEnum
+ graphql_name 'ReleaseAssetLinkType'
description 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`'
::Releases::Link.link_types.keys.each do |link_type|
diff --git a/app/graphql/types/release_assets_type.rb b/app/graphql/types/release_assets_type.rb
index 58ad05b5365..d6042bdbc0b 100644
--- a/app/graphql/types/release_assets_type.rb
+++ b/app/graphql/types/release_assets_type.rb
@@ -3,6 +3,7 @@
module Types
class ReleaseAssetsType < BaseObject
graphql_name 'ReleaseAssets'
+ description 'A container for all assets associated with a release'
authorize :read_release
@@ -10,9 +11,9 @@ module Types
present_using ReleasePresenter
- field :assets_count, GraphQL::INT_TYPE, null: true,
+ field :count, GraphQL::INT_TYPE, null: true, method: :assets_count,
description: 'Number of assets of the release'
- field :links, Types::ReleaseLinkType.connection_type, null: true,
+ field :links, Types::ReleaseAssetLinkType.connection_type, null: true,
description: 'Asset links of the release'
field :sources, Types::ReleaseSourceType.connection_type, null: true,
description: 'Sources of the release'
diff --git a/app/graphql/types/release_links_type.rb b/app/graphql/types/release_links_type.rb
new file mode 100644
index 00000000000..f61a16f5b67
--- /dev/null
+++ b/app/graphql/types/release_links_type.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Types
+ class ReleaseLinksType < BaseObject
+ graphql_name 'ReleaseLinks'
+
+ authorize :download_code
+
+ alias_method :release, :object
+
+ present_using ReleasePresenter
+
+ field :self_url, GraphQL::STRING_TYPE, null: true,
+ description: 'HTTP URL of the release'
+ field :merge_requests_url, GraphQL::STRING_TYPE, null: true,
+ description: 'HTTP URL of the merge request page filtered by this release'
+ field :issues_url, GraphQL::STRING_TYPE, null: true,
+ description: 'HTTP URL of the issues page filtered by this release'
+ field :edit_url, GraphQL::STRING_TYPE, null: true,
+ description: "HTTP URL of the release's edit page",
+ authorize: :update_release
+ end
+end
diff --git a/app/graphql/types/release_source_type.rb b/app/graphql/types/release_source_type.rb
index 0ec1ad85a39..891da472116 100644
--- a/app/graphql/types/release_source_type.rb
+++ b/app/graphql/types/release_source_type.rb
@@ -3,8 +3,9 @@
module Types
class ReleaseSourceType < BaseObject
graphql_name 'ReleaseSource'
+ description 'Represents the source code attached to a release in a particular format'
- authorize :read_release_sources
+ authorize :download_code
field :format, GraphQL::STRING_TYPE, null: true,
description: 'Format of the source'
diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb
index 3d8e5a93c68..a0703b96a36 100644
--- a/app/graphql/types/release_type.rb
+++ b/app/graphql/types/release_type.rb
@@ -3,6 +3,7 @@
module Types
class ReleaseType < BaseObject
graphql_name 'Release'
+ description 'Represents a release'
authorize :read_release
@@ -10,10 +11,12 @@ module Types
present_using ReleasePresenter
- field :tag_name, GraphQL::STRING_TYPE, null: false, method: :tag,
- description: 'Name of the tag associated with the release'
+ field :tag_name, GraphQL::STRING_TYPE, null: true, method: :tag,
+ description: 'Name of the tag associated with the release',
+ authorize: :download_code
field :tag_path, GraphQL::STRING_TYPE, null: true,
- description: 'Relative web path to the tag associated with the release'
+ description: 'Relative web path to the tag associated with the release',
+ authorize: :download_code
field :description, GraphQL::STRING_TYPE, null: true,
description: 'Description (also known as "release notes") of the release'
markdown_field :description_html, null: true
@@ -25,6 +28,8 @@ module Types
description: 'Timestamp of when the release was released'
field :assets, Types::ReleaseAssetsType, null: true, method: :itself,
description: 'Assets of the release'
+ field :links, Types::ReleaseLinksType, null: true, method: :itself,
+ description: 'Links of the release'
field :milestones, Types::MilestoneType.connection_type, null: true,
description: 'Milestones associated to the release'
field :evidences, Types::EvidenceType.connection_type, null: true,
@@ -39,8 +44,7 @@ module Types
field :commit, Types::CommitType, null: true,
complexity: 10, calls_gitaly: true,
- description: 'The commit associated with the release',
- authorize: :reporter_access
+ description: 'The commit associated with the release'
def commit
return if release.sha.nil?
diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb
index e2d85aebc48..3acc1d9ca44 100644
--- a/app/graphql/types/root_storage_statistics_type.rb
+++ b/app/graphql/types/root_storage_statistics_type.rb
@@ -12,5 +12,6 @@ module Types
field :build_artifacts_size, GraphQL::FLOAT_TYPE, null: false, description: 'The CI artifacts size in bytes'
field :packages_size, GraphQL::FLOAT_TYPE, null: false, description: 'The packages size in bytes'
field :wiki_size, GraphQL::FLOAT_TYPE, null: false, description: 'The wiki size in bytes'
+ field :snippets_size, GraphQL::FLOAT_TYPE, null: false, description: 'The snippets size in bytes'
end
end
diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb
index a377c3aafdc..b797722fef8 100644
--- a/app/graphql/types/todo_target_enum.rb
+++ b/app/graphql/types/todo_target_enum.rb
@@ -6,6 +6,7 @@ module Types
value 'ISSUE', value: 'Issue', description: 'An Issue'
value 'MERGEREQUEST', value: 'MergeRequest', description: 'A MergeRequest'
value 'DESIGN', value: 'DesignManagement::Design', description: 'A Design'
+ value 'ALERT', value: 'AlertManagement::Alert', description: 'An Alert'
end
end
diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb
index 22349203519..36cae756a0d 100644
--- a/app/graphql/types/tree/blob_type.rb
+++ b/app/graphql/types/tree/blob_type.rb
@@ -17,6 +17,8 @@ module Types
resolve: -> (blob, args, ctx) do
Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find
end
+ field :mode, GraphQL::STRING_TYPE, null: true,
+ description: 'Blob mode in numeric format'
# rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/untrusted_regexp.rb b/app/graphql/types/untrusted_regexp.rb
new file mode 100644
index 00000000000..2c715ab4967
--- /dev/null
+++ b/app/graphql/types/untrusted_regexp.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ class UntrustedRegexp < Types::BaseScalar
+ description 'A regexp containing patterns sourced from user input'
+
+ def self.coerce_input(input_value, _)
+ return unless input_value
+
+ Gitlab::UntrustedRegexp.new(input_value)
+
+ input_value
+ rescue RegexpError => e
+ message = "#{input_value} is an invalid regexp: #{e.message}"
+ raise GraphQL::CoercionError, message
+ end
+
+ def self.coerce_result(ruby_value, _)
+ ruby_value.to_s
+ end
+ end
+end
diff --git a/app/helpers/analytics/unique_visits_helper.rb b/app/helpers/analytics/unique_visits_helper.rb
new file mode 100644
index 00000000000..ded7f54e44e
--- /dev/null
+++ b/app/helpers/analytics/unique_visits_helper.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Analytics
+ module UniqueVisitsHelper
+ extend ActiveSupport::Concern
+
+ def visitor_id
+ return cookies[:visitor_id] if cookies[:visitor_id].present?
+ return unless current_user
+
+ uuid = SecureRandom.uuid
+ cookies[:visitor_id] = { value: uuid, expires: 24.months }
+ uuid
+ end
+
+ def track_visit(target_id)
+ return unless Feature.enabled?(:track_unique_visits)
+ return unless Gitlab::CurrentSettings.usage_ping_enabled?
+ return unless visitor_id
+
+ Gitlab::Analytics::UniqueVisits.new.track_visit(visitor_id, target_id)
+ end
+
+ class_methods do
+ def track_unique_visits(controller_actions, target_id:)
+ after_action only: controller_actions, if: -> { request.format.html? && request.headers['DNT'] != '1' } do
+ track_visit(target_id)
+ end
+ end
+ end
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index bdfdf5a69b3..e8bd5ad9b9b 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -335,6 +335,15 @@ module ApplicationHelper
}
end
+ def page_startup_api_calls
+ @api_startup_calls
+ end
+
+ def add_page_startup_api_call(api_path, options: {})
+ @api_startup_calls ||= {}
+ @api_startup_calls[api_path] = options
+ end
+
def autocomplete_data_sources(object, noteable_type)
return {} unless object && noteable_type
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index e709d15a946..aa118a9bc45 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -190,6 +190,7 @@ module ApplicationSettingsHelper
:container_expiration_policies_enable_historic_entries,
:container_registry_token_expire_delay,
:default_artifacts_expire_in,
+ :default_branch_name,
:default_branch_protection,
:default_ci_config_path,
:default_group_visibility,
@@ -244,6 +245,7 @@ module ApplicationSettingsHelper
:metrics_method_call_threshold,
:minimum_password_length,
:mirror_available,
+ :notify_on_unknown_sign_in,
:pages_domain_verification_enabled,
:password_authentication_enabled_for_web,
:password_authentication_enabled_for_git,
@@ -319,7 +321,13 @@ module ApplicationSettingsHelper
:email_restrictions_enabled,
:email_restrictions,
:issues_create_limit,
- :raw_blob_request_limit
+ :raw_blob_request_limit,
+ :project_import_limit,
+ :project_export_limit,
+ :project_download_export_limit,
+ :group_import_limit,
+ :group_export_limit,
+ :group_download_export_limit
]
end
diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb
index 0f14680607e..c27f5d4ebce 100644
--- a/app/helpers/auto_devops_helper.rb
+++ b/app/helpers/auto_devops_helper.rb
@@ -22,4 +22,8 @@ module AutoDevopsHelper
s_('CICD|instance enabled')
end
end
+
+ def auto_devops_settings_path(project)
+ project_settings_ci_cd_path(project, anchor: 'autodevops-settings')
+ end
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 69fe3303840..f4238e7711a 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -52,13 +52,12 @@ module BlobHelper
edit_button_tag(blob,
common_classes,
_('Edit'),
- Feature.enabled?(:web_ide_default) ? ide_edit_path(project, ref, path) : edit_blob_path(project, ref, path, options),
+ edit_blob_path(project, ref, path, options),
project,
ref)
end
def ide_edit_button(project = @project, ref = @ref, path = @path, blob:)
- return if Feature.enabled?(:web_ide_default)
return unless blob
edit_button_tag(blob,
diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb
deleted file mode 100644
index 2def3488184..00000000000
--- a/app/helpers/builds_helper.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-module BuildsHelper
- def build_summary(build, skip: false)
- if build.has_trace?
- if skip
- link_to _("View job log"), pipeline_job_url(build.pipeline, build)
- else
- build.trace.html(last_lines: 10).html_safe
- end
- else
- _("No job log")
- end
- end
-
- def sidebar_build_class(build, current_build)
- build_class = []
- build_class << 'active' if build.id === current_build.id
- build_class << 'retried' if build.retried?
- build_class.join(' ')
- end
-
- def javascript_build_options
- {
- page_path: project_job_path(@project, @build),
- build_status: @build.status,
- build_stage: @build.stage,
- log_state: ''
- }
- end
-
- def build_failed_issue_options
- {
- title: _("Job Failed #%{build_id}") % { build_id: @build.id },
- description: project_job_url(@project, @build)
- }
- end
-end
diff --git a/app/helpers/ci/builds_helper.rb b/app/helpers/ci/builds_helper.rb
new file mode 100644
index 00000000000..bfdb830f2c3
--- /dev/null
+++ b/app/helpers/ci/builds_helper.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Ci
+ module BuildsHelper
+ def build_summary(build, skip: false)
+ if build.has_trace?
+ if skip
+ link_to _('View job log'), pipeline_job_url(build.pipeline, build)
+ else
+ build.trace.html(last_lines: 10).html_safe
+ end
+ else
+ _('No job log')
+ end
+ end
+
+ def sidebar_build_class(build, current_build)
+ build_class = []
+ build_class << 'active' if build.id === current_build.id
+ build_class << 'retried' if build.retried?
+ build_class.join(' ')
+ end
+
+ def javascript_build_options
+ {
+ page_path: project_job_path(@project, @build),
+ build_status: @build.status,
+ build_stage: @build.stage,
+ log_state: ''
+ }
+ end
+
+ def build_failed_issue_options
+ {
+ title: _("Job Failed #%{build_id}") % { build_id: @build.id },
+ description: project_job_url(@project, @build)
+ }
+ end
+ end
+end
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
new file mode 100644
index 00000000000..0344413b849
--- /dev/null
+++ b/app/helpers/ci/jobs_helper.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobsHelper
+ def jobs_data
+ {
+ "endpoint" => project_job_path(@project, @build, format: :json),
+ "project_path" => @project.full_path,
+ "deployment_help_url" => help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting'),
+ "runner_help_url" => help_page_path('ci/runners/README.html', anchor: 'set-maximum-job-timeout-for-a-runner'),
+ "runner_settings_url" => project_runners_path(@build.project, anchor: 'js-runners-settings'),
+ "variables_settings_url" => project_variables_path(@build.project, anchor: 'js-cicd-variables-settings'),
+ "page_path" => project_job_path(@project, @build),
+ "build_status" => @build.status,
+ "build_stage" => @build.stage,
+ "log_state" => '',
+ "build_options" => javascript_build_options
+ }
+ end
+ end
+end
+
+Ci::JobsHelper.prepend_if_ee('::EE::Ci::JobsHelper')
diff --git a/app/helpers/ci/pipeline_schedules_helper.rb b/app/helpers/ci/pipeline_schedules_helper.rb
new file mode 100644
index 00000000000..20e5c90a60e
--- /dev/null
+++ b/app/helpers/ci/pipeline_schedules_helper.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Ci
+ module PipelineSchedulesHelper
+ def timezone_data
+ ActiveSupport::TimeZone.all.map do |timezone|
+ {
+ name: timezone.name,
+ offset: timezone.now.utc_offset,
+ identifier: timezone.tzinfo.identifier
+ }
+ end
+ end
+ end
+end
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
new file mode 100644
index 00000000000..8cdb28b2874
--- /dev/null
+++ b/app/helpers/ci/runners_helper.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Ci
+ module RunnersHelper
+ def runner_status_icon(runner)
+ status = runner.status
+ case status
+ when :not_connected
+ content_tag :i, nil,
+ class: "fa fa-warning",
+ title: "New runner. Has not connected yet"
+
+ when :online, :offline, :paused
+ content_tag :i, nil,
+ class: "fa fa-circle runner-status-#{status}",
+ title: "Runner is #{status}, last contact was #{time_ago_in_words(runner.contacted_at)} ago"
+ end
+ end
+
+ def runner_link(runner)
+ display_name = truncate(runner.display_name, length: 15)
+ id = "\##{runner.id}"
+
+ if current_user && current_user.admin
+ link_to admin_runner_path(runner) do
+ display_name + id
+ end
+ else
+ display_name + id
+ end
+ end
+
+ # Due to inability of performing sorting of runners by cached "contacted_at" values we have to show uncached values if sorting by "contacted_asc" is requested.
+ # Please refer to the following issue for more details: https://gitlab.com/gitlab-org/gitlab-foss/issues/55920
+ def runner_contacted_at(runner)
+ if params[:sort] == 'contacted_asc'
+ runner.uncached_contacted_at
+ else
+ runner.contacted_at
+ end
+ end
+ end
+end
+
+Ci::RunnersHelper.prepend_if_ee('EE::Ci::RunnersHelper')
diff --git a/app/helpers/ci/status_helper.rb b/app/helpers/ci/status_helper.rb
new file mode 100644
index 00000000000..bca49324a19
--- /dev/null
+++ b/app/helpers/ci/status_helper.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+##
+# DEPRECATED
+#
+# These helpers are deprecated in favor of detailed CI/CD statuses.
+#
+# See 'detailed_status?` method and `Gitlab::Ci::Status` module.
+#
+module Ci
+ module StatusHelper
+ def ci_label_for_status(status)
+ if detailed_status?(status)
+ return status.label
+ end
+
+ label = case status
+ when 'success'
+ 'passed'
+ when 'success-with-warnings'
+ 'passed with warnings'
+ when 'manual'
+ 'waiting for manual action'
+ when 'scheduled'
+ 'waiting for delayed job'
+ else
+ status
+ end
+ translation = "CiStatusLabel|#{label}"
+ s_(translation)
+ end
+
+ def ci_text_for_status(status)
+ if detailed_status?(status)
+ return status.text
+ end
+
+ case status
+ when 'success'
+ s_('CiStatusText|passed')
+ when 'success-with-warnings'
+ s_('CiStatusText|passed')
+ when 'manual'
+ s_('CiStatusText|blocked')
+ when 'scheduled'
+ s_('CiStatusText|delayed')
+ else
+ # All states are already being translated inside the detailed statuses:
+ # :running => Gitlab::Ci::Status::Running
+ # :skipped => Gitlab::Ci::Status::Skipped
+ # :failed => Gitlab::Ci::Status::Failed
+ # :success => Gitlab::Ci::Status::Success
+ # :canceled => Gitlab::Ci::Status::Canceled
+ # The following states are customized above:
+ # :manual => Gitlab::Ci::Status::Manual
+ status_translation = "CiStatusText|#{status}"
+ s_(status_translation)
+ end
+ end
+
+ def ci_status_for_statuseable(subject)
+ status = subject.try(:status) || 'not found'
+ status.humanize
+ end
+
+ # rubocop:disable Metrics/CyclomaticComplexity
+ def ci_icon_for_status(status, size: 16)
+ if detailed_status?(status)
+ return sprite_icon(status.icon, size: size)
+ end
+
+ icon_name =
+ case status
+ when 'success'
+ 'status_success'
+ when 'success-with-warnings'
+ 'status_warning'
+ when 'failed'
+ 'status_failed'
+ when 'pending'
+ 'status_pending'
+ when 'waiting_for_resource'
+ 'status_pending'
+ when 'preparing'
+ 'status_preparing'
+ when 'running'
+ 'status_running'
+ when 'play'
+ 'play'
+ when 'created'
+ 'status_created'
+ when 'skipped'
+ 'status_skipped'
+ when 'manual'
+ 'status_manual'
+ when 'scheduled'
+ 'status_scheduled'
+ else
+ 'status_canceled'
+ end
+
+ sprite_icon(icon_name, size: size)
+ end
+ # rubocop:enable Metrics/CyclomaticComplexity
+
+ def ci_icon_class_for_status(status)
+ group = detailed_status?(status) ? status.group : status.dasherize
+
+ "ci-status-icon-#{group}"
+ end
+
+ def pipeline_status_cache_key(pipeline_status)
+ "pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}"
+ end
+
+ def render_commit_status(commit, status, ref: nil, tooltip_placement: 'left')
+ project = commit.project
+ path = pipelines_project_commit_path(project, commit, ref: ref)
+
+ render_status_with_link(
+ status,
+ path,
+ tooltip_placement: tooltip_placement,
+ icon_size: 24)
+ end
+
+ def render_status_with_link(status, path = nil, type: _('pipeline'), tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16)
+ klass = "ci-status-link #{ci_icon_class_for_status(status)} d-inline-flex #{cssclass}"
+ title = "#{type.titleize}: #{ci_label_for_status(status)}"
+ data = { toggle: 'tooltip', placement: tooltip_placement, container: container }
+
+ if path
+ link_to ci_icon_for_status(status, size: icon_size), path,
+ class: klass, title: title, data: data
+ else
+ content_tag :span, ci_icon_for_status(status, size: icon_size),
+ class: klass, title: title, data: data
+ end
+ end
+
+ def detailed_status?(status)
+ status.respond_to?(:text) &&
+ status.respond_to?(:group) &&
+ status.respond_to?(:label) &&
+ status.respond_to?(:icon)
+ end
+ end
+end
diff --git a/app/helpers/ci/variables_helper.rb b/app/helpers/ci/variables_helper.rb
new file mode 100644
index 00000000000..b20390d58e9
--- /dev/null
+++ b/app/helpers/ci/variables_helper.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Ci
+ module VariablesHelper
+ def ci_variable_protected_by_default?
+ Gitlab::CurrentSettings.current_application_settings.protected_ci_variables
+ end
+
+ def create_deploy_token_path(entity, opts = {})
+ if entity.is_a?(::Group)
+ create_deploy_token_group_settings_repository_path(entity, opts)
+ else
+ # TODO: change this path to 'create_deploy_token_project_settings_ci_cd_path'
+ # See MR comment for more detail: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27059#note_311585356
+ create_deploy_token_project_settings_repository_path(entity, opts)
+ end
+ end
+
+ def revoke_deploy_token_path(entity, token)
+ if entity.is_a?(::Group)
+ revoke_group_deploy_token_path(entity, token)
+ else
+ revoke_project_deploy_token_path(entity, token)
+ end
+ end
+
+ def ci_variable_protected?(variable, only_key_value)
+ if variable && !only_key_value
+ variable.protected
+ else
+ ci_variable_protected_by_default?
+ end
+ end
+
+ def ci_variable_masked?(variable, only_key_value)
+ if variable && !only_key_value
+ variable.masked
+ else
+ false
+ end
+ end
+
+ def ci_variable_type_options
+ [
+ %w(Variable env_var),
+ %w(File file)
+ ]
+ end
+
+ def ci_variable_maskable_regex
+ Ci::Maskable::REGEX.inspect.sub('\\A', '^').sub('\\z', '$').sub(/^\//, '').sub(/\/[a-z]*$/, '').gsub('\/', '/')
+ end
+ end
+end
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
deleted file mode 100644
index 80d1b7e7edb..00000000000
--- a/app/helpers/ci_status_helper.rb
+++ /dev/null
@@ -1,146 +0,0 @@
-# frozen_string_literal: true
-
-##
-# DEPRECATED
-#
-# These helpers are deprecated in favor of detailed CI/CD statuses.
-#
-# See 'detailed_status?` method and `Gitlab::Ci::Status` module.
-#
-module CiStatusHelper
- def ci_label_for_status(status)
- if detailed_status?(status)
- return status.label
- end
-
- label = case status
- when 'success'
- 'passed'
- when 'success-with-warnings'
- 'passed with warnings'
- when 'manual'
- 'waiting for manual action'
- when 'scheduled'
- 'waiting for delayed job'
- else
- status
- end
- translation = "CiStatusLabel|#{label}"
- s_(translation)
- end
-
- def ci_text_for_status(status)
- if detailed_status?(status)
- return status.text
- end
-
- case status
- when 'success'
- s_('CiStatusText|passed')
- when 'success-with-warnings'
- s_('CiStatusText|passed')
- when 'manual'
- s_('CiStatusText|blocked')
- when 'scheduled'
- s_('CiStatusText|delayed')
- else
- # All states are already being translated inside the detailed statuses:
- # :running => Gitlab::Ci::Status::Running
- # :skipped => Gitlab::Ci::Status::Skipped
- # :failed => Gitlab::Ci::Status::Failed
- # :success => Gitlab::Ci::Status::Success
- # :canceled => Gitlab::Ci::Status::Canceled
- # The following states are customized above:
- # :manual => Gitlab::Ci::Status::Manual
- status_translation = "CiStatusText|#{status}"
- s_(status_translation)
- end
- end
-
- def ci_status_for_statuseable(subject)
- status = subject.try(:status) || 'not found'
- status.humanize
- end
-
- # rubocop:disable Metrics/CyclomaticComplexity
- def ci_icon_for_status(status, size: 16)
- if detailed_status?(status)
- return sprite_icon(status.icon, size: size)
- end
-
- icon_name =
- case status
- when 'success'
- 'status_success'
- when 'success-with-warnings'
- 'status_warning'
- when 'failed'
- 'status_failed'
- when 'pending'
- 'status_pending'
- when 'waiting_for_resource'
- 'status_pending'
- when 'preparing'
- 'status_preparing'
- when 'running'
- 'status_running'
- when 'play'
- 'play'
- when 'created'
- 'status_created'
- when 'skipped'
- 'status_skipped'
- when 'manual'
- 'status_manual'
- when 'scheduled'
- 'status_scheduled'
- else
- 'status_canceled'
- end
-
- sprite_icon(icon_name, size: size)
- end
- # rubocop:enable Metrics/CyclomaticComplexity
-
- def ci_icon_class_for_status(status)
- group = detailed_status?(status) ? status.group : status.dasherize
-
- "ci-status-icon-#{group}"
- end
-
- def pipeline_status_cache_key(pipeline_status)
- "pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}"
- end
-
- def render_commit_status(commit, status, ref: nil, tooltip_placement: 'left')
- project = commit.project
- path = pipelines_project_commit_path(project, commit, ref: ref)
-
- render_status_with_link(
- status,
- path,
- tooltip_placement: tooltip_placement,
- icon_size: 24)
- end
-
- def render_status_with_link(status, path = nil, type: _('pipeline'), tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16)
- klass = "ci-status-link #{ci_icon_class_for_status(status)} d-inline-flex #{cssclass}"
- title = "#{type.titleize}: #{ci_label_for_status(status)}"
- data = { toggle: 'tooltip', placement: tooltip_placement, container: container }
-
- if path
- link_to ci_icon_for_status(status, size: icon_size), path,
- class: klass, title: title, data: data
- else
- content_tag :span, ci_icon_for_status(status, size: icon_size),
- class: klass, title: title, data: data
- end
- end
-
- def detailed_status?(status)
- status.respond_to?(:text) &&
- status.respond_to?(:group) &&
- status.respond_to?(:label) &&
- status.respond_to?(:icon)
- end
-end
diff --git a/app/helpers/ci_variables_helper.rb b/app/helpers/ci_variables_helper.rb
deleted file mode 100644
index cd0718c1b82..00000000000
--- a/app/helpers/ci_variables_helper.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-module CiVariablesHelper
- def ci_variable_protected_by_default?
- Gitlab::CurrentSettings.current_application_settings.protected_ci_variables
- end
-
- def create_deploy_token_path(entity, opts = {})
- if entity.is_a?(Group)
- create_deploy_token_group_settings_repository_path(entity, opts)
- else
- # TODO: change this path to 'create_deploy_token_project_settings_ci_cd_path'
- # See MR comment for more detail: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27059#note_311585356
- create_deploy_token_project_settings_repository_path(entity, opts)
- end
- end
-
- def revoke_deploy_token_path(entity, token)
- if entity.is_a?(Group)
- revoke_group_deploy_token_path(entity, token)
- else
- revoke_project_deploy_token_path(entity, token)
- end
- end
-
- def ci_variable_protected?(variable, only_key_value)
- if variable && !only_key_value
- variable.protected
- else
- ci_variable_protected_by_default?
- end
- end
-
- def ci_variable_masked?(variable, only_key_value)
- if variable && !only_key_value
- variable.masked
- else
- false
- end
- end
-
- def ci_variable_type_options
- [
- %w(Variable env_var),
- %w(File file)
- ]
- end
-
- def ci_variable_maskable_regex
- Ci::Maskable::REGEX.inspect.sub('\\A', '^').sub('\\z', '$').sub(/^\//, '').sub(/\/[a-z]*$/, '').gsub('\/', '/')
- end
-end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 1204f882707..c85d2a68f14 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -1,11 +1,6 @@
# frozen_string_literal: true
module ClustersHelper
- # EE overrides this
- def has_multiple_clusters?
- false
- end
-
def create_new_cluster_label(provider: nil)
case provider
when 'aws'
@@ -19,6 +14,7 @@ module ClustersHelper
def js_clusters_list_data(path = nil)
{
+ ancestor_help_path: help_page_path('user/group/clusters/index', anchor: 'cluster-precedence'),
endpoint: path,
img_tags: {
aws: { path: image_path('illustrations/logos/amazon_eks.svg'), text: s_('ClusterIntegration|Amazon EKS') },
@@ -95,5 +91,3 @@ module ClustersHelper
can?(user, :admin_cluster, cluster)
end
end
-
-ClustersHelper.prepend_if_ee('EE::ClustersHelper')
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index 2a0c2e73dd6..f8490d79427 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -79,7 +79,7 @@ module CommitsHelper
# Returns a link formatted as a commit tag link
def commit_tag_link(url, text)
link_to(url, class: 'badge badge-gray ref-name') do
- sprite_icon('tag', size: 12, css_class: 'append-right-5 vertical-align-middle') + "#{text}"
+ sprite_icon('tag', size: 12, css_class: 'gl-mr-2 vertical-align-middle') + "#{text}"
end
end
@@ -181,15 +181,11 @@ module CommitsHelper
end
def view_file_button(commit_sha, diff_new_path, project, replaced: false)
+ path = project_blob_path(project, tree_join(commit_sha, diff_new_path))
title = replaced ? _('View replaced file @ ') : _('View file @ ')
- link_to(
- project_blob_path(project,
- tree_join(commit_sha, diff_new_path)),
- class: 'btn view-file js-view-file'
- ) do
- raw(title) + content_tag(:span, Commit.truncate_sha(commit_sha),
- class: 'commit-sha')
+ link_to(path, class: 'btn') do
+ raw(title) + content_tag(:span, truncate_sha(commit_sha), class: 'commit-sha')
end
end
diff --git a/app/helpers/cookies_helper.rb b/app/helpers/cookies_helper.rb
index 3a7e9987190..938379818de 100644
--- a/app/helpers/cookies_helper.rb
+++ b/app/helpers/cookies_helper.rb
@@ -1,9 +1,19 @@
# frozen_string_literal: true
module CookiesHelper
- def set_secure_cookie(key, value, httponly: false, permanent: false)
- cookie_jar = permanent ? cookies.permanent : cookies
+ COOKIE_TYPE_PERMANENT = :permanent
+ COOKIE_TYPE_ENCRYPTED = :encrypted
- cookie_jar[key] = { value: value, secure: Gitlab.config.gitlab.https, httponly: httponly }
+ def set_secure_cookie(key, value, httponly: false, expires: nil, type: nil)
+ cookie_jar = case type
+ when COOKIE_TYPE_PERMANENT
+ cookies.permanent
+ when COOKIE_TYPE_ENCRYPTED
+ cookies.encrypted
+ else
+ cookies
+ end
+
+ cookie_jar[key] = { value: value, secure: Gitlab.config.gitlab.https, httponly: httponly, expires: expires }
end
end
diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb
index b38feb0fb6c..7bf3795d73a 100644
--- a/app/helpers/dashboard_helper.rb
+++ b/app/helpers/dashboard_helper.rb
@@ -41,7 +41,7 @@ module DashboardHelper
if doc_href.present?
link_to_doc = link_to(sprite_icon('question', size: 16), doc_href,
- class: 'prepend-left-5', title: _('Documentation'),
+ class: 'gl-ml-2', title: _('Documentation'),
target: '_blank', rel: 'noopener noreferrer')
concat(link_to_doc)
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 4c3c4931387..3b25de521d0 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -135,8 +135,7 @@ module DiffHelper
def diff_file_html_data(project, diff_file_path, diff_commit_id)
{
- blob_diff_path: project_blob_diff_path(project,
- tree_join(diff_commit_id, diff_file_path)),
+ blob_diff_path: project_blob_diff_path(project, tree_join(diff_commit_id, diff_file_path)),
view: diff_view
}
end
@@ -175,6 +174,10 @@ module DiffHelper
end
end
+ def apply_diff_view_cookie!
+ set_secure_cookie(:diff_view, params.delete(:view), type: CookiesHelper::COOKIE_TYPE_PERMANENT) if params[:view].present?
+ end
+
private
def diff_btn(title, name, selected)
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 64c5fae7d96..772a5f79a4d 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -15,7 +15,10 @@ module DropdownsHelper
dropdown_output = dropdown_toggle_link(toggle_text, data_attr, options)
end
- dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do
+ content_tag_options = { class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}" }
+ content_tag_options[:data] = { qa_selector: "#{options[:dropdown_qa_selector]}" } if options[:dropdown_qa_selector]
+
+ dropdown_output << content_tag(:div, content_tag_options) do
output = []
if options.key?(:title)
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 41a255434af..b522a9dfb4f 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -24,7 +24,7 @@ module EnvironmentsHelper
def metrics_data(project, environment)
metrics_data = {}
metrics_data.merge!(project_metrics_data(project)) if project
- metrics_data.merge!(environment_metrics_data(environment)) if environment
+ metrics_data.merge!(environment_metrics_data(environment, project)) if environment
metrics_data.merge!(project_and_environment_metrics_data(project, environment)) if project && environment
metrics_data.merge!(static_metrics_data)
@@ -36,7 +36,8 @@ module EnvironmentsHelper
"environment-name": environment.name,
"environments-path": project_environments_path(project, format: :json),
"environment-id": environment.id,
- "cluster-applications-documentation-path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack')
+ "cluster-applications-documentation-path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack'),
+ "clusters-path": project_clusters_path(project, format: :json)
}
end
@@ -65,11 +66,11 @@ module EnvironmentsHelper
}
end
- def environment_metrics_data(environment)
+ def environment_metrics_data(environment, project = nil)
return {} unless environment
{
- 'metrics-dashboard-base-path' => environment_metrics_path(environment),
+ 'metrics-dashboard-base-path' => metrics_dashboard_base_path(environment, project),
'current-environment-name' => environment.name,
'has-metrics' => "#{environment.has_metrics?}",
'prometheus-status' => "#{environment.prometheus_status}",
@@ -77,6 +78,17 @@ module EnvironmentsHelper
}
end
+ def metrics_dashboard_base_path(environment, project)
+ # This is needed to support our transition from environment scoped metric paths to project scoped.
+ if project
+ path = project_metrics_dashboard_path(project)
+
+ return path if request.path.include?(path)
+ end
+
+ environment_metrics_path(environment)
+ end
+
def project_and_environment_metrics_data(project, environment)
return {} unless project && environment
@@ -84,14 +96,16 @@ module EnvironmentsHelper
'metrics-endpoint' => additional_metrics_project_environment_path(project, environment, format: :json),
'dashboard-endpoint' => metrics_dashboard_project_environment_path(project, environment, format: :json),
'deployments-endpoint' => project_environment_deployments_path(project, environment, format: :json),
- 'alerts-endpoint' => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json)
-
+ 'alerts-endpoint' => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json),
+ 'operations-settings-path' => project_settings_operations_path(project),
+ 'can-access-operations-settings' => can?(current_user, :admin_operations, project).to_s
}
end
def static_metrics_data
{
'documentation-path' => help_page_path('administration/monitoring/prometheus/index.md'),
+ 'add-dashboard-documentation-path' => help_page_path('user/project/integrations/prometheus.md', anchor: 'adding-a-new-dashboard-to-your-project'),
'empty-getting-started-svg-path' => image_path('illustrations/monitoring/getting_started.svg'),
'empty-loading-svg-path' => image_path('illustrations/monitoring/loading.svg'),
'empty-no-data-svg-path' => image_path('illustrations/monitoring/no_data.svg'),
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index c1f343edd10..207230fd92e 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -29,7 +29,11 @@ module EventsHelper
def event_action_name(event)
target = if event.target_type
- if event.note?
+ if event.design? || event.design_note?
+ 'design'
+ elsif event.wiki_page?
+ 'wiki page'
+ elsif event.note?
event.note_target_type
else
event.target_type.titleize.downcase
@@ -58,11 +62,28 @@ module EventsHelper
end
def event_filter_visible(feature_key)
+ return designs_visible? if feature_key == :designs
return true unless @project
@project.feature_available?(feature_key, current_user)
end
+ def designs_visible?
+ if @project
+ design_activity_enabled?(@project)
+ elsif @group
+ design_activity_enabled?(@group)
+ elsif @projects
+ @projects.with_namespace.include_project_feature.any? { |p| design_activity_enabled?(p) }
+ else
+ true
+ end
+ end
+
+ def design_activity_enabled?(project)
+ Ability.allowed?(current_user, :read_design_activity, project)
+ end
+
def comments_visible?
event_filter_visible(:repository) ||
event_filter_visible(:merge_requests) ||
@@ -94,6 +115,12 @@ module EventsHelper
elsif event.milestone?
words << "##{event.target_iid}" if event.target_iid
words << "in"
+ elsif event.design?
+ words << event.design.to_reference
+ words << "in"
+ elsif event.wiki_page?
+ words << event.target_title
+ words << "in"
elsif event.target
prefix =
if event.merge_request?
@@ -180,10 +207,19 @@ module EventsHelper
def event_wiki_title_html(event)
capture do
- concat content_tag(:span, _('wiki page'), class: "event-target-type append-right-4")
+ concat content_tag(:span, _('wiki page'), class: "event-target-type gl-mr-2")
concat link_to(event.target_title, event_wiki_page_target_url(event),
title: event.target_title,
- class: 'has-tooltip event-target-link append-right-4')
+ class: 'has-tooltip event-target-link gl-mr-2')
+ end
+ end
+
+ def event_design_title_html(event)
+ capture do
+ concat content_tag(:span, _('design'), class: "event-target-type gl-mr-2")
+ concat link_to(event.design.reference_link_text, design_url(event.design),
+ title: event.target_title,
+ class: 'has-tooltip event-design event-target-link gl-mr-2')
end
end
@@ -194,8 +230,8 @@ module EventsHelper
def event_note_title_html(event)
if event.note_target
capture do
- concat content_tag(:span, event.note_target_type, class: "event-target-type append-right-4")
- concat link_to(event.note_target_reference, event_note_target_url(event), title: event.target_title, class: 'has-tooltip event-target-link append-right-4')
+ concat content_tag(:span, event.note_target_type, class: "event-target-type gl-mr-2")
+ concat link_to(event.note_target_reference, event_note_target_url(event), title: event.target_title, class: 'has-tooltip event-target-link gl-mr-2')
end
else
content_tag(:strong, '(deleted)')
@@ -214,6 +250,18 @@ module EventsHelper
sprite_icon(icon_name, size: size) if icon_name
end
+ DESIGN_ICONS = {
+ 'created' => 'upload',
+ 'updated' => 'pencil',
+ 'destroyed' => ICON_NAMES_BY_EVENT_TYPE['destroyed'],
+ 'archived' => 'archive'
+ }.freeze
+
+ def design_event_icon(action, size: 24)
+ icon_name = DESIGN_ICONS[action]
+ sprite_icon(icon_name, size: size) if icon_name
+ end
+
def icon_for_profile_event(event)
if current_path?('users#show')
content_tag :div, class: "system-note-image #{event.action_name.parameterize}-icon" do
@@ -228,7 +276,9 @@ module EventsHelper
def inline_event_icon(event)
unless current_path?('users#show')
- content_tag :span, class: "system-note-image-inline d-none d-sm-flex append-right-4 #{event.action_name.parameterize}-icon align-self-center" do
+ content_tag :span, class: "system-note-image-inline d-none d-sm-flex gl-mr-2 #{event.action_name.parameterize}-icon align-self-center" do
+ next design_event_icon(event.action, size: 14) if event.design?
+
icon_for_event(event.action_name, size: 14)
end
end
@@ -244,7 +294,7 @@ module EventsHelper
private
- def design_url(design, opts)
+ def design_url(design, opts = {})
designs_project_issue_url(
design.project,
design.issue,
diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb
index 483b350b99b..38a4f7f1b4b 100644
--- a/app/helpers/export_helper.rb
+++ b/app/helpers/export_helper.rb
@@ -6,7 +6,7 @@ module ExportHelper
[
_('Project and wiki repositories'),
_('Project uploads'),
- _('Project configuration, including services'),
+ _('Project configuration, excluding integrations'),
_('Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities'),
_('LFS objects'),
_('Issue Boards'),
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 8a9380f4771..04f34f5a3ae 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -271,6 +271,36 @@ module GitlabRoutingHelper
end
end
+ def gitlab_raw_snippet_blob_url(snippet, path, ref = nil)
+ params = {
+ snippet_id: snippet,
+ ref: ref || snippet.repository.root_ref,
+ path: path
+ }
+
+ if snippet.is_a?(ProjectSnippet)
+ project_snippet_blob_raw_url(snippet.project, params)
+ else
+ snippet_blob_raw_url(params)
+ end
+ end
+
+ def gitlab_raw_snippet_blob_path(blob, ref = nil)
+ snippet = blob.container
+
+ params = {
+ snippet_id: snippet,
+ ref: ref || blob.repository.root_ref,
+ path: blob.path
+ }
+
+ if snippet.is_a?(ProjectSnippet)
+ project_snippet_blob_raw_path(snippet.project, params)
+ else
+ snippet_blob_raw_path(params)
+ end
+ end
+
def gitlab_snippet_notes_path(snippet, *args)
new_args = snippet_query_params(snippet, *args)
snippet_notes_path(snippet, *new_args)
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index a6c3c97a873..61c9bd74451 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -176,6 +176,10 @@ module GroupsHelper
links << :settings
end
+ if can?(current_user, :read_wiki, @group)
+ links << :wiki
+ end
+
links
end
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 8a32d3c8a3f..add15cc0d12 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -28,10 +28,12 @@ module IconsHelper
end
def sprite_icon_path
- # SVG Sprites currently don't work across domains, so in the case of a CDN
- # we have to set the current path deliberately to prevent addition of asset_host
- sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host
- ActionController::Base.helpers.image_path('icons.svg', host: sprite_base_url)
+ @sprite_icon_path ||= begin
+ # SVG Sprites currently don't work across domains, so in the case of a CDN
+ # we have to set the current path deliberately to prevent addition of asset_host
+ sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host
+ ActionController::Base.helpers.image_path('icons.svg', host: sprite_base_url)
+ end
end
def sprite_file_icons_path
@@ -53,6 +55,15 @@ module IconsHelper
content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes.join(' '))
end
+ def loading_icon(container: false, color: 'orange', size: 'sm', css_class: nil)
+ css_classes = ['gl-spinner', "gl-spinner-#{color}", "gl-spinner-#{size}"]
+ css_classes << "#{css_class}" unless css_class.blank?
+
+ spinner = content_tag(:span, "", { class: css_classes.join(' '), aria: { label: _('Loading') } })
+
+ container == true ? content_tag(:div, spinner, { class: 'gl-spinner-container' }) : spinner
+ end
+
def external_snippet_icon(name)
content_tag(:span, "", class: "gl-snippet-icon gl-snippet-icon-#{name}")
end
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index d6145493ba6..93f5ca7258d 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -9,10 +9,12 @@ module IdeHelper
"pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'),
"promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'),
"ci-help-page-path" => help_page_path('ci/quick_start/README'),
- "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'),
+ "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.md'),
"clientside-preview-enabled": Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?.to_s,
"render-whitespace-in-code": current_user.render_whitespace_in_code.to_s,
"codesandbox-bundler-url": Gitlab::CurrentSettings.web_ide_clientside_preview_bundler_url
}
end
end
+
+::IdeHelper.prepend_if_ee('::EE::IdeHelper')
diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb
index 9122ad5b35a..1ee67211ab0 100644
--- a/app/helpers/import_helper.rb
+++ b/app/helpers/import_helper.rb
@@ -19,7 +19,11 @@ module ImportHelper
end
def provider_project_link_url(provider_url, full_path)
- Gitlab::Utils.append_path(provider_url, full_path)
+ if Gitlab::Utils.parse_url(full_path)&.absolute?
+ full_path
+ else
+ Gitlab::Utils.append_path(provider_url, full_path)
+ end
end
def import_will_timeout_message(_ci_cd_only)
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index a848c814742..dccb89eec79 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -367,15 +367,6 @@ module IssuablesHelper
end
end
- def issuable_close_reopen_button_method(issuable)
- case issuable
- when Issue
- ''
- when MergeRequest
- 'put'
- end
- end
-
def issuable_author_is_current_user(issuable)
issuable.author == current_user
end
@@ -394,6 +385,14 @@ module IssuablesHelper
end
end
+ def issuable_squash_option?(issuable, project)
+ if issuable.persisted?
+ issuable.squash
+ else
+ project.squash_enabled_by_default?
+ end
+ end
+
private
def sidebar_gutter_collapsed?
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 244b97c7196..61fe075303c 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -41,7 +41,7 @@ module IssuesHelper
end
def confidential_icon(issue)
- icon('eye-slash') if issue.confidential?
+ sprite_icon('eye-slash', size: 16, css_class: 'gl-vertical-align-text-bottom') if issue.confidential?
end
def award_user_list(awards, current_user, limit: 10)
@@ -132,7 +132,10 @@ module IssuesHelper
end
def show_moved_service_desk_issue_warning?(issue)
- false
+ return false unless issue.moved_from
+ return false unless issue.from_service_desk?
+
+ issue.moved_from.project.service_desk_enabled? && !issue.project.service_desk_enabled?
end
end
diff --git a/app/helpers/jobs_helper.rb b/app/helpers/jobs_helper.rb
deleted file mode 100644
index 46edba261dd..00000000000
--- a/app/helpers/jobs_helper.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module JobsHelper
- def jobs_data
- {
- "endpoint" => project_job_path(@project, @build, format: :json),
- "project_path" => @project.full_path,
- "deployment_help_url" => help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting-failed-deployment-jobs'),
- "runner_help_url" => help_page_path('ci/runners/README.html', anchor: 'setting-maximum-job-timeout-for-a-runner'),
- "runner_settings_url" => project_runners_path(@build.project, anchor: 'js-runners-settings'),
- "variables_settings_url" => project_variables_path(@build.project, anchor: 'js-cicd-variables-settings'),
- "page_path" => project_job_path(@project, @build),
- "build_status" => @build.status,
- "build_stage" => @build.stage,
- "log_state" => '',
- "build_options" => javascript_build_options
- }
- end
-end
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 7ab2b33de8c..ed8931fe0f2 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -244,7 +244,6 @@ module MarkupHelper
content_tag :button,
type: 'button',
class: 'toolbar-btn js-md has-tooltip',
- tabindex: -1,
data: data,
title: options[:title],
aria: { label: options[:title] } do
diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb
index 31995c27fac..d66f67fbb60 100644
--- a/app/helpers/members_helper.rb
+++ b/app/helpers/members_helper.rb
@@ -48,6 +48,14 @@ module MembersHelper
"#{request.path}?#{options.to_param}"
end
+ def member_path(member)
+ if member.is_a?(GroupMember)
+ group_group_member_path(member.source, member)
+ else
+ project_project_member_path(member.source, member)
+ end
+ end
+
private
def source_text(member)
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 7940ec1162b..caf39741543 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -118,7 +118,7 @@ module MergeRequestsHelper
auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS,
should_remove_source_branch: true,
sha: merge_request.diff_head_sha,
- squash: merge_request.squash
+ squash: merge_request.squash_on_merge?
}
end
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index b9f8d81bc4e..81451e398f2 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -56,45 +56,6 @@ module NamespacesHelper
namespaces_options(selected, options)
end
- def namespace_storage_alert(namespace)
- return {} if current_user.nil?
-
- payload = Namespaces::CheckStorageSizeService.new(namespace, current_user).execute.payload
-
- return {} if payload.empty?
-
- alert_level = payload[:alert_level]
- root_namespace = payload[:root_namespace]
-
- return {} if cookies["hide_storage_limit_alert_#{root_namespace.id}_#{alert_level}"] == 'true'
-
- payload
- end
-
- def namespace_storage_alert_style(alert_level)
- if alert_level == :error || alert_level == :alert
- 'danger'
- else
- alert_level.to_s
- end
- end
-
- def namespace_storage_alert_icon(alert_level)
- if alert_level == :error || alert_level == :alert
- 'error'
- elsif alert_level == :info
- 'information-o'
- else
- alert_level.to_s
- end
- end
-
- def namespace_storage_usage_link(namespace)
- # The usage quota page is only available in EE. This will be changed in
- # the future, see https://gitlab.com/gitlab-org/gitlab/-/issues/220042.
- nil
- end
-
private
# Many importers create a temporary Group, so use the real
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 9ea0b9cb584..d849ed9d076 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -27,7 +27,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', 'destroy')
+ elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access', 'destroy', 'diff')
%w[page-gutter wiki-sidebar right-sidebar-expanded]
else
[]
diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb
new file mode 100644
index 00000000000..fb68029928c
--- /dev/null
+++ b/app/helpers/notify_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module NotifyHelper
+ def merge_request_reference_link(entity, *args)
+ link_to(entity.to_reference, merge_request_url(entity, *args))
+ end
+
+ def issue_reference_link(entity, *args)
+ link_to(entity.to_reference, issue_url(entity, *args))
+ end
+end
diff --git a/app/helpers/onboarding_experiment_helper.rb b/app/helpers/onboarding_experiment_helper.rb
deleted file mode 100644
index 138fc60479d..00000000000
--- a/app/helpers/onboarding_experiment_helper.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-module OnboardingExperimentHelper
- def allow_access_to_onboarding?
- ::Gitlab.dev_env_or_com? && Feature.enabled?(:user_onboarding)
- end
-end
-
-OnboardingExperimentHelper.prepend_if_ee('EE::OnboardingExperimentHelper')
diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb
new file mode 100644
index 00000000000..3444773fe88
--- /dev/null
+++ b/app/helpers/operations_helper.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module OperationsHelper
+ include Gitlab::Utils::StrongMemoize
+
+ def prometheus_service
+ strong_memoize(:prometheus_service) do
+ @project.find_or_initialize_service(::PrometheusService.to_param)
+ end
+ end
+
+ def alerts_service
+ strong_memoize(:alerts_service) do
+ @project.find_or_initialize_service(::AlertsService.to_param)
+ end
+ end
+
+ def alerts_settings_data(disabled: false)
+ {
+ 'prometheus_activated' => prometheus_service.manual_configuration?.to_s,
+ 'activated' => alerts_service.activated?.to_s,
+ 'prometheus_form_path' => scoped_integration_path(prometheus_service),
+ 'form_path' => scoped_integration_path(alerts_service),
+ 'prometheus_reset_key_path' => reset_alerting_token_project_settings_operations_path(@project),
+ 'prometheus_authorization_key' => @project.alerting_setting&.token,
+ 'prometheus_api_url' => prometheus_service.api_url,
+ 'authorization_key' => alerts_service.token,
+ 'prometheus_url' => notify_project_prometheus_alerts_url(@project, format: :json),
+ 'url' => alerts_service.url,
+ 'alerts_setup_url' => help_page_path('user/project/integrations/generic_alerts.md', anchor: 'setting-up-generic-alerts'),
+ 'alerts_usage_url' => project_alert_management_index_path(@project),
+ 'disabled' => disabled.to_s
+ }
+ end
+
+ def operations_settings_data
+ setting = project_incident_management_setting
+ templates = setting.available_issue_templates.map { |t| { key: t.key, name: t.name } }
+
+ {
+ operations_settings_endpoint: project_settings_operations_path(@project),
+ templates: templates.to_json,
+ create_issue: setting.create_issue.to_s,
+ issue_template_key: setting.issue_template_key.to_s,
+ send_email: setting.send_email.to_s,
+ pagerduty_active: setting.pagerduty_active.to_s,
+ pagerduty_token: setting.pagerduty_token.to_s,
+ pagerduty_webhook_url: project_incidents_pagerduty_url(@project, token: setting.pagerduty_token),
+ pagerduty_reset_key_path: reset_pagerduty_token_project_settings_operations_path(@project)
+ }
+ end
+end
+
+OperationsHelper.prepend_if_ee('EE::OperationsHelper')
diff --git a/app/helpers/pipeline_schedules_helper.rb b/app/helpers/pipeline_schedules_helper.rb
deleted file mode 100644
index 0e166106b32..00000000000
--- a/app/helpers/pipeline_schedules_helper.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module PipelineSchedulesHelper
- def timezone_data
- ActiveSupport::TimeZone.all.map do |timezone|
- {
- name: timezone.name,
- offset: timezone.now.utc_offset,
- identifier: timezone.tzinfo.identifier
- }
- end
- end
-end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 7a0462e1b2c..271359fcfd1 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -70,7 +70,10 @@ module PreferencesHelper
end
def language_choices
- Gitlab::I18n::AVAILABLE_LANGUAGES.map(&:reverse).sort
+ options_for_select(
+ Gitlab::I18n::AVAILABLE_LANGUAGES.map(&:reverse).sort,
+ current_user.preferred_language
+ )
end
private
diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb
index bc585899591..d6e8e738a1c 100644
--- a/app/helpers/projects/alert_management_helper.rb
+++ b/app/helpers/projects/alert_management_helper.rb
@@ -4,10 +4,11 @@ module Projects::AlertManagementHelper
def alert_management_data(current_user, project)
{
'project-path' => project.full_path,
- 'enable-alert-management-path' => edit_project_service_path(project, AlertsService),
+ 'enable-alert-management-path' => project_settings_operations_path(project, anchor: 'js-alert-management-settings'),
+ 'populating-alerts-help-url' => help_page_url('user/project/operations/alert_management.html', anchor: 'enable-alert-management'),
'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'),
- 'user-can-enable-alert-management' => can?(current_user, :admin_project, project).to_s,
- 'alert-management-enabled' => (!!project.alerts_service_activated?).to_s
+ 'user-can-enable-alert-management' => can?(current_user, :admin_operations, project).to_s,
+ 'alert-management-enabled' => alert_management_enabled?(project).to_s
}
end
@@ -15,7 +16,16 @@ module Projects::AlertManagementHelper
{
'alert-id' => alert_id,
'project-path' => project.full_path,
+ 'project-id' => project.id,
'project-issues-path' => project_issues_path(project)
}
end
+
+ private
+
+ def alert_management_enabled?(project)
+ !!(project.alerts_service_activated? || project.prometheus_service_active?)
+ end
end
+
+Projects::AlertManagementHelper.prepend_if_ee('EE::Projects::AlertManagementHelper')
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index bda9a69d71f..840e3ef9daa 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -180,7 +180,7 @@ module ProjectsHelper
end
def link_to_autodeploy_doc
- link_to _('About auto deploy'), help_page_path('autodevops/index.md#auto-deploy'), target: '_blank'
+ link_to _('About auto deploy'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-deploy'), target: '_blank'
end
def autodeploy_flash_notice(branch_name)
@@ -384,9 +384,12 @@ module ProjectsHelper
end
def project_license_name(project)
- project.repository.license&.name
+ key = "project:#{project.id}:license_name"
+
+ Gitlab::SafeRequestStore.fetch(key) { project.repository.license&.name }
rescue GRPC::Unavailable, GRPC::DeadlineExceeded, Gitlab::Git::CommandError => e
Gitlab::ErrorTracking.track_exception(e)
+ Gitlab::SafeRequestStore[key] = nil
nil
end
@@ -397,7 +400,7 @@ module ProjectsHelper
nav_tabs = [:home]
unless project.empty_repo?
- nav_tabs << [:files, :commits, :network, :graphs, :forks] if can?(current_user, :download_code, project)
+ nav_tabs += [:files, :commits, :network, :graphs, :forks] if can?(current_user, :download_code, project)
nav_tabs << :releases if can?(current_user, :read_release, project)
end
@@ -418,30 +421,30 @@ module ProjectsHelper
nav_tabs << :operations
end
- if can?(current_user, :read_cycle_analytics, project)
- nav_tabs << :cycle_analytics
- end
-
tab_ability_map.each do |tab, ability|
if can?(current_user, ability, project)
nav_tabs << tab
end
end
- nav_tabs << external_nav_tabs(project)
+ apply_external_nav_tabs(nav_tabs, project)
- nav_tabs.flatten
+ nav_tabs
end
- def external_nav_tabs(project)
- [].tap do |tabs|
- tabs << :external_issue_tracker if project.external_issue_tracker
- tabs << :external_wiki if project.external_wiki
+ def apply_external_nav_tabs(nav_tabs, project)
+ nav_tabs << :external_issue_tracker if project.external_issue_tracker
+ nav_tabs << :external_wiki if project.external_wiki
+
+ if project.has_confluence?
+ nav_tabs.delete(:wiki)
+ nav_tabs << :confluence
end
end
def tab_ability_map
{
+ cycle_analytics: :read_cycle_analytics,
environments: :read_environment,
metrics_dashboards: :metrics_dashboard,
milestones: :read_milestone,
@@ -565,7 +568,7 @@ module ProjectsHelper
end
def project_child_container_class(view_path)
- view_path == "projects/issues/issues" ? "prepend-top-default" : "project-show-#{view_path}"
+ view_path == "projects/issues/issues" ? "gl-mt-3" : "project-show-#{view_path}"
end
def project_issues(project)
@@ -729,10 +732,6 @@ module ProjectsHelper
!project.repository.gitlab_ci_yml
end
- def vue_file_list_enabled?
- Feature.enabled?(:vue_file_list, @project, default_enabled: true)
- end
-
def native_code_navigation_enabled?(project)
Feature.enabled?(:code_navigation, project, default_enabled: true)
end
diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb
index 1238567a4ed..a3d944c64cc 100644
--- a/app/helpers/releases_helper.rb
+++ b/app/helpers/releases_helper.rb
@@ -18,21 +18,40 @@ module ReleasesHelper
illustration_path: illustration,
documentation_path: help_page
}.tap do |data|
- data[:new_release_path] = new_project_tag_path(@project) if can?(current_user, :create_release, @project)
+ if can?(current_user, :create_release, @project)
+ data[:new_release_path] = if Feature.enabled?(:new_release_page, @project)
+ new_project_release_path(@project)
+ else
+ new_project_tag_path(@project)
+ end
+ end
end
end
def data_for_edit_release_page
+ new_edit_pages_shared_data.merge(
+ tag_name: @release.tag,
+ releases_page_path: project_releases_path(@project, anchor: @release.tag)
+ )
+ end
+
+ def data_for_new_release_page
+ new_edit_pages_shared_data.merge(
+ default_branch: @project.default_branch
+ )
+ end
+
+ private
+
+ def new_edit_pages_shared_data
{
project_id: @project.id,
- tag_name: @release.tag,
markdown_preview_path: preview_markdown_path(@project),
markdown_docs_path: help_page_path('user/markdown'),
- releases_page_path: project_releases_path(@project, anchor: @release.tag),
update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'),
release_assets_docs_path: help_page(anchor: 'release-assets'),
manage_milestones_path: project_milestones_path(@project),
- new_milestone_path: new_project_milestone_url(@project)
+ new_milestone_path: new_project_milestone_path(@project)
}
end
end
diff --git a/app/helpers/runners_helper.rb b/app/helpers/runners_helper.rb
deleted file mode 100644
index d871aaa9c86..00000000000
--- a/app/helpers/runners_helper.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-# frozen_string_literal: true
-
-module RunnersHelper
- def runner_status_icon(runner)
- status = runner.status
- case status
- when :not_connected
- content_tag :i, nil,
- class: "fa fa-warning",
- title: "New runner. Has not connected yet"
-
- when :online, :offline, :paused
- content_tag :i, nil,
- class: "fa fa-circle runner-status-#{status}",
- title: "Runner is #{status}, last contact was #{time_ago_in_words(runner.contacted_at)} ago"
- end
- end
-
- def runner_link(runner)
- display_name = truncate(runner.display_name, length: 15)
- id = "\##{runner.id}"
-
- if current_user && current_user.admin
- link_to admin_runner_path(runner) do
- display_name + id
- end
- else
- display_name + id
- end
- end
-
- # Due to inability of performing sorting of runners by cached "contacted_at" values we have to show uncached values if sorting by "contacted_asc" is requested.
- # Please refer to the following issue for more details: https://gitlab.com/gitlab-org/gitlab-foss/issues/55920
- def runner_contacted_at(runner)
- if params[:sort] == 'contacted_asc'
- runner.uncached_contacted_at
- else
- runner.contacted_at
- end
- end
-end
-
-RunnersHelper.prepend_if_ee('EE::RunnersHelper')
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 4e3b6aad8cc..1b9876b9a6a 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -3,6 +3,28 @@
module SearchHelper
SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets].freeze
+ def search_autocomplete_opts(term)
+ return unless current_user
+
+ resources_results = [
+ groups_autocomplete(term),
+ projects_autocomplete(term)
+ ].flatten
+
+ search_pattern = Regexp.new(Regexp.escape(term), "i")
+
+ generic_results = project_autocomplete + default_autocomplete + help_autocomplete
+ generic_results.concat(default_autocomplete_admin) if current_user.admin?
+ generic_results.select! { |result| result[:label] =~ search_pattern }
+
+ [
+ resources_results,
+ generic_results
+ ].flatten.uniq do |item|
+ item[:label]
+ end
+ end
+
def search_entries_info(collection, scope, term)
return if collection.to_a.empty?
@@ -62,7 +84,7 @@ module SearchHelper
}).html_safe
end
- # Overriden in EE
+ # Overridden in EE
def search_blob_title(project, path)
path
end
@@ -73,6 +95,91 @@ module SearchHelper
private
+ # Autocomplete results for various settings pages
+ def default_autocomplete
+ [
+ { category: "Settings", label: _("User settings"), url: profile_path },
+ { category: "Settings", label: _("SSH Keys"), url: profile_keys_path },
+ { category: "Settings", label: _("Dashboard"), url: root_path }
+ ]
+ end
+
+ # Autocomplete results for settings pages, for admins
+ def default_autocomplete_admin
+ [
+ { category: "Settings", label: _("Admin Section"), url: admin_root_path }
+ ]
+ end
+
+ # Autocomplete results for internal help pages
+ def help_autocomplete
+ [
+ { category: "Help", label: _("API Help"), url: help_page_path("api/README") },
+ { category: "Help", label: _("Markdown Help"), url: help_page_path("user/markdown") },
+ { category: "Help", label: _("Permissions Help"), url: help_page_path("user/permissions") },
+ { category: "Help", label: _("Public Access Help"), url: help_page_path("public_access/public_access") },
+ { category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/README") },
+ { category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/README") },
+ { category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") },
+ { category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") },
+ { category: "Help", label: _("Workflow Help"), url: help_page_path("workflow/README") }
+ ]
+ end
+
+ # Autocomplete results for the current project, if it's defined
+ def project_autocomplete
+ if @project && @project.repository.root_ref
+ ref = @ref || @project.repository.root_ref
+
+ [
+ { category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) },
+ { category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) },
+ { category: "In this project", label: _("Network"), url: project_network_path(@project, ref) },
+ { category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) },
+ { category: "In this project", label: _("Issues"), url: project_issues_path(@project) },
+ { category: "In this project", label: _("Merge Requests"), url: project_merge_requests_path(@project) },
+ { category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) },
+ { category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) },
+ { category: "In this project", label: _("Members"), url: project_project_members_path(@project) },
+ { category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) }
+ ]
+ else
+ []
+ end
+ end
+
+ # Autocomplete results for the current user's groups
+ # rubocop: disable CodeReuse/ActiveRecord
+ def groups_autocomplete(term, limit = 5)
+ current_user.authorized_groups.order_id_desc.search(term).limit(limit).map do |group|
+ {
+ category: "Groups",
+ id: group.id,
+ label: "#{search_result_sanitize(group.full_name)}",
+ url: group_path(group),
+ avatar_url: group.avatar_url || ''
+ }
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # Autocomplete results for the current user's projects
+ # rubocop: disable CodeReuse/ActiveRecord
+ def projects_autocomplete(term, limit = 5)
+ current_user.authorized_projects.order_id_desc.search_by_title(term)
+ .sorted_by_stars_desc.non_archived.limit(limit).map do |p|
+ {
+ category: "Projects",
+ id: p.id,
+ value: "#{search_result_sanitize(p.name)}",
+ label: "#{search_result_sanitize(p.full_name)}",
+ url: project_path(p),
+ avatar_url: p.avatar_url || ''
+ }
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
def search_result_sanitize(str)
Sanitize.clean(str)
end
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index fe839b92ba6..1f9cce80bed 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -4,25 +4,29 @@ module ServicesHelper
def service_event_description(event)
case event
when "push", "push_events"
- "Event will be triggered by a push to the repository"
+ s_("ProjectService|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"
+ s_("ProjectService|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"
+ s_("ProjectService|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"
+ s_("ProjectService|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"
+ s_("ProjectService|Event will be triggered when an issue is created/updated/closed")
+ when "confidential_issue", "confidential_issue_events"
+ s_("ProjectService|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"
+ s_("ProjectService|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"
+ s_("ProjectService|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"
+ s_("ProjectService|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"
+ s_("ProjectService|Event will be triggered when a commit is created/updated")
+ when "deployment"
+ s_("ProjectService|Event will be triggered when a deployment finishes")
+ when "alert"
+ s_("ProjectService|Event will be triggered when a new, unique alert is recorded")
end
end
@@ -44,15 +48,8 @@ module ServicesHelper
end
end
- def event_action_description(action)
- case action
- when "comment"
- s_("ProjectService|Comment will be posted on each event")
- end
- end
-
- def service_save_button
- button_tag(class: 'btn btn-success', type: 'submit', data: { qa_selector: 'save_changes_button' }) do
+ def service_save_button(disabled: false)
+ button_tag(class: 'btn btn-success', type: 'submit', disabled: disabled, data: { qa_selector: 'save_changes_button' }) do
icon('spinner spin', class: 'hidden js-btn-spinner') +
content_tag(:span, 'Save changes', class: 'js-btn-label')
end
@@ -90,7 +87,7 @@ module ServicesHelper
def scoped_test_integration_path(integration)
if @project.present?
- test_project_settings_integration_path(@project, integration)
+ test_project_service_path(@project, integration)
elsif @group.present?
test_group_settings_integration_path(@group, integration)
else
@@ -99,25 +96,45 @@ module ServicesHelper
end
def integration_form_refactor?
- Feature.enabled?(:integration_form_refactor, @project)
+ Feature.enabled?(:integration_form_refactor, @project, default_enabled: true)
end
- def trigger_events_for_service
+ def integration_form_data(integration)
+ {
+ id: integration.id,
+ show_active: integration.show_active_box?.to_s,
+ activated: (integration.active || integration.new_record?).to_s,
+ type: integration.to_param,
+ merge_request_events: integration.merge_requests_events.to_s,
+ commit_events: integration.commit_events.to_s,
+ enable_comments: integration.comment_on_event_enabled.to_s,
+ comment_detail: integration.comment_detail,
+ trigger_events: trigger_events_for_service(integration),
+ fields: fields_for_service(integration),
+ inherit_from_id: integration.inherit_from_id
+ }
+ end
+
+ def trigger_events_for_service(integration)
return [] unless integration_form_refactor?
- ServiceEventSerializer.new(service: @service).represent(@service.configurable_events).to_json
+ ServiceEventSerializer.new(service: integration).represent(integration.configurable_events).to_json
end
- def fields_for_service
+ def fields_for_service(integration)
return [] unless integration_form_refactor?
- ServiceFieldSerializer.new(service: @service).represent(@service.global_fields).to_json
+ ServiceFieldSerializer.new(service: integration).represent(integration.global_fields).to_json
end
- def show_service_trigger_events?
- return false if @service.is_a?(JiraService) || integration_form_refactor?
+ def show_service_trigger_events?(integration)
+ return false if integration.is_a?(JiraService) || integration_form_refactor?
+
+ integration.configurable_events.present?
+ end
- @service.configurable_events.present?
+ def project_jira_issues_integration?
+ false
end
extend self
diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb
index ce810433a3a..13bf9c92d52 100644
--- a/app/helpers/storage_helper.rb
+++ b/app/helpers/storage_helper.rb
@@ -14,9 +14,10 @@ module StorageHelper
counter_repositories: storage_counter(statistics.repository_size),
counter_wikis: storage_counter(statistics.wiki_size),
counter_build_artifacts: storage_counter(statistics.build_artifacts_size),
- counter_lfs_objects: storage_counter(statistics.lfs_objects_size)
+ counter_lfs_objects: storage_counter(statistics.lfs_objects_size),
+ counter_snippets: storage_counter(statistics.snippets_size)
}
- _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects}") % counters
+ _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets}") % counters
end
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 7baa615d36f..6ea6a33ba5e 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -31,7 +31,9 @@ module SystemNoteHelper
'designs_added' => 'doc-image',
'designs_modified' => 'doc-image',
'designs_removed' => 'doc-image',
- 'designs_discussion_added' => 'doc-image'
+ 'designs_discussion_added' => 'doc-image',
+ 'status' => 'status',
+ 'alert_issue_added' => 'issues'
}.freeze
def system_note_icon_name(note)
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 2b4f2f11d1e..b9a6cab07a8 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -22,6 +22,7 @@ module TodosHelper
when Todo::APPROVAL_REQUIRED then "set #{todo_action_subject(todo)} as an approver for"
when Todo::UNMERGEABLE then 'Could not merge'
when Todo::DIRECTLY_ADDRESSED then "directly addressed #{todo_action_subject(todo)} on"
+ when Todo::MERGE_TRAIN_REMOVED then "Removed from Merge Train:"
end
end
@@ -97,11 +98,13 @@ module TodosHelper
'mr'
when Issue
'issue'
+ when AlertManagement::Alert
+ 'alert'
end
content_tag(:span, nil, class: 'target-status') do
- content_tag(:span, nil, class: "status-box status-box-#{type}-#{todo.target.state.dasherize}") do
- todo.target.state.capitalize
+ content_tag(:span, nil, class: "status-box status-box-#{type}-#{todo.target.state.to_s.dasherize}") do
+ todo.target.state.to_s.capitalize
end
end
end
@@ -195,6 +198,10 @@ module TodosHelper
"&middot; #{content}".html_safe
end
+ def todo_author_display?(todo)
+ !todo.build_failed? && !todo.unmergeable?
+ end
+
private
def todos_design_path(todo, path_options)
@@ -214,7 +221,14 @@ module TodosHelper
end
def show_todo_state?(todo)
- (todo.target.is_a?(MergeRequest) || todo.target.is_a?(Issue)) && %w(closed merged).include?(todo.target.state)
+ case todo.target
+ when MergeRequest, Issue
+ %w(closed merged).include?(todo.target.state)
+ when AlertManagement::Alert
+ %i(resolved).include?(todo.target.state)
+ else
+ false
+ end
end
def todo_group_options
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 4dc00581703..90a5b6da4c7 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -191,8 +191,10 @@ module TreeHelper
def vue_file_list_data(project, ref)
{
+ can_push_code: current_user&.can?(:push_code, project) && "true",
project_path: project.full_path,
project_short_path: project.path,
+ fork_path: current_user&.fork_of(project)&.full_path,
ref: ref,
escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref),
full_name: project.name_with_namespace
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 3c983606b73..cf2d2d178e1 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -3,6 +3,30 @@
module WikiHelper
include API::Helpers::RelatedResourcesHelpers
+ def wiki_page_title(page, action = nil)
+ titles = [_('Wiki')]
+
+ if page.persisted?
+ titles << page.human_title
+ breadcrumb_title(page.human_title)
+ wiki_breadcrumb_dropdown_links(page.slug)
+ end
+
+ titles << action if action
+ page_title(*titles.reverse)
+ add_to_breadcrumbs(_('Wiki'), wiki_path(page.wiki))
+ end
+
+ def link_to_wiki_page(page, **options)
+ link_to page.human_title, wiki_page_path(page.wiki, page), **options
+ end
+
+ def wiki_sidebar_toggle_button
+ content_tag :button, class: 'btn btn-default sidebar-toggle js-sidebar-wiki-toggle', role: 'button', type: 'button' do
+ sprite_icon('chevron-double-lg-left')
+ end
+ end
+
# Produces a pure text breadcrumb for a given page.
#
# page_slug - The slug of a WikiPage object.
@@ -71,10 +95,13 @@ module WikiHelper
def wiki_empty_state_messages(wiki)
case wiki.container
when Project
+ writable_body = s_("WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, its principles, how to use it, and so on.")
+ writable_body += s_("WikiEmpty| Have a Confluence wiki already? Use that instead.") if show_enable_confluence_integration?(wiki.container)
+
{
writable: {
title: s_('WikiEmpty|The wiki lets you write documentation for your project'),
- body: s_("WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, its principles, how to use it, and so on.")
+ body: writable_body
},
issuable: {
title: s_('WikiEmpty|This project has no wiki pages'),
@@ -104,4 +131,19 @@ module WikiHelper
raise NotImplementedError, "Unknown wiki container type #{wiki.container.class.name}"
end
end
+
+ def wiki_page_tracking_context(page)
+ {
+ 'wiki-format' => page.format,
+ 'wiki-title-size' => page.title.bytesize,
+ 'wiki-content-size' => page.raw_content.bytesize,
+ 'wiki-directory-nest-level' => page.path.scan('/').count
+ }
+ end
+
+ def show_enable_confluence_integration?(container)
+ container.is_a?(Project) &&
+ current_user&.can?(:admin_project, container) &&
+ !container.has_confluence?
+ end
end
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index 76b1c2d234c..c709c2950d6 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -92,6 +92,13 @@ module Emails
mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id, reason))
end
+ def merge_when_pipeline_succeeds_email(recipient_id, merge_request_id, mwps_set_by_user_id, reason = nil)
+ setup_merge_request_mail(merge_request_id, recipient_id)
+
+ @mwps_set_by = ::User.find(mwps_set_by_user_id)
+ mail_answer_thread(@merge_request, merge_request_thread_options(mwps_set_by_user_id, recipient_id, reason))
+ end
+
private
def setup_merge_request_mail(merge_request_id, recipient_id, present: false)
diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb
new file mode 100644
index 00000000000..29fe608472d
--- /dev/null
+++ b/app/mailers/emails/service_desk.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module Emails
+ module ServiceDesk
+ extend ActiveSupport::Concern
+ include MarkupHelper
+
+ included do
+ layout 'service_desk', only: [:service_desk_thank_you_email, :service_desk_new_note_email]
+ end
+
+ def service_desk_thank_you_email(issue_id)
+ setup_service_desk_mail(issue_id)
+
+ email_sender = sender(
+ @support_bot.id,
+ send_from_user_email: false,
+ sender_name: @project.service_desk_setting&.outgoing_name
+ )
+ options = service_desk_options(email_sender, 'thank_you')
+ .merge(subject: "Re: #{subject_base}")
+
+ mail_new_thread(@issue, options)
+ end
+
+ def service_desk_new_note_email(issue_id, note_id)
+ @note = Note.find(note_id)
+ setup_service_desk_mail(issue_id)
+
+ email_sender = sender(@note.author_id)
+ options = service_desk_options(email_sender, 'new_note')
+ .merge(subject: subject_base)
+
+ mail_answer_thread(@issue, options)
+ end
+
+ private
+
+ def setup_service_desk_mail(issue_id)
+ @issue = Issue.find(issue_id)
+ @project = @issue.project
+ @support_bot = User.support_bot
+
+ @sent_notification = SentNotification.record(@issue, @support_bot.id, reply_key)
+ end
+
+ def service_desk_options(email_sender, email_type)
+ {
+ from: email_sender,
+ to: @issue.service_desk_reply_to
+ }.tap do |options|
+ next unless template_body = template_content(email_type)
+
+ options[:body] = template_body
+ options[:content_type] = 'text/html'
+ end
+ end
+
+ def template_content(email_type)
+ template = Gitlab::Template::ServiceDeskTemplate.find(email_type, @project)
+
+ text = substitute_template_replacements(template.content)
+
+ markdown(text, project: @project)
+ rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
+ nil
+ end
+
+ def substitute_template_replacements(template_body)
+ template_body
+ .gsub(/%\{\s*ISSUE_ID\s*\}/, issue_id)
+ .gsub(/%\{\s*ISSUE_PATH\s*\}/, issue_path)
+ .gsub(/%\{\s*NOTE_TEXT\s*\}/, note_text)
+ end
+
+ def issue_id
+ "#{Issue.reference_prefix}#{@issue.iid}"
+ end
+
+ def issue_path
+ @issue.to_reference(full: true)
+ end
+
+ def note_text
+ @note&.note.to_s
+ end
+
+ def subject_base
+ "#{@issue.title} (##{@issue.iid})"
+ end
+ end
+end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 2cf72d40635..f9aba3fe4f2 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -19,6 +19,7 @@ class Notify < ApplicationMailer
include Emails::Releases
include Emails::Groups
include Emails::Reviews
+ include Emails::ServiceDesk
helper TimeboxesHelper
helper MergeRequestsHelper
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index cb7c6a36c27..c70ac1428cd 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -165,6 +165,22 @@ class NotifyPreview < ActionMailer::Preview
Notify.unknown_sign_in_email(user, '127.0.0.1', Time.current).message
end
+ def service_desk_new_note_email
+ cleanup do
+ note = create_note(noteable_type: 'Issue', noteable_id: issue.id, note: 'Issue note content')
+
+ Notify.service_desk_new_note_email(issue.id, note.id).message
+ end
+ end
+
+ def service_desk_thank_you_email
+ Notify.service_desk_thank_you_email(issue.id).message
+ end
+
+ def merge_when_pipeline_succeeds_email
+ Notify.merge_when_pipeline_succeeds_email(user.id, merge_request.id, user.id).message
+ end
+
private
def project
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index a23190cc8b3..be07c221f32 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -91,8 +91,11 @@ class ActiveSession
key_names = session_ids.map { |session_id| key_name(user.id, session_id.public_id) }
redis.srem(lookup_key_name(user.id), session_ids.map(&:public_id))
- redis.del(key_names)
- redis.del(rack_session_keys(session_ids))
+
+ Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+ redis.del(key_names)
+ redis.del(rack_session_keys(session_ids))
+ end
end
def self.cleanup(user)
@@ -136,8 +139,10 @@ class ActiveSession
session_keys = rack_session_keys(session_ids)
session_keys.each_slice(SESSION_BATCH_SIZE).flat_map do |session_keys_batch|
- redis.mget(session_keys_batch).compact.map do |raw_session|
- load_raw_session(raw_session)
+ Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+ redis.mget(session_keys_batch).compact.map do |raw_session|
+ load_raw_session(raw_session)
+ end
end
end
end
@@ -178,7 +183,9 @@ class ActiveSession
entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
- redis.mget(entry_keys)
+ Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+ redis.mget(entry_keys)
+ end
end
def self.active_session_entries(session_ids, user_id, redis)
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
index af60ddd6f9a..fb166fb56b7 100644
--- a/app/models/alert_management/alert.rb
+++ b/app/models/alert_management/alert.rb
@@ -10,6 +10,7 @@ module AlertManagement
include Sortable
include Noteable
include Gitlab::SQL::Pattern
+ include Presentable
STATUSES = {
triggered: 0,
@@ -25,8 +26,17 @@ module AlertManagement
ignored: :ignore
}.freeze
+ OPEN_STATUSES = [
+ :triggered,
+ :acknowledged
+ ].freeze
+
+ DETAILS_IGNORED_PARAMS = %w(start_time).freeze
+
belongs_to :project
belongs_to :issue, optional: true
+ belongs_to :prometheus_alert, optional: true
+ belongs_to :environment, optional: true
has_many :alert_assignees, inverse_of: :alert
has_many :assignees, through: :alert_assignees
@@ -50,8 +60,12 @@ module AlertManagement
validates :severity, presence: true
validates :status, presence: true
validates :started_at, presence: true
- validates :fingerprint, uniqueness: { scope: :project }, allow_blank: true
- validate :hosts_length
+ validates :fingerprint, allow_blank: true, uniqueness: {
+ scope: :project,
+ conditions: -> { not_resolved },
+ message: -> (object, data) { _('Cannot have multiple unresolved alerts') }
+ }, unless: :resolved?
+ validate :hosts_length
enum severity: {
critical: 0,
@@ -108,15 +122,30 @@ module AlertManagement
scope :for_iid, -> (iid) { where(iid: iid) }
scope :for_status, -> (status) { where(status: status) }
scope :for_fingerprint, -> (project, fingerprint) { where(project: project, fingerprint: fingerprint) }
+ scope :for_environment, -> (environment) { where(environment: environment) }
scope :search, -> (query) { fuzzy_search(query, [:title, :description, :monitoring_tool, :service]) }
+ scope :open, -> { with_status(OPEN_STATUSES) }
+ scope :not_resolved, -> { where.not(status: STATUSES[:resolved]) }
+ scope :with_prometheus_alert, -> { includes(:prometheus_alert) }
scope :order_start_time, -> (sort_order) { order(started_at: sort_order) }
scope :order_end_time, -> (sort_order) { order(ended_at: sort_order) }
scope :order_event_count, -> (sort_order) { order(events: sort_order) }
- scope :order_severity, -> (sort_order) { order(severity: sort_order) }
- scope :order_status, -> (sort_order) { order(status: sort_order) }
+
+ # Ascending sort order sorts severity from less critical to more critical.
+ # Descending sort order sorts severity from more critical to less critical.
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior
+ scope :order_severity, -> (sort_order) { order(severity: sort_order == :asc ? :desc : :asc) }
+
+ # Ascending sort order sorts statuses: Ignored > Resolved > Acknowledged > Triggered
+ # Descending sort order sorts statuses: Triggered > Acknowledged > Resolved > Ignored
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior
+ scope :order_status, -> (sort_order) { order(status: sort_order == :asc ? :desc : :asc) }
scope :counts_by_status, -> { group(:status).count }
+ scope :counts_by_project_id, -> { group(:project_id).count }
+
+ alias_method :state, :status_name
def self.sort_by_attribute(method)
case method.to_s
@@ -135,8 +164,13 @@ module AlertManagement
end
end
+ def self.last_prometheus_alert_by_project_id
+ ids = select(arel_table[:id].maximum).group(:project_id)
+ with_prometheus_alert.where(id: ids)
+ end
+
def details
- details_payload = payload.except(*attributes.keys)
+ details_payload = payload.except(*attributes.keys, *DETAILS_IGNORED_PARAMS)
Gitlab::Utils::InlineHash.merge_keys(details_payload)
end
@@ -161,6 +195,12 @@ module AlertManagement
project.execute_services(hook_data, :alert_hooks)
end
+ def present
+ return super(presenter_class: AlertManagement::PrometheusAlertPresenter) if prometheus?
+
+ super
+ end
+
private
def hook_data
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index c7e4d64d3d5..9ec407a10a4 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -13,6 +13,10 @@ class ApplicationRecord < ActiveRecord::Base
where(id: ids)
end
+ def self.iid_in(iids)
+ where(iid: iids)
+ end
+
def self.id_not_in(ids)
where.not(id: ids)
end
@@ -34,6 +38,10 @@ class ApplicationRecord < ActiveRecord::Base
false
end
+ def self.at_most(count)
+ limit(count)
+ end
+
def self.safe_find_or_create_by!(*args)
safe_find_or_create_by(*args).tap do |record|
record.validate! unless record.persisted?
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index d24136cc04a..c489d11d462 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -50,6 +50,7 @@ module ApplicationSettingImplementation
default_artifacts_expire_in: '30 days',
default_branch_protection: Settings.gitlab['default_branch_protection'],
default_ci_config_path: nil,
+ default_branch_name: nil,
default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'],
default_project_creation: Settings.gitlab['default_project_creation'],
default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'],
@@ -88,6 +89,7 @@ module ApplicationSettingImplementation
max_attachment_size: Settings.gitlab['max_attachment_size'],
max_import_size: 50,
mirror_available: true,
+ notify_on_unknown_sign_in: true,
outbound_local_requests_whitelist: [],
password_authentication_enabled_for_git: true,
password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'],
@@ -156,7 +158,13 @@ module ApplicationSettingImplementation
snowplow_iglu_registry_url: nil,
custom_http_clone_url_root: nil,
productivity_analytics_start_date: Time.current,
- snippet_size_limit: 50.megabytes
+ snippet_size_limit: 50.megabytes,
+ project_import_limit: 6,
+ project_export_limit: 6,
+ project_download_export_limit: 1,
+ group_import_limit: 6,
+ group_export_limit: 6,
+ group_download_export_limit: 1
}
end
diff --git a/app/models/approval.rb b/app/models/approval.rb
new file mode 100644
index 00000000000..bc123de0b20
--- /dev/null
+++ b/app/models/approval.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Approval < ApplicationRecord
+ belongs_to :user
+ belongs_to :merge_request
+
+ validates :merge_request_id, presence: true
+ validates :user_id, presence: true, uniqueness: { scope: [:merge_request_id] }
+
+ scope :with_user, -> { joins(:user) }
+end
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 3bbd2e43a51..13fc2514f0c 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -3,8 +3,11 @@
class AuditEvent < ApplicationRecord
include CreatedAtFilterable
include IgnorableColumns
+ include BulkInsertSafe
- ignore_column :updated_at, remove_with: '13.3', remove_after: '2020-08-22'
+ PARALLEL_PERSISTENCE_COLUMNS = [:author_name, :entity_path].freeze
+
+ ignore_column :updated_at, remove_with: '13.4', remove_after: '2020-09-22'
serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize
@@ -16,8 +19,15 @@ class AuditEvent < ApplicationRecord
scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) }
scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) }
+ scope :by_author_id, -> (author_id) { where(author_id: author_id) }
after_initialize :initialize_details
+ # Note: The intention is to remove this once refactoring of AuditEvent
+ # has proceeded further.
+ #
+ # See further details in the epic:
+ # https://gitlab.com/groups/gitlab-org/-/epics/2765
+ after_validation :parallel_persist
def self.order_by(method)
case method.to_s
@@ -51,7 +61,11 @@ class AuditEvent < ApplicationRecord
private
def default_author_value
- ::Gitlab::Audit::NullAuthor.for(author_id, details[:author_name])
+ ::Gitlab::Audit::NullAuthor.for(author_id, (self[:author_name] || details[:author_name]))
+ end
+
+ def parallel_persist
+ PARALLEL_PERSISTENCE_COLUMNS.each { |col| self[col] = details[col] }
end
end
diff --git a/app/models/blob_viewer/image.rb b/app/models/blob_viewer/image.rb
index cbebef46c60..97eb0489158 100644
--- a/app/models/blob_viewer/image.rb
+++ b/app/models/blob_viewer/image.rb
@@ -8,7 +8,7 @@ module BlobViewer
self.partial_name = 'image'
self.extensions = UploaderHelper::SAFE_IMAGE_EXT
self.binary = true
- self.switcher_icon = 'picture-o'
+ self.switcher_icon = 'doc-image'
self.switcher_title = 'image'
end
end
diff --git a/app/models/blob_viewer/notebook.rb b/app/models/blob_viewer/notebook.rb
index 57d6d802db3..351502d451f 100644
--- a/app/models/blob_viewer/notebook.rb
+++ b/app/models/blob_viewer/notebook.rb
@@ -8,7 +8,7 @@ module BlobViewer
self.partial_name = 'notebook'
self.extensions = %w(ipynb)
self.binary = false
- self.switcher_icon = 'file-text-o'
+ self.switcher_icon = 'doc-text'
self.switcher_title = 'notebook'
end
end
diff --git a/app/models/blob_viewer/open_api.rb b/app/models/blob_viewer/open_api.rb
index 963b7336c8d..0551f3bb1e3 100644
--- a/app/models/blob_viewer/open_api.rb
+++ b/app/models/blob_viewer/open_api.rb
@@ -8,8 +8,6 @@ module BlobViewer
self.partial_name = 'openapi'
self.file_types = %i(openapi)
self.binary = false
- # TODO: get an icon for OpenAPI
- self.switcher_icon = 'file-pdf-o'
- self.switcher_title = 'OpenAPI'
+ self.switcher_icon = 'api'
end
end
diff --git a/app/models/blob_viewer/rich.rb b/app/models/blob_viewer/rich.rb
index 0f66a672102..46f36cc2674 100644
--- a/app/models/blob_viewer/rich.rb
+++ b/app/models/blob_viewer/rich.rb
@@ -6,7 +6,7 @@ module BlobViewer
included do
self.type = :rich
- self.switcher_icon = 'file-text-o'
+ self.switcher_icon = 'doc-text'
self.switcher_title = 'rendered file'
end
end
diff --git a/app/models/blob_viewer/svg.rb b/app/models/blob_viewer/svg.rb
index 454c6a57568..60a11fbd97e 100644
--- a/app/models/blob_viewer/svg.rb
+++ b/app/models/blob_viewer/svg.rb
@@ -8,7 +8,7 @@ module BlobViewer
self.partial_name = 'svg'
self.extensions = %w(svg)
self.binary = false
- self.switcher_icon = 'picture-o'
+ self.switcher_icon = 'doc-image'
self.switcher_title = 'image'
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index b5e68b55f72..6c90645e997 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -27,7 +27,7 @@ module Ci
upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? },
refspecs: -> (build) { build.merge_request_ref? },
artifacts_exclude: -> (build) { build.supports_artifacts_exclude? },
- release_steps: -> (build) { build.release_steps? }
+ multi_build_steps: -> (build) { build.multi_build_steps? }
}.freeze
DEFAULT_RETRIES = {
@@ -539,7 +539,6 @@ module Ci
.concat(job_variables)
.concat(environment_changed_page_variables)
.concat(persisted_environment_variables)
- .concat(deploy_freeze_variables)
.to_runner_variables
end
end
@@ -595,18 +594,6 @@ module Ci
end
end
- def deploy_freeze_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables unless freeze_period?
-
- variables.append(key: 'CI_DEPLOY_FREEZE', value: 'true')
- end
- end
-
- def freeze_period?
- Ci::FreezePeriodStatus.new(project: project).execute
- end
-
def dependency_variables
return [] if all_dependencies.empty?
@@ -801,6 +788,11 @@ module Ci
has_expiring_artifacts? && job_artifacts_archive.present?
end
+ def self.keep_artifacts!
+ update_all(artifacts_expire_at: nil)
+ Ci::JobArtifact.where(job: self.select(:id)).update_all(expire_at: nil)
+ end
+
def keep_artifacts!
self.update(artifacts_expire_at: nil)
self.job_artifacts.update_all(expire_at: nil)
@@ -885,7 +877,7 @@ module Ci
Gitlab::Ci::Features.artifacts_exclude_enabled?
end
- def release_steps?
+ def multi_build_steps?
options.dig(:release)&.any? &&
Gitlab::Ci::Features.release_generation_enabled?
end
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index 0df5ebfe843..4094bdb26dc 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -19,6 +19,7 @@ module Ci
before_create :set_build_project
validates :build, presence: true
+ validates :secrets, json_schema: { filename: 'build_metadata_secrets' }
serialize :config_options, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize
serialize :config_variables, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize
diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb
index 0b243c20e67..b977a5f4419 100644
--- a/app/models/ci/build_need.rb
+++ b/app/models/ci/build_need.rb
@@ -4,6 +4,8 @@ module Ci
class BuildNeed < ApplicationRecord
extend Gitlab::Ci::Model
+ include BulkInsertSafe
+
belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id, inverse_of: :needs
validates :build, presence: true
diff --git a/app/models/ci/build_trace.rb b/app/models/ci/build_trace.rb
index b9db1559836..f70e1ed69ea 100644
--- a/app/models/ci/build_trace.rb
+++ b/app/models/ci/build_trace.rb
@@ -2,40 +2,22 @@
module Ci
class BuildTrace
- CONVERTERS = {
- html: Gitlab::Ci::Ansi2html,
- json: Gitlab::Ci::Ansi2json
- }.freeze
-
attr_reader :trace, :build
delegate :state, :append, :truncated, :offset, :size, :total, to: :trace, allow_nil: true
delegate :id, :status, :complete?, to: :build, prefix: true
- def initialize(build:, stream:, state:, content_format:)
+ def initialize(build:, stream:, state:)
@build = build
- @content_format = content_format
if stream.valid?
stream.limit
- @trace = CONVERTERS.fetch(content_format).convert(stream.stream, state)
+ @trace = Gitlab::Ci::Ansi2json.convert(stream.stream, state)
end
end
- def json?
- @content_format == :json
- end
-
- def html?
- @content_format == :html
- end
-
- def json_lines
- @trace&.lines if json?
- end
-
- def html_lines
- @trace&.html if html?
+ def lines
+ @trace&.lines
end
end
end
diff --git a/app/models/ci/build_trace_chunks/redis.rb b/app/models/ci/build_trace_chunks/redis.rb
index 813eaf5d839..c3864f78b01 100644
--- a/app/models/ci/build_trace_chunks/redis.rb
+++ b/app/models/ci/build_trace_chunks/redis.rb
@@ -35,7 +35,10 @@ module Ci
keys = keys.map { |key| key_raw(*key) }
Gitlab::Redis::SharedState.with do |redis|
- redis.del(keys)
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/224171
+ Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+ redis.del(keys)
+ end
end
end
diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb
index 8245729a884..628749b32cb 100644
--- a/app/models/ci/instance_variable.rb
+++ b/app/models/ci/instance_variable.rb
@@ -45,13 +45,5 @@ module Ci
end
end
end
-
- private
-
- def validate_plan_limit_not_exceeded
- if Gitlab::Ci::Features.instance_level_variables_limit_enabled?
- super
- end
- end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 8aba9356949..dbeba1ece31 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -7,10 +7,13 @@ module Ci
include UpdateProjectStatistics
include UsageStatistics
include Sortable
+ include IgnorableColumns
extend Gitlab::Ci::Model
NotSupportedAdapterError = Class.new(StandardError)
+ ignore_columns :locked, remove_after: '2020-07-22', remove_with: '13.4'
+
TEST_REPORT_FILE_TYPES = %w[junit].freeze
COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze
ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze
@@ -34,13 +37,16 @@ module Ci
license_management: 'gl-license-management-report.json',
license_scanning: 'gl-license-scanning-report.json',
performance: 'performance.json',
+ browser_performance: 'browser-performance.json',
+ load_performance: 'load-performance.json',
metrics: 'metrics.txt',
lsif: 'lsif.json',
dotenv: '.env',
cobertura: 'cobertura-coverage.xml',
terraform: 'tfplan.json',
cluster_applications: 'gl-cluster-applications.json',
- requirements: 'requirements.json'
+ requirements: 'requirements.json',
+ coverage_fuzzing: 'gl-coverage-fuzzing.json'
}.freeze
INTERNAL_TYPES = {
@@ -72,8 +78,11 @@ module Ci
license_management: :raw,
license_scanning: :raw,
performance: :raw,
+ browser_performance: :raw,
+ load_performance: :raw,
terraform: :raw,
- requirements: :raw
+ requirements: :raw,
+ coverage_fuzzing: :raw
}.freeze
DOWNLOADABLE_TYPES = %w[
@@ -91,6 +100,8 @@ module Ci
lsif
metrics
performance
+ browser_performance
+ load_performance
sast
secret_detection
requirements
@@ -98,9 +109,7 @@ module Ci
TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze
- # This is required since we cannot add a default to the database
- # https://gitlab.com/gitlab-org/gitlab/-/issues/215418
- attribute :locked, :boolean, default: false
+ PLAN_LIMIT_PREFIX = 'ci_max_artifact_size_'
belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
@@ -117,10 +126,9 @@ module Ci
after_save :update_file_store, if: :saved_change_to_file?
scope :not_expired, -> { where('expire_at IS NULL OR expire_at > ?', Time.current) }
- scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
+ scope :with_files_stored_locally, -> { where(file_store: ::JobArtifactUploader::Store::LOCAL) }
scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) }
scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) }
- scope :for_ref, ->(ref, project_id) { joins(job: :pipeline).where(ci_pipelines: { ref: ref, project_id: project_id }) }
scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) }
scope :with_file_types, -> (file_types) do
@@ -157,8 +165,7 @@ module Ci
scope :expired, -> (limit) { where('expire_at < ?', Time.current).limit(limit) }
scope :downloadable, -> { where(file_type: DOWNLOADABLE_TYPES) }
- scope :locked, -> { where(locked: true) }
- scope :unlocked, -> { where(locked: [false, nil]) }
+ scope :unlocked, -> { joins(job: :pipeline).merge(::Ci::Pipeline.unlocked).order(expire_at: :desc) }
scope :scoped_project, -> { where('ci_job_artifacts.project_id = projects.id') }
@@ -176,7 +183,7 @@ module Ci
codequality: 9, ## EE-specific
license_management: 10, ## EE-specific
license_scanning: 101, ## EE-specific till 13.0
- performance: 11, ## EE-specific
+ performance: 11, ## EE-specific till 13.2
metrics: 12, ## EE-specific
metrics_referee: 13, ## runner referees
network_referee: 14, ## runner referees
@@ -187,7 +194,10 @@ module Ci
accessibility: 19,
cluster_applications: 20,
secret_detection: 21, ## EE-specific
- requirements: 22 ## EE-specific
+ requirements: 22, ## EE-specific
+ coverage_fuzzing: 23, ## EE-specific
+ browser_performance: 24, ## EE-specific
+ load_performance: 25 ## EE-specific
}
enum file_format: {
@@ -235,6 +245,12 @@ module Ci
self.update_column(:file_store, file.object_store)
end
+ def self.associated_file_types_for(file_type)
+ return unless file_types.include?(file_type)
+
+ [file_type]
+ end
+
def self.total_size
self.sum(:size)
end
@@ -286,6 +302,21 @@ module Ci
where(job_id: job_id).trace.take&.file&.file&.exists?
end
+ def self.max_artifact_size(type:, project:)
+ max_size = if Feature.enabled?(:ci_max_artifact_size_per_type, project, default_enabled: false)
+ limit_name = "#{PLAN_LIMIT_PREFIX}#{type}"
+
+ project.actual_limits.limit_for(
+ limit_name,
+ alternate_limit: -> { project.closest_setting(:max_artifacts_size) }
+ )
+ else
+ project.closest_setting(:max_artifacts_size)
+ end
+
+ max_size&.megabytes.to_i
+ end
+
private
def file_format_adapter_class
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 497e1a4d74a..d4b439d648f 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -3,7 +3,7 @@
module Ci
class Pipeline < ApplicationRecord
extend Gitlab::Ci::Model
- include HasStatus
+ include Ci::HasStatus
include Importable
include AfterCommitQueue
include Presentable
@@ -51,6 +51,8 @@ module Ci
has_many :latest_builds, -> { latest }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build'
has_many :downloadable_artifacts, -> { not_expired.downloadable }, through: :latest_builds, source: :job_artifacts
+ has_many :messages, class_name: 'Ci::PipelineMessage', inverse_of: :pipeline
+
# Merge requests for which the current pipeline is running against
# the merge request's latest commit.
has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest'
@@ -80,6 +82,7 @@ module Ci
has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline
has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult', foreign_key: :last_pipeline_id
+ has_many :latest_builds_report_results, through: :latest_builds, source: :report_results
accepts_nested_attributes_for :variables, reject_if: :persisted?
@@ -110,6 +113,8 @@ module Ci
# extend this `Hash` with new values.
enum failure_reason: ::Ci::PipelineEnums.failure_reasons
+ enum locked: { unlocked: 0, artifacts_locked: 1 }
+
state_machine :status, initial: :created do
event :enqueue do
transition [:created, :manual, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending
@@ -244,6 +249,14 @@ module Ci
pipeline.run_after_commit { AutoDevops::DisableWorker.perform_async(pipeline.id) }
end
+
+ after_transition any => [:success] do |pipeline|
+ next unless Gitlab::Ci::Features.keep_latest_artifacts_for_ref_enabled?(pipeline.project)
+
+ pipeline.run_after_commit do
+ Ci::PipelineSuccessUnlockArtifactsWorker.perform_async(pipeline.id)
+ end
+ end
end
scope :internal, -> { where(source: internal_sources) }
@@ -256,7 +269,14 @@ module Ci
scope :for_ref, -> (ref) { where(ref: ref) }
scope :for_id, -> (id) { where(id: id) }
scope :for_iid, -> (iid) { where(iid: iid) }
+ scope :for_project, -> (project) { where(project: project) }
scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) }
+ scope :created_before_id, -> (id) { where('ci_pipelines.id < ?', id) }
+ scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) }
+
+ scope :outside_pipeline_family, ->(pipeline) do
+ where.not(id: pipeline.same_family_pipeline_ids)
+ end
scope :with_reports, -> (reports_scope) do
where('EXISTS (?)', ::Ci::Build.latest.with_reports(reports_scope).where('ci_pipelines.id=ci_builds.commit_id').select(1))
@@ -270,6 +290,15 @@ module Ci
)
end
+ # Returns the pipelines that associated with the given merge request.
+ # In general, please use `Ci::PipelinesForMergeRequestFinder` instead,
+ # for checking permission of the actor.
+ scope :triggered_by_merge_request, -> (merge_request) do
+ ci_sources.where(source: :merge_request_event,
+ merge_request: merge_request,
+ project: [merge_request.source_project, merge_request.target_project])
+ end
+
# Returns the pipelines in descending order (= newest first), optionally
# limited to a number of references.
#
@@ -348,6 +377,10 @@ module Ci
success.group(:project_id).select('max(id) as id')
end
+ def self.last_finished_for_ref_id(ci_ref_id)
+ where(ci_ref_id: ci_ref_id).ci_sources.finished.order(id: :desc).select(:id).take
+ end
+
def self.truncate_sha(sha)
sha[0...8]
end
@@ -440,6 +473,10 @@ module Ci
end
end
+ def triggered_pipelines_with_preloads
+ triggered_pipelines.preload(:source_job)
+ end
+
def legacy_stages
if ::Gitlab::Ci::Features.composite_status?(project)
legacy_stages_using_composite_status
@@ -552,10 +589,28 @@ module Ci
end
end
+ def lazy_ref_commit
+ return unless ::Gitlab::Ci::Features.pipeline_latest?
+
+ BatchLoader.for(ref).batch do |refs, loader|
+ next unless project.repository_exists?
+
+ project.repository.list_commits_by_ref_name(refs).then do |commits|
+ commits.each { |key, commit| loader.call(key, commits[key]) }
+ end
+ end
+ end
+
def latest?
return false unless git_ref && commit.present?
- project.commit(git_ref) == commit
+ unless ::Gitlab::Ci::Features.pipeline_latest?
+ return project.commit(git_ref) == commit
+ end
+
+ return false if lazy_ref_commit.nil?
+
+ lazy_ref_commit.id == commit.id
end
def retried
@@ -569,10 +624,46 @@ module Ci
end
end
+ def batch_lookup_report_artifact_for_file_type(file_type)
+ latest_report_artifacts
+ .values_at(*::Ci::JobArtifact.associated_file_types_for(file_type.to_s))
+ .flatten
+ .compact
+ .last
+ end
+
+ # This batch loads the latest reports for each CI job artifact
+ # type (e.g. sast, dast, etc.) in a single SQL query to eliminate
+ # the need to do N different `job_artifacts.where(file_type:
+ # X).last` calls.
+ #
+ # Return a hash of file type => array of 1 job artifact
+ def latest_report_artifacts
+ ::Gitlab::SafeRequestStore.fetch("pipeline:#{self.id}:latest_report_artifacts") do
+ # Note we use read_attribute(:project_id) to read the project
+ # ID instead of self.project_id. The latter appears to load
+ # the Project model. This extra filter doesn't appear to
+ # affect query plan but included to ensure we don't leak the
+ # wrong informaiton.
+ ::Ci::JobArtifact.where(
+ id: job_artifacts.with_reports
+ .select('max(ci_job_artifacts.id) as id')
+ .where(project_id: self.read_attribute(:project_id))
+ .group(:file_type)
+ )
+ .preload(:job)
+ .group_by(&:file_type)
+ end
+ end
+
def has_kubernetes_active?
project.deployment_platform&.active?
end
+ def freeze_period?
+ Ci::FreezePeriodStatus.new(project: project).execute
+ end
+
def has_warnings?
number_of_warnings.positive?
end
@@ -607,6 +698,25 @@ module Ci
yaml_errors.present?
end
+ def add_error_message(content)
+ add_message(:error, content)
+ end
+
+ def add_warning_message(content)
+ add_message(:warning, content)
+ end
+
+ # We can't use `messages.error` scope here because messages should also be
+ # read when the pipeline is not persisted. Using the scope will return no
+ # results as it would query persisted data.
+ def error_messages
+ messages.select(&:error?)
+ end
+
+ def warning_messages
+ messages.select(&:warning?)
+ end
+
# Manually set the notes for a Ci::Pipeline
# There is no ActiveRecord relation between Ci::Pipeline and notes
# as they are related to a commit sha. This method helps importing
@@ -639,7 +749,7 @@ module Ci
when 'manual' then block
when 'scheduled' then delay
else
- raise HasStatus::UnknownStatusError,
+ raise Ci::HasStatus::UnknownStatusError,
"Unknown status `#{new_status}`"
end
end
@@ -683,6 +793,7 @@ module Ci
end
variables.append(key: 'CI_KUBERNETES_ACTIVE', value: 'true') if has_kubernetes_active?
+ variables.append(key: 'CI_DEPLOY_FREEZE', value: 'true') if freeze_period?
if external_pull_request_event? && external_pull_request
variables.concat(external_pull_request.predefined_variables)
@@ -748,13 +859,10 @@ module Ci
end
# If pipeline is a child of another pipeline, include the parent
- # and the siblings, otherwise return only itself.
+ # and the siblings, otherwise return only itself and children.
def same_family_pipeline_ids
- if (parent = parent_pipeline)
- [parent.id] + parent.child_pipelines.pluck(:id)
- else
- [self.id]
- end
+ parent = parent_pipeline || self
+ [parent.id] + parent.child_pipelines.pluck(:id)
end
def bridge_triggered?
@@ -802,6 +910,10 @@ module Ci
complete? && latest_report_builds(reports_scope).exists?
end
+ def test_report_summary
+ Gitlab::Ci::Reports::TestReportSummary.new(latest_builds_report_results)
+ end
+
def test_reports
Gitlab::Ci::Reports::TestReports.new.tap do |test_reports|
latest_report_builds(Ci::JobArtifact.test_reports).preload(:project).find_each do |build|
@@ -840,6 +952,10 @@ module Ci
end
end
+ def has_archive_artifacts?
+ complete? && builds.latest.with_existing_job_artifacts(Ci::JobArtifact.archive.or(Ci::JobArtifact.metadata)).exists?
+ end
+
def has_exposed_artifacts?
complete? && builds.latest.with_exposed_artifacts.exists?
end
@@ -925,7 +1041,7 @@ module Ci
stages.find_by!(name: name)
end
- def error_messages
+ def full_error_messages
errors ? errors.full_messages.to_sentence : ""
end
@@ -964,8 +1080,6 @@ module Ci
# Set scheduling type of processables if they were created before scheduling_type
# data was deployed (https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22246).
def ensure_scheduling_type!
- return unless ::Gitlab::Ci::Features.ensure_scheduling_type_enabled?
-
processables.populate_scheduling_type!
end
@@ -977,6 +1091,12 @@ module Ci
private
+ def add_message(severity, content)
+ return unless Gitlab::Ci::Features.store_pipeline_messages?(project)
+
+ messages.build(severity: severity, content: content)
+ end
+
def pipeline_data
Gitlab::DataBuilder::Pipeline.build(self)
end
diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb
index 2ccd8445aa8..352dc56aac7 100644
--- a/app/models/ci/pipeline_enums.rb
+++ b/app/models/ci/pipeline_enums.rb
@@ -31,7 +31,7 @@ module Ci
merge_request_event: 10,
external_pull_request_event: 11,
parent_pipeline: 12,
- ondemand_scan: 13
+ ondemand_dast_scan: 13
}
end
@@ -45,7 +45,8 @@ module Ci
webide_source: 3,
remote_source: 4,
external_project_source: 5,
- bridge_source: 6
+ bridge_source: 6,
+ parameter_source: 7
}
end
diff --git a/app/models/ci/pipeline_message.rb b/app/models/ci/pipeline_message.rb
new file mode 100644
index 00000000000..a47ec554462
--- /dev/null
+++ b/app/models/ci/pipeline_message.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Ci
+ class PipelineMessage < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ MAX_CONTENT_LENGTH = 10_000
+
+ belongs_to :pipeline
+
+ validates :content, presence: true
+
+ before_save :truncate_long_content
+
+ enum severity: { error: 0, warning: 1 }
+
+ private
+
+ def truncate_long_content
+ return if content.length <= MAX_CONTENT_LENGTH
+
+ self.content = content.truncate(MAX_CONTENT_LENGTH)
+ end
+ end
+end
diff --git a/app/models/ci/ref.rb b/app/models/ci/ref.rb
index be6062b6e6e..29b44575d65 100644
--- a/app/models/ci/ref.rb
+++ b/app/models/ci/ref.rb
@@ -43,7 +43,7 @@ module Ci
end
def last_finished_pipeline_id
- Ci::Pipeline.where(ci_ref_id: self.id).finished.order(id: :desc).select(:id).take&.id
+ Ci::Pipeline.last_finished_for_ref_id(self.id)&.id
end
def update_status_by!(pipeline)
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 8fc273556f0..1cd6c64841b 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -239,6 +239,10 @@ module Ci
runner_projects.count == 1
end
+ def belongs_to_more_than_one_project?
+ self.projects.limit(2).count(:all) > 1
+ end
+
def assigned_to_group?
runner_namespaces.any?
end
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index a316b4718e0..41215601704 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -4,10 +4,10 @@ module Ci
class Stage < ApplicationRecord
extend Gitlab::Ci::Model
include Importable
- include HasStatus
+ include Ci::HasStatus
include Gitlab::OptimisticLocking
- enum status: HasStatus::STATUSES_ENUM
+ enum status: Ci::HasStatus::STATUSES_ENUM
belongs_to :project
belongs_to :pipeline
@@ -98,7 +98,7 @@ module Ci
when 'scheduled' then delay
when 'skipped', nil then skip
else
- raise HasStatus::UnknownStatusError,
+ raise Ci::HasStatus::UnknownStatusError,
"Unknown status `#{new_status}`"
end
end
diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb
index 08d39595c61..13358b95a47 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -18,5 +18,7 @@ module Ci
}
scope :unprotected, -> { where(protected: false) }
+ scope :by_key, -> (key) { where(key: key) }
+ scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) }
end
end
diff --git a/app/models/clusters/applications/cilium.rb b/app/models/clusters/applications/cilium.rb
new file mode 100644
index 00000000000..7936b0b18de
--- /dev/null
+++ b/app/models/clusters/applications/cilium.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class Cilium < ApplicationRecord
+ self.table_name = 'clusters_applications_cilium'
+
+ include ::Clusters::Concerns::ApplicationCore
+ include ::Clusters::Concerns::ApplicationStatus
+
+ # Cilium can only be installed and uninstalled through the
+ # cluster-applications project by triggering CI pipeline for a
+ # management project. UI operations are not available for such
+ # applications. More information:
+ # https://docs.gitlab.com/ee/user/clusters/management_project.html
+ def allowed_to_uninstall?
+ false
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index 24bb1df6d22..101d782db3a 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -17,6 +17,9 @@ module Clusters
default_value_for :version, VERSION
+ scope :preload_cluster_platform, -> { preload(cluster: [:platform_kubernetes]) }
+ scope :with_clusters_with_cilium, -> { joins(:cluster).merge(Clusters::Cluster.with_available_cilium) }
+
attr_encrypted :alert_manager_token,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 6d3b6c4ed8f..9ec7c194a26 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
- VERSION = '0.17.1'
+ VERSION = '0.18.1'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index bde7a2104ba..7641b6d2a4b 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -2,6 +2,7 @@
module Clusters
class Cluster < ApplicationRecord
+ prepend HasEnvironmentScope
include Presentable
include Gitlab::Utils::StrongMemoize
include FromUnion
@@ -20,7 +21,8 @@ module Clusters
Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter,
Clusters::Applications::Knative.application_name => Clusters::Applications::Knative,
Clusters::Applications::ElasticStack.application_name => Clusters::Applications::ElasticStack,
- Clusters::Applications::Fluentd.application_name => Clusters::Applications::Fluentd
+ Clusters::Applications::Fluentd.application_name => Clusters::Applications::Fluentd,
+ Clusters::Applications::Cilium.application_name => Clusters::Applications::Cilium
}.freeze
DEFAULT_ENVIRONMENT = '*'
KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'
@@ -64,6 +66,7 @@ module Clusters
has_one_cluster_application :knative
has_one_cluster_application :elastic_stack
has_one_cluster_application :fluentd
+ has_one_cluster_application :cilium
has_many :kubernetes_namespaces
has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :cluster
@@ -81,6 +84,7 @@ module Clusters
validate :no_groups, unless: :group_type?
validate :no_projects, unless: :project_type?
validate :unique_management_project_environment_scope
+ validate :unique_environment_scope
after_save :clear_reactive_cache!
@@ -129,6 +133,7 @@ module Clusters
scope :with_enabled_modsecurity, -> { joins(:application_ingress).merge(::Clusters::Applications::Ingress.modsecurity_enabled) }
scope :with_available_elasticstack, -> { joins(:application_elastic_stack).merge(::Clusters::Applications::ElasticStack.available) }
+ scope :with_available_cilium, -> { joins(:application_cilium).merge(::Clusters::Applications::Cilium.available) }
scope :distinct_with_deployed_environments, -> { joins(:environments).merge(::Deployment.success).distinct }
scope :preload_elasticstack, -> { preload(:application_elastic_stack) }
scope :preload_environments, -> { preload(:environments) }
@@ -228,7 +233,9 @@ module Clusters
def calculate_reactive_cache
return unless enabled?
- { connection_status: retrieve_connection_status, nodes: retrieve_nodes }
+ gitlab_kubernetes_nodes = Gitlab::Kubernetes::Node.new(self)
+
+ { connection_status: retrieve_connection_status, nodes: gitlab_kubernetes_nodes.all.presence }
end
def persisted_applications
@@ -335,7 +342,11 @@ module Clusters
end
def local_tiller_enabled?
- Feature.enabled?(:managed_apps_local_tiller, clusterable, default_enabled: false)
+ Feature.enabled?(:managed_apps_local_tiller, clusterable, default_enabled: true)
+ end
+
+ def prometheus_adapter
+ application_prometheus
end
private
@@ -352,6 +363,12 @@ module Clusters
end
end
+ def unique_environment_scope
+ if clusterable.present? && clusterable.clusters.where(environment_scope: environment_scope).where.not(id: id).exists?
+ errors.add(:environment_scope, 'cannot add duplicated environment scope')
+ end
+ end
+
def managed_namespace(environment)
Clusters::KubernetesNamespaceFinder.new(
self,
@@ -383,54 +400,6 @@ module Clusters
result[:status]
end
- def retrieve_nodes
- result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.get_nodes }
-
- return unless result[:response]
-
- cluster_nodes = result[:response]
-
- result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.metrics_client.get_nodes }
- nodes_metrics = result[:response].to_a
-
- cluster_nodes.inject([]) do |memo, node|
- sliced_node = filter_relevant_node_attributes(node)
-
- matched_node_metric = nodes_metrics.find { |node_metric| node_metric.metadata.name == node.metadata.name }
-
- sliced_node_metrics = matched_node_metric ? filter_relevant_node_metrics_attributes(matched_node_metric) : {}
-
- memo << sliced_node.merge(sliced_node_metrics)
- end
- end
-
- def filter_relevant_node_attributes(node)
- {
- 'metadata' => {
- 'name' => node.metadata.name
- },
- 'status' => {
- 'capacity' => {
- 'cpu' => node.status.capacity.cpu,
- 'memory' => node.status.capacity.memory
- },
- 'allocatable' => {
- 'cpu' => node.status.allocatable.cpu,
- 'memory' => node.status.allocatable.memory
- }
- }
- }
- end
-
- def filter_relevant_node_metrics_attributes(node_metrics)
- {
- 'usage' => {
- 'cpu' => node_metrics.usage.cpu,
- 'memory' => node_metrics.usage.memory
- }
- }
- end
-
# To keep backward compatibility with AUTO_DEVOPS_DOMAIN
# environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN
# is set if AUTO_DEVOPS_DOMAIN is set on any of the following options:
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 444368d0ef3..7af78960e35 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -159,7 +159,16 @@ module Clusters
if ca_pem.present?
opts[:cert_store] = OpenSSL::X509::Store.new
- opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem))
+
+ file = Tempfile.new('cluster_ca_pem_temp')
+ begin
+ file.write(ca_pem)
+ file.rewind
+ opts[:cert_store].add_file(file.path)
+ ensure
+ file.close
+ file.unlink # deletes the temp file
+ end
end
opts
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 681fe727456..53bcdf8165f 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -469,10 +469,12 @@ class Commit
# 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
+ # WIP is deprecated in favor of Draft. Currently both options are supported
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/227426
+ DRAFT_REGEX = /\A\s*#{Regexp.union(Gitlab::Regex.merge_request_wip, Gitlab::Regex.merge_request_draft)}|(fixup!|squash!)\s/.freeze
def work_in_progress?
- !!(title =~ WIP_REGEX)
+ !!(title =~ DRAFT_REGEX)
end
def merged_merge_request?(user)
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index 456d32bf403..b8653f47392 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -53,6 +53,17 @@ class CommitCollection
self
end
+ # Returns the collection with markdown fields preloaded.
+ #
+ # Get the markdown cache from redis using pipeline to prevent n+1 requests
+ # when rendering the markdown of an attribute (e.g. title, full_title,
+ # description).
+ def with_markdown_cache
+ Commit.preload_markdown_cache!(commits)
+
+ self
+ end
+
def unenriched
commits.reject(&:gitaly_commit?)
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 475f82f23ca..c85292feb25 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -1,11 +1,12 @@
# frozen_string_literal: true
class CommitStatus < ApplicationRecord
- include HasStatus
+ include Ci::HasStatus
include Importable
include AfterCommitQueue
include Presentable
include EnumWithNil
+ include BulkInsertableAssociations
self.table_name = 'ci_builds'
diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb
index 39e8408f794..f1c39dda49d 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stage.rb
@@ -125,7 +125,7 @@ module Analytics
def label_available_for_group?(label_id)
LabelsFinder.new(nil, { group_id: group.id, include_ancestor_groups: true, only_group_labels: true })
.execute(skip_authorization: true)
- .by_ids(label_id)
+ .id_in(label_id)
.exists?
end
end
diff --git a/app/models/concerns/approvable_base.rb b/app/models/concerns/approvable_base.rb
new file mode 100644
index 00000000000..6323bd01c58
--- /dev/null
+++ b/app/models/concerns/approvable_base.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module ApprovableBase
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :approvals, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :approved_by_users, through: :approvals, source: :user
+ end
+
+ def approved_by?(user)
+ return false unless user
+
+ approved_by_users.include?(user)
+ end
+end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index a98baeb0e3d..ac84ef94b1c 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -36,6 +36,12 @@ module Avatarable
end
end
+ class_methods do
+ def bot_avatar(image:)
+ Rails.root.join('app', 'assets', 'images', 'bot_avatars', image).open
+ end
+ end
+
def avatar_type
unless self.avatar.image?
errors.add :avatar, "file format is not supported. Please try one of the following supported formats: #{AvatarUploader::SAFE_IMAGE_EXT.join(', ')}"
diff --git a/app/models/concerns/bulk_insert_safe.rb b/app/models/concerns/bulk_insert_safe.rb
index e09f44e68dc..f9eb3fb875e 100644
--- a/app/models/concerns/bulk_insert_safe.rb
+++ b/app/models/concerns/bulk_insert_safe.rb
@@ -37,7 +37,7 @@ module BulkInsertSafe
# These are the callbacks we think safe when used on models that are
# written to the database in bulk
- CALLBACK_NAME_WHITELIST = Set[
+ ALLOWED_CALLBACKS = Set[
:initialize,
:validate,
:validation,
@@ -179,16 +179,12 @@ module BulkInsertSafe
end
def _bulk_insert_callback_allowed?(name, args)
- _bulk_insert_whitelisted?(name) || _bulk_insert_saved_from_belongs_to?(name, args)
+ ALLOWED_CALLBACKS.include?(name) || _bulk_insert_saved_from_belongs_to?(name, args)
end
# belongs_to associations will install a before_save hook during class loading
def _bulk_insert_saved_from_belongs_to?(name, args)
args.first == :before && args.second.to_s.start_with?('autosave_associated_records_for_')
end
-
- def _bulk_insert_whitelisted?(name)
- CALLBACK_NAME_WHITELIST.include?(name)
- end
end
end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index 7ea5382a4fa..10df5e1a8dc 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -84,8 +84,6 @@ module Ci
end
def secret_instance_variables
- return [] unless ::Feature.enabled?(:ci_instance_level_variables, project, default_enabled: true)
-
project.ci_instance_variables_for(ref: git_ref)
end
diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb
new file mode 100644
index 00000000000..c52807ec501
--- /dev/null
+++ b/app/models/concerns/ci/has_status.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+module Ci
+ module HasStatus
+ extend ActiveSupport::Concern
+
+ DEFAULT_STATUS = 'created'
+ BLOCKED_STATUS = %w[manual scheduled].freeze
+ AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze
+ STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze
+ ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze
+ COMPLETED_STATUSES = %w[success failed canceled skipped].freeze
+ ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze
+ PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze
+ EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze
+ STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
+ failed: 4, canceled: 5, skipped: 6, manual: 7,
+ scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze
+
+ UnknownStatusError = Class.new(StandardError)
+
+ class_methods do
+ def legacy_status_sql
+ scope_relevant = respond_to?(:exclude_ignored) ? exclude_ignored : all
+ scope_warnings = respond_to?(:failed_but_allowed) ? failed_but_allowed : none
+
+ builds = scope_relevant.select('count(*)').to_sql
+ created = scope_relevant.created.select('count(*)').to_sql
+ success = scope_relevant.success.select('count(*)').to_sql
+ manual = scope_relevant.manual.select('count(*)').to_sql
+ scheduled = scope_relevant.scheduled.select('count(*)').to_sql
+ preparing = scope_relevant.preparing.select('count(*)').to_sql
+ waiting_for_resource = scope_relevant.waiting_for_resource.select('count(*)').to_sql
+ pending = scope_relevant.pending.select('count(*)').to_sql
+ running = scope_relevant.running.select('count(*)').to_sql
+ skipped = scope_relevant.skipped.select('count(*)').to_sql
+ canceled = scope_relevant.canceled.select('count(*)').to_sql
+ warnings = scope_warnings.select('count(*) > 0').to_sql.presence || 'false'
+
+ Arel.sql(
+ "(CASE
+ WHEN (#{builds})=(#{skipped}) AND (#{warnings}) THEN 'success'
+ WHEN (#{builds})=(#{skipped}) THEN 'skipped'
+ WHEN (#{builds})=(#{success}) THEN 'success'
+ WHEN (#{builds})=(#{created}) THEN 'created'
+ WHEN (#{builds})=(#{preparing}) THEN 'preparing'
+ WHEN (#{builds})=(#{success})+(#{skipped}) THEN 'success'
+ WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled'
+ WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending'
+ WHEN (#{running})+(#{pending})>0 THEN 'running'
+ WHEN (#{waiting_for_resource})>0 THEN 'waiting_for_resource'
+ WHEN (#{manual})>0 THEN 'manual'
+ WHEN (#{scheduled})>0 THEN 'scheduled'
+ WHEN (#{preparing})>0 THEN 'preparing'
+ WHEN (#{created})>0 THEN 'running'
+ ELSE 'failed'
+ END)"
+ )
+ end
+
+ def legacy_status
+ all.pluck(legacy_status_sql).first
+ end
+
+ # This method should not be used.
+ # This method performs expensive calculation of status:
+ # 1. By plucking all related objects,
+ # 2. Or executes expensive SQL query
+ def slow_composite_status(project:)
+ if ::Gitlab::Ci::Features.composite_status?(project)
+ Gitlab::Ci::Status::Composite
+ .new(all, with_allow_failure: columns_hash.key?('allow_failure'))
+ .status
+ else
+ legacy_status
+ end
+ end
+
+ def started_at
+ all.minimum(:started_at)
+ end
+
+ def finished_at
+ all.maximum(:finished_at)
+ end
+
+ def all_state_names
+ state_machines.values.flat_map(&:states).flat_map { |s| s.map(&:name) }
+ end
+
+ def completed_statuses
+ COMPLETED_STATUSES.map(&:to_sym)
+ end
+ end
+
+ included do
+ validates :status, inclusion: { in: AVAILABLE_STATUSES }
+
+ state_machine :status, initial: :created do
+ state :created, value: 'created'
+ state :waiting_for_resource, value: 'waiting_for_resource'
+ state :preparing, value: 'preparing'
+ state :pending, value: 'pending'
+ state :running, value: 'running'
+ state :failed, value: 'failed'
+ state :success, value: 'success'
+ state :canceled, value: 'canceled'
+ state :skipped, value: 'skipped'
+ state :manual, value: 'manual'
+ state :scheduled, value: 'scheduled'
+ end
+
+ scope :created, -> { with_status(:created) }
+ scope :waiting_for_resource, -> { with_status(:waiting_for_resource) }
+ scope :preparing, -> { with_status(:preparing) }
+ scope :relevant, -> { without_status(:created) }
+ scope :running, -> { with_status(:running) }
+ scope :pending, -> { with_status(:pending) }
+ scope :success, -> { with_status(:success) }
+ scope :failed, -> { with_status(:failed) }
+ scope :canceled, -> { with_status(:canceled) }
+ scope :skipped, -> { with_status(:skipped) }
+ scope :manual, -> { with_status(:manual) }
+ scope :scheduled, -> { with_status(:scheduled) }
+ scope :alive, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running) }
+ scope :alive_or_scheduled, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled) }
+ scope :created_or_pending, -> { with_status(:created, :pending) }
+ scope :running_or_pending, -> { with_status(:running, :pending) }
+ scope :finished, -> { with_status(:success, :failed, :canceled) }
+ scope :failed_or_canceled, -> { with_status(:failed, :canceled) }
+ scope :incomplete, -> { without_statuses(completed_statuses) }
+
+ scope :cancelable, -> do
+ where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled])
+ end
+
+ scope :without_statuses, -> (names) do
+ with_status(all_state_names - names.to_a)
+ end
+ end
+
+ def started?
+ STARTED_STATUSES.include?(status) && started_at
+ end
+
+ def active?
+ ACTIVE_STATUSES.include?(status)
+ end
+
+ def complete?
+ COMPLETED_STATUSES.include?(status)
+ end
+
+ def blocked?
+ BLOCKED_STATUS.include?(status)
+ end
+
+ private
+
+ def calculate_duration
+ if started_at && finished_at
+ finished_at - started_at
+ elsif started_at
+ Time.current - started_at
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb
index bd40af28bc9..26e644646b4 100644
--- a/app/models/concerns/ci/metadatable.rb
+++ b/app/models/concerns/ci/metadatable.rb
@@ -87,3 +87,5 @@ module Ci
end
end
end
+
+Ci::Metadatable.prepend_if_ee('EE::Ci::Metadatable')
diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb
index 3b893a56bd6..02f7711e927 100644
--- a/app/models/concerns/deployment_platform.rb
+++ b/app/models/concerns/deployment_platform.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
module DeploymentPlatform
- # EE would override this and utilize environment argument
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def deployment_platform(environment: nil)
@deployment_platform ||= {}
@@ -20,16 +19,27 @@ module DeploymentPlatform
find_instance_cluster_platform_kubernetes(environment: environment)
end
- # EE would override this and utilize environment argument
- def find_platform_kubernetes_with_cte(_environment)
- Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?).base_and_ancestors
+ def find_platform_kubernetes_with_cte(environment)
+ if environment
+ ::Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?)
+ .base_and_ancestors
+ .enabled
+ .on_environment(environment, relevant_only: true)
+ .first&.platform_kubernetes
+ else
+ Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?).base_and_ancestors
.enabled.default_environment
.first&.platform_kubernetes
+ end
end
- # EE would override this and utilize environment argument
def find_instance_cluster_platform_kubernetes(environment: nil)
- Clusters::Instance.new.clusters.enabled.default_environment
+ if environment
+ ::Clusters::Instance.new.clusters.enabled.on_environment(environment, relevant_only: true)
.first&.platform_kubernetes
+ else
+ Clusters::Instance.new.clusters.enabled.default_environment
+ .first&.platform_kubernetes
+ end
end
end
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index 29d31b8bb4f..d909b67d7ba 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -5,7 +5,7 @@
# of directly having a repository, like project or snippet.
#
# It also includes `Referable`, therefore the method
-# `to_reference` should be overriden in case the object
+# `to_reference` should be overridden in case the object
# needs any special behavior.
module HasRepository
extend ActiveSupport::Concern
@@ -76,7 +76,11 @@ module HasRepository
end
def default_branch
- @default_branch ||= repository.root_ref
+ @default_branch ||= repository.root_ref || default_branch_from_preferences
+ end
+
+ def default_branch_from_preferences
+ empty_repo? ? Gitlab::CurrentSettings.default_branch_name : nil
end
def reload_default_branch
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
deleted file mode 100644
index c885dea862f..00000000000
--- a/app/models/concerns/has_status.rb
+++ /dev/null
@@ -1,166 +0,0 @@
-# frozen_string_literal: true
-
-module HasStatus
- extend ActiveSupport::Concern
-
- DEFAULT_STATUS = 'created'
- BLOCKED_STATUS = %w[manual scheduled].freeze
- AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze
- STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze
- ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze
- COMPLETED_STATUSES = %w[success failed canceled skipped].freeze
- ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze
- PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze
- EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze
- STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
- failed: 4, canceled: 5, skipped: 6, manual: 7,
- scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze
-
- UnknownStatusError = Class.new(StandardError)
-
- class_methods do
- def legacy_status_sql
- scope_relevant = respond_to?(:exclude_ignored) ? exclude_ignored : all
- scope_warnings = respond_to?(:failed_but_allowed) ? failed_but_allowed : none
-
- builds = scope_relevant.select('count(*)').to_sql
- created = scope_relevant.created.select('count(*)').to_sql
- success = scope_relevant.success.select('count(*)').to_sql
- manual = scope_relevant.manual.select('count(*)').to_sql
- scheduled = scope_relevant.scheduled.select('count(*)').to_sql
- preparing = scope_relevant.preparing.select('count(*)').to_sql
- waiting_for_resource = scope_relevant.waiting_for_resource.select('count(*)').to_sql
- pending = scope_relevant.pending.select('count(*)').to_sql
- running = scope_relevant.running.select('count(*)').to_sql
- skipped = scope_relevant.skipped.select('count(*)').to_sql
- canceled = scope_relevant.canceled.select('count(*)').to_sql
- warnings = scope_warnings.select('count(*) > 0').to_sql.presence || 'false'
-
- Arel.sql(
- "(CASE
- WHEN (#{builds})=(#{skipped}) AND (#{warnings}) THEN 'success'
- WHEN (#{builds})=(#{skipped}) THEN 'skipped'
- WHEN (#{builds})=(#{success}) THEN 'success'
- WHEN (#{builds})=(#{created}) THEN 'created'
- WHEN (#{builds})=(#{preparing}) THEN 'preparing'
- WHEN (#{builds})=(#{success})+(#{skipped}) THEN 'success'
- WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled'
- WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending'
- WHEN (#{running})+(#{pending})>0 THEN 'running'
- WHEN (#{waiting_for_resource})>0 THEN 'waiting_for_resource'
- WHEN (#{manual})>0 THEN 'manual'
- WHEN (#{scheduled})>0 THEN 'scheduled'
- WHEN (#{preparing})>0 THEN 'preparing'
- WHEN (#{created})>0 THEN 'running'
- ELSE 'failed'
- END)"
- )
- end
-
- def legacy_status
- all.pluck(legacy_status_sql).first
- end
-
- # This method should not be used.
- # This method performs expensive calculation of status:
- # 1. By plucking all related objects,
- # 2. Or executes expensive SQL query
- def slow_composite_status(project:)
- if ::Gitlab::Ci::Features.composite_status?(project)
- Gitlab::Ci::Status::Composite
- .new(all, with_allow_failure: columns_hash.key?('allow_failure'))
- .status
- else
- legacy_status
- end
- end
-
- def started_at
- all.minimum(:started_at)
- end
-
- def finished_at
- all.maximum(:finished_at)
- end
-
- def all_state_names
- state_machines.values.flat_map(&:states).flat_map { |s| s.map(&:name) }
- end
-
- def completed_statuses
- COMPLETED_STATUSES.map(&:to_sym)
- end
- end
-
- included do
- validates :status, inclusion: { in: AVAILABLE_STATUSES }
-
- state_machine :status, initial: :created do
- state :created, value: 'created'
- state :waiting_for_resource, value: 'waiting_for_resource'
- state :preparing, value: 'preparing'
- state :pending, value: 'pending'
- state :running, value: 'running'
- state :failed, value: 'failed'
- state :success, value: 'success'
- state :canceled, value: 'canceled'
- state :skipped, value: 'skipped'
- state :manual, value: 'manual'
- state :scheduled, value: 'scheduled'
- end
-
- scope :created, -> { with_status(:created) }
- scope :waiting_for_resource, -> { with_status(:waiting_for_resource) }
- scope :preparing, -> { with_status(:preparing) }
- scope :relevant, -> { without_status(:created) }
- scope :running, -> { with_status(:running) }
- scope :pending, -> { with_status(:pending) }
- scope :success, -> { with_status(:success) }
- scope :failed, -> { with_status(:failed) }
- scope :canceled, -> { with_status(:canceled) }
- scope :skipped, -> { with_status(:skipped) }
- scope :manual, -> { with_status(:manual) }
- scope :scheduled, -> { with_status(:scheduled) }
- scope :alive, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running) }
- scope :alive_or_scheduled, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled) }
- scope :created_or_pending, -> { with_status(:created, :pending) }
- scope :running_or_pending, -> { with_status(:running, :pending) }
- scope :finished, -> { with_status(:success, :failed, :canceled) }
- scope :failed_or_canceled, -> { with_status(:failed, :canceled) }
- scope :incomplete, -> { without_statuses(completed_statuses) }
-
- scope :cancelable, -> do
- where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled])
- end
-
- scope :without_statuses, -> (names) do
- with_status(all_state_names - names.to_a)
- end
- end
-
- def started?
- STARTED_STATUSES.include?(status) && started_at
- end
-
- def active?
- ACTIVE_STATUSES.include?(status)
- end
-
- def complete?
- COMPLETED_STATUSES.include?(status)
- end
-
- def blocked?
- BLOCKED_STATUS.include?(status)
- end
-
- private
-
- def calculate_duration
- if started_at && finished_at
- finished_at - started_at
- elsif started_at
- Time.current - started_at
- end
- end
-end
diff --git a/app/models/concerns/integration.rb b/app/models/concerns/integration.rb
index 644a0ba1b5e..34ff5bb1195 100644
--- a/app/models/concerns/integration.rb
+++ b/app/models/concerns/integration.rb
@@ -15,5 +15,19 @@ module Integration
Project.where(id: custom_integration_project_ids)
end
+
+ def ids_without_integration(integration, limit)
+ services = Service
+ .select('1')
+ .where('services.project_id = projects.id')
+ .where(type: integration.type)
+
+ Project
+ .where('NOT EXISTS (?)', services)
+ .where(pending_delete: false)
+ .where(archived: false)
+ .limit(limit)
+ .pluck(:id)
+ end
end
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 220af8ab7c7..715cbd15d93 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -411,8 +411,8 @@ module Issuable
changes = previous_changes
if old_associations
- old_labels = old_associations.fetch(:labels, [])
- old_assignees = old_associations.fetch(:assignees, [])
+ old_labels = old_associations.fetch(:labels, labels)
+ old_assignees = old_associations.fetch(:assignees, assignees)
if old_labels != labels
changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
@@ -423,7 +423,7 @@ module Issuable
end
if self.respond_to?(:total_time_spent)
- old_total_time_spent = old_associations.fetch(:total_time_spent, nil)
+ old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent)
if old_total_time_spent != total_time_spent
changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index 183b902dd37..2dbe9360d42 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -67,6 +67,10 @@ module Noteable
false
end
+ def has_any_diff_note_positions?
+ notes.any? && DiffNotePosition.where(note: notes).exists?
+ end
+
def discussion_notes
notes
end
diff --git a/app/models/concerns/partitioned_table.rb b/app/models/concerns/partitioned_table.rb
new file mode 100644
index 00000000000..9f1cec5d520
--- /dev/null
+++ b/app/models/concerns/partitioned_table.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module PartitionedTable
+ extend ActiveSupport::Concern
+
+ class_methods do
+ attr_reader :partitioning_strategy
+
+ PARTITIONING_STRATEGIES = {
+ monthly: Gitlab::Database::Partitioning::MonthlyStrategy
+ }.freeze
+
+ def partitioned_by(partitioning_key, strategy:)
+ strategy_class = PARTITIONING_STRATEGIES[strategy.to_sym] || raise(ArgumentError, "Unknown partitioning strategy: #{strategy}")
+
+ @partitioning_strategy = strategy_class.new(self, partitioning_key)
+
+ Gitlab::Database::Partitioning::PartitionCreator.register(self)
+ end
+ end
+end
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index d294563139c..5f30fc0c36c 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -29,7 +29,7 @@ module ReactiveCaching
self.reactive_cache_lease_timeout = 2.minutes
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
- self.reactive_cache_hard_limit = 1.megabyte
+ self.reactive_cache_hard_limit = nil # this value should be set in megabytes. E.g: 1.megabyte
self.reactive_cache_work_type = :default
self.reactive_cache_worker_finder = ->(id, *_args) do
find_by(primary_key => id)
@@ -159,8 +159,12 @@ module ReactiveCaching
WORK_TYPE.fetch(self.class.reactive_cache_work_type.to_sym)
end
+ def reactive_cache_limit_enabled?
+ !!self.reactive_cache_hard_limit
+ end
+
def check_exceeded_reactive_cache_limit!(data)
- return unless Feature.enabled?(:reactive_cache_limit)
+ return unless reactive_cache_limit_enabled?
data_deep_size = Gitlab::Utils::DeepSize.new(data, max_size: self.class.reactive_cache_hard_limit)
diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb
index 129d0fbb2c0..c70ce9bebcc 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -17,11 +17,8 @@ module Routable
after_validation :set_path_errors
- before_validation do
- if full_path_changed? || full_name_changed?
- prepare_route
- end
- end
+ before_validation :prepare_route
+ before_save :prepare_route # in case validation is skipped
end
class_methods do
@@ -118,6 +115,8 @@ module Routable
end
def prepare_route
+ return unless full_path_changed? || full_name_changed?
+
route || build_route(source: self)
route.path = build_full_path
route.name = build_full_name
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
index 6cf012680d8..c0fa14d3369 100644
--- a/app/models/concerns/update_project_statistics.rb
+++ b/app/models/concerns/update_project_statistics.rb
@@ -35,8 +35,8 @@ module UpdateProjectStatistics
@project_statistics_name = project_statistics_name
@statistic_attribute = statistic_attribute
- after_save(:update_project_statistics_after_save, if: :update_project_statistics_attribute_changed?)
- after_destroy(:update_project_statistics_after_destroy, unless: :project_destroyed?)
+ after_save(:update_project_statistics_after_save, if: :update_project_statistics_after_save?)
+ after_destroy(:update_project_statistics_after_destroy, if: :update_project_statistics_after_destroy?)
end
private :update_project_statistics
@@ -45,6 +45,14 @@ module UpdateProjectStatistics
included do
private
+ def update_project_statistics_after_save?
+ update_project_statistics_attribute_changed?
+ end
+
+ def update_project_statistics_after_destroy?
+ !project_destroyed?
+ end
+
def update_project_statistics_after_save
attr = self.class.statistic_attribute
delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
new file mode 100644
index 00000000000..643b4060ad6
--- /dev/null
+++ b/app/models/custom_emoji.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class CustomEmoji < ApplicationRecord
+ belongs_to :namespace, inverse_of: :custom_emoji
+
+ validate :valid_emoji_name
+
+ validates :namespace, presence: true
+ validates :name,
+ uniqueness: { scope: [:namespace_id, :name] },
+ presence: true,
+ length: { maximum: 36 },
+ format: { with: /\A\w+\z/ }
+
+ private
+
+ def valid_emoji_name
+ if Gitlab::Emoji.emoji_exists?(name)
+ errors.add(:name, _('%{name} is already being used for another emoji') % { name: self.name })
+ end
+ end
+end
diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb
index 40c66d5bc4c..a9cc56a7246 100644
--- a/app/models/deploy_keys_project.rb
+++ b/app/models/deploy_keys_project.rb
@@ -6,6 +6,7 @@ class DeployKeysProject < ApplicationRecord
scope :without_project_deleted, -> { joins(:project).where(projects: { pending_delete: false }) }
scope :in_project, ->(project) { where(project: project) }
scope :with_write_access, -> { where(can_push: true) }
+ scope :with_deploy_keys, -> { includes(:deploy_key) }
accepts_nested_attributes_for :deploy_key
diff --git a/app/models/diff_viewer/image.rb b/app/models/diff_viewer/image.rb
index cfda0058d81..62a3446a7b6 100644
--- a/app/models/diff_viewer/image.rb
+++ b/app/models/diff_viewer/image.rb
@@ -8,7 +8,7 @@ module DiffViewer
self.partial_name = 'image'
self.extensions = UploaderHelper::SAFE_IMAGE_EXT
self.binary = true
- self.switcher_icon = 'picture-o'
+ self.switcher_icon = 'doc-image'
self.switcher_title = _('image diff')
end
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 8dae2d760f5..bddc84f10b5 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -21,6 +21,7 @@ class Environment < ApplicationRecord
has_many :prometheus_alerts, inverse_of: :environment
has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :environment
has_many :self_managed_prometheus_alert_events, inverse_of: :environment
+ has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment
has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment'
has_one :last_deployable, through: :last_deployment, source: 'deployable', source_type: 'CommitStatus'
@@ -147,7 +148,7 @@ class Environment < ApplicationRecord
Ci::Build.joins(inner_join_stop_actions)
.with(cte.to_arel)
.where(ci_builds[:commit_id].in(pipeline_ids))
- .where(status: HasStatus::BLOCKED_STATUS)
+ .where(status: Ci::HasStatus::BLOCKED_STATUS)
.preload_project_and_pipeline_project
.preload(:user, :metadata, :deployment)
end
@@ -226,6 +227,21 @@ class Environment < ApplicationRecord
available? && stop_action.present?
end
+ def cancel_deployment_jobs!
+ jobs = active_deployments.with_deployable
+ jobs.each do |deployment|
+ # guard against data integrity issues,
+ # for example https://gitlab.com/gitlab-org/gitlab/-/issues/218659#note_348823660
+ next unless deployment.deployable
+
+ Gitlab::OptimisticLocking.retry_lock(deployment.deployable) do |deployable|
+ deployable.cancel! if deployable&.cancelable?
+ end
+ rescue => e
+ Gitlab::ErrorTracking.track_exception(e, environment_id: id, deployment_id: deployment.id)
+ end
+ end
+
def stop_with_action!(current_user)
return unless available?
@@ -362,6 +378,11 @@ class Environment < ApplicationRecord
def generate_slug
self.slug = Gitlab::Slug::Environment.new(name).generate
end
+
+ # Overrides ReactiveCaching default to activate limit checking behind a FF
+ def reactive_cache_limit_enabled?
+ Feature.enabled?(:reactive_caching_limit_environment, project)
+ end
end
Environment.prepend_if_ee('EE::Environment')
diff --git a/app/models/epic.rb b/app/models/epic.rb
index e09dc1080e6..93f286f97d3 100644
--- a/app/models/epic.rb
+++ b/app/models/epic.rb
@@ -5,8 +5,6 @@
class Epic < ApplicationRecord
include IgnorableColumns
- ignore_column :health_status, remove_with: '13.0', remove_after: '2019-05-22'
-
def self.link_reference_pattern
nil
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 9c0fcbb354b..56d7742c51a 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -83,10 +83,6 @@ class Event < ApplicationRecord
scope :for_wiki_page, -> { where(target_type: 'WikiPage::Meta') }
scope :for_design, -> { where(target_type: 'DesignManagement::Design') }
- # Needed to implement feature flag: can be removed when feature flag is removed
- scope :not_wiki_page, -> { where('target_type IS NULL or target_type <> ?', 'WikiPage::Meta') }
- scope :not_design, -> { where('target_type IS NULL or target_type <> ?', 'DesignManagement::Design') }
-
scope :with_associations, -> do
# We're using preload for "push_event_payload" as otherwise the association
# is not always available (depending on the query being built).
diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb
index 4c178e27b75..4768506b8fa 100644
--- a/app/models/event_collection.rb
+++ b/app/models/event_collection.rb
@@ -33,23 +33,16 @@ class EventCollection
project_events
end
- relation = apply_feature_flags(relation)
relation = paginate_events(relation)
relation.with_associations.to_a
end
def all_project_events
- apply_feature_flags(Event.from_union([project_events]).recent)
+ Event.from_union([project_events]).recent
end
private
- def apply_feature_flags(events)
- return events if ::Feature.enabled?(:wiki_events)
-
- events.not_wiki_page
- end
-
def project_events
relation_with_join_lateral('project_id', projects)
end
diff --git a/app/models/group.rb b/app/models/group.rb
index 71f58a5fd1a..c38ddbdf6fb 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -18,6 +18,8 @@ class Group < Namespace
ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
+ UpdateSharedRunnersError = Class.new(StandardError)
+
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members
has_many :users, through: :group_members
@@ -89,6 +91,8 @@ class Group < Namespace
scope :with_users, -> { includes(:users) }
+ scope :by_id, ->(groups) { where(id: groups) }
+
class << self
def sort_by_attribute(method)
if method == 'storage_size_desc'
@@ -504,6 +508,55 @@ class Group < Namespace
preloader.preload(self, shared_with_group_links: [shared_with_group: :route])
end
+ def shared_runners_allowed?
+ shared_runners_enabled? || allow_descendants_override_disabled_shared_runners?
+ end
+
+ def parent_allows_shared_runners?
+ return true unless has_parent?
+
+ parent.shared_runners_allowed?
+ end
+
+ def parent_enabled_shared_runners?
+ return true unless has_parent?
+
+ parent.shared_runners_enabled?
+ end
+
+ def enable_shared_runners!
+ raise UpdateSharedRunnersError, 'Shared Runners disabled for the parent group' unless parent_enabled_shared_runners?
+
+ update_column(:shared_runners_enabled, true)
+ end
+
+ def disable_shared_runners!
+ group_ids = self_and_descendants
+ return if group_ids.empty?
+
+ Group.by_id(group_ids).update_all(shared_runners_enabled: false)
+
+ all_projects.update_all(shared_runners_enabled: false)
+ end
+
+ def allow_descendants_override_disabled_shared_runners!
+ raise UpdateSharedRunnersError, 'Shared Runners enabled' if shared_runners_enabled?
+ raise UpdateSharedRunnersError, 'Group level shared Runners not allowed' unless parent_allows_shared_runners?
+
+ update_column(:allow_descendants_override_disabled_shared_runners, true)
+ end
+
+ def disallow_descendants_override_disabled_shared_runners!
+ raise UpdateSharedRunnersError, 'Shared Runners enabled' if shared_runners_enabled?
+
+ group_ids = self_and_descendants
+ return if group_ids.empty?
+
+ Group.by_id(group_ids).update_all(allow_descendants_override_disabled_shared_runners: false)
+
+ all_projects.update_all(shared_runners_enabled: false)
+ end
+
private
def update_two_factor_requirement
diff --git a/app/models/incident_management/project_incident_management_setting.rb b/app/models/incident_management/project_incident_management_setting.rb
index bf57c5b883f..c79acdb685f 100644
--- a/app/models/incident_management/project_incident_management_setting.rb
+++ b/app/models/incident_management/project_incident_management_setting.rb
@@ -8,6 +8,15 @@ module IncidentManagement
validate :issue_template_exists, if: :create_issue?
+ before_validation :ensure_pagerduty_token
+
+ attr_encrypted :pagerduty_token,
+ mode: :per_attribute_iv,
+ key: ::Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm',
+ encode: false, # No need to encode for binary column https://github.com/attr-encrypted/attr_encrypted#the-encode-encode_iv-encode_salt-and-default_encoding-options
+ encode_iv: false
+
def available_issue_templates
Gitlab::Template::IssueTemplate.all(project)
end
@@ -30,5 +39,15 @@ module IncidentManagement
Gitlab::Template::IssueTemplate.find(issue_template_key, project)
rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
end
+
+ def ensure_pagerduty_token
+ return unless pagerduty_active
+
+ self.pagerduty_token ||= generate_pagerduty_token
+ end
+
+ def generate_pagerduty_token
+ SecureRandom.hex
+ end
end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 5c5190f88b1..619555f369d 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -98,6 +98,8 @@ class Issue < ApplicationRecord
scope :counts_by_state, -> { reorder(nil).group(:state_id).count }
+ scope :service_desk, -> { where(author: ::User.support_bot) }
+
# An issue can be uniquely identified by project_id and iid
# Takes one or more sets of composite IDs, expressed as hash-like records of
# `{project_id: x, iid: y}`.
@@ -373,6 +375,10 @@ class Issue < ApplicationRecord
)
end
+ def from_service_desk?
+ author.id == User.support_bot.id
+ end
+
private
def ensure_metrics
diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb
index 8128b8a538e..e57acbae546 100644
--- a/app/models/issue_assignee.rb
+++ b/app/models/issue_assignee.rb
@@ -2,9 +2,12 @@
class IssueAssignee < ApplicationRecord
belongs_to :issue
- belongs_to :assignee, class_name: "User", foreign_key: :user_id
+ belongs_to :assignee, class_name: "User", foreign_key: :user_id, inverse_of: :issue_assignees
validates :assignee, uniqueness: { scope: :issue_id }
+
+ scope :in_projects, ->(project_ids) { joins(:issue).where("issues.project_id in (?)", project_ids) }
+ scope :on_issues, ->(issue_ids) { where(issue_id: issue_ids) }
end
IssueAssignee.prepend_if_ee('EE::IssueAssignee')
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
index 2bda0725471..0b59cf047f7 100644
--- a/app/models/iteration.rb
+++ b/app/models/iteration.rb
@@ -34,6 +34,9 @@ class Iteration < ApplicationRecord
.where('due_date is NULL or due_date >= ?', start_date)
end
+ scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date > ?', Date.current) }
+ scope :due_date_passed, -> { where('due_date <= ?', Date.current) }
+
state_machine :state_enum, initial: :upcoming do
event :start do
transition upcoming: :started
@@ -93,7 +96,7 @@ class Iteration < ApplicationRecord
# ensure dates do not overlap with other Iterations in the same group/project
def dates_do_not_overlap
- return unless resource_parent.iterations.within_timeframe(start_date, due_date).exists?
+ return unless resource_parent.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations"))
end
diff --git a/app/models/label.rb b/app/models/label.rb
index 910cc0d68cd..3c70eef9bd5 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -149,10 +149,6 @@ class Label < ApplicationRecord
1
end
- def self.by_ids(ids)
- where(id: ids)
- end
-
def self.on_project_board?(project_id, label_id)
return false if label_id.blank?
diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb
index e1966eda277..674294f0916 100644
--- a/app/models/lfs_objects_project.rb
+++ b/app/models/lfs_objects_project.rb
@@ -15,7 +15,7 @@ class LfsObjectsProject < ApplicationRecord
enum repository_type: {
project: 0,
wiki: 1,
- design: 2 ## EE-specific
+ design: 2
}
scope :project_id_in, ->(ids) { where(project_id: ids) }
diff --git a/app/models/member.rb b/app/models/member.rb
index f2926d32d47..36f9741ce01 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -38,6 +38,11 @@ class Member < ApplicationRecord
scope: [:source_type, :source_id],
allow_nil: true
}
+ validates :user_id,
+ uniqueness: {
+ message: _('project bots cannot be added to other groups / projects')
+ },
+ if: :project_bot?
# This scope encapsulates (most of) the conditions a row in the member table
# must satisfy if it is a valid permission. Of particular note:
@@ -473,6 +478,10 @@ class Member < ApplicationRecord
def update_highest_role_attribute
user_id
end
+
+ def project_bot?
+ user&.project_bot?
+ end
end
Member.prepend_if_ee('EE::Member')
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 9a916cd40ae..8c224dea88f 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -17,14 +17,7 @@ class GroupMember < Member
scope :of_groups, ->(groups) { where(source_id: groups.select(:id)) }
scope :of_ldap_type, -> { where(ldap: true) }
-
- scope :count_users_by_group_id, -> do
- if Feature.enabled?(:optimized_count_users_by_group_id)
- group(:source_id).count
- else
- joins(:user).group(:source_id).count
- end
- end
+ scope :count_users_by_group_id, -> { group(:source_id).count }
after_create :update_two_factor_requirement, unless: :invite?
after_destroy :update_two_factor_requirement, unless: :invite?
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index a7e0907eb5f..b7885771781 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -20,13 +20,15 @@ class MergeRequest < ApplicationRecord
include IgnorableColumns
include MilestoneEventable
include StateEventable
+ include ApprovableBase
+
+ extend ::Gitlab::Utils::Override
sha_attribute :squash_commit_sha
self.reactive_cache_key = ->(model) { [model.project.id, model.iid] }
self.reactive_cache_refresh_interval = 10.minutes
self.reactive_cache_lifetime = 10.minutes
- self.reactive_cache_hard_limit = 20.megabytes
SORTING_PREFERENCE_FIELD = :merge_requests_sort
@@ -103,6 +105,7 @@ class MergeRequest < ApplicationRecord
after_create :ensure_merge_request_diff
after_update :clear_memoized_shas
+ after_update :clear_memoized_source_branch_exists
after_update :reload_diff_if_branch_changed
after_commit :ensure_metrics, on: [:create, :update], unless: :importing?
after_commit :expire_etag_cache, unless: :importing?
@@ -260,6 +263,7 @@ class MergeRequest < ApplicationRecord
*PROJECT_ROUTE_AND_NAMESPACE_ROUTE,
metrics: [:latest_closed_by, :merged_by])
}
+
scope :by_target_branch_wildcard, ->(wildcard_branch_name) do
where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%'))
end
@@ -386,25 +390,27 @@ class MergeRequest < ApplicationRecord
end
end
- WIP_REGEX = /\A*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze
+ # WIP is deprecated in favor of Draft. Currently both options are supported
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/227426
+ DRAFT_REGEX = /\A*#{Regexp.union(Gitlab::Regex.merge_request_wip, Gitlab::Regex.merge_request_draft)}+\s*/i.freeze
def self.work_in_progress?(title)
- !!(title =~ WIP_REGEX)
+ !!(title =~ DRAFT_REGEX)
end
def self.wipless_title(title)
- title.sub(WIP_REGEX, "")
+ title.sub(DRAFT_REGEX, "")
end
def self.wip_title(title)
- work_in_progress?(title) ? title : "WIP: #{title}"
+ work_in_progress?(title) ? title : "Draft: #{title}"
end
def committers
@committers ||= commits.committers
end
- # Verifies if title has changed not taking into account WIP prefix
+ # Verifies if title has changed not taking into account Draft prefix
# for merge requests.
def wipless_title_changed(old_title)
self.class.wipless_title(old_title) != self.wipless_title
@@ -858,6 +864,10 @@ class MergeRequest < ApplicationRecord
clear_memoization(:target_branch_head)
end
+ def clear_memoized_source_branch_exists
+ clear_memoization(:source_branch_exists)
+ end
+
def reload_diff_if_branch_changed
if (saved_change_to_source_branch? || saved_change_to_target_branch?) &&
(source_branch_head && target_branch_head)
@@ -946,7 +956,8 @@ class MergeRequest < ApplicationRecord
end
def can_remove_source_branch?(current_user)
- !ProtectedBranch.protected?(source_project, source_branch) &&
+ source_project &&
+ !ProtectedBranch.protected?(source_project, source_branch) &&
!source_project.root_ref?(source_branch) &&
Ability.allowed?(current_user, :push_code, source_project) &&
diff_head_sha == source_branch_head.try(:sha)
@@ -1017,6 +1028,10 @@ class MergeRequest < ApplicationRecord
target_project != source_project
end
+ def for_same_project?
+ target_project == source_project
+ end
+
# If the merge request closes any issues, save this information in the
# `MergeRequestsClosingIssues` model. This is a performance optimization.
# Calculating this information for a number of merge requests requires
@@ -1104,9 +1119,11 @@ class MergeRequest < ApplicationRecord
end
def source_branch_exists?
- return false unless self.source_project
+ strong_memoize(:source_branch_exists) do
+ next false unless self.source_project
- self.source_project.repository.branch_exists?(self.source_branch)
+ self.source_project.repository.branch_exists?(self.source_branch)
+ end
end
def target_branch_exists?
@@ -1142,6 +1159,13 @@ class MergeRequest < ApplicationRecord
end
end
+ def squash_on_merge?
+ return true if target_project.squash_always?
+ return false if target_project.squash_never?
+
+ squash?
+ end
+
def has_ci?
return false if has_no_commits?
@@ -1273,7 +1297,7 @@ class MergeRequest < ApplicationRecord
def all_pipelines
strong_memoize(:all_pipelines) do
- Ci::PipelinesForMergeRequestFinder.new(self).all
+ Ci::PipelinesForMergeRequestFinder.new(self, nil).all
end
end
@@ -1374,9 +1398,9 @@ class MergeRequest < ApplicationRecord
# TODO: consider renaming this as with exposed artifacts we generate reports,
# not always compare
# issue: https://gitlab.com/gitlab-org/gitlab/issues/34224
- def compare_reports(service_class, current_user = nil)
- with_reactive_cache(service_class.name, current_user&.id) do |data|
- unless service_class.new(project, current_user, id: id)
+ def compare_reports(service_class, current_user = nil, report_type = nil )
+ with_reactive_cache(service_class.name, current_user&.id, report_type) do |data|
+ unless service_class.new(project, current_user, id: id, report_type: report_type)
.latest?(base_pipeline, actual_head_pipeline, data)
raise InvalidateReactiveCache
end
@@ -1385,7 +1409,7 @@ class MergeRequest < ApplicationRecord
end || { status: :parsing }
end
- def calculate_reactive_cache(identifier, current_user_id = nil, *args)
+ def calculate_reactive_cache(identifier, current_user_id = nil, report_type = nil, *args)
service_class = identifier.constantize
# TODO: the type check should change to something that includes exposed artifacts service
@@ -1393,7 +1417,7 @@ class MergeRequest < ApplicationRecord
raise NameError, service_class unless service_class < Ci::CompareReportsBaseService
current_user = User.find_by(id: current_user_id)
- service_class.new(project, current_user, id: id).execute(base_pipeline, actual_head_pipeline)
+ service_class.new(project, current_user, id: id, report_type: report_type).execute(base_pipeline, actual_head_pipeline)
end
def all_commits
@@ -1582,6 +1606,23 @@ class MergeRequest < ApplicationRecord
super.merge(label_url_method: :project_merge_requests_url)
end
+ override :ensure_metrics
+ def ensure_metrics
+ MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id).tap do |metrics_record|
+ # Make sure we refresh the loaded association object with the newly created/loaded item.
+ # This is needed in order to have the exact functionality than before.
+ #
+ # Example:
+ #
+ # merge_request.metrics.destroy
+ # merge_request.ensure_metrics
+ # merge_request.metrics # should return the metrics record and not nil
+ # merge_request.metrics.merge_request # should return the same MR record
+ metrics_record.association(:merge_request).target = self
+ association(:metrics).target = metrics_record
+ end
+ end
+
private
def with_rebase_lock
diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb
index fe642bee8e2..2ac1de4321a 100644
--- a/app/models/merge_request_assignee.rb
+++ b/app/models/merge_request_assignee.rb
@@ -2,7 +2,9 @@
class MergeRequestAssignee < ApplicationRecord
belongs_to :merge_request
- belongs_to :assignee, class_name: "User", foreign_key: :user_id
+ belongs_to :assignee, class_name: "User", foreign_key: :user_id, inverse_of: :merge_request_assignees
validates :assignee, uniqueness: { scope: :merge_request_id }
+
+ scope :in_projects, ->(project_ids) { joins(:merge_request).where("merge_requests.target_project_id in (?)", project_ids) }
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 66b27aeac91..eb5250d5cf6 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -414,10 +414,16 @@ class MergeRequestDiff < ApplicationRecord
return if stored_externally? || !use_external_diff? || merge_request_diff_files.count == 0
rows = build_merge_request_diff_files(merge_request_diff_files)
+ rows = build_external_merge_request_diff_files(rows)
+
+ # Perform carrierwave activity before entering the database transaction.
+ # This is safe as until the `external_diff_store` column is changed, we will
+ # continue to consult the in-database content.
+ self.external_diff.store!
transaction do
MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all
- create_merge_request_diff_files(rows)
+ Gitlab::Database.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert
save!
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 90b4be7a674..e529ba6b486 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -13,9 +13,6 @@ class Namespace < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include IgnorableColumns
- ignore_column :plan_id, remove_with: '13.1', remove_after: '2020-06-22'
- ignore_column :trial_ends_on, remove_with: '13.2', remove_after: '2020-07-22'
-
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
# Android repo (15) + some extra backup.
@@ -25,6 +22,7 @@ class Namespace < ApplicationRecord
has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :project_statistics
+ has_one :namespace_settings, inverse_of: :namespace, class_name: 'NamespaceSetting', autosave: true
has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace'
has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner'
@@ -35,6 +33,7 @@ class Namespace < ApplicationRecord
belongs_to :parent, class_name: "Namespace"
has_many :children, class_name: "Namespace", foreign_key: :parent_id
+ has_many :custom_emoji, inverse_of: :namespace
has_one :chat_team, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :root_storage_statistics, class_name: 'Namespace::RootStorageStatistics'
has_one :aggregation_schedule, class_name: 'Namespace::AggregationSchedule'
@@ -50,6 +49,13 @@ class Namespace < ApplicationRecord
length: { maximum: 255 },
namespace_path: true
+ # Introduce minimal path length of 2 characters.
+ # Allow change of other attributes without forcing users to
+ # rename their user or group. At the same time prevent changing
+ # the path without complying with new 2 chars requirement.
+ # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/225214
+ validates :path, length: { minimum: 2 }, if: :path_changed?
+
validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
validate :nesting_level_allowed
@@ -82,6 +88,7 @@ class Namespace < ApplicationRecord
'COALESCE(SUM(ps.storage_size), 0) AS storage_size',
'COALESCE(SUM(ps.repository_size), 0) AS repository_size',
'COALESCE(SUM(ps.wiki_size), 0) AS wiki_size',
+ 'COALESCE(SUM(ps.snippets_size), 0) AS snippets_size',
'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size',
'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size',
'COALESCE(SUM(ps.packages_size), 0) AS packages_size'
@@ -212,7 +219,7 @@ class Namespace < ApplicationRecord
Gitlab.config.lfs.enabled
end
- def shared_runners_enabled?
+ def any_project_with_shared_runners_enabled?
projects.with_shared_runners.any?
end
@@ -281,6 +288,8 @@ class Namespace < ApplicationRecord
end
def root_ancestor
+ return self if persisted? && parent_id.nil?
+
strong_memoize(:root_ancestor) do
self_and_ancestors.reorder(nil).find_by(parent_id: nil)
end
diff --git a/app/models/namespace/root_storage_size.rb b/app/models/namespace/root_storage_size.rb
deleted file mode 100644
index d61917e468e..00000000000
--- a/app/models/namespace/root_storage_size.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-class Namespace::RootStorageSize
- def initialize(root_namespace)
- @root_namespace = root_namespace
- end
-
- def above_size_limit?
- return false if limit == 0
-
- usage_ratio > 1
- end
-
- def usage_ratio
- return 0 if limit == 0
-
- current_size.to_f / limit.to_f
- end
-
- def current_size
- @current_size ||= root_namespace.root_storage_statistics&.storage_size
- end
-
- def limit
- @limit ||= Gitlab::CurrentSettings.namespace_storage_size_limit.megabytes
- end
-
- private
-
- attr_reader :root_namespace
-end
diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb
index ae9b2f14343..2ad6ea59588 100644
--- a/app/models/namespace/root_storage_statistics.rb
+++ b/app/models/namespace/root_storage_statistics.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
class Namespace::RootStorageStatistics < ApplicationRecord
- STATISTICS_ATTRIBUTES = %w(storage_size repository_size wiki_size lfs_objects_size build_artifacts_size packages_size).freeze
+ SNIPPETS_SIZE_STAT_NAME = 'snippets_size'.freeze
+ STATISTICS_ATTRIBUTES = %W(storage_size repository_size wiki_size lfs_objects_size build_artifacts_size packages_size #{SNIPPETS_SIZE_STAT_NAME}).freeze
self.primary_key = :namespace_id
@@ -13,11 +14,15 @@ class Namespace::RootStorageStatistics < ApplicationRecord
delegate :all_projects, to: :namespace
def recalculate!
- update!(attributes_from_project_statistics)
+ update!(merged_attributes)
end
private
+ def merged_attributes
+ attributes_from_project_statistics.merge!(attributes_from_personal_snippets) { |key, v1, v2| v1 + v2 }
+ end
+
def attributes_from_project_statistics
from_project_statistics
.take
@@ -34,7 +39,22 @@ class Namespace::RootStorageStatistics < ApplicationRecord
'COALESCE(SUM(ps.wiki_size), 0) AS wiki_size',
'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size',
'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size',
- 'COALESCE(SUM(ps.packages_size), 0) AS packages_size'
+ 'COALESCE(SUM(ps.packages_size), 0) AS packages_size',
+ "COALESCE(SUM(ps.snippets_size), 0) AS #{SNIPPETS_SIZE_STAT_NAME}"
)
end
+
+ def attributes_from_personal_snippets
+ # Return if the type of namespace does not belong to a user
+ return {} unless namespace.type.nil?
+
+ from_personal_snippets.take.slice(SNIPPETS_SIZE_STAT_NAME)
+ end
+
+ def from_personal_snippets
+ PersonalSnippet
+ .joins('INNER JOIN snippet_statistics s ON s.snippet_id = snippets.id')
+ .where(author: namespace.owner_id)
+ .select("COALESCE(SUM(s.repository_size), 0) AS #{SNIPPETS_SIZE_STAT_NAME}")
+ end
end
diff --git a/app/models/namespace/traversal_hierarchy.rb b/app/models/namespace/traversal_hierarchy.rb
new file mode 100644
index 00000000000..cfb6cfdde74
--- /dev/null
+++ b/app/models/namespace/traversal_hierarchy.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+#
+# A Namespace::TraversalHierarchy is the collection of namespaces that descend
+# from a root Namespace as defined by the Namespace#traversal_ids attributes.
+#
+# This class provides operations to be performed on the hierarchy itself,
+# rather than individual namespaces.
+#
+# This includes methods for synchronizing traversal_ids attributes to a correct
+# state. We use recursive methods to determine the correct state so we don't
+# have to depend on the integrity of the traversal_ids attribute values
+# themselves.
+#
+class Namespace
+ class TraversalHierarchy
+ attr_accessor :root
+
+ def self.for_namespace(namespace)
+ new(recursive_root_ancestor(namespace))
+ end
+
+ def initialize(root)
+ raise StandardError.new('Must specify a root node') if root.parent_id
+
+ @root = root
+ end
+
+ # Update all traversal_ids in the current namespace hierarchy.
+ def sync_traversal_ids!
+ # An issue in Rails since 2013 prevents this kind of join based update in
+ # ActiveRecord. https://github.com/rails/rails/issues/13496
+ # Ideally it would be:
+ # `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')`
+ sql = """
+ UPDATE namespaces
+ SET traversal_ids = cte.traversal_ids
+ FROM (#{recursive_traversal_ids}) as cte
+ WHERE namespaces.id = cte.id
+ AND namespaces.traversal_ids <> cte.traversal_ids
+ """
+ Namespace.connection.exec_query(sql)
+ end
+
+ # Identify all incorrect traversal_ids in the current namespace hierarchy.
+ def incorrect_traversal_ids
+ Namespace
+ .joins("INNER JOIN (#{recursive_traversal_ids}) as cte ON namespaces.id = cte.id")
+ .where('namespaces.traversal_ids <> cte.traversal_ids')
+ end
+
+ private
+
+ # Determine traversal_ids for the namespace hierarchy using recursive methods.
+ # Generate a collection of [id, traversal_ids] rows.
+ #
+ # Note that the traversal_ids represent a calculated traversal path for the
+ # namespace and not the value stored within the traversal_ids attribute.
+ def recursive_traversal_ids
+ root_id = Integer(@root.id)
+
+ """
+ WITH RECURSIVE cte(id, traversal_ids, cycle) AS (
+ VALUES(#{root_id}, ARRAY[#{root_id}], false)
+ UNION ALL
+ SELECT n.id, cte.traversal_ids || n.id, n.id = ANY(cte.traversal_ids)
+ FROM namespaces n, cte
+ WHERE n.parent_id = cte.id AND NOT cycle
+ )
+ SELECT id, traversal_ids FROM cte
+ """
+ end
+
+ # This is essentially Namespace#root_ancestor which will soon be rewritten
+ # to use traversal_ids. We replicate here as a reliable way to find the
+ # root using recursive methods.
+ def self.recursive_root_ancestor(namespace)
+ Gitlab::ObjectHierarchy
+ .new(Namespace.where(id: namespace))
+ .base_and_ancestors
+ .reorder(nil)
+ .find_by(parent_id: nil)
+ end
+ end
+end
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
new file mode 100644
index 00000000000..53bfa3d979e
--- /dev/null
+++ b/app/models/namespace_setting.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class NamespaceSetting < ApplicationRecord
+ belongs_to :namespace, inverse_of: :namespace_settings
+
+ self.primary_key = :namespace_id
+end
+
+NamespaceSetting.prepend_if_ee('EE::NamespaceSetting')
diff --git a/app/models/note.rb b/app/models/note.rb
index 6b6a7c50b00..2db7e4e406d 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -5,6 +5,7 @@
# A note of this type is never resolvable.
class Note < ApplicationRecord
extend ActiveModel::Naming
+ include Gitlab::Utils::StrongMemoize
include Participable
include Mentionable
include Awardable
@@ -122,6 +123,8 @@ class Note < ApplicationRecord
scope :common, -> { where(noteable_type: ["", nil]) }
scope :fresh, -> { order(created_at: :asc, id: :asc) }
scope :updated_after, ->(time) { where('updated_at > ?', time) }
+ scope :with_updated_at, ->(time) { where(updated_at: time) }
+ scope :by_updated_at, -> { reorder(:updated_at, :id) }
scope :inc_author_project, -> { includes(:project, :author) }
scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> do
@@ -446,8 +449,10 @@ class Note < ApplicationRecord
# Consider using `#to_discussion` if we do not need to render the discussion
# and all its notes and if we don't care about the discussion's resolvability status.
def discussion
- full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
- full_discussion || to_discussion
+ strong_memoize(:discussion) do
+ full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
+ full_discussion || to_discussion
+ end
end
def start_of_discussion?
diff --git a/app/models/packages.rb b/app/models/packages.rb
new file mode 100644
index 00000000000..e14c9290093
--- /dev/null
+++ b/app/models/packages.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module Packages
+ def self.table_name_prefix
+ 'packages_'
+ end
+end
diff --git a/app/models/packages/build_info.rb b/app/models/packages/build_info.rb
new file mode 100644
index 00000000000..df8cf68490e
--- /dev/null
+++ b/app/models/packages/build_info.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class Packages::BuildInfo < ApplicationRecord
+ belongs_to :package, inverse_of: :build_info
+ belongs_to :pipeline, class_name: 'Ci::Pipeline'
+end
diff --git a/app/models/packages/composer/metadatum.rb b/app/models/packages/composer/metadatum.rb
new file mode 100644
index 00000000000..3026f5ea878
--- /dev/null
+++ b/app/models/packages/composer/metadatum.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Packages
+ module Composer
+ class Metadatum < ApplicationRecord
+ self.table_name = 'packages_composer_metadata'
+ self.primary_key = :package_id
+
+ belongs_to :package, -> { where(package_type: :composer) }, inverse_of: :composer_metadatum
+
+ validates :package, :target_sha, :composer_json, presence: true
+ end
+ end
+end
diff --git a/app/models/packages/conan.rb b/app/models/packages/conan.rb
new file mode 100644
index 00000000000..01007c3fa78
--- /dev/null
+++ b/app/models/packages/conan.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+module Packages
+ module Conan
+ def self.table_name_prefix
+ 'packages_conan_'
+ end
+ end
+end
diff --git a/app/models/packages/conan/file_metadatum.rb b/app/models/packages/conan/file_metadatum.rb
new file mode 100644
index 00000000000..e1ef62b3959
--- /dev/null
+++ b/app/models/packages/conan/file_metadatum.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class Packages::Conan::FileMetadatum < ApplicationRecord
+ belongs_to :package_file, inverse_of: :conan_file_metadatum
+
+ validates :package_file, presence: true
+
+ validates :recipe_revision,
+ presence: true,
+ format: { with: Gitlab::Regex.conan_revision_regex }
+
+ validates :package_revision, absence: true, if: :recipe_file?
+ validates :package_revision, format: { with: Gitlab::Regex.conan_revision_regex }, if: :package_file?
+
+ validates :conan_package_reference, absence: true, if: :recipe_file?
+ validates :conan_package_reference, format: { with: Gitlab::Regex.conan_package_reference_regex }, if: :package_file?
+ validate :conan_package_type
+
+ enum conan_file_type: { recipe_file: 1, package_file: 2 }
+
+ RECIPE_FILES = ::Gitlab::Regex::Packages::CONAN_RECIPE_FILES
+ PACKAGE_FILES = ::Gitlab::Regex::Packages::CONAN_PACKAGE_FILES
+ PACKAGE_BINARY = 'conan_package.tgz'
+
+ private
+
+ def conan_package_type
+ unless package_file&.package&.conan?
+ errors.add(:base, _('Package type must be Conan'))
+ end
+ end
+end
diff --git a/app/models/packages/conan/metadatum.rb b/app/models/packages/conan/metadatum.rb
new file mode 100644
index 00000000000..7ec2641177a
--- /dev/null
+++ b/app/models/packages/conan/metadatum.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class Packages::Conan::Metadatum < ApplicationRecord
+ belongs_to :package, -> { where(package_type: :conan) }, inverse_of: :conan_metadatum
+
+ validates :package, presence: true
+
+ validates :package_username,
+ presence: true,
+ format: { with: Gitlab::Regex.conan_recipe_component_regex }
+
+ validates :package_channel,
+ presence: true,
+ format: { with: Gitlab::Regex.conan_recipe_component_regex }
+
+ validate :conan_package_type
+
+ def recipe
+ "#{package.name}/#{package.version}@#{package_username}/#{package_channel}"
+ end
+
+ def recipe_path
+ recipe.tr('@', '/')
+ end
+
+ def self.package_username_from(full_path:)
+ full_path.tr('/', '+')
+ end
+
+ def self.full_path_from(package_username:)
+ package_username.tr('+', '/')
+ end
+
+ private
+
+ def conan_package_type
+ unless package&.conan?
+ errors.add(:base, _('Package type must be Conan'))
+ end
+ end
+end
diff --git a/app/models/packages/dependency.rb b/app/models/packages/dependency.rb
new file mode 100644
index 00000000000..51b80934827
--- /dev/null
+++ b/app/models/packages/dependency.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+class Packages::Dependency < ApplicationRecord
+ has_many :dependency_links, class_name: 'Packages::DependencyLink'
+
+ validates :name, :version_pattern, presence: true
+
+ validates :name, uniqueness: { scope: :version_pattern }
+
+ NAME_VERSION_PATTERN_TUPLE_MATCHING = '(name, version_pattern) = (?, ?)'.freeze
+ MAX_STRING_LENGTH = 255.freeze
+ MAX_CHUNKED_QUERIES_COUNT = 10.freeze
+
+ def self.ids_for_package_names_and_version_patterns(names_and_version_patterns = {}, chunk_size = 50, max_rows_limit = 200)
+ names_and_version_patterns.reject! { |key, value| key.size > MAX_STRING_LENGTH || value.size > MAX_STRING_LENGTH }
+ raise ArgumentError, 'Too many names_and_version_patterns' if names_and_version_patterns.size > MAX_CHUNKED_QUERIES_COUNT * chunk_size
+
+ matched_ids = []
+ names_and_version_patterns.each_slice(chunk_size) do |tuples|
+ where_statement = Array.new(tuples.size, NAME_VERSION_PATTERN_TUPLE_MATCHING)
+ .join(' OR ')
+ ids = where(where_statement, *tuples.flatten)
+ .limit(max_rows_limit + 1)
+ .pluck(:id)
+ matched_ids.concat(ids)
+
+ raise ArgumentError, 'Too many Dependencies selected' if matched_ids.size > max_rows_limit
+ end
+
+ matched_ids
+ end
+
+ def self.for_package_names_and_version_patterns(names_and_version_patterns = {}, chunk_size = 50, max_rows_limit = 200)
+ ids = ids_for_package_names_and_version_patterns(names_and_version_patterns, chunk_size, max_rows_limit)
+
+ return none if ids.empty?
+
+ id_in(ids)
+ end
+
+ def self.pluck_ids_and_names
+ pluck(:id, :name)
+ end
+
+ def orphaned?
+ self.dependency_links.empty?
+ end
+end
diff --git a/app/models/packages/dependency_link.rb b/app/models/packages/dependency_link.rb
new file mode 100644
index 00000000000..51018602bdc
--- /dev/null
+++ b/app/models/packages/dependency_link.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+class Packages::DependencyLink < ApplicationRecord
+ belongs_to :package, inverse_of: :dependency_links
+ belongs_to :dependency, inverse_of: :dependency_links, class_name: 'Packages::Dependency'
+ has_one :nuget_metadatum, inverse_of: :dependency_link, class_name: 'Packages::Nuget::DependencyLinkMetadatum'
+
+ validates :package, :dependency, presence: true
+
+ validates :dependency_type,
+ uniqueness: { scope: %i[package_id dependency_id] }
+
+ enum dependency_type: { dependencies: 1, devDependencies: 2, bundleDependencies: 3, peerDependencies: 4 }
+
+ scope :with_dependency_type, ->(dependency_type) { where(dependency_type: dependency_type) }
+ scope :includes_dependency, -> { includes(:dependency) }
+ scope :for_package, ->(package) { where(package_id: package.id) }
+ scope :preload_dependency, -> { preload(:dependency) }
+ scope :preload_nuget_metadatum, -> { preload(:nuget_metadatum) }
+end
diff --git a/app/models/packages/go/module.rb b/app/models/packages/go/module.rb
new file mode 100644
index 00000000000..b38b691ed6c
--- /dev/null
+++ b/app/models/packages/go/module.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module Packages
+ module Go
+ class Module
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :project, :name, :path
+
+ def initialize(project, name, path)
+ @project = project
+ @name = name
+ @path = path
+ end
+
+ def versions
+ strong_memoize(:versions) { Packages::Go::VersionFinder.new(self).execute }
+ end
+
+ def version_by(ref: nil, commit: nil)
+ raise ArgumentError.new 'no filter specified' unless ref || commit
+ raise ArgumentError.new 'ref and commit are mutually exclusive' if ref && commit
+
+ if commit
+ return version_by_sha(commit) if commit.is_a? String
+
+ return version_by_commit(commit)
+ end
+
+ return version_by_name(ref) if ref.is_a? String
+
+ version_by_ref(ref)
+ end
+
+ def path_valid?(major)
+ m = /\/v(\d+)$/i.match(@name)
+
+ case major
+ when 0, 1
+ m.nil?
+ else
+ !m.nil? && m[1].to_i == major
+ end
+ end
+
+ def gomod_valid?(gomod)
+ if Feature.enabled?(:go_proxy_disable_gomod_validation, @project)
+ return gomod&.start_with?("module ")
+ end
+
+ gomod&.split("\n", 2)&.first == "module #{@name}"
+ end
+
+ private
+
+ def version_by_name(name)
+ # avoid a Gitaly call if possible
+ if strong_memoized?(:versions)
+ v = versions.find { |v| v.name == ref }
+ return v if v
+ end
+
+ ref = @project.repository.find_tag(name) || @project.repository.find_branch(name)
+ return unless ref
+
+ version_by_ref(ref)
+ end
+
+ def version_by_ref(ref)
+ # reuse existing versions
+ if strong_memoized?(:versions)
+ v = versions.find { |v| v.ref == ref }
+ return v if v
+ end
+
+ commit = ref.dereferenced_target
+ semver = Packages::SemVer.parse(ref.name, prefixed: true)
+ Packages::Go::ModuleVersion.new(self, :ref, commit, ref: ref, semver: semver)
+ end
+
+ def version_by_sha(sha)
+ commit = @project.commit_by(oid: sha)
+ return unless ref
+
+ version_by_commit(commit)
+ end
+
+ def version_by_commit(commit)
+ Packages::Go::ModuleVersion.new(self, :commit, commit)
+ end
+ end
+ end
+end
diff --git a/app/models/packages/go/module_version.rb b/app/models/packages/go/module_version.rb
new file mode 100644
index 00000000000..a50c78f8e69
--- /dev/null
+++ b/app/models/packages/go/module_version.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+module Packages
+ module Go
+ class ModuleVersion
+ include Gitlab::Utils::StrongMemoize
+
+ VALID_TYPES = %i[ref commit pseudo].freeze
+
+ attr_reader :mod, :type, :ref, :commit
+
+ delegate :major, to: :@semver, allow_nil: true
+ delegate :minor, to: :@semver, allow_nil: true
+ delegate :patch, to: :@semver, allow_nil: true
+ delegate :prerelease, to: :@semver, allow_nil: true
+ delegate :build, to: :@semver, allow_nil: true
+
+ def initialize(mod, type, commit, name: nil, semver: nil, ref: nil)
+ raise ArgumentError.new("invalid type '#{type}'") unless VALID_TYPES.include? type
+ raise ArgumentError.new("mod is required") unless mod
+ raise ArgumentError.new("commit is required") unless commit
+
+ if type == :ref
+ raise ArgumentError.new("ref is required") unless ref
+ elsif type == :pseudo
+ raise ArgumentError.new("name is required") unless name
+ raise ArgumentError.new("semver is required") unless semver
+ end
+
+ @mod = mod
+ @type = type
+ @commit = commit
+ @name = name if name
+ @semver = semver if semver
+ @ref = ref if ref
+ end
+
+ def name
+ @name || @ref&.name
+ end
+
+ def full_name
+ "#{mod.name}@#{name || commit.sha}"
+ end
+
+ def gomod
+ strong_memoize(:gomod) do
+ if strong_memoized?(:blobs)
+ blob_at(@mod.path + '/go.mod')
+ elsif @mod.path.empty?
+ @mod.project.repository.blob_at(@commit.sha, 'go.mod')&.data
+ else
+ @mod.project.repository.blob_at(@commit.sha, @mod.path + '/go.mod')&.data
+ end
+ end
+ end
+
+ def archive
+ suffix_len = @mod.path == '' ? 0 : @mod.path.length + 1
+
+ Zip::OutputStream.write_buffer do |zip|
+ files.each do |file|
+ zip.put_next_entry "#{full_name}/#{file[suffix_len...]}"
+ zip.write blob_at(file)
+ end
+ end
+ end
+
+ def files
+ strong_memoize(:files) do
+ ls_tree.filter { |e| !excluded.any? { |n| e.start_with? n } }
+ end
+ end
+
+ def excluded
+ strong_memoize(:excluded) do
+ ls_tree
+ .filter { |f| f.end_with?('/go.mod') && f != @mod.path + '/go.mod' }
+ .map { |f| f[0..-7] }
+ end
+ end
+
+ def valid?
+ @mod.path_valid?(major) && @mod.gomod_valid?(gomod)
+ end
+
+ private
+
+ def blob_at(path)
+ return if path.nil? || path.empty?
+
+ path = path[1..] if path.start_with? '/'
+
+ blobs.find { |x| x.path == path }&.data
+ end
+
+ def blobs
+ strong_memoize(:blobs) { @mod.project.repository.batch_blobs(files.map { |x| [@commit.sha, x] }) }
+ end
+
+ def ls_tree
+ strong_memoize(:ls_tree) do
+ path =
+ if @mod.path.empty?
+ '.'
+ else
+ @mod.path
+ end
+
+ @mod.project.repository.gitaly_repository_client.search_files_by_name(@commit.sha, path)
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/packages/maven.rb b/app/models/packages/maven.rb
new file mode 100644
index 00000000000..5c1581ce0b7
--- /dev/null
+++ b/app/models/packages/maven.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+module Packages
+ module Maven
+ def self.table_name_prefix
+ 'packages_maven_'
+ end
+ end
+end
diff --git a/app/models/packages/maven/metadatum.rb b/app/models/packages/maven/metadatum.rb
new file mode 100644
index 00000000000..b7f27fb9e06
--- /dev/null
+++ b/app/models/packages/maven/metadatum.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+class Packages::Maven::Metadatum < ApplicationRecord
+ belongs_to :package, -> { where(package_type: :maven) }
+
+ validates :package, presence: true
+
+ validates :path,
+ presence: true,
+ format: { with: Gitlab::Regex.maven_path_regex }
+
+ validates :app_group,
+ presence: true,
+ format: { with: Gitlab::Regex.maven_app_group_regex }
+
+ validates :app_name,
+ presence: true,
+ format: { with: Gitlab::Regex.maven_app_name_regex }
+
+ validate :maven_package_type
+
+ private
+
+ def maven_package_type
+ unless package&.maven?
+ errors.add(:base, _('Package type must be Maven'))
+ end
+ end
+end
diff --git a/app/models/packages/nuget.rb b/app/models/packages/nuget.rb
new file mode 100644
index 00000000000..42c167e9b7f
--- /dev/null
+++ b/app/models/packages/nuget.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+module Packages
+ module Nuget
+ def self.table_name_prefix
+ 'packages_nuget_'
+ end
+ end
+end
diff --git a/app/models/packages/nuget/dependency_link_metadatum.rb b/app/models/packages/nuget/dependency_link_metadatum.rb
new file mode 100644
index 00000000000..b586b55d3f0
--- /dev/null
+++ b/app/models/packages/nuget/dependency_link_metadatum.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Packages::Nuget::DependencyLinkMetadatum < ApplicationRecord
+ self.primary_key = :dependency_link_id
+
+ belongs_to :dependency_link, inverse_of: :nuget_metadatum
+
+ validates :dependency_link, :target_framework, presence: true
+
+ validate :ensure_nuget_package_type
+
+ private
+
+ def ensure_nuget_package_type
+ return if dependency_link&.package&.nuget?
+
+ errors.add(:base, _('Package type must be NuGet'))
+ end
+end
diff --git a/app/models/packages/nuget/metadatum.rb b/app/models/packages/nuget/metadatum.rb
new file mode 100644
index 00000000000..1db8c0eddbf
--- /dev/null
+++ b/app/models/packages/nuget/metadatum.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class Packages::Nuget::Metadatum < ApplicationRecord
+ belongs_to :package, -> { where(package_type: :nuget) }, inverse_of: :nuget_metadatum
+
+ validates :package, presence: true
+ validates :license_url, public_url: { allow_blank: true }
+ validates :project_url, public_url: { allow_blank: true }
+ validates :icon_url, public_url: { allow_blank: true }
+
+ validate :ensure_at_least_one_field_supplied
+ validate :ensure_nuget_package_type
+
+ private
+
+ def ensure_at_least_one_field_supplied
+ return if license_url? || project_url? || icon_url?
+
+ errors.add(:base, _('Nuget metadatum must have at least license_url, project_url or icon_url set'))
+ end
+
+ def ensure_nuget_package_type
+ return if package&.nuget?
+
+ errors.add(:base, _('Package type must be NuGet'))
+ end
+end
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
new file mode 100644
index 00000000000..d6633456de4
--- /dev/null
+++ b/app/models/packages/package.rb
@@ -0,0 +1,195 @@
+# frozen_string_literal: true
+class Packages::Package < ApplicationRecord
+ include Sortable
+ include Gitlab::SQL::Pattern
+ include UsageStatistics
+
+ belongs_to :project
+ # package_files must be destroyed by ruby code in order to properly remove carrierwave uploads and update project statistics
+ has_many :package_files, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :dependency_links, inverse_of: :package, class_name: 'Packages::DependencyLink'
+ has_many :tags, inverse_of: :package, class_name: 'Packages::Tag'
+ has_one :conan_metadatum, inverse_of: :package, class_name: 'Packages::Conan::Metadatum'
+ has_one :pypi_metadatum, inverse_of: :package, class_name: 'Packages::Pypi::Metadatum'
+ has_one :maven_metadatum, inverse_of: :package, class_name: 'Packages::Maven::Metadatum'
+ has_one :nuget_metadatum, inverse_of: :package, class_name: 'Packages::Nuget::Metadatum'
+ has_one :composer_metadatum, inverse_of: :package, class_name: 'Packages::Composer::Metadatum'
+ has_one :build_info, inverse_of: :package
+
+ accepts_nested_attributes_for :conan_metadatum
+ accepts_nested_attributes_for :maven_metadatum
+
+ delegate :recipe, :recipe_path, to: :conan_metadatum, prefix: :conan
+
+ validates :project, presence: true
+ validates :name, presence: true
+
+ validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: :conan?
+
+ validates :name,
+ uniqueness: { scope: %i[project_id version package_type] }, unless: :conan?
+
+ validate :valid_conan_package_recipe, if: :conan?
+ validate :valid_npm_package_name, if: :npm?
+ validate :valid_composer_global_name, if: :composer?
+ validate :package_already_taken, if: :npm?
+ validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { npm? || nuget? }
+ validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
+ validates :version, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
+ validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? }
+
+ enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6 }
+
+ scope :with_name, ->(name) { where(name: name) }
+ scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) }
+ scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) }
+ scope :with_version, ->(version) { where(version: version) }
+ scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) }
+ scope :with_package_type, ->(package_type) { where(package_type: package_type) }
+
+ scope :with_conan_channel, ->(package_channel) do
+ joins(:conan_metadatum).where(packages_conan_metadata: { package_channel: package_channel })
+ end
+ scope :with_conan_username, ->(package_username) do
+ joins(:conan_metadatum).where(packages_conan_metadata: { package_username: package_username })
+ end
+
+ scope :with_composer_target, -> (target) do
+ includes(:composer_metadatum)
+ .joins(:composer_metadatum)
+ .where(Packages::Composer::Metadatum.table_name => { target_sha: target })
+ end
+ scope :preload_composer, -> { preload(:composer_metadatum) }
+
+ scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
+
+ scope :has_version, -> { where.not(version: nil) }
+ scope :processed, -> do
+ where.not(package_type: :nuget).or(
+ where.not(name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME)
+ )
+ end
+ scope :preload_files, -> { preload(:package_files) }
+ scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) }
+ scope :limit_recent, ->(limit) { order_created_desc.limit(limit) }
+ scope :select_distinct_name, -> { select(:name).distinct }
+
+ # Sorting
+ scope :order_created, -> { reorder('created_at ASC') }
+ scope :order_created_desc, -> { reorder('created_at DESC') }
+ scope :order_name, -> { reorder('name ASC') }
+ scope :order_name_desc, -> { reorder('name DESC') }
+ scope :order_version, -> { reorder('version ASC') }
+ scope :order_version_desc, -> { reorder('version DESC') }
+ scope :order_type, -> { reorder('package_type ASC') }
+ scope :order_type_desc, -> { reorder('package_type DESC') }
+ scope :order_project_name, -> { joins(:project).reorder('projects.name ASC') }
+ scope :order_project_name_desc, -> { joins(:project).reorder('projects.name DESC') }
+ scope :order_project_path, -> { joins(:project).reorder('projects.path ASC, id ASC') }
+ scope :order_project_path_desc, -> { joins(:project).reorder('projects.path DESC, id DESC') }
+
+ def self.for_projects(projects)
+ return none unless projects.any?
+
+ where(project_id: projects)
+ end
+
+ def self.only_maven_packages_with_path(path)
+ joins(:maven_metadatum).where(packages_maven_metadata: { path: path })
+ end
+
+ def self.by_name_and_file_name(name, file_name)
+ with_name(name)
+ .joins(:package_files)
+ .where(packages_package_files: { file_name: file_name }).last!
+ end
+
+ def self.by_file_name_and_sha256(file_name, sha256)
+ joins(:package_files)
+ .where(packages_package_files: { file_name: file_name, file_sha256: sha256 }).last!
+ end
+
+ def self.pluck_names
+ pluck(:name)
+ end
+
+ def self.pluck_versions
+ pluck(:version)
+ end
+
+ def self.sort_by_attribute(method)
+ case method.to_s
+ when 'created_asc' then order_created
+ when 'created_at_asc' then order_created
+ when 'name_asc' then order_name
+ when 'name_desc' then order_name_desc
+ when 'version_asc' then order_version
+ when 'version_desc' then order_version_desc
+ when 'type_asc' then order_type
+ when 'type_desc' then order_type_desc
+ when 'project_name_asc' then order_project_name
+ when 'project_name_desc' then order_project_name_desc
+ when 'project_path_asc' then order_project_path
+ when 'project_path_desc' then order_project_path_desc
+ else
+ order_created_desc
+ end
+ end
+
+ def versions
+ project.packages
+ .with_name(name)
+ .where.not(version: version)
+ .with_package_type(package_type)
+ .order(:version)
+ end
+
+ def pipeline
+ build_info&.pipeline
+ end
+
+ def tag_names
+ tags.pluck(:name)
+ end
+
+ private
+
+ def valid_conan_package_recipe
+ recipe_exists = project.packages
+ .conan
+ .includes(:conan_metadatum)
+ .with_name(name)
+ .with_version(version)
+ .with_conan_channel(conan_metadatum.package_channel)
+ .with_conan_username(conan_metadatum.package_username)
+ .id_not_in(id)
+ .exists?
+
+ errors.add(:base, _('Package recipe already exists')) if recipe_exists
+ end
+
+ def valid_composer_global_name
+ # .default_scoped is required here due to a bug in rails that leaks
+ # the scope and adds `self` to the query incorrectly
+ # See https://github.com/rails/rails/pull/35186
+ if Packages::Package.default_scoped.composer.with_name(name).where.not(project_id: project_id).exists?
+ errors.add(:name, 'is already taken by another project')
+ end
+ end
+
+ def valid_npm_package_name
+ return unless project&.root_namespace
+
+ unless name =~ %r{\A@#{project.root_namespace.path}/[^/]+\z}
+ errors.add(:name, 'is not valid')
+ end
+ end
+
+ def package_already_taken
+ return unless project
+
+ if project.package_already_taken?(name)
+ errors.add(:base, _('Package already exists'))
+ end
+ end
+end
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
new file mode 100644
index 00000000000..567b5a14603
--- /dev/null
+++ b/app/models/packages/package_file.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+class Packages::PackageFile < ApplicationRecord
+ include UpdateProjectStatistics
+
+ delegate :project, :project_id, to: :package
+ delegate :conan_file_type, to: :conan_file_metadatum
+
+ belongs_to :package
+
+ has_one :conan_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Conan::FileMetadatum'
+
+ accepts_nested_attributes_for :conan_file_metadatum
+
+ validates :package, presence: true
+ validates :file, presence: true
+ validates :file_name, presence: true
+
+ scope :recent, -> { order(id: :desc) }
+ scope :with_file_name, ->(file_name) { where(file_name: file_name) }
+ scope :with_file_name_like, ->(file_name) { where(arel_table[:file_name].matches(file_name)) }
+ scope :with_files_stored_locally, -> { where(file_store: ::Packages::PackageFileUploader::Store::LOCAL) }
+ scope :preload_conan_file_metadata, -> { preload(:conan_file_metadatum) }
+
+ scope :with_conan_file_type, ->(file_type) do
+ joins(:conan_file_metadatum)
+ .where(packages_conan_file_metadata: { conan_file_type: ::Packages::Conan::FileMetadatum.conan_file_types[file_type] })
+ end
+
+ scope :with_conan_package_reference, ->(conan_package_reference) do
+ joins(:conan_file_metadatum)
+ .where(packages_conan_file_metadata: { conan_package_reference: conan_package_reference })
+ end
+
+ mount_uploader :file, Packages::PackageFileUploader
+
+ after_save :update_file_metadata, if: :saved_change_to_file?
+
+ update_project_statistics project_statistics_name: :packages_size
+
+ def update_file_metadata
+ # The file.object_store is set during `uploader.store!`
+ # which happens after object is inserted/updated
+ self.update_column(:file_store, file.object_store)
+ self.update_column(:size, file.size) unless file.size == self.size
+ end
+
+ def download_path
+ Gitlab::Routing.url_helpers.download_project_package_file_path(project, self) if ::Gitlab.ee?
+ end
+
+ def local?
+ file_store == ::Packages::PackageFileUploader::Store::LOCAL
+ end
+end
+
+Packages::PackageFile.prepend_if_ee('EE::Packages::PackageFileGeo')
diff --git a/app/models/packages/pypi.rb b/app/models/packages/pypi.rb
new file mode 100644
index 00000000000..fc8a55caa31
--- /dev/null
+++ b/app/models/packages/pypi.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+module Packages
+ module Pypi
+ def self.table_name_prefix
+ 'packages_pypi_'
+ end
+ end
+end
diff --git a/app/models/packages/pypi/metadatum.rb b/app/models/packages/pypi/metadatum.rb
new file mode 100644
index 00000000000..7e6456ad964
--- /dev/null
+++ b/app/models/packages/pypi/metadatum.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Packages::Pypi::Metadatum < ApplicationRecord
+ self.primary_key = :package_id
+
+ belongs_to :package, -> { where(package_type: :pypi) }, inverse_of: :pypi_metadatum
+
+ validates :package, presence: true
+
+ validate :pypi_package_type
+
+ private
+
+ def pypi_package_type
+ unless package&.pypi?
+ errors.add(:base, _('Package type must be PyPi'))
+ end
+ end
+end
diff --git a/app/models/packages/sem_ver.rb b/app/models/packages/sem_ver.rb
new file mode 100644
index 00000000000..b73d51b08b7
--- /dev/null
+++ b/app/models/packages/sem_ver.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+class Packages::SemVer
+ attr_accessor :major, :minor, :patch, :prerelease, :build
+
+ def initialize(major = 0, minor = 0, patch = 0, prerelease = nil, build = nil, prefixed: false)
+ @major = major
+ @minor = minor
+ @patch = patch
+ @prerelease = prerelease
+ @build = build
+ @prefixed = prefixed
+ end
+
+ def prefixed?
+ @prefixed
+ end
+
+ def ==(other)
+ self.class == other.class &&
+ self.major == other.major &&
+ self.minor == other.minor &&
+ self.patch == other.patch &&
+ self.prerelease == other.prerelease &&
+ self.build == other.build
+ end
+
+ def to_s
+ s = "#{prefixed? ? 'v' : ''}#{major || 0}.#{minor || 0}.#{patch || 0}"
+ s += "-#{prerelease}" if prerelease
+ s += "+#{build}" if build
+
+ s
+ end
+
+ def self.match(str, prefixed: false)
+ return unless str&.start_with?('v') == prefixed
+
+ str = str[1..] if prefixed
+
+ Gitlab::Regex.semver_regex.match(str)
+ end
+
+ def self.match?(str, prefixed: false)
+ !match(str, prefixed: prefixed).nil?
+ end
+
+ def self.parse(str, prefixed: false)
+ m = match str, prefixed: prefixed
+ return unless m
+
+ new(m[1].to_i, m[2].to_i, m[3].to_i, m[4], m[5], prefixed: prefixed)
+ end
+end
diff --git a/app/models/packages/tag.rb b/app/models/packages/tag.rb
new file mode 100644
index 00000000000..771d016daed
--- /dev/null
+++ b/app/models/packages/tag.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+class Packages::Tag < ApplicationRecord
+ belongs_to :package, inverse_of: :tags
+
+ validates :package, :name, presence: true
+
+ FOR_PACKAGES_TAGS_LIMIT = 200.freeze
+ NUGET_TAGS_SEPARATOR = ' ' # https://docs.microsoft.com/en-us/nuget/reference/nuspec#tags
+
+ scope :preload_package, -> { preload(:package) }
+ scope :with_name, -> (name) { where(name: name) }
+
+ def self.for_packages(packages)
+ where(package_id: packages.select(:id))
+ .order(updated_at: :desc)
+ .limit(FOR_PACKAGES_TAGS_LIMIT)
+ end
+end
diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb
index b04e7e689cd..bf87d2c3916 100644
--- a/app/models/performance_monitoring/prometheus_dashboard.rb
+++ b/app/models/performance_monitoring/prometheus_dashboard.rb
@@ -7,7 +7,7 @@ module PerformanceMonitoring
attr_accessor :dashboard, :panel_groups, :path, :environment, :priority, :templating, :links
validates :dashboard, presence: true
- validates :panel_groups, presence: true
+ validates :panel_groups, array_members: { member_class: PerformanceMonitoring::PrometheusPanelGroup }
class << self
def from_json(json_content)
@@ -35,9 +35,15 @@ module PerformanceMonitoring
new(
dashboard: attributes['dashboard'],
- panel_groups: attributes['panel_groups']&.map { |group| PrometheusPanelGroup.from_json(group) }
+ panel_groups: initialize_children_collection(attributes['panel_groups'])
)
end
+
+ def initialize_children_collection(children)
+ return unless children.is_a?(Array)
+
+ children.map { |group| PerformanceMonitoring::PrometheusPanelGroup.from_json(group) }
+ end
end
def to_yaml
@@ -47,7 +53,7 @@ module PerformanceMonitoring
# This method is planned to be refactored as a part of https://gitlab.com/gitlab-org/gitlab/-/issues/219398
# implementation. For new existing logic was reused to faster deliver MVC
def schema_validation_warnings
- self.class.from_json(self.as_json)
+ self.class.from_json(reload_schema)
nil
rescue ActiveModel::ValidationError => exception
exception.model.errors.map { |attr, error| "#{attr}: #{error}" }
@@ -55,6 +61,14 @@ module PerformanceMonitoring
private
+ # dashboard finder methods are somehow limited, #find includes checking if
+ # user is authorised to view selected dashboard, but modifies schema, which in some cases may
+ # cause false positives returned from validation, and #find_raw does not authorise users
+ def reload_schema
+ project = environment&.project
+ project.nil? ? self.as_json : Gitlab::Metrics::Dashboard::Finder.find_raw(project, dashboard_path: path)
+ end
+
def yaml_valid_attributes
%w(panel_groups panels metrics group priority type title y_label weight id unit label query query_range dashboard)
end
diff --git a/app/models/performance_monitoring/prometheus_panel.rb b/app/models/performance_monitoring/prometheus_panel.rb
index a16a68ba832..b33c09001ae 100644
--- a/app/models/performance_monitoring/prometheus_panel.rb
+++ b/app/models/performance_monitoring/prometheus_panel.rb
@@ -7,7 +7,8 @@ module PerformanceMonitoring
attr_accessor :type, :title, :y_label, :weight, :metrics, :y_axis, :max_value
validates :title, presence: true
- validates :metrics, presence: true
+ validates :metrics, array_members: { member_class: PerformanceMonitoring::PrometheusMetric }
+
class << self
def from_json(json_content)
build_from_hash(json_content).tap(&:validate!)
@@ -23,9 +24,15 @@ module PerformanceMonitoring
title: attributes['title'],
y_label: attributes['y_label'],
weight: attributes['weight'],
- metrics: attributes['metrics']&.map { |metric| PrometheusMetric.from_json(metric) }
+ metrics: initialize_children_collection(attributes['metrics'])
)
end
+
+ def initialize_children_collection(children)
+ return unless children.is_a?(Array)
+
+ children.map { |metrics| PerformanceMonitoring::PrometheusMetric.from_json(metrics) }
+ end
end
def id(group_title)
diff --git a/app/models/performance_monitoring/prometheus_panel_group.rb b/app/models/performance_monitoring/prometheus_panel_group.rb
index f88106f259b..7f3d2a1b8f4 100644
--- a/app/models/performance_monitoring/prometheus_panel_group.rb
+++ b/app/models/performance_monitoring/prometheus_panel_group.rb
@@ -7,7 +7,8 @@ module PerformanceMonitoring
attr_accessor :group, :priority, :panels
validates :group, presence: true
- validates :panels, presence: true
+ validates :panels, array_members: { member_class: PerformanceMonitoring::PrometheusPanel }
+
class << self
def from_json(json_content)
build_from_hash(json_content).tap(&:validate!)
@@ -21,9 +22,15 @@ module PerformanceMonitoring
new(
group: attributes['group'],
priority: attributes['priority'],
- panels: attributes['panels']&.map { |panel| PrometheusPanel.from_json(panel) }
+ panels: initialize_children_collection(attributes['panels'])
)
end
+
+ def initialize_children_collection(children)
+ return unless children.is_a?(Array)
+
+ children.map { |panels| PerformanceMonitoring::PrometheusPanel.from_json(panels) }
+ end
end
end
end
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 7afee2a35cb..488ebd531a8 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -17,11 +17,13 @@ class PersonalAccessToken < ApplicationRecord
before_save :ensure_token
- scope :active, -> { where("revoked = false AND (expires_at >= NOW() OR expires_at IS NULL)") }
- scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= NOW() AND expires_at <= ?", date]) }
- scope :inactive, -> { where("revoked = true OR expires_at < NOW()") }
+ scope :active, -> { where("revoked = false AND (expires_at >= CURRENT_DATE OR expires_at IS NULL)") }
+ scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) }
+ scope :inactive, -> { where("revoked = true OR expires_at < CURRENT_DATE") }
scope :with_impersonation, -> { where(impersonation: true) }
scope :without_impersonation, -> { where(impersonation: false) }
+ scope :revoked, -> { where(revoked: true) }
+ scope :not_revoked, -> { where(revoked: [false, nil]) }
scope :for_user, -> (user) { where(user: user) }
scope :preload_users, -> { preload(:user) }
scope :order_expires_at_asc, -> { reorder(expires_at: :asc) }
diff --git a/app/models/plan.rb b/app/models/plan.rb
index acac5f9aeae..b4091e0a755 100644
--- a/app/models/plan.rb
+++ b/app/models/plan.rb
@@ -27,7 +27,7 @@ class Plan < ApplicationRecord
end
def actual_limits
- self.limits || PlanLimits.new
+ self.limits || self.build_limits
end
def default?
diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb
index 575105cfd79..f17078c0cab 100644
--- a/app/models/plan_limits.rb
+++ b/app/models/plan_limits.rb
@@ -1,23 +1,36 @@
# frozen_string_literal: true
class PlanLimits < ApplicationRecord
+ LimitUndefinedError = Class.new(StandardError)
+
belongs_to :plan
- def exceeded?(limit_name, object)
- return false unless enabled?(limit_name)
+ def exceeded?(limit_name, subject, alternate_limit: 0)
+ limit = limit_for(limit_name, alternate_limit: alternate_limit)
+ return false unless limit
- if object.is_a?(Integer)
- object >= read_attribute(limit_name)
- else
- # object.count >= limit value is slower than checking
+ case subject
+ when Integer
+ subject >= limit
+ when ActiveRecord::Relation
+ # We intentionally not accept just plain ApplicationRecord classes to
+ # enforce the subject to be scoped down to a relation first.
+ #
+ # subject.count >= limit value is slower than checking
# if a record exists at the limit value - 1 position.
- object.offset(read_attribute(limit_name) - 1).exists?
+ subject.offset(limit - 1).exists?
+ else
+ raise ArgumentError, "#{subject.class} is not supported as a limit value"
end
end
- private
+ def limit_for(limit_name, alternate_limit: 0)
+ limit = read_attribute(limit_name)
+ raise LimitUndefinedError, "The limit `#{limit_name}` is undefined" if limit.nil?
+
+ alternate_limit = alternate_limit.call if alternate_limit.respond_to?(:call)
- def enabled?(limit_name)
- read_attribute(limit_name) > 0
+ limits = [limit, alternate_limit]
+ limits.map(&:to_i).select(&:positive?).min
end
end
diff --git a/app/models/product_analytics_event.rb b/app/models/product_analytics_event.rb
new file mode 100644
index 00000000000..95a2e7a26c4
--- /dev/null
+++ b/app/models/product_analytics_event.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class ProductAnalyticsEvent < ApplicationRecord
+ self.table_name = 'product_analytics_events_experimental'
+
+ # Ignore that the partition key :project_id is part of the formal primary key
+ self.primary_key = :id
+
+ belongs_to :project
+
+ validates :event_id, :project_id, :v_collector, :v_etl, presence: true
+
+ # There is no default Rails timestamps in the table.
+ # collector_tstamp is a timestamp when a collector recorded an event.
+ scope :order_by_time, -> { order(collector_tstamp: :desc) }
+
+ # If we decide to change this scope to use date_trunc('day', collector_tstamp),
+ # we should remember that a btree index on collector_tstamp will be no longer effective.
+ scope :timerange, ->(duration, today = Time.zone.today) {
+ where('collector_tstamp BETWEEN ? AND ? ', today - duration + 1, today + 1)
+ }
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 845e9e83e78..3aa0db56404 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -65,6 +65,7 @@ class Project < ApplicationRecord
cache_markdown_field :description, pipeline: :description
+ default_value_for :packages_enabled, true
default_value_for :archived, false
default_value_for :resolve_outdated_diff_discussions, false
default_value_for :container_registry_enabled, gitlab_config_features.container_registry
@@ -168,6 +169,7 @@ class Project < ApplicationRecord
has_one :custom_issue_tracker_service
has_one :bugzilla_service
has_one :gitlab_issue_tracker_service, inverse_of: :project
+ has_one :confluence_service
has_one :external_wiki_service
has_one :prometheus_service, inverse_of: :project
has_one :mock_ci_service
@@ -190,6 +192,10 @@ class Project < ApplicationRecord
has_many :forks, through: :forked_to_members, source: :project, inverse_of: :forked_from_project
has_many :fork_network_projects, through: :fork_network, source: :projects
+ # Packages
+ has_many :packages, class_name: 'Packages::Package'
+ has_many :package_files, through: :packages, class_name: 'Packages::PackageFile'
+
has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :export_jobs, class_name: 'ProjectExportJob'
@@ -200,6 +206,7 @@ class Project < ApplicationRecord
has_one :grafana_integration, inverse_of: :project
has_one :project_setting, inverse_of: :project, autosave: true
has_one :alerting_setting, inverse_of: :project, class_name: 'Alerting::ProjectAlertingSetting'
+ has_one :service_desk_setting, class_name: 'ServiceDeskSetting'
# Merge Requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project
@@ -363,6 +370,7 @@ class Project < ApplicationRecord
to: :project_setting, allow_nil: true
delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?,
prefix: :import, to: :import_state, allow_nil: true
+ delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting
delegate :no_import?, to: :import_state, allow_nil: true
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
@@ -376,7 +384,10 @@ class Project < ApplicationRecord
delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci
delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings
delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true
- delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=, to: :project_setting
+ delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?,
+ :allow_merge_on_skipped_pipeline=, :has_confluence?,
+ to: :project_setting
+ delegate :active?, to: :prometheus_service, allow_nil: true, prefix: true
# Validations
validates :creator, presence: true, on: :create
@@ -439,6 +450,7 @@ class Project < ApplicationRecord
# Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name
scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) }
+ scope :with_packages, -> { joins(:packages) }
scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) }
scope :personal, ->(user) { where(namespace_id: user.namespace_id) }
scope :joined, ->(user) { where('namespace_id != ?', user.namespace_id) }
@@ -454,6 +466,7 @@ class Project < ApplicationRecord
scope :with_statistics, -> { includes(:statistics) }
scope :with_namespace, -> { includes(:namespace) }
scope :with_import_state, -> { includes(:import_state) }
+ scope :include_project_feature, -> { includes(:project_feature) }
scope :with_service, ->(service) { joins(service).eager_load(service) }
scope :with_shared_runners, -> { where(shared_runners_enabled: true) }
scope :with_container_registry, -> { where(container_registry_enabled: true) }
@@ -488,6 +501,7 @@ class Project < ApplicationRecord
.where(repository_languages: { programming_language_id: lang_id_query })
end
+ scope :service_desk_enabled, -> { where(service_desk_enabled: true) }
scope :with_builds_enabled, -> { with_feature_enabled(:builds) }
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) }
@@ -513,9 +527,8 @@ class Project < ApplicationRecord
.where(project_pages_metadata: { project_id: nil })
end
- scope :with_api_entity_associations, -> {
- preload(:project_feature, :route, :tags,
- group: :ip_restrictions, namespace: [:route, :owner])
+ scope :with_api_commit_entity_associations, -> {
+ preload(:project_feature, :route, namespace: [:route, :owner])
}
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
@@ -532,6 +545,10 @@ class Project < ApplicationRecord
# Used by Projects::CleanupService to hold a map of rewritten object IDs
mount_uploader :bfg_object_map, AttachmentUploader
+ def self.with_api_entity_associations
+ preload(:project_feature, :route, :tags, :group, namespace: [:route, :owner])
+ end
+
def self.with_web_entity_associations
preload(:project_feature, :route, :creator, :group, namespace: [:route, :owner])
end
@@ -589,6 +606,14 @@ class Project < ApplicationRecord
end
end
+ def self.projects_user_can(projects, user, action)
+ projects = where(id: projects)
+
+ DeclarativePolicy.user_scope do
+ projects.select { |project| Ability.allowed?(user, action, project) }
+ end
+ end
+
# This scope returns projects where user has access to both the project and the feature.
def self.filter_by_feature_visibility(feature, user)
with_feature_available_for_user(feature, user)
@@ -675,10 +700,11 @@ class Project < ApplicationRecord
# '>' or its escaped form ('&gt;') are checked for because '>' is sometimes escaped
# when the reference comes from an external source.
def markdown_reference_pattern
- %r{
- #{reference_pattern}
- (#{reference_postfix}|#{reference_postfix_escaped})
- }x
+ @markdown_reference_pattern ||=
+ %r{
+ #{reference_pattern}
+ (#{reference_postfix}|#{reference_postfix_escaped})
+ }x
end
def trending
@@ -706,6 +732,12 @@ class Project < ApplicationRecord
from_union([with_issues_enabled, with_merge_requests_enabled]).select(:id)
end
+
+ def find_by_service_desk_project_key(key)
+ # project_key is not indexed for now
+ # see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24063#note_282435524 for details
+ joins(:service_desk_setting).find_by('service_desk_settings.project_key' => key)
+ end
end
def initialize(attributes = nil)
@@ -839,6 +871,15 @@ class Project < ApplicationRecord
end
end
+ # Because we use default_value_for we need to be sure
+ # packages_enabled= method does exist even if we rollback migration.
+ # Otherwise many tests from spec/migrations will fail.
+ def packages_enabled=(value)
+ if has_attribute?(:packages_enabled)
+ write_attribute(:packages_enabled, value)
+ end
+ end
+
def cleanup
@repository = nil
end
@@ -1699,7 +1740,7 @@ class Project < ApplicationRecord
url_path = full_path.partition('/').last
# If the project path is the same as host, we serve it as group page
- return url if url == "#{Settings.pages.protocol}://#{url_path}"
+ return url if url == "#{Settings.pages.protocol}://#{url_path}".downcase
"#{url}/#{url_path}"
end
@@ -1795,6 +1836,7 @@ class Project < ApplicationRecord
after_create_default_branch
join_pool_repository
refresh_markdown_cache!
+ write_repository_config
end
def update_project_counter_caches
@@ -1922,6 +1964,7 @@ class Project < ApplicationRecord
.append(key: 'CI_PROJECT_PATH', value: full_path)
.append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug)
.append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path)
+ .append(key: 'CI_PROJECT_ROOT_NAMESPACE', value: namespace.root_ancestor.path)
.append(key: 'CI_PROJECT_URL', value: web_url)
.append(key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level))
.append(key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: repository_languages.map(&:name).join(',').downcase)
@@ -2131,7 +2174,13 @@ class Project < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def forks_count
- Projects::ForksCountService.new(self).count
+ BatchLoader.for(self).batch do |projects, loader|
+ fork_count_per_project = ::Projects::BatchForksCountService.new(projects).refresh_cache_and_retrieve_data
+
+ fork_count_per_project.each do |project, count|
+ loader.call(project, count)
+ end
+ end
end
# rubocop: enable CodeReuse/ServiceClass
@@ -2410,6 +2459,37 @@ class Project < ApplicationRecord
super || build_metrics_setting
end
+ def service_desk_enabled
+ Gitlab::ServiceDesk.enabled?(project: self)
+ end
+
+ alias_method :service_desk_enabled?, :service_desk_enabled
+
+ def service_desk_address
+ return unless service_desk_enabled?
+
+ config = Gitlab.config.incoming_email
+ wildcard = Gitlab::IncomingEmail::WILDCARD_PLACEHOLDER
+
+ config.address&.gsub(wildcard, "#{full_path_slug}-#{id}-issue-")
+ end
+
+ def root_namespace
+ if namespace.has_parent?
+ namespace.root_ancestor
+ else
+ namespace
+ end
+ end
+
+ def package_already_taken?(package_name)
+ namespace.root_ancestor.all_projects
+ .joins(:packages)
+ .where.not(id: id)
+ .merge(Packages::Package.with_name(package_name))
+ .exists?
+ end
+
private
def find_service(services, name)
diff --git a/app/models/project_services/alerts_service.rb b/app/models/project_services/alerts_service.rb
index 58c47accfd1..28902114f3c 100644
--- a/app/models/project_services/alerts_service.rb
+++ b/app/models/project_services/alerts_service.rb
@@ -78,3 +78,5 @@ class AlertsService < Service
Gitlab::Routing.url_helpers
end
end
+
+AlertsService.prepend_if_ee('EE::AlertsService')
diff --git a/app/models/project_services/bugzilla_service.rb b/app/models/project_services/bugzilla_service.rb
index 0a498fde95a..4332db3e961 100644
--- a/app/models/project_services/bugzilla_service.rb
+++ b/app/models/project_services/bugzilla_service.rb
@@ -3,11 +3,11 @@
class BugzillaService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- def default_title
+ def title
'Bugzilla'
end
- def default_description
+ def description
s_('IssueTracker|Bugzilla issue tracker')
end
diff --git a/app/models/project_services/confluence_service.rb b/app/models/project_services/confluence_service.rb
new file mode 100644
index 00000000000..dd44a0d1d56
--- /dev/null
+++ b/app/models/project_services/confluence_service.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+class ConfluenceService < Service
+ include ActionView::Helpers::UrlHelper
+
+ VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze
+ VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze
+ VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze
+
+ prop_accessor :confluence_url
+
+ validates :confluence_url, presence: true, if: :activated?
+ validate :validate_confluence_url_is_cloud, if: :activated?
+
+ after_commit :cache_project_has_confluence
+
+ def self.to_param
+ 'confluence'
+ end
+
+ def self.supported_events
+ %w()
+ end
+
+ def title
+ s_('ConfluenceService|Confluence Workspace')
+ end
+
+ def description
+ s_('ConfluenceService|Connect a Confluence Cloud Workspace to your GitLab project')
+ end
+
+ def detailed_description
+ return unless project.wiki_enabled?
+
+ if activated?
+ wiki_url = project.wiki.web_url
+
+ s_(
+ 'ConfluenceService|Your GitLab Wiki can be accessed here: %{wiki_link}. To re-enable your GitLab Wiki, disable this integration' %
+ { wiki_link: link_to(wiki_url, wiki_url) }
+ ).html_safe
+ else
+ s_('ConfluenceService|Enabling the Confluence Workspace will disable the default GitLab Wiki. Your GitLab Wiki data will be saved and you can always re-enable it later by turning off this integration').html_safe
+ end
+ end
+
+ def fields
+ [
+ {
+ type: 'text',
+ name: 'confluence_url',
+ title: 'Confluence Cloud Workspace URL',
+ placeholder: s_('ConfluenceService|The URL of the Confluence Workspace'),
+ required: true
+ }
+ ]
+ end
+
+ def can_test?
+ false
+ end
+
+ private
+
+ def validate_confluence_url_is_cloud
+ unless confluence_uri_valid?
+ errors.add(:confluence_url, 'URL must be to a Confluence Cloud Workspace hosted on atlassian.net')
+ end
+ end
+
+ def confluence_uri_valid?
+ return false unless confluence_url
+
+ uri = URI.parse(confluence_url)
+
+ (uri.scheme&.match(VALID_SCHEME_MATCH) &&
+ uri.host&.match(VALID_HOST_MATCH) &&
+ uri.path&.match(VALID_PATH_MATCH)).present?
+
+ rescue URI::InvalidURIError
+ false
+ end
+
+ def cache_project_has_confluence
+ return unless project && !project.destroyed?
+
+ project.project_setting.save! unless project.project_setting.persisted?
+ project.project_setting.update_column(:has_confluence, active?)
+ end
+end
diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb
index dbc42b1b86d..fc58ba27c3d 100644
--- a/app/models/project_services/custom_issue_tracker_service.rb
+++ b/app/models/project_services/custom_issue_tracker_service.rb
@@ -3,11 +3,11 @@
class CustomIssueTrackerService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- def default_title
+ def title
'Custom Issue Tracker'
end
- def default_description
+ def description
s_('IssueTracker|Custom issue tracker')
end
@@ -17,8 +17,6 @@ class CustomIssueTrackerService < IssueTrackerService
def fields
[
- { type: 'text', name: 'title', placeholder: title },
- { type: 'text', name: 'description', placeholder: description },
{ type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
{ type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true },
{ type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true }
diff --git a/app/models/project_services/gitlab_issue_tracker_service.rb b/app/models/project_services/gitlab_issue_tracker_service.rb
index ec28602b5e6..b3f44e040bc 100644
--- a/app/models/project_services/gitlab_issue_tracker_service.rb
+++ b/app/models/project_services/gitlab_issue_tracker_service.rb
@@ -7,11 +7,11 @@ class GitlabIssueTrackerService < IssueTrackerService
default_value_for :default, true
- def default_title
+ def title
'GitLab'
end
- def default_description
+ def description
s_('IssueTracker|GitLab issue tracker')
end
diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb
index f5d6ae10469..694374e9548 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -25,28 +25,6 @@ class IssueTrackerService < Service
end
end
- # this will be removed as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
- def title
- if title_attribute = read_attribute(:title)
- title_attribute
- elsif self.properties && self.properties['title'].present?
- self.properties['title']
- else
- default_title
- end
- end
-
- # this will be removed as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
- def description
- if description_attribute = read_attribute(:description)
- description_attribute
- elsif self.properties && self.properties['description'].present?
- self.properties['description']
- else
- default_description
- end
- end
-
def handle_properties
# this has been moved from initialize_properties and should be improved
# as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
@@ -54,13 +32,6 @@ class IssueTrackerService < Service
@legacy_properties_data = properties.dup
data_values = properties.slice!('title', 'description')
- properties.each do |key, _|
- current_value = self.properties.delete(key)
- value = attribute_changed?(key) ? attribute_change(key).last : current_value
-
- write_attribute(key, value)
- end
-
data_values.reject! { |key| data_fields.changed.include?(key) }
data_values.slice!(*data_fields.attributes.keys)
data_fields.assign_attributes(data_values) if data_values.present?
@@ -102,7 +73,6 @@ class IssueTrackerService < Service
def fields
[
- { type: 'text', name: 'description', placeholder: description },
{ type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
{ type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true },
{ type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true }
@@ -117,8 +87,6 @@ class IssueTrackerService < Service
def set_default_data
return unless issues_tracker.present?
- self.title ||= issues_tracker['title']
-
# we don't want to override if we have set something
return if project_url || issues_url || new_issue_url
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index bb4d35cad22..4ea2ec10f11 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -23,7 +23,7 @@ class JiraService < IssueTrackerService
# TODO: we can probably just delegate as part of
# https://gitlab.com/gitlab-org/gitlab/issues/29404
- data_field :username, :password, :url, :api_url, :jira_issue_transition_id
+ data_field :username, :password, :url, :api_url, :jira_issue_transition_id, :project_key, :issues_enabled
before_update :reset_password
@@ -64,8 +64,6 @@ class JiraService < IssueTrackerService
def set_default_data
return unless issues_tracker.present?
- self.title ||= issues_tracker['title']
-
return if url
data_fields.url ||= issues_tracker['url']
@@ -103,11 +101,11 @@ class JiraService < IssueTrackerService
[Jira service documentation](#{help_page_url('user/project/integrations/jira')})."
end
- def default_title
+ def title
'Jira'
end
- def default_description
+ def description
s_('JiraService|Jira issue tracker')
end
@@ -130,7 +128,7 @@ class JiraService < IssueTrackerService
end
def new_issue_url
- "#{url}/secure/CreateIssue.jspa"
+ "#{url}/secure/CreateIssue!default.jspa"
end
alias_method :original_url, :url
@@ -442,3 +440,5 @@ class JiraService < IssueTrackerService
end
end
end
+
+JiraService.prepend_if_ee('EE::JiraService')
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index 44a41969b1c..997c6eba91a 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -28,6 +28,9 @@ class PrometheusService < MonitoringService
after_create_commit :create_default_alerts
+ scope :preload_project, -> { preload(:project) }
+ scope :with_clusters_with_cilium, -> { joins(project: [:clusters]).merge(Clusters::Cluster.with_available_cilium) }
+
def initialize_properties
if properties.nil?
self.properties = {}
@@ -51,7 +54,7 @@ class PrometheusService < MonitoringService
end
def fields
- result = [
+ [
{
type: 'checkbox',
name: 'manual_configuration',
@@ -64,30 +67,23 @@ class PrometheusService < MonitoringService
title: 'API URL',
placeholder: s_('PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/'),
required: true
+ },
+ {
+ type: 'text',
+ name: 'google_iap_audience_client_id',
+ title: 'Google IAP Audience Client ID',
+ placeholder: s_('PrometheusService|Client ID of the IAP secured resource (looks like IAP_CLIENT_ID.apps.googleusercontent.com)'),
+ autocomplete: 'off',
+ required: false
+ },
+ {
+ type: 'textarea',
+ name: 'google_iap_service_account_json',
+ title: 'Google IAP Service Account JSON',
+ placeholder: s_('PrometheusService|Contents of the credentials.json file of your service account, like: { "type": "service_account", "project_id": ... }'),
+ required: false
}
]
-
- if Feature.enabled?(:prometheus_service_iap_auth)
- result += [
- {
- type: 'text',
- name: 'google_iap_audience_client_id',
- title: 'Google IAP Audience Client ID',
- placeholder: s_('PrometheusService|Client ID of the IAP secured resource (looks like IAP_CLIENT_ID.apps.googleusercontent.com)'),
- autocomplete: 'off',
- required: false
- },
- {
- type: 'textarea',
- name: 'google_iap_service_account_json',
- title: 'Google IAP Service Account JSON',
- placeholder: s_('PrometheusService|Contents of the credentials.json file of your service account, like: { "type": "service_account", "project_id": ... }'),
- required: false
- }
- ]
- end
-
- result
end
# Check we can connect to the Prometheus API
@@ -103,7 +99,7 @@ class PrometheusService < MonitoringService
options = { allow_local_requests: allow_local_api_url? }
- if Feature.enabled?(:prometheus_service_iap_auth) && behind_iap?
+ if behind_iap?
# Adds the Authorization header
options[:headers] = iap_client.apply({})
end
diff --git a/app/models/project_services/redmine_service.rb b/app/models/project_services/redmine_service.rb
index a4ca0d20669..df78520d65f 100644
--- a/app/models/project_services/redmine_service.rb
+++ b/app/models/project_services/redmine_service.rb
@@ -3,11 +3,11 @@
class RedmineService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- def default_title
+ def title
'Redmine'
end
- def default_description
+ def description
s_('IssueTracker|Redmine issue tracker')
end
diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb
index 40203ad692d..7fb3bde44a5 100644
--- a/app/models/project_services/youtrack_service.rb
+++ b/app/models/project_services/youtrack_service.rb
@@ -12,11 +12,11 @@ class YoutrackService < IssueTrackerService
end
end
- def default_title
+ def title
'YouTrack'
end
- def default_description
+ def description
s_('IssueTracker|YouTrack issue tracker')
end
@@ -26,7 +26,6 @@ class YoutrackService < IssueTrackerService
def fields
[
- { type: 'text', name: 'description', placeholder: description },
{ type: 'text', name: 'project_url', title: 'Project URL', placeholder: 'Project URL', required: true },
{ type: 'text', name: 'issues_url', title: 'Issue URL', placeholder: 'Issue URL', required: true }
]
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index 9022d3e879d..aca7eec3382 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -3,7 +3,22 @@
class ProjectSetting < ApplicationRecord
belongs_to :project, inverse_of: :project_setting
+ enum squash_option: {
+ never: 0,
+ always: 1,
+ default_on: 2,
+ default_off: 3
+ }, _prefix: 'squash'
+
self.primary_key = :project_id
+
+ def squash_enabled_by_default?
+ %w[always default_on].include?(squash_option)
+ end
+
+ def squash_readonly?
+ %w[always never].include?(squash_option)
+ end
end
ProjectSetting.prepend_if_ee('EE::ProjectSetting')
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 6f04a36392d..f153bfe3f5b 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -7,16 +7,12 @@ class ProjectStatistics < ApplicationRecord
belongs_to :namespace
default_value_for :wiki_size, 0
-
- # older migrations fail due to non-existent attribute without this
- def wiki_size
- has_attribute?(:wiki_size) ? super : 0
- end
+ default_value_for :snippets_size, 0
before_save :update_storage_size
- COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count].freeze
- INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size] }.freeze
+ COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size].freeze
+ INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size], snippets_size: %i[storage_size] }.freeze
NAMESPACE_RELATABLE_COLUMNS = [:repository_size, :wiki_size, :lfs_objects_size].freeze
scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) }
@@ -54,17 +50,37 @@ class ProjectStatistics < ApplicationRecord
self.wiki_size = project.wiki.repository.size * 1.megabyte
end
+ def update_snippets_size
+ self.snippets_size = project.snippets.with_statistics.sum(:repository_size)
+ end
+
def update_lfs_objects_size
self.lfs_objects_size = project.lfs_objects.sum(:size)
end
- # older migrations fail due to non-existent attribute without this
- def packages_size
- has_attribute?(:packages_size) ? super : 0
+ # `wiki_size` and `snippets_size` have no default value in the database
+ # and the column can be nil.
+ # This means that, when the columns were added, all rows had nil
+ # values on them.
+ # Therefore, any call to any of those methods will return nil instead
+ # of 0, because `default_value_for` works with new records, not existing ones.
+ #
+ # These two methods provide consistency and avoid returning nil.
+ def wiki_size
+ super.to_i
+ end
+
+ def snippets_size
+ super.to_i
end
def update_storage_size
- self.storage_size = repository_size + wiki_size.to_i + lfs_objects_size + build_artifacts_size + packages_size
+ storage_size = repository_size + wiki_size + lfs_objects_size + build_artifacts_size + packages_size
+ # The `snippets_size` column was added on 20200622095419 but db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb
+ # might try to update project statistics before the `snippets_size` column has been created.
+ storage_size += snippets_size if self.class.column_names.include?('snippets_size')
+
+ self.storage_size = storage_size
end
# Since this incremental update method does not call update_storage_size above,
diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb
index fbc0281296f..32f9809e538 100644
--- a/app/models/prometheus_alert.rb
+++ b/app/models/prometheus_alert.rb
@@ -16,6 +16,7 @@ class PrometheusAlert < ApplicationRecord
has_many :prometheus_alert_events, inverse_of: :prometheus_alert
has_many :related_issues, through: :prometheus_alert_events
+ has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :prometheus_alert
after_save :clear_prometheus_adapter_cache!
after_destroy :clear_prometheus_adapter_cache!
diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb
index 571b586056b..bfd23d2a334 100644
--- a/app/models/prometheus_metric.rb
+++ b/app/models/prometheus_metric.rb
@@ -11,6 +11,7 @@ class PrometheusMetric < ApplicationRecord
validates :group, presence: true
validates :y_label, presence: true
validates :unit, presence: true
+ validates :identifier, uniqueness: { scope: :project_id }, allow_nil: true
validates :project, presence: true, unless: :common?
validates :project, absence: true, if: :common?
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 911cfc7db38..48e96d4c193 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -149,7 +149,8 @@ class Repository
before: opts[:before],
all: !!opts[:all],
first_parent: !!opts[:first_parent],
- order: opts[:order]
+ order: opts[:order],
+ literal_pathspec: opts.fetch(:literal_pathspec, true)
}
commits = Gitlab::Git::Commit.where(options)
@@ -676,24 +677,24 @@ class Repository
end
end
- def list_last_commits_for_tree(sha, path, offset: 0, limit: 25)
- commits = raw_repository.list_last_commits_for_tree(sha, path, offset: offset, limit: limit)
+ def list_last_commits_for_tree(sha, path, offset: 0, limit: 25, literal_pathspec: false)
+ commits = raw_repository.list_last_commits_for_tree(sha, path, offset: offset, limit: limit, literal_pathspec: literal_pathspec)
commits.each do |path, commit|
commits[path] = ::Commit.new(commit, container)
end
end
- def last_commit_for_path(sha, path)
- commit = raw_repository.last_commit_for_path(sha, path)
+ def last_commit_for_path(sha, path, literal_pathspec: false)
+ commit = raw_repository.last_commit_for_path(sha, path, literal_pathspec: literal_pathspec)
::Commit.new(commit, container) if commit
end
- def last_commit_id_for_path(sha, path)
+ def last_commit_id_for_path(sha, path, literal_pathspec: false)
key = path.blank? ? "last_commit_id_for_path:#{sha}" : "last_commit_id_for_path:#{sha}:#{Digest::SHA1.hexdigest(path)}"
cache.fetch(key) do
- last_commit_for_path(sha, path)&.id
+ last_commit_for_path(sha, path, literal_pathspec: literal_pathspec)&.id
end
end
@@ -712,8 +713,8 @@ class Repository
"#{name}-#{highest_branch_id + 1}"
end
- def branches_sorted_by(value)
- raw_repository.local_branches(sort_by: value)
+ def branches_sorted_by(sort_by, pagination_params = nil)
+ raw_repository.local_branches(sort_by: sort_by, pagination_params: pagination_params)
end
def tags_sorted_by(value)
@@ -1113,7 +1114,7 @@ class Repository
def project
if repo_type.snippet?
container.project
- else
+ elsif container.is_a?(Project)
container
end
end
diff --git a/app/models/resource_event.rb b/app/models/resource_event.rb
index 86e11c2d568..26dcda2630a 100644
--- a/app/models/resource_event.rb
+++ b/app/models/resource_event.rb
@@ -11,6 +11,7 @@ class ResourceEvent < ApplicationRecord
belongs_to :user
scope :created_after, ->(time) { where('created_at > ?', time) }
+ scope :created_on_or_before, ->(time) { where('created_at <= ?', time) }
def discussion_id
strong_memoize(:discussion_id) do
diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb
index 1d6573b180f..766b4d7a865 100644
--- a/app/models/resource_state_event.rb
+++ b/app/models/resource_state_event.rb
@@ -6,10 +6,16 @@ class ResourceStateEvent < ResourceEvent
validate :exactly_one_issuable
+ belongs_to :source_merge_request, class_name: 'MergeRequest', foreign_key: :source_merge_request_id
+
# state is used for issue and merge request states.
enum state: Issue.available_states.merge(MergeRequest.available_states).merge(reopened: 5)
def self.issuable_attrs
%i(issue merge_request).freeze
end
+
+ def issuable
+ issue || merge_request
+ end
end
diff --git a/app/models/service.rb b/app/models/service.rb
index 2880526c9de..89bde61bfe1 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -7,9 +7,12 @@ class Service < ApplicationRecord
include Importable
include ProjectServicesLoggable
include DataFields
+ include IgnorableColumns
+
+ ignore_columns %i[title description], remove_with: '13.4', remove_after: '2020-09-22'
SERVICE_NAMES = %w[
- alerts asana assembla bamboo bugzilla buildkite campfire custom_issue_tracker discord
+ alerts asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord
drone_ci emails_on_push external_wiki flowdock hangouts_chat hipchat irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack
@@ -357,6 +360,14 @@ class Service < ApplicationRecord
service
end
+ def self.instance_exists_for?(type)
+ exists?(instance: true, type: type)
+ end
+
+ def self.instance_for(type)
+ find_by(instance: true, type: type)
+ end
+
# override if needed
def supports_data_fields?
false
@@ -381,30 +392,7 @@ class Service < ApplicationRecord
end
def self.event_description(event)
- case event
- when "push", "push_events"
- "Event will be triggered by a push to the repository"
- when "tag_push", "tag_push_events"
- "Event will be triggered when a new tag is pushed to the repository"
- when "note", "note_events"
- "Event will be triggered when someone adds a comment"
- when "issue", "issue_events"
- "Event will be triggered when an issue is created/updated/closed"
- when "confidential_issue", "confidential_issue_events"
- "Event will be triggered when a confidential issue is created/updated/closed"
- when "merge_request", "merge_request_events"
- "Event will be triggered when a merge request is created/updated/merged"
- when "pipeline", "pipeline_events"
- "Event will be triggered when a pipeline status changes"
- when "wiki_page", "wiki_page_events"
- "Event will be triggered when a wiki page is created/updated"
- when "commit", "commit_events"
- "Event will be triggered when a commit is created/updated"
- when "deployment"
- "Event will be triggered when a deployment finishes"
- when "alert"
- "Event will be triggered when a new, unique alert is recorded"
- end
+ ServicesHelper.service_event_description(event)
end
def valid_recipients?
diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb
new file mode 100644
index 00000000000..bcc17d32272
--- /dev/null
+++ b/app/models/service_desk_setting.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class ServiceDeskSetting < ApplicationRecord
+ include Gitlab::Utils::StrongMemoize
+
+ belongs_to :project
+ validates :project_id, presence: true
+ validate :valid_issue_template
+ validates :outgoing_name, length: { maximum: 255 }, allow_blank: true
+ validates :project_key, length: { maximum: 255 }, allow_blank: true, format: { with: /\A[a-z0-9_]+\z/ }
+
+ def issue_template_content
+ strong_memoize(:issue_template_content) do
+ next unless issue_template_key.present?
+
+ Gitlab::Template::IssueTemplate.find(issue_template_key, project).content
+ rescue ::Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
+ end
+ end
+
+ def issue_template_missing?
+ issue_template_key.present? && !issue_template_content.present?
+ end
+
+ def valid_issue_template
+ if issue_template_missing?
+ errors.add(:issue_template_key, 'is empty or does not exist')
+ end
+ end
+end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index b63ab003711..eb3960ff12b 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -45,6 +45,9 @@ class Snippet < ApplicationRecord
has_many :user_mentions, class_name: "SnippetUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_one :snippet_repository, inverse_of: :snippet
+ # We need to add the `dependent` in order to call the after_destroy callback
+ has_one :statistics, class_name: 'SnippetStatistics', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+
delegate :name, :email, to: :author, prefix: true, allow_nil: true
validates :author, presence: true
@@ -68,6 +71,7 @@ class Snippet < ApplicationRecord
validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values }
after_save :store_mentions!, if: :any_mentionable_attributes_changed?
+ after_create :create_statistics
# Scopes
scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) }
@@ -77,6 +81,7 @@ class Snippet < ApplicationRecord
scope :fresh, -> { order("created_at DESC") }
scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> { includes(author: :status) }
+ scope :with_statistics, -> { joins(:statistics) }
attr_mentionable :description
@@ -331,7 +336,13 @@ class Snippet < ApplicationRecord
def file_name_on_repo
return if repository.empty?
- repository.ls_files(repository.root_ref).first
+ list_files(repository.root_ref).first
+ end
+
+ def list_files(ref = nil)
+ return [] if repository.empty?
+
+ repository.ls_files(ref)
end
class << self
diff --git a/app/models/snippet_input_action.rb b/app/models/snippet_input_action.rb
index 7f4ab775ab0..cc6373264cc 100644
--- a/app/models/snippet_input_action.rb
+++ b/app/models/snippet_input_action.rb
@@ -15,9 +15,10 @@ class SnippetInputAction
validates :action, inclusion: { in: ACTIONS, message: "%{value} is not a valid action" }
validates :previous_path, presence: true, if: :move_action?
- validates :file_path, presence: true
+ validates :file_path, presence: true, unless: :create_action?
validates :content, presence: true, if: -> (action) { action.create_action? || action.update_action? }
validate :ensure_same_file_path_and_previous_path, if: :update_action?
+ validate :ensure_different_file_path_and_previous_path, if: :move_action?
validate :ensure_allowed_action
def initialize(action: nil, previous_path: nil, file_path: nil, content: nil, allowed_actions: nil)
@@ -52,6 +53,12 @@ class SnippetInputAction
errors.add(:file_path, "can't be different from the previous_path attribute")
end
+ def ensure_different_file_path_and_previous_path
+ return if previous_path != file_path
+
+ errors.add(:file_path, 'must be different from the previous_path attribute')
+ end
+
def ensure_allowed_action
return if @allowed_actions.empty?
diff --git a/app/models/snippet_statistics.rb b/app/models/snippet_statistics.rb
new file mode 100644
index 00000000000..7439f98d114
--- /dev/null
+++ b/app/models/snippet_statistics.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+class SnippetStatistics < ApplicationRecord
+ include AfterCommitQueue
+ include UpdateProjectStatistics
+
+ belongs_to :snippet
+
+ validates :snippet, presence: true
+
+ update_project_statistics project_statistics_name: :snippets_size, statistic_attribute: :repository_size
+
+ delegate :repository, :project, :project_id, to: :snippet
+
+ after_save :update_author_root_storage_statistics, if: :update_author_root_storage_statistics?
+ after_destroy :update_author_root_storage_statistics, unless: :project_snippet?
+
+ def update_commit_count
+ self.commit_count = repository.commit_count
+ end
+
+ def update_repository_size
+ self.repository_size = repository.size.megabytes
+ end
+
+ def update_file_count
+ count = if snippet.repository_exists?
+ repository.ls_files(repository.root_ref).size
+ else
+ 0
+ end
+
+ self.file_count = count
+ end
+
+ def refresh!
+ update_commit_count
+ update_repository_size
+ update_file_count
+
+ save!
+ end
+
+ private
+
+ alias_method :original_update_project_statistics_after_save?, :update_project_statistics_after_save?
+ def update_project_statistics_after_save?
+ project_snippet? && original_update_project_statistics_after_save?
+ end
+
+ alias_method :original_update_project_statistics_after_destroy?, :update_project_statistics_after_destroy?
+ def update_project_statistics_after_destroy?
+ project_snippet? && original_update_project_statistics_after_destroy?
+ end
+
+ def update_author_root_storage_statistics?
+ !project_snippet? && saved_change_to_repository_size?
+ end
+
+ def update_author_root_storage_statistics
+ run_after_commit do
+ Namespaces::ScheduleAggregationWorker.perform_async(snippet.author.namespace_id)
+ end
+ end
+
+ def project_snippet?
+ snippet.is_a?(ProjectSnippet)
+ end
+end
diff --git a/app/models/state_note.rb b/app/models/state_note.rb
index cbcb1c2b49d..5e35f15aac4 100644
--- a/app/models/state_note.rb
+++ b/app/models/state_note.rb
@@ -1,19 +1,47 @@
# frozen_string_literal: true
class StateNote < SyntheticNote
+ include Gitlab::Utils::StrongMemoize
+
def self.from_event(event, resource: nil, resource_parent: nil)
- attrs = note_attributes(event.state, event, resource, resource_parent)
+ attrs = note_attributes(action_by(event), event, resource, resource_parent)
StateNote.new(attrs)
end
def note_html
- @note_html ||= "<p dir=\"auto\">#{note_text(html: true)}</p>"
+ @note_html ||= Banzai::Renderer.cacheless_render_field(self, :note, { group: group, project: project })
end
private
def note_text(html: false)
- event.state
+ if event.state == 'closed'
+ if event.close_after_error_tracking_resolve
+ return 'resolved the corresponding error and closed the issue.'
+ end
+
+ if event.close_auto_resolve_prometheus_alert
+ return 'automatically closed this issue because the alert resolved.'
+ end
+ end
+
+ body = event.state.dup
+ body << " via #{event_source.gfm_reference(project)}" if event_source
+ body
+ end
+
+ def event_source
+ strong_memoize(:event_source) do
+ if event.source_commit
+ project&.commit(event.source_commit)
+ else
+ event.source_merge_request
+ end
+ end
+ end
+
+ def self.action_by(event)
+ event.state == 'reopened' ? 'opened' : event.state
end
end
diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb
index 96ffec90c00..94f3a140098 100644
--- a/app/models/suggestion.rb
+++ b/app/models/suggestion.rb
@@ -38,11 +38,18 @@ class Suggestion < ApplicationRecord
end
def appliable?(cached: true)
- !applied? &&
- noteable.opened? &&
- !outdated?(cached: cached) &&
- different_content? &&
- note.active?
+ inapplicable_reason(cached: cached).nil?
+ end
+
+ def inapplicable_reason(cached: true)
+ strong_memoize("inapplicable_reason_#{cached}") do
+ next :applied if applied?
+ next :merge_request_merged if noteable.merged?
+ next :merge_request_closed if noteable.closed?
+ next :source_branch_deleted unless noteable.source_branch_exists?
+ next :outdated if outdated?(cached: cached) || !note.active?
+ next :same_content unless different_content?
+ end
end
# Overwrites outdated column
@@ -53,6 +60,10 @@ class Suggestion < ApplicationRecord
from_content != fetch_from_content
end
+ def single_line?
+ lines_above.zero? && lines_below.zero?
+ end
+
def target_line
position.new_line
end
diff --git a/app/models/synthetic_note.rb b/app/models/synthetic_note.rb
index 3017140f871..dea7165af9f 100644
--- a/app/models/synthetic_note.rb
+++ b/app/models/synthetic_note.rb
@@ -3,20 +3,18 @@
class SyntheticNote < Note
attr_accessor :resource_parent, :event
- self.abstract_class = true
-
def self.note_attributes(action, event, resource, resource_parent)
resource ||= event.resource
attrs = {
- system: true,
- author: event.user,
- created_at: event.created_at,
- discussion_id: event.discussion_id,
- noteable: resource,
- event: event,
- system_note_metadata: ::SystemNoteMetadata.new(action: action),
- resource_parent: resource_parent
+ system: true,
+ author: event.user,
+ created_at: event.created_at,
+ discussion_id: event.discussion_id,
+ noteable: resource,
+ event: event,
+ system_note_metadata: ::SystemNoteMetadata.new(action: action),
+ resource_parent: resource_parent
}
if resource_parent.is_a?(Project)
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 4e14bb4e92c..b6ba96c768e 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -18,7 +18,8 @@ class SystemNoteMetadata < ApplicationRecord
designs_added designs_modified designs_removed designs_discussion_added
title time_tracking branch milestone discussion task moved
opened closed merged duplicate locked unlocked outdated
- tag due_date pinned_embed cherry_pick health_status
+ tag due_date pinned_embed cherry_pick health_status approved unapproved
+ status alert_issue_added
].freeze
validates :note, presence: true
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 102f36a991e..f973c1ff1d4 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -7,15 +7,16 @@ class Todo < ApplicationRecord
# Time to wait for todos being removed when not visible for user anymore.
# Prevents TODOs being removed by mistake, for example, removing access from a user
# and giving it back again.
- WAIT_FOR_DELETE = 1.hour
+ WAIT_FOR_DELETE = 1.hour
- ASSIGNED = 1
- MENTIONED = 2
- BUILD_FAILED = 3
- MARKED = 4
- APPROVAL_REQUIRED = 5 # This is an EE-only feature
- UNMERGEABLE = 6
- DIRECTLY_ADDRESSED = 7
+ ASSIGNED = 1
+ MENTIONED = 2
+ BUILD_FAILED = 3
+ MARKED = 4
+ APPROVAL_REQUIRED = 5 # This is an EE-only feature
+ UNMERGEABLE = 6
+ DIRECTLY_ADDRESSED = 7
+ MERGE_TRAIN_REMOVED = 8 # This is an EE-only feature
ACTION_NAMES = {
ASSIGNED => :assigned,
@@ -24,7 +25,8 @@ class Todo < ApplicationRecord
MARKED => :marked,
APPROVAL_REQUIRED => :approval_required,
UNMERGEABLE => :unmergeable,
- DIRECTLY_ADDRESSED => :directly_addressed
+ DIRECTLY_ADDRESSED => :directly_addressed,
+ MERGE_TRAIN_REMOVED => :merge_train_removed
}.freeze
belongs_to :author, class_name: "User"
@@ -165,6 +167,10 @@ class Todo < ApplicationRecord
action == ASSIGNED
end
+ def merge_train_removed?
+ action == MERGE_TRAIN_REMOVED
+ end
+
def done?
state == 'done'
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 431a5b3a5b7..643b759e6f4 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -69,7 +69,7 @@ class User < ApplicationRecord
MINIMUM_INACTIVE_DAYS = 180
- ignore_column :ghost, remove_with: '13.2', remove_after: '2020-06-22'
+ ignore_column :bio, remove_with: '13.4', remove_after: '2020-09-22'
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
@@ -163,9 +163,10 @@ class User < ApplicationRecord
has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id # rubocop:disable Cop/ActiveRecordDependent
- has_many :issue_assignees
+ has_many :issue_assignees, inverse_of: :assignee
+ has_many :merge_request_assignees, inverse_of: :assignee
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
- has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent
+ has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request
has_many :custom_attributes, class_name: 'UserCustomAttribute'
has_many :callouts, class_name: 'UserCallout'
@@ -194,7 +195,6 @@ class User < ApplicationRecord
validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email }
validates :public_email, presence: true, uniqueness: true, devise_email: true, allow_blank: true
validates :commit_email, devise_email: true, allow_nil: true, if: ->(user) { user.commit_email != user.email }
- validates :bio, length: { maximum: 255 }, allow_blank: true
validates :projects_limit,
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE }
@@ -229,7 +229,6 @@ class User < ApplicationRecord
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
- before_save :ensure_bio_is_assigned_to_user_details, if: :bio_changed?
after_validation :set_username_errors
after_update :username_changed_hook, if: :saved_change_to_username?
after_destroy :post_destroy_hook
@@ -272,6 +271,7 @@ class User < ApplicationRecord
:time_display_relative, :time_display_relative=,
:time_format_in_24h, :time_format_in_24h=,
:show_whitespace_in_diffs, :show_whitespace_in_diffs=,
+ :view_diffs_file_by_file, :view_diffs_file_by_file=,
:tab_width, :tab_width=,
:sourcegraph_enabled, :sourcegraph_enabled=,
:setup_for_company, :setup_for_company=,
@@ -281,6 +281,7 @@ class User < ApplicationRecord
delegate :path, to: :namespace, allow_nil: true, prefix: true
delegate :job_title, :job_title=, to: :user_detail, allow_nil: true
+ delegate :bio, :bio=, :bio_html, to: :user_detail, allow_nil: true
accepts_nested_attributes_for :user_preference, update_only: true
accepts_nested_attributes_for :user_detail, update_only: true
@@ -619,11 +620,12 @@ class User < ApplicationRecord
# Pattern used to extract `@user` user references from text
def reference_pattern
- %r{
- (?<!\w)
- #{Regexp.escape(reference_prefix)}
- (?<user>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})
- }x
+ @reference_pattern ||=
+ %r{
+ (?<!\w)
+ #{Regexp.escape(reference_prefix)}
+ (?<user>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})
+ }x
end
# Return (create if necessary) the ghost user. The ghost user
@@ -642,6 +644,7 @@ class User < ApplicationRecord
unique_internal(where(user_type: :alert_bot), 'alert-bot', email_pattern) do |u|
u.bio = 'The GitLab alert bot'
u.name = 'GitLab Alert Bot'
+ u.avatar = bot_avatar(image: 'alert-bot.png')
end
end
@@ -655,6 +658,16 @@ class User < ApplicationRecord
end
end
+ def support_bot
+ email_pattern = "support%s@#{Settings.gitlab.host}"
+
+ unique_internal(where(user_type: :support_bot), 'support-bot', email_pattern) do |u|
+ u.bio = 'The GitLab support bot used for Service Desk'
+ u.name = 'GitLab Support Bot'
+ u.avatar = bot_avatar(image: 'support-bot.png')
+ end
+ end
+
# Return true if there is only single non-internal user in the deployment,
# ghost user is ignored.
def single_user?
@@ -1257,17 +1270,11 @@ class User < ApplicationRecord
namespace.path = username if username_changed?
namespace.name = name if name_changed?
else
- build_namespace(path: username, name: name)
+ namespace = build_namespace(path: username, name: name)
+ namespace.build_namespace_settings
end
end
- # Temporary, will be removed when bio is fully migrated
- def ensure_bio_is_assigned_to_user_details
- return if Feature.disabled?(:migrate_bio_to_user_details, default_enabled: true)
-
- user_detail.bio = bio.to_s[0...255] # bio can be NULL in users, but cannot be NULL in user_details
- end
-
def set_username_errors
namespace_path_errors = self.errors.delete(:"namespace.path")
self.errors[:username].concat(namespace_path_errors) if namespace_path_errors
@@ -1692,6 +1699,10 @@ class User < ApplicationRecord
impersonator.present?
end
+ def created_recently?
+ created_at > Devise.confirm_within.ago
+ end
+
protected
# override, from Devise::Validatable
diff --git a/app/models/user_callout_enums.rb b/app/models/user_callout_enums.rb
index 0a3f597ae27..226c8cd9ab5 100644
--- a/app/models/user_callout_enums.rb
+++ b/app/models/user_callout_enums.rb
@@ -17,7 +17,8 @@ module UserCalloutEnums
suggest_popover_dismissed: 9,
tabs_position_highlight: 10,
webhooks_moved: 13,
- admin_integrations_moved: 15
+ admin_integrations_moved: 15,
+ personal_access_token_expiry: 21 # EE-only
}
end
end
diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb
index 5dc74421705..9674f9a41da 100644
--- a/app/models/user_detail.rb
+++ b/app/models/user_detail.rb
@@ -1,7 +1,33 @@
# frozen_string_literal: true
class UserDetail < ApplicationRecord
+ extend ::Gitlab::Utils::Override
+ include CacheMarkdownField
+
belongs_to :user
validates :job_title, length: { maximum: 200 }
+ validates :bio, length: { maximum: 255 }, allow_blank: true
+
+ before_save :prevent_nil_bio
+
+ cache_markdown_field :bio
+
+ def bio_html
+ read_attribute(:bio_html) || bio
+ end
+
+ # For backward compatibility.
+ # Older migrations (and their tests) reference the `User.migration_bot` where the `bio` attribute is set.
+ # Here we disable writing the markdown cache when the `bio_html` column does not exists.
+ override :invalidated_markdown_cache?
+ def invalidated_markdown_cache?
+ self.class.column_names.include?('bio_html') && super
+ end
+
+ private
+
+ def prevent_nil_bio
+ self.bio = '' if bio_changed? && bio.nil?
+ end
end
diff --git a/app/models/webauthn_registration.rb b/app/models/webauthn_registration.rb
new file mode 100644
index 00000000000..76f8faa11c7
--- /dev/null
+++ b/app/models/webauthn_registration.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# Registration information for WebAuthn credentials
+
+class WebauthnRegistration < ApplicationRecord
+ belongs_to :user
+
+ validates :credential_xid, :public_key, :name, :counter, presence: true
+ validates :counter,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
+end
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 9e4e2f68d38..3dc90edb331 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -301,6 +301,10 @@ class WikiPage
version&.commit&.committed_date
end
+ def diffs(diff_options = {})
+ Gitlab::Diff::FileCollection::WikiPage.new(self, diff_options: diff_options)
+ end
+
private
def serialize_front_matter(hash)
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 2c26ba565ab..13d732e4edd 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -21,6 +21,10 @@ class BasePolicy < DeclarativePolicy::Base
with_options scope: :user, score: 0
condition(:deactivated) { @user&.deactivated? }
+ desc "User is support bot"
+ with_options scope: :user, score: 0
+ condition(:support_bot) { @user&.support_bot? }
+
desc "User email is unconfirmed or user account is locked"
with_options scope: :user, score: 0
condition(:inactive) do
@@ -54,6 +58,8 @@ class BasePolicy < DeclarativePolicy::Base
rule { admin }.enable :read_all_resources
rule { default }.enable :read_cross_project
+
+ condition(:is_gitlab_com) { ::Gitlab.dev_env_or_com? }
end
BasePolicy.prepend_if_ee('EE::BasePolicy')
diff --git a/app/policies/concerns/find_group_projects.rb b/app/policies/concerns/find_group_projects.rb
index e2cb90079c7..aad9081bd7d 100644
--- a/app/policies/concerns/find_group_projects.rb
+++ b/app/policies/concerns/find_group_projects.rb
@@ -3,11 +3,11 @@
module FindGroupProjects
extend ActiveSupport::Concern
- def group_projects_for(user:, group:)
+ def group_projects_for(user:, group:, only_owned: true)
GroupProjectsFinder.new(
group: group,
current_user: user,
- options: { include_subgroups: true, only_owned: true }
+ options: { include_subgroups: true, only_owned: only_owned }
).execute
end
end
diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb
index f910e04d015..3073a2e5d10 100644
--- a/app/policies/concerns/policy_actor.rb
+++ b/app/policies/concerns/policy_actor.rb
@@ -45,6 +45,10 @@ module PolicyActor
false
end
+ def support_bot?
+ false
+ end
+
def deactivated?
false
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 03f5a863421..c66f0d199b0 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -105,6 +105,9 @@ class GlobalPolicy < BasePolicy
enable :update_custom_attribute
end
+ # We can't use `read_statistics` because the user may have different permissions for different projects
+ rule { admin }.enable :use_project_statistics_filters
+
rule { external_user }.prevent :create_snippet
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index b1b52d62b85..62f66093875 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -42,6 +42,14 @@ class GroupPolicy < BasePolicy
@subject.subgroup_creation_level == ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS
end
+ condition(:design_management_enabled) do
+ group_projects_for(user: @user, group: @subject, only_owned: false).any? { |p| p.design_management_enabled? }
+ end
+
+ rule { design_management_enabled }.policy do
+ enable :read_design_activity
+ end
+
rule { public_group }.policy do
enable :read_group
enable :read_package
@@ -59,6 +67,10 @@ class GroupPolicy < BasePolicy
enable :update_max_artifacts_size
end
+ rule { can?(:read_all_resources) }.policy do
+ enable :read_confidential_issues
+ end
+
rule { has_projects }.policy do
enable :read_group
end
@@ -70,6 +82,10 @@ class GroupPolicy < BasePolicy
enable :read_board
end
+ rule { ~can?(:read_group) }.policy do
+ prevent :read_design_activity
+ end
+
rule { has_access }.enable :read_namespace
rule { developer }.policy do
@@ -87,6 +103,7 @@ class GroupPolicy < BasePolicy
enable :admin_list
enable :admin_issue
enable :read_metrics_dashboard_annotation
+ enable :read_prometheus
end
rule { maintainer }.policy do
diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb
index e2aca2a37d5..e5ac228b0ee 100644
--- a/app/policies/merge_request_policy.rb
+++ b/app/policies/merge_request_policy.rb
@@ -10,6 +10,10 @@ class MergeRequestPolicy < IssuablePolicy
# it would not be safe to prevent :create_note there, since
# note permissions are shared, and this would apply too broadly.
rule { ~can?(:read_merge_request) }.prevent :create_note
+
+ rule { can?(:update_merge_request) }.policy do
+ enable :approve_merge_request
+ end
end
MergeRequestPolicy.prepend_if_ee('EE::MergeRequestPolicy')
diff --git a/app/policies/packages/package_policy.rb b/app/policies/packages/package_policy.rb
new file mode 100644
index 00000000000..8eef280c640
--- /dev/null
+++ b/app/policies/packages/package_policy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module Packages
+ class PackagePolicy < BasePolicy
+ delegate { @subject.project }
+ end
+end
diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb
index f2f18406bd3..ca33b95e523 100644
--- a/app/policies/project_member_policy.rb
+++ b/app/policies/project_member_policy.rb
@@ -5,14 +5,17 @@ class ProjectMemberPolicy < BasePolicy
condition(:target_is_owner, scope: :subject) { @subject.user == @subject.project.owner }
condition(:target_is_self) { @user && @subject.user == @user }
+ condition(:project_bot) { @subject.user&.project_bot? }
rule { anonymous }.prevent_all
rule { target_is_owner }.prevent_all
- rule { can?(:admin_project_member) }.policy do
+ rule { ~project_bot & can?(:admin_project_member) }.policy do
enable :update_project_member
enable :destroy_project_member
end
+ rule { project_bot & can?(:admin_project_member) }.enable :destroy_project_bot_member
+
rule { target_is_self }.enable :destroy_project_member
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index f87c72007ec..39b39bd2fce 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -123,6 +123,9 @@ class ProjectPolicy < BasePolicy
!@subject.design_management_enabled?
end
+ with_scope :subject
+ condition(:service_desk_enabled) { @subject.service_desk_enabled? }
+
# 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
@@ -151,6 +154,9 @@ class ProjectPolicy < BasePolicy
::Feature.enabled?(:build_service_proxy, @subject)
end
+ with_scope :subject
+ condition(:packages_disabled) { !@subject.packages_enabled }
+
features = %w[
merge_requests
issues
@@ -173,6 +179,7 @@ class ProjectPolicy < BasePolicy
rule { guest | admin }.enable :read_project_for_iids
rule { admin }.enable :update_max_artifacts_size
+ rule { can?(:read_all_resources) }.enable :read_confidential_issues
rule { guest }.enable :guest_access
rule { reporter }.enable :reporter_access
@@ -254,6 +261,8 @@ class ProjectPolicy < BasePolicy
enable :read_prometheus
enable :read_metrics_dashboard_annotation
enable :metrics_dashboard
+ enable :read_confidential_issues
+ enable :read_package
end
# We define `:public_user_access` separately because there are cases in gitlab-ee
@@ -290,12 +299,17 @@ class ProjectPolicy < BasePolicy
enable :read_metrics_user_starred_dashboard
end
+ rule { packages_disabled | repository_disabled }.policy do
+ prevent(*create_read_update_admin_destroy(:package))
+ end
+
rule { owner | admin | guest | group_member }.prevent :request_access
rule { ~request_access_enabled }.prevent :request_access
rule { can?(:developer_access) & can?(:create_issue) }.enable :import_issues
rule { can?(:developer_access) }.policy do
+ enable :create_package
enable :admin_board
enable :admin_merge_request
enable :admin_milestone
@@ -327,6 +341,7 @@ class ProjectPolicy < BasePolicy
enable :update_alert_management_alert
enable :create_design
enable :destroy_design
+ enable :read_terraform_state
end
rule { can?(:developer_access) & user_confirmed? }.policy do
@@ -336,6 +351,7 @@ class ProjectPolicy < BasePolicy
end
rule { can?(:maintainer_access) }.policy do
+ enable :destroy_package
enable :admin_board
enable :push_to_delete_protected_branch
enable :update_snippet
@@ -470,6 +486,7 @@ class ProjectPolicy < BasePolicy
end
rule { can?(:public_access) }.policy do
+ enable :read_package
enable :read_project
enable :read_board
enable :read_list
@@ -545,11 +562,13 @@ class ProjectPolicy < BasePolicy
rule { can?(:read_issue) }.policy do
enable :read_design
+ enable :read_design_activity
end
# Design abilities could also be prevented in the issue policy.
rule { design_management_disabled }.policy do
prevent :read_design
+ prevent :read_design_activity
prevent :create_design
prevent :destroy_design
end
@@ -576,6 +595,12 @@ class ProjectPolicy < BasePolicy
enable :read_build_report_results
end
+ rule { support_bot }.enable :guest_access
+ rule { support_bot & ~service_desk_enabled }.policy do
+ prevent :create_note
+ prevent :read_project
+ end
+
private
def team_member?
@@ -624,6 +649,7 @@ class ProjectPolicy < BasePolicy
def lookup_access_level!
return ::Gitlab::Access::REPORTER if alert_bot?
+ return ::Gitlab::Access::REPORTER if support_bot? && service_desk_enabled?
# NOTE: max_member_access has its own cache
project.team.max_member_access(@user.id)
@@ -636,7 +662,7 @@ class ProjectPolicy < BasePolicy
when ProjectFeature::DISABLED
false
when ProjectFeature::PRIVATE
- admin? || team_access_level >= ProjectFeature.required_minimum_access_level(feature)
+ can?(:read_all_resources) || team_access_level >= ProjectFeature.required_minimum_access_level(feature)
else
true
end
diff --git a/app/policies/releases/source_policy.rb b/app/policies/releases/source_policy.rb
index 8b86b925589..3b11c661237 100644
--- a/app/policies/releases/source_policy.rb
+++ b/app/policies/releases/source_policy.rb
@@ -3,11 +3,5 @@
module Releases
class SourcePolicy < BasePolicy
delegate { @subject.project }
-
- rule { can?(:public_access) | can?(:reporter_access) }.policy do
- enable :read_release_sources
- end
-
- rule { ~can?(:read_release) }.prevent :read_release_sources
end
end
diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb
new file mode 100644
index 00000000000..a515c70152d
--- /dev/null
+++ b/app/presenters/alert_management/alert_presenter.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class AlertPresenter < Gitlab::View::Presenter::Delegated
+ include Gitlab::Utils::StrongMemoize
+ include IncidentManagement::Settings
+
+ MARKDOWN_LINE_BREAK = " \n".freeze
+
+ def initialize(alert, _attributes = {})
+ super
+
+ @alert = alert
+ @project = alert.project
+ end
+
+ def issue_description
+ horizontal_line = "\n\n---\n\n"
+
+ [
+ issue_summary_markdown,
+ alert_markdown,
+ incident_management_setting.issue_template_content
+ ].compact.join(horizontal_line)
+ end
+
+ def start_time
+ started_at&.strftime('%d %B %Y, %-l:%M%p (%Z)')
+ end
+
+ def issue_summary_markdown
+ <<~MARKDOWN.chomp
+ #### Summary
+
+ #{metadata_list}
+ #{alert_details}#{metric_embed_for_alert}
+ MARKDOWN
+ end
+
+ def metrics_dashboard_url; end
+
+ private
+
+ attr_reader :alert, :project
+
+ def alerting_alert
+ strong_memoize(:alerting_alert) do
+ Gitlab::Alerting::Alert.new(project: project, payload: alert.payload).present
+ end
+ end
+
+ def alert_markdown; end
+
+ def metadata_list
+ metadata = []
+
+ metadata << list_item('Start time', start_time)
+ metadata << list_item('Severity', severity)
+ metadata << list_item('full_query', backtick(full_query)) if full_query
+ metadata << list_item('Service', service) if service
+ metadata << list_item('Monitoring tool', monitoring_tool) if monitoring_tool
+ metadata << list_item('Hosts', host_links) if hosts.any?
+ metadata << list_item('Description', description) if description.present?
+
+ metadata.join(MARKDOWN_LINE_BREAK)
+ end
+
+ def alert_details
+ if details.present?
+ <<~MARKDOWN.chomp
+
+ #### Alert Details
+
+ #{details_list}
+ MARKDOWN
+ end
+ end
+
+ def details_list
+ alert.details
+ .map { |label, value| list_item(label, value) }
+ .join(MARKDOWN_LINE_BREAK)
+ end
+
+ def metric_embed_for_alert; end
+
+ def full_query; end
+
+ def list_item(key, value)
+ "**#{key}:** #{value}".strip
+ end
+
+ def backtick(value)
+ "`#{value}`"
+ end
+
+ def host_links
+ hosts.join(' ')
+ end
+ end
+end
diff --git a/app/presenters/alert_management/prometheus_alert_presenter.rb b/app/presenters/alert_management/prometheus_alert_presenter.rb
new file mode 100644
index 00000000000..3bcc98e6784
--- /dev/null
+++ b/app/presenters/alert_management/prometheus_alert_presenter.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class PrometheusAlertPresenter < AlertManagement::AlertPresenter
+ def metrics_dashboard_url
+ alerting_alert.metrics_dashboard_url
+ end
+
+ private
+
+ def alert_markdown
+ alerting_alert.alert_markdown
+ end
+
+ def details_list
+ alerting_alert.annotation_list
+ end
+
+ def metric_embed_for_alert
+ alerting_alert.metric_embed_for_alert
+ end
+
+ def full_query
+ alerting_alert.full_query
+ end
+ end
+end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 395eaeea8de..da610f13899 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -110,6 +110,17 @@ module Ci
merge_request_presenter&.target_branch_link
end
+ def downloadable_path_for_report_type(file_type)
+ if (job_artifact = batch_lookup_report_artifact_for_file_type(file_type)) &&
+ can?(current_user, :read_build, job_artifact.job)
+ download_project_job_artifacts_path(
+ job_artifact.project,
+ job_artifact.job,
+ file_type: file_type,
+ proxy: true)
+ end
+ end
+
private
def plain_ref_name
diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb
index 5e669ff2e50..efb3cf7f348 100644
--- a/app/presenters/clusterable_presenter.rb
+++ b/app/presenters/clusterable_presenter.rb
@@ -13,8 +13,7 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
end
def can_add_cluster?
- can?(current_user, :add_cluster, clusterable) &&
- (has_no_clusters? || multiple_clusters_available?)
+ can?(current_user, :add_cluster, clusterable)
end
def can_create_cluster?
@@ -65,7 +64,11 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
raise NotImplementedError
end
- # Will be overidden in EE
+ def metrics_dashboard_path(cluster)
+ raise NotImplementedError
+ end
+
+ # Will be overridden in EE
def environments_cluster_path(cluster)
nil
end
@@ -81,17 +84,6 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
def learn_more_link
raise NotImplementedError
end
-
- private
-
- # Overridden on EE module
- def multiple_clusters_available?
- false
- end
-
- def has_no_clusters?
- clusterable.clusters.empty?
- end
end
ClusterablePresenter.prepend_if_ee('EE::ClusterablePresenter')
diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb
index c4e3393cac9..c0da5310ca4 100644
--- a/app/presenters/clusters/cluster_presenter.rb
+++ b/app/presenters/clusters/cluster_presenter.rb
@@ -2,6 +2,7 @@
module Clusters
class ClusterPresenter < Gitlab::View::Presenter::Delegated
+ include ::Gitlab::Utils::StrongMemoize
include ActionView::Helpers::SanitizeHelper
include ActionView::Helpers::UrlHelper
include IconsHelper
@@ -60,12 +61,53 @@ module Clusters
end
end
+ def gitlab_managed_apps_logs_path
+ return unless logs_project && can_read_cluster?
+
+ if cluster.application_elastic_stack&.available?
+ elasticsearch_project_logs_path(logs_project, cluster_id: cluster.id, format: :json)
+ else
+ k8s_project_logs_path(logs_project, cluster_id: cluster.id, format: :json)
+ end
+ end
+
def read_only_kubernetes_platform_fields?
!cluster.provided_by_user?
end
+ def health_data(clusterable)
+ {
+ 'clusters-path': clusterable.index_path,
+ 'dashboard-endpoint': clusterable.metrics_dashboard_path(cluster),
+ 'documentation-path': help_page_path('user/project/clusters/index', anchor: 'monitoring-your-kubernetes-cluster-ultimate'),
+ 'add-dashboard-documentation-path': help_page_path('user/project/integrations/prometheus.md', anchor: 'adding-a-new-dashboard-to-your-project'),
+ 'empty-getting-started-svg-path': image_path('illustrations/monitoring/getting_started.svg'),
+ 'empty-loading-svg-path': image_path('illustrations/monitoring/loading.svg'),
+ 'empty-no-data-svg-path': image_path('illustrations/monitoring/no_data.svg'),
+ 'empty-no-data-small-svg-path': image_path('illustrations/chart-empty-state-small.svg'),
+ 'empty-unable-to-connect-svg-path': image_path('illustrations/monitoring/unable_to_connect.svg'),
+ 'settings-path': '',
+ 'project-path': '',
+ 'tags-path': ''
+ }
+ end
+
private
+ def image_path(path)
+ ActionController::Base.helpers.image_path(path)
+ end
+
+ # currently log explorer is only available in the scope of the project
+ # for group and instance level cluster selected project does not affects
+ # fetching logs from gitlab managed apps namespace, therefore any project
+ # available to user will be sufficient.
+ def logs_project
+ strong_memoize(:logs_project) do
+ cluster.all_projects.first
+ end
+ end
+
def clusterable
if cluster.group_type?
cluster.group
diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb
index 21db2f6f96b..dfe8e315f94 100644
--- a/app/presenters/group_clusterable_presenter.rb
+++ b/app/presenters/group_clusterable_presenter.rb
@@ -43,6 +43,10 @@ class GroupClusterablePresenter < ClusterablePresenter
def learn_more_link
link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/group/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
end
+
+ def metrics_dashboard_path(cluster)
+ metrics_dashboard_group_cluster_path(clusterable, cluster)
+ end
end
GroupClusterablePresenter.prepend_if_ee('EE::GroupClusterablePresenter')
diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb
index 41071bc7bc7..7704e6b59c1 100644
--- a/app/presenters/instance_clusterable_presenter.rb
+++ b/app/presenters/instance_clusterable_presenter.rb
@@ -81,6 +81,10 @@ class InstanceClusterablePresenter < ClusterablePresenter
def learn_more_link
link_to(s_('ClusterIntegration|Learn more about instance Kubernetes clusters'), help_page_path('user/instance/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
end
+
+ def metrics_dashboard_path(cluster)
+ metrics_dashboard_admin_cluster_path(cluster)
+ end
end
InstanceClusterablePresenter.prepend_if_ee('EE::InstanceClusterablePresenter')
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index af98a6ee36a..bccf0340749 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -8,6 +8,8 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
include ChecksCollaboration
include Gitlab::Utils::StrongMemoize
+ APPROVALS_WIDGET_BASE_TYPE = 'base'
+
presents :merge_request
def ci_status
@@ -224,6 +226,22 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
end
+ def api_approvals_path
+ expose_path(api_v4_projects_merge_requests_approvals_path(id: project.id, merge_request_iid: merge_request.iid))
+ end
+
+ def api_approve_path
+ expose_path(api_v4_projects_merge_requests_approve_path(id: project.id, merge_request_iid: merge_request.iid))
+ end
+
+ def api_unapprove_path
+ expose_path(api_v4_projects_merge_requests_unapprove_path(id: project.id, merge_request_iid: merge_request.iid))
+ end
+
+ def approvals_widget_type
+ APPROVALS_WIDGET_BASE_TYPE
+ end
+
private
def cached_can_be_reverted?
diff --git a/app/presenters/packages/composer/packages_presenter.rb b/app/presenters/packages/composer/packages_presenter.rb
new file mode 100644
index 00000000000..84f266989e9
--- /dev/null
+++ b/app/presenters/packages/composer/packages_presenter.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module Packages
+ module Composer
+ class PackagesPresenter
+ include API::Helpers::RelatedResourcesHelpers
+
+ def initialize(group, packages)
+ @group = group
+ @packages = packages
+ end
+
+ def root
+ path = api_v4_group___packages_composer_package_name_path({ id: @group.id, package_name: '%package%', format: '.json' }, true)
+ { 'packages' => [], 'provider-includes' => { 'p/%hash%.json' => { 'sha256' => provider_sha } }, 'providers-url' => path }
+ end
+
+ def provider
+ { 'providers' => providers_map }
+ end
+
+ def package_versions(packages = @packages)
+ { 'packages' => { packages.first.name => package_versions_map(packages) } }
+ end
+
+ private
+
+ def package_versions_map(packages)
+ packages.each_with_object({}) do |package, map|
+ map[package.version] = package_metadata(package)
+ end
+ end
+
+ def package_metadata(package)
+ json = package.composer_metadatum.composer_json
+
+ json.merge('dist' => package_dist(package), 'uid' => package.id, 'version' => package.version)
+ end
+
+ def package_dist(package)
+ sha = package.composer_metadatum.target_sha
+ archive_api_path = api_v4_projects_packages_composer_archives_package_name_path({ id: package.project_id, package_name: package.name, format: '.zip' }, true)
+
+ {
+ 'type' => 'zip',
+ 'url' => expose_url(archive_api_path) + "?sha=#{sha}",
+ 'reference' => sha,
+ 'shasum' => ''
+ }
+ end
+
+ def providers_map
+ map = {}
+
+ @packages.group_by(&:name).each_pair do |package_name, packages|
+ map[package_name] = { 'sha256' => package_versions_sha(packages) }
+ end
+
+ map
+ end
+
+ def package_versions_sha(packages)
+ Digest::SHA256.hexdigest(package_versions(packages).to_json)
+ end
+
+ def provider_sha
+ Digest::SHA256.hexdigest(provider.to_json)
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/conan/package_presenter.rb b/app/presenters/packages/conan/package_presenter.rb
new file mode 100644
index 00000000000..5141c450412
--- /dev/null
+++ b/app/presenters/packages/conan/package_presenter.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+module Packages
+ module Conan
+ class PackagePresenter
+ include API::Helpers::RelatedResourcesHelpers
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :params
+
+ def initialize(recipe, user, project, params = {})
+ @recipe = recipe
+ @user = user
+ @project = project
+ @params = params
+ end
+
+ def recipe_urls
+ map_package_files do |package_file|
+ build_recipe_file_url(package_file) if package_file.conan_file_metadatum.recipe_file?
+ end
+ end
+
+ def recipe_snapshot
+ map_package_files do |package_file|
+ package_file.file_md5 if package_file.conan_file_metadatum.recipe_file?
+ end
+ end
+
+ def package_urls
+ map_package_files do |package_file|
+ next unless package_file.conan_file_metadatum.package_file? && matching_reference?(package_file)
+
+ build_package_file_url(package_file)
+ end
+ end
+
+ def package_snapshot
+ map_package_files do |package_file|
+ next unless package_file.conan_file_metadatum.package_file? && matching_reference?(package_file)
+
+ package_file.file_md5
+ end
+ end
+
+ private
+
+ def build_recipe_file_url(package_file)
+ expose_url(
+ api_v4_packages_conan_v1_files_export_path(
+ package_name: package.name,
+ package_version: package.version,
+ package_username: package.conan_metadatum.package_username,
+ package_channel: package.conan_metadatum.package_channel,
+ recipe_revision: package_file.conan_file_metadatum.recipe_revision,
+ file_name: package_file.file_name
+ )
+ )
+ end
+
+ def build_package_file_url(package_file)
+ expose_url(
+ api_v4_packages_conan_v1_files_package_path(
+ package_name: package.name,
+ package_version: package.version,
+ package_username: package.conan_metadatum.package_username,
+ package_channel: package.conan_metadatum.package_channel,
+ recipe_revision: package_file.conan_file_metadatum.recipe_revision,
+ conan_package_reference: package_file.conan_file_metadatum.conan_package_reference,
+ package_revision: package_file.conan_file_metadatum.package_revision,
+ file_name: package_file.file_name
+ )
+ )
+ end
+
+ def map_package_files
+ package_files.to_a.map do |package_file|
+ key = package_file.file_name
+ value = yield(package_file)
+ next unless key && value
+
+ [key, value]
+ end.compact.to_h
+ end
+
+ def package_files
+ return unless package
+
+ @package_files ||= package.package_files.preload_conan_file_metadata
+ end
+
+ def package
+ strong_memoize(:package) do
+ name, version = @recipe.split('@')[0].split('/')
+
+ @project.packages
+ .conan
+ .with_name(name)
+ .with_version(version)
+ .order_created
+ .last
+ end
+ end
+
+ def matching_reference?(package_file)
+ package_file.conan_file_metadatum.conan_package_reference == conan_package_reference
+ end
+
+ def conan_package_reference
+ params[:conan_package_reference]
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb
new file mode 100644
index 00000000000..f6e068302c1
--- /dev/null
+++ b/app/presenters/packages/detail/package_presenter.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Packages
+ module Detail
+ class PackagePresenter
+ def initialize(package)
+ @package = package
+ end
+
+ def detail_view
+ package_detail = {
+ id: @package.id,
+ created_at: @package.created_at,
+ name: @package.name,
+ package_files: @package.package_files.map { |pf| build_package_file_view(pf) },
+ package_type: @package.package_type,
+ project_id: @package.project_id,
+ tags: @package.tags.as_json,
+ updated_at: @package.updated_at,
+ version: @package.version
+ }
+
+ package_detail[:maven_metadatum] = @package.maven_metadatum if @package.maven_metadatum
+ package_detail[:nuget_metadatum] = @package.nuget_metadatum if @package.nuget_metadatum
+ package_detail[:dependency_links] = @package.dependency_links.map(&method(:build_dependency_links))
+ package_detail[:pipeline] = build_pipeline_info(@package.build_info.pipeline) if @package.build_info
+
+ package_detail
+ end
+
+ private
+
+ def build_package_file_view(package_file)
+ {
+ created_at: package_file.created_at,
+ download_path: package_file.download_path,
+ file_name: package_file.file_name,
+ size: package_file.size
+ }
+ end
+
+ def build_pipeline_info(pipeline_info)
+ {
+ created_at: pipeline_info.created_at,
+ id: pipeline_info.id,
+ sha: pipeline_info.sha,
+ ref: pipeline_info.ref,
+ git_commit_message: pipeline_info.git_commit_message,
+ user: build_user_info(pipeline_info.user),
+ project: {
+ name: pipeline_info.project.name,
+ web_url: pipeline_info.project.web_url
+ }
+ }
+ end
+
+ def build_user_info(user)
+ return unless user
+
+ {
+ avatar_url: user.avatar_url,
+ name: user.name
+ }
+ end
+
+ def build_dependency_links(link)
+ {
+ name: link.dependency.name,
+ version_pattern: link.dependency.version_pattern,
+ target_framework: link.nuget_metadatum&.target_framework
+ }.compact
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/go/module_version_presenter.rb b/app/presenters/packages/go/module_version_presenter.rb
new file mode 100644
index 00000000000..4c86eae46cd
--- /dev/null
+++ b/app/presenters/packages/go/module_version_presenter.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Packages
+ module Go
+ class ModuleVersionPresenter
+ def initialize(version)
+ @version = version
+ end
+
+ def name
+ @version.name
+ end
+
+ def time
+ @version.commit.committed_date
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb
new file mode 100644
index 00000000000..a3ab10d3913
--- /dev/null
+++ b/app/presenters/packages/npm/package_presenter.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module Packages
+ module Npm
+ class PackagePresenter
+ include API::Helpers::RelatedResourcesHelpers
+
+ attr_reader :name, :packages
+
+ NPM_VALID_DEPENDENCY_TYPES = %i[dependencies devDependencies bundleDependencies peerDependencies].freeze
+
+ def initialize(name, packages)
+ @name = name
+ @packages = packages
+ end
+
+ def versions
+ package_versions = {}
+
+ packages.each do |package|
+ package_file = package.package_files.last
+
+ next unless package_file
+
+ package_versions[package.version] = build_package_version(package, package_file)
+ end
+
+ package_versions
+ end
+
+ def dist_tags
+ build_package_tags.tap { |t| t["latest"] ||= sorted_versions.last }
+ end
+
+ private
+
+ def build_package_tags
+ Hash[
+ package_tags.map { |tag| [tag.name, tag.package.version] }
+ ]
+ end
+
+ def build_package_version(package, package_file)
+ {
+ name: package.name,
+ version: package.version,
+ dist: {
+ shasum: package_file.file_sha1,
+ tarball: tarball_url(package, package_file)
+ }
+ }.tap do |package_version|
+ package_version.merge!(build_package_dependencies(package))
+ end
+ end
+
+ def tarball_url(package, package_file)
+ expose_url "#{api_v4_projects_path(id: package.project_id)}" \
+ "/packages/npm/#{package.name}" \
+ "/-/#{package_file.file_name}"
+ end
+
+ def build_package_dependencies(package)
+ dependencies = Hash.new { |h, key| h[key] = {} }
+ dependency_links = package.dependency_links
+ .with_dependency_type(NPM_VALID_DEPENDENCY_TYPES)
+ .includes_dependency
+
+ dependency_links.find_each do |dependency_link|
+ dependency = dependency_link.dependency
+ dependencies[dependency_link.dependency_type][dependency.name] = dependency.version_pattern
+ end
+
+ dependencies
+ end
+
+ def sorted_versions
+ versions = packages.map(&:version).compact
+ VersionSorter.sort(versions)
+ end
+
+ def package_tags
+ Packages::Tag.for_packages(packages)
+ .preload_package
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/nuget/package_metadata_presenter.rb b/app/presenters/packages/nuget/package_metadata_presenter.rb
new file mode 100644
index 00000000000..500fc982e11
--- /dev/null
+++ b/app/presenters/packages/nuget/package_metadata_presenter.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class PackageMetadataPresenter
+ include Packages::Nuget::PresenterHelpers
+
+ def initialize(package)
+ @package = package
+ end
+
+ def json_url
+ json_url_for(@package)
+ end
+
+ def archive_url
+ archive_url_for(@package)
+ end
+
+ def catalog_entry
+ catalog_entry_for(@package)
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/nuget/packages_metadata_presenter.rb b/app/presenters/packages/nuget/packages_metadata_presenter.rb
new file mode 100644
index 00000000000..5f22d5dd8a1
--- /dev/null
+++ b/app/presenters/packages/nuget/packages_metadata_presenter.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class PackagesMetadataPresenter
+ include Packages::Nuget::PresenterHelpers
+ include Gitlab::Utils::StrongMemoize
+
+ COUNT = 1.freeze
+
+ def initialize(packages)
+ @packages = packages
+ end
+
+ def count
+ COUNT
+ end
+
+ def items
+ [summary]
+ end
+
+ private
+
+ def summary
+ {
+ json_url: json_url,
+ lower_version: lower_version,
+ upper_version: upper_version,
+ packages_count: @packages.count,
+ packages: @packages.map { |pkg| metadata_for(pkg) }
+ }
+ end
+
+ def metadata_for(package)
+ {
+ json_url: json_url_for(package),
+ archive_url: archive_url_for(package),
+ catalog_entry: catalog_entry_for(package)
+ }
+ end
+
+ def json_url
+ json_url_for(@packages.first)
+ end
+
+ def lower_version
+ sorted_versions.first
+ end
+
+ def upper_version
+ sorted_versions.last
+ end
+
+ def sorted_versions
+ strong_memoize(:sorted_versions) do
+ versions = @packages.map(&:version).compact
+ VersionSorter.sort(versions)
+ end
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/nuget/packages_versions_presenter.rb b/app/presenters/packages/nuget/packages_versions_presenter.rb
new file mode 100644
index 00000000000..7f4ce4dbb2f
--- /dev/null
+++ b/app/presenters/packages/nuget/packages_versions_presenter.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class PackagesVersionsPresenter
+ def initialize(packages)
+ @packages = packages
+ end
+
+ def versions
+ @packages.pluck_versions.sort
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/nuget/presenter_helpers.rb b/app/presenters/packages/nuget/presenter_helpers.rb
new file mode 100644
index 00000000000..cc7e8619220
--- /dev/null
+++ b/app/presenters/packages/nuget/presenter_helpers.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ module PresenterHelpers
+ include ::API::Helpers::RelatedResourcesHelpers
+
+ BLANK_STRING = ''
+ PACKAGE_DEPENDENCY_GROUP = 'PackageDependencyGroup'
+ PACKAGE_DEPENDENCY = 'PackageDependency'
+
+ private
+
+ def json_url_for(package)
+ path = api_v4_projects_packages_nuget_metadata_package_name_package_version_path(
+ {
+ id: package.project_id,
+ package_name: package.name,
+ package_version: package.version,
+ format: '.json'
+ },
+ true
+ )
+
+ expose_url(path)
+ end
+
+ def archive_url_for(package)
+ path = api_v4_projects_packages_nuget_download_package_name_package_version_package_filename_path(
+ {
+ id: package.project_id,
+ package_name: package.name,
+ package_version: package.version,
+ package_filename: package.package_files.last&.file_name
+ },
+ true
+ )
+
+ expose_url(path)
+ end
+
+ def catalog_entry_for(package)
+ {
+ json_url: json_url_for(package),
+ authors: BLANK_STRING,
+ dependency_groups: dependency_groups_for(package),
+ package_name: package.name,
+ package_version: package.version,
+ archive_url: archive_url_for(package),
+ summary: BLANK_STRING,
+ tags: tags_for(package),
+ metadatum: metadatum_for(package)
+ }
+ end
+
+ def dependency_groups_for(package)
+ base_nuget_id = "#{json_url_for(package)}#dependencyGroup"
+
+ dependency_links_grouped_by_target_framework(package).map do |target_framework, dependency_links|
+ nuget_id = target_framework_nuget_id(base_nuget_id, target_framework)
+ {
+ id: nuget_id,
+ type: PACKAGE_DEPENDENCY_GROUP,
+ target_framework: target_framework,
+ dependencies: dependencies_for(nuget_id, dependency_links)
+ }.compact
+ end
+ end
+
+ def dependency_links_grouped_by_target_framework(package)
+ package
+ .dependency_links
+ .includes_dependency
+ .preload_nuget_metadatum
+ .group_by { |dependency_link| dependency_link.nuget_metadatum&.target_framework }
+ end
+
+ def dependencies_for(nuget_id, dependency_links)
+ return [] if dependency_links.empty?
+
+ dependency_links.map do |dependency_link|
+ dependency = dependency_link.dependency
+ {
+ id: "#{nuget_id}/#{dependency.name.downcase}",
+ type: PACKAGE_DEPENDENCY,
+ name: dependency.name,
+ range: dependency.version_pattern
+ }
+ end
+ end
+
+ def target_framework_nuget_id(base_nuget_id, target_framework)
+ target_framework.blank? ? base_nuget_id : "#{base_nuget_id}/#{target_framework.downcase}"
+ end
+
+ def metadatum_for(package)
+ metadatum = package.nuget_metadatum
+ return {} unless metadatum
+
+ metadatum.slice(:project_url, :license_url, :icon_url)
+ .compact
+ end
+
+ def base_path_for(package)
+ api_v4_projects_packages_nuget_path(id: package.project_id)
+ end
+
+ def tags_for(package)
+ package.tag_names.join(::Packages::Tag::NUGET_TAGS_SEPARATOR)
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/nuget/search_results_presenter.rb b/app/presenters/packages/nuget/search_results_presenter.rb
new file mode 100644
index 00000000000..96c8fe7dd2a
--- /dev/null
+++ b/app/presenters/packages/nuget/search_results_presenter.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class SearchResultsPresenter
+ include Packages::Nuget::PresenterHelpers
+ include Gitlab::Utils::StrongMemoize
+
+ delegate :total_count, to: :@search
+
+ def initialize(search)
+ @search = search
+ @package_versions = {}
+ end
+
+ def data
+ strong_memoize(:data) do
+ @search.results.group_by(&:name).map do |package_name, packages|
+ latest_version = latest_version(packages)
+ latest_package = packages.find { |pkg| pkg.version == latest_version }
+
+ {
+ type: 'Package',
+ authors: '',
+ name: package_name,
+ version: latest_version,
+ versions: build_package_versions(packages),
+ summary: '',
+ total_downloads: 0,
+ verified: true,
+ tags: tags_for(latest_package),
+ metadatum: metadatum_for(latest_package)
+ }
+ end
+ end
+ end
+
+ private
+
+ def build_package_versions(packages)
+ packages.map do |pkg|
+ {
+ json_url: json_url_for(pkg),
+ downloads: 0,
+ version: pkg.version
+ }
+ end
+ end
+
+ def latest_version(packages)
+ versions = packages.map(&:version).compact
+ VersionSorter.sort(versions).last # rubocop: disable Style/UnneededSort
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/nuget/service_index_presenter.rb b/app/presenters/packages/nuget/service_index_presenter.rb
new file mode 100644
index 00000000000..ed00b36b362
--- /dev/null
+++ b/app/presenters/packages/nuget/service_index_presenter.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class ServiceIndexPresenter
+ include API::Helpers::RelatedResourcesHelpers
+
+ SERVICE_VERSIONS = {
+ download: %w[PackageBaseAddress/3.0.0],
+ search: %w[SearchQueryService SearchQueryService/3.0.0-beta SearchQueryService/3.0.0-rc],
+ publish: %w[PackagePublish/2.0.0],
+ metadata: %w[RegistrationsBaseUrl RegistrationsBaseUrl/3.0.0-beta RegistrationsBaseUrl/3.0.0-rc]
+ }.freeze
+
+ SERVICE_COMMENTS = {
+ download: 'Get package content (.nupkg).',
+ search: 'Filter and search for packages by keyword.',
+ publish: 'Push and delete (or unlist) packages.',
+ metadata: 'Get package metadata.'
+ }.freeze
+
+ VERSION = '3.0.0'.freeze
+
+ def initialize(project)
+ @project = project
+ end
+
+ def version
+ VERSION
+ end
+
+ def resources
+ [
+ build_service(:download),
+ build_service(:search),
+ build_service(:publish),
+ build_service(:metadata)
+ ].flatten
+ end
+
+ private
+
+ def build_service(service_type)
+ url = build_service_url(service_type)
+ comment = SERVICE_COMMENTS[service_type]
+
+ SERVICE_VERSIONS[service_type].map do |version|
+ { :@id => url, :@type => version, :comment => comment }
+ end
+ end
+
+ def build_service_url(service_type)
+ base_path = api_v4_projects_packages_nuget_path(id: @project.id)
+
+ full_path = case service_type
+ when :download
+ api_v4_projects_packages_nuget_download_package_name_package_version_package_filename_path(
+ {
+ id: @project.id,
+ package_name: nil,
+ package_version: nil,
+ package_filename: nil
+ },
+ true
+ )
+ when :search
+ "#{base_path}/query"
+ when :metadata
+ api_v4_projects_packages_nuget_metadata_package_name_package_version_path(
+ {
+ id: @project.id,
+ package_name: nil,
+ package_version: nil
+ },
+ true
+ )
+ when :publish
+ base_path
+ end
+
+ expose_url(full_path)
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/pypi/package_presenter.rb b/app/presenters/packages/pypi/package_presenter.rb
new file mode 100644
index 00000000000..4192e974645
--- /dev/null
+++ b/app/presenters/packages/pypi/package_presenter.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+# Display package version data acording to PyPi
+# Simple API: https://warehouse.pypa.io/api-reference/legacy/#simple-project-api
+module Packages
+ module Pypi
+ class PackagePresenter
+ include API::Helpers::RelatedResourcesHelpers
+
+ def initialize(packages, project)
+ @packages = packages
+ @project = project
+ end
+
+ # Returns the HTML body for PyPi simple API.
+ # Basically a list of package download links for a specific
+ # package
+ def body
+ <<-HTML
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <title>Links for #{escape(name)}</title>
+ </head>
+ <body>
+ <h1>Links for #{escape(name)}</h1>
+ #{links}
+ </body>
+ </html>
+ HTML
+ end
+
+ private
+
+ def links
+ refs = []
+
+ @packages.map do |package|
+ package.package_files.each do |file|
+ url = build_pypi_package_path(file)
+
+ refs << package_link(url, package.pypi_metadatum.required_python, file.file_name)
+ end
+ end
+
+ refs.join
+ end
+
+ def package_link(url, required_python, filename)
+ "<a href=\"#{url}\" data-requires-python=\"#{escape(required_python)}\">#{filename}</a><br>"
+ end
+
+ def build_pypi_package_path(file)
+ expose_url(
+ api_v4_projects_packages_pypi_files_file_identifier_path(
+ {
+ id: @project.id,
+ sha256: file.file_sha256,
+ file_identifier: file.file_name
+ },
+ true
+ )
+ ) + "#sha256=#{file.file_sha256}"
+ end
+
+ def name
+ @packages.first.name
+ end
+
+ def escape(str)
+ ERB::Util.html_escape(str)
+ end
+ end
+ end
+end
diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb
index 5c56d42ed27..718f653eab1 100644
--- a/app/presenters/project_clusterable_presenter.rb
+++ b/app/presenters/project_clusterable_presenter.rb
@@ -38,6 +38,10 @@ class ProjectClusterablePresenter < ClusterablePresenter
def learn_more_link
link_to(s_('ClusterIntegration|Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
end
+
+ def metrics_dashboard_path(cluster)
+ metrics_dashboard_project_cluster_path(clusterable, cluster)
+ end
end
ProjectClusterablePresenter.prepend_if_ee('EE::ProjectClusterablePresenter')
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index a663bc555f6..4e8dae1d508 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -16,7 +16,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
MAX_TOPICS_TO_SHOW = 3
def statistic_icon(icon_name = 'plus-square-o')
- sprite_icon(icon_name, size: 16, css_class: 'icon append-right-4')
+ sprite_icon(icon_name, size: 16, css_class: 'icon gl-mr-2')
end
def statistics_anchors(show_auto_devops_callout:)
diff --git a/app/presenters/projects/prometheus/alert_presenter.rb b/app/presenters/projects/prometheus/alert_presenter.rb
index 6009ee4c7be..1cf8b202810 100644
--- a/app/presenters/projects/prometheus/alert_presenter.rb
+++ b/app/presenters/projects/prometheus/alert_presenter.rb
@@ -6,7 +6,7 @@ module Projects
RESERVED_ANNOTATIONS = %w(gitlab_incident_markdown gitlab_y_label title).freeze
GENERIC_ALERT_SUMMARY_ANNOTATIONS = %w(monitoring_tool service hosts).freeze
MARKDOWN_LINE_BREAK = " \n".freeze
- INCIDENT_LABEL_NAME = IncidentManagement::CreateIssueService::INCIDENT_LABEL[:title].freeze
+ INCIDENT_LABEL_NAME = ::IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES[:title].freeze
METRIC_TIME_WINDOW = 30.minutes
def full_title
@@ -58,6 +58,25 @@ module Projects
MARKDOWN
end
+ def annotation_list
+ strong_memoize(:annotation_list) do
+ annotations
+ .reject { |annotation| annotation.label.in?(RESERVED_ANNOTATIONS | GENERIC_ALERT_SUMMARY_ANNOTATIONS) }
+ .map { |annotation| list_item(annotation.label, annotation.value) }
+ .join(MARKDOWN_LINE_BREAK)
+ end
+ end
+
+ def metric_embed_for_alert
+ "\n[](#{metrics_dashboard_url})" if metrics_dashboard_url
+ end
+
+ def metrics_dashboard_url
+ strong_memoize(:metrics_dashboard_url) do
+ embed_url_for_gitlab_alert || embed_url_for_self_managed_alert
+ end
+ end
+
private
def alert_title
@@ -93,15 +112,6 @@ module Projects
end
end
- def annotation_list
- strong_memoize(:annotation_list) do
- annotations
- .reject { |annotation| annotation.label.in?(RESERVED_ANNOTATIONS | GENERIC_ALERT_SUMMARY_ANNOTATIONS) }
- .map { |annotation| list_item(annotation.label, annotation.value) }
- .join(MARKDOWN_LINE_BREAK)
- end
- end
-
def list_item(key, value)
"**#{key}:** #{value}".strip
end
@@ -120,12 +130,6 @@ module Projects
Array(hosts.value).join(' ')
end
- def metric_embed_for_alert
- url = embed_url_for_gitlab_alert || embed_url_for_self_managed_alert
-
- "\n[](#{url})" if url
- end
-
def embed_url_for_gitlab_alert
return unless gitlab_alert
@@ -133,6 +137,7 @@ module Projects
project,
gitlab_alert.prometheus_metric_id,
environment_id: environment.id,
+ embedded: true,
**alert_embed_window_params(embed_time)
)
end
@@ -144,6 +149,7 @@ module Projects
project,
environment,
embed_json: dashboard_for_self_managed_alert.to_json,
+ embedded: true,
**alert_embed_window_params(embed_time)
)
end
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index 7b0a3d1e7b9..4393ca05f48 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -5,7 +5,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
presents :release
- delegate :project, :tag, :assets_count, to: :release
+ delegate :project, :tag, to: :release
def commit_path
return unless release.commit && can_download_code?
@@ -43,6 +43,18 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
edit_project_release_url(project, release)
end
+ def assets_count
+ if can_download_code?
+ release.assets_count
+ else
+ release.assets_count(except: [:sources])
+ end
+ end
+
+ def name
+ can_download_code? ? release.name : "Release-#{release.id}"
+ end
+
private
def can_download_code?
diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb
index ed9c28bbc2c..d27fe751ab7 100644
--- a/app/presenters/snippet_blob_presenter.rb
+++ b/app/presenters/snippet_blob_presenter.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class SnippetBlobPresenter < BlobPresenter
+ include GitlabRoutingHelper
+
def rich_data
return if blob.binary?
return unless blob.rich_viewer
@@ -15,15 +17,17 @@ class SnippetBlobPresenter < BlobPresenter
end
def raw_path
- if snippet.is_a?(ProjectSnippet)
- raw_project_snippet_path(snippet.project, snippet)
- else
- raw_snippet_path(snippet)
- end
+ return gitlab_raw_snippet_blob_path(blob) if snippet_multiple_files?
+
+ gitlab_raw_snippet_path(snippet)
end
private
+ def snippet_multiple_files?
+ blob.container.repository_exists? && Feature.enabled?(:snippet_multiple_files, current_user)
+ end
+
def snippet
blob.container
end
diff --git a/app/serializers/build_trace_entity.rb b/app/serializers/build_trace_entity.rb
index b5bac8a5d64..f4c3c7770b2 100644
--- a/app/serializers/build_trace_entity.rb
+++ b/app/serializers/build_trace_entity.rb
@@ -12,6 +12,5 @@ class BuildTraceEntity < Grape::Entity
expose :size
expose :total
- expose :json_lines, as: :lines, if: ->(*) { object.json? }
- expose :html_lines, as: :html, if: ->(*) { object.html? }
+ expose :lines
end
diff --git a/app/serializers/ci/group_variable_entity.rb b/app/serializers/ci/group_variable_entity.rb
new file mode 100644
index 00000000000..e7d0a957082
--- /dev/null
+++ b/app/serializers/ci/group_variable_entity.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Ci
+ class GroupVariableEntity < Ci::BasicVariableEntity
+ end
+end
diff --git a/app/serializers/ci/group_variable_serializer.rb b/app/serializers/ci/group_variable_serializer.rb
new file mode 100644
index 00000000000..b100a931620
--- /dev/null
+++ b/app/serializers/ci/group_variable_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class GroupVariableSerializer < BaseSerializer
+ entity ::Ci::GroupVariableEntity
+ end
+end
diff --git a/app/serializers/ci/variable_entity.rb b/app/serializers/ci/variable_entity.rb
new file mode 100644
index 00000000000..715f829a0e1
--- /dev/null
+++ b/app/serializers/ci/variable_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class VariableEntity < Ci::BasicVariableEntity
+ expose :environment_scope
+ end
+end
diff --git a/app/serializers/ci/variable_serializer.rb b/app/serializers/ci/variable_serializer.rb
new file mode 100644
index 00000000000..eb47d3b71b5
--- /dev/null
+++ b/app/serializers/ci/variable_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class VariableSerializer < BaseSerializer
+ entity ::Ci::VariableEntity
+ end
+end
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
index 32b759b9628..6b9a3ce114b 100644
--- a/app/serializers/cluster_application_entity.rb
+++ b/app/serializers/cluster_application_entity.rb
@@ -4,7 +4,7 @@ class ClusterApplicationEntity < Grape::Entity
expose :name
expose :status_name, as: :status
expose :status_reason
- expose :version
+ expose :version, if: -> (e, _) { e.respond_to?(:version) }
expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) }
expose :external_hostname, if: -> (e, _) { e.respond_to?(:external_hostname) }
expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) }
diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb
index 8a1d41dbd96..a46f2889a96 100644
--- a/app/serializers/cluster_entity.rb
+++ b/app/serializers/cluster_entity.rb
@@ -16,4 +16,8 @@ class ClusterEntity < Grape::Entity
expose :path do |cluster|
Clusters::ClusterPresenter.new(cluster).show_path # rubocop: disable CodeReuse/Presenter
end
+
+ expose :gitlab_managed_apps_logs_path do |cluster|
+ Clusters::ClusterPresenter.new(cluster, current_user: request.current_user).gitlab_managed_apps_logs_path # rubocop: disable CodeReuse/Presenter
+ end
end
diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb
index 27156d3178f..92363a4942c 100644
--- a/app/serializers/cluster_serializer.rb
+++ b/app/serializers/cluster_serializer.rb
@@ -10,6 +10,7 @@ class ClusterSerializer < BaseSerializer
:cluster_type,
:enabled,
:environment_scope,
+ :gitlab_managed_apps_logs_path,
:name,
:nodes,
:path,
diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb
index 653316ce4d2..486189b84ca 100644
--- a/app/serializers/deploy_key_entity.rb
+++ b/app/serializers/deploy_key_entity.rb
@@ -16,6 +16,7 @@ class DeployKeyEntity < Grape::Entity
end
end
expose :can_edit
+ expose :user, as: :owner, using: ::API::Entities::UserBasic, if: -> (_, opts) { can_read_owner?(opts) }
private
@@ -24,6 +25,10 @@ class DeployKeyEntity < Grape::Entity
Ability.allowed?(options[:user], :update_deploy_keys_project, object.deploy_keys_project_for(options[:project]))
end
+ def can_read_owner?(opts)
+ opts[:with_owner] && Ability.allowed?(options[:user], :read_user, object.user)
+ end
+
def allowed_to_read_project?(project)
if options[:readable_project_ids]
options[:readable_project_ids].include?(project.id)
diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb
index 33eb33d314b..2af14f1eb82 100644
--- a/app/serializers/diff_file_base_entity.rb
+++ b/app/serializers/diff_file_base_entity.rb
@@ -26,13 +26,9 @@ class DiffFileBaseEntity < Grape::Entity
target_project, target_branch = edit_project_branch_options(merge_request)
- if Feature.enabled?(:web_ide_default)
- ide_edit_path(target_project, target_branch, diff_file.new_path)
- else
- options = merge_request.persisted? && merge_request.source_branch_exists? && !merge_request.merged? ? { from_merge_request_iid: merge_request.iid } : {}
+ options = merge_request.persisted? && merge_request.source_branch_exists? && !merge_request.merged? ? { from_merge_request_iid: merge_request.iid } : {}
- project_edit_blob_path(target_project, tree_join(target_branch, diff_file.new_path), options)
- end
+ project_edit_blob_path(target_project, tree_join(target_branch, diff_file.new_path), options)
end
expose :old_path_html do |diff_file|
diff --git a/app/serializers/evidences/release_entity.rb b/app/serializers/evidences/release_entity.rb
index 59e379a3c08..dfc4f52de07 100644
--- a/app/serializers/evidences/release_entity.rb
+++ b/app/serializers/evidences/release_entity.rb
@@ -11,3 +11,5 @@ module Evidences
expose :milestones, using: Evidences::MilestoneEntity
end
end
+
+Evidences::ReleaseEntity.prepend_if_ee('EE::Evidences::ReleaseEntity')
diff --git a/app/serializers/fork_namespace_entity.rb b/app/serializers/fork_namespace_entity.rb
new file mode 100644
index 00000000000..068862e0951
--- /dev/null
+++ b/app/serializers/fork_namespace_entity.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+class ForkNamespaceEntity < Grape::Entity
+ include ActionView::Helpers::NumberHelper
+ include RequestAwareEntity
+ include MarkupHelper
+
+ expose :id, :name, :description, :visibility, :full_name,
+ :created_at, :updated_at, :avatar_url
+
+ expose :fork_path do |namespace, options|
+ project_forks_path(options[:project], namespace_key: namespace.id)
+ end
+
+ expose :forked_project_path do |namespace, options|
+ if forked_project = namespace.find_fork_of(options[:project])
+ project_path(forked_project)
+ end
+ end
+
+ expose :permission do |namespace, options|
+ membership(options[:current_user], namespace)&.human_access
+ end
+
+ expose :relative_path do |namespace|
+ polymorphic_path(namespace)
+ end
+
+ expose :markdown_description do |namespace|
+ markdown_description(namespace)
+ end
+
+ expose :can_create_project do |namespace, options|
+ options[:current_user].can?(:create_projects, namespace)
+ end
+
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def membership(user, object)
+ return unless user
+
+ @membership ||= user.members.find_by(source: object)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def markdown_description(namespace)
+ markdown_field(namespace, :description)
+ end
+end
+
+ForkNamespaceEntity.prepend_if_ee('EE::ForkNamespaceEntity')
diff --git a/app/serializers/fork_namespace_serializer.rb b/app/serializers/fork_namespace_serializer.rb
new file mode 100644
index 00000000000..1461938269e
--- /dev/null
+++ b/app/serializers/fork_namespace_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ForkNamespaceSerializer < BaseSerializer
+ entity ForkNamespaceEntity
+end
diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb
deleted file mode 100644
index 4f44723fefe..00000000000
--- a/app/serializers/group_variable_entity.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-# frozen_string_literal: true
-
-class GroupVariableEntity < Ci::BasicVariableEntity
-end
diff --git a/app/serializers/group_variable_serializer.rb b/app/serializers/group_variable_serializer.rb
deleted file mode 100644
index ed20b240cce..00000000000
--- a/app/serializers/group_variable_serializer.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-class GroupVariableSerializer < BaseSerializer
- entity GroupVariableEntity
-end
diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb
index 72f629b3507..c51c08ab646 100644
--- a/app/serializers/merge_request_poll_cached_widget_entity.rb
+++ b/app/serializers/merge_request_poll_cached_widget_entity.rb
@@ -74,6 +74,30 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
diffs_project_merge_request_path(merge_request.project, merge_request)
end
+ expose :squash_enabled_by_default do |merge_request|
+ presenter(merge_request).project.squash_enabled_by_default?
+ end
+
+ expose :squash_readonly do |merge_request|
+ presenter(merge_request).project.squash_readonly?
+ end
+
+ expose :squash_on_merge do |merge_request|
+ presenter(merge_request).squash_on_merge?
+ end
+
+ expose :api_approvals_path do |merge_request|
+ presenter(merge_request).api_approvals_path
+ end
+
+ expose :api_approve_path do |merge_request|
+ presenter(merge_request).api_approve_path
+ end
+
+ expose :api_unapprove_path do |merge_request|
+ presenter(merge_request).api_unapprove_path
+ end
+
private
delegate :current_user, to: :request
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index aad607f358a..a365ebc29c9 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -145,6 +145,22 @@ class MergeRequestPollWidgetEntity < Grape::Entity
presenter(merge_request).revert_in_fork_path
end
+ expose :squash_enabled_by_default do |merge_request|
+ presenter(merge_request).project.squash_enabled_by_default?
+ end
+
+ expose :squash_readonly do |merge_request|
+ presenter(merge_request).project.squash_readonly?
+ end
+
+ expose :squash_on_merge do |merge_request|
+ presenter(merge_request).squash_on_merge?
+ end
+
+ expose :approvals_widget_type do |merge_request|
+ presenter(merge_request).approvals_widget_type
+ end
+
private
delegate :current_user, to: :request
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 74f29b36209..2a7afb57314 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -85,6 +85,26 @@ class MergeRequestWidgetEntity < Grape::Entity
end
end
+ expose :blob_path do
+ expose :head_path, if: -> (mr, _) { mr.source_branch_sha } do |merge_request|
+ project_blob_path(merge_request.project, merge_request.source_branch_sha)
+ end
+
+ expose :base_path, if: -> (mr, _) { mr.diff_base_sha } do |merge_request|
+ project_blob_path(merge_request.project, merge_request.diff_base_sha)
+ end
+ end
+
+ expose :codeclimate, if: -> (mr, _) { head_pipeline_downloadable_path_for_report_type(:codequality) } do
+ expose :head_path do |merge_request|
+ head_pipeline_downloadable_path_for_report_type(:codequality)
+ end
+
+ expose :base_path do |merge_request|
+ base_pipeline_downloadable_path_for_report_type(:codequality)
+ end
+ end
+
private
delegate :current_user, to: :request
@@ -95,12 +115,24 @@ class MergeRequestWidgetEntity < Grape::Entity
end
def can_add_ci_config_path?(merge_request)
- merge_request.source_project&.uses_default_ci_config? &&
+ merge_request.open? &&
+ merge_request.source_branch_exists? &&
+ merge_request.source_project&.uses_default_ci_config? &&
!merge_request.source_project.has_ci? &&
merge_request.commits_count.positive? &&
can?(current_user, :read_build, merge_request.source_project) &&
can?(current_user, :create_pipeline, merge_request.source_project)
end
+
+ def head_pipeline_downloadable_path_for_report_type(file_type)
+ object.head_pipeline&.present(current_user: current_user)
+ &.downloadable_path_for_report_type(file_type)
+ end
+
+ def base_pipeline_downloadable_path_for_report_type(file_type)
+ object.base_pipeline&.present(current_user: current_user)
+ &.downloadable_path_for_report_type(file_type)
+ end
end
MergeRequestWidgetEntity.prepend_if_ee('EE::MergeRequestWidgetEntity')
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index c3ddbb88c9c..8333a0bb863 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -85,6 +85,10 @@ class PipelineEntity < Grape::Entity
pipeline.failed_builds
end
+ expose :tests_total_count, if: -> (pipeline, _) { Feature.enabled?(:build_report_summary, pipeline.project) } do |pipeline|
+ pipeline.test_report_summary.total_count
+ end
+
private
alias_method :pipeline, :object
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 21d49c6c292..bfd6851647f 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -60,8 +60,8 @@ class PipelineSerializer < BaseSerializer
},
pending_builds: :project,
project: [:route, { namespace: :route }],
- triggered_by_pipeline: [:project, :user],
- triggered_pipelines: [:project, :user]
+ triggered_by_pipeline: [{ project: [:route, { namespace: :route }] }, :user],
+ triggered_pipelines: [{ project: [:route, { namespace: :route }] }, :user, :source_job]
}
]
end
diff --git a/app/serializers/service_event_entity.rb b/app/serializers/service_event_entity.rb
index fd655dd1ed3..eb4f9c665f2 100644
--- a/app/serializers/service_event_entity.rb
+++ b/app/serializers/service_event_entity.rb
@@ -14,7 +14,7 @@ class ServiceEventEntity < Grape::Entity
end
expose :description do |event|
- service.class.event_description(event)
+ ServicesHelper.service_event_description(event)
end
expose :field, if: -> (_, _) { event_field } do
diff --git a/app/serializers/service_field_entity.rb b/app/serializers/service_field_entity.rb
index 9929d7e2e5a..08e08ae187f 100644
--- a/app/serializers/service_field_entity.rb
+++ b/app/serializers/service_field_entity.rb
@@ -11,6 +11,8 @@ class ServiceFieldEntity < Grape::Entity
if field[:type] == 'password' && value.present?
'true'
+ elsif field[:type] == 'checkbox'
+ ActiveRecord::Type::Boolean.new.deserialize(value).to_s
else
value
end
diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb
index 0b0454c5282..0aadcd01a43 100644
--- a/app/serializers/stage_entity.rb
+++ b/app/serializers/stage_entity.rb
@@ -59,13 +59,13 @@ class StageEntity < Grape::Entity
end
def latest_statuses
- HasStatus::ORDERED_STATUSES.flat_map do |ordered_status|
+ Ci::HasStatus::ORDERED_STATUSES.flat_map do |ordered_status|
grouped_statuses.fetch(ordered_status, [])
end
end
def retried_statuses
- HasStatus::ORDERED_STATUSES.flat_map do |ordered_status|
+ Ci::HasStatus::ORDERED_STATUSES.flat_map do |ordered_status|
grouped_retried_statuses.fetch(ordered_status, [])
end
end
diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb
index 4fb19fbc074..c9fcbe14f2e 100644
--- a/app/serializers/suggestion_entity.rb
+++ b/app/serializers/suggestion_entity.rb
@@ -2,6 +2,7 @@
class SuggestionEntity < API::Entities::Suggestion
include RequestAwareEntity
+ include Gitlab::Utils::StrongMemoize
unexpose :from_line, :to_line, :from_content, :to_content
expose :diff_lines, using: DiffLineEntity do |suggestion|
@@ -9,7 +10,29 @@ class SuggestionEntity < API::Entities::Suggestion
end
expose :current_user do
expose :can_apply do |suggestion|
- Ability.allowed?(current_user, :apply_suggestion, suggestion)
+ can_apply?(suggestion)
+ end
+ end
+
+ expose :inapplicable_reason do |suggestion|
+ next _("You don't have write access to the source branch.") unless can_apply?(suggestion)
+ next if suggestion.appliable?
+
+ case suggestion.inapplicable_reason
+ when :merge_request_merged
+ _("This merge request was merged. To apply this suggestion, edit this file directly.")
+ when :merge_request_closed
+ _("This merge request is closed. To apply this suggestion, edit this file directly.")
+ when :source_branch_deleted
+ _("Can't apply as the source branch was deleted.")
+ when :outdated
+ phrase = suggestion.single_line? ? 'this line was' : 'these lines were'
+
+ _("Can't apply as %{phrase} changed in a more recent version.") % { phrase: phrase }
+ when :same_content
+ _("This suggestion already matches its content.")
+ else
+ _("Can't apply this suggestion.")
end
end
@@ -18,4 +41,10 @@ class SuggestionEntity < API::Entities::Suggestion
def current_user
request.current_user
end
+
+ def can_apply?(suggestion)
+ strong_memoize(:can_apply) do
+ Ability.allowed?(current_user, :apply_suggestion, suggestion)
+ end
+ end
end
diff --git a/app/serializers/test_report_summary_entity.rb b/app/serializers/test_report_summary_entity.rb
new file mode 100644
index 00000000000..5995ca007d6
--- /dev/null
+++ b/app/serializers/test_report_summary_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class TestReportSummaryEntity < TestReportEntity
+ expose :test_suites, using: TestSuiteSummaryEntity do |summary|
+ summary.test_suites.values
+ end
+end
diff --git a/app/serializers/test_report_summary_serializer.rb b/app/serializers/test_report_summary_serializer.rb
new file mode 100644
index 00000000000..6077a4e87bb
--- /dev/null
+++ b/app/serializers/test_report_summary_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class TestReportSummarySerializer < BaseSerializer
+ entity TestReportSummaryEntity
+end
diff --git a/app/serializers/test_suite_entity.rb b/app/serializers/test_suite_entity.rb
index 53fa830718a..d04fd5f6a84 100644
--- a/app/serializers/test_suite_entity.rb
+++ b/app/serializers/test_suite_entity.rb
@@ -9,9 +9,11 @@ class TestSuiteEntity < Grape::Entity
expose :failed_count
expose :skipped_count
expose :error_count
- expose :suite_error
- expose :test_cases, using: TestCaseEntity do |test_suite|
- test_suite.suite_error ? [] : test_suite.test_cases.values.flat_map(&:values)
+ with_options if: -> (_, opts) { opts[:details] } do |test_suite|
+ expose :suite_error
+ expose :test_cases, using: TestCaseEntity do |test_suite|
+ test_suite.suite_error ? [] : test_suite.test_cases.values.flat_map(&:values)
+ end
end
end
diff --git a/app/serializers/test_suite_serializer.rb b/app/serializers/test_suite_serializer.rb
new file mode 100644
index 00000000000..f11d0fbe7e6
--- /dev/null
+++ b/app/serializers/test_suite_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class TestSuiteSerializer < BaseSerializer
+ entity TestSuiteEntity
+end
diff --git a/app/serializers/test_suite_summary_entity.rb b/app/serializers/test_suite_summary_entity.rb
new file mode 100644
index 00000000000..6718b31a7f5
--- /dev/null
+++ b/app/serializers/test_suite_summary_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class TestSuiteSummaryEntity < TestSuiteEntity
+ expose :build_ids do |summary|
+ summary.build_ids
+ end
+end
diff --git a/app/serializers/triggered_pipeline_entity.rb b/app/serializers/triggered_pipeline_entity.rb
index fd7e4454abf..47f51a6d76a 100644
--- a/app/serializers/triggered_pipeline_entity.rb
+++ b/app/serializers/triggered_pipeline_entity.rb
@@ -11,6 +11,12 @@ class TriggeredPipelineEntity < Grape::Entity
expose :coverage
expose :source
+ expose :source_job do
+ expose :name do |pipeline|
+ pipeline.source_job&.name
+ end
+ end
+
expose :path do |pipeline|
project_pipeline_path(pipeline.project, pipeline)
end
@@ -27,7 +33,7 @@ class TriggeredPipelineEntity < Grape::Entity
as: :triggered_by, with: TriggeredPipelineEntity,
if: -> (_, opts) { can_read_details? && expand_for_path?(opts) }
- expose :triggered_pipelines,
+ expose :triggered_pipelines_with_preloads,
as: :triggered, using: TriggeredPipelineEntity,
if: -> (_, opts) { can_read_details? && expand_for_path?(opts) }
diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb
deleted file mode 100644
index 9b0db371acb..00000000000
--- a/app/serializers/variable_entity.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-class VariableEntity < Ci::BasicVariableEntity
- expose :environment_scope
-end
diff --git a/app/serializers/variable_serializer.rb b/app/serializers/variable_serializer.rb
deleted file mode 100644
index 586666cad8e..00000000000
--- a/app/serializers/variable_serializer.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-class VariableSerializer < BaseSerializer
- entity VariableEntity
-end
diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb
index 851d862c0cf..eb2e66a9285 100644
--- a/app/services/access_token_validation_service.rb
+++ b/app/services/access_token_validation_service.rb
@@ -17,21 +17,21 @@ class AccessTokenValidationService
def validate(scopes: [])
if token.expired?
- return EXPIRED
+ EXPIRED
elsif token.revoked?
- return REVOKED
+ REVOKED
elsif !self.include_any_scope?(scopes)
- return INSUFFICIENT_SCOPE
+ INSUFFICIENT_SCOPE
elsif token.respond_to?(:impersonation) &&
token.impersonation &&
!Gitlab.config.gitlab.impersonation_enabled
- return IMPERSONATION_DISABLED
+ IMPERSONATION_DISABLED
else
- return VALID
+ VALID
end
end
diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb
index 084b103ee3b..e21bb03ed68 100644
--- a/app/services/admin/propagate_integration_service.rb
+++ b/app/services/admin/propagate_integration_service.rb
@@ -64,7 +64,7 @@ module Admin
def create_integration_for_projects_without_integration
loop do
- batch = Project.uncached { project_ids_without_integration }
+ batch = Project.uncached { Project.ids_without_integration(integration, BATCH_SIZE) }
bulk_create_from_integration(batch) unless batch.empty?
@@ -114,22 +114,6 @@ module Admin
integration.type == 'ExternalWikiService'
end
- # rubocop: disable CodeReuse/ActiveRecord
- def project_ids_without_integration
- services = Service
- .select('1')
- .where('services.project_id = projects.id')
- .where(type: integration.type)
-
- Project
- .where('NOT EXISTS (?)', services)
- .where(pending_delete: false)
- .where(archived: false)
- .limit(BATCH_SIZE)
- .pluck(:id)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def service_hash
@service_hash ||= integration.to_service_hash
.tap { |json| json['inherit_from_id'] = integration.id }
diff --git a/app/services/alert_management/alerts/todo/create_service.rb b/app/services/alert_management/alerts/todo/create_service.rb
new file mode 100644
index 00000000000..87af943fdc2
--- /dev/null
+++ b/app/services/alert_management/alerts/todo/create_service.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ module Alerts
+ module Todo
+ class CreateService
+ # @param alert [AlertManagement::Alert]
+ # @param current_user [User]
+ def initialize(alert, current_user)
+ @alert = alert
+ @current_user = current_user
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+
+ todos = TodoService.new.mark_todo(alert, current_user)
+ todo = todos&.first
+
+ return error_existing_todo unless todo
+
+ success(todo)
+ end
+
+ private
+
+ attr_reader :alert, :current_user
+
+ def allowed?
+ current_user&.can?(:update_alert_management_alert, alert)
+ end
+
+ def error(message)
+ ServiceResponse.error(payload: { alert: alert, todo: nil }, message: message)
+ end
+
+ def success(todo)
+ ServiceResponse.success(payload: { alert: alert, todo: todo })
+ end
+
+ def error_no_permissions
+ error(_('You have insufficient permissions to create a Todo for this alert'))
+ end
+
+ def error_existing_todo
+ error(_('You already have pending todo for this alert'))
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/alert_management/alerts/update_service.rb b/app/services/alert_management/alerts/update_service.rb
index ffabbb37289..0b7216cd9f8 100644
--- a/app/services/alert_management/alerts/update_service.rb
+++ b/app/services/alert_management/alerts/update_service.rb
@@ -12,17 +12,20 @@ module AlertManagement
@alert = alert
@current_user = current_user
@params = params
+ @param_errors = []
end
def execute
return error_no_permissions unless allowed?
- return error_no_updates if params.empty?
- filter_assignees
+ filter_params
+ return error_invalid_params if param_errors.any?
+
+ # Save old assignees for system notes
old_assignees = alert.assignees.to_a
if alert.update(params)
- process_assignement(old_assignees)
+ handle_changes(old_assignees: old_assignees)
success
else
@@ -32,16 +35,13 @@ module AlertManagement
private
- attr_reader :alert, :current_user, :params
+ attr_reader :alert, :current_user, :params, :param_errors
+ delegate :resolved?, to: :alert
def allowed?
current_user&.can?(:update_alert_management_alert, alert)
end
- def assignee_todo_allowed?
- assignee&.can?(:read_alert_management_alert, alert)
- end
-
def todo_service
strong_memoize(:todo_service) do
TodoService.new
@@ -60,39 +60,122 @@ module AlertManagement
error(_('You have no permissions'))
end
- def error_no_updates
- error(_('Please provide attributes to update'))
+ def error_invalid_params
+ error(param_errors.to_sentence)
+ end
+
+ def add_param_error(message)
+ param_errors << message
+ end
+
+ def filter_params
+ param_errors << _('Please provide attributes to update') if params.empty?
+
+ filter_status
+ filter_assignees
+ filter_duplicate
+ end
+
+ def handle_changes(old_assignees:)
+ handle_assignement(old_assignees) if params[:assignees]
+ handle_status_change if params[:status_event]
end
# ----- Assignee-related behavior ------
def filter_assignees
return if params[:assignees].nil?
- params[:assignees] = Array(assignee)
+ # Always take first assignee while multiple are not currently supported
+ params[:assignees] = Array(params[:assignees].first)
+
+ param_errors << _('Assignee has no permissions') if unauthorized_assignees?
end
- def assignee
- strong_memoize(:assignee) do
- # Take first assignee while multiple are not currently supported
- params[:assignees]&.first
- end
+ def unauthorized_assignees?
+ params[:assignees]&.any? { |user| !user.can?(:read_alert_management_alert, alert) }
end
- def process_assignement(old_assignees)
+ def handle_assignement(old_assignees)
assign_todo
add_assignee_system_note(old_assignees)
end
def assign_todo
- # Remove check in follow-up issue https://gitlab.com/gitlab-org/gitlab/-/issues/222672
- return unless assignee_todo_allowed?
-
todo_service.assign_alert(alert, current_user)
end
def add_assignee_system_note(old_assignees)
SystemNoteService.change_issuable_assignees(alert, alert.project, current_user, old_assignees)
end
+
+ # ------ Status-related behavior -------
+ def filter_status
+ return unless params[:status]
+
+ status_event = AlertManagement::Alert::STATUS_EVENTS[status_key]
+
+ unless status_event
+ param_errors << _('Invalid status')
+ return
+ end
+
+ params[:status_event] = status_event
+ end
+
+ def status_key
+ strong_memoize(:status_key) do
+ status = params.delete(:status)
+ AlertManagement::Alert::STATUSES.key(status)
+ end
+ end
+
+ def handle_status_change
+ add_status_change_system_note
+ resolve_todos if resolved?
+ end
+
+ def add_status_change_system_note
+ SystemNoteService.change_alert_status(alert, current_user)
+ end
+
+ def resolve_todos
+ todo_service.resolve_todos_for_target(alert, current_user)
+ end
+
+ def filter_duplicate
+ # Only need to check if changing to an open status
+ return unless params[:status_event] && AlertManagement::Alert::OPEN_STATUSES.include?(status_key)
+
+ param_errors << unresolved_alert_error if duplicate_alert?
+ end
+
+ def duplicate_alert?
+ return if alert.fingerprint.blank?
+
+ open_alerts.any? && open_alerts.exclude?(alert)
+ end
+
+ def open_alerts
+ strong_memoize(:open_alerts) do
+ AlertManagement::Alert.for_fingerprint(alert.project, alert.fingerprint).open
+ end
+ end
+
+ def unresolved_alert_error
+ _('An %{link_start}alert%{link_end} with the same fingerprint is already open. ' \
+ 'To change the status of this alert, resolve the linked alert.'
+ ) % open_alert_url_params
+ end
+
+ def open_alert_url_params
+ open_alert = open_alerts.first
+ alert_path = Gitlab::Routing.url_helpers.details_project_alert_management_path(alert.project, open_alert)
+
+ {
+ link_start: '<a href="%{url}">'.html_safe % { url: alert_path },
+ link_end: '</a>'.html_safe
+ }
+ end
end
end
end
diff --git a/app/services/alert_management/create_alert_issue_service.rb b/app/services/alert_management/create_alert_issue_service.rb
index beacd240b08..6ea3fd867ef 100644
--- a/app/services/alert_management/create_alert_issue_service.rb
+++ b/app/services/alert_management/create_alert_issue_service.rb
@@ -2,6 +2,8 @@
module AlertManagement
class CreateAlertIssueService
+ include Gitlab::Utils::StrongMemoize
+
# @param alert [AlertManagement::Alert]
# @param user [User]
def initialize(alert, user)
@@ -13,18 +15,20 @@ module AlertManagement
return error_no_permissions unless allowed?
return error_issue_already_exists if alert.issue
- result = create_issue(alert, user, alert_payload)
- @issue = result[:issue]
+ result = create_issue
+ issue = result.payload[:issue]
+
+ return error(result.message, issue) if result.error?
+ return error(object_errors(alert), issue) unless associate_alert_with_issue(issue)
- return error(result[:message]) if result[:status] == :error
- return error(alert.errors.full_messages.to_sentence) unless update_alert_issue_id
+ SystemNoteService.new_alert_issue(alert, issue, user)
- success
+ result
end
private
- attr_reader :alert, :user, :issue
+ attr_reader :alert, :user
delegate :project, to: :alert
@@ -32,29 +36,36 @@ module AlertManagement
user.can?(:create_issue, project)
end
- def create_issue(alert, user, alert_payload)
- ::IncidentManagement::CreateIssueService
- .new(project, alert_payload, user)
- .execute(skip_settings_check: true)
- end
+ def create_issue
+ label_result = find_or_create_incident_label
- def alert_payload
- if alert.prometheus?
- alert.payload
- else
- Gitlab::Alerting::NotificationPayloadParser.call(alert.payload.to_h)
- end
+ # Create an unlabelled issue if we couldn't create the label
+ # due to a race condition.
+ # See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042
+ extra_params = label_result.success? ? { label_ids: [label_result.payload[:label].id] } : {}
+
+ issue = Issues::CreateService.new(
+ project,
+ user,
+ title: alert_presenter.title,
+ description: alert_presenter.issue_description,
+ **extra_params
+ ).execute
+
+ return error(object_errors(issue), issue) unless issue.valid?
+
+ success(issue)
end
- def update_alert_issue_id
+ def associate_alert_with_issue(issue)
alert.update(issue_id: issue.id)
end
- def success
+ def success(issue)
ServiceResponse.success(payload: { issue: issue })
end
- def error(message)
+ def error(message, issue = nil)
ServiceResponse.error(payload: { issue: issue }, message: message)
end
@@ -65,5 +76,19 @@ module AlertManagement
def error_no_permissions
error(_('You have no permissions'))
end
+
+ def alert_presenter
+ strong_memoize(:alert_presenter) do
+ alert.present
+ end
+ end
+
+ def find_or_create_incident_label
+ IncidentManagement::CreateIncidentLabelService.new(project, user).execute
+ end
+
+ def object_errors(object)
+ object.errors.full_messages.to_sentence
+ end
end
end
diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb
index 90fcbd95e4b..573d3914c05 100644
--- a/app/services/alert_management/process_prometheus_alert_service.rb
+++ b/app/services/alert_management/process_prometheus_alert_service.rb
@@ -66,7 +66,11 @@ module AlertManagement
def process_resolved_alert_management_alert
return if am_alert.blank?
- return if am_alert.resolve(ends_at)
+
+ if am_alert.resolve(ends_at)
+ close_issue(am_alert.issue)
+ return
+ end
logger.warn(
message: 'Unable to update AlertManagement::Alert status to resolved',
@@ -75,12 +79,22 @@ module AlertManagement
)
end
+ def close_issue(issue)
+ return if issue.blank? || issue.closed?
+
+ Issues::CloseService
+ .new(project, User.alert_bot)
+ .execute(issue, system_note: false)
+
+ SystemNoteService.auto_resolve_prometheus_alert(issue, project, User.alert_bot) if issue.reset.closed?
+ end
+
def logger
@logger ||= Gitlab::AppLogger
end
def am_alert
- @am_alert ||= AlertManagement::Alert.for_fingerprint(project, gitlab_fingerprint).first
+ @am_alert ||= AlertManagement::Alert.not_resolved.for_fingerprint(project, gitlab_fingerprint).first
end
def bad_request
diff --git a/app/services/alert_management/update_alert_status_service.rb b/app/services/alert_management/update_alert_status_service.rb
deleted file mode 100644
index a7ebddb82e0..00000000000
--- a/app/services/alert_management/update_alert_status_service.rb
+++ /dev/null
@@ -1,63 +0,0 @@
-# frozen_string_literal: true
-
-module AlertManagement
- class UpdateAlertStatusService
- include Gitlab::Utils::StrongMemoize
-
- # @param alert [AlertManagement::Alert]
- # @param user [User]
- # @param status [Integer] Must match a value from AlertManagement::Alert::STATUSES
- def initialize(alert, user, status)
- @alert = alert
- @user = user
- @status = status
- end
-
- def execute
- return error_no_permissions unless allowed?
- return error_invalid_status unless status_key
-
- if alert.update(status_event: status_event)
- success
- else
- error(alert.errors.full_messages.to_sentence)
- end
- end
-
- private
-
- attr_reader :alert, :user, :status
-
- delegate :project, to: :alert
-
- def allowed?
- user.can?(:update_alert_management_alert, project)
- end
-
- def status_key
- strong_memoize(:status_key) do
- AlertManagement::Alert::STATUSES.key(status)
- end
- end
-
- def status_event
- AlertManagement::Alert::STATUS_EVENTS[status_key]
- end
-
- def success
- ServiceResponse.success(payload: { alert: alert })
- end
-
- def error_no_permissions
- error(_('You have no permissions'))
- end
-
- def error_invalid_status
- error(_('Invalid status'))
- end
-
- def error(message)
- ServiceResponse.error(payload: { alert: alert }, message: message)
- end
- end
-end
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index fb309aed649..fef733a7d09 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -16,6 +16,7 @@ class AuditEventService
@author = build_author(author)
@entity = entity
@details = details
+ @ip_address = (@details[:ip_address].presence || @author.current_sign_in_ip)
end
# Builds the @details attribute for authentication
@@ -49,6 +50,8 @@ class AuditEventService
private
+ attr_reader :ip_address
+
def build_author(author)
case author
when User
@@ -61,6 +64,7 @@ class AuditEventService
def base_payload
{
author_id: @author.id,
+ author_name: @author.name,
entity_id: @entity.id,
entity_type: @entity.class.name
}
diff --git a/app/services/authorized_project_update/project_create_service.rb b/app/services/authorized_project_update/project_create_service.rb
index c17c0a033fe..5809315a066 100644
--- a/app/services/authorized_project_update/project_create_service.rb
+++ b/app/services/authorized_project_update/project_create_service.rb
@@ -21,7 +21,7 @@ module AuthorizedProjectUpdate
{ user_id: member.user_id, project_id: project.id, access_level: member.access_level }
end
- ProjectAuthorization.insert_all(attributes)
+ ProjectAuthorization.insert_all(attributes) unless attributes.empty?
end
ServiceResponse.success
diff --git a/app/services/authorized_project_update/project_group_link_create_service.rb b/app/services/authorized_project_update/project_group_link_create_service.rb
new file mode 100644
index 00000000000..db2db091374
--- /dev/null
+++ b/app/services/authorized_project_update/project_group_link_create_service.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module AuthorizedProjectUpdate
+ class ProjectGroupLinkCreateService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ BATCH_SIZE = 1000
+
+ def initialize(project, group)
+ @project = project
+ @group = group
+ end
+
+ def execute
+ group.members_from_self_and_ancestors_with_effective_access_level
+ .each_batch(of: BATCH_SIZE, column: :user_id) do |members|
+ existing_authorizations = existing_project_authorizations(members)
+ authorizations_to_create = []
+ user_ids_to_delete = []
+
+ members.each do |member|
+ existing_access_level = existing_authorizations[member.user_id]
+
+ if existing_access_level
+ # User might already have access to the project unrelated to the
+ # current project share
+ next if existing_access_level >= member.access_level
+
+ user_ids_to_delete << member.user_id
+ end
+
+ authorizations_to_create << { user_id: member.user_id,
+ project_id: project.id,
+ access_level: member.access_level }
+ end
+
+ update_authorizations(user_ids_to_delete, authorizations_to_create)
+ end
+
+ ServiceResponse.success
+ end
+
+ private
+
+ attr_reader :project, :group
+
+ def existing_project_authorizations(members)
+ user_ids = members.map(&:user_id)
+
+ ProjectAuthorization.where(project_id: project.id, user_id: user_ids) # rubocop: disable CodeReuse/ActiveRecord
+ .select(:user_id, :access_level)
+ .each_with_object({}) do |authorization, hash|
+ hash[authorization.user_id] = authorization.access_level
+ end
+ end
+
+ def update_authorizations(user_ids_to_delete, authorizations_to_create)
+ ProjectAuthorization.transaction do
+ if user_ids_to_delete.any?
+ ProjectAuthorization.where(project_id: project.id, user_id: user_ids_to_delete) # rubocop: disable CodeReuse/ActiveRecord
+ .delete_all
+ end
+
+ if authorizations_to_create.any?
+ ProjectAuthorization.insert_all(authorizations_to_create)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb
index c4109765a1c..5c63dc34cb1 100644
--- a/app/services/auto_merge/base_service.rb
+++ b/app/services/auto_merge/base_service.rb
@@ -11,7 +11,7 @@ module AutoMerge
yield if block_given?
end
- # Notify the event that auto merge is enabled or merge param is updated
+ notify(merge_request)
AutoMergeProcessWorker.perform_async(merge_request.id)
strategy.to_sym
@@ -62,6 +62,10 @@ module AutoMerge
private
+ # Overridden in child classes
+ def notify(merge_request)
+ end
+
def strategy
strong_memoize(:strategy) do
self.class.name.demodulize.remove('Service').underscore
diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
index 9ae5bd1b5ec..7e0298432ac 100644
--- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
+++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
@@ -34,5 +34,13 @@ module AutoMerge
merge_request.actual_head_pipeline&.active?
end
end
+
+ private
+
+ def notify(merge_request)
+ return unless Feature.enabled?(:mwps_notification, project)
+
+ notification_service.async.merge_when_pipeline_succeeds(merge_request, current_user) if merge_request.saved_change_to_auto_merge_enabled?
+ end
end
end
diff --git a/app/services/branches/delete_service.rb b/app/services/branches/delete_service.rb
index ca2b4556b58..9bd5b343448 100644
--- a/app/services/branches/delete_service.rb
+++ b/app/services/branches/delete_service.rb
@@ -19,6 +19,7 @@ module Branches
end
if repository.rm_branch(current_user, branch_name)
+ unlock_artifacts(branch_name)
ServiceResponse.success(message: 'Branch was deleted')
else
ServiceResponse.error(
@@ -28,5 +29,11 @@ module Branches
rescue Gitlab::Git::PreReceiveError => ex
ServiceResponse.error(message: ex.message, http_status: 400)
end
+
+ private
+
+ def unlock_artifacts(branch_name)
+ Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, "#{::Gitlab::Git::BRANCH_REF_PREFIX}#{branch_name}")
+ end
end
end
diff --git a/app/services/ci/authorize_job_artifact_service.rb b/app/services/ci/authorize_job_artifact_service.rb
deleted file mode 100644
index 893e92d427c..00000000000
--- a/app/services/ci/authorize_job_artifact_service.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class AuthorizeJobArtifactService
- include Gitlab::Utils::StrongMemoize
-
- # Max size of the zipped LSIF artifact
- LSIF_ARTIFACT_MAX_SIZE = 20.megabytes
- LSIF_ARTIFACT_TYPE = 'lsif'
-
- def initialize(job, params, max_size:)
- @job = job
- @max_size = max_size
- @size = params[:filesize]
- @type = params[:artifact_type].to_s
- end
-
- def forbidden?
- lsif? && !code_navigation_enabled?
- end
-
- def too_large?
- size && max_size <= size.to_i
- end
-
- def headers
- default_headers = JobArtifactUploader.workhorse_authorize(has_length: false, maximum_size: max_size)
- default_headers.tap do |h|
- h[:ProcessLsif] = true if lsif? && code_navigation_enabled?
- end
- end
-
- private
-
- attr_reader :job, :size, :type
-
- def code_navigation_enabled?
- strong_memoize(:code_navigation_enabled) do
- Feature.enabled?(:code_navigation, job.project, default_enabled: true)
- end
- end
-
- def lsif?
- strong_memoize(:lsif) do
- type == LSIF_ARTIFACT_TYPE
- end
- end
-
- def max_size
- lsif? ? LSIF_ARTIFACT_MAX_SIZE : @max_size.to_i
- end
- end
-end
diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb
index f0ffe67510b..9a6e103e5dd 100644
--- a/app/services/ci/create_job_artifacts_service.rb
+++ b/app/services/ci/create_job_artifacts_service.rb
@@ -3,42 +3,104 @@
module Ci
class CreateJobArtifactsService < ::BaseService
ArtifactsExistError = Class.new(StandardError)
+
+ LSIF_ARTIFACT_TYPE = 'lsif'
+
OBJECT_STORAGE_ERRORS = [
Errno::EIO,
Google::Apis::ServerError,
Signet::RemoteServerError
].freeze
- def execute(job, artifacts_file, params, metadata_file: nil)
- return success if sha256_matches_existing_artifact?(job, params['artifact_type'], artifacts_file)
+ def initialize(job)
+ @job = job
+ @project = job.project
+ end
+
+ def authorize(artifact_type:, filesize: nil)
+ result = validate_requirements(artifact_type: artifact_type, filesize: filesize)
+ return result unless result[:status] == :success
+
+ headers = JobArtifactUploader.workhorse_authorize(has_length: false, maximum_size: max_size(artifact_type))
- artifact, artifact_metadata = build_artifact(job, artifacts_file, params, metadata_file)
- result = parse_artifact(job, artifact)
+ if lsif?(artifact_type)
+ headers[:ProcessLsif] = true
+ headers[:ProcessLsifReferences] = Feature.enabled?(:code_navigation_references, project, default_enabled: false)
+ end
+ success(headers: headers)
+ end
+
+ def execute(artifacts_file, params, metadata_file: nil)
+ result = validate_requirements(artifact_type: params[:artifact_type], filesize: artifacts_file.size)
return result unless result[:status] == :success
- persist_artifact(job, artifact, artifact_metadata)
+ return success if sha256_matches_existing_artifact?(params[:artifact_type], artifacts_file)
+
+ artifact, artifact_metadata = build_artifact(artifacts_file, params, metadata_file)
+ result = parse_artifact(artifact)
+
+ return result unless result[:status] == :success
+
+ persist_artifact(artifact, artifact_metadata, params)
end
private
- def build_artifact(job, artifacts_file, params, metadata_file)
+ attr_reader :job, :project
+
+ def validate_requirements(artifact_type:, filesize:)
+ return forbidden_type_error(artifact_type) if forbidden_type?(artifact_type)
+ return too_large_error if too_large?(artifact_type, filesize)
+
+ success
+ end
+
+ def forbidden_type?(type)
+ lsif?(type) && !code_navigation_enabled?
+ end
+
+ def too_large?(type, size)
+ size > max_size(type) if size
+ end
+
+ def code_navigation_enabled?
+ Feature.enabled?(:code_navigation, project, default_enabled: true)
+ end
+
+ def lsif?(type)
+ type == LSIF_ARTIFACT_TYPE
+ end
+
+ def max_size(type)
+ Ci::JobArtifact.max_artifact_size(type: type, project: project)
+ end
+
+ def forbidden_type_error(type)
+ error("#{type} artifacts are forbidden", :forbidden)
+ end
+
+ def too_large_error
+ error('file size has reached maximum size limit', :payload_too_large)
+ end
+
+ def build_artifact(artifacts_file, params, metadata_file)
expire_in = params['expire_in'] ||
Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in
artifact = Ci::JobArtifact.new(
job_id: job.id,
- project: job.project,
+ project: project,
file: artifacts_file,
- file_type: params['artifact_type'],
- file_format: params['artifact_format'],
+ file_type: params[:artifact_type],
+ file_format: params[:artifact_format],
file_sha256: artifacts_file.sha256,
expire_in: expire_in)
artifact_metadata = if metadata_file
Ci::JobArtifact.new(
job_id: job.id,
- project: job.project,
+ project: project,
file: metadata_file,
file_type: :metadata,
file_format: :gzip,
@@ -46,31 +108,25 @@ module Ci
expire_in: expire_in)
end
- if Feature.enabled?(:keep_latest_artifact_for_ref, job.project)
- artifact.locked = true
- artifact_metadata&.locked = true
- end
-
[artifact, artifact_metadata]
end
- def parse_artifact(job, artifact)
- unless Feature.enabled?(:ci_synchronous_artifact_parsing, job.project, default_enabled: true)
+ def parse_artifact(artifact)
+ unless Feature.enabled?(:ci_synchronous_artifact_parsing, project, default_enabled: true)
return success
end
case artifact.file_type
- when 'dotenv' then parse_dotenv_artifact(job, artifact)
- when 'cluster_applications' then parse_cluster_applications_artifact(job, artifact)
+ when 'dotenv' then parse_dotenv_artifact(artifact)
+ when 'cluster_applications' then parse_cluster_applications_artifact(artifact)
else success
end
end
- def persist_artifact(job, artifact, artifact_metadata)
+ def persist_artifact(artifact, artifact_metadata, params)
Ci::JobArtifact.transaction do
artifact.save!
artifact_metadata&.save!
- unlock_previous_artifacts!(artifact)
# NOTE: The `artifacts_expire_at` column is already deprecated and to be removed in the near future.
job.update_column(:artifacts_expire_at, artifact.expire_at)
@@ -78,42 +134,36 @@ module Ci
success
rescue ActiveRecord::RecordNotUnique => error
- track_exception(error, job, params)
+ track_exception(error, params)
error('another artifact of the same type already exists', :bad_request)
rescue *OBJECT_STORAGE_ERRORS => error
- track_exception(error, job, params)
+ track_exception(error, params)
error(error.message, :service_unavailable)
rescue => error
- track_exception(error, job, params)
+ track_exception(error, params)
error(error.message, :bad_request)
end
- def unlock_previous_artifacts!(artifact)
- return unless Feature.enabled?(:keep_latest_artifact_for_ref, artifact.job.project)
-
- Ci::JobArtifact.for_ref(artifact.job.ref, artifact.project_id).locked.update_all(locked: false)
- end
-
- def sha256_matches_existing_artifact?(job, artifact_type, artifacts_file)
+ def sha256_matches_existing_artifact?(artifact_type, artifacts_file)
existing_artifact = job.job_artifacts.find_by_file_type(artifact_type)
return false unless existing_artifact
existing_artifact.file_sha256 == artifacts_file.sha256
end
- def track_exception(error, job, params)
+ def track_exception(error, params)
Gitlab::ErrorTracking.track_exception(error,
job_id: job.id,
project_id: job.project_id,
- uploading_type: params['artifact_type']
+ uploading_type: params[:artifact_type]
)
end
- def parse_dotenv_artifact(job, artifact)
- Ci::ParseDotenvArtifactService.new(job.project, current_user).execute(artifact)
+ def parse_dotenv_artifact(artifact)
+ Ci::ParseDotenvArtifactService.new(project, current_user).execute(artifact)
end
- def parse_cluster_applications_artifact(job, artifact)
+ def parse_cluster_applications_artifact(artifact)
Clusters::ParseClusterApplicationsArtifactService.new(job, job.user).execute(artifact)
end
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 922c3556362..2d7f5014aa9 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -23,6 +23,24 @@ module Ci
Gitlab::Ci::Pipeline::Chain::Limit::Activity,
Gitlab::Ci::Pipeline::Chain::Limit::JobActivity].freeze
+ # Create a new pipeline in the specified project.
+ #
+ # @param [Symbol] source What event (Ci::Pipeline.sources) triggers the pipeline
+ # creation.
+ # @param [Boolean] ignore_skip_ci Whether skipping a pipeline creation when `[skip ci]` comment
+ # is present in the commit body
+ # @param [Boolean] save_on_errors Whether persisting an invalid pipeline when it encounters an
+ # error during creation (e.g. invalid yaml)
+ # @param [Ci::TriggerRequest] trigger_request The pipeline trigger triggers the pipeline creation.
+ # @param [Ci::PipelineSchedule] schedule The pipeline schedule triggers the pipeline creation.
+ # @param [MergeRequest] merge_request The merge request triggers the pipeline creation.
+ # @param [ExternalPullRequest] external_pull_request The external pull request triggers the pipeline creation.
+ # @param [Ci::Bridge] bridge The bridge job that triggers the downstream pipeline creation.
+ # @param [String] content The content of .gitlab-ci.yml to override the default config
+ # contents (e.g. .gitlab-ci.yml in repostiry). Mainly used for
+ # generating a dangling pipeline.
+ #
+ # @return [Ci::Pipeline] The created Ci::Pipeline object.
# rubocop: disable Metrics/ParameterLists
def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, bridge: nil, **options, &block)
@pipeline = Ci::Pipeline.new
@@ -77,7 +95,7 @@ module Ci
def execute!(*args, &block)
execute(*args, &block).tap do |pipeline|
unless pipeline.persisted?
- raise CreateError, pipeline.error_messages
+ raise CreateError, pipeline.full_error_messages
end
end
end
@@ -122,13 +140,8 @@ module Ci
end
end
- def extra_options(options = {})
- # In Ruby 2.4, even when options is empty, f(**options) doesn't work when f
- # doesn't have any parameters. We reproduce the Ruby 2.5 behavior by
- # checking explicitly that no arguments are given.
- raise ArgumentError if options.any?
-
- {} # overridden in EE
+ def extra_options(content: nil)
+ { content: content }
end
end
end
diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb
index 5deb84812ac..1fa8926faa1 100644
--- a/app/services/ci/destroy_expired_job_artifacts_service.rb
+++ b/app/services/ci/destroy_expired_job_artifacts_service.rb
@@ -28,7 +28,7 @@ module Ci
private
def destroy_batch
- artifact_batch = if Feature.enabled?(:keep_latest_artifact_for_ref)
+ artifact_batch = if Gitlab::Ci::Features.destroy_only_unlocked_expired_artifacts_enabled?
Ci::JobArtifact.expired(BATCH_SIZE).unlocked
else
Ci::JobArtifact.expired(BATCH_SIZE)
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
index b01a9d2e3b8..a23d5d8941a 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -77,7 +77,7 @@ module Ci
def update_processable!(processable)
status = processable_status(processable)
- return unless HasStatus::COMPLETED_STATUSES.include?(status)
+ return unless Ci::HasStatus::COMPLETED_STATUSES.include?(status)
# transition status if possible
Gitlab::OptimisticLocking.retry_lock(processable) do |subject|
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
index 2228328882d..d0aa8b04775 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
@@ -80,7 +80,7 @@ module Ci
# TODO: This is hack to support
# the same exact behaviour for Atomic and Legacy processing
# that DAG is blocked from executing if dependent is not "complete"
- if dag && statuses.any? { |status| HasStatus::COMPLETED_STATUSES.exclude?(status[:status]) }
+ if dag && statuses.any? { |status| Ci::HasStatus::COMPLETED_STATUSES.exclude?(status[:status]) }
return 'pending'
end
diff --git a/app/services/ci/pipeline_processing/legacy_processing_service.rb b/app/services/ci/pipeline_processing/legacy_processing_service.rb
index c471f7f0011..56fbc7271da 100644
--- a/app/services/ci/pipeline_processing/legacy_processing_service.rb
+++ b/app/services/ci/pipeline_processing/legacy_processing_service.rb
@@ -35,7 +35,7 @@ module Ci
def process_stage_for_stage_scheduling(index)
current_status = status_for_prior_stages(index)
- return unless HasStatus::COMPLETED_STATUSES.include?(current_status)
+ return unless Ci::HasStatus::COMPLETED_STATUSES.include?(current_status)
created_stage_scheduled_processables_in_stage(index).find_each.select do |build|
process_build(build, current_status)
@@ -73,7 +73,7 @@ module Ci
def process_dag_build_with_needs(build)
current_status = status_for_build_needs(build.needs.map(&:name))
- return unless HasStatus::COMPLETED_STATUSES.include?(current_status)
+ return unless Ci::HasStatus::COMPLETED_STATUSES.include?(current_status)
process_build(build, current_status)
end
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index 80ebe5f5eb6..1f24dce0458 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -9,6 +9,8 @@ module Ci
end
def execute(trigger_build_ids = nil, initial_process: false)
+ increment_processing_counter
+
update_retried
if ::Gitlab::Ci::Features.atomic_processing?(pipeline.project)
@@ -22,6 +24,10 @@ module Ci
end
end
+ def metrics
+ @metrics ||= ::Gitlab::Ci::Pipeline::Metrics.new
+ end
+
private
# This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab
@@ -43,5 +49,9 @@ module Ci
.update_all(retried: true) if latest_statuses.any?
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def increment_processing_counter
+ metrics.pipeline_processing_events_counter.increment
+ end
end
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 17b9e56636b..3797ea1d96c 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -11,7 +11,7 @@ module Ci
METRICS_SHARD_TAG_PREFIX = 'metrics_shard::'.freeze
DEFAULT_METRICS_SHARD = 'default'.freeze
- Result = Struct.new(:build, :valid?)
+ Result = Struct.new(:build, :build_json, :valid?)
def initialize(runner)
@runner = runner
@@ -59,7 +59,7 @@ module Ci
end
register_failure
- Result.new(nil, valid)
+ Result.new(nil, nil, valid)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -71,7 +71,7 @@ module Ci
# In case when 2 runners try to assign the same build, second runner will be declined
# with StateMachines::InvalidTransition or StaleObjectError when doing run! or save method.
if assign_runner!(build, params)
- Result.new(build, true)
+ present_build!(build)
end
rescue StateMachines::InvalidTransition, ActiveRecord::StaleObjectError
# We are looping to find another build that is not conflicting
@@ -83,8 +83,10 @@ module Ci
# In case we hit the concurrency-access lock,
# we still have to return 409 in the end,
# to make sure that this is properly handled by runner.
- Result.new(nil, false)
+ Result.new(nil, nil, false)
rescue => ex
+ # If an error (e.g. GRPC::DeadlineExceeded) occurred constructing
+ # the result, consider this as a failure to be retried.
scheduler_failure!(build)
track_exception_for_build(ex, build)
@@ -92,6 +94,15 @@ module Ci
nil
end
+ # Force variables evaluation to occur now
+ def present_build!(build)
+ # We need to use the presenter here because Gitaly calls in the presenter
+ # may fail, and we need to ensure the response has been generated.
+ presented_build = ::Ci::BuildRunnerPresenter.new(build) # rubocop:disable CodeReuse/Presenter
+ build_json = ::API::Entities::JobRequest::Response.new(presented_build).to_json
+ Result.new(build, build_json, true)
+ end
+
def assign_runner!(build, params)
build.runner_id = runner.id
build.runner_session_attributes = params[:session] if params[:session].present?
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 23507a31c72..60b3d28b0c5 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -34,10 +34,6 @@ module Ci
attributes[:user] = current_user
- # TODO: we can probably remove this logic
- # see: https://gitlab.com/gitlab-org/gitlab/-/issues/217930
- attributes[:scheduling_type] ||= build.find_legacy_scheduling_type
-
Ci::Build.transaction do
# mark all other builds of that name as retried
build.pipeline.builds.latest
@@ -59,7 +55,9 @@ module Ci
build = project.builds.new(attributes)
build.assign_attributes(::Gitlab::Ci::Pipeline::Seed::Build.environment_attributes_for(build))
build.retried = false
- build.save!
+ BulkInsertableAssociations.with_bulk_insert(enabled: ::Gitlab::Ci::Features.bulk_insert_on_create?(project)) do
+ build.save!
+ end
build
end
end
diff --git a/app/services/ci/unlock_artifacts_service.rb b/app/services/ci/unlock_artifacts_service.rb
new file mode 100644
index 00000000000..07faf90dd6d
--- /dev/null
+++ b/app/services/ci/unlock_artifacts_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Ci
+ class UnlockArtifactsService < ::BaseService
+ BATCH_SIZE = 100
+
+ def execute(ci_ref, before_pipeline = nil)
+ query = <<~SQL.squish
+ UPDATE "ci_pipelines"
+ SET "locked" = #{::Ci::Pipeline.lockeds[:unlocked]}
+ WHERE "ci_pipelines"."id" in (
+ #{collect_pipelines(ci_ref, before_pipeline).select(:id).to_sql}
+ LIMIT #{BATCH_SIZE}
+ FOR UPDATE SKIP LOCKED
+ )
+ RETURNING "ci_pipelines"."id";
+ SQL
+
+ loop do
+ break if ActiveRecord::Base.connection.exec_query(query).empty?
+ end
+ end
+
+ private
+
+ def collect_pipelines(ci_ref, before_pipeline)
+ pipeline_scope = ci_ref.pipelines
+ pipeline_scope = pipeline_scope.before_pipeline(before_pipeline) if before_pipeline
+
+ pipeline_scope.artifacts_locked
+ end
+ end
+end
diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb
index 7b5bf6b32c2..6693a58683f 100644
--- a/app/services/clusters/create_service.rb
+++ b/app/services/clusters/create_service.rb
@@ -19,10 +19,6 @@ module Clusters
cluster = Clusters::Cluster.new(cluster_params)
- unless can_create_cluster?
- cluster.errors.add(:base, _('Instance does not support multiple Kubernetes clusters'))
- end
-
validate_management_project_permissions(cluster)
return cluster if cluster.errors.present?
@@ -55,16 +51,9 @@ module Clusters
end
end
- # EE would override this method
- def can_create_cluster?
- clusterable.clusters.empty?
- end
-
def validate_management_project_permissions(cluster)
Clusters::Management::ValidateManagementProjectPermissionsService.new(current_user)
.execute(cluster, params[:management_project_id])
end
end
end
-
-Clusters::CreateService.prepend_if_ee('EE::Clusters::CreateService')
diff --git a/app/services/clusters/parse_cluster_applications_artifact_service.rb b/app/services/clusters/parse_cluster_applications_artifact_service.rb
index 35fba5f47c7..6a0ca0ef9d0 100644
--- a/app/services/clusters/parse_cluster_applications_artifact_service.rb
+++ b/app/services/clusters/parse_cluster_applications_artifact_service.rb
@@ -5,7 +5,7 @@ module Clusters
include Gitlab::Utils::StrongMemoize
MAX_ACCEPTABLE_ARTIFACT_SIZE = 5.kilobytes
- RELEASE_NAMES = %w[prometheus].freeze
+ RELEASE_NAMES = %w[prometheus cilium].freeze
def initialize(job, current_user)
@job = job
diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb
index 4678d051d29..a58e9aefcec 100644
--- a/app/services/concerns/exclusive_lease_guard.rb
+++ b/app/services/concerns/exclusive_lease_guard.rb
@@ -21,7 +21,7 @@ module ExclusiveLeaseGuard
lease = exclusive_lease.try_obtain
unless lease
- log_error('Cannot obtain an exclusive lease. There must be another instance already in execution.')
+ log_error("Cannot obtain an exclusive lease for #{self.class.name}. There must be another instance already in execution.")
return
end
diff --git a/app/services/concerns/incident_management/settings.rb b/app/services/concerns/incident_management/settings.rb
index 5f56d6e7f53..491bd4fa6bf 100644
--- a/app/services/concerns/incident_management/settings.rb
+++ b/app/services/concerns/incident_management/settings.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module IncidentManagement
module Settings
+ include Gitlab::Utils::StrongMemoize
+
def incident_management_setting
strong_memoize(:incident_management_setting) do
project.incident_management_setting ||
diff --git a/app/services/deploy_keys/collect_keys_service.rb b/app/services/deploy_keys/collect_keys_service.rb
new file mode 100644
index 00000000000..2ef49bf0f30
--- /dev/null
+++ b/app/services/deploy_keys/collect_keys_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module DeployKeys
+ class CollectKeysService
+ def initialize(project, current_user)
+ @project = project
+ @current_user = current_user
+ end
+
+ def execute
+ return [] unless current_user && project && user_can_read_project
+
+ project.deploy_keys_projects
+ .with_deploy_keys
+ .with_write_access
+ .map(&:deploy_key)
+ end
+
+ private
+
+ def user_can_read_project
+ Ability.allowed?(current_user, :read_project, project)
+ end
+
+ attr_reader :project, :current_user
+ end
+end
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index 89c3225dbcd..ad36fe70b3a 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -11,44 +11,30 @@ class EventCreateService
IllegalActionError = Class.new(StandardError)
def open_issue(issue, current_user)
- create_resource_event(issue, current_user, :opened)
-
create_record_event(issue, current_user, :created)
end
def close_issue(issue, current_user)
- create_resource_event(issue, current_user, :closed)
-
create_record_event(issue, current_user, :closed)
end
def reopen_issue(issue, current_user)
- create_resource_event(issue, current_user, :reopened)
-
create_record_event(issue, current_user, :reopened)
end
def open_mr(merge_request, current_user)
- create_resource_event(merge_request, current_user, :opened)
-
create_record_event(merge_request, current_user, :created)
end
def close_mr(merge_request, current_user)
- create_resource_event(merge_request, current_user, :closed)
-
create_record_event(merge_request, current_user, :closed)
end
def reopen_mr(merge_request, current_user)
- create_resource_event(merge_request, current_user, :reopened)
-
create_record_event(merge_request, current_user, :reopened)
end
def merge_mr(merge_request, current_user)
- create_resource_event(merge_request, current_user, :merged)
-
create_record_event(merge_request, current_user, :merged)
end
@@ -97,23 +83,13 @@ class EventCreateService
end
def save_designs(current_user, create: [], update: [])
- created = create.group_by(&:project).flat_map do |project, designs|
- Feature.enabled?(:design_activity_events, project) ? designs : []
- end.to_set
- updated = update.group_by(&:project).flat_map do |project, designs|
- Feature.enabled?(:design_activity_events, project) ? designs : []
- end.to_set
- return [] if created.empty? && updated.empty?
-
- records = created.zip([:created].cycle) + updated.zip([:updated].cycle)
+ records = create.zip([:created].cycle) + update.zip([:updated].cycle)
+ return [] if records.empty?
create_record_events(records, current_user)
end
def destroy_designs(designs, current_user)
- designs = designs.select do |design|
- Feature.enabled?(:design_activity_events, design.project)
- end
return [] unless designs.present?
create_record_events(designs.zip([:destroyed].cycle), current_user)
@@ -127,8 +103,6 @@ class EventCreateService
#
# @return a tuple of event and either :found or :created
def wiki_event(wiki_page_meta, author, action)
- return unless Feature.enabled?(:wiki_events)
-
raise IllegalActionError, action unless Event::WIKI_ACTIONS.include?(action)
if duplicate = existing_wiki_event(wiki_page_meta, action)
@@ -142,9 +116,15 @@ class EventCreateService
event.update_columns(updated_at: time_stamp, created_at: time_stamp)
end
+ Gitlab::UsageDataCounters::TrackUniqueActions.track_action(event_action: action, event_target: wiki_page_meta.class, author_id: author.id)
+
event
end
+ def approve_mr(merge_request, current_user)
+ create_record_event(merge_request, current_user, :approved)
+ end
+
private
def existing_wiki_event(wiki_page_meta, action)
@@ -182,7 +162,13 @@ class EventCreateService
.merge(action: action, target_id: record.id, target_type: record.class.name)
end
- Event.insert_all(attribute_sets, returning: %w[id])
+ result = Event.insert_all(attribute_sets, returning: %w[id])
+
+ pairs.each do |record, status|
+ Gitlab::UsageDataCounters::TrackUniqueActions.track_action(event_action: status, event_target: record.class, author_id: current_user.id)
+ end
+
+ result
end
def create_push_event(service_class, project, current_user, push_data)
@@ -197,6 +183,8 @@ class EventCreateService
new_event
end
+ Gitlab::UsageDataCounters::TrackUniqueActions.track_action(event_action: :pushed, event_target: Project, author_id: current_user.id)
+
Users::LastPushEventService.new(current_user)
.cache_last_push_event(event)
@@ -225,18 +213,6 @@ class EventCreateService
{ resource_parent_attr => resource_parent.id }
end
-
- def create_resource_event(issuable, current_user, status)
- return unless state_change_tracking_enabled?(issuable)
-
- ResourceEvents::ChangeStateService.new(resource: issuable, user: current_user)
- .execute(status)
- end
-
- def state_change_tracking_enabled?(issuable)
- issuable&.respond_to?(:resource_state_events) &&
- ::Feature.enabled?(:track_resource_state_change_events, issuable&.project)
- end
end
EventCreateService.prepend_if_ee('EE::EventCreateService')
diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb
index 39e614d6569..d42f718a272 100644
--- a/app/services/files/base_service.rb
+++ b/app/services/files/base_service.rb
@@ -25,7 +25,7 @@ module Files
return false unless commit_id
last_commit = Gitlab::Git::Commit
- .last_for_path(@start_project.repository, @start_branch, path)
+ .last_for_path(@start_project.repository, @start_branch, path, literal_pathspec: true)
return false unless last_commit
diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb
index 5c1ee981d0c..2ec6ac99ece 100644
--- a/app/services/git/branch_push_service.rb
+++ b/app/services/git/branch_push_service.rb
@@ -29,6 +29,7 @@ module Git
perform_housekeeping
stop_environments
+ unlock_artifacts
true
end
@@ -60,6 +61,12 @@ module Git
Ci::StopEnvironmentsService.new(project, current_user).execute(branch_name)
end
+ def unlock_artifacts
+ return unless removing_branch?
+
+ Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, ref)
+ end
+
def execute_related_hooks
BranchHooksService.new(project, current_user, params).execute
end
diff --git a/app/services/git/tag_push_service.rb b/app/services/git/tag_push_service.rb
index 9a266f7d74c..120c4cde94b 100644
--- a/app/services/git/tag_push_service.rb
+++ b/app/services/git/tag_push_service.rb
@@ -10,7 +10,25 @@ module Git
project.repository.before_push_tag
TagHooksService.new(project, current_user, params).execute
+ unlock_artifacts
+
true
end
+
+ private
+
+ def unlock_artifacts
+ return unless removing_tag?
+
+ Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, ref)
+ end
+
+ def removing_tag?
+ Gitlab::Git.blank_ref?(newrev)
+ end
+
+ def tag_name
+ Gitlab::Git.ref_name(ref)
+ end
end
end
diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb
index 8bdbc28f3e8..b3937a10a70 100644
--- a/app/services/git/wiki_push_service.rb
+++ b/app/services/git/wiki_push_service.rb
@@ -23,7 +23,7 @@ module Git
end
def can_process_wiki_events?
- Feature.enabled?(:wiki_events) && Feature.enabled?(:wiki_events_on_git_push, project)
+ Feature.enabled?(:wiki_events_on_git_push, project)
end
def push_changes
diff --git a/app/services/gpg_keys/destroy_service.rb b/app/services/gpg_keys/destroy_service.rb
new file mode 100644
index 00000000000..cecbfe26611
--- /dev/null
+++ b/app/services/gpg_keys/destroy_service.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module GpgKeys
+ class DestroyService < Keys::BaseService
+ def execute(key)
+ key.destroy
+ end
+ end
+end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index eb1b8d4fcc0..ce583095168 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -28,7 +28,11 @@ module Groups
@group.build_chat_team(name: response['name'], team_id: response['id'])
end
- @group.add_owner(current_user) if @group.save
+ if @group.save
+ @group.add_owner(current_user)
+ add_settings_record
+ end
+
@group
end
@@ -79,6 +83,10 @@ module Groups
params[:visibility_level] = Gitlab::CurrentSettings.current_application_settings.default_group_visibility
end
+
+ def add_settings_record
+ @group.create_namespace_settings
+ end
end
end
diff --git a/app/services/groups/update_shared_runners_service.rb b/app/services/groups/update_shared_runners_service.rb
new file mode 100644
index 00000000000..63f57104510
--- /dev/null
+++ b/app/services/groups/update_shared_runners_service.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Groups
+ class UpdateSharedRunnersService < Groups::BaseService
+ def execute
+ return error('Operation not allowed', 403) unless can?(current_user, :admin_group, group)
+
+ validate_params
+
+ enable_or_disable_shared_runners!
+ allow_or_disallow_descendants_override_disabled_shared_runners!
+
+ success
+
+ rescue Group::UpdateSharedRunnersError => error
+ error(error.message)
+ end
+
+ private
+
+ def validate_params
+ if Gitlab::Utils.to_boolean(params[:shared_runners_enabled]) && !params[:allow_descendants_override_disabled_shared_runners].nil?
+ raise Group::UpdateSharedRunnersError, 'Cannot set shared_runners_enabled to true and allow_descendants_override_disabled_shared_runners'
+ end
+ end
+
+ def enable_or_disable_shared_runners!
+ return if params[:shared_runners_enabled].nil?
+
+ if Gitlab::Utils.to_boolean(params[:shared_runners_enabled])
+ group.enable_shared_runners!
+ else
+ group.disable_shared_runners!
+ end
+ end
+
+ def allow_or_disallow_descendants_override_disabled_shared_runners!
+ return if params[:allow_descendants_override_disabled_shared_runners].nil?
+
+ # Needs to reset group because if both params are present could result in error
+ group.reset
+
+ if Gitlab::Utils.to_boolean(params[:allow_descendants_override_disabled_shared_runners])
+ group.allow_descendants_override_disabled_shared_runners!
+ else
+ group.disallow_descendants_override_disabled_shared_runners!
+ end
+ end
+ end
+end
diff --git a/app/services/import/bitbucket_server_service.rb b/app/services/import/bitbucket_server_service.rb
new file mode 100644
index 00000000000..86e8215821e
--- /dev/null
+++ b/app/services/import/bitbucket_server_service.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+module Import
+ class BitbucketServerService < Import::BaseService
+ attr_reader :client, :params, :current_user
+
+ def execute(credentials)
+ if blocked_url?
+ return log_and_return_error("Invalid URL: #{url}", :bad_request)
+ end
+
+ unless authorized?
+ return log_and_return_error("You don't have permissions to create this project", :unauthorized)
+ end
+
+ unless repo
+ return log_and_return_error("Project %{project_repo} could not be found" % { project_repo: "#{project_key}/#{repo_slug}" }, :unprocessable_entity)
+ end
+
+ project = create_project(credentials)
+
+ if project.persisted?
+ success(project)
+ else
+ log_and_return_error(project_save_error(project), :unprocessable_entity)
+ end
+ rescue BitbucketServer::Connection::ConnectionError => e
+ log_and_return_error("Import failed due to a BitBucket Server error: #{e}", :bad_request)
+ end
+
+ private
+
+ def create_project(credentials)
+ Gitlab::BitbucketServerImport::ProjectCreator.new(
+ project_key,
+ repo_slug,
+ repo,
+ project_name,
+ target_namespace,
+ current_user,
+ credentials
+ ).execute
+ end
+
+ def repo
+ @repo ||= client.repo(project_key, repo_slug)
+ end
+
+ def project_name
+ @project_name ||= params[:new_name].presence || repo.name
+ end
+
+ def namespace_path
+ @namespace_path ||= params[:new_namespace].presence || current_user.namespace_path
+ end
+
+ def target_namespace
+ @target_namespace ||= find_or_create_namespace(namespace_path, current_user.namespace_path)
+ end
+
+ def repo_slug
+ @repo_slug ||= params[:bitbucket_server_repo]
+ end
+
+ def project_key
+ @project_key ||= params[:bitbucket_server_project]
+ end
+
+ def url
+ @url ||= params[:bitbucket_server_url]
+ end
+
+ def authorized?
+ can?(current_user, :create_projects, target_namespace)
+ end
+
+ def allow_local_requests?
+ Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services?
+ end
+
+ def blocked_url?
+ Gitlab::UrlBlocker.blocked_url?(
+ url,
+ {
+ allow_localhost: allow_local_requests?,
+ allow_local_network: allow_local_requests?,
+ schemes: %w(http https)
+ }
+ )
+ end
+
+ def log_and_return_error(message, error_type)
+ log_error(message)
+ error(_(message), error_type)
+ end
+
+ def log_error(message)
+ Gitlab::Import::Logger.error(
+ message: 'Import failed due to a BitBucket Server error',
+ error: message
+ )
+ end
+ end
+end
diff --git a/app/services/incident_management/create_incident_label_service.rb b/app/services/incident_management/create_incident_label_service.rb
new file mode 100644
index 00000000000..dbd0d78fa3c
--- /dev/null
+++ b/app/services/incident_management/create_incident_label_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ class CreateIncidentLabelService < BaseService
+ LABEL_PROPERTIES = {
+ title: 'incident',
+ color: '#CC0033',
+ description: <<~DESCRIPTION.chomp
+ Denotes a disruption to IT services and \
+ the associated issues require immediate attention
+ DESCRIPTION
+ }.freeze
+
+ def execute
+ label = Labels::FindOrCreateService
+ .new(current_user, project, **LABEL_PROPERTIES)
+ .execute
+
+ if label.invalid?
+ log_invalid_label_info(label)
+ return ServiceResponse.error(payload: { label: label }, message: full_error_message(label))
+ end
+
+ ServiceResponse.success(payload: { label: label })
+ end
+
+ private
+
+ def log_invalid_label_info(label)
+ log_info <<~TEXT.chomp
+ Cannot create incident label "#{label.title}" \
+ for "#{label.project.full_name}": #{full_error_message(label)}.
+ TEXT
+ end
+
+ def full_error_message(label)
+ label.errors.full_messages.to_sentence
+ end
+ end
+end
diff --git a/app/services/incident_management/create_issue_service.rb b/app/services/incident_management/create_issue_service.rb
index 4b59dc64cec..5e1e0863115 100644
--- a/app/services/incident_management/create_issue_service.rb
+++ b/app/services/incident_management/create_issue_service.rb
@@ -4,21 +4,12 @@ module IncidentManagement
class CreateIssueService < BaseService
include Gitlab::Utils::StrongMemoize
- INCIDENT_LABEL = {
- title: 'incident',
- color: '#CC0033',
- description: <<~DESCRIPTION.chomp
- Denotes a disruption to IT services and \
- the associated issues require immediate attention
- DESCRIPTION
- }.freeze
-
- def initialize(project, params, user = User.alert_bot)
- super(project, user, params)
+ def initialize(project, params)
+ super(project, User.alert_bot, params)
end
- def execute(skip_settings_check: false)
- return error_with('setting disabled') unless skip_settings_check || incident_management_setting.create_issue?
+ def execute
+ return error_with('setting disabled') unless incident_management_setting.create_issue?
return error_with('invalid alert') unless alert.valid?
issue = create_issue
@@ -30,26 +21,19 @@ module IncidentManagement
private
def create_issue
- issue = do_create_issue(label_ids: issue_label_ids)
+ label_result = find_or_create_incident_label
- # Create an unlabelled issue if we couldn't create the issue
- # due to labels errors.
+ # Create an unlabelled issue if we couldn't create the label
+ # due to a race condition.
# See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042
- if issue.errors.include?(:labels)
- log_label_error(issue)
- issue = do_create_issue
- end
-
- issue
- end
+ extra_params = label_result.success? ? { label_ids: [label_result.payload[:label].id] } : {}
- def do_create_issue(**params)
Issues::CreateService.new(
project,
current_user,
title: issue_title,
description: issue_description,
- **params
+ **extra_params
).execute
end
@@ -67,16 +51,8 @@ module IncidentManagement
].compact.join(horizontal_line)
end
- def issue_label_ids
- [
- find_or_create_label(**INCIDENT_LABEL)
- ].compact.map(&:id)
- end
-
- def find_or_create_label(**params)
- Labels::FindOrCreateService
- .new(current_user, project, **params)
- .execute
+ def find_or_create_incident_label
+ IncidentManagement::CreateIncidentLabelService.new(project, current_user).execute
end
def alert_summary
@@ -108,15 +84,6 @@ module IncidentManagement
issue.errors.full_messages.to_sentence
end
- def log_label_error(issue)
- log_info <<~TEXT.chomp
- Cannot create incident issue with labels \
- #{issue.labels.map(&:title).inspect} \
- for "#{project.full_name}": #{issue.errors.full_messages.to_sentence}.
- Retrying without labels.
- TEXT
- end
-
def error_with(message)
log_error(%{Cannot create incident issue for "#{project.full_name}": #{message}})
diff --git a/app/services/incident_management/pager_duty/create_incident_issue_service.rb b/app/services/incident_management/pager_duty/create_incident_issue_service.rb
new file mode 100644
index 00000000000..ee0feb49e0d
--- /dev/null
+++ b/app/services/incident_management/pager_duty/create_incident_issue_service.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ module PagerDuty
+ class CreateIncidentIssueService < BaseService
+ include IncidentManagement::Settings
+
+ def initialize(project, incident_payload)
+ super(project, User.alert_bot, incident_payload)
+ end
+
+ def execute
+ return forbidden unless webhook_available?
+
+ issue = create_issue
+ return error(issue.errors.full_messages.to_sentence, issue) unless issue.valid?
+
+ success(issue)
+ end
+
+ private
+
+ alias_method :incident_payload, :params
+
+ def create_issue
+ label_result = find_or_create_incident_label
+
+ # Create an unlabelled issue if we couldn't create the label
+ # due to a race condition.
+ # See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042
+ extra_params = label_result.success? ? { label_ids: [label_result.payload[:label].id] } : {}
+
+ Issues::CreateService.new(
+ project,
+ current_user,
+ title: issue_title,
+ description: issue_description,
+ **extra_params
+ ).execute
+ end
+
+ def webhook_available?
+ Feature.enabled?(:pagerduty_webhook, project) &&
+ incident_management_setting.pagerduty_active?
+ end
+
+ def forbidden
+ ServiceResponse.error(message: 'Forbidden', http_status: :forbidden)
+ end
+
+ def find_or_create_incident_label
+ ::IncidentManagement::CreateIncidentLabelService.new(project, current_user).execute
+ end
+
+ def issue_title
+ incident_payload['title']
+ end
+
+ def issue_description
+ Gitlab::IncidentManagement::PagerDuty::IncidentIssueDescription.new(incident_payload).to_s
+ end
+
+ def success(issue)
+ ServiceResponse.success(payload: { issue: issue })
+ end
+
+ def error(message, issue = nil)
+ ServiceResponse.error(payload: { issue: issue }, message: message)
+ end
+ end
+ end
+end
diff --git a/app/services/incident_management/pager_duty/process_webhook_service.rb b/app/services/incident_management/pager_duty/process_webhook_service.rb
new file mode 100644
index 00000000000..5dd3186694a
--- /dev/null
+++ b/app/services/incident_management/pager_duty/process_webhook_service.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ module PagerDuty
+ class ProcessWebhookService < BaseService
+ include Gitlab::Utils::StrongMemoize
+ include IncidentManagement::Settings
+
+ # https://developer.pagerduty.com/docs/webhooks/webhook-behavior/#size-limit
+ PAGER_DUTY_PAYLOAD_SIZE_LIMIT = 55.kilobytes
+
+ # https://developer.pagerduty.com/docs/webhooks/v2-overview/#webhook-types
+ PAGER_DUTY_PROCESSABLE_EVENT_TYPES = %w(incident.trigger).freeze
+
+ def execute(token)
+ return forbidden unless webhook_setting_active?
+ return unauthorized unless valid_token?(token)
+ return bad_request unless valid_payload_size?
+
+ process_incidents
+
+ accepted
+ end
+
+ private
+
+ def process_incidents
+ pager_duty_processable_events.each do |event|
+ ::IncidentManagement::PagerDuty::ProcessIncidentWorker.perform_async(project.id, event['incident'])
+ end
+ end
+
+ def pager_duty_processable_events
+ strong_memoize(:pager_duty_processable_events) do
+ ::PagerDuty::WebhookPayloadParser
+ .call(params.to_h)
+ .filter { |msg| msg['event'].in?(PAGER_DUTY_PROCESSABLE_EVENT_TYPES) }
+ end
+ end
+
+ def webhook_setting_active?
+ Feature.enabled?(:pagerduty_webhook, project) &&
+ incident_management_setting.pagerduty_active?
+ end
+
+ def valid_token?(token)
+ token && incident_management_setting.pagerduty_token == token
+ end
+
+ def valid_payload_size?
+ Gitlab::Utils::DeepSize.new(params, max_size: PAGER_DUTY_PAYLOAD_SIZE_LIMIT).valid?
+ end
+
+ def accepted
+ ServiceResponse.success(http_status: :accepted)
+ end
+
+ def forbidden
+ ServiceResponse.error(message: 'Forbidden', http_status: :forbidden)
+ end
+
+ def unauthorized
+ ServiceResponse.error(message: 'Unauthorized', http_status: :unauthorized)
+ end
+
+ def bad_request
+ ServiceResponse.error(message: 'Bad Request', http_status: :bad_request)
+ end
+ end
+ end
+end
diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb
index 2902385da4a..79be771b3fb 100644
--- a/app/services/issuable/bulk_update_service.rb
+++ b/app/services/issuable/bulk_update_service.rb
@@ -11,40 +11,29 @@ module Issuable
end
def execute(type)
- model_class = type.classify.constantize
- update_class = type.classify.pluralize.constantize::UpdateService
-
ids = params.delete(:issuable_ids).split(",")
- items = find_issuables(parent, model_class, ids)
+ set_update_params(type)
+ items = update_issuables(type, ids)
+ response_success(payload: { count: items.count })
+ rescue ArgumentError => e
+ response_error(e.message, 422)
+ end
+
+ private
+
+ def set_update_params(type)
params.slice!(*permitted_attrs(type))
params.delete_if { |k, v| v.blank? }
if params[:assignee_ids] == [IssuableFinder::Params::NONE.to_s]
params[:assignee_ids] = []
end
-
- items.each do |issuable|
- next unless can?(current_user, :"update_#{type}", issuable)
-
- update_class.new(issuable.issuing_parent, current_user, params).execute(issuable)
- end
-
- {
- count: items.count,
- success: !items.count.zero?
- }
end
- private
-
def permitted_attrs(type)
attrs = %i(state_event milestone_id add_label_ids remove_label_ids subscription_event)
- issuable_specific_attrs(type, attrs)
- end
-
- def issuable_specific_attrs(type, attrs)
if type == 'issue' || type == 'merge_request'
attrs.push(:assignee_ids)
else
@@ -52,6 +41,20 @@ module Issuable
end
end
+ def update_issuables(type, ids)
+ model_class = type.classify.constantize
+ update_class = type.classify.pluralize.constantize::UpdateService
+ items = find_issuables(parent, model_class, ids)
+
+ items.each do |issuable|
+ next unless can?(current_user, :"update_#{type}", issuable)
+
+ update_class.new(issuable.issuing_parent, current_user, params).execute(issuable)
+ end
+
+ items
+ end
+
def find_issuables(parent, model_class, ids)
if parent.is_a?(Project)
model_class.id_in(ids).of_projects(parent)
@@ -59,6 +62,14 @@ module Issuable
model_class.id_in(ids).of_projects(parent.all_projects)
end
end
+
+ def response_success(message: nil, payload: nil)
+ ServiceResponse.success(message: message, payload: payload)
+ end
+
+ def response_error(message, http_status)
+ ServiceResponse.error(message: message, http_status: http_status)
+ end
end
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 38b10996f44..65a73dadc2e 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -97,29 +97,6 @@ class IssuableBaseService < BaseService
params.delete(label_key) if params[label_key].nil?
end
- def filter_labels_in_param(key)
- return if params[key].to_a.empty?
-
- params[key] = available_labels.id_in(params[key]).pluck_primary_key
- end
-
- def find_or_create_label_ids
- labels = params.delete(:labels)
-
- return unless labels
-
- params[:label_ids] = labels.map do |label_name|
- label = Labels::FindOrCreateService.new(
- current_user,
- parent,
- title: label_name.strip,
- available_labels: available_labels
- ).execute
-
- label.try(:id)
- end.compact
- end
-
def labels_service
@labels_service ||= ::Labels::AvailableLabelsService.new(current_user, parent, params)
end
@@ -138,7 +115,7 @@ class IssuableBaseService < BaseService
new_label_ids.uniq
end
- def handle_quick_actions_on_create(issuable)
+ def handle_quick_actions(issuable)
merge_quick_actions_into_params!(issuable)
end
@@ -146,17 +123,21 @@ class IssuableBaseService < BaseService
original_description = params.fetch(:description, issuable.description)
description, command_params =
- QuickActions::InterpretService.new(project, current_user)
+ QuickActions::InterpretService.new(project, current_user, quick_action_options)
.execute(original_description, issuable, only: only)
# Avoid a description already set on an issuable to be overwritten by a nil
- params[:description] = description if description
+ params[:description] = description if description && description != original_description
params.merge!(command_params)
end
+ def quick_action_options
+ {}
+ end
+
def create(issuable)
- handle_quick_actions_on_create(issuable)
+ handle_quick_actions(issuable)
filter_params(issuable)
params.delete(:state_event)
@@ -200,11 +181,13 @@ class IssuableBaseService < BaseService
end
def update(issuable)
+ handle_quick_actions(issuable)
+ filter_params(issuable)
+
change_state(issuable)
change_subscription(issuable)
change_todo(issuable)
toggle_award(issuable)
- filter_params(issuable)
old_associations = associations_before_update(issuable)
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 2409396c1ac..ce1466307e1 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -19,11 +19,22 @@ module Issues
notify_participants
+ # Updates old issue sent notifications allowing
+ # to receive service desk emails on the new moved issue.
+ update_service_desk_sent_notifications
+
new_entity
end
private
+ def update_service_desk_sent_notifications
+ return unless original_entity.from_service_desk?
+
+ original_entity
+ .sent_notifications.update_all(project_id: new_entity.project_id, noteable_id: new_entity.id)
+ end
+
def update_old_entity
super
diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb
index 7521c7610cb..7c6db372257 100644
--- a/app/services/jira/requests/base.rb
+++ b/app/services/jira/requests/base.rb
@@ -5,28 +5,32 @@ module Jira
class Base
include ProjectServicesLoggable
- PER_PAGE = 50
+ JIRA_API_VERSION = 2
- attr_reader :jira_service, :project, :limit, :start_at, :query
-
- def initialize(jira_service, limit: PER_PAGE, start_at: 0, query: nil)
+ def initialize(jira_service, params = {})
@project = jira_service&.project
@jira_service = jira_service
-
- @limit = limit
- @start_at = start_at
- @query = query
end
def execute
return ServiceResponse.error(message: _('Jira service not configured.')) unless jira_service&.active?
- return ServiceResponse.success(payload: empty_payload) if limit.to_i <= 0
request
end
+ def base_api_url
+ "/rest/api/#{api_version}"
+ end
+
private
+ attr_reader :jira_service, :project
+
+ # override this method in the specific request class implementation if a differnt API version is required
+ def api_version
+ JIRA_API_VERSION
+ end
+
def client
@client ||= jira_service.client
end
diff --git a/app/services/jira/requests/projects.rb b/app/services/jira/requests/projects.rb
deleted file mode 100644
index da464503211..00000000000
--- a/app/services/jira/requests/projects.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-module Jira
- module Requests
- class Projects < Base
- extend ::Gitlab::Utils::Override
-
- private
-
- override :url
- def url
- '/rest/api/2/project/search?query=%{query}&maxResults=%{limit}&startAt=%{start_at}' %
- { query: CGI.escape(query.to_s), limit: limit.to_i, start_at: start_at.to_i }
- end
-
- override :build_service_response
- def build_service_response(response)
- return ServiceResponse.success(payload: empty_payload) unless response['values'].present?
-
- ServiceResponse.success(payload: { projects: map_projects(response), is_last: response['isLast'] })
- end
-
- def map_projects(response)
- response['values'].map { |v| JIRA::Resource::Project.build(client, v) }
- end
-
- def empty_payload
- { projects: [], is_last: true }
- end
- end
- end
-end
diff --git a/app/services/jira/requests/projects/list_service.rb b/app/services/jira/requests/projects/list_service.rb
new file mode 100644
index 00000000000..8ecfd358ffb
--- /dev/null
+++ b/app/services/jira/requests/projects/list_service.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Jira
+ module Requests
+ module Projects
+ class ListService < Base
+ extend ::Gitlab::Utils::Override
+
+ def initialize(jira_service, params: {})
+ super(jira_service, params)
+
+ @query = params[:query]
+ end
+
+ private
+
+ attr_reader :query
+
+ override :url
+ def url
+ "#{base_api_url}/project"
+ end
+
+ override :build_service_response
+ def build_service_response(response)
+ return ServiceResponse.success(payload: empty_payload) unless response.present?
+
+ ServiceResponse.success(payload: { projects: map_projects(response), is_last: true })
+ end
+
+ def map_projects(response)
+ response.map { |v| JIRA::Resource::Project.build(client, v) }.select(&method(:match_query?))
+ end
+
+ def match_query?(jira_project)
+ query = query.to_s.downcase
+
+ jira_project&.key&.downcase&.include?(query) || jira_project&.name&.downcase&.include?(query)
+ end
+
+ def empty_payload
+ { projects: [], is_last: true }
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb
index a06cc6df719..f85f686c61a 100644
--- a/app/services/jira_import/start_import_service.rb
+++ b/app/services/jira_import/start_import_service.rb
@@ -2,23 +2,39 @@
module JiraImport
class StartImportService
- attr_reader :user, :project, :jira_project_key
+ attr_reader :user, :project, :jira_project_key, :users_mapping
- def initialize(user, project, jira_project_key)
+ def initialize(user, project, jira_project_key, users_mapping)
@user = user
@project = project
@jira_project_key = jira_project_key
+ @users_mapping = users_mapping
end
def execute
validation_response = validate
return validation_response if validation_response&.error?
+ store_users_mapping
create_and_schedule_import
end
private
+ def store_users_mapping
+ return if users_mapping.blank?
+
+ mapping = users_mapping.map do |map|
+ next if !map[:jira_account_id] || !map[:gitlab_id]
+
+ [map[:jira_account_id], map[:gitlab_id]]
+ end.compact.to_h
+
+ return if mapping.blank?
+
+ Gitlab::JiraImport.cache_users_mapping(project.id, mapping)
+ end
+
def create_and_schedule_import
jira_import = build_jira_import
project.import_type = 'jira'
diff --git a/app/services/jira_import/users_mapper.rb b/app/services/jira_import/users_mapper.rb
index 31a3f721556..c3cbeb157bd 100644
--- a/app/services/jira_import/users_mapper.rb
+++ b/app/services/jira_import/users_mapper.rb
@@ -14,9 +14,8 @@ module JiraImport
{
jira_account_id: jira_user['accountId'],
jira_display_name: jira_user['displayName'],
- jira_email: jira_user['emailAddress'],
- gitlab_id: match_user(jira_user)
- }
+ jira_email: jira_user['emailAddress']
+ }.merge(match_user(jira_user))
end
end
@@ -25,7 +24,7 @@ module JiraImport
# TODO: Matching user by email and displayName will be done as the part
# of follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/219023
def match_user(jira_user)
- nil
+ { gitlab_id: nil, gitlab_username: nil, gitlab_name: nil }
end
end
end
diff --git a/app/services/labels/available_labels_service.rb b/app/services/labels/available_labels_service.rb
index 979964e09fd..3b226f39d04 100644
--- a/app/services/labels/available_labels_service.rb
+++ b/app/services/labels/available_labels_service.rb
@@ -34,7 +34,7 @@ module Labels
return [] if ids.empty?
# rubocop:disable CodeReuse/ActiveRecord
- existing_ids = available_labels.by_ids(ids).pluck(:id)
+ existing_ids = available_labels.id_in(ids).pluck(:id)
# rubocop:enable CodeReuse/ActiveRecord
ids.map(&:to_i) & existing_ids
end
diff --git a/app/services/labels/transfer_service.rb b/app/services/labels/transfer_service.rb
index e6f9cf35fcb..a05090d6bfb 100644
--- a/app/services/labels/transfer_service.rb
+++ b/app/services/labels/transfer_service.rb
@@ -15,14 +15,18 @@ module Labels
def execute
return unless old_group.present?
+ # rubocop: disable CodeReuse/ActiveRecord
+ link_ids = group_labels_applied_to_issues.pluck("label_links.id") +
+ group_labels_applied_to_merge_requests.pluck("label_links.id")
+ # rubocop: disable CodeReuse/ActiveRecord
+
Label.transaction do
labels_to_transfer.find_each do |label|
new_label_id = find_or_create_label!(label)
next if new_label_id == label.id
- update_label_links(group_labels_applied_to_issues, old_label_id: label.id, new_label_id: new_label_id)
- update_label_links(group_labels_applied_to_merge_requests, old_label_id: label.id, new_label_id: new_label_id)
+ update_label_links(link_ids, old_label_id: label.id, new_label_id: new_label_id)
update_label_priorities(old_label_id: label.id, new_label_id: new_label_id)
end
end
@@ -46,20 +50,20 @@ module Labels
# rubocop: disable CodeReuse/ActiveRecord
def group_labels_applied_to_issues
- Label.joins(:issues)
+ @group_labels_applied_to_issues ||= Label.joins(:issues)
.where(
issues: { project_id: project.id },
- labels: { type: 'GroupLabel', group_id: old_group.self_and_ancestors }
+ labels: { group_id: old_group.self_and_ancestors }
)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def group_labels_applied_to_merge_requests
- Label.joins(:merge_requests)
+ @group_labels_applied_to_merge_requests ||= Label.joins(:merge_requests)
.where(
merge_requests: { target_project_id: project.id },
- labels: { type: 'GroupLabel', group_id: old_group.self_and_ancestors }
+ labels: { group_id: old_group.self_and_ancestors }
)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -72,14 +76,7 @@ module Labels
end
# rubocop: disable CodeReuse/ActiveRecord
- def update_label_links(labels, old_label_id:, new_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-foss/-/jobs/62435068
- link_ids = labels.pluck('label_links.id')
-
+ def update_label_links(link_ids, old_label_id:, new_label_id:)
LabelLink.where(id: link_ids, label_id: old_label_id)
.update_all(label_id: new_label_id)
end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 0b729981a93..610288c5e76 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -22,7 +22,7 @@ module Members
errors = []
members.each do |member|
- if member.errors.any?
+ if member.invalid?
current_error =
# Invited users may not have an associated user
if member.user.present?
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index 20f64a99ad7..fdd43260521 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -2,8 +2,8 @@
module Members
class DestroyService < Members::BaseService
- def execute(member, skip_authorization: false, skip_subresources: false)
- raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_destroy_member?(member)
+ def execute(member, skip_authorization: false, skip_subresources: false, unassign_issuables: false, destroy_bot: false)
+ raise Gitlab::Access::AccessDeniedError unless skip_authorization || authorized?(member, destroy_bot)
@skip_auth = skip_authorization
@@ -19,6 +19,7 @@ module Members
delete_subresources(member) unless skip_subresources
enqueue_delete_todos(member)
+ enqueue_unassign_issuables(member) if unassign_issuables
after_execute(member: member)
@@ -27,6 +28,12 @@ module Members
private
+ def authorized?(member, destroy_bot)
+ return can_destroy_bot_member?(member) if destroy_bot
+
+ can_destroy_member?(member)
+ end
+
def delete_subresources(member)
return unless member.is_a?(GroupMember) && member.user && member.group
@@ -54,6 +61,10 @@ module Members
can?(current_user, destroy_member_permission(member), member)
end
+ def can_destroy_bot_member?(member)
+ can?(current_user, destroy_bot_member_permission(member), member)
+ end
+
def destroy_member_permission(member)
case member
when GroupMember
@@ -64,6 +75,20 @@ module Members
raise "Unknown member type: #{member}!"
end
end
+
+ def destroy_bot_member_permission(member)
+ raise "Unsupported bot member type: #{member}" unless member.is_a?(ProjectMember)
+
+ :destroy_project_bot_member
+ end
+
+ def enqueue_unassign_issuables(member)
+ source_type = member.is_a?(GroupMember) ? 'Group' : 'Project'
+
+ member.run_after_commit_or_now do
+ MembersDestroyer::UnassignIssuablesWorker.perform_async(member.user_id, member.source_id, source_type)
+ end
+ end
end
end
diff --git a/app/services/members/unassign_issuables_service.rb b/app/services/members/unassign_issuables_service.rb
new file mode 100644
index 00000000000..95e07deb761
--- /dev/null
+++ b/app/services/members/unassign_issuables_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Members
+ class UnassignIssuablesService
+ attr_reader :user, :entity
+
+ def initialize(user, entity)
+ @user = user
+ @entity = entity
+ end
+
+ def execute
+ return unless entity && user
+
+ project_ids = entity.is_a?(Group) ? entity.all_projects.select(:id) : [entity.id]
+
+ user.issue_assignees.on_issues(Issue.in_projects(project_ids).select(:id)).delete_all
+ user.merge_request_assignees.in_projects(project_ids).delete_all
+
+ user.invalidate_cache_counts
+ end
+ end
+end
diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb
new file mode 100644
index 00000000000..150ec85fca9
--- /dev/null
+++ b/app/services/merge_requests/approval_service.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class ApprovalService < MergeRequests::BaseService
+ def execute(merge_request)
+ return unless can_be_approved?(merge_request)
+
+ approval = merge_request.approvals.new(user: current_user)
+
+ return success unless save_approval(approval)
+
+ reset_approvals_cache(merge_request)
+ create_event(merge_request)
+ create_approval_note(merge_request)
+ mark_pending_todos_as_done(merge_request)
+ execute_approval_hooks(merge_request, current_user)
+
+ success
+ end
+
+ private
+
+ def can_be_approved?(merge_request)
+ current_user.can?(:approve_merge_request, merge_request)
+ end
+
+ def reset_approvals_cache(merge_request)
+ merge_request.approvals.reset
+ end
+
+ def execute_approval_hooks(merge_request, current_user)
+ # Only one approval is required for a merge request to be approved
+ execute_hooks(merge_request, 'approved')
+ end
+
+ def save_approval(approval)
+ Approval.safe_ensure_unique do
+ approval.save
+ end
+ end
+
+ def create_approval_note(merge_request)
+ SystemNoteService.approve_mr(merge_request, current_user)
+ end
+
+ def mark_pending_todos_as_done(merge_request)
+ todo_service.resolve_todos_for_target(merge_request, current_user)
+ end
+
+ def create_event(merge_request)
+ event_service.approve_mr(merge_request, current_user)
+ end
+ end
+end
+
+MergeRequests::ApprovalService.prepend_if_ee('EE::MergeRequests::ApprovalService')
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 7f7bfa29af7..7e301f311e9 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -2,6 +2,7 @@
module MergeRequests
class BaseService < ::IssuableBaseService
+ extend ::Gitlab::Utils::Override
include MergeRequests::AssignsMergeParams
def create_note(merge_request, state = merge_request.state)
@@ -29,6 +30,11 @@ module MergeRequests
.execute_for_merge_request(merge_request)
end
+ def cancel_review_app_jobs!(merge_request)
+ environments = merge_request.environments.in_review_folder.available
+ environments.each { |environment| environment.cancel_deployment_jobs! }
+ end
+
def source_project
@source_project ||= merge_request.source_project
end
@@ -58,6 +64,12 @@ module MergeRequests
super
end
+ override :handle_quick_actions
+ def handle_quick_actions(merge_request)
+ super
+ handle_wip_event(merge_request)
+ end
+
def handle_wip_event(merge_request)
if wip_event = params.delete(:wip_event)
# We update the title that is provided in the params or we use the mr title
@@ -90,10 +102,6 @@ module MergeRequests
MergeRequests::CreatePipelineService.new(project, user).execute(merge_request)
end
- def can_use_merge_request_ref?(merge_request)
- !merge_request.for_fork?
- end
-
def abort_auto_merge(merge_request, reason)
AutoMergeService.new(project, current_user).abort(merge_request, reason)
end
diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb
index f802aa44487..f9352f10fea 100644
--- a/app/services/merge_requests/create_pipeline_service.rb
+++ b/app/services/merge_requests/create_pipeline_service.rb
@@ -9,7 +9,7 @@ module MergeRequests
end
def create_detached_merge_request_pipeline(merge_request)
- Ci::CreatePipelineService.new(merge_request.source_project,
+ Ci::CreatePipelineService.new(pipeline_project(merge_request),
current_user,
ref: pipeline_ref_for_detached_merge_request_pipeline(merge_request))
.execute(:merge_request_event, merge_request: merge_request)
@@ -31,13 +31,29 @@ module MergeRequests
private
+ def pipeline_project(merge_request)
+ if can_create_pipeline_in_target_project?(merge_request)
+ merge_request.target_project
+ else
+ merge_request.source_project
+ end
+ end
+
def pipeline_ref_for_detached_merge_request_pipeline(merge_request)
- if can_use_merge_request_ref?(merge_request)
+ if can_create_pipeline_in_target_project?(merge_request)
merge_request.ref_path
else
merge_request.source_branch
end
end
+
+ def can_create_pipeline_in_target_project?(merge_request)
+ if Gitlab::Ci::Features.allow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project)
+ can?(current_user, :create_pipeline, merge_request.target_project)
+ else
+ merge_request.for_same_project?
+ end
+ end
end
end
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 1cdfba41432..ac84a13f437 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -33,12 +33,6 @@ module MergeRequests
super
end
- # Override from IssuableBaseService
- def handle_quick_actions_on_create(merge_request)
- super
- handle_wip_event(merge_request)
- end
-
private
def set_projects!
diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb
index 6f1fa607ef9..b3896d61a78 100644
--- a/app/services/merge_requests/ff_merge_service.rb
+++ b/app/services/merge_requests/ff_merge_service.rb
@@ -16,7 +16,7 @@ module MergeRequests
merge_request.target_branch,
merge_request: merge_request)
- if merge_request.squash
+ if merge_request.squash_on_merge?
merge_request.update_column(:squash_commit_sha, merge_request.in_progress_merge_commit_sha)
end
diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb
index 27b5e31faab..fe09c92aab9 100644
--- a/app/services/merge_requests/merge_base_service.rb
+++ b/app/services/merge_requests/merge_base_service.rb
@@ -20,7 +20,7 @@ module MergeRequests
def source
strong_memoize(:source) do
- if merge_request.squash
+ if merge_request.squash_on_merge?
squash_sha!
else
merge_request.diff_head_sha
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 8d57a76f7d0..961a7cb1ef6 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -27,6 +27,7 @@ module MergeRequests
success
end
end
+
log_info("Merge process finished on JID #{merge_jid} with state #{state}")
rescue MergeError => e
handle_merge_error(log_message: e.message, save_message_on_model: true)
@@ -56,6 +57,8 @@ module MergeRequests
'Only fast-forward merge is allowed for your project. Please update your source branch'
elsif !@merge_request.mergeable?
'Merge request is not mergeable'
+ elsif !@merge_request.squash && project.squash_always?
+ 'This project requires squashing commits when merge requests are accepted.'
end
raise_error(error) if error
diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index 0364c0dd479..fdf8f442297 100644
--- a/app/services/merge_requests/post_merge_service.rb
+++ b/app/services/merge_requests/post_merge_service.rb
@@ -18,6 +18,7 @@ module MergeRequests
invalidate_cache_counts(merge_request, users: merge_request.assignees)
merge_request.update_project_counter_caches
delete_non_latest_diffs(merge_request)
+ cancel_review_app_jobs!(merge_request)
cleanup_environments(merge_request)
end
diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb
new file mode 100644
index 00000000000..3164d0b4069
--- /dev/null
+++ b/app/services/merge_requests/remove_approval_service.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class RemoveApprovalService < MergeRequests::BaseService
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute(merge_request)
+ return unless merge_request.approved_by?(current_user)
+
+ # paranoid protection against running wrong deletes
+ return unless merge_request.id && current_user.id
+
+ approval = merge_request.approvals.where(user: current_user)
+
+ trigger_approval_hooks(merge_request) do
+ next unless approval.destroy_all # rubocop: disable Cop/DestroyAll
+
+ reset_approvals_cache(merge_request)
+ create_note(merge_request)
+ end
+
+ success
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def reset_approvals_cache(merge_request)
+ merge_request.approvals.reset
+ end
+
+ def trigger_approval_hooks(merge_request)
+ yield
+
+ execute_hooks(merge_request, 'unapproved')
+ end
+
+ def create_note(merge_request)
+ SystemNoteService.unapprove_mr(merge_request, current_user)
+ end
+ end
+end
+
+MergeRequests::RemoveApprovalService.prepend_if_ee('EE::MergeRequests::RemoveApprovalService')
diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb
index 4b04d42b48e..faa2e921581 100644
--- a/app/services/merge_requests/squash_service.rb
+++ b/app/services/merge_requests/squash_service.rb
@@ -11,11 +11,14 @@ module MergeRequests
return success(squash_sha: merge_request.diff_head_sha)
end
+ return error(s_('MergeRequests|This project does not allow squashing commits when merge requests are accepted.')) if squash_forbidden?
+
if squash_in_progress?
return error(s_('MergeRequests|Squash task canceled: another squash is already in progress.'))
end
squash! || error(s_('MergeRequests|Failed to squash. Should be done manually.'))
+
rescue SquashInProgressError
error(s_('MergeRequests|An error occurred while checking whether another squash is in progress.'))
end
@@ -40,6 +43,10 @@ module MergeRequests
raise SquashInProgressError, e.message
end
+ def squash_forbidden?
+ target_project.squash_never?
+ end
+
def repository
target_project.repository
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 561695baeab..29e0c22b155 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -2,6 +2,8 @@
module MergeRequests
class UpdateService < MergeRequests::BaseService
+ extend ::Gitlab::Utils::Override
+
def execute(merge_request)
# We don't allow change of source/target projects and source branch
# after merge request was created
@@ -9,14 +11,11 @@ module MergeRequests
params.delete(:target_project_id)
params.delete(:source_branch)
- merge_from_quick_action(merge_request) if params[:merge]
-
if merge_request.closed_without_fork?
params.delete(:target_branch)
params.delete(:force_remove_source_branch)
end
- handle_wip_event(merge_request)
update_task_event(merge_request) || update(merge_request)
end
@@ -77,26 +76,6 @@ module MergeRequests
todo_service.update_merge_request(merge_request, current_user)
end
- def merge_from_quick_action(merge_request)
- last_diff_sha = params.delete(:merge)
-
- if Feature.enabled?(:merge_orchestration_service, merge_request.project, default_enabled: true)
- MergeRequests::MergeOrchestrationService
- .new(project, current_user, { sha: last_diff_sha })
- .execute(merge_request)
- else
- return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha)
-
- merge_request.update(merge_error: nil)
-
- if merge_request.head_pipeline_active?
- AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
- else
- merge_request.merge_async(current_user.id, { sha: last_diff_sha })
- end
- end
- end
-
def reopen_service
MergeRequests::ReopenService
end
@@ -134,6 +113,37 @@ module MergeRequests
issuable, issuable.project, current_user, branch_type,
old_branch, new_branch)
end
+
+ override :handle_quick_actions
+ def handle_quick_actions(merge_request)
+ super
+ merge_from_quick_action(merge_request) if params[:merge]
+ end
+
+ def merge_from_quick_action(merge_request)
+ last_diff_sha = params.delete(:merge)
+
+ if Feature.enabled?(:merge_orchestration_service, merge_request.project, default_enabled: true)
+ MergeRequests::MergeOrchestrationService
+ .new(project, current_user, { sha: last_diff_sha })
+ .execute(merge_request)
+ else
+ return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha)
+
+ merge_request.update(merge_error: nil)
+
+ if merge_request.head_pipeline_active?
+ AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
+ else
+ merge_request.merge_async(current_user.id, { sha: last_diff_sha })
+ end
+ end
+ end
+
+ override :quick_action_options
+ def quick_action_options
+ { merge_request_diff_head_sha: params.delete(:merge_request_diff_head_sha) }
+ end
end
end
diff --git a/app/services/metrics/dashboard/base_service.rb b/app/services/metrics/dashboard/base_service.rb
index c2a0f22e73e..5fa127d64b2 100644
--- a/app/services/metrics/dashboard/base_service.rb
+++ b/app/services/metrics/dashboard/base_service.rb
@@ -10,7 +10,8 @@ module Metrics
STAGES = ::Gitlab::Metrics::Dashboard::Stages
SEQUENCE = [
STAGES::CommonMetricsInserter,
- STAGES::EndpointInserter,
+ STAGES::MetricEndpointInserter,
+ STAGES::VariableEndpointInserter,
STAGES::PanelIdsInserter,
STAGES::Sorter,
STAGES::AlertsInserter,
@@ -36,6 +37,14 @@ module Metrics
Gitlab::Metrics::Dashboard::Cache.fetch(cache_key) { get_raw_dashboard }
end
+ # Should return true if this dashboard service is for an out-of-the-box
+ # dashboard.
+ # This method is overridden in app/services/metrics/dashboard/predefined_dashboard_service.rb.
+ # @return Boolean
+ def self.out_of_the_box_dashboard?
+ false
+ end
+
private
# Determines whether users should be able to view
@@ -83,6 +92,17 @@ module Metrics
params[:dashboard_path]
end
+ def load_yaml(data)
+ ::Gitlab::Config::Loader::Yaml.new(data).load_raw!
+ rescue Gitlab::Config::Loader::Yaml::NotHashError
+ # Raise more informative error in app/models/performance_monitoring/prometheus_dashboard.rb.
+ {}
+ rescue Gitlab::Config::Loader::Yaml::DataTooLargeError => exception
+ raise Gitlab::Metrics::Dashboard::Errors::LayoutError, exception.message
+ rescue Gitlab::Config::Loader::FormatError
+ raise Gitlab::Metrics::Dashboard::Errors::LayoutError, _('Invalid yaml')
+ end
+
# @return [Hash] an unmodified dashboard
def get_raw_dashboard
raise NotImplementedError
diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb
index 3ca25b3bd9b..a6bece391f2 100644
--- a/app/services/metrics/dashboard/clone_dashboard_service.rb
+++ b/app/services/metrics/dashboard/clone_dashboard_service.rb
@@ -6,30 +6,33 @@ module Metrics
module Dashboard
class CloneDashboardService < ::BaseService
include Stepable
+ include Gitlab::Utils::StrongMemoize
ALLOWED_FILE_TYPE = '.yml'
USER_DASHBOARDS_DIR = ::Metrics::Dashboard::CustomDashboardService::DASHBOARD_ROOT
+ SEQUENCES = {
+ ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => [
+ ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter,
+ ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter,
+ ::Gitlab::Metrics::Dashboard::Stages::Sorter
+ ].freeze,
+
+ ::Metrics::Dashboard::SelfMonitoringDashboardService::DASHBOARD_PATH => [
+ ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter
+ ].freeze,
+
+ ::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH => [
+ ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter,
+ ::Gitlab::Metrics::Dashboard::Stages::Sorter
+ ].freeze
+ }.freeze
steps :check_push_authorized,
- :check_branch_name,
- :check_file_type,
- :check_dashboard_template,
- :create_file,
- :refresh_repository_method_caches
-
- class << self
- def allowed_dashboard_templates
- @allowed_dashboard_templates ||= Set[::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH].freeze
- end
-
- def sequences
- @sequences ||= {
- ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => [::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter,
- ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter,
- ::Gitlab::Metrics::Dashboard::Stages::Sorter].freeze
- }.freeze
- end
- end
+ :check_branch_name,
+ :check_file_type,
+ :check_dashboard_template,
+ :create_file,
+ :refresh_repository_method_caches
def execute
execute_steps
@@ -56,8 +59,12 @@ module Metrics
success(result)
end
+ # Only allow out of the box metrics dashboards to be cloned. This can be
+ # changed to allow cloning of any metrics dashboard, if desired.
+ # However, only metrics dashboards should be allowed. If any file is
+ # allowed to be cloned, this will become a security risk.
def check_dashboard_template(result)
- return error(_('Not found.'), :not_found) unless self.class.allowed_dashboard_templates.include?(params[:dashboard])
+ return error(_('Not found.'), :not_found) unless dashboard_service&.out_of_the_box_dashboard?
success(result)
end
@@ -78,6 +85,12 @@ module Metrics
success(result.merge(http_status: :created, dashboard: dashboard_details))
end
+ def dashboard_service
+ strong_memoize(:dashboard_service) do
+ Gitlab::Metrics::Dashboard::ServiceSelector.call(dashboard_service_options)
+ end
+ end
+
def dashboard_attrs
{
commit_message: params[:commit_message],
@@ -149,14 +162,19 @@ module Metrics
end
def raw_dashboard
- YAML.safe_load(File.read(Rails.root.join(dashboard_template)))
+ dashboard_service.new(project, current_user, dashboard_service_options).raw_dashboard
+ end
+
+ def dashboard_service_options
+ {
+ embedded: false,
+ dashboard_path: dashboard_template
+ }
end
def sequence
- self.class.sequences[dashboard_template]
+ SEQUENCES[dashboard_template] || []
end
end
end
end
-
-Metrics::Dashboard::CloneDashboardService.prepend_if_ee('EE::Metrics::Dashboard::CloneDashboardService')
diff --git a/app/services/metrics/dashboard/cluster_dashboard_service.rb b/app/services/metrics/dashboard/cluster_dashboard_service.rb
new file mode 100644
index 00000000000..bfd5abf1126
--- /dev/null
+++ b/app/services/metrics/dashboard/cluster_dashboard_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+# Fetches the system metrics dashboard and formats the output.
+# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards.
+module Metrics
+ module Dashboard
+ class ClusterDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
+ DASHBOARD_PATH = 'config/prometheus/cluster_metrics.yml'
+ DASHBOARD_NAME = 'Cluster'
+
+ # SHA256 hash of dashboard content
+ DASHBOARD_VERSION = '9349afc1d96329c08ab478ea0b77db94ee5cc2549b8c754fba67a7f424666b22'
+
+ SEQUENCE = [
+ STAGES::ClusterEndpointInserter,
+ STAGES::PanelIdsInserter,
+ STAGES::Sorter
+ ].freeze
+
+ class << self
+ def valid_params?(params)
+ # support selecting this service by cluster id via .find
+ # Use super to support selecting this service by dashboard_path via .find_raw
+ (params[:cluster].present? && params[:embedded] != 'true') || super
+ end
+ end
+
+ # Permissions are handled at the controller level
+ def allowed?
+ true
+ end
+
+ private
+
+ def dashboard_version
+ DASHBOARD_VERSION
+ end
+ end
+ end
+end
diff --git a/app/services/metrics/dashboard/cluster_metrics_embed_service.rb b/app/services/metrics/dashboard/cluster_metrics_embed_service.rb
new file mode 100644
index 00000000000..6fb39ed3004
--- /dev/null
+++ b/app/services/metrics/dashboard/cluster_metrics_embed_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+#
+module Metrics
+ module Dashboard
+ class ClusterMetricsEmbedService < Metrics::Dashboard::DynamicEmbedService
+ class << self
+ def valid_params?(params)
+ [
+ params[:cluster],
+ embedded?(params[:embedded]),
+ params[:group].present?,
+ params[:title].present?,
+ params[:y_label].present?
+ ].all?
+ end
+ end
+
+ private
+
+ # Permissions are handled at the controller level
+ def allowed?
+ true
+ end
+
+ def dashboard_path
+ ::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH
+ end
+
+ def sequence
+ [
+ STAGES::ClusterEndpointInserter,
+ STAGES::PanelIdsInserter
+ ]
+ end
+ end
+ end
+end
diff --git a/app/services/metrics/dashboard/custom_dashboard_service.rb b/app/services/metrics/dashboard/custom_dashboard_service.rb
index 77173813a4f..741738cc3af 100644
--- a/app/services/metrics/dashboard/custom_dashboard_service.rb
+++ b/app/services/metrics/dashboard/custom_dashboard_service.rb
@@ -21,7 +21,8 @@ module Metrics
path: filepath,
display_name: name_for_path(filepath),
default: false,
- system_dashboard: false
+ system_dashboard: false,
+ out_of_the_box_dashboard: out_of_the_box_dashboard?
}
end
end
@@ -42,7 +43,7 @@ module Metrics
def get_raw_dashboard
yml = self.class.file_finder(project).read(dashboard_path)
- YAML.safe_load(yml)
+ load_yaml(yml)
end
def cache_key
diff --git a/app/services/metrics/dashboard/gitlab_alert_embed_service.rb b/app/services/metrics/dashboard/gitlab_alert_embed_service.rb
index 38e89d392ad..08d65413e1d 100644
--- a/app/services/metrics/dashboard/gitlab_alert_embed_service.rb
+++ b/app/services/metrics/dashboard/gitlab_alert_embed_service.rb
@@ -11,7 +11,7 @@ module Metrics
include Gitlab::Utils::StrongMemoize
SEQUENCE = [
- STAGES::EndpointInserter,
+ STAGES::MetricEndpointInserter,
STAGES::PanelIdsInserter
].freeze
diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb
index d9ce2c5e905..8e72a185406 100644
--- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb
+++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb
@@ -80,7 +80,7 @@ module Metrics
def fetch_dashboard
uid = GrafanaUidParser.new(grafana_url, project).parse
- raise DashboardProcessingError.new('Dashboard uid not found') unless uid
+ raise DashboardProcessingError.new(_('Dashboard uid not found')) unless uid
response = client.get_dashboard(uid: uid)
@@ -89,7 +89,7 @@ module Metrics
def fetch_datasource(dashboard)
name = DatasourceNameParser.new(grafana_url, dashboard).parse
- raise DashboardProcessingError.new('Datasource name not found') unless name
+ raise DashboardProcessingError.new(_('Datasource name not found')) unless name
response = client.get_datasource(name: name)
@@ -115,7 +115,7 @@ module Metrics
def parse_json(json)
Gitlab::Json.parse(json, symbolize_names: true)
rescue JSON::ParserError
- raise DashboardProcessingError.new('Grafana response contains invalid json')
+ raise DashboardProcessingError.new(_('Grafana response contains invalid json'))
end
end
diff --git a/app/services/metrics/dashboard/pod_dashboard_service.rb b/app/services/metrics/dashboard/pod_dashboard_service.rb
index 16b87d2d587..8699189deac 100644
--- a/app/services/metrics/dashboard/pod_dashboard_service.rb
+++ b/app/services/metrics/dashboard/pod_dashboard_service.rb
@@ -5,6 +5,15 @@ module Metrics
class PodDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
DASHBOARD_PATH = 'config/prometheus/pod_metrics.yml'
DASHBOARD_NAME = 'Pod Health'
+
+ # SHA256 hash of dashboard content
+ DASHBOARD_VERSION = 'f12f641d2575d5dcb69e2c633ff5231dbd879ad35020567d8fc4e1090bfdb4b4'
+
+ private
+
+ def dashboard_version
+ DASHBOARD_VERSION
+ end
end
end
end
diff --git a/app/services/metrics/dashboard/predefined_dashboard_service.rb b/app/services/metrics/dashboard/predefined_dashboard_service.rb
index f454df63773..c21083475f0 100644
--- a/app/services/metrics/dashboard/predefined_dashboard_service.rb
+++ b/app/services/metrics/dashboard/predefined_dashboard_service.rb
@@ -10,7 +10,8 @@ module Metrics
DASHBOARD_NAME = nil
SEQUENCE = [
- STAGES::EndpointInserter,
+ STAGES::MetricEndpointInserter,
+ STAGES::VariableEndpointInserter,
STAGES::PanelIdsInserter,
STAGES::Sorter
].freeze
@@ -23,12 +24,20 @@ module Metrics
def matching_dashboard?(filepath)
filepath == self::DASHBOARD_PATH
end
+
+ def out_of_the_box_dashboard?
+ true
+ end
end
private
+ def dashboard_version
+ raise NotImplementedError
+ end
+
def cache_key
- "metrics_dashboard_#{dashboard_path}"
+ "metrics_dashboard_#{dashboard_path}_#{dashboard_version}"
end
def dashboard_path
@@ -39,7 +48,7 @@ module Metrics
def get_raw_dashboard
yml = File.read(Rails.root.join(dashboard_path))
- YAML.safe_load(yml)
+ load_yaml(yml)
end
def sequence
diff --git a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
index 8599c23c206..f1f5cd7d77e 100644
--- a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
+++ b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
@@ -8,9 +8,13 @@ module Metrics
DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml'
DASHBOARD_NAME = N_('Default dashboard')
+ # SHA256 hash of dashboard content
+ DASHBOARD_VERSION = '1dff3e3cb76e73c8e368823c98b34c61aec0d141978450dea195a3b3dc2415d6'
+
SEQUENCE = [
STAGES::CustomMetricsInserter,
- STAGES::EndpointInserter,
+ STAGES::MetricEndpointInserter,
+ STAGES::VariableEndpointInserter,
STAGES::PanelIdsInserter,
STAGES::Sorter
].freeze
@@ -25,7 +29,8 @@ module Metrics
path: DASHBOARD_PATH,
display_name: _(DASHBOARD_NAME),
default: true,
- system_dashboard: false
+ system_dashboard: false,
+ out_of_the_box_dashboard: out_of_the_box_dashboard?
}]
end
@@ -33,6 +38,12 @@ module Metrics
params[:dashboard_path].nil? && params[:environment]&.project&.self_monitoring?
end
end
+
+ private
+
+ def dashboard_version
+ DASHBOARD_VERSION
+ end
end
end
end
diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb
index db5599b4def..5c3562b8ca0 100644
--- a/app/services/metrics/dashboard/system_dashboard_service.rb
+++ b/app/services/metrics/dashboard/system_dashboard_service.rb
@@ -8,11 +8,15 @@ module Metrics
DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'
DASHBOARD_NAME = N_('Default dashboard')
+ # SHA256 hash of dashboard content
+ DASHBOARD_VERSION = '4685fe386c25b1a786b3be18f79bb2ee9828019003e003816284cdb634fa3e13'
+
SEQUENCE = [
STAGES::CommonMetricsInserter,
STAGES::CustomMetricsInserter,
STAGES::CustomMetricsDetailsInserter,
- STAGES::EndpointInserter,
+ STAGES::MetricEndpointInserter,
+ STAGES::VariableEndpointInserter,
STAGES::PanelIdsInserter,
STAGES::Sorter,
STAGES::AlertsInserter
@@ -24,10 +28,17 @@ module Metrics
path: DASHBOARD_PATH,
display_name: _(DASHBOARD_NAME),
default: true,
- system_dashboard: true
+ system_dashboard: true,
+ out_of_the_box_dashboard: out_of_the_box_dashboard?
}]
end
end
+
+ private
+
+ def dashboard_version
+ DASHBOARD_VERSION
+ end
end
end
end
diff --git a/app/services/metrics/dashboard/transient_embed_service.rb b/app/services/metrics/dashboard/transient_embed_service.rb
index cb6ca215447..0a9c4bc7b86 100644
--- a/app/services/metrics/dashboard/transient_embed_service.rb
+++ b/app/services/metrics/dashboard/transient_embed_service.rb
@@ -30,7 +30,7 @@ module Metrics
override :sequence
def sequence
- [STAGES::EndpointInserter]
+ [STAGES::MetricEndpointInserter]
end
override :identifiers
@@ -39,7 +39,7 @@ module Metrics
end
def invalid_embed_json!(message)
- raise DashboardProcessingError.new("Parsing error for param :embed_json. #{message}")
+ raise DashboardProcessingError.new(_("Parsing error for param :embed_json. %{message}") % { message: message })
end
end
end
diff --git a/app/services/namespaces/check_storage_size_service.rb b/app/services/namespaces/check_storage_size_service.rb
deleted file mode 100644
index 57d2645a0c8..00000000000
--- a/app/services/namespaces/check_storage_size_service.rb
+++ /dev/null
@@ -1,95 +0,0 @@
-# frozen_string_literal: true
-
-module Namespaces
- class CheckStorageSizeService
- include ActiveSupport::NumberHelper
- include Gitlab::Allowable
- include Gitlab::Utils::StrongMemoize
-
- def initialize(namespace, user)
- @root_namespace = namespace.root_ancestor
- @root_storage_size = Namespace::RootStorageSize.new(root_namespace)
- @user = user
- end
-
- def execute
- return ServiceResponse.success unless Feature.enabled?(:namespace_storage_limit, root_namespace)
- return ServiceResponse.success if alert_level == :none
-
- if root_storage_size.above_size_limit?
- ServiceResponse.error(message: above_size_limit_message, payload: payload)
- else
- ServiceResponse.success(payload: payload)
- end
- end
-
- private
-
- attr_reader :root_namespace, :root_storage_size, :user
-
- USAGE_THRESHOLDS = {
- none: 0.0,
- info: 0.5,
- warning: 0.75,
- alert: 0.95,
- error: 1.0
- }.freeze
-
- def payload
- return {} unless can?(user, :admin_namespace, root_namespace)
-
- {
- explanation_message: explanation_message,
- usage_message: usage_message,
- alert_level: alert_level,
- root_namespace: root_namespace
- }
- end
-
- def explanation_message
- root_storage_size.above_size_limit? ? above_size_limit_message : below_size_limit_message
- end
-
- def usage_message
- s_("You reached %{usage_in_percent} of %{namespace_name}'s storage capacity (%{used_storage} of %{storage_limit})" % current_usage_params)
- end
-
- def alert_level
- strong_memoize(:alert_level) do
- usage_ratio = root_storage_size.usage_ratio
- current_level = USAGE_THRESHOLDS.each_key.first
-
- USAGE_THRESHOLDS.each do |level, threshold|
- current_level = level if usage_ratio >= threshold
- end
-
- current_level
- end
- end
-
- def below_size_limit_message
- s_("If you reach 100%% storage capacity, you will not be able to: %{base_message}" % { base_message: base_message } )
- end
-
- def above_size_limit_message
- s_("%{namespace_name} is now read-only. You cannot: %{base_message}" % { namespace_name: root_namespace.name, base_message: base_message })
- end
-
- def base_message
- s_("push to your repository, create pipelines, create issues or add comments. To reduce storage capacity, delete unused repositories, artifacts, wikis, issues, and pipelines.")
- end
-
- def current_usage_params
- {
- usage_in_percent: number_to_percentage(root_storage_size.usage_ratio * 100, precision: 0),
- namespace_name: root_namespace.name,
- used_storage: formatted(root_storage_size.current_size),
- storage_limit: formatted(root_storage_size.limit)
- }
- end
-
- def formatted(number)
- number_to_human_size(number, delimiter: ',', precision: 2)
- end
- end
-end
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index 0e455c641ce..4f3b2000e9a 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -10,13 +10,13 @@ module Notes
def execute
# Skip system notes, like status changes and cross-references and awards
- unless @note.system?
- EventCreateService.new.leave_note(@note, @note.author)
+ unless note.system?
+ EventCreateService.new.leave_note(note, note.author)
- return if @note.for_personal_snippet?
+ return if note.for_personal_snippet?
- @note.create_cross_references!
- ::SystemNoteService.design_discussion_added(@note) if create_design_discussion_system_note?
+ note.create_cross_references!
+ ::SystemNoteService.design_discussion_added(note) if create_design_discussion_system_note?
execute_note_hooks
end
@@ -25,21 +25,21 @@ module Notes
private
def create_design_discussion_system_note?
- @note && @note.for_design? && @note.start_of_discussion?
+ note && note.for_design? && note.start_of_discussion?
end
def hook_data
- Gitlab::DataBuilder::Note.build(@note, @note.author)
+ Gitlab::DataBuilder::Note.build(note, note.author)
end
def execute_note_hooks
- return unless @note.project
+ return unless note.project
note_data = hook_data
- hooks_scope = @note.confidential?(include_noteable: true) ? :confidential_note_hooks : :note_hooks
+ hooks_scope = note.confidential?(include_noteable: true) ? :confidential_note_hooks : :note_hooks
- @note.project.execute_hooks(note_data, hooks_scope)
- @note.project.execute_services(note_data, hooks_scope)
+ 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/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb
index 7e6568b5b25..c670f01e502 100644
--- a/app/services/notes/quick_actions_service.rb
+++ b/app/services/notes/quick_actions_service.rb
@@ -41,7 +41,7 @@ module Notes
@interpret_service = QuickActions::InterpretService.new(project, current_user, options)
- @interpret_service.execute(note.note, note.noteable)
+ interpret_service.execute(note.note, note.noteable)
end
# Applies updates extracted to note#noteable
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index 444656348ed..047848fd1a3 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -10,6 +10,7 @@ module Notes
note.assign_attributes(params.merge(updated_by: current_user))
note.with_transaction_returning_status do
+ update_confidentiality(note)
note.save
end
@@ -79,6 +80,15 @@ module Notes
TodoService.new.update_note(note, current_user, old_mentioned_users)
end
+
+ # This method updates confidentiality of all discussion notes at once
+ def update_confidentiality(note)
+ return unless params.key?(:confidential)
+ return unless note.is_a?(DiscussionNote) # we don't need to do bulk update for single notes
+ return unless note.start_of_discussion? # don't update all notes if a response is being updated
+
+ Note.id_in(note.discussion.notes.map(&:id)).update_all(confidential: params[:confidential])
+ end
end
end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 73e60ac8420..a4e935a8cf5 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -294,6 +294,7 @@ class NotificationService
return true if note.system_note_with_references?
send_new_note_notifications(note)
+ send_service_desk_notification(note)
end
def send_new_note_notifications(note)
@@ -305,6 +306,21 @@ class NotificationService
end
end
+ def send_service_desk_notification(note)
+ return unless Gitlab::ServiceDesk.supported?
+ return unless note.noteable_type == 'Issue'
+
+ issue = note.noteable
+ support_bot = User.support_bot
+
+ return unless issue.service_desk_reply_to.present?
+ return unless issue.project.service_desk_enabled?
+ return if note.author == support_bot
+ return unless issue.subscribed?(support_bot, issue.project)
+
+ mailer.service_desk_new_note_email(issue.id, note.id).deliver_later
+ end
+
# Notify users when a new release is created
def send_new_release_notifications(release)
recipients = NotificationRecipients::BuildService.build_new_release_recipients(release)
@@ -566,6 +582,14 @@ class NotificationService
end
end
+ def merge_when_pipeline_succeeds(merge_request, current_user)
+ recipients = ::NotificationRecipients::BuildService.build_recipients(merge_request, current_user, action: 'merge_when_pipeline_succeeds')
+
+ recipients.each do |recipient|
+ mailer.merge_when_pipeline_succeeds_email(recipient.user.id, merge_request.id, current_user.id).deliver_later
+ end
+ end
+
protected
def new_resource_email(target, method)
diff --git a/app/services/packages/composer/composer_json_service.rb b/app/services/packages/composer/composer_json_service.rb
new file mode 100644
index 00000000000..6ffb5a77da3
--- /dev/null
+++ b/app/services/packages/composer/composer_json_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Packages
+ module Composer
+ class ComposerJsonService
+ def initialize(project, target)
+ @project, @target = project, target
+ end
+
+ def execute
+ composer_json
+ end
+
+ private
+
+ def composer_json
+ composer_file = @project.repository.blob_at(@target, 'composer.json')
+
+ composer_file_not_found! unless composer_file
+
+ Gitlab::Json.parse(composer_file.data)
+ rescue JSON::ParserError
+ raise 'Could not parse composer.json file. Invalid JSON.'
+ end
+
+ def composer_file_not_found!
+ raise 'The file composer.json was not found.'
+ end
+ end
+ end
+end
diff --git a/app/services/packages/composer/create_package_service.rb b/app/services/packages/composer/create_package_service.rb
new file mode 100644
index 00000000000..ad5d267698b
--- /dev/null
+++ b/app/services/packages/composer/create_package_service.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Packages
+ module Composer
+ class CreatePackageService < BaseService
+ include ::Gitlab::Utils::StrongMemoize
+
+ def execute
+ # fetches json outside of transaction
+ composer_json
+
+ ::Packages::Package.transaction do
+ ::Packages::Composer::Metadatum.upsert(
+ package_id: created_package.id,
+ target_sha: target,
+ composer_json: composer_json
+ )
+ end
+ end
+
+ private
+
+ def created_package
+ project
+ .packages
+ .composer
+ .safe_find_or_create_by!(name: package_name, version: package_version)
+ end
+
+ def composer_json
+ strong_memoize(:composer_json) do
+ ::Packages::Composer::ComposerJsonService.new(project, target).execute
+ end
+ end
+
+ def package_name
+ composer_json['name']
+ end
+
+ def target
+ (branch || tag).target
+ end
+
+ def branch
+ params[:branch]
+ end
+
+ def tag
+ params[:tag]
+ end
+
+ def package_version
+ ::Packages::Composer::VersionParserService.new(tag_name: tag&.name, branch_name: branch&.name).execute
+ end
+ end
+ end
+end
diff --git a/app/services/packages/composer/version_parser_service.rb b/app/services/packages/composer/version_parser_service.rb
new file mode 100644
index 00000000000..76dfd7a14bd
--- /dev/null
+++ b/app/services/packages/composer/version_parser_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Packages
+ module Composer
+ class VersionParserService
+ def initialize(tag_name: nil, branch_name: nil)
+ @tag_name, @branch_name = tag_name, branch_name
+ end
+
+ def execute
+ if @tag_name.present?
+ @tag_name.match(Gitlab::Regex.composer_package_version_regex).captures[0]
+ elsif @branch_name.present?
+ branch_sufix_or_prefix(@branch_name.match(Gitlab::Regex.composer_package_version_regex))
+ end
+ end
+
+ private
+
+ def branch_sufix_or_prefix(match)
+ if match
+ if match.captures[1] == '.x'
+ match.captures[0] + '-dev'
+ else
+ match.captures[0] + '.x-dev'
+ end
+ else
+ "dev-#{@branch_name}"
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/conan/create_package_file_service.rb b/app/services/packages/conan/create_package_file_service.rb
new file mode 100644
index 00000000000..2db5c4e507b
--- /dev/null
+++ b/app/services/packages/conan/create_package_file_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Packages
+ module Conan
+ class CreatePackageFileService
+ attr_reader :package, :file, :params
+
+ def initialize(package, file, params)
+ @package = package
+ @file = file
+ @params = params
+ end
+
+ def execute
+ package.package_files.create!(
+ file: file,
+ size: params['file.size'],
+ file_name: params[:file_name],
+ file_sha1: params['file.sha1'],
+ file_md5: params['file.md5'],
+ conan_file_metadatum_attributes: {
+ recipe_revision: params[:recipe_revision],
+ package_revision: params[:package_revision],
+ conan_package_reference: params[:conan_package_reference],
+ conan_file_type: params[:conan_file_type]
+ }
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/packages/conan/create_package_service.rb b/app/services/packages/conan/create_package_service.rb
new file mode 100644
index 00000000000..22a0436c5fb
--- /dev/null
+++ b/app/services/packages/conan/create_package_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Packages
+ module Conan
+ class CreatePackageService < BaseService
+ def execute
+ project.packages.create!(
+ name: params[:package_name],
+ version: params[:package_version],
+ package_type: :conan,
+ conan_metadatum_attributes: {
+ package_username: params[:package_username],
+ package_channel: params[:package_channel]
+ }
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/packages/conan/search_service.rb b/app/services/packages/conan/search_service.rb
new file mode 100644
index 00000000000..4513616bad2
--- /dev/null
+++ b/app/services/packages/conan/search_service.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Packages
+ module Conan
+ class SearchService < BaseService
+ include ActiveRecord::Sanitization::ClassMethods
+
+ WILDCARD = '*'
+ RECIPE_SEPARATOR = '@'
+
+ def initialize(user, params)
+ super(nil, user, params)
+ end
+
+ def execute
+ ServiceResponse.success(payload: { results: search_results })
+ end
+
+ private
+
+ def search_results
+ return [] if wildcard_query?
+
+ return search_for_single_package(sanitized_query) if params[:query].include?(RECIPE_SEPARATOR)
+
+ search_packages(build_query)
+ end
+
+ def wildcard_query?
+ params[:query] == WILDCARD
+ end
+
+ def build_query
+ return "#{sanitized_query}%" if params[:query].end_with?(WILDCARD)
+
+ sanitized_query
+ end
+
+ def search_packages(query)
+ ::Packages::Conan::PackageFinder.new(current_user, query: query).execute.map(&:conan_recipe)
+ end
+
+ def search_for_single_package(query)
+ name, version, username, _ = query.split(/[@\/]/)
+ full_path = Packages::Conan::Metadatum.full_path_from(package_username: username)
+ project = Project.find_by_full_path(full_path)
+ return unless current_user.can?(:read_package, project)
+
+ result = project.packages.with_name(name).with_version(version).order_created.last
+ [result&.conan_recipe].compact
+ end
+
+ def sanitized_query
+ @sanitized_query ||= sanitize_sql_like(params[:query].delete(WILDCARD))
+ end
+ end
+ end
+end
diff --git a/app/services/packages/create_dependency_service.rb b/app/services/packages/create_dependency_service.rb
new file mode 100644
index 00000000000..2999885d55d
--- /dev/null
+++ b/app/services/packages/create_dependency_service.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+module Packages
+ class CreateDependencyService < BaseService
+ attr_reader :package, :dependencies
+
+ def initialize(package, dependencies)
+ @package = package
+ @dependencies = dependencies
+ end
+
+ def execute
+ Packages::DependencyLink.dependency_types.each_key do |type|
+ create_dependency(type)
+ end
+ end
+
+ private
+
+ def create_dependency(type)
+ return unless dependencies[type].is_a?(Hash)
+
+ names_and_version_patterns = dependencies[type]
+ existing_ids, existing_names = find_existing_ids_and_names(names_and_version_patterns)
+ dependencies_to_insert = names_and_version_patterns
+
+ if existing_names.any?
+ dependencies_to_insert = names_and_version_patterns.reject { |k, _| k.in?(existing_names) }
+ end
+
+ ActiveRecord::Base.transaction do
+ inserted_ids = bulk_insert_package_dependencies(dependencies_to_insert)
+ bulk_insert_package_dependency_links(type, (existing_ids + inserted_ids))
+ end
+ end
+
+ def find_existing_ids_and_names(names_and_version_patterns)
+ ids_and_names = Packages::Dependency.for_package_names_and_version_patterns(names_and_version_patterns)
+ .pluck_ids_and_names
+ ids = ids_and_names.map(&:first) || []
+ names = ids_and_names.map(&:second) || []
+ [ids, names]
+ end
+
+ def bulk_insert_package_dependencies(names_and_version_patterns)
+ return [] if names_and_version_patterns.empty?
+
+ rows = names_and_version_patterns.map do |name, version_pattern|
+ {
+ name: name,
+ version_pattern: version_pattern
+ }
+ end
+
+ ids = database.bulk_insert(Packages::Dependency.table_name, rows, return_ids: true, on_conflict: :do_nothing)
+ return ids if ids.size == names_and_version_patterns.size
+
+ Packages::Dependency.uncached do
+ # The bulk_insert statement above do not dirty the query cache. To make
+ # sure that the results are fresh from the database and not from a stalled
+ # and potentially wrong cache, this query has to be done with the query
+ # chache disabled.
+ Packages::Dependency.ids_for_package_names_and_version_patterns(names_and_version_patterns)
+ end
+ end
+
+ def bulk_insert_package_dependency_links(type, dependency_ids)
+ rows = dependency_ids.map do |dependency_id|
+ {
+ package_id: package.id,
+ dependency_id: dependency_id,
+ dependency_type: Packages::DependencyLink.dependency_types[type.to_s]
+ }
+ end
+
+ database.bulk_insert(Packages::DependencyLink.table_name, rows)
+ end
+
+ def database
+ ::Gitlab::Database
+ end
+ end
+end
diff --git a/app/services/packages/create_package_file_service.rb b/app/services/packages/create_package_file_service.rb
new file mode 100644
index 00000000000..0ebceeee779
--- /dev/null
+++ b/app/services/packages/create_package_file_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+module Packages
+ class CreatePackageFileService
+ attr_reader :package, :params
+
+ def initialize(package, params)
+ @package = package
+ @params = params
+ end
+
+ def execute
+ package.package_files.create!(
+ file: params[:file],
+ size: params[:size],
+ file_name: params[:file_name],
+ file_sha1: params[:file_sha1],
+ file_sha256: params[:file_sha256],
+ file_md5: params[:file_md5]
+ )
+ end
+ end
+end
diff --git a/app/services/packages/maven/create_package_service.rb b/app/services/packages/maven/create_package_service.rb
new file mode 100644
index 00000000000..aca5d28ca98
--- /dev/null
+++ b/app/services/packages/maven/create_package_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+module Packages
+ module Maven
+ class CreatePackageService < BaseService
+ def execute
+ app_group, _, app_name = params[:name].rpartition('/')
+ app_group.tr!('/', '.')
+
+ package = project.packages.create!(
+ name: params[:name],
+ version: params[:version],
+ package_type: :maven,
+ maven_metadatum_attributes: {
+ path: params[:path],
+ app_group: app_group,
+ app_name: app_name,
+ app_version: params[:version]
+ }
+ )
+
+ build = params[:build]
+ package.create_build_info!(pipeline: build.pipeline) if build.present?
+
+ package
+ end
+ end
+ end
+end
diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb
new file mode 100644
index 00000000000..50a008843ad
--- /dev/null
+++ b/app/services/packages/maven/find_or_create_package_service.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+module Packages
+ module Maven
+ class FindOrCreatePackageService < BaseService
+ MAVEN_METADATA_FILE = 'maven-metadata.xml'.freeze
+
+ def execute
+ package = ::Packages::Maven::PackageFinder
+ .new(params[:path], current_user, project: project).execute
+
+ unless package
+ if params[:file_name] == MAVEN_METADATA_FILE
+ # Maven uploads several files during `mvn deploy` in next order:
+ # - my-company/my-app/1.0-SNAPSHOT/my-app.jar
+ # - my-company/my-app/1.0-SNAPSHOT/my-app.pom
+ # - my-company/my-app/1.0-SNAPSHOT/maven-metadata.xml
+ # - my-company/my-app/maven-metadata.xml
+ #
+ # The last xml file does not have VERSION in URL because it contains
+ # information about all versions.
+ package_name, version = params[:path], nil
+ else
+ package_name, _, version = params[:path].rpartition('/')
+ end
+
+ package_params = {
+ name: package_name,
+ path: params[:path],
+ version: version,
+ build: params[:build]
+ }
+
+ package = ::Packages::Maven::CreatePackageService
+ .new(project, current_user, package_params).execute
+ end
+
+ package
+ end
+ end
+ end
+end
diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb
new file mode 100644
index 00000000000..cf927683ce9
--- /dev/null
+++ b/app/services/packages/npm/create_package_service.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+module Packages
+ module Npm
+ class CreatePackageService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute
+ return error('Version is empty.', 400) if version.blank?
+ return error('Package already exists.', 403) if current_package_exists?
+
+ ActiveRecord::Base.transaction { create_package! }
+ end
+
+ private
+
+ def create_package!
+ package = project.packages.create!(
+ name: name,
+ version: version,
+ package_type: 'npm'
+ )
+
+ if build.present?
+ package.create_build_info!(pipeline: build.pipeline)
+ end
+
+ ::Packages::CreatePackageFileService.new(package, file_params).execute
+ ::Packages::CreateDependencyService.new(package, package_dependencies).execute
+ ::Packages::Npm::CreateTagService.new(package, dist_tag).execute
+
+ package
+ end
+
+ def current_package_exists?
+ project.packages
+ .npm
+ .with_name(name)
+ .with_version(version)
+ .exists?
+ end
+
+ def name
+ params[:name]
+ end
+
+ def version
+ strong_memoize(:version) do
+ params[:versions].each_key.first
+ end
+ end
+
+ def version_data
+ params[:versions][version]
+ end
+
+ def build
+ params[:build]
+ end
+
+ def dist_tag
+ params['dist-tags'].each_key.first
+ end
+
+ def package_file_name
+ strong_memoize(:package_file_name) do
+ "#{name}-#{version}.tgz"
+ end
+ end
+
+ def attachment
+ strong_memoize(:attachment) do
+ params['_attachments'][package_file_name]
+ end
+ end
+
+ def file_params
+ {
+ file: CarrierWaveStringFile.new(Base64.decode64(attachment['data'])),
+ size: attachment['length'],
+ file_sha1: version_data[:dist][:shasum],
+ file_name: package_file_name
+ }
+ end
+
+ def package_dependencies
+ _version, versions_data = params[:versions].first
+ versions_data
+ end
+ end
+ end
+end
diff --git a/app/services/packages/npm/create_tag_service.rb b/app/services/packages/npm/create_tag_service.rb
new file mode 100644
index 00000000000..82974d0ca4b
--- /dev/null
+++ b/app/services/packages/npm/create_tag_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+module Packages
+ module Npm
+ class CreateTagService
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :package, :tag_name
+
+ def initialize(package, tag_name)
+ @package = package
+ @tag_name = tag_name
+ end
+
+ def execute
+ if existing_tag.present?
+ existing_tag.update_column(:package_id, package.id)
+ existing_tag
+ else
+ package.tags.create!(name: tag_name)
+ end
+ end
+
+ private
+
+ def existing_tag
+ strong_memoize(:existing_tag) do
+ Packages::TagsFinder
+ .new(package.project, package.name, package_type: package.package_type)
+ .find_by_name(tag_name)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/nuget/create_dependency_service.rb b/app/services/packages/nuget/create_dependency_service.rb
new file mode 100644
index 00000000000..2be5db732f6
--- /dev/null
+++ b/app/services/packages/nuget/create_dependency_service.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+module Packages
+ module Nuget
+ class CreateDependencyService < BaseService
+ def initialize(package, dependencies = [])
+ @package = package
+ @dependencies = dependencies
+ end
+
+ def execute
+ return if @dependencies.empty?
+
+ @package.transaction do
+ create_dependency_links
+ create_dependency_link_metadata
+ end
+ end
+
+ private
+
+ def create_dependency_links
+ ::Packages::CreateDependencyService
+ .new(@package, dependencies_for_create_dependency_service)
+ .execute
+ end
+
+ def create_dependency_link_metadata
+ inserted_links = ::Packages::DependencyLink.preload_dependency
+ .for_package(@package)
+
+ return if inserted_links.empty?
+
+ rows = inserted_links.map do |dependency_link|
+ raw_dependency = raw_dependency_for(dependency_link.dependency)
+
+ next if raw_dependency[:target_framework].blank?
+
+ {
+ dependency_link_id: dependency_link.id,
+ target_framework: raw_dependency[:target_framework]
+ }
+ end
+
+ ::Gitlab::Database.bulk_insert(::Packages::Nuget::DependencyLinkMetadatum.table_name, rows.compact)
+ end
+
+ def raw_dependency_for(dependency)
+ name = dependency.name
+ version = dependency.version_pattern.presence
+
+ @dependencies.find do |raw_dependency|
+ raw_dependency[:name] == name && raw_dependency[:version] == version
+ end
+ end
+
+ def dependencies_for_create_dependency_service
+ names_and_versions = @dependencies.map do |dependency|
+ [dependency[:name], version_or_empty_string(dependency[:version])]
+ end.to_h
+
+ { 'dependencies' => names_and_versions }
+ end
+
+ def version_or_empty_string(version)
+ return '' if version.blank?
+
+ version
+ end
+ end
+ end
+end
diff --git a/app/services/packages/nuget/create_package_service.rb b/app/services/packages/nuget/create_package_service.rb
new file mode 100644
index 00000000000..68ad7f028e4
--- /dev/null
+++ b/app/services/packages/nuget/create_package_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class CreatePackageService < BaseService
+ TEMPORARY_PACKAGE_NAME = 'NuGet.Temporary.Package'
+ PACKAGE_VERSION = '0.0.0'
+
+ def execute
+ project.packages.nuget.create!(
+ name: TEMPORARY_PACKAGE_NAME,
+ version: "#{PACKAGE_VERSION}-#{uuid}"
+ )
+ end
+
+ private
+
+ def uuid
+ SecureRandom.uuid
+ end
+ end
+ end
+end
diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb
new file mode 100644
index 00000000000..6fec398fab0
--- /dev/null
+++ b/app/services/packages/nuget/metadata_extraction_service.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class MetadataExtractionService
+ include Gitlab::Utils::StrongMemoize
+
+ ExtractionError = Class.new(StandardError)
+
+ XPATHS = {
+ package_name: '//xmlns:package/xmlns:metadata/xmlns:id',
+ package_version: '//xmlns:package/xmlns:metadata/xmlns:version',
+ license_url: '//xmlns:package/xmlns:metadata/xmlns:licenseUrl',
+ project_url: '//xmlns:package/xmlns:metadata/xmlns:projectUrl',
+ icon_url: '//xmlns:package/xmlns:metadata/xmlns:iconUrl'
+ }.freeze
+
+ XPATH_DEPENDENCIES = '//xmlns:package/xmlns:metadata/xmlns:dependencies/xmlns:dependency'
+ XPATH_DEPENDENCY_GROUPS = '//xmlns:package/xmlns:metadata/xmlns:dependencies/xmlns:group'
+ XPATH_TAGS = '//xmlns:package/xmlns:metadata/xmlns:tags'
+
+ MAX_FILE_SIZE = 4.megabytes.freeze
+
+ def initialize(package_file_id)
+ @package_file_id = package_file_id
+ end
+
+ def execute
+ raise ExtractionError.new('invalid package file') unless valid_package_file?
+
+ extract_metadata(nuspec_file)
+ end
+
+ private
+
+ def package_file
+ strong_memoize(:package_file) do
+ ::Packages::PackageFile.find_by_id(@package_file_id)
+ end
+ end
+
+ def valid_package_file?
+ package_file &&
+ package_file.package&.nuget? &&
+ package_file.file.size.positive?
+ end
+
+ def extract_metadata(file)
+ doc = Nokogiri::XML(file)
+
+ XPATHS.transform_values { |query| doc.xpath(query).text.presence }
+ .compact
+ .tap do |metadata|
+ metadata[:package_dependencies] = extract_dependencies(doc)
+ metadata[:package_tags] = extract_tags(doc)
+ end
+ end
+
+ def extract_dependencies(doc)
+ dependencies = []
+
+ doc.xpath(XPATH_DEPENDENCIES).each do |node|
+ dependencies << extract_dependency(node)
+ end
+
+ doc.xpath(XPATH_DEPENDENCY_GROUPS).each do |group_node|
+ target_framework = group_node.attr("targetFramework")
+
+ group_node.xpath("xmlns:dependency").each do |node|
+ dependencies << extract_dependency(node).merge(target_framework: target_framework)
+ end
+ end
+
+ dependencies
+ end
+
+ def extract_dependency(node)
+ {
+ name: node.attr('id'),
+ version: node.attr('version')
+ }.compact
+ end
+
+ def extract_tags(doc)
+ tags = doc.xpath(XPATH_TAGS).text
+
+ return [] if tags.blank?
+
+ tags.split(::Packages::Tag::NUGET_TAGS_SEPARATOR)
+ end
+
+ def nuspec_file
+ package_file.file.use_file do |file_path|
+ Zip::File.open(file_path) do |zip_file|
+ entry = zip_file.glob('*.nuspec').first
+
+ raise ExtractionError.new('nuspec file not found') unless entry
+ raise ExtractionError.new('nuspec file too big') if entry.size > MAX_FILE_SIZE
+
+ entry.get_input_stream.read
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/nuget/search_service.rb b/app/services/packages/nuget/search_service.rb
new file mode 100644
index 00000000000..f7e09e11819
--- /dev/null
+++ b/app/services/packages/nuget/search_service.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class SearchService < BaseService
+ include Gitlab::Utils::StrongMemoize
+ include ActiveRecord::ConnectionAdapters::Quoting
+
+ MAX_PER_PAGE = 30
+ MAX_VERSIONS_PER_PACKAGE = 10
+ PRE_RELEASE_VERSION_MATCHING_TERM = '%-%'
+
+ DEFAULT_OPTIONS = {
+ include_prerelease_versions: true,
+ per_page: Kaminari.config.default_per_page,
+ padding: 0
+ }.freeze
+
+ def initialize(project, search_term, options = {})
+ @project = project
+ @search_term = search_term
+ @options = DEFAULT_OPTIONS.merge(options)
+
+ raise ArgumentError, 'negative per_page' if per_page.negative?
+ raise ArgumentError, 'negative padding' if padding.negative?
+ end
+
+ def execute
+ OpenStruct.new(
+ total_count: package_names.total_count,
+ results: search_packages
+ )
+ end
+
+ private
+
+ def search_packages
+ # custom query to get package names and versions as expected from the nuget search api
+ # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24182#technical-notes
+ # and https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource
+ subquery_name = :partition_subquery
+ arel_table = Arel::Table.new(:partition_subquery)
+ column_names = Packages::Package.column_names.map do |cn|
+ "#{subquery_name}.#{quote_column_name(cn)}"
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ pkgs = Packages::Package.select(column_names.join(','))
+ .from(package_names_partition, subquery_name)
+ .where(arel_table[:row_number].lteq(MAX_VERSIONS_PER_PACKAGE))
+
+ return pkgs if include_prerelease_versions?
+
+ # we can't use pkgs.without_version_like since we have a custom from
+ pkgs.where.not(arel_table[:version].matches(PRE_RELEASE_VERSION_MATCHING_TERM))
+ end
+
+ def package_names_partition
+ table_name = quote_table_name(Packages::Package.table_name)
+ name_column = "#{table_name}.#{quote_column_name('name')}"
+ created_at_column = "#{table_name}.#{quote_column_name('created_at')}"
+ select_sql = "ROW_NUMBER() OVER (PARTITION BY #{name_column} ORDER BY #{created_at_column} DESC) AS row_number, #{table_name}.*"
+
+ @project.packages
+ .select(select_sql)
+ .nuget
+ .has_version
+ .without_nuget_temporary_name
+ .with_name(package_names)
+ end
+
+ def package_names
+ strong_memoize(:package_names) do
+ pkgs = @project.packages
+ .nuget
+ .has_version
+ .without_nuget_temporary_name
+ .order_name
+ .select_distinct_name
+ pkgs = pkgs.without_version_like(PRE_RELEASE_VERSION_MATCHING_TERM) unless include_prerelease_versions?
+ pkgs = pkgs.search_by_name(@search_term) if @search_term.present?
+ pkgs.page(0) # we're using a padding
+ .per(per_page)
+ .padding(padding)
+ end
+ end
+
+ def include_prerelease_versions?
+ @options[:include_prerelease_versions]
+ end
+
+ def padding
+ @options[:padding]
+ end
+
+ def per_page
+ [@options[:per_page], MAX_PER_PAGE].min
+ end
+ end
+ end
+end
diff --git a/app/services/packages/nuget/sync_metadatum_service.rb b/app/services/packages/nuget/sync_metadatum_service.rb
new file mode 100644
index 00000000000..ca9cc4d5b78
--- /dev/null
+++ b/app/services/packages/nuget/sync_metadatum_service.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class SyncMetadatumService
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(package, metadata)
+ @package = package
+ @metadata = metadata
+ end
+
+ def execute
+ if blank_metadata?
+ metadatum.destroy! if metadatum.persisted?
+ else
+ metadatum.update!(
+ license_url: license_url,
+ project_url: project_url,
+ icon_url: icon_url
+ )
+ end
+ end
+
+ private
+
+ def metadatum
+ strong_memoize(:metadatum) do
+ @package.nuget_metadatum || @package.build_nuget_metadatum
+ end
+ end
+
+ def blank_metadata?
+ project_url.blank? && license_url.blank? && icon_url.blank?
+ end
+
+ def project_url
+ @metadata[:project_url]
+ end
+
+ def license_url
+ @metadata[:license_url]
+ end
+
+ def icon_url
+ @metadata[:icon_url]
+ end
+ end
+ end
+end
diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb
new file mode 100644
index 00000000000..f72b1386985
--- /dev/null
+++ b/app/services/packages/nuget/update_package_from_metadata_service.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class UpdatePackageFromMetadataService
+ include Gitlab::Utils::StrongMemoize
+ include ExclusiveLeaseGuard
+
+ # used by ExclusiveLeaseGuard
+ DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze
+
+ InvalidMetadataError = Class.new(StandardError)
+
+ def initialize(package_file)
+ @package_file = package_file
+ end
+
+ def execute
+ raise InvalidMetadataError.new('package name and/or package version not found in metadata') unless valid_metadata?
+
+ try_obtain_lease do
+ @package_file.transaction do
+ package = existing_package ? link_to_existing_package : update_linked_package
+
+ update_package(package)
+
+ # Updating file_name updates the path where the file is stored.
+ # We must pass the file again so that CarrierWave can handle the update
+ @package_file.update!(
+ file_name: package_filename,
+ file: @package_file.file
+ )
+ end
+ end
+ end
+
+ private
+
+ def update_package(package)
+ ::Packages::Nuget::SyncMetadatumService
+ .new(package, metadata.slice(:project_url, :license_url, :icon_url))
+ .execute
+ ::Packages::UpdateTagsService
+ .new(package, package_tags)
+ .execute
+ rescue => e
+ raise InvalidMetadataError, e.message
+ end
+
+ def valid_metadata?
+ package_name.present? && package_version.present?
+ end
+
+ def link_to_existing_package
+ package_to_destroy = @package_file.package
+ # Updating package_id updates the path where the file is stored.
+ # We must pass the file again so that CarrierWave can handle the update
+ @package_file.update!(
+ package_id: existing_package.id,
+ file: @package_file.file
+ )
+ package_to_destroy.destroy!
+ existing_package
+ end
+
+ def update_linked_package
+ @package_file.package.update!(
+ name: package_name,
+ version: package_version
+ )
+
+ ::Packages::Nuget::CreateDependencyService.new(@package_file.package, package_dependencies)
+ .execute
+ @package_file.package
+ end
+
+ def existing_package
+ strong_memoize(:existing_package) do
+ @package_file.project.packages
+ .nuget
+ .with_name(package_name)
+ .with_version(package_version)
+ .first
+ end
+ end
+
+ def package_name
+ metadata[:package_name]
+ end
+
+ def package_version
+ metadata[:package_version]
+ end
+
+ def package_dependencies
+ metadata.fetch(:package_dependencies, [])
+ end
+
+ def package_tags
+ metadata.fetch(:package_tags, [])
+ end
+
+ def metadata
+ strong_memoize(:metadata) do
+ ::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute
+ end
+ end
+
+ def package_filename
+ "#{package_name.downcase}.#{package_version.downcase}.nupkg"
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_key
+ package_id = existing_package ? existing_package.id : @package_file.package_id
+ "packages:nuget:update_package_from_metadata_service:package:#{package_id}"
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_timeout
+ DEFAULT_LEASE_TIMEOUT
+ end
+ end
+ end
+end
diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb
new file mode 100644
index 00000000000..1313fc80e33
--- /dev/null
+++ b/app/services/packages/pypi/create_package_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Packages
+ module Pypi
+ class CreatePackageService < BaseService
+ include ::Gitlab::Utils::StrongMemoize
+
+ def execute
+ ::Packages::Package.transaction do
+ Packages::Pypi::Metadatum.upsert(
+ package_id: created_package.id,
+ required_python: params[:requires_python]
+ )
+
+ ::Packages::CreatePackageFileService.new(created_package, file_params).execute
+ end
+ end
+
+ private
+
+ def created_package
+ strong_memoize(:created_package) do
+ project
+ .packages
+ .pypi
+ .safe_find_or_create_by!(name: params[:name], version: params[:version])
+ end
+ end
+
+ def file_params
+ {
+ file: params[:content],
+ file_name: params[:content].original_filename,
+ file_md5: params[:md5_digest],
+ file_sha256: params[:sha256_digest]
+ }
+ end
+ end
+ end
+end
diff --git a/app/services/packages/remove_tag_service.rb b/app/services/packages/remove_tag_service.rb
new file mode 100644
index 00000000000..465b85506a6
--- /dev/null
+++ b/app/services/packages/remove_tag_service.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+module Packages
+ class RemoveTagService < BaseService
+ attr_reader :package_tag
+
+ def initialize(package_tag)
+ raise ArgumentError, "Package tag must be set" if package_tag.blank?
+
+ @package_tag = package_tag
+ end
+
+ def execute
+ package_tag.delete
+ end
+ end
+end
diff --git a/app/services/packages/update_tags_service.rb b/app/services/packages/update_tags_service.rb
new file mode 100644
index 00000000000..da50cd3479e
--- /dev/null
+++ b/app/services/packages/update_tags_service.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+module Packages
+ class UpdateTagsService
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(package, tags = [])
+ @package = package
+ @tags = tags
+ end
+
+ def execute
+ return if @tags.empty?
+
+ tags_to_destroy = existing_tags - @tags
+ tags_to_create = @tags - existing_tags
+
+ @package.tags.with_name(tags_to_destroy).delete_all if tags_to_destroy.any?
+ ::Gitlab::Database.bulk_insert(Packages::Tag.table_name, rows(tags_to_create)) if tags_to_create.any?
+ end
+
+ private
+
+ def existing_tags
+ strong_memoize(:existing_tags) do
+ @package.tag_names
+ end
+ end
+
+ def rows(tags)
+ now = Time.zone.now
+ tags.map do |tag|
+ {
+ package_id: @package.id,
+ name: tag,
+ created_at: now,
+ updated_at: now
+ }
+ end
+ end
+ end
+end
diff --git a/app/services/personal_access_tokens/last_used_service.rb b/app/services/personal_access_tokens/last_used_service.rb
new file mode 100644
index 00000000000..9066fd1acdf
--- /dev/null
+++ b/app/services/personal_access_tokens/last_used_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module PersonalAccessTokens
+ class LastUsedService
+ def initialize(personal_access_token)
+ @personal_access_token = personal_access_token
+ end
+
+ def execute
+ # Needed to avoid calling service on Oauth tokens
+ return unless @personal_access_token.has_attribute?(:last_used_at)
+
+ # We _only_ want to update last_used_at and not also updated_at (which
+ # would be updated when using #touch).
+ @personal_access_token.update_column(:last_used_at, Time.zone.now) if update?
+ end
+
+ private
+
+ def update?
+ return false if ::Gitlab::Database.read_only?
+
+ last_used = @personal_access_token.last_used_at
+
+ last_used.nil? || (last_used <= 1.day.ago)
+ end
+ end
+end
diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb
index 65e6ebc17d2..69c9868c75c 100644
--- a/app/services/post_receive_service.rb
+++ b/app/services/post_receive_service.rb
@@ -29,8 +29,6 @@ class PostReceiveService
response.add_alert_message(message)
end
- response.add_alert_message(storage_size_limit_alert)
-
broadcast_message = BroadcastMessage.current_banner_messages&.last&.message
response.add_alert_message(broadcast_message)
@@ -76,19 +74,6 @@ class PostReceiveService
::MergeRequests::GetUrlsService.new(project).execute(params[:changes])
end
-
- private
-
- def storage_size_limit_alert
- return unless repository&.repo_type&.project?
-
- payload = Namespaces::CheckStorageSizeService.new(project.namespace, user).execute.payload
- return unless payload.present?
-
- alert_level = "##### #{payload[:alert_level].to_s.upcase} #####"
-
- [alert_level, payload[:usage_message], payload[:explanation_message]].join("\n")
- end
end
PostReceiveService.prepend_if_ee('EE::PostReceiveService')
diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb
index fad2290a47b..b37ae56ba0f 100644
--- a/app/services/projects/after_import_service.rb
+++ b/app/services/projects/after_import_service.rb
@@ -26,7 +26,7 @@ module Projects
message: 'Project housekeeping failed',
project_full_path: @project.full_path,
project_id: @project.id,
- error: e.message
+ 'error.message' => e.message
)
end
diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb
index 86c408aeec8..e08bc8efb15 100644
--- a/app/services/projects/alerting/notify_service.rb
+++ b/app/services/projects/alerting/notify_service.rb
@@ -4,7 +4,7 @@ module Projects
module Alerting
class NotifyService < BaseService
include Gitlab::Utils::StrongMemoize
- include IncidentManagement::Settings
+ include ::IncidentManagement::Settings
def execute(token)
return forbidden unless alerts_service_activated?
@@ -55,7 +55,7 @@ module Projects
def find_alert_by_fingerprint(fingerprint)
return unless fingerprint
- AlertManagement::Alert.for_fingerprint(project, fingerprint).first
+ AlertManagement::Alert.not_resolved.for_fingerprint(project, fingerprint).first
end
def send_email?
@@ -65,8 +65,7 @@ module Projects
def process_incident_issues(alert)
return if alert.issue
- IncidentManagement::ProcessAlertWorker
- .perform_async(project.id, parsed_payload, alert.id)
+ ::IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id)
end
def send_alert_email
@@ -76,7 +75,7 @@ module Projects
end
def parsed_payload
- Gitlab::Alerting::NotificationPayloadParser.call(params.to_h)
+ Gitlab::Alerting::NotificationPayloadParser.call(params.to_h, project)
end
def valid_token?(token)
diff --git a/app/services/projects/batch_forks_count_service.rb b/app/services/projects/batch_forks_count_service.rb
index 6467744a435..d12772b40ff 100644
--- a/app/services/projects/batch_forks_count_service.rb
+++ b/app/services/projects/batch_forks_count_service.rb
@@ -5,6 +5,21 @@
# because the service use maps to retrieve the project ids
module Projects
class BatchForksCountService < Projects::BatchCountService
+ def refresh_cache_and_retrieve_data
+ count_services = @projects.map { |project| count_service.new(project) }
+
+ values = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+ Rails.cache.fetch_multi(*(count_services.map { |ser| ser.cache_key } )) { |key| nil }
+ end
+
+ results_per_service = Hash[count_services.zip(values.values)]
+ projects_to_refresh = results_per_service.select { |_k, value| value.nil? }
+ projects_to_refresh = recreate_cache(projects_to_refresh)
+
+ results_per_service.update(projects_to_refresh)
+ results_per_service.transform_keys { |k| k.project }
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def global_count
@global_count ||= begin
@@ -18,5 +33,13 @@ module Projects
def count_service
::Projects::ForksCountService
end
+
+ def recreate_cache(projects_to_refresh)
+ projects_to_refresh.each_with_object({}) do |(service, _v), hash|
+ count = global_count[service.project.id].to_i
+ service.refresh_cache { count }
+ hash[service] = count
+ end
+ end
end
end
diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb
index 21081bd077f..5d4059710bb 100644
--- a/app/services/projects/container_repository/delete_tags_service.rb
+++ b/app/services/projects/container_repository/delete_tags_service.rb
@@ -3,6 +3,8 @@
module Projects
module ContainerRepository
class DeleteTagsService < BaseService
+ LOG_DATA_BASE = { service_class: self.to_s }.freeze
+
def execute(container_repository)
return error('access denied') unless can?(current_user, :destroy_container_image, project)
@@ -51,10 +53,27 @@ module Projects
def smart_delete(container_repository, tag_names)
fast_delete_enabled = Feature.enabled?(:container_registry_fast_tag_delete, default_enabled: true)
- if fast_delete_enabled && container_repository.client.supports_tag_delete?
- fast_delete(container_repository, tag_names)
+ response = if fast_delete_enabled && container_repository.client.supports_tag_delete?
+ fast_delete(container_repository, tag_names)
+ else
+ slow_delete(container_repository, tag_names)
+ end
+
+ response.tap { |r| log_response(r, container_repository) }
+ end
+
+ def log_response(response, container_repository)
+ log_data = LOG_DATA_BASE.merge(
+ container_repository_id: container_repository.id,
+ message: 'deleted tags'
+ )
+
+ if response[:status] == :success
+ log_data[:deleted_tags_count] = response[:deleted].size
+ log_info(log_data)
else
- slow_delete(container_repository, tag_names)
+ log_data[:message] = response[:message]
+ log_error(log_data)
end
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index bffd443c49f..6569277ad9d 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -84,8 +84,12 @@ module Projects
def after_create_actions
log_info("#{@project.owner.name} created a new project \"#{@project.full_name}\"")
+ # Skip writing the config for project imports/forks because it
+ # will always fail since the Git directory doesn't exist until
+ # a background job creates it (see Project#add_import_job).
+ @project.write_repository_config unless @project.import?
+
unless @project.gitlab_project_import?
- @project.write_repository_config
@project.create_wiki unless skip_wiki?
end
@@ -103,12 +107,13 @@ module Projects
create_readme if @initialize_with_readme
end
- # Refresh the current user's authorizations inline (so they can access the
- # project immediately after this request completes), and any other affected
- # users in the background
+ # Add an authorization for the current user authorizations inline
+ # (so they can access the project immediately after this request
+ # completes), and any other affected users in the background
def setup_authorizations
if @project.group
- current_user.refresh_authorized_projects
+ current_user.project_authorizations.create!(project: @project,
+ access_level: @project.group.max_member_access_for_user(current_user))
if Feature.enabled?(:specialized_project_authorization_workers)
AuthorizedProjectUpdate::ProjectCreateWorker.perform_async(@project.id)
@@ -131,7 +136,7 @@ module Projects
def create_readme
commit_attrs = {
- branch_name: 'master',
+ branch_name: Gitlab::CurrentSettings.default_branch_name.presence || 'master',
commit_message: 'Initial commit',
file_path: 'README.md',
file_content: "# #{@project.name}\n\n#{@project.description}"
diff --git a/app/services/projects/forks_count_service.rb b/app/services/projects/forks_count_service.rb
index ca85e2dc281..848d8d54104 100644
--- a/app/services/projects/forks_count_service.rb
+++ b/app/services/projects/forks_count_service.rb
@@ -3,6 +3,8 @@
module Projects
# Service class for getting and caching the number of forks of a project.
class ForksCountService < Projects::CountService
+ attr_reader :project
+
def cache_key_name
'forks_count'
end
diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb
index 2ba3cd6694f..3c3cab26fb5 100644
--- a/app/services/projects/group_links/create_service.rb
+++ b/app/services/projects/group_links/create_service.rb
@@ -13,12 +13,32 @@ module Projects
)
if link.save
- group.refresh_members_authorized_projects
+ setup_authorizations(group)
success(link: link)
else
error(link.errors.full_messages.to_sentence, 409)
end
end
+
+ private
+
+ def setup_authorizations(group)
+ if Feature.enabled?(:specialized_project_authorization_project_share_worker)
+ AuthorizedProjectUpdate::ProjectGroupLinkCreateWorker.perform_async(project.id, group.id)
+
+ # AuthorizedProjectsWorker uses an exclusive lease per user but
+ # specialized workers might have synchronization issues. Until we
+ # compare the inconsistency rates of both approaches, we still run
+ # AuthorizedProjectsWorker but with some delay and lower urgency as a
+ # safety net.
+ group.refresh_members_authorized_projects(
+ blocking: false,
+ priority: UserProjectAccessChangedService::LOW_PRIORITY
+ )
+ else
+ group.refresh_members_authorized_projects(blocking: false)
+ end
+ end
end
end
end
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index 7aa7ea73639..7af489c3751 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -108,7 +108,18 @@ module Projects
end
def incident_management_setting_params
- params.slice(:incident_management_setting_attributes)
+ attrs = params[:incident_management_setting_attributes]
+ return {} unless attrs
+
+ regenerate_token = attrs.delete(:regenerate_token)
+
+ if regenerate_token
+ attrs[:pagerduty_token] = nil
+ else
+ attrs = attrs.except(:pagerduty_token)
+ end
+
+ { incident_management_setting_attributes: attrs }
end
end
end
diff --git a/app/services/projects/prometheus/alerts/create_events_service.rb b/app/services/projects/prometheus/alerts/create_events_service.rb
deleted file mode 100644
index 4fcf841314b..00000000000
--- a/app/services/projects/prometheus/alerts/create_events_service.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- module Prometheus
- module Alerts
- # Persists a series of Prometheus alert events as list of PrometheusAlertEvent.
- class CreateEventsService < BaseService
- def execute
- create_events_from(alerts)
- end
-
- private
-
- def create_events_from(alerts)
- Array.wrap(alerts).map { |alert| create_event(alert) }.compact
- end
-
- def create_event(payload)
- parsed_alert = Gitlab::Alerting::Alert.new(project: project, payload: payload)
-
- return unless parsed_alert.valid?
-
- if parsed_alert.gitlab_managed?
- create_managed_prometheus_alert_event(parsed_alert)
- else
- create_self_managed_prometheus_alert_event(parsed_alert)
- end
- end
-
- def alerts
- params['alerts']
- end
-
- def find_alert(metric)
- Projects::Prometheus::AlertsFinder
- .new(project: project, metric: metric)
- .execute
- .first
- end
-
- def create_managed_prometheus_alert_event(parsed_alert)
- alert = find_alert(parsed_alert.metric_id)
- event = PrometheusAlertEvent.find_or_initialize_by_payload_key(parsed_alert.project, alert, parsed_alert.gitlab_fingerprint)
-
- set_status(parsed_alert, event)
- end
-
- def create_self_managed_prometheus_alert_event(parsed_alert)
- event = SelfManagedPrometheusAlertEvent.find_or_initialize_by_payload_key(parsed_alert.project, parsed_alert.gitlab_fingerprint) do |event|
- event.environment = parsed_alert.environment
- event.title = parsed_alert.title
- event.query_expression = parsed_alert.full_query
- end
-
- set_status(parsed_alert, event)
- end
-
- def set_status(parsed_alert, event)
- persisted = case parsed_alert.status
- when 'firing'
- event.fire(parsed_alert.starts_at)
- when 'resolved'
- event.resolve(parsed_alert.ends_at)
- end
-
- event if persisted
- end
- end
- end
- end
-end
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index 877a4f99a94..ea557ebe20f 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -5,7 +5,7 @@ module Projects
module Alerts
class NotifyService < BaseService
include Gitlab::Utils::StrongMemoize
- include IncidentManagement::Settings
+ include ::IncidentManagement::Settings
# This set of keys identifies a payload as a valid Prometheus
# payload and thus processable by this service. See also
@@ -23,9 +23,7 @@ module Projects
return unauthorized unless valid_alert_manager_token?(token)
process_prometheus_alerts
- persist_events
send_alert_email if send_email?
- process_incident_issues if process_issues?
ServiceResponse.success
end
@@ -132,13 +130,6 @@ module Projects
.prometheus_alerts_fired(project, firings)
end
- def process_incident_issues
- alerts.each do |alert|
- IncidentManagement::ProcessPrometheusAlertWorker
- .perform_async(project.id, alert.to_h)
- end
- end
-
def process_prometheus_alerts
alerts.each do |alert|
AlertManagement::ProcessPrometheusAlertService
@@ -147,10 +138,6 @@ module Projects
end
end
- def persist_events
- CreateEventsService.new(project, nil, params).execute
- end
-
def bad_request
ServiceResponse.error(message: 'Bad Request', http_status: :bad_request)
end
diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb
index 4adcda042d1..b6465810fde 100644
--- a/app/services/projects/propagate_service_template.rb
+++ b/app/services/projects/propagate_service_template.rb
@@ -26,7 +26,7 @@ module Projects
def propagate_projects_with_template
loop do
- batch = Project.uncached { project_ids_without_integration }
+ batch = Project.uncached { Project.ids_without_integration(template, BATCH_SIZE) }
bulk_create_from_template(batch) unless batch.empty?
@@ -50,22 +50,6 @@ module Projects
end
end
- # rubocop: disable CodeReuse/ActiveRecord
- def project_ids_without_integration
- services = Service
- .select('1')
- .where('services.project_id = projects.id')
- .where(type: template.type)
-
- Project
- .where('NOT EXISTS (?)', services)
- .where(pending_delete: false)
- .where(archived: false)
- .limit(BATCH_SIZE)
- .pluck(:id)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def bulk_insert(klass, columns, values_array)
items_to_insert = values_array.map { |array| Hash[columns.zip(array)] }
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
index 5f8ef75a8d7..d6c0d647468 100644
--- a/app/services/projects/update_remote_mirror_service.rb
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -29,7 +29,7 @@ module Projects
remote_mirror.ensure_remote!
# https://gitlab.com/gitlab-org/gitaly/-/issues/2670
- if Feature.disabled?(:gitaly_ruby_remote_branches_ls_remote)
+ if Feature.disabled?(:gitaly_ruby_remote_branches_ls_remote, default_enabled: true)
repository.fetch_remote(remote_mirror.remote_name, ssh_auth: remote_mirror, no_tags: true)
end
diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb
index fa8d4c5aa5f..7b346c09635 100644
--- a/app/services/projects/update_repository_storage_service.rb
+++ b/app/services/projects/update_repository_storage_service.rb
@@ -14,7 +14,11 @@ module Projects
end
def execute
- repository_storage_move.start!
+ repository_storage_move.with_lock do
+ return ServiceResponse.success unless repository_storage_move.scheduled? # rubocop:disable Cop/AvoidReturnFromBlocks
+
+ repository_storage_move.start!
+ end
raise SameFilesystemError if same_filesystem?(repository.storage, destination_storage_name)
@@ -79,8 +83,6 @@ module Projects
full_path
)
- new_repository.create_repository
-
new_repository.replicate(raw_repository)
new_checksum = new_repository.checksum
@@ -93,25 +95,25 @@ module Projects
old_repository_storage = project.repository_storage
new_project_path = moved_path(project.disk_path)
- # Notice that the block passed to `run_after_commit` will run with `project`
+ # Notice that the block passed to `run_after_commit` will run with `repository_storage_move`
# as its context
- project.run_after_commit do
+ repository_storage_move.run_after_commit do
GitlabShellWorker.perform_async(:mv_repository,
old_repository_storage,
- disk_path,
+ project.disk_path,
new_project_path)
- if wiki.repository_exists?
+ if project.wiki.repository_exists?
GitlabShellWorker.perform_async(:mv_repository,
old_repository_storage,
- wiki.disk_path,
+ project.wiki.disk_path,
"#{new_project_path}.wiki")
end
- if design_repository.exists?
+ if project.design_repository.exists?
GitlabShellWorker.perform_async(:mv_repository,
old_repository_storage,
- design_repository.disk_path,
+ project.design_repository.disk_path,
"#{new_project_path}.design")
end
end
diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb
index e0bc5518d30..33635796771 100644
--- a/app/services/prometheus/proxy_service.rb
+++ b/app/services/prometheus/proxy_service.rb
@@ -22,16 +22,20 @@ module Prometheus
attr_accessor :proxyable, :method, :path, :params
+ PROMETHEUS_QUERY_API = 'query'
+ PROMETHEUS_QUERY_RANGE_API = 'query_range'
+ PROMETHEUS_SERIES_API = 'series'
+
PROXY_SUPPORT = {
- 'query' => {
+ PROMETHEUS_QUERY_API => {
method: ['GET'],
params: %w(query time timeout)
},
- 'query_range' => {
+ PROMETHEUS_QUERY_RANGE_API => {
method: ['GET'],
params: %w(query start end step timeout)
},
- 'series' => {
+ PROMETHEUS_SERIES_API => {
method: %w(GET),
params: %w(match start end)
}
diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb
index 10fb3a8c1b5..820b551c30a 100644
--- a/app/services/prometheus/proxy_variable_substitution_service.rb
+++ b/app/services/prometheus/proxy_variable_substitution_service.rb
@@ -19,10 +19,52 @@ module Prometheus
:substitute_params,
:substitute_variables
+ # @param environment [Environment]
+ # @param params [Hash<Symbol,Any>]
+ # @param params - query [String] The Prometheus query string.
+ # @param params - start [String] (optional) A time string in the rfc3339 format.
+ # @param params - start_time [String] (optional) A time string in the rfc3339 format.
+ # @param params - end [String] (optional) A time string in the rfc3339 format.
+ # @param params - end_time [String] (optional) A time string in the rfc3339 format.
+ # @param params - variables [ActionController::Parameters] (optional) Variables with their values.
+ # The keys in the Hash should be the name of the variable. The value should be the value of the
+ # variable. Ex: `ActionController::Parameters.new(variable1: 'value 1', variable2: 'value 2').permit!`
+ # @return [Prometheus::ProxyVariableSubstitutionService]
+ #
+ # Example:
+ # Prometheus::ProxyVariableSubstitutionService.new(environment, {
+ # params: {
+ # start_time: '2020-07-03T06:08:36Z',
+ # end_time: '2020-07-03T14:08:52Z',
+ # query: 'up{instance="{{instance}}"}',
+ # variables: { instance: 'srv1' }
+ # }
+ # })
def initialize(environment, params = {})
@environment, @params = environment, params.deep_dup
end
+ # @return - params [Hash<Symbol,Any>] Returns a Hash containing a params key which is
+ # similar to the `params` that is passed to the initialize method with 2 differences:
+ # 1. Variables in the query string are substituted with their values.
+ # If a variable present in the query string has no known value (values
+ # are obtained from the `variables` Hash in `params` or from
+ # `Gitlab::Prometheus::QueryVariables.call`), it will not be substituted.
+ # 2. `start` and `end` keys are added, with their values copied from `start_time`
+ # and `end_time`.
+ #
+ # Example output:
+ #
+ # {
+ # params: {
+ # start_time: '2020-07-03T06:08:36Z',
+ # start: '2020-07-03T06:08:36Z',
+ # end_time: '2020-07-03T14:08:52Z',
+ # end: '2020-07-03T14:08:52Z',
+ # query: 'up{instance="srv1"}',
+ # variables: { instance: 'srv1' }
+ # }
+ # }
def execute
execute_steps
end
diff --git a/app/services/releases/create_evidence_service.rb b/app/services/releases/create_evidence_service.rb
index ac13dce1729..9c370722d2c 100644
--- a/app/services/releases/create_evidence_service.rb
+++ b/app/services/releases/create_evidence_service.rb
@@ -10,7 +10,7 @@ module Releases
def execute
evidence = release.evidences.build
- summary = Evidences::EvidenceSerializer.new.represent(evidence) # rubocop: disable CodeReuse/Serializer
+ summary = ::Evidences::EvidenceSerializer.new.represent(evidence, evidence_options) # rubocop: disable CodeReuse/Serializer
evidence.summary = summary
# TODO: fix the sha generating https://gitlab.com/gitlab-org/gitlab/-/issues/209000
evidence.summary_sha = Gitlab::CryptoHelper.sha256(summary)
@@ -20,6 +20,12 @@ module Releases
private
- attr_reader :release
+ attr_reader :release, :pipeline
+
+ def evidence_options
+ {}
+ end
end
end
+
+Releases::CreateEvidenceService.prepend_if_ee('EE::Releases::CreateEvidenceService')
diff --git a/app/services/repositories/base_service.rb b/app/services/repositories/base_service.rb
index a99a65b7edb..efb6f6de8db 100644
--- a/app/services/repositories/base_service.rb
+++ b/app/services/repositories/base_service.rb
@@ -8,20 +8,19 @@ class Repositories::BaseService < BaseService
attr_reader :repository
delegate :container, :disk_path, :full_path, to: :repository
- delegate :repository_storage, to: :container
def initialize(repository)
@repository = repository
end
def repo_exists?(path)
- gitlab_shell.repository_exists?(repository_storage, path + '.git')
+ gitlab_shell.repository_exists?(repository.shard, path + '.git')
end
def mv_repository(from_path, to_path)
return true unless repo_exists?(from_path)
- gitlab_shell.mv_repository(repository_storage, from_path, to_path)
+ gitlab_shell.mv_repository(repository.shard, from_path, to_path)
end
# Build a path for removing repositories
diff --git a/app/services/repositories/destroy_service.rb b/app/services/repositories/destroy_service.rb
index b12d0744387..1e34dfbe398 100644
--- a/app/services/repositories/destroy_service.rb
+++ b/app/services/repositories/destroy_service.rb
@@ -14,8 +14,17 @@ class Repositories::DestroyService < Repositories::BaseService
log_info(%Q{Repository "#{disk_path}" moved to "#{removal_path}" for repository "#{full_path}"})
current_repository = repository
- container.run_after_commit do
+
+ # Because GitlabShellWorker is inside a run_after_commit callback it will
+ # never be triggered on a read-only instance.
+ #
+ # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/223272
+ if Gitlab::Database.read_only?
Repositories::ShellDestroyService.new(current_repository).execute
+ else
+ container.run_after_commit do
+ Repositories::ShellDestroyService.new(current_repository).execute
+ end
end
log_info("Repository \"#{full_path}\" was removed")
diff --git a/app/services/repositories/shell_destroy_service.rb b/app/services/repositories/shell_destroy_service.rb
index 2f5af10e24c..d25cb28c6d7 100644
--- a/app/services/repositories/shell_destroy_service.rb
+++ b/app/services/repositories/shell_destroy_service.rb
@@ -9,7 +9,7 @@ class Repositories::ShellDestroyService < Repositories::BaseService
GitlabShellWorker.perform_in(delay,
:remove_repository,
- repository_storage,
+ repository.shard,
removal_path)
end
end
diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb
index c8e86e68383..2d0a78feb8e 100644
--- a/app/services/resource_access_tokens/create_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -13,8 +13,6 @@ module ResourceAccessTokens
return unless feature_enabled?
return error("User does not have permission to create #{resource_type} Access Token") unless has_permission_to_create?
- # We skip authorization by default, since the user creating the bot is not an admin
- # and project/group bot users are not created via sign-up
user = create_user
return error(user.errors.full_messages.to_sentence) unless user.persisted?
@@ -49,6 +47,11 @@ module ResourceAccessTokens
end
def create_user
+ # Even project maintainers can create project access tokens, which in turn
+ # creates a bot user, and so it becomes necessary to have `skip_authorization: true`
+ # since someone like a project maintainer does not inherently have the ability
+ # to create a new user in the system.
+
Users::CreateService.new(current_user, default_user_params).execute(skip_authorization: true)
end
@@ -57,7 +60,8 @@ module ResourceAccessTokens
name: params[:name] || "#{resource.name.to_s.humanize} bot",
email: generate_email,
username: generate_username,
- user_type: "#{resource_type}_bot".to_sym
+ user_type: "#{resource_type}_bot".to_sym,
+ skip_confirmation: true # Bot users should always have their emails confirmed.
}
end
diff --git a/app/services/resource_access_tokens/revoke_service.rb b/app/services/resource_access_tokens/revoke_service.rb
index eea6bff572b..efeb0bfb8d5 100644
--- a/app/services/resource_access_tokens/revoke_service.rb
+++ b/app/services/resource_access_tokens/revoke_service.rb
@@ -35,7 +35,7 @@ module ResourceAccessTokens
attr_reader :current_user, :access_token, :bot_user, :resource
def remove_member
- ::Members::DestroyService.new(current_user).execute(find_member)
+ ::Members::DestroyService.new(current_user).execute(find_member, destroy_bot: true)
end
def migrate_to_ghost_user
diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb
index db8bf6e4b74..a2d78ec67c3 100644
--- a/app/services/resource_events/base_synthetic_notes_builder_service.rb
+++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb
@@ -23,11 +23,25 @@ module ResourceEvents
private
- def since_fetch_at(events)
+ def apply_common_filters(events)
+ events = apply_last_fetched_at(events)
+ events = apply_fetch_until(events)
+
+ events
+ end
+
+ def apply_last_fetched_at(events)
return events unless params[:last_fetched_at].present?
- last_fetched_at = Time.zone.at(params.fetch(:last_fetched_at).to_i)
- events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP)
+ last_fetched_at = params[:last_fetched_at] - NotesFinder::FETCH_OVERLAP
+
+ events.created_after(last_fetched_at)
+ end
+
+ def apply_fetch_until(events)
+ return events unless params[:fetch_until].present?
+
+ events.created_on_or_before(params[:fetch_until])
end
def resource_parent
diff --git a/app/services/resource_events/change_state_service.rb b/app/services/resource_events/change_state_service.rb
index 8beb76d8aee..202972c1efd 100644
--- a/app/services/resource_events/change_state_service.rb
+++ b/app/services/resource_events/change_state_service.rb
@@ -8,12 +8,18 @@ module ResourceEvents
@user, @resource = user, resource
end
- def execute(state)
+ def execute(params)
+ @params = params
+
ResourceStateEvent.create(
user: user,
issue: issue,
merge_request: merge_request,
+ source_commit: commit_id_of(mentionable_source),
+ source_merge_request_id: merge_request_id_of(mentionable_source),
state: ResourceStateEvent.states[state],
+ close_after_error_tracking_resolve: close_after_error_tracking_resolve,
+ close_auto_resolve_prometheus_alert: close_auto_resolve_prometheus_alert,
created_at: Time.zone.now)
resource.expire_note_etag_cache
@@ -21,6 +27,36 @@ module ResourceEvents
private
+ attr_reader :params
+
+ def close_auto_resolve_prometheus_alert
+ params[:close_auto_resolve_prometheus_alert] || false
+ end
+
+ def close_after_error_tracking_resolve
+ params[:close_after_error_tracking_resolve] || false
+ end
+
+ def state
+ params[:status]
+ end
+
+ def mentionable_source
+ params[:mentionable_source]
+ end
+
+ def commit_id_of(mentionable_source)
+ return unless mentionable_source.is_a?(Commit)
+
+ mentionable_source.id[0...40]
+ end
+
+ def merge_request_id_of(mentionable_source)
+ return unless mentionable_source.is_a?(MergeRequest)
+
+ mentionable_source.id
+ end
+
def issue
return unless resource.is_a?(Issue)
diff --git a/app/services/resource_events/synthetic_label_notes_builder_service.rb b/app/services/resource_events/synthetic_label_notes_builder_service.rb
index fd128101b49..5915ea938cf 100644
--- a/app/services/resource_events/synthetic_label_notes_builder_service.rb
+++ b/app/services/resource_events/synthetic_label_notes_builder_service.rb
@@ -19,7 +19,7 @@ module ResourceEvents
return [] unless resource.respond_to?(:resource_label_events)
events = resource.resource_label_events.includes(:label, user: :status) # rubocop: disable CodeReuse/ActiveRecord
- events = since_fetch_at(events)
+ events = apply_common_filters(events)
events.group_by { |event| event.discussion_id }
end
diff --git a/app/services/resource_events/synthetic_milestone_notes_builder_service.rb b/app/services/resource_events/synthetic_milestone_notes_builder_service.rb
index cc6383d7083..10acf94e22b 100644
--- a/app/services/resource_events/synthetic_milestone_notes_builder_service.rb
+++ b/app/services/resource_events/synthetic_milestone_notes_builder_service.rb
@@ -19,7 +19,7 @@ module ResourceEvents
return [] unless resource.respond_to?(:resource_milestone_events)
events = resource.resource_milestone_events.includes(user: :status) # rubocop: disable CodeReuse/ActiveRecord
- since_fetch_at(events)
+ apply_common_filters(events)
end
end
end
diff --git a/app/services/resource_events/synthetic_state_notes_builder_service.rb b/app/services/resource_events/synthetic_state_notes_builder_service.rb
index 763134d98d8..71d40200365 100644
--- a/app/services/resource_events/synthetic_state_notes_builder_service.rb
+++ b/app/services/resource_events/synthetic_state_notes_builder_service.rb
@@ -14,7 +14,7 @@ module ResourceEvents
return [] unless resource.respond_to?(:resource_state_events)
events = resource.resource_state_events.includes(user: :status) # rubocop: disable CodeReuse/ActiveRecord
- since_fetch_at(events)
+ apply_common_filters(events)
end
end
end
diff --git a/app/services/service_desk_settings/update_service.rb b/app/services/service_desk_settings/update_service.rb
new file mode 100644
index 00000000000..08106b04d18
--- /dev/null
+++ b/app/services/service_desk_settings/update_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module ServiceDeskSettings
+ class UpdateService < BaseService
+ def execute
+ settings = ServiceDeskSetting.safe_find_or_create_by!(project_id: project.id)
+
+ unless ::Feature.enabled?(:service_desk_custom_address, project)
+ params.delete(:project_key)
+ end
+
+ if settings.update(params)
+ success
+ else
+ error(settings.errors.full_messages.to_sentence)
+ end
+ end
+ end
+end
diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb
index 5d1fe815d83..d9e8326f159 100644
--- a/app/services/snippets/base_service.rb
+++ b/app/services/snippets/base_service.rb
@@ -6,13 +6,15 @@ module Snippets
CreateRepositoryError = Class.new(StandardError)
- attr_reader :uploaded_assets, :snippet_files
+ attr_reader :uploaded_assets, :snippet_actions
def initialize(project, user = nil, params = {})
super
@uploaded_assets = Array(@params.delete(:files).presence)
- @snippet_files = SnippetInputActionCollection.new(Array(@params.delete(:snippet_files).presence))
+
+ input_actions = Array(@params.delete(:snippet_actions).presence)
+ @snippet_actions = SnippetInputActionCollection.new(input_actions, allowed_actions: restricted_files_actions)
filter_spam_check_params
end
@@ -30,18 +32,18 @@ module Snippets
end
def valid_params?
- return true if snippet_files.empty?
+ return true if snippet_actions.empty?
- (params.keys & [:content, :file_name]).none? && snippet_files.valid?
+ (params.keys & [:content, :file_name]).none? && snippet_actions.valid?
end
def invalid_params_error(snippet)
- if snippet_files.valid?
+ if snippet_actions.valid?
[:content, :file_name].each do |key|
snippet.errors.add(key, 'and snippet files cannot be used together') if params.key?(key)
end
else
- snippet.errors.add(:snippet_files, 'have invalid data')
+ snippet.errors.add(:snippet_actions, 'have invalid data')
end
snippet_error_response(snippet, 403)
@@ -73,11 +75,15 @@ module Snippets
end
def files_to_commit(snippet)
- snippet_files.to_commit_actions.presence || build_actions_from_params(snippet)
+ snippet_actions.to_commit_actions.presence || build_actions_from_params(snippet)
end
def build_actions_from_params(snippet)
raise NotImplementedError
end
+
+ def restricted_files_actions
+ nil
+ end
end
end
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index 7b477621da3..dab47de8a36 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -37,13 +37,13 @@ module Snippets
end
end
- # If the snippet_files param is present
+ # If the snippet_actions param is present
# we need to fill content and file_name from
# the model
def create_params
- return params if snippet_files.empty?
+ return params if snippet_actions.empty?
- params.merge(content: snippet_files[0].content, file_name: snippet_files[0].file_path)
+ params.merge(content: snippet_actions[0].content, file_name: snippet_actions[0].file_path)
end
def save_and_commit
@@ -100,5 +100,9 @@ module Snippets
def build_actions_from_params(_snippet)
[{ file_path: params[:file_name], content: params[:content] }]
end
+
+ def restricted_files_actions
+ :create
+ end
end
end
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index 6cdc2c374da..00146389e22 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -37,8 +37,9 @@ module Snippets
# is implemented.
# Once we can perform different operations through this service
# we won't need to keep track of the `content` and `file_name` fields
- if snippet_files.any?
- params.merge!(content: snippet_files[0].content, file_name: snippet_files[0].file_path)
+ if snippet_actions.any?
+ params[:content] = snippet_actions[0].content if snippet_actions[0].content
+ params[:file_name] = snippet_actions[0].file_path
end
snippet.assign_attributes(params)
@@ -108,7 +109,7 @@ module Snippets
end
def committable_attributes?
- (params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present? || snippet_files.any?
+ (params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present? || snippet_actions.any?
end
def build_actions_from_params(snippet)
diff --git a/app/services/snippets/update_statistics_service.rb b/app/services/snippets/update_statistics_service.rb
new file mode 100644
index 00000000000..295cb963ccc
--- /dev/null
+++ b/app/services/snippets/update_statistics_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Snippets
+ class UpdateStatisticsService
+ attr_reader :snippet
+
+ def initialize(snippet)
+ @snippet = snippet
+ end
+
+ def execute
+ unless snippet.repository_exists?
+ return ServiceResponse.error(message: 'Invalid snippet repository', http_status: 400)
+ end
+
+ snippet.repository.expire_statistics_caches
+ statistics.refresh!
+
+ ServiceResponse.success(message: 'Snippet statistics successfully updated.')
+ end
+
+ private
+
+ def statistics
+ @statistics ||= snippet.statistics || snippet.build_statistics
+ end
+ end
+end
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index 68f1135ae28..7de3bad607a 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -14,7 +14,7 @@ module Spam
end
def execute
- external_spam_check_result = spam_verdict
+ external_spam_check_result = external_verdict
akismet_result = akismet_verdict
# filter out anything we don't recognise, including nils.
@@ -38,7 +38,7 @@ module Spam
end
end
- def spam_verdict
+ def external_verdict
return unless Gitlab::CurrentSettings.spam_check_endpoint_enabled
return if endpoint_url.blank?
@@ -50,17 +50,14 @@ module Spam
# @TODO metrics/logging
# Expecting:
# error: (string or nil)
- # result: (string or nil)
- verdict = json_result[:verdict]
- return unless SUPPORTED_VERDICTS.include?(verdict)
-
+ # verdict: (string or nil)
# @TODO log if json_result[:error]
json_result[:verdict]
rescue *Gitlab::HTTP::HTTP_ERRORS => e
# @TODO: log error via try_post https://gitlab.com/gitlab-org/gitlab/-/issues/219223
Gitlab::ErrorTracking.log_exception(e)
- return
+ nil
rescue
# @TODO log
ALLOW
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 6bf04c55415..db5693960b2 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -273,6 +273,38 @@ module SystemNoteService
::SystemNotes::DesignManagementService.new(noteable: design.issue, project: design.project, author: discussion_note.author).design_discussion_added(discussion_note)
end
+
+ # Called when the merge request is approved by user
+ #
+ # noteable - Noteable object
+ # user - User performing approve
+ #
+ # Example Note text:
+ #
+ # "approved this merge request"
+ #
+ # Returns the created Note object
+ def approve_mr(noteable, user)
+ merge_requests_service(noteable, noteable.project, user).approve_mr
+ end
+
+ def unapprove_mr(noteable, user)
+ merge_requests_service(noteable, noteable.project, user).unapprove_mr
+ end
+
+ def change_alert_status(alert, author)
+ ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: author).change_alert_status(alert)
+ end
+
+ def new_alert_issue(alert, issue, author)
+ ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: author).new_alert_issue(alert, issue)
+ end
+
+ private
+
+ def merge_requests_service(noteable, project, author)
+ ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author)
+ end
end
SystemNoteService.prepend_if_ee('EE::SystemNoteService')
diff --git a/app/services/system_notes/alert_management_service.rb b/app/services/system_notes/alert_management_service.rb
new file mode 100644
index 00000000000..55a6a17bbca
--- /dev/null
+++ b/app/services/system_notes/alert_management_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module SystemNotes
+ class AlertManagementService < ::SystemNotes::BaseService
+ # Called when the status of an AlertManagement::Alert has changed
+ #
+ # alert - AlertManagement::Alert object.
+ #
+ # Example Note text:
+ #
+ # "changed the status to Acknowledged"
+ #
+ # Returns the created Note object
+ def change_alert_status(alert)
+ status = AlertManagement::Alert::STATUSES.key(alert.status).to_s.titleize
+ body = "changed the status to **#{status}**"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'status'))
+ end
+
+ # Called when an issue is created based on an AlertManagement::Alert
+ #
+ # alert - AlertManagement::Alert object.
+ # issue - Issue object.
+ #
+ # Example Note text:
+ #
+ # "created issue #17 for this alert"
+ #
+ # Returns the created Note object
+ def new_alert_issue(alert, issue)
+ body = "created issue #{issue.to_reference(project)} for this alert"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'alert_issue_added'))
+ end
+ end
+end
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index 7d7ee8d829e..76261aa716e 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -228,7 +228,9 @@ module SystemNotes
# A state event which results in a synthetic note will be
# created by EventCreateService if change event tracking
# is enabled.
- unless state_change_tracking_enabled?
+ if state_change_tracking_enabled?
+ create_resource_state_event(status: status, mentionable_source: source)
+ else
create_note(NoteSummary.new(noteable, project, author, body, action: action))
end
end
@@ -288,15 +290,23 @@ module SystemNotes
end
def close_after_error_tracking_resolve
- body = _('resolved the corresponding error and closed the issue.')
+ if state_change_tracking_enabled?
+ create_resource_state_event(status: 'closed', close_after_error_tracking_resolve: true)
+ else
+ body = 'resolved the corresponding error and closed the issue.'
- create_note(NoteSummary.new(noteable, project, author, body, action: 'closed'))
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'closed'))
+ end
end
def auto_resolve_prometheus_alert
- body = 'automatically closed this issue because the alert resolved.'
+ if state_change_tracking_enabled?
+ create_resource_state_event(status: 'closed', close_auto_resolve_prometheus_alert: true)
+ else
+ body = 'automatically closed this issue because the alert resolved.'
- create_note(NoteSummary.new(noteable, project, author, body, action: 'closed'))
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'closed'))
+ end
end
private
@@ -324,6 +334,11 @@ module SystemNotes
note_text =~ /\A#{cross_reference_note_prefix}/i
end
+ def create_resource_state_event(params)
+ ResourceEvents::ChangeStateService.new(resource: noteable, user: author)
+ .execute(params)
+ end
+
def state_change_tracking_enabled?
noteable.respond_to?(:resource_state_events) &&
::Feature.enabled?(:track_resource_state_change_events, noteable.project)
diff --git a/app/services/system_notes/merge_requests_service.rb b/app/services/system_notes/merge_requests_service.rb
index baf26245eb9..9b5c9ba20b2 100644
--- a/app/services/system_notes/merge_requests_service.rb
+++ b/app/services/system_notes/merge_requests_service.rb
@@ -150,7 +150,24 @@ module SystemNotes
create_note(summary)
end
+
+ # Called when the merge request is approved by user
+ #
+ # Example Note text:
+ #
+ # "approved this merge request"
+ #
+ # Returns the created Note object
+ def approve_mr
+ body = "approved this merge request"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'approved'))
+ end
+
+ def unapprove_mr
+ body = "unapproved this merge request"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'unapproved'))
+ end
end
end
-
-SystemNotes::MergeRequestsService.prepend_if_ee('::EE::SystemNotes::MergeRequestsService')
diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb
index 3a01192487d..4d1f4043b01 100644
--- a/app/services/tags/destroy_service.rb
+++ b/app/services/tags/destroy_service.rb
@@ -18,6 +18,8 @@ module Tags
.new(project, current_user, tag: tag_name)
.execute
+ unlock_artifacts(tag_name)
+
success('Tag was removed')
else
error('Failed to remove tag')
@@ -33,5 +35,11 @@ module Tags
def success(message)
super().merge(message: message)
end
+
+ private
+
+ def unlock_artifacts(tag_name)
+ Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, "#{::Gitlab::Git::TAG_REF_PREFIX}#{tag_name}")
+ end
end
end
diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb
index d180a3a2432..d2c44d4a265 100644
--- a/app/services/terraform/remote_state_handler.rb
+++ b/app/services/terraform/remote_state_handler.rb
@@ -5,26 +5,17 @@ module Terraform
include Gitlab::OptimisticLocking
StateLockedError = Class.new(StandardError)
+ UnauthorizedError = Class.new(StandardError)
- # rubocop: disable CodeReuse/ActiveRecord
def find_with_lock
- raise ArgumentError unless params[:name].present?
-
- state = Terraform::State.find_by(project: project, name: params[:name])
- raise ActiveRecord::RecordNotFound.new("Couldn't find state") unless state
-
- retry_optimistic_lock(state) { |state| yield state } if state && block_given?
- state
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def create_or_find!
- raise ArgumentError unless params[:name].present?
-
- Terraform::State.create_or_find_by(project: project, name: params[:name])
+ retrieve_with_lock(find_only: true) do |state|
+ yield state if block_given?
+ end
end
def handle_with_lock
+ raise UnauthorizedError unless can_modify_state?
+
retrieve_with_lock do |state|
raise StateLockedError unless lock_matches?(state)
@@ -36,6 +27,7 @@ module Terraform
def lock!
raise ArgumentError if params[:lock_id].blank?
+ raise UnauthorizedError unless can_modify_state?
retrieve_with_lock do |state|
raise StateLockedError if state.locked?
@@ -49,6 +41,8 @@ module Terraform
end
def unlock!
+ raise UnauthorizedError unless can_modify_state?
+
retrieve_with_lock do |state|
# force-unlock does not pass ID, so we ignore it if it is missing
raise StateLockedError unless params[:lock_id].nil? || lock_matches?(state)
@@ -63,8 +57,21 @@ module Terraform
private
- def retrieve_with_lock
- create_or_find!.tap { |state| retry_optimistic_lock(state) { |state| yield state } }
+ def retrieve_with_lock(find_only: false)
+ create_or_find!(find_only: find_only).tap { |state| retry_optimistic_lock(state) { |state| yield state } }
+ end
+
+ def create_or_find!(find_only:)
+ raise ArgumentError unless params[:name].present?
+
+ find_params = { project: project, name: params[:name] }
+
+ if find_only
+ Terraform::State.find_by(find_params) || # rubocop: disable CodeReuse/ActiveRecord
+ raise(ActiveRecord::RecordNotFound.new("Couldn't find state"))
+ else
+ Terraform::State.create_or_find_by(find_params)
+ end
end
def lock_matches?(state)
@@ -73,5 +80,9 @@ module Terraform
ActiveSupport::SecurityUtils
.secure_compare(state.lock_xid.to_s, params[:lock_id].to_s)
end
+
+ def can_modify_state?
+ current_user.can?(:admin_terraform_state, project)
+ end
end
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index e6fb0d3c72e..ec15bdde8d7 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -162,9 +162,9 @@ class TodoService
create_assignment_todo(alert, current_user, [])
end
- # When user marks an issue as todo
- def mark_todo(issuable, current_user)
- attributes = attributes_for_todo(issuable.project, issuable, current_user, Todo::MARKED)
+ # When user marks a target as todo
+ def mark_todo(target, current_user)
+ attributes = attributes_for_todo(target.project, target, current_user, Todo::MARKED)
create_todos(current_user, attributes)
end
diff --git a/app/services/update_container_registry_info_service.rb b/app/services/update_container_registry_info_service.rb
new file mode 100644
index 00000000000..531335839a9
--- /dev/null
+++ b/app/services/update_container_registry_info_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class UpdateContainerRegistryInfoService
+ def execute
+ registry_config = Gitlab.config.registry
+ return unless registry_config.enabled && registry_config.api_url.presence
+
+ # registry_info will query the /v2 route of the registry API. This route
+ # requires authentication, but not authorization (the response has no body,
+ # only headers that show the version of the registry). There might be no
+ # associated user when running this (e.g. from a rake task or a cron job),
+ # so we need to generate a valid JWT token with no access permissions to
+ # authenticate as a trusted client.
+ token = Auth::ContainerRegistryAuthenticationService.access_token([], [])
+ client = ContainerRegistry::Client.new(registry_config.api_url, token: token)
+ info = client.registry_info
+
+ Gitlab::CurrentSettings.update!(
+ container_registry_vendor: info[:vendor] || '',
+ container_registry_version: info[:version] || '',
+ container_registry_features: info[:features] || []
+ )
+ end
+end
diff --git a/app/services/users/block_service.rb b/app/services/users/block_service.rb
index 9c393832d8f..041db731875 100644
--- a/app/services/users/block_service.rb
+++ b/app/services/users/block_service.rb
@@ -19,7 +19,7 @@ module Users
private
def after_block_hook(user)
- # overriden by EE module
+ # overridden by EE module
end
end
end
diff --git a/app/services/wiki_pages/base_service.rb b/app/services/wiki_pages/base_service.rb
index a0256ea5e69..2967684f7bc 100644
--- a/app/services/wiki_pages/base_service.rb
+++ b/app/services/wiki_pages/base_service.rb
@@ -44,8 +44,6 @@ module WikiPages
end
def create_wiki_event(page)
- return unless ::Feature.enabled?(:wiki_events)
-
response = WikiPages::EventCreateService.new(current_user).execute(slug_for_page(page), page, event_action)
log_error(response.message) if response.error?
diff --git a/app/services/wiki_pages/event_create_service.rb b/app/services/wiki_pages/event_create_service.rb
index 18a45d057a9..0453c90d693 100644
--- a/app/services/wiki_pages/event_create_service.rb
+++ b/app/services/wiki_pages/event_create_service.rb
@@ -10,8 +10,6 @@ module WikiPages
end
def execute(slug, page, action)
- return ServiceResponse.success(message: 'No event created as `wiki_events` feature is disabled') unless ::Feature.enabled?(:wiki_events)
-
event = Event.transaction do
wiki_page_meta = WikiPage::Meta.find_or_create(slug, page)
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
index 5297112eef8..63b6197a04d 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -169,6 +169,10 @@ module ObjectStorage
object_store_options.connection.to_hash.deep_symbolize_keys
end
+ def consolidated_settings?
+ object_store_options.fetch('consolidated_settings', false)
+ end
+
def remote_store_path
object_store_options.remote_directory
end
@@ -196,7 +200,7 @@ module ObjectStorage
id = [CarrierWave.generate_cache_id, SecureRandom.hex].join('-')
upload_path = File.join(TMP_UPLOAD_PATH, id)
direct_upload = ObjectStorage::DirectUpload.new(self.object_store_credentials, remote_store_path, upload_path,
- has_length: has_length, maximum_size: maximum_size)
+ has_length: has_length, maximum_size: maximum_size, consolidated_settings: consolidated_settings?)
direct_upload.to_hash.merge(ID: id)
end
diff --git a/app/uploaders/packages/package_file_uploader.rb b/app/uploaders/packages/package_file_uploader.rb
new file mode 100644
index 00000000000..20fcf0a7a32
--- /dev/null
+++ b/app/uploaders/packages/package_file_uploader.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+class Packages::PackageFileUploader < GitlabUploader
+ extend Workhorse::UploadPath
+ include ObjectStorage::Concern
+
+ storage_options Gitlab.config.packages
+
+ after :store, :schedule_background_upload
+
+ alias_method :upload, :model
+
+ def filename
+ model.file_name
+ end
+
+ def store_dir
+ dynamic_segment
+ end
+
+ private
+
+ def dynamic_segment
+ File.join(disk_hash[0..1], disk_hash[2..3], disk_hash,
+ 'packages', model.package.id.to_s, 'files', model.id.to_s)
+ end
+
+ def disk_hash
+ @disk_hash ||= Digest::SHA2.hexdigest(model.package.project_id.to_s)
+ end
+end
diff --git a/app/validators/addressable_url_validator.rb b/app/validators/addressable_url_validator.rb
index 99f503c3f06..9fa99903e36 100644
--- a/app/validators/addressable_url_validator.rb
+++ b/app/validators/addressable_url_validator.rb
@@ -95,9 +95,9 @@ class AddressableUrlValidator < ActiveModel::EachValidator
end
def current_options
- options.map do |option, value|
- [option, value.is_a?(Proc) ? value.call(record) : value]
- end.to_h
+ options.transform_values do |value|
+ value.is_a?(Proc) ? value.call(record) : value
+ end
end
def blocker_args
diff --git a/app/validators/array_members_validator.rb b/app/validators/array_members_validator.rb
new file mode 100644
index 00000000000..c5d3d25b4d9
--- /dev/null
+++ b/app/validators/array_members_validator.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# ArrayMembersValidator
+#
+# Custom validator that checks if validated
+# attribute contains non empty array, which every
+# element is an instances of :member_class
+#
+# Example:
+#
+# class Config::Root < ActiveRecord::Base
+# validates :nodes, member_class: Config::Node
+# end
+#
+class ArrayMembersValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ if !value.is_a?(Array) || value.empty? || value.any? { |child| !child.instance_of?(options[:member_class]) }
+ record.errors.add(attribute, _("should be an array of %{object_name} objects") % { object_name: options.fetch(:object_name, attribute) })
+ end
+ end
+end
diff --git a/app/validators/json_schemas/build_metadata_secrets.json b/app/validators/json_schemas/build_metadata_secrets.json
new file mode 100644
index 00000000000..e745a266777
--- /dev/null
+++ b/app/validators/json_schemas/build_metadata_secrets.json
@@ -0,0 +1,30 @@
+{
+ "description": "CI builds metadata secrets",
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "type": "object",
+ "patternProperties": {
+ "^vault$": {
+ "type": "object",
+ "required": ["path", "field", "engine"],
+ "properties": {
+ "path": { "type": "string" },
+ "field": { "type": "string" },
+ "engine": {
+ "type": "object",
+ "required": ["name", "path"],
+ "properties": {
+ "path": { "type": "string" },
+ "name": { "type": "string" }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+}
diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
new file mode 100644
index 00000000000..1154a4c45b8
--- /dev/null
+++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
@@ -0,0 +1,153 @@
+{
+ "global": [
+ {
+ "field" : "SECURE_ANALYZERS_PREFIX",
+ "label" : "Image prefix",
+ "type": "string",
+ "default_value": "registry.gitlab.com/gitlab-org/security-products/analyzers",
+ "value": ""
+ },
+ {
+ "field" : "SAST_EXCLUDED_PATHS",
+ "label" : "Excluded Paths",
+ "type": "string",
+ "default_value": "spec, test, tests, tmp",
+ "value": ""
+ },
+ {
+ "field" : "SECURE_ANALYZER_IMAGE_TAG",
+ "label" : "Image tag",
+ "type": "string",
+ "options": [],
+ "default_value": "2",
+ "value": ""
+ },
+ {
+ "field" : "SAST_DISABLED",
+ "label" : "Disable SAST",
+ "type": "options",
+ "options": [
+ {
+ "value" :"true",
+ "label" : "true (disables SAST)"
+ },
+ {
+ "value":"false",
+ "label":"false (enables SAST)"
+ }
+ ],
+ "default_value": "false",
+ "value": ""
+ }
+ ],
+ "pipeline": [
+ {
+ "field" : "stage",
+ "label" : "Stage",
+ "type": "dropdown",
+ "options": [
+ {
+ "value" :"test",
+ "label" : "test"
+ },
+ {
+ "value":"build",
+ "label":"build"
+ }
+ ],
+ "default_value": "test",
+ "value": ""
+ },
+ {
+ "field" : "allow_failure",
+ "label" : "Allow Failure",
+ "type": "options",
+ "options": [
+ {
+ "value" :"true",
+ "label" : "Allows pipeline failure"
+ },
+ {
+ "value": "false",
+ "label": "Does not allow pipeline failure"
+ }
+ ],
+ "default_value": "true",
+ "value": ""
+ },
+ {
+ "field" : "rules",
+ "label" : "Rules",
+ "type": "multiline",
+ "default_value": "",
+ "value": ""
+ }
+ ],
+ "analyzers": [
+ {
+ "name": "brakeman",
+ "label": "Brakeman",
+ "enabled" : true
+ },
+ {
+ "name": "bandit",
+ "label": "Bandit",
+ "enabled" : true
+ },
+ {
+ "name": "eslint",
+ "label": "ESLint",
+ "enabled" : true
+ },
+ {
+ "name": "flawfinder",
+ "label": "Flawfinder",
+ "enabled" : true
+ },
+ {
+ "name": "kubesec",
+ "label": "kubesec",
+ "enabled" : true
+ },
+ {
+ "name": "nodejsscan",
+ "label": "Node.js Scan",
+ "enabled" : true
+ },
+ {
+ "name": "gosec",
+ "label": "Golang Security Checker",
+ "enabled" : true
+ },
+ {
+ "name": "phpcs-security-audit",
+ "label": "PHP Security Audit",
+ "enabled" : true
+ },
+ {
+ "name": "pmd-apex",
+ "label": "PMD APEX",
+ "enabled" : true
+ },
+ {
+ "name": "security-code-scan",
+ "label": "Security Code Scan",
+ "enabled" : true
+ },
+ {
+ "name": "sobelow",
+ "label": "Sobelow",
+ "enabled" : true
+ },
+ {
+ "name": "spotbugs",
+ "label": "Spotbugs",
+ "enabled" : true
+ },
+ {
+ "name": "secrets",
+ "label": "Secrets",
+ "enabled" : true
+ }
+ ]
+}
diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml
index aa47daf4a57..fcb1c1a6f3e 100644
--- a/app/views/admin/appearances/_form.html.haml
+++ b/app/views/admin/appearances/_form.html.haml
@@ -1,6 +1,6 @@
- parsed_with_gfm = "Content parsed with #{link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank')}.".html_safe
-= form_for @appearance, url: admin_appearances_path, html: { class: 'prepend-top-default' } do |f|
+= form_for @appearance, url: admin_appearances_path, html: { class: 'gl-mt-3' } do |f|
= form_errors(@appearance)
@@ -100,7 +100,7 @@
.hint
= parsed_with_gfm
- .prepend-top-default.append-bottom-default
+ .gl-mt-3.gl-mb-3
= f.submit 'Update appearance settings', class: 'btn btn-success'
- if @appearance.persisted? || @appearance.updated_at
.mt-4
diff --git a/app/views/admin/appearances/show.html.haml b/app/views/admin/appearances/show.html.haml
index ccf6f960cf2..77a08913666 100644
--- a/app/views/admin/appearances/show.html.haml
+++ b/app/views/admin/appearances/show.html.haml
@@ -1,4 +1,4 @@
-- page_title "Appearance"
+- page_title _("Appearance")
- @content_class = "limit-container-width" unless fluid_layout
= render 'form'
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index ceec8901951..65a2f1d42e1 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -54,10 +54,10 @@
= _('Newly registered users will by default be external')
.prepend-top-10
= _('Internal users')
- = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control prepend-top-5'
+ = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control gl-mt-2'
.help-block
= _('Specify an e-mail address regex pattern to identify default internal users.')
- = link_to _('More information'), help_page_path('user/permissions', anchor: 'external-users-permissions'),
+ = link_to _('More information'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'),
target: '_blank'
.form-group
= f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold'
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index c7918881bdf..410820dfb85 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -40,7 +40,7 @@
= f.text_field :default_artifacts_expire_in, class: 'form-control'
.form-text.text-muted
= _("Set the default expiration time for each job's artifacts. 0 for unlimited. The default unit is in seconds, but you can define an alternative. For example: <code>4 mins 2 sec</code>, <code>2h42min</code>.").html_safe
- = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration')
+ = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration-core-only')
.form-group
= f.label :archive_builds_in_human_readable, _('Archive jobs'), class: 'label-bold'
= f.text_field :archive_builds_in_human_readable, class: 'form-control', placeholder: 'never'
diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml
index 3dd72909805..49747f2bfd4 100644
--- a/app/views/admin/application_settings/_email.html.haml
+++ b/app/views/admin/application_settings/_email.html.haml
@@ -20,7 +20,7 @@
= f.label :commit_email_hostname, _('Custom hostname (for private commit emails)'), class: 'label-bold'
= f.text_field :commit_email_hostname, class: 'form-control'
.form-text.text-muted
- - commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('user/admin_area/settings/email', anchor: 'custom-private-commit-email-hostname'), target: '_blank'
+ - commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('user/admin_area/settings/email.md', anchor: 'custom-hostname-for-private-commit-emails'), target: '_blank'
= _("This setting will update the hostname that is used to generate private commit emails. %{learn_more}").html_safe % { learn_more: commit_email_hostname_docs_link }
= render_if_exists 'admin/application_settings/email_additional_text_setting', form: f
diff --git a/app/views/admin/application_settings/_import_export_limits.html.haml b/app/views/admin/application_settings/_import_export_limits.html.haml
new file mode 100644
index 00000000000..d26c3376391
--- /dev/null
+++ b/app/views/admin/application_settings/_import_export_limits.html.haml
@@ -0,0 +1,34 @@
+= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-import-export-limits-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :project_import_limit, _('Max Project Import requests per minute per user'), class: 'label-bold'
+ = f.number_field :project_import_limit, class: 'form-control'
+
+ %fieldset
+ .form-group
+ = f.label :project_export_limit, _('Max Project Export requests per minute per user'), class: 'label-bold'
+ = f.number_field :project_export_limit, class: 'form-control'
+
+ %fieldset
+ .form-group
+ = f.label :project_download_export_limit, _('Max Project Export Download requests per minute per user'), class: 'label-bold'
+ = f.number_field :project_download_export_limit, class: 'form-control'
+
+ %fieldset
+ .form-group
+ = f.label :group_import_limit, _('Max Group Import requests per minute per user'), class: 'label-bold'
+ = f.number_field :group_import_limit, class: 'form-control'
+
+ %fieldset
+ .form-group
+ = f.label :group_export_limit, _('Max Group Export requests per minute per user'), class: 'label-bold'
+ = f.number_field :group_export_limit, class: 'form-control'
+
+ %fieldset
+ .form-group
+ = f.label :group_download_export_limit, _('Max Group Export Download requests per minute per user'), class: 'label-bold'
+ = f.number_field :group_download_export_limit, class: 'form-control'
+
+ = f.submit 'Save changes', class: "btn btn-success", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_initial_branch_name.html.haml b/app/views/admin/application_settings/_initial_branch_name.html.haml
new file mode 100644
index 00000000000..e76374e88a8
--- /dev/null
+++ b/app/views/admin/application_settings/_initial_branch_name.html.haml
@@ -0,0 +1,12 @@
+= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ - fallback_branch_name = '<code>master</code>'
+
+ %fieldset
+ .form-group
+ = f.label :default_branch_name, _('Default initial branch name'), class: 'label-light'
+ = f.text_field :default_branch_name, placeholder: 'master', class: 'form-control'
+ %span.form-text.text-muted
+ = (_("Changes affect new repositories only. If not specified, Git's default name %{branch_name_default} will be used.") % { branch_name_default: fallback_branch_name } ).html_safe
+ = f.submit _('Save changes'), class: 'gl-button btn-success'
diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml
index 0631c024eb8..fea3ff4c3ba 100644
--- a/app/views/admin/application_settings/_registry.html.haml
+++ b/app/views/admin/application_settings/_registry.html.haml
@@ -10,7 +10,7 @@
= f.check_box :container_expiration_policies_enable_historic_entries, class: 'form-check-input'
= f.label :container_expiration_policies_enable_historic_entries, class: 'form-check-label' do
= _("Enable container expiration and retention policies for projects created earlier than GitLab 12.7.")
- = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy')
+ = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy')
.form-text.text-muted
= _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.")
= link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'use-with-external-container-registries')
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
index ed276da08f2..ecae720cd49 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -15,7 +15,7 @@
.form-group
.form-text
%p.text-secondary
- = _('Select a weight for the storage new repositories will be placed on.')
+ = _('Enter weights for storages for new repositories.')
= link_to icon('question-circle'), help_page_path('administration/repository_storage_paths')
.form-check
- storage_weights.each do |attribute|
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 007cd343339..0972e10e12c 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -33,6 +33,15 @@
= f.label :require_two_factor_authentication, class: 'form-check-label' do
Require all users to set up Two-factor authentication
.form-group
+ = f.label :unknown_sign_in, _('Email notification for unknown sign-ins'), class: 'label-bold'
+ .form-check
+ = f.check_box :notify_on_unknown_sign_in, class: 'form-check-input'
+ = f.label :notify_on_unknown_sign_in, class: 'form-check-label' do
+ = _('Notify users by email when sign-in location is not recognized')
+ = link_to icon('question-circle'),
+ 'https://docs.gitlab.com/ee/user/profile/unknown_sign_in_notification.html',
+ target: '_blank'
+ .form-group
= f.label :two_factor_authentication, 'Two-factor grace period (hours)', class: 'label-bold'
= f.number_field :two_factor_grace_period, min: 0, class: 'form-control', placeholder: '0'
.form-text.text-muted Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index 9421585b70c..d8a4c601b77 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -31,7 +31,7 @@
%pre.usage-data.js-usage-ping-payload.js-syntax-highlight.code.highlight.mt-2.d-none{ data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
- else
= _('The usage ping is disabled, and cannot be configured through this form.')
- - deactivating_usage_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'deactivate-the-usage-ping')
+ - deactivating_usage_ping_path = help_page_path('development/telemetry/usage_ping', anchor: 'disable-usage-ping')
- deactivating_usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: deactivating_usage_ping_path }
= s_('For more information, see the documentation on %{deactivating_usage_ping_link_start}deactivating the usage ping%{deactivating_usage_ping_link_end}.').html_safe % { deactivating_usage_ping_link_start: deactivating_usage_ping_link_start, deactivating_usage_ping_link_end: '</a>'.html_safe }
.form-group.mt-3
diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml
index 9f03936f64a..fe86284ba2f 100644
--- a/app/views/admin/application_settings/ci/_header.html.haml
+++ b/app/views/admin/application_settings/ci/_header.html.haml
@@ -2,7 +2,7 @@
%h4
= _('Variables')
- = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'custom-environment-variables'), target: '_blank', rel: 'noopener noreferrer'
%button.btn.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index 2452ab794fc..cdb69d33b12 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -9,7 +9,7 @@
.settings-content
- if ci_variable_protected_by_default?
%p.settings-message.text-center
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protected-variables') }
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable') }
= s_('Environment variables on this GitLab instance are configured to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
#js-instance-variables{ data: { endpoint: admin_ci_variables_path, group: 'true', maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} }
diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml
index a8eff26b94c..cca0240462f 100644
--- a/app/views/admin/application_settings/integrations.html.haml
+++ b/app/views/admin/application_settings/integrations.html.haml
@@ -12,7 +12,7 @@
%h4.gl-alert-title= s_('AdminSettings|Some settings have moved')
= s_('AdminSettings|Elasticsearch, PlantUML, Slack application, Third party offers, Snowplow, Amazon EKS have moved to Settings > General.')
.gl-alert-actions
- = link_to s_('AdminSettings|Go to General Settings'), admin_application_settings_path, class: 'btn gl-alert-action btn-info new-gl-button'
+ = link_to s_('AdminSettings|Go to General Settings'), general_admin_application_settings_path, class: 'btn gl-alert-action btn-info new-gl-button'
%h4= s_('AdminSettings|Apply integration settings to all Projects')
%p
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index db4611964b4..15149e46f9c 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -57,4 +57,15 @@
.settings-content
= render 'issue_limits'
+%section.settings.as-import-export-limits.no-animate#js-import-export-limits-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Import/Export Rate Limits')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Configure limits for Project/Group Import/Export.')
+ .settings-content
+ = render 'import_export_limits'
+
= render_if_exists 'admin/application_settings/ee_network_settings'
diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml
index b0934a9d9fb..33a6715d424 100644
--- a/app/views/admin/application_settings/repository.html.haml
+++ b/app/views/admin/application_settings/repository.html.haml
@@ -2,6 +2,18 @@
- page_title _("Repository")
- @content_class = "limit-container-width" unless fluid_layout
+- if Feature.enabled?(:global_default_branch_name, default_enabled: true)
+ %section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Default initial branch name')
+ %button.gl-button.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Set the default name of the initial branch when creating new repositories through the user interface.')
+ .settings-content
+ = render 'initial_branch_name'
+
%section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
diff --git a/app/views/admin/applications/edit.html.haml b/app/views/admin/applications/edit.html.haml
index 13c408914bb..4f737a14e12 100644
--- a/app/views/admin/applications/edit.html.haml
+++ b/app/views/admin/applications/edit.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs "Applications", admin_applications_path
+- add_to_breadcrumbs _("Applications"), admin_applications_path
- breadcrumb_title @application.name
-- page_title "Edit", @application.name, "Applications"
+- page_title _("Edit"), @application.name, _("Applications")
%h3.page-title Edit application
- @url = admin_application_path(@application)
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index c3861f335b8..0119cabf1ad 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -1,4 +1,4 @@
-- page_title "Applications"
+- page_title _("Applications")
%h3.page-title
System OAuth applications
%p.light
diff --git a/app/views/admin/applications/new.html.haml b/app/views/admin/applications/new.html.haml
index 346c58877d9..4d4b6b0c994 100644
--- a/app/views/admin/applications/new.html.haml
+++ b/app/views/admin/applications/new.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title "Applications"
-- page_title "New Application"
+- breadcrumb_title _("Applications")
+- page_title _("New Application")
%h3.page-title New application
- @url = admin_applications_path
diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml
index 146674a2fac..5259dd56df5 100644
--- a/app/views/admin/applications/show.html.haml
+++ b/app/views/admin/applications/show.html.haml
@@ -1,4 +1,4 @@
-- page_title @application.name, "Applications"
+- page_title @application.name, _("Applications")
%h3.page-title
Application: #{@application.name}
@@ -46,4 +46,4 @@
.form-actions
= link_to 'Edit', edit_admin_application_path(@application), class: 'btn btn-primary wide float-left'
- = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger prepend-left-10'
+ = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3'
diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml
index 1001a69b787..bbb47e29bb9 100644
--- a/app/views/admin/background_jobs/show.html.haml
+++ b/app/views/admin/background_jobs/show.html.haml
@@ -1,4 +1,4 @@
-- page_title "Background Jobs"
+- page_title _("Background Jobs")
%h3.page-title Background Jobs
%p.light GitLab uses #{link_to "sidekiq", "http://sidekiq.org/"} library for async job processing
diff --git a/app/views/admin/broadcast_messages/edit.html.haml b/app/views/admin/broadcast_messages/edit.html.haml
index 8cbc4597e32..569aaa29cc4 100644
--- a/app/views/admin/broadcast_messages/edit.html.haml
+++ b/app/views/admin/broadcast_messages/edit.html.haml
@@ -1,4 +1,4 @@
-- breadcrumb_title "Messages"
-- page_title "Broadcast Messages"
+- breadcrumb_title _("Messages")
+- page_title _("Broadcast Messages")
= render 'form'
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index e7a7ee96508..bca74f71c5c 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title "Messages"
-- page_title "Broadcast Messages"
+- breadcrumb_title _("Messages")
+- page_title _("Broadcast Messages")
%h3.page-title
Broadcast Messages
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 951e5364ad8..7c6c21bc509 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -1,4 +1,5 @@
-- breadcrumb_title "Dashboard"
+- breadcrumb_title _("Dashboard")
+- page_title _("Dashboard")
- if show_license_breakdown?
= render_if_exists 'admin/licenses/breakdown', license: @license
@@ -9,7 +10,7 @@
dismissible: true.to_s } }
= notice[:message].html_safe
-.admin-dashboard.prepend-top-default
+.admin-dashboard.gl-mt-3
.row
.col-sm-4
.info-well.dark-well
diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml
index 9a563a5bc78..f43c1447f09 100644
--- a/app/views/admin/deploy_keys/new.html.haml
+++ b/app/views/admin/deploy_keys/new.html.haml
@@ -1,4 +1,4 @@
-- page_title 'New Deploy Key'
+- page_title _('New Deploy Key')
%h3.page-title New public deploy key
%hr
diff --git a/app/views/admin/gitaly_servers/index.html.haml b/app/views/admin/gitaly_servers/index.html.haml
index 9b24f411a75..0b06f145687 100644
--- a/app/views/admin/gitaly_servers/index.html.haml
+++ b/app/views/admin/gitaly_servers/index.html.haml
@@ -1,4 +1,5 @@
- breadcrumb_title _("Gitaly Servers")
+- page_title _("Gitaly Servers")
%h3.page-title= _("Gitaly Servers")
%hr
diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml
index f295e5a06cb..da2b2c60b15 100644
--- a/app/views/admin/groups/index.html.haml
+++ b/app/views/admin/groups/index.html.haml
@@ -1,7 +1,7 @@
- page_title _("Groups")
.top-area
- .prepend-top-default.append-bottom-default
+ .gl-mt-3.gl-mb-3
= form_tag admin_groups_path, method: :get, class: 'js-search-form' do |f|
= hidden_field_tag :sort, @sort
.search-holder
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index e105091e773..4b0e0b9c697 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -1,6 +1,8 @@
- add_to_breadcrumbs _("Groups"), admin_groups_path
- breadcrumb_title @group.name
- page_title @group.name, _("Groups")
+
+.js-remove-member-modal
%h3.page-title
= _('Group: %{group_name}') % { group_name: @group.full_name }
diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml
index 841640efad2..5e70e80cff7 100644
--- a/app/views/admin/hook_logs/_index.html.haml
+++ b/app/views/admin/hook_logs/_index.html.haml
@@ -1,4 +1,4 @@
-.row.prepend-top-default.append-bottom-default
+.row.gl-mt-3.gl-mb-3
.col-lg-3
%h4.gl-mt-0
Recent Deliveries
diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml
index 86729dbe7bc..4d534c59c40 100644
--- a/app/views/admin/hook_logs/show.html.haml
+++ b/app/views/admin/hook_logs/show.html.haml
@@ -1,9 +1,9 @@
-- page_title 'Request details'
+- page_title _('Request details')
%h3.page-title
Request details
%hr
-= link_to 'Resend Request', retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn btn-default float-right prepend-left-10"
+= link_to 'Resend Request', retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn btn-default float-right gl-ml-3"
= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml
index 072f80b56b9..17bb054b869 100644
--- a/app/views/admin/hooks/_form.html.haml
+++ b/app/views/admin/hooks/_form.html.haml
@@ -16,7 +16,7 @@
System hook will be triggered on set of events like creating project
or adding ssh key. But you can also enable extra triggers like Push events.
- .prepend-top-default
+ .gl-mt-3
= form.check_box :repository_update_events, class: 'float-left'
.prepend-left-20
= form.label :repository_update_events, class: 'list-label' do
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index 636dd6bdfc1..f9faf5b11fa 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -1,11 +1,11 @@
- add_to_breadcrumbs @hook.pluralized_name, admin_hooks_path
- page_title _('Edit System Hook')
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-3
= render 'shared/web_hooks/title_and_docs', hook: @hook
- .col-lg-9.append-bottom-default
+ .col-lg-9.gl-mb-3
= form_for @hook, as: :hook, url: admin_hook_path do |f|
= render partial: 'form', locals: { form: f, hook: @hook }
.form-actions
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index 1c14291b58e..d70baa592ea 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -1,10 +1,10 @@
- page_title @hook.pluralized_name
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4
= render 'shared/web_hooks/title_and_docs', hook: @hook
- .col-lg-8.append-bottom-default
+ .col-lg-8.gl-mb-3
= form_for @hook, as: :hook, url: admin_hooks_path do |f|
= render partial: 'form', locals: { form: f, hook: @hook }
= f.submit _('Add system hook'), class: 'btn btn-success'
diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml
index 8342507d8a6..ec393fdd794 100644
--- a/app/views/admin/impersonation_tokens/index.html.haml
+++ b/app/views/admin/impersonation_tokens/index.html.haml
@@ -6,7 +6,7 @@
= render 'admin/users/head'
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-12
- if @new_impersonation_token
= render 'shared/access_tokens/created_container',
diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml
index f1bdd52b399..32c0a801a1d 100644
--- a/app/views/admin/jobs/index.html.haml
+++ b/app/views/admin/jobs/index.html.haml
@@ -1,4 +1,5 @@
-- breadcrumb_title "Jobs"
+- breadcrumb_title _("Jobs")
+- page_title _("Jobs")
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
- build_path_proc = ->(scope) { admin_jobs_path(scope: scope) }
diff --git a/app/views/admin/keys/show.html.haml b/app/views/admin/keys/show.html.haml
index 9ee77c77398..03cc0ae15be 100644
--- a/app/views/admin/keys/show.html.haml
+++ b/app/views/admin/keys/show.html.haml
@@ -1,2 +1,2 @@
-- page_title @key.title, "Keys"
+- page_title @key.title, _("Keys")
= render "profiles/keys/key_details", admin: true
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index f9d42d3f53b..96337d357eb 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -1,13 +1,14 @@
-- add_to_breadcrumbs "Projects", admin_projects_path
+- add_to_breadcrumbs _("Projects"), admin_projects_path
- breadcrumb_title @project.full_name
-- page_title @project.full_name, "Projects"
+- page_title @project.full_name, _("Projects")
- @content_class = "admin-projects"
+.js-remove-member-modal
%h3.page-title
- Project: #{@project.full_name}
+ = _('Project: %{name}') % { name: @project.full_name }
= link_to edit_project_path(@project), class: "btn btn-nr float-right" do
%i.fa.fa-pencil-square-o
- Edit
+ = _('Edit')
%hr
- if @project.last_repository_check_failed?
.row
@@ -21,57 +22,67 @@
.col-md-6
.card
.card-header
- Project info:
+ = _('Project info:')
%ul.content-list
%li
- %span.light Name:
+ %span.light
+ = _('Name:')
%strong
= link_to @project.name, project_path(@project)
%li
- %span.light Namespace:
+ %span.light
+ = _('Namespace:')
%strong
- if @project.namespace
= link_to @project.namespace.human_name, [:admin, @project.group || @project.owner]
- else
- Global
+ = s_('ProjectSettings|Global')
%li
- %span.light Owned by:
+ %span.light
+ = _('Owned by:')
%strong
- if @project.owner
= link_to @project.owner_name, [:admin, @project.owner]
- else
- (deleted)
+ = _('(deleted)')
%li
- %span.light Created by:
+ %span.light
+ = _('Created by:')
%strong
- = @project.creator.try(:name) || '(deleted)'
+ = @project.creator.try(:name) || _('(deleted)')
%li
- %span.light Created on:
+ %span.light
+ = _('Created on:')
%strong
= @project.created_at.to_s(:medium)
%li
- %span.light ID:
+ %span.light
+ = _('ID:')
%strong
= @project.id
%li
- %span.light http:
+ %span.light
+ = _('http:')
%strong
= link_to @project.http_url_to_repo, project_path(@project)
%li
- %span.light ssh:
+ %span.light
+ = _('ssh:')
%strong
= link_to @project.ssh_url_to_repo, project_path(@project)
- if @project.repository.exists?
%li
- %span.light Gitaly storage name:
+ %span.light
+ = _('Gitaly storage name:')
%strong
= @project.repository.storage
%li
- %span.light Gitaly relative path:
+ %span.light
+ = _('Gitaly relative path:')
%strong
= @project.repository.relative_path
@@ -79,30 +90,36 @@
= render 'shared/storage_counter_statistics', storage_size: @project.statistics&.storage_size, storage_details: @project.statistics
%li
- %span.light last commit:
+ %span.light
+ = _('last commit:')
%strong
= last_commit(@project)
%li
- %span.light Git LFS status:
+ %span.light
+ = _('Git LFS status:')
%strong
= project_lfs_status(@project)
= link_to icon('question-circle'), help_page_path('topics/git/lfs/index')
- else
%li
- %span.light repository:
+ %span.light
+ = _('repository:')
%strong.cred
- does not exist
+ = _('does not exist')
- if @project.archived?
%li
- %span.light archived:
- %strong project is read-only
+ %span.light
+ = _('archived:')
+ %strong
+ = _('project is read-only')
= render_if_exists "shared_runner_status", project: @project
%li
- %span.light access:
+ %span.light
+ = _('access:')
%strong
%span{ class: visibility_level_color(@project.visibility_level) }
= visibility_level_icon(@project.visibility_level)
@@ -114,24 +131,24 @@
.card
.card-header
- Transfer project
+ = s_('ProjectSettings|Transfer project')
.card-body
= form_for @project, url: transfer_admin_project_path(@project), method: :put do |f|
.form-group.row
.col-sm-3.col-form-label
- = f.label :new_namespace_id, "Namespace"
+ = f.label :new_namespace_id, _("Namespace")
.col-sm-9
.dropdown
- = dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' })
+ = dropdown_toggle(_('Search for Namespace'), { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' })
.dropdown-menu.dropdown-select
- = dropdown_title('Namespaces')
- = dropdown_filter("Search for Namespace")
+ = dropdown_title(_('Namespaces'))
+ = dropdown_filter(_('Search for Namespace'))
= dropdown_content
= dropdown_loading
.form-group.row
.offset-sm-3.col-sm-9
- = f.submit 'Transfer', class: 'btn btn-primary'
+ = f.submit _('Transfer'), class: 'btn btn-primary'
.card.repository-check
.card-header
@@ -151,18 +168,18 @@
= link_to icon('question-circle'), help_page_path('administration/repository_checks')
.form-group
- = f.submit 'Trigger repository check', class: 'btn btn-primary'
+ = f.submit _('Trigger repository check'), class: 'btn btn-primary'
.col-md-6
- if @group
.card
.card-header
%strong= @group.name
- group members
+ = _('group members')
%span.badge.badge-pill= @group_members.size
.float-right
= link_to admin_group_path(@group), class: 'btn btn-sm' do
- = icon('pencil-square-o', text: 'Manage access')
+ = icon('pencil-square-o', text: _('Manage access'))
%ul.content-list.members-list
= render partial: 'shared/members/member', collection: @group_members, as: :member, locals: { show_controls: false }
.card-footer
@@ -173,10 +190,10 @@
.card
.card-header
%strong= @project.name
- project members
+ = _('project members')
%span.badge.badge-pill= @project.users.size
.float-right
- = link_to icon('pencil-square-o', text: 'Manage access'), project_project_members_path(@project), class: "btn btn-sm"
+ = link_to icon('pencil-square-o', text: _('Manage access')), project_project_members_path(@project), class: "btn btn-sm"
%ul.content-list.project_members.members-list
= render partial: 'shared/members/member', collection: @project_members, as: :member, locals: { show_controls: false }
.card-footer
diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml
index efc16bb4d3b..6e1ac452d52 100644
--- a/app/views/admin/requests_profiles/index.html.haml
+++ b/app/views/admin/requests_profiles/index.html.haml
@@ -1,4 +1,4 @@
-- page_title 'Requests Profiles'
+- page_title _('Requests Profiles')
%h3.page-title
= page_title
@@ -9,7 +9,7 @@
to profile the request
- if @profiles.present?
- .prepend-top-default
+ .gl-mt-3
- @profiles.each do |path, profiles|
.card.card-small
.card-header
diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml
index 423472324fe..5c834c2125f 100644
--- a/app/views/admin/runners/_runner.html.haml
+++ b/app/views/admin/runners/_runner.html.haml
@@ -72,8 +72,8 @@
= link_to [:pause, :admin, runner], method: :get, class: 'btn btn-default has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= icon('pause')
- else
- = link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
- = icon('play')
+ = link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
+ = sprite_icon('play')
.btn-group
= link_to [:admin, runner], method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= icon('remove')
diff --git a/app/views/admin/runners/_sort_dropdown.html.haml b/app/views/admin/runners/_sort_dropdown.html.haml
index 4f4f0a543e0..3b3de042511 100644
--- a/app/views/admin/runners/_sort_dropdown.html.haml
+++ b/app/views/admin/runners/_sort_dropdown.html.haml
@@ -1,6 +1,6 @@
- sorted_by = sort_options_hash[@sort]
-.dropdown.inline.prepend-left-10
+.dropdown.inline.gl-ml-3
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
= sorted_by
= icon('chevron-down')
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 59e28a3b244..08d65819476 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -1,4 +1,5 @@
- breadcrumb_title _('Runners')
+- page_title _('Runners')
.row
.col-sm-6
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index 0120d4038b9..0c2b9bab357 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -9,6 +9,7 @@
%span.runner-state.runner-state-specific
Specific
+- page_title _("Runners")
- add_to_breadcrumbs _("Runners"), admin_runners_path
- breadcrumb_title "##{@runner.id}"
diff --git a/app/views/admin/services/_form.html.haml b/app/views/admin/services/_form.html.haml
index d18e91c0b14..f2153e503af 100644
--- a/app/views/admin/services/_form.html.haml
+++ b/app/views/admin/services/_form.html.haml
@@ -4,7 +4,7 @@
%p #{@service.description} template.
= form_for :service, url: admin_application_settings_service_path, method: :put, html: { class: 'fieldset-form' } do |form|
- = render 'shared/service_settings', form: form, service: @service
+ = render 'shared/service_settings', form: form, integration: @service
.footer-block.row-content-block
= form.submit 'Save', class: 'btn btn-success'
diff --git a/app/views/admin/services/edit.html.haml b/app/views/admin/services/edit.html.haml
index 00ed5464a44..d13b5a34dac 100644
--- a/app/views/admin/services/edit.html.haml
+++ b/app/views/admin/services/edit.html.haml
@@ -1,5 +1,6 @@
-- add_to_breadcrumbs "Service Templates", admin_application_settings_services_path
+- add_to_breadcrumbs _("Service Templates"), admin_application_settings_services_path
+- page_title @service.title, _("Service Templates")
- breadcrumb_title @service.title
-- page_title @service.title, "Service Templates"
+- @content_class = 'limit-container-width' unless fluid_layout
= render 'form'
diff --git a/app/views/admin/services/index.html.haml b/app/views/admin/services/index.html.haml
index e0a1a3549a5..ec343c38470 100644
--- a/app/views/admin/services/index.html.haml
+++ b/app/views/admin/services/index.html.haml
@@ -1,4 +1,4 @@
-- page_title "Service Templates"
+- page_title _("Service Templates")
%h3.page-title Service templates
%p.light= s_('AdminSettings|Service template allows you to set default values for integrations')
@@ -11,13 +11,24 @@
%th Description
%th Last edit
- @services.each do |service|
- %tr
- %td
- = boolean_to_icon service.activated?
- %td
- = link_to edit_admin_application_settings_service_path(service.id) do
- %strong= service.title
- %td
- = service.description
- %td.light
- = time_ago_with_tooltip service.updated_at
+ - if service.type.in?(@existing_instance_types)
+ %tr
+ %td
+ %td
+ = link_to edit_admin_application_settings_integration_path(service.to_param), class: 'gl-text-blue-300!' do
+ %strong.has-tooltip{ title: s_('AdminSettings|Moved to integrations'), data: { container: 'body' } }
+ = service.title
+ %td.gl-cursor-default.gl-text-gray-600
+ = service.description
+ %td
+ - else
+ %tr
+ %td
+ = boolean_to_icon service.activated?
+ %td
+ = link_to edit_admin_application_settings_service_path(service.id) do
+ %strong= service.title
+ %td
+ = service.description
+ %td.light
+ = time_ago_with_tooltip service.updated_at
diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml
index 4ce1629bb53..67c607270a5 100644
--- a/app/views/admin/sessions/new.html.haml
+++ b/app/views/admin/sessions/new.html.haml
@@ -15,7 +15,7 @@
-# Show a message if none of the mechanisms above are enabled
- if !allow_admin_mode_password_authentication_for_web? && !ldap_sign_in_enabled? && !omniauth_enabled?
- .prepend-top-default.center
+ .gl-mt-3.center
= _('No authentication methods configured.')
- if omniauth_enabled? && button_based_providers_enabled?
diff --git a/app/views/admin/spam_logs/index.html.haml b/app/views/admin/spam_logs/index.html.haml
index b45d3e4823b..40fbc559d72 100644
--- a/app/views/admin/spam_logs/index.html.haml
+++ b/app/views/admin/spam_logs/index.html.haml
@@ -1,4 +1,4 @@
-- page_title "Spam Logs"
+- page_title _("Spam Logs")
%h3.page-title Spam Logs
%hr
- if @spam_logs.present?
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index b7648979edd..312ca62cfdf 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -1,6 +1,6 @@
- page_title _('System Info')
-.prepend-top-default
+.gl-mt-3
.row
.col-sm
.bg-light.light-well
@@ -11,7 +11,7 @@
- else
= icon('warning', class: 'text-warning')
= _('Unable to collect CPU info')
- .bg-light.light-well.prepend-top-default
+ .bg-light.light-well.gl-mt-3
%h4= _('Memory Usage')
.data
- if @memory
@@ -19,7 +19,7 @@
- else
= icon('warning', class: 'text-warning')
= _('Unable to collect memory info')
- .bg-light.light-well.prepend-top-default
+ .bg-light.light-well.gl-mt-3
%h4= _('Uptime')
.data
%h2= distance_of_time_in_words_to_now(Rails.application.config.booted_at)
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index e3ab2e4f9bd..3ba01e8a350 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -1,5 +1,6 @@
%fieldset
- %legend Access
+ %legend
+ = s_('AdminUsers|Access')
.form-group.row
.col-sm-2.col-form-label
= f.label :projects_limit
@@ -7,43 +8,43 @@
= f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control'
.form-group.row
- .col-sm-2.col-form-label
+ .col-sm-2.col-form-label.gl-pt-0
= f.label :can_create_group
.col-sm-10
= f.check_box :can_create_group
.form-group.row
- .col-sm-2.col-form-label
+ .col-sm-2.col-form-label.gl-pt-0
= f.label :access_level
.col-sm-10
- editing_current_user = (current_user == @user)
= f.radio_button :access_level, :regular, disabled: editing_current_user
= f.label :access_level_regular, class: 'font-weight-bold' do
- Regular
+ = s_('AdminUsers|Regular')
%p.light
- Regular users have access to their groups and projects
+ = s_('AdminUsers|Regular users have access to their groups and projects')
= render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user
= f.radio_button :access_level, :admin, disabled: editing_current_user
= f.label :access_level_admin, class: 'font-weight-bold' do
- Admin
+ = s_('AdminUsers|Admin')
%p.light
- Administrators have access to all groups, projects and users and can manage all features in this installation
+ = s_('AdminUsers|Administrators have access to all groups, projects and users and can manage all features in this installation')
- if editing_current_user
%p.light
- You cannot remove your own admin rights.
+ = s_('AdminUsers|You cannot remove your own admin rights.')
.form-group.row
- .col-sm-2.col-form-label
+ .col-sm-2.col-form-label.gl-pt-0
= f.label :external
.hidden{ data: user_internal_regex_data }
- .col-sm-10
+ .col-sm-10.gl-display-flex.gl-align-items-baseline
= f.check_box :external do
- External
- %p.light
- External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.
+ = s_('AdminUsers|External')
+ %p.light.gl-pl-2
+ = s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.')
%row.hidden#warning_external_automatically_set.hidden
.badge.badge-warning.text-white
- = _('Automatically marked as default internal user')
+ = s_('AdminUsers|Automatically marked as default internal user')
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index a218885a00e..3403e9e5abf 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -28,4 +28,4 @@
= link_to "Identities", admin_user_identities_path(@user)
= nav_link(controller: :impersonation_tokens) do
= link_to "Impersonation Tokens", admin_user_impersonation_tokens_path(@user)
-.append-bottom-default
+.gl-mb-3
diff --git a/app/views/admin/users/_user_listing_note.html.haml b/app/views/admin/users/_user_listing_note.html.haml
index df4af009c5c..b6c9bc43339 100644
--- a/app/views/admin/users/_user_listing_note.html.haml
+++ b/app/views/admin/users/_user_listing_note.html.haml
@@ -1,3 +1,3 @@
- if user.note.present?
%span.has-tooltip.user-note{ title: user.note }
- = icon("sticky-note-o cgrey")
+ = sprite_icon('document', size: 16, css_class: 'gl-vertical-align-middle')
diff --git a/app/views/admin/users/edit.html.haml b/app/views/admin/users/edit.html.haml
index 3b6fd71500d..7d10e839cd6 100644
--- a/app/views/admin/users/edit.html.haml
+++ b/app/views/admin/users/edit.html.haml
@@ -1,4 +1,4 @@
-- page_title "Edit", @user.name, "Users"
+- page_title _("Edit"), @user.name, _("Users")
%h3.page-title
Edit user: #{@user.name}
%hr
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index ecbabab3e7f..05988c17412 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -1,10 +1,10 @@
-- page_title "Users"
+- page_title _("Users")
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left
- = icon('angle-left')
+ = sprite_icon('chevron-lg-left', size: 12)
.fade-right
- = icon('angle-right')
+ = sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.nav.nav-tabs.scrolling-tabs
= nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
= link_to admin_users_path do
diff --git a/app/views/admin/users/keys.html.haml b/app/views/admin/users/keys.html.haml
index 103bbb3b063..5f9d11af7c1 100644
--- a/app/views/admin/users/keys.html.haml
+++ b/app/views/admin/users/keys.html.haml
@@ -1,5 +1,5 @@
-- add_to_breadcrumbs "Users", admin_users_path
+- add_to_breadcrumbs _("Users"), admin_users_path
- breadcrumb_title @user.name
-- page_title "SSH Keys", @user.name, "Users"
+- page_title _("SSH Keys"), @user.name, _("Users")
= render 'admin/users/head'
= render 'profiles/keys/key_table', admin: true
diff --git a/app/views/admin/users/new.html.haml b/app/views/admin/users/new.html.haml
index bfc36ed7373..e5e6790b789 100644
--- a/app/views/admin/users/new.html.haml
+++ b/app/views/admin/users/new.html.haml
@@ -1,4 +1,4 @@
-- page_title "New User"
+- page_title _("New User")
%h3.page-title
New user
%hr
diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml
index e6da81831ab..f66d9b76afc 100644
--- a/app/views/admin/users/projects.html.haml
+++ b/app/views/admin/users/projects.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs "Users", admin_users_path
+- add_to_breadcrumbs _("Users"), admin_users_path
- breadcrumb_title @user.name
-- page_title "Groups and projects", @user.name, "Users"
+- page_title _("Groups and projects"), @user.name, _("Users")
= render 'admin/users/head'
- if @user.groups.any?
@@ -16,7 +16,7 @@
.float-right
%span.light.vertical-align-middle= group_member.human_access
- unless group_member.owner?
- = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member) }, method: :delete, remote: true, class: "btn-sm btn btn-remove prepend-left-10", title: 'Remove user from group' do
+ = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member) }, method: :delete, remote: true, class: "btn-sm btn btn-remove gl-ml-3", title: 'Remove user from group' do
%i.fa.fa-times.fa-inverse
.row
@@ -46,5 +46,5 @@
%span.light.vertical-align-middle= member.human_access
- if member.respond_to? :project
- = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-sm btn btn-remove prepend-left-10", title: 'Remove user from project' do
+ = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-sm btn btn-remove gl-ml-3", title: 'Remove user from project' do
%i.fa.fa-times
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index e76f1f6444c..2bc39a23b2d 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs "Users", admin_users_path
+- add_to_breadcrumbs _("Users"), admin_users_path
- breadcrumb_title @user.name
-- page_title @user.name, "Users"
+- page_title @user.name, _("Users")
= render 'admin/users/head'
.row
@@ -86,34 +86,22 @@
%li
%span.light Current sign-in IP:
%strong
- - if @user.current_sign_in_ip # rubocop:disable Style/RedundantCondition
- = @user.current_sign_in_ip
- - else
- never
+ = @user.current_sign_in_ip || _('never')
%li
%span.light Current sign-in at:
%strong
- - if @user.current_sign_in_at
- = @user.current_sign_in_at.to_s(:medium)
- - else
- never
+ = @user.current_sign_in_at&.to_s(:medium) || _('never')
%li
%span.light Last sign-in IP:
%strong
- - if @user.last_sign_in_ip # rubocop:disable Style/RedundantCondition
- = @user.last_sign_in_ip
- - else
- never
+ = @user.last_sign_in_ip || _('never')
%li
%span.light Last sign-in at:
%strong
- - if @user.last_sign_in_at
- = @user.last_sign_in_at.to_s(:medium)
- - else
- never
+ = @user.last_sign_in_at&.to_s(:medium) || _('never')
%li
%span.light Sign-in count:
diff --git a/app/views/ci/group_variables/_index.html.haml b/app/views/ci/group_variables/_index.html.haml
index c350ba5caf7..84bcd42e07c 100644
--- a/app/views/ci/group_variables/_index.html.haml
+++ b/app/views/ci/group_variables/_index.html.haml
@@ -6,8 +6,8 @@
= render 'ci/group_variables/variable_header'
- variables.each do |variable|
.group-variable-row.d-flex.w-100.border-bottom.pt-2.pb-2
- .table-section.section-40.append-right-10.key
+ .table-section.section-40.gl-mr-3.key
= variable.key
- .table-section.section-40.append-right-10
+ .table-section.section-40.gl-mr-3
%a.group-origin-link{ href: group_settings_ci_cd_path(variable.group) }
= variable.group.name
diff --git a/app/views/ci/group_variables/_variable_header.html.haml b/app/views/ci/group_variables/_variable_header.html.haml
index 1a3168cf781..a8d533da0e0 100644
--- a/app/views/ci/group_variables/_variable_header.html.haml
+++ b/app/views/ci/group_variables/_variable_header.html.haml
@@ -1,5 +1,5 @@
.group-variable-keys.d-flex.w-100.align-items-center.pb-2.border-bottom
- .bold.table-section.section-40.append-right-10
+ .bold.table-section.section-40.gl-mr-3
= s_('Key')
- .bold.table-section.section-40.append-right-10
+ .bold.table-section.section-40.gl-mr-3
= s_('Origin')
diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml
index 0b5c1a806b2..144d13565b2 100644
--- a/app/views/ci/variables/_content.html.haml
+++ b/app/views/ci/variables/_content.html.haml
@@ -1,3 +1,3 @@
= _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. Additionally, they can be masked so they are hidden in job logs, though they must match certain regexp requirements to do so. You can use environment variables for passwords, secret keys, or whatever you want.')
= _('You may also add variables that are made available to the running application by prepending the variable key with <code>K8S_SECRET_</code>.').html_safe
-= link_to _('More information'), help_page_path('ci/variables/README', anchor: 'variables')
+= link_to _('More information'), help_page_path('ci/variables/README', anchor: 'custom-environment-variables')
diff --git a/app/views/ci/variables/_environment_scope_header.html.haml b/app/views/ci/variables/_environment_scope_header.html.haml
index 4ba4ceec16c..fc3b7f925fc 100644
--- a/app/views/ci/variables/_environment_scope_header.html.haml
+++ b/app/views/ci/variables/_environment_scope_header.html.haml
@@ -1,2 +1,2 @@
-.bold.table-section.section-15.append-right-10
+.bold.table-section.section-15.gl-mr-3
= s_('CiVariables|Scope')
diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml
index ce4dd5a4877..d0148e455de 100644
--- a/app/views/ci/variables/_header.html.haml
+++ b/app/views/ci/variables/_header.html.haml
@@ -2,7 +2,7 @@
%h4
= _('Variables')
- = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'custom-environment-variables'), target: '_blank', rel: 'noopener noreferrer'
%button.btn.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index fa5f2c514ae..8d379774719 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -2,7 +2,7 @@
- if ci_variable_protected_by_default?
%p.settings-message.text-center
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protected-variables') }
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable') }
= s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- if Feature.enabled?(:new_variables_ui, @project || @group, default_enabled: true)
@@ -36,7 +36,7 @@
%span.hide.js-ci-variables-save-loading-icon
.spinner.spinner-light.mr-1
= _('Save variables')
- %button.btn.btn-info.btn-inverted.prepend-left-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } }
+ %button.btn.btn-info.btn-inverted.gl-ml-3.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } }
- if @variables.size == 0
= n_('Hide value', 'Hide values', @variables.size)
- else
diff --git a/app/views/ci/variables/_variable_header.html.haml b/app/views/ci/variables/_variable_header.html.haml
index d3b7a5ae883..65cea00a0c4 100644
--- a/app/views/ci/variables/_variable_header.html.haml
+++ b/app/views/ci/variables/_variable_header.html.haml
@@ -2,11 +2,11 @@
%li.ci-variable-row.m-0.d-none.d-sm-block
.d-flex.w-100.align-items-center.pb-2
- .bold.table-section.section-15.append-right-10
+ .bold.table-section.section-15.gl-mr-3
= s_('CiVariables|Type')
- .bold.table-section.section-15.append-right-10
+ .bold.table-section.section-15.gl-mr-3
= s_('CiVariables|Key')
- .bold.table-section.section-15.append-right-10
+ .bold.table-section.section-15.gl-mr-3
= s_('CiVariables|Value')
- unless only_key_value
.bold.table-section.section-20
diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
index 4244556a24a..c69a3adb0e9 100644
--- a/app/views/ci/variables/_variable_row.html.haml
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -39,10 +39,10 @@
= value
%p.masking-validation-error.gl-field-error.hide
= s_("CiVariables|Cannot use Masked Variable with current value")
- = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'masked-variables'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'mask-a-custom-variable'), target: '_blank', rel: 'noopener noreferrer'
- unless only_key_value
.ci-variable-body-item.ci-variable-protected-item.table-section.section-20.mr-0.border-top-0
- .append-right-default
+ .gl-mr-3
= s_("CiVariable|Protected")
= render "shared/buttons/project_feature_toggle", is_checked: is_protected, label: s_("CiVariable|Toggle protected") do
%input{ type: "hidden",
@@ -51,7 +51,7 @@
value: is_protected,
data: { default: is_protected_default.to_s } }
.ci-variable-body-item.ci-variable-masked-item.table-section.section-20.mr-0.border-top-0
- .append-right-default
+ .gl-mr-3
= s_("CiVariable|Masked")
= render "shared/buttons/project_feature_toggle", is_checked: is_masked, label: s_("CiVariable|Toggle masked"), class_list: "js-project-feature-toggle project-feature-toggle qa-variable-masked" do
%input{ type: "hidden",
diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml
index d823cd0412b..d1681409a93 100644
--- a/app/views/clusters/clusters/_advanced_settings.html.haml
+++ b/app/views/clusters/clusters/_advanced_settings.html.haml
@@ -40,4 +40,6 @@
%p
= s_("ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster.")
- #js-cluster-remove-actions{ data: { cluster_path: clusterable.cluster_path(@cluster), cluster_name: @cluster.name } }
+ #js-cluster-remove-actions{ data: { cluster_path: clusterable.cluster_path(@cluster),
+ cluster_name: @cluster.name,
+ has_management_project: @cluster.management_project_id? } }
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 486625c790b..3869ca6591c 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -1,5 +1,5 @@
- link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
-.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } }
+.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.gl-mt-3.gl-mb-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } }
%button.close.js-close{ type: "button" } &times;
.gcp-signup-offer--content
.gcp-signup-offer--icon.gl-mr-3
diff --git a/app/views/clusters/clusters/_gitlab_integration_form.html.haml b/app/views/clusters/clusters/_gitlab_integration_form.html.haml
index c5b54997407..160964b532a 100644
--- a/app/views/clusters/clusters/_gitlab_integration_form.html.haml
+++ b/app/views/clusters/clusters/_gitlab_integration_form.html.haml
@@ -10,17 +10,10 @@
.form-group
%h5= s_('ClusterIntegration|Environment scope')
- - if has_multiple_clusters?
- = field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope')
- .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.")
- - else
- = text_field_tag :environment_scope, '*', class: 'col-md-6 form-control disabled', placeholder: s_('ClusterIntegration|Environment scope'), disabled: true
- - environment_scope_url = help_page_path('user/project/clusters/index', anchor: 'base-domain')
- - environment_scope_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: environment_scope_url }
- .form-text.text-muted
- %code
- = _('*')
- = s_("ClusterIntegration| is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. %{environment_scope_start}More information%{environment_scope_end}").html_safe % { environment_scope_start: environment_scope_start, environment_scope_end: '</a>'.html_safe }
+ = field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope')
+ - environment_scope_url = help_page_path('user/project/clusters/index', anchor: 'base-domain')
+ - environment_scope_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: environment_scope_url }
+ .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster. %{environment_scope_start}More information%{environment_scope_end}").html_safe % { environment_scope_start: environment_scope_start, environment_scope_end: '</a>'.html_safe }
.form-group
%h5= s_('ClusterIntegration|Base domain')
diff --git a/app/views/clusters/clusters/_health.html.haml b/app/views/clusters/clusters/_health.html.haml
new file mode 100644
index 00000000000..5400bd7f201
--- /dev/null
+++ b/app/views/clusters/clusters/_health.html.haml
@@ -0,0 +1,6 @@
+%section.settings.no-animate.expanded.cluster-health-graphs.qa-cluster-health-section#cluster-health
+ - if @cluster&.application_prometheus_available?
+ #prometheus-graphs{ data: @cluster.health_data(clusterable) }
+
+ - else
+ %p.settings-message.text-center= s_("ClusterIntegration|In order to view the health of your cluster, you must first install Prometheus in the Applications tab.")
diff --git a/app/views/clusters/clusters/_health_tab.html.haml b/app/views/clusters/clusters/_health_tab.html.haml
new file mode 100644
index 00000000000..fda392693f6
--- /dev/null
+++ b/app/views/clusters/clusters/_health_tab.html.haml
@@ -0,0 +1,5 @@
+- active = params[:tab] == 'health'
+
+%li.nav-item{ role: 'presentation' }
+ %a#cluster-health-tab.nav-link.qa-health{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: 'health'}) }
+ %span= _('Health')
diff --git a/app/views/clusters/clusters/_multiple_clusters_message.html.haml b/app/views/clusters/clusters/_multiple_clusters_message.html.haml
new file mode 100644
index 00000000000..da3e128ba32
--- /dev/null
+++ b/app/views/clusters/clusters/_multiple_clusters_message.html.haml
@@ -0,0 +1,6 @@
+- autodevops_help_url = help_page_path('topics/autodevops/index.md', anchor: 'using-multiple-kubernetes-clusters')
+- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
+- help_link_end = '</a>'.html_safe
+
+%p
+ = s_('ClusterIntegration|If you are setting up multiple clusters and are using Auto DevOps, %{help_link_start}read this first%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: autodevops_help_url }, help_link_end: help_link_end }
diff --git a/app/views/clusters/clusters/_sidebar.html.haml b/app/views/clusters/clusters/_sidebar.html.haml
index 24a74c59b97..31add011bfa 100644
--- a/app/views/clusters/clusters/_sidebar.html.haml
+++ b/app/views/clusters/clusters/_sidebar.html.haml
@@ -5,4 +5,4 @@
%p
= clusterable.learn_more_link
-= render_if_exists 'clusters/multiple_clusters_message'
+= render 'clusters/clusters/multiple_clusters_message'
diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml
index 5bbdadf83f3..ec604ca83e5 100644
--- a/app/views/clusters/clusters/aws/_new.html.haml
+++ b/app/views/clusters/clusters/aws/_new.html.haml
@@ -1,6 +1,6 @@
- if !Gitlab::CurrentSettings.eks_integration_enabled?
- - documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/clusters/add_remove_clusters.md',
- anchor: 'additional-requirements-for-self-managed-instances') }
+ - documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/clusters/add_eks_clusters.md',
+ anchor: 'additional-requirements-for-self-managed-instances-core-only') }
= s_('Amazon authentication is not %{link_start}correctly configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: '<a/>'.html_safe }
- else
.js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'),
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index e83bf61ab9b..434c02a5c41 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -16,12 +16,11 @@
data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field|
= field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'),
label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold'
- - if has_multiple_clusters?
- = field.form_group :environment_scope, label: { text: s_('ClusterIntegration|Environment scope'),
- class: 'label-bold' } do
- = field.text_field :environment_scope, required: true, class: 'form-control',
- title: 'Environment scope is required.', wrapper: false
- .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.")
+ = field.form_group :environment_scope, label: { text: s_('ClusterIntegration|Environment scope'),
+ class: 'label-bold' } do
+ = field.text_field :environment_scope, required: true, class: 'form-control',
+ title: 'Environment scope is required.', wrapper: false
+ .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.")
= field.fields_for :provider_gcp, @gcp_cluster.provider_gcp do |provider_gcp_field|
.form-group
@@ -70,7 +69,7 @@
label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.')
- = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank'
+ = link_to _('More information'), help_page_path('user/project/clusters/add_gke_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank'
.form-group
= field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'),
diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml
index a654a8741a4..557ad1bf280 100644
--- a/app/views/clusters/clusters/index.html.haml
+++ b/app/views/clusters/clusters/index.html.haml
@@ -12,15 +12,14 @@
= s_('ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project')
= render 'clusters/clusters/buttons'
- - if @has_ancestor_clusters
- .bs-callout.bs-callout-info
- = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.')
- %strong
- = link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence')
-
- if Feature.enabled?(:clusters_list_redesign)
#js-clusters-list-app{ data: js_clusters_list_data(clusterable.index_path(format: :json)) }
- else
+ - if @has_ancestor_clusters
+ .bs-callout.bs-callout-info
+ = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.')
+ %strong
+ = link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence')
.clusters-table.js-clusters-list
.gl-responsive-table-row.table-row-header{ role: "row" }
.table-section.section-60{ role: "rowheader" }
diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml
index fae78fbb7f4..0a51d4b2e93 100644
--- a/app/views/clusters/clusters/new.html.haml
+++ b/app/views/clusters/clusters/new.html.haml
@@ -6,7 +6,7 @@
= render_gcp_signup_offer
-.row.prepend-top-default
+.row.gl-mt-3
.col-md-3
= render 'sidebar'
.col-md-9.js-toggle-container
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index 83b8092fb48..ffa99f06593 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -32,7 +32,7 @@
ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'),
environments_help_path: help_page_path('ci/environments/index.md', anchor: 'defining-environments'),
clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'),
- deploy_boards_help_path: help_page_path('user/project/deploy_boards.html', anchor: 'enabling-deploy-boards'),
+ deploy_boards_help_path: help_page_path('user/project/deploy_boards.md', anchor: 'enabling-deploy-boards'),
cloud_run_help_path: help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'cloud-run-for-anthos'),
manage_prometheus_path: manage_prometheus_path,
cluster_id: @cluster.id } }
@@ -55,7 +55,7 @@
%ul.nav-links.mobile-separator.nav.nav-tabs{ role: 'tablist' }
= render 'details_tab'
= render_if_exists 'clusters/clusters/environments_tab'
- = render_if_exists 'clusters/clusters/health_tab'
+ = render 'clusters/clusters/health_tab'
= render 'applications_tab'
= render 'advanced_settings_tab'
diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml
index ce226d29113..11772107135 100644
--- a/app/views/clusters/clusters/user/_form.html.haml
+++ b/app/views/clusters/clusters/user/_form.html.haml
@@ -13,10 +13,10 @@
url: clusterable.create_user_clusters_path, as: :cluster do |field|
= field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'),
label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold'
- - if has_multiple_clusters?
- = field.text_field :environment_scope, required: true, title: 'Environment scope is required.',
- label: s_('ClusterIntegration|Environment scope'), label_class: 'label-bold',
- help: s_("ClusterIntegration|Choose which of your environments will use this cluster.")
+
+ = field.text_field :environment_scope, required: true, title: s_('ClusterIntegration|Environment scope is required.'),
+ label: s_('ClusterIntegration|Environment scope'), label_class: 'label-bold',
+ help: s_('ClusterIntegration|Choose which of your environments will use this cluster.')
= field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field|
= platform_kubernetes_field.url_field :api_url, required: true,
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index 97a446dbeec..5e78749fee2 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -12,8 +12,8 @@
= link_to _("New project"), new_project_path, class: "btn btn-success"
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
+ .fade-left= sprite_icon('chevron-lg-left', size: 12)
+ .fade-right= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs{ class: ('border-0' if feature_project_list_filter_bar) }
= nav_link(page: [dashboard_projects_path, root_path]) do
= link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do
diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml
index d7306f5932d..1e93613e978 100644
--- a/app/views/dashboard/activity.html.haml
+++ b/app/views/dashboard/activity.html.haml
@@ -5,8 +5,8 @@
= render_dashboard_gold_trial(current_user)
-- page_title "Activity"
-- header_title "Activity", activity_dashboard_path
+- page_title _("Activity")
+- header_title _("Activity"), activity_dashboard_path
= render "projects/last_push"
= render 'dashboard/activity_head'
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index d1d8d970b59..9536ff940f5 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -1,6 +1,6 @@
- @hide_top_links = true
-- page_title "Groups"
-- header_title "Groups", dashboard_groups_path
+- page_title _("Groups")
+- header_title _("Groups"), dashboard_groups_path
= render_dashboard_gold_trial(current_user)
= render 'dashboard/groups_head'
diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml
index b9be6028b72..a0c1c314a85 100644
--- a/app/views/dashboard/milestones/index.html.haml
+++ b/app/views/dashboard/milestones/index.html.haml
@@ -1,6 +1,6 @@
- @hide_top_links = true
-- page_title 'Milestones'
-- header_title 'Milestones', dashboard_milestones_path
+- page_title _('Milestones')
+- header_title _('Milestones'), dashboard_milestones_path
.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Milestones')
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index d2aa07bab22..2e7eab87af3 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -5,8 +5,8 @@
= render_dashboard_gold_trial(current_user)
-- page_title "Projects"
-- header_title "Projects", dashboard_projects_path
+- page_title _("Projects")
+- header_title _("Projects"), dashboard_projects_path
= render "projects/last_push"
- if show_projects?(@projects, params)
diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml
index 2f0cc76f2e0..68457ab33f7 100644
--- a/app/views/dashboard/snippets/index.html.haml
+++ b/app/views/dashboard/snippets/index.html.haml
@@ -1,6 +1,6 @@
- @hide_top_links = true
-- page_title "Snippets"
-- header_title "Snippets", dashboard_snippets_path
+- page_title _("Snippets")
+- header_title _("Snippets"), dashboard_snippets_path
- button_path = new_snippet_path if can?(current_user, :create_snippet)
= render 'dashboard/snippets_head'
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index f5ffe8f2e36..82abb9b3b8a 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -4,7 +4,7 @@
.todo-item.todo-block.align-self-center
.todo-title
- - unless todo.build_failed? || todo.unmergeable?
+ - if todo_author_display?(todo)
= todo_target_state_pill(todo)
%span.title-item.author-name.bold
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index cfc637592d3..9b6150c4be2 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -1,6 +1,6 @@
- @hide_top_links = true
-- page_title "To-Do List"
-- header_title "To-Do List", dashboard_todos_path
+- page_title _("To-Do List")
+- header_title _("To-Do List"), dashboard_todos_path
= render_dashboard_gold_trial(current_user)
@@ -25,7 +25,7 @@
.nav-controls
- if @todos.any?(&:pending?)
- .append-right-default
+ .gl-mr-3
= link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'btn btn-loading d-flex align-items-center js-todos-mark-all', method: :delete, data: { href: destroy_all_dashboard_todos_path(todos_filter_params) } do
Mark all as done
%span.spinner.ml-1
diff --git a/app/views/devise/mailer/_confirmation_instructions_account.html.haml b/app/views/devise/mailer/_confirmation_instructions_account.html.haml
index 65565b7b8a8..27ef586d90f 100644
--- a/app/views/devise/mailer/_confirmation_instructions_account.html.haml
+++ b/app/views/devise/mailer/_confirmation_instructions_account.html.haml
@@ -1,7 +1,7 @@
- confirmation_link = confirmation_url(@resource, confirmation_token: @token)
-- if @resource.unconfirmed_email.present?
+- if @resource.unconfirmed_email.present? || !@resource.created_recently?
#content
- = email_default_heading(@resource.unconfirmed_email)
+ = email_default_heading(@resource.unconfirmed_email || @resource.email)
%p Click the link below to confirm your email address.
#cta
= link_to 'Confirm your email address', confirmation_link
diff --git a/app/views/devise/mailer/_confirmation_instructions_account.text.erb b/app/views/devise/mailer/_confirmation_instructions_account.text.erb
index 01f09aa763d..5bccb68bbe2 100644
--- a/app/views/devise/mailer/_confirmation_instructions_account.text.erb
+++ b/app/views/devise/mailer/_confirmation_instructions_account.text.erb
@@ -1,6 +1,5 @@
-<% if @resource.unconfirmed_email.present? %>
-<%= @resource.unconfirmed_email %>,
-
+<% if @resource.unconfirmed_email.present? || !@resource.created_recently? %>
+<%= @resource.unconfirmed_email || @resource.email %>,
Use the link below to confirm your email address.
<% else %>
<% if Gitlab.com? %>
diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml
index ccc3e734276..f14d50eaf71 100644
--- a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml
+++ b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml
@@ -1,5 +1,5 @@
#content
- = email_default_heading("#{sanitize_name(@resource.user.name)}, you've added an additional email!")
+ = email_default_heading("#{sanitize_name(@resource.user.name)}, confirm your email address now!")
%p Click the link below to confirm your email address (#{@resource.email})
#cta
= link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token)
diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb
index a3b28cb0b84..b91498ccfae 100644
--- a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb
+++ b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb
@@ -1,4 +1,4 @@
-<%= @resource.user.name %>, you've added an additional email!
+<%= @resource.user.name %>, confirm your email address now!
Use the link below to confirm your email address (<%= @resource.email %>)
diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml
index 9fb5e27b692..fb00e1b4384 100644
--- a/app/views/devise/registrations/new.html.haml
+++ b/app/views/devise/registrations/new.html.haml
@@ -1,4 +1,4 @@
-- page_title "Sign up"
+- page_title _("Sign up")
- if experiment_enabled?(:signup_flow)
.row
.col-lg-7
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index fd6d8f3f769..c466d2ce936 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -1,4 +1,4 @@
-- page_title "Sign in"
+- page_title _("Sign in")
#signin-container
- if any_form_based_providers_enabled?
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index 126d8450568..115ebc94238 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -8,10 +8,10 @@
= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
%div
= f.label 'Two-Factor Authentication code', name: :otp_attempt
- = f.text_field :otp_attempt, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.'
+ = f.text_field :otp_attempt, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.', data: { qa_selector: 'two_fa_code_field' }
%p.form-text.text-muted.hint Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.
.prepend-top-20
- = f.submit "Verify code", class: "btn btn-success"
+ = f.submit "Verify code", class: "btn btn-success", data: { qa_selector: 'verify_code_button' }
- if @user.two_factor_u2f_enabled?
= render "u2f/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
index 7bc3042c94d..61271f4525c 100644
--- a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
+++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
@@ -1,5 +1,6 @@
- max_first_name_length = max_last_name_length = 127
- max_username_length = 255
+- min_username_length = 2
.signup-box.p-3.mb-2
.signup-body
= form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f|
@@ -16,7 +17,7 @@
= f.text_field :last_name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_last_name_length, :max_length_message => _("Last Name is too long (maximum is %{max_length} characters).") % { max_length: max_last_name_length }, :qa_selector => 'new_user_lastname_field' }, required: true, title: _("This field is required.")
.username.form-group
= f.label :username, class: 'label-bold'
- = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :max_length => max_username_length, :max_length_message => _("Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
+ = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :min_length => min_username_length, :min_length_message => s_("SignUp|Username is too short (minimum is %{min_length} characters).") % { min_length: min_username_length }, :max_length => max_username_length, :max_length_message => _("Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
%p.validation-error.gl-field-error-ignore.field-validation.mt-1.hide.cred= _('Username is already taken.')
%p.validation-success.gl-field-error-ignore.field-validation.mt-1.hide.cgreen= _('Username is available.')
%p.validation-pending.gl-field-error-ignore.field-validation.mt-1.hide= _('Checking username availability...')
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 7c5b85c903c..0735702ae5f 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -1,5 +1,6 @@
- max_name_length = 255
- max_username_length = 255
+- min_username_length = 2
#register-pane.tab-pane.login-box{ role: 'tabpanel' }
.login-body
= form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f|
@@ -12,7 +13,7 @@
= f.text_field :name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_name_length, :max_length_message => s_("SignUp|Name is too long (maximum is %{max_length} characters).") % { max_length: max_name_length }, :qa_selector => 'new_user_name_field' }, required: true, title: _("This field is required.")
.username.form-group
= f.label :username, class: 'label-bold'
- = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :max_length => max_username_length, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
+ = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :min_length => min_username_length, :min_length_message => s_("SignUp|Username is too short (minimum is %{min_length} characters).") % { min_length: min_username_length }, :max_length => max_username_length, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
%p.validation-error.gl-field-error-ignore.field-validation.hide= _('Username is already taken.')
%p.validation-success.gl-field-error-ignore.field-validation.hide= _('Username is available.')
%p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking username availability...')
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 9659d416a38..4a27284cbae 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -37,7 +37,7 @@
an outdated change in
commit
- %span.commit-sha= Commit.truncate_sha(discussion.commit_id)
+ %span.commit-sha= truncate_sha(discussion.commit_id)
- else
- unless discussion.active?
an old version of
diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml
index 79abe31a056..d74cba984e8 100644
--- a/app/views/doorkeeper/applications/_form.html.haml
+++ b/app/views/doorkeeper/applications/_form.html.haml
@@ -25,5 +25,5 @@
= f.label :scopes, class: 'label-bold'
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
- .prepend-top-default
+ .gl-mt-3
= f.submit _('Save application'), class: "btn btn-success"
diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml
index 9aab1556373..051799ca13f 100644
--- a/app/views/doorkeeper/applications/index.html.haml
+++ b/app/views/doorkeeper/applications/index.html.haml
@@ -1,7 +1,7 @@
- page_title _("Applications")
- @content_class = "limit-container-width" unless fluid_layout
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= page_title
@@ -41,7 +41,7 @@
%div= uri
%td= application.access_tokens.count
%td
- = link_to edit_oauth_application_path(application), class: "btn btn-transparent append-right-5" do
+ = link_to edit_oauth_application_path(application), class: "btn btn-transparent gl-mr-2" do
%span.sr-only
= _('Edit')
= icon('pencil')
@@ -49,7 +49,7 @@
- else
.settings-message.text-center
= _("You don't have any applications")
- .oauth-authorized-applications.prepend-top-20.append-bottom-default
+ .oauth-authorized-applications.prepend-top-20.gl-mb-3
- if user_oauth_applications?
%h5
= _("Authorized applications (%{size})") % { size: @authorized_apps.size + @authorized_anonymous_tokens.size }
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index 7b29269dbb1..280b5d90793 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -44,4 +44,4 @@
.form-actions
= link_to _('Edit'), edit_oauth_application_path(@application), class: 'btn btn-primary wide float-left'
- = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger prepend-left-10'
+ = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3'
diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml
index 5d57337a568..70abc1a267a 100644
--- a/app/views/doorkeeper/authorizations/new.html.haml
+++ b/app/views/doorkeeper/authorizations/new.html.haml
@@ -46,4 +46,4 @@
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= hidden_field_tag :nonce, @pre_auth.nonce
- = submit_tag _("Authorize"), class: "btn btn-success prepend-left-10", data: { qa_selector: 'authorization_button' }
+ = submit_tag _("Authorize"), class: "btn btn-success gl-ml-3", data: { qa_selector: 'authorization_button' }
diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml
index c042cd2c3e3..83f7d743755 100644
--- a/app/views/events/_event.html.haml
+++ b/app/views/events/_event.html.haml
@@ -7,6 +7,8 @@
- if event.wiki_page?
= render "events/event/wiki", event: event
+ - elsif event.design?
+ = render 'events/event/design', event: event
- elsif event.created_project_action?
= render "events/event/created_project", event: event
- elsif event.push_action?
diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml
index 50c5885c648..dc16c46476e 100644
--- a/app/views/events/event/_common.html.haml
+++ b/app/views/events/event/_common.html.haml
@@ -5,16 +5,16 @@
.event-title.d-flex.flex-wrap
= inline_event_icon(event)
- if event.target
- %span.event-type.d-inline-block.append-right-4{ class: event.action_name }
+ %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name }
= event.action_name
- %span.event-target-type.append-right-4= event.target_type.titleize.downcase
- = link_to event.target_link_options, class: 'has-tooltip event-target-link append-right-4', title: event.target_title do
+ %span.event-target-type.gl-mr-2= event.target_type.titleize.downcase
+ = link_to event.target_link_options, class: 'has-tooltip event-target-link gl-mr-2', title: event.target_title do
= event.target.reference_link_text
- unless event.milestone?
- %span.event-target-title.append-right-4{ dir: "auto" }
+ %span.event-target-title.gl-mr-2{ dir: "auto" }
= "&quot;".html_safe + event.target.title + "&quot".html_safe
- else
- %span.event-type.d-inline-block.append-right-4{ class: event.action_name }
+ %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name }
= event_action_name(event)
= render "events/event_scope", event: event if event.resource_parent.present?
diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml
index 606b0febb57..f0bb07d062c 100644
--- a/app/views/events/event/_created_project.html.haml
+++ b/app/views/events/event/_created_project.html.haml
@@ -4,7 +4,7 @@
.event-title.d-flex.flex-wrap
= inline_event_icon(event)
- %span.event-type.d-inline-block.append-right-4{ class: event.action_name }
+ %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name }
= event_action_name(event)
- if event.project
diff --git a/app/views/events/event/_design.html.haml b/app/views/events/event/_design.html.haml
new file mode 100644
index 00000000000..c1fa1aaca50
--- /dev/null
+++ b/app/views/events/event/_design.html.haml
@@ -0,0 +1,11 @@
+= icon_for_profile_event(event)
+
+= event_user_info(event)
+
+.event-title.d-flex.flex-wrap
+ = inline_event_icon(event)
+ %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name }
+ = event.action_name
+ = event_design_title_html(event)
+ = render "events/event_scope", event: event
+
diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml
index 21e8b1401ca..a81b999acba 100644
--- a/app/views/events/event/_note.html.haml
+++ b/app/views/events/event/_note.html.haml
@@ -4,12 +4,12 @@
.event-title.d-flex.flex-wrap
= inline_event_icon(event)
- %span.event-type.d-inline-block.append-right-4{ class: event.action_name }
+ %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name }
= event.action_name
= event_note_title_html(event)
- title = note_target_title(event.target)
- if title.present?
- %span.event-target-title.append-right-4{ dir: "auto" }
+ %span.event-target-title.gl-mr-2{ dir: "auto" }
= "&quot;".html_safe + title + "&quot".html_safe
= render "events/event_scope", event: event
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index b9e88f3fc47..4c1ee5fd3b7 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -7,9 +7,9 @@
.event-title.d-flex.flex-wrap
= inline_event_icon(event)
- many_refs = event.ref_count.to_i > 1
- %span.event-type.d-inline-block.append-right-4.pushed= many_refs ? "#{event.action_name} #{event.ref_count} #{event.ref_type.pluralize}" : "#{event.action_name} #{event.ref_type}"
+ %span.event-type.d-inline-block.gl-mr-2.pushed= many_refs ? "#{event.action_name} #{event.ref_count} #{event.ref_type.pluralize}" : "#{event.action_name} #{event.ref_type}"
- unless many_refs
- %span.append-right-4
+ %span.gl-mr-2
- commits_link = project_commits_path(project, event.ref_name)
- should_link = event.tag? ? project.repository.tag_exists?(event.ref_name) : project.repository.branch_exists?(event.ref_name)
= link_to_if should_link, event.ref_name, commits_link, class: 'ref-name'
diff --git a/app/views/events/event/_wiki.html.haml b/app/views/events/event/_wiki.html.haml
index 7ca98294521..cbd5ebcae12 100644
--- a/app/views/events/event/_wiki.html.haml
+++ b/app/views/events/event/_wiki.html.haml
@@ -4,7 +4,7 @@
.event-title.d-flex.flex-wrap
= inline_event_icon(event)
- %span.event-type.d-inline-block.append-right-4{ class: event.action_name }
+ %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name }
= event.action_name
= event_wiki_title_html(event)
= render "events/event_scope", event: event
diff --git a/app/views/explore/snippets/index.html.haml b/app/views/explore/snippets/index.html.haml
index d23c8301b10..bf861e30b3a 100644
--- a/app/views/explore/snippets/index.html.haml
+++ b/app/views/explore/snippets/index.html.haml
@@ -1,6 +1,6 @@
- @hide_top_links = true
-- page_title "Snippets"
-- header_title "Snippets", snippets_path
+- page_title _("Snippets")
+- header_title _("Snippets"), snippets_path
- if current_user
= render 'dashboard/snippets_head'
diff --git a/app/views/groups/_flash_messages.html.haml b/app/views/groups/_flash_messages.html.haml
index ca951f28fcf..d1fea0e60c6 100644
--- a/app/views/groups/_flash_messages.html.haml
+++ b/app/views/groups/_flash_messages.html.haml
@@ -1,3 +1,3 @@
= content_for :flash_message do
= render_if_exists 'shared/shared_runners_minutes_limit', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)]
- = render 'shared/namespace_storage_limit_alert', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)]
+ = render_if_exists 'shared/namespace_storage_limit_alert', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)]
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 9bf7ad228d9..2cf94695482 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -5,11 +5,11 @@
.group-home-panel
.row.mb-3
.home-panel-title-row.col-md-12.col-lg-6.d-flex
- .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none
+ .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none
= group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64)
.d-flex.flex-column.flex-wrap.align-items-baseline
.d-inline-flex.align-items-baseline
- %h1.home-panel-title.gl-mt-3.append-bottom-5
+ %h1.home-panel-title.gl-mt-3.gl-mb-2
= @group.name
%span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
= visibility_level_icon(@group.visibility_level, fw: false, options: {class: 'icon'})
@@ -27,7 +27,7 @@
- new_project_label = _("New project")
- new_subgroup_label = _("New subgroup")
- if can_create_projects and can_create_subgroups
- .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.prepend-top-default.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
+ .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.gl-mt-3.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
%input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } }
%button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } }
= sprite_icon("chevron-down", css_class: "icon dropdown-btn-icon")
@@ -48,9 +48,9 @@
%strong= new_subgroup_label
%span= s_("GroupsTree|Create a subgroup in this group.")
- elsif can_create_projects
- = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success prepend-top-default"
+ = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success gl-mt-3"
- elsif can_create_subgroups
- = link_to new_subgroup_label, new_group_path(parent_id: @group.id), class: "btn btn-success prepend-top-default"
+ = link_to new_subgroup_label, new_group_path(parent_id: @group.id), class: "btn btn-success gl-mt-3"
- if @group.description.present?
.group-home-desc.mt-1
diff --git a/app/views/groups/activity.html.haml b/app/views/groups/activity.html.haml
index cb7dab26332..bc75fada937 100644
--- a/app/views/groups/activity.html.haml
+++ b/app/views/groups/activity.html.haml
@@ -1,7 +1,7 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
-- page_title "Activity"
+- page_title _("Activity")
%section.activities
= render 'activities'
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 2e58517fdc7..1e04b2761f6 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -1,4 +1,5 @@
- breadcrumb_title _("General Settings")
+- page_title _("General Settings")
- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 1f2fb747c7d..b9ea8316bbc 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -4,7 +4,8 @@
- pending_active = params[:search_invited].present?
- total_count = @members.count + @group.shared_with_group_links.count
-.project-members-page.prepend-top-default
+.js-remove-member-modal
+.project-members-page.gl-mt-3
%h4
= _("Group members")
%hr
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 1cb1cc45bdb..59432e5f015 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -1,6 +1,6 @@
- @can_bulk_update = can?(current_user, :admin_issue, @group) && @group.feature_available?(:group_bulk_edit)
-- page_title "Issues"
+- page_title _("Issues")
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
diff --git a/app/views/groups/labels/edit.html.haml b/app/views/groups/labels/edit.html.haml
index 586b0f6ebfa..fbab4f8a250 100644
--- a/app/views/groups/labels/edit.html.haml
+++ b/app/views/groups/labels/edit.html.haml
@@ -1,6 +1,6 @@
- add_to_breadcrumbs _("Labels"), group_labels_path(@group)
- breadcrumb_title _("Edit")
-- page_title "Edit", @label.name, _("Labels")
+- page_title _("Edit"), @label.name, _("Labels")
%h3.page-title
Edit Label
diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index 41c1d3e84b7..3299d127222 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -1,4 +1,4 @@
-- page_title 'Labels'
+- page_title _('Labels')
- can_admin_label = can?(current_user, :admin_label, @group)
- search = params[:search]
- subscribed = params[:subscribed]
@@ -8,7 +8,7 @@
#promote-label-modal
= render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label
- .labels-container.prepend-top-5
+ .labels-container.gl-mt-2
- if @labels.any?
.text-muted
= _('Labels can be applied to %{features}. Group labels are available for any project within the group.') % { features: issuable_types.to_sentence }
@@ -27,5 +27,5 @@
= render 'shared/empty_states/labels'
%template#js-badge-item-template
- %li.label-link-item.js-priority-badge.inline.prepend-left-10
+ %li.label-link-item.js-priority-badge.inline.gl-ml-3
.label-badge.label-badge-blue= _('Prioritized label')
diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml
index 0780fab513b..1828f850d35 100644
--- a/app/views/groups/merge_requests.html.haml
+++ b/app/views/groups/merge_requests.html.haml
@@ -1,6 +1,6 @@
- @can_bulk_update = can?(current_user, :admin_merge_request, @group) && @group.feature_available?(:group_bulk_edit)
-- page_title "Merge Requests"
+- page_title _("Merge Requests")
- if group_merge_requests_count(state: 'all').zero?
= render 'shared/empty_states/merge_requests', project_select_button: true
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index 7a35bc12eee..df82b264f9a 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -6,20 +6,20 @@
.col-form-label.col-sm-2
= f.label :title, "Title"
.col-sm-10
- = f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
+ = f.text_field :title, maxlength: 255, class: "form-control", data: { qa_selector: "milestone_title_field" }, required: true, autofocus: true
.form-group.row.milestone-description
.col-form-label.col-sm-2
= f.label :description, "Description"
.col-sm-10
= render layout: 'shared/md_preview', locals: { url: group_preview_markdown_path } do
- = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...', supports_autocomplete: false
+ = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', qa_selector: 'milestone_description_field', placeholder: 'Write milestone description...', supports_autocomplete: false
.clearfix
.error-alert
= render "shared/milestones/form_dates", f: f
.form-actions
- if @milestone.new_record?
- = f.submit 'Create milestone', class: "btn-success btn"
+ = f.submit 'Create milestone', class: "btn-success btn", data: { qa_selector: "create_milestone_button" }
= link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
- else
= f.submit 'Update milestone', class: "btn-success btn"
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index 03407adb57d..1685707d457 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -1,4 +1,4 @@
-- page_title "Milestones"
+- page_title _("Milestones")
.top-area
= render 'shared/milestones_filter', counts: @milestone_states
@@ -7,7 +7,7 @@
= render 'shared/milestones/search_form'
= render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestone, @group)
- = link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-success"
+ = link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-success", data: { qa_selector: "new_group_milestone_link" }
.milestones
%ul.content-list
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index ed016206310..a231702012c 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -4,7 +4,7 @@
- header_title _("Groups"), dashboard_groups_path
- active_tab = local_assigns.fetch(:active_tab, 'create')
-.group-edit-container.prepend-top-default
+.group-edit-container.gl-mt-3
.row
.col-lg-3.group-settings-sidebar
%h4.prepend-top-0
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index 8b01e54474a..bf9d89da24a 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -1,6 +1,7 @@
-- breadcrumb_title "Projects"
+- breadcrumb_title _("Projects")
+- page_title _("Projects")
-.card.prepend-top-default
+.card.gl-mt-3
.card-header
%strong= @group.name
projects:
diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml
index f752bc0a702..554240b7aef 100644
--- a/app/views/groups/runners/_group_runners.html.haml
+++ b/app/views/groups/runners/_group_runners.html.haml
@@ -18,13 +18,3 @@
locals: { registration_token: @group.runners_token,
type: 'group',
reset_token_url: reset_registration_token_group_settings_ci_cd_path }
-
-- if @group.runners.empty?
- %h4.underlined-title
- = _('This group does not provide any group Runners yet.')
-
-- else
- %h4.underlined-title
- = _('Available group Runners: %{runners}').html_safe % { runners: @group.runners.count }
- %ul.bordered-list
- = render partial: 'groups/runners/runner', collection: @group.runners, as: :runner
diff --git a/app/views/groups/runners/_index.html.haml b/app/views/groups/runners/_index.html.haml
index 0cf9011b471..51375f50659 100644
--- a/app/views/groups/runners/_index.html.haml
+++ b/app/views/groups/runners/_index.html.haml
@@ -7,3 +7,97 @@
.row
.col-sm-6
= render 'groups/runners/group_runners'
+
+%h4.underlined-title
+ = _('Available Runners: %{runners}').html_safe % { runners: limited_counter_with_delimiter(@all_group_runners) }
+
+-# haml-lint:disable NoPlainNodes
+.row
+ .col-sm-9
+ = form_tag group_settings_ci_cd_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
+ .filtered-search-wrapper.d-flex
+ .filtered-search-box
+ = dropdown_tag(_('Recent searches'),
+ options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
+ toggle_class: 'btn filtered-search-history-dropdown-toggle-button',
+ dropdown_class: 'filtered-search-history-dropdown',
+ content_class: 'filtered-search-history-dropdown-content' }) do
+ .js-filtered-search-history-dropdown{ data: { full_path: group_settings_ci_cd_path } }
+ .filtered-search-box-input-container.droplab-dropdown
+ .scroll-container
+ %ul.tokens-container.list-unstyled
+ %li.input-token
+ %input.form-control.filtered-search{ search_filter_input_options('runners') }
+ #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
+ = button_tag class: 'btn btn-link' do
+ -# Encapsulate static class name `{{icon}}` inside #{} to bypass
+ -# haml lint's ClassAttributeWithStaticValue
+ %svg
+ %use{ 'xlink:href': "#{'{{icon}}'}" }
+ %span.js-filter-hint
+ {{formattedKey}}
+ #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
+ %li.filter-dropdown-item{ data: { value: "{{ title }}" } }
+ = button_tag class: 'btn btn-link' do
+ {{ title }}
+ %span.btn-helptext
+ {{ help }}
+ #js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ - Ci::Runner::AVAILABLE_STATUSES.each do |status|
+ %li.filter-dropdown-item{ data: { value: status } }
+ = button_tag class: 'btn btn-link' do
+ = status.titleize
+
+ #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ - Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
+ - next if runner_type == 'instance_type'
+ %li.filter-dropdown-item{ data: { value: runner_type } }
+ = button_tag class: 'btn btn-link' do
+ = runner_type.titleize
+
+ #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'none' } }
+ = button_tag class: 'btn btn-link' do
+ = _('No Tag')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ = button_tag class: 'btn btn-link js-data-value' do
+ %span.dropdown-light-content
+ {{name}}
+
+ = button_tag class: 'clear-search hidden' do
+ = icon('times')
+ .filter-dropdown-container
+ = render 'admin/runners/sort_dropdown'
+
+ .col-sm-3.text-right-lg
+ = _('Runners currently online: %{active_runners_count}') % { active_runners_count: limited_counter_with_delimiter(@all_group_runners.online) }
+
+
+- if @group_runners.any?
+ .runners-content.content-list
+ .table-holder
+ .gl-responsive-table-row.table-row-header{ role: 'row' }
+ .table-section.section-10{ role: 'rowheader' }= _('Type/State')
+ .table-section.section-10{ role: 'rowheader' }= _('Runner token')
+ .table-section.section-20{ role: 'rowheader' }= _('Description')
+ .table-section.section-10{ role: 'rowheader' }= _('Version')
+ .table-section.section-10{ role: 'rowheader' }= _('IP Address')
+ .table-section.section-5{ role: 'rowheader' }= _('Projects')
+ .table-section.section-5{ role: 'rowheader' }= _('Jobs')
+ .table-section.section-10{ role: 'rowheader' }= _('Tags')
+ .table-section.section-10{ role: 'rowheader' }= _('Last contact')
+ .table-section.section-10{ role: 'rowheader' }
+
+ - @group_runners.each do |runner|
+ = render 'groups/runners/runner', runner: runner
+ = paginate @group_runners, theme: 'gitlab', :params => { :anchor => 'runners-settings' }
+- else
+ .nothing-here-block= _('No runners found')
diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml
index 3f89b04a5fc..df615eb189a 100644
--- a/app/views/groups/runners/_runner.html.haml
+++ b/app/views/groups/runners/_runner.html.haml
@@ -1,27 +1,86 @@
-%li.runner{ id: dom_id(runner) }
- %h4
- = runner_status_icon(runner)
+.gl-responsive-table-row{ id: dom_id(runner) }
+ .table-section.section-10.section-wrap
+ .table-mobile-header{ role: 'rowheader' }= _('Type')
+ .table-mobile-content
+ - if runner.group_type?
+ %span.badge.badge-success
+ = _('group')
+ - else
+ %span.badge.badge-info
+ = _('specific')
+ - if runner.locked?
+ %span.badge.badge-warning
+ = _('locked')
+ - unless runner.active?
+ %span.badge.badge-danger
+ = _('paused')
+
+ .table-section.section-10
+ .table-mobile-header{ role: 'rowheader' }= _('Runner token')
+ .table-mobile-content
+ = link_to runner.short_sha, group_runner_path(@group, runner)
+
+ .table-section.section-20
+ .table-mobile-header{ role: 'rowheader' }= _('Description')
+ .table-mobile-content.str-truncated.has-tooltip{ title: runner.description }
+ = runner.description
- = link_to runner.short_sha, group_runner_path(@group, runner), class: 'commit-sha'
+ .table-section.section-10
+ .table-mobile-header{ role: 'rowheader' }= _('Version')
+ .table-mobile-content.str-truncated.has-tooltip{ title: runner.version }
+ = runner.version
- %small.edit-runner
- = link_to edit_group_runner_path(@group, runner) do
- = icon('edit')
+ .table-section.section-10
+ .table-mobile-header{ role: 'rowheader' }= _('IP Address')
+ .table-mobile-content.str-truncated.has-tooltip{ title: runner.ip_address }
+ = runner.ip_address
- .float-right
- - if runner.active?
- = link_to _('Pause'), pause_group_runner_path(@group, runner), method: :post, class: 'btn btn-sm btn-danger', data: { confirm: _("Are you sure?") }
+ .table-section.section-5
+ .table-mobile-header{ role: 'rowheader' }= _('Projects')
+ .table-mobile-content
+ - if runner.group_type?
+ = _('n/a')
- else
- = link_to _('Resume'), resume_group_runner_path(@group, runner), method: :post, class: 'btn btn-success btn-sm'
- = link_to _('Remove Runner'), group_runner_path(@group, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn btn-danger btn-sm'
- .float-right
- %small.light
- \##{runner.id}
- - if runner.description.present?
- %p.runner-description
- = runner.description
- - if runner.tag_list.present?
- %p
- - runner.tag_list.sort.each do |tag|
- %span.label.label-primary
+ = runner.projects.count(:all)
+
+ .table-section.section-5
+ .table-mobile-header{ role: 'rowheader' }= _('Jobs')
+ .table-mobile-content
+ = limited_counter_with_delimiter(runner.builds)
+
+ .table-section.section-10.section-wrap
+ .table-mobile-header{ role: 'rowheader' }= _('Tags')
+ .table-mobile-content
+ - runner.tags.map(&:name).sort.each do |tag|
+ %span.badge.badge-primary.str-truncated.has-tooltip{ title: tag }
= tag
+
+ .table-section.section-10
+ .table-mobile-header{ role: 'rowheader' }= _('Last contact')
+ .table-mobile-content
+ - contacted_at = runner_contacted_at(runner)
+ - if contacted_at
+ = time_ago_with_tooltip contacted_at
+ - else
+ = _('Never')
+
+ .table-section.table-button-footer.section-10
+ .btn-group.table-action-buttons
+ .btn-group
+ = link_to edit_group_runner_path(@group, runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
+ = icon('pencil')
+ .btn-group
+ - if runner.active?
+ = link_to pause_group_runner_path(@group, runner), method: :post, class: 'btn btn-default has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
+ = icon('pause')
+ - else
+ = link_to resume_group_runner_path(@group, runner), method: :post, class: 'btn btn-default has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
+ = icon('play')
+ - if runner.belongs_to_more_than_one_project?
+ .btn-group
+ .btn.btn-danger.has-tooltip{ 'aria-label' => 'Remove', 'data-container' => 'body', 'data-original-title' => _('Multi-project Runners cannot be removed'), 'data-placement' => 'top', disabled: 'disabled' }
+ = icon('remove')
+ - else
+ .btn-group
+ = link_to group_runner_path(@group, runner), method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
+ = icon('remove')
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 742bf50fb89..0094104e07d 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -19,7 +19,7 @@
= render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group
- .form-group.prepend-top-default.append-bottom-20
+ .form-group.gl-mt-3.append-bottom-20
.avatar-container.rect-avatar.s90
= group_icon(@group, alt: '', class: 'avatar group-avatar s90')
= f.label :avatar, _('Group avatar'), class: 'label-bold d-block'
diff --git a/app/views/groups/settings/_lfs.html.haml b/app/views/groups/settings/_lfs.html.haml
index 7970c3c73f6..77c84862316 100644
--- a/app/views/groups/settings/_lfs.html.haml
+++ b/app/views/groups/settings/_lfs.html.haml
@@ -5,7 +5,7 @@
%p= s_('Check the %{docs_link_start}documentation%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
-.form-group.append-bottom-default
+.form-group.gl-mb-3
.form-check
= f.check_box :lfs_enabled, checked: @group.lfs_enabled?, class: 'form-check-input', data: { qa_selector: 'lfs_checkbox' }
= f.label :lfs_enabled, class: 'form-check-label' do
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index e886c99a656..507246d573e 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -7,7 +7,7 @@
.form-group
= render 'shared/allow_request_access', form: f
- .form-group.append-bottom-default
+ .form-group.gl-mb-3
.form-check
= f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group), class: 'form-check-input'
= f.label :share_with_group_lock, class: 'form-check-label' do
@@ -16,20 +16,21 @@
= s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: group_link }
%span.js-descr.text-muted= share_with_group_lock_help_text(@group)
- .form-group.append-bottom-default
+ .form-group.gl-mb-3
.form-check
= f.check_box :emails_disabled, checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group), class: 'form-check-input'
= f.label :emails_disabled, class: 'form-check-label' do
%span.d-block= s_('GroupSettings|Disable email notifications')
%span.text-muted= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.')
- .form-group.append-bottom-default
+ .form-group.gl-mb-3
.form-check
= f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'form-check-input'
= f.label :mentions_disabled, class: 'form-check-label' do
%span.d-block= s_('GroupSettings|Disable group mentions')
%span.text-muted= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.')
+ = render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group
= render_if_exists 'groups/settings/ip_restriction', f: f, group: @group
= render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group
= render 'groups/settings/lfs', f: f
@@ -40,4 +41,4 @@
= render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group
= render_if_exists 'groups/member_lock_setting', f: f, group: @group
- = f.submit _('Save changes'), class: 'btn btn-success prepend-top-default js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' }
+ = f.submit _('Save changes'), class: 'btn btn-success gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' }
diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml
index 54e88d11827..139c710fac0 100644
--- a/app/views/groups/settings/ci_cd/_form.html.haml
+++ b/app/views/groups/settings/ci_cd/_form.html.haml
@@ -1,4 +1,4 @@
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-12
= form_for group, url: group_settings_ci_cd_path(group, anchor: 'js-general-pipeline-settings') do |f|
= form_errors(group)
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 8c9b859e127..366d7dd5afe 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title "CI / CD Settings"
-- page_title "CI / CD"
+- breadcrumb_title _("CI / CD Settings")
+- page_title _("CI / CD")
- expanded = expanded_by_default?
- general_expanded = @group.errors.empty? ? expanded : true
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 7e5bf6ddde1..6ad864121d7 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,4 +1,5 @@
- breadcrumb_title _("Details")
+- page_title _("Groups")
- @content_class = "limit-container-width" unless fluid_layout
= content_for :meta_tags do
@@ -18,8 +19,8 @@
.groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
.top-area.group-nav-container.justify-content-between
.scrolling-tabs-container.inner-page-scroll-tabs
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
+ .fade-left= sprite_icon('chevron-lg-left', size: 12)
+ .fade-right= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs
%li.js-subgroups_and_projects-tab
= link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index bd5424c30c6..80df8581a9b 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -2,10 +2,6 @@
.modal-dialog.modal-lg.modal-1040
.modal-content
.modal-header
- %h4.modal-title
- = _('Keyboard Shortcuts')
- %small
- = link_to _('(Show all)'), '#', class: 'js-more-help-button'
.js-toggle-shortcuts
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
@@ -313,6 +309,10 @@
%td.shortcut
%kbd p
%td= _('Previous unresolved discussion')
+ %tr
+ %td.shortcut
+ %kbd b
+ %td= _('Copy source branch name')
%tbody
%tr
%th
diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml
index ed904c48ddb..03f8539293b 100644
--- a/app/views/help/index.html.haml
+++ b/app/views/help/index.html.haml
@@ -1,6 +1,6 @@
%div
- if Gitlab::CurrentSettings.help_page_text.present?
- .prepend-top-default.md
+ .gl-mt-3.md
= markdown_field(Gitlab::CurrentSettings.current_application_settings, :help_page_text)
%hr
@@ -28,7 +28,7 @@
%p= link_to 'Check the current instance configuration ', help_instance_configuration_url
%hr
-.row.prepend-top-default
+.row.gl-mt-3
.col-md-8
.documentation-index.md
= markdown(@help_index)
diff --git a/app/views/help/instance_configuration.html.haml b/app/views/help/instance_configuration.html.haml
index 99576d45f76..260566b1441 100644
--- a/app/views/help/instance_configuration.html.haml
+++ b/app/views/help/instance_configuration.html.haml
@@ -1,4 +1,4 @@
-- page_title 'Instance Configuration'
+- page_title _('Instance Configuration')
.documentation.md
%h1 Instance Configuration
diff --git a/app/views/help/show.html.haml b/app/views/help/show.html.haml
index dace8a77736..c41f6ea3ed4 100644
--- a/app/views/help/show.html.haml
+++ b/app/views/help/show.html.haml
@@ -1,5 +1,5 @@
- page_title @path.split("/").reverse.map(&:humanize)
- @content_class = "limit-container-width" unless fluid_layout
-.documentation.md.prepend-top-default
+.documentation.md.gl-mt-3
= markdown @markdown
diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml
index d71650ae50c..5c216ee1ec0 100644
--- a/app/views/help/ui.html.haml
+++ b/app/views/help/ui.html.haml
@@ -1,4 +1,4 @@
-- page_title "UI Development Kit", "Help"
+- page_title _("UI Development Kit"), _("Help")
- lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed fermentum nisi sapien, non consequat lectus aliquam ultrices. Suspendisse sodales est euismod nunc condimentum, a consectetur diam ornare."
- link_classes = "flex-grow-1 mx-1 "
diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml
index b871f0363f3..d0384fd50bc 100644
--- a/app/views/ide/_show.html.haml
+++ b/app/views/ide/_show.html.haml
@@ -1,5 +1,5 @@
- @body_class = 'ide-layout'
-- page_title 'IDE'
+- page_title _('IDE')
- content_for :page_specific_javascripts do
= stylesheet_link_tag 'page_bundles/ide'
diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml
index d405acef75c..9b54cbe577a 100644
--- a/app/views/import/bitbucket/status.html.haml
+++ b/app/views/import/bitbucket/status.html.haml
@@ -5,93 +5,4 @@
%i.fa.fa-bitbucket
= _('Import projects from Bitbucket')
-- if Feature.enabled?(:new_import_ui)
- = render 'import/githubish_status', provider: 'bitbucket'
-- else
- - if @repos.any?
- %p.light
- = _('Select projects you want to import.')
- %p
- - if @incompatible_repos.any?
- = button_tag class: 'btn btn-import btn-success js-import-all' do
- = _('Import all compatible projects')
- = icon('spinner spin', class: 'loading-icon')
- - else
- = button_tag class: 'btn btn-import btn-success js-import-all' do
- = _('Import all projects')
- = icon('spinner spin', class: 'loading-icon')
-
- .position-relative.ms-no-clear.d-flex.flex-fill.float-right.append-bottom-10
- = form_tag status_import_bitbucket_path, method: 'get' do
- = text_field_tag :filter, @filter, class: 'form-control pr-5', placeholder: _('Filter projects'), size: 40, autofocus: true, 'aria-label': _('Search')
- .position-absolute.position-top-0.d-flex.align-items-center.text-muted.position-right-0.h-100
- .border-left
- %button{ class: 'btn btn-transparent btn-secondary', 'aria-label': _('Search Button'), type: 'submit' }
- %i{ class: 'fa fa-search', 'aria-hidden': true }
-
- .table-responsive
- %table.table.import-jobs
- %colgroup.import-jobs-from-col
- %colgroup.import-jobs-to-col
- %colgroup.import-jobs-status-col
- %thead
- %tr
- %th= _('From Bitbucket')
- %th= _('To GitLab')
- %th= _('Status')
- %tbody
- - @already_added_projects.each do |project|
- %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
- %td
- = link_to project.import_source, "https://bitbucket.org/#{project.import_source}", target: '_blank', rel: 'noopener noreferrer'
- %td
- = link_to project.full_path, [project.namespace.becomes(Namespace), project]
- %td.job-status
- - case project.import_status
- - when 'finished'
- %span
- %i.fa.fa-check
- = _('done')
- - when 'started'
- %i.fa.fa-spinner.fa-spin
- = _('started')
- - else
- = project.human_import_status_name
-
- - @repos.each do |repo|
- %tr{ id: "repo_#{repo.owner}___#{repo.slug}" }
- %td
- = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank', rel: 'noopener noreferrer'
- %td.import-target
- %fieldset.row
- .input-group
- .project-path.input-group-prepend
- - if current_user.can_select_namespace?
- - selected = params[:namespace_id] || :current_user
- - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner, path: repo.owner) } : {}
- = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'select2 js-select-namespace', tabindex: 1 }
- - else
- = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
- %span.input-group-prepend
- .input-group-text /
- = text_field_tag :path, sanitize_project_name(repo.slug), class: "input-mini form-control", tabindex: 2, autofocus: true, required: true
- %td.import-actions.job-status
- = button_tag class: 'btn btn-import js-add-to-import' do
- = _('Import')
- = icon('spinner spin', class: 'loading-icon')
- - @incompatible_repos.each do |repo|
- %tr{ id: "repo_#{repo.owner}___#{repo.slug}" }
- %td
- = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank', rel: 'noopener noreferrer'
- %td.import-target
- %td.import-actions-job-status
- = label_tag _('Incompatible Project'), nil, class: 'label badge-danger'
-
- - if @incompatible_repos.any?
- %p
- = _("One or more of your Bitbucket projects cannot be imported into GitLab directly because they use Subversion or Mercurial for version control, rather than Git.")
- - link_to_git = link_to(_('Git'), 'https://www.atlassian.com/git/tutorials/migrating-overview')
- - link_to_import_flow = link_to(_('import flow'), status_import_bitbucket_path)
- = _("Please convert them to %{link_to_git}, and go through the %{link_to_import_flow} again.").html_safe % { link_to_git: link_to_git, link_to_import_flow: link_to_import_flow }
-
- .js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_path}", import_path: "#{import_bitbucket_path}" } }
+= render 'import/githubish_status', provider: 'bitbucket'
diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml
index 2eac8d0c5a1..735535ffc36 100644
--- a/app/views/import/bitbucket_server/new.html.haml
+++ b/app/views/import/bitbucket_server/new.html.haml
@@ -1,7 +1,7 @@
- title = _('Bitbucket Server Import')
- page_title title
- breadcrumb_title title
-- header_title "Projects", root_path
+- header_title _("Projects"), root_path
%h3.page-title
= icon 'bitbucket-square', text: _('Import repositories from Bitbucket Server')
@@ -17,7 +17,7 @@
.form-group.row
= label_tag :bitbucket_server_url, 'Username', class: 'col-form-label col-md-2'
.col-md-4
- = text_field_tag :bitbucket_username, '', class: 'form-control gl-mr-3', placeholder: _('username'), size: 40
+ = text_field_tag :bitbucket_server_username, '', class: 'form-control gl-mr-3', placeholder: _('username'), size: 40
.form-group.row
= label_tag :personal_access_token, 'Password/Personal Access Token', class: 'col-form-label col-md-2'
.col-md-4
diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml
index 3e16f449831..a24a1c1fb05 100644
--- a/app/views/import/bitbucket_server/status.html.haml
+++ b/app/views/import/bitbucket_server/status.html.haml
@@ -1,98 +1,8 @@
-- page_title 'Bitbucket Server import'
-- header_title 'Projects', root_path
+- page_title _('Bitbucket Server import')
+- header_title _('Projects'), root_path
%h3.page-title
%i.fa.fa-bitbucket-square
= _('Import projects from Bitbucket Server')
-- if Feature.enabled?(:new_import_ui)
- = render 'import/githubish_status', provider: 'bitbucket_server', extra_data: { reconfigure_path: configure_import_bitbucket_server_path }
-- else
- - if @repos.any?
- %p.light
- = _('Select projects you want to import.')
- .btn-group
- - if @incompatible_repos.any?
- = button_tag class: 'btn btn-import btn-success js-import-all' do
- = _('Import all compatible projects')
- = icon('spinner spin', class: 'loading-icon')
- - else
- = button_tag class: 'btn btn-import btn-success js-import-all' do
- = _('Import all projects')
- = icon('spinner spin', class: 'loading-icon')
-
- .btn-group
- = link_to('Reconfigure', configure_import_bitbucket_server_path, class: 'btn btn-primary', method: :post)
-
- .input-btn-group.float-right
- = form_tag status_import_bitbucket_server_path, :method => 'get' do
- = text_field_tag :filter, sanitize(params[:filter]), class: 'form-control append-bottom-10', placeholder: _('Filter your projects by name'), size: 40, autoFocus: true
-
- .table-responsive.prepend-top-10
- %table.table.import-jobs
- %colgroup.import-jobs-from-col
- %colgroup.import-jobs-to-col
- %colgroup.import-jobs-status-col
- %thead
- %tr
- %th= _('From Bitbucket Server')
- %th= _('To GitLab')
- %th= _('Status')
- %tbody
- - @already_added_projects.each do |project|
- %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
- %td
- = link_to project.import_source, project.import_source, target: '_blank', rel: 'noopener noreferrer'
- %td
- = link_to project.full_path, [project.namespace.becomes(Namespace), project]
- %td.job-status
- - case project.import_status
- - when 'finished'
- = icon('check', text: 'Done')
- - when 'started'
- = icon('spin', text: 'started')
- - else
- = project.human_import_status_name
-
- - @repos.each do |repo|
- %tr{ id: "repo_#{repo.project_key}___#{repo.slug}", data: { project: repo.project_key, repository: repo.slug } }
- %td
- = sanitize(link_to(repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'), attributes: %w(href target rel))
- %td.import-target
- %fieldset.row
- .input-group
- .project-path.input-group-prepend
- - if current_user.can_select_namespace?
- - selected = params[:namespace_id] || :extra_group
- - opts = current_user.can_create_group? ? { extra_group: Group.new(name: sanitize_project_name(repo.project_key), path: sanitize_project_name(repo.project_key)) } : {}
- = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'select2 js-select-namespace', tabindex: 1 }
- - else
- = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
- %span.input-group-prepend
- .input-group-text /
- = text_field_tag :path, sanitize_project_name(repo.slug), class: "input-mini form-control", tabindex: 2, required: true
- %td.import-actions.job-status
- = button_tag class: 'btn btn-import js-add-to-import' do
- Import
- = icon('spinner spin', class: 'loading-icon')
- - @incompatible_repos.each do |repo|
- %tr{ id: "repo_#{repo.project_key}___#{repo.slug}" }
- %td
- = sanitize(link_to(repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'), attributes: %w(href target rel))
- %td.import-target
- %td.import-actions-job-status
- = label_tag 'Incompatible Project', nil, class: 'label badge-danger'
-
- - if @incompatible_repos.any?
- %p
- One or more of your Bitbucket Server projects cannot be imported into GitLab
- directly because they use Subversion or Mercurial for version control,
- rather than Git. Please convert
- = link_to 'them to Git,', 'https://www.atlassian.com/git/tutorials/migrating-overview'
- and go through the
- = link_to 'import flow', status_import_bitbucket_server_path
- again.
-
- = paginate_without_count(@collection)
-
- .js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_server_path}", import_path: "#{import_bitbucket_server_path}" } }
+= render 'import/githubish_status', provider: 'bitbucket_server', extra_data: { reconfigure_path: configure_import_bitbucket_server_path }
diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml
index 75529487aa4..f201c0e83fe 100644
--- a/app/views/import/fogbugz/status.html.haml
+++ b/app/views/import/fogbugz/status.html.haml
@@ -4,63 +4,8 @@
%i.fa.fa-bug
= _('Import projects from FogBugz')
-- if Feature.enabled?(:new_import_ui)
- %p.light
- - link_to_customize = link_to('customize', new_user_map_import_fogbugz_path)
- = _('Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab.').html_safe % { link_to_customize: link_to_customize }
- %hr
- = render 'import/githubish_status', provider: 'fogbugz', filterable: false
-- else
- - if @repos.any?
- %p.light
- = _('Select projects you want to import.')
- %p.light
- - link_to_customize = link_to('customize', new_user_map_import_fogbugz_path)
- = _('Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab.').html_safe % { link_to_customize: link_to_customize }
- %hr
- %p
- = button_tag class: 'btn btn-import btn-success js-import-all' do
- = _('Import all projects')
- = icon("spinner spin", class: "loading-icon")
-
- .table-responsive
- %table.table.import-jobs
- %colgroup.import-jobs-from-col
- %colgroup.import-jobs-to-col
- %colgroup.import-jobs-status-col
- %thead
- %tr
- %th= _("From FogBugz")
- %th= _("To GitLab")
- %th= _("Status")
- %tbody
- - @already_added_projects.each do |project|
- %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
- %td
- = project.import_source
- %td
- = link_to project.full_path, [project.namespace.becomes(Namespace), project]
- %td.job-status
- - case project.import_status
- - when 'finished'
- %span
- %i.fa.fa-check
- = _("done")
- - when 'started'
- %i.fa.fa-spinner.fa-spin
- = _("started")
- - else
- = project.human_import_status_name
-
- - @repos.each do |repo|
- %tr{ id: "repo_#{repo.id}" }
- %td
- = repo.name
- %td.import-target
- #{current_user.username}/#{repo.name}
- %td.import-actions.job-status
- = button_tag class: "btn btn-import js-add-to-import" do
- = _("Import")
- = icon("spinner spin", class: "loading-icon")
-
- .js-importer-status{ data: { jobs_import_path: "#{jobs_import_fogbugz_path}", import_path: "#{import_fogbugz_path}" } }
+%p.light
+ - link_to_customize = link_to('customize', new_user_map_import_fogbugz_path)
+ = _('Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab.').html_safe % { link_to_customize: link_to_customize }
+%hr
+= render 'import/githubish_status', provider: 'fogbugz', filterable: false
diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml
index a12b69ae5f9..5513849be3d 100644
--- a/app/views/import/gitlab/status.html.haml
+++ b/app/views/import/gitlab/status.html.haml
@@ -1,58 +1,7 @@
- page_title _("GitLab.com import")
- header_title _("Projects"), root_path
%h3.page-title
- %i.fa.fa-heart
+ = sprite_icon('heart', size: 16, css_class: 'gl-vertical-align-middle')
= _('Import projects from GitLab.com')
-- if Feature.enabled?(:new_import_ui)
- = render 'import/githubish_status', provider: 'gitlab', filterable: false
-- else
- %p.light
- = _('Select projects you want to import.')
- %hr
- %p
- = button_tag class: "btn btn-import btn-success js-import-all" do
- = _('Import all projects')
- = icon("spinner spin", class: "loading-icon")
-
- .table-responsive
- %table.table.import-jobs
- %colgroup.import-jobs-from-col
- %colgroup.import-jobs-to-col
- %colgroup.import-jobs-status-col
- %thead
- %tr
- %th= _('From GitLab.com')
- %th= _('To this GitLab instance')
- %th= _('Status')
- %tbody
- - @already_added_projects.each do |project|
- %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
- %td
- = link_to project.import_source, "https://gitlab.com/#{project.import_source}", target: "_blank"
- %td
- = link_to project.full_path, [project.namespace.becomes(Namespace), project]
- %td.job-status
- - case project.import_status
- - when 'finished'
- %span
- %i.fa.fa-check
- = _('done')
- - when 'started'
- %i.fa.fa-spinner.fa-spin
- = _('started')
- - else
- = project.human_import_status_name
-
- - @repos.each do |repo|
- %tr{ id: "repo_#{repo["id"]}" }
- %td
- = link_to repo["path_with_namespace"], "https://gitlab.com/#{repo["path_with_namespace"]}", target: "_blank", rel: 'noopener noreferrer'
- %td.import-target
- = import_project_target(repo['namespace']['path'], repo['name'])
- %td.import-actions.job-status
- = button_tag class: "btn btn-import js-add-to-import" do
- = _('Import')
- = icon("spinner spin", class: "loading-icon")
-
- .js-importer-status{ data: { jobs_import_path: "#{jobs_import_gitlab_path}", import_path: "#{import_gitlab_path}" } }
+= render 'import/githubish_status', provider: 'gitlab', filterable: false
diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml
index feebbccf46a..b667d2aa0d7 100644
--- a/app/views/import/gitlab_projects/new.html.haml
+++ b/app/views/import/gitlab_projects/new.html.haml
@@ -1,8 +1,9 @@
- page_title _("GitLab Import")
- header_title _("Projects"), root_path
-%h3.page-title
- = icon('gitlab')
+%h3.page-title.d-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('tanuki', size: 16, css_class: 'gl-mr-2')
= _('Import an exported GitLab project')
%hr
diff --git a/app/views/import/manifest/new.html.haml b/app/views/import/manifest/new.html.haml
index df00c4d2179..852f269f2ed 100644
--- a/app/views/import/manifest/new.html.haml
+++ b/app/views/import/manifest/new.html.haml
@@ -1,5 +1,5 @@
-- page_title "Manifest file import"
-- header_title "Projects", root_path
+- page_title _("Manifest file import")
+- header_title _("Projects"), root_path
%h3.page-title
= _('Manifest file import')
diff --git a/app/views/import/manifest/status.html.haml b/app/views/import/manifest/status.html.haml
index 3d4abc32b88..e85162ad1b4 100644
--- a/app/views/import/manifest/status.html.haml
+++ b/app/views/import/manifest/status.html.haml
@@ -1,5 +1,5 @@
-- page_title "Manifest import"
-- header_title "Projects", root_path
+- page_title _("Manifest import")
+- header_title _("Projects"), root_path
- provider = 'manifest'
%h3.page-title
diff --git a/app/views/instance_statistics/cohorts/index.html.haml b/app/views/instance_statistics/cohorts/index.html.haml
index 5333f8b7a1f..a038246bd53 100644
--- a/app/views/instance_statistics/cohorts/index.html.haml
+++ b/app/views/instance_statistics/cohorts/index.html.haml
@@ -1,11 +1,12 @@
- breadcrumb_title _("Cohorts")
+- page_title _("Cohorts")
- if @cohorts
= render 'cohorts_table'
- else
.bs-callout.bs-callout-warning.clearfix
%p
- - usage_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping')
+ - usage_ping_path = help_page_path('development/telemetry/usage_ping')
- usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: usage_ping_path }
= s_('User Cohorts are only shown when the %{usage_ping_link_start}usage ping%{usage_ping_link_end} is enabled.').html_safe % { usage_ping_link_start: usage_ping_link_start, usage_ping_link_end: '</a>'.html_safe }
- if current_user.admin?
diff --git a/app/views/instance_statistics/dev_ops_score/_callout.html.haml b/app/views/instance_statistics/dev_ops_score/_callout.html.haml
index 64eb72c0d8d..31ae7721f5f 100644
--- a/app/views/instance_statistics/dev_ops_score/_callout.html.haml
+++ b/app/views/instance_statistics/dev_ops_score/_callout.html.haml
@@ -1,4 +1,4 @@
-.prepend-top-default
+.gl-mt-3
.user-callout{ data: { uid: 'dev_ops_score_intro_callout_dismissed' } }
.bordered-box.landing.content-block
%button.btn.btn-default.close.js-close-callout{ type: 'button',
diff --git a/app/views/instance_statistics/dev_ops_score/_disabled.html.haml b/app/views/instance_statistics/dev_ops_score/_disabled.html.haml
index da27ea17b61..bd808218f75 100644
--- a/app/views/instance_statistics/dev_ops_score/_disabled.html.haml
+++ b/app/views/instance_statistics/dev_ops_score/_disabled.html.haml
@@ -4,7 +4,7 @@
%h4= _('Usage ping is not enabled')
- if !current_user.admin?
%p
- - usage_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping')
+ - usage_ping_path = help_page_path('development/telemetry/usage_ping')
- usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: usage_ping_path }
= s_('In order to enable instance-level analytics, please ask an admin to enable %{usage_ping_link_start}usage ping%{usage_ping_link_end}.').html_safe % { usage_ping_link_start: usage_ping_link_start, usage_ping_link_end: '</a>'.html_safe }
- if current_user.admin?
diff --git a/app/views/instance_statistics/dev_ops_score/index.html.haml b/app/views/instance_statistics/dev_ops_score/index.html.haml
index 44c6e9664db..215624d27ce 100644
--- a/app/views/instance_statistics/dev_ops_score/index.html.haml
+++ b/app/views/instance_statistics/dev_ops_score/index.html.haml
@@ -5,7 +5,7 @@
- if usage_ping_enabled && show_callout?('dev_ops_score_intro_callout_dismissed')
= render 'callout'
- .prepend-top-default
+ .gl-mt-3
- if !usage_ping_enabled
= render 'disabled'
- elsif @metric.blank?
diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml
index 30ab5781014..2bcd64d0690 100644
--- a/app/views/invites/show.html.haml
+++ b/app/views/invites/show.html.haml
@@ -20,21 +20,19 @@
= link_to group.name, group_url(group)
as #{@member.human_access}.
-- is_member = @member.source.users.include?(current_user)
-
-- if is_member
+- if member?
%p
- member_source = @member.source.is_a?(Group) ? _("group") : _("project")
= _("However, you are already a member of this %{member_source}. Sign in using a different account to accept the invitation.") % { member_source: member_source }
-- if @member.invite_email != current_user.email
+- if !current_user_matches_invite?
%p
- mail_to_invite_email = mail_to(@member.invite_email)
- mail_to_current_user = mail_to(current_user.email)
- link_to_current_user = link_to(current_user.to_reference, user_url(current_user))
= _("Note that this invitation was sent to %{mail_to_invite_email}, but you are signed in as %{link_to_current_user} with email %{mail_to_current_user}.").html_safe % { mail_to_invite_email: mail_to_invite_email, mail_to_current_user: mail_to_current_user, link_to_current_user: link_to_current_user }
-- unless is_member
+- unless member?
.actions
= link_to _("Accept invitation"), accept_invite_url(@token), method: :post, class: "btn btn-success"
- = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10"
+ = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger gl-ml-3"
diff --git a/app/views/kaminari/gitlab/_paginator.html.haml b/app/views/kaminari/gitlab/_paginator.html.haml
index 1b2edc0ad22..91998147966 100644
--- a/app/views/kaminari/gitlab/_paginator.html.haml
+++ b/app/views/kaminari/gitlab/_paginator.html.haml
@@ -6,7 +6,7 @@
-# remote: data-remote
-# paginator: the paginator that renders the pagination tags inside
= paginator.render do
- .gl-pagination.prepend-top-default
+ .gl-pagination.gl-mt-3
%ul.pagination.justify-content-center
= prev_page_tag
- each_page do |page|
diff --git a/app/views/kaminari/gitlab/_without_count.html.haml b/app/views/kaminari/gitlab/_without_count.html.haml
index d13f6ca5fa8..dc9dcbeed1d 100644
--- a/app/views/kaminari/gitlab/_without_count.html.haml
+++ b/app/views/kaminari/gitlab/_without_count.html.haml
@@ -1,4 +1,4 @@
-.gl-pagination.prepend-top-default
+.gl-pagination.gl-mt-3
%ul.pagination.justify-content-center
- if previous_path
%li.page-item.prev
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 886d4109ff5..d1311f17b72 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -25,6 +25,8 @@
%meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' }
+ = render 'layouts/startup_js'
+
-# Open Graph - http://ogp.me/
%meta{ property: 'og:type', content: "object" }
%meta{ property: 'og:site_name', content: site_name }
@@ -51,7 +53,6 @@
= stylesheet_link_tag "application_dark", media: "all"
- else
= stylesheet_link_tag "application", media: "all"
- = stylesheet_link_tag "print", media: "print"
= stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations']
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
diff --git a/app/views/layouts/_img_loader.html.haml b/app/views/layouts/_img_loader.html.haml
new file mode 100644
index 00000000000..cddcd6e0af6
--- /dev/null
+++ b/app/views/layouts/_img_loader.html.haml
@@ -0,0 +1,17 @@
+= javascript_tag nonce: true do
+ :plain
+ if ('loading' in HTMLImageElement.prototype) {
+ document.querySelectorAll('img.lazy').forEach(img => {
+ img.loading = 'lazy';
+ let imgUrl = img.dataset.src;
+ // Only adding width + height for avatars for now
+ if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) {
+ const targetWidth = img.getAttribute('width') || img.width;
+ imgUrl += `?width=${targetWidth}`;
+ }
+ img.src = imgUrl;
+ img.removeAttribute('data-src');
+ img.classList.remove('lazy');
+ img.classList.add('js-lazy-loaded', 'qa-js-lazy-loaded');
+ });
+ }
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index d1cf83b2a9f..72b88fa8f7f 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -7,6 +7,7 @@
= render 'shared/outdated_browser'
= render_if_exists 'layouts/header/users_over_license_banner'
= render_if_exists "layouts/header/licensed_user_count_threshold"
+ = render_if_exists "layouts/header/token_expiry_notification"
= render "layouts/broadcast"
= render "layouts/header/read_only_banner"
= render "layouts/nav/classification_level_banner"
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index 97d00bce11b..81fe0798bd1 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -2,7 +2,7 @@
= form_tag search_path, method: :get, class: 'form-inline' do |f|
.search-input-container
.search-input-wrap
- .dropdown
+ .dropdown{ data: { url: search_autocomplete_path } }
= search_field_tag 'search', nil, placeholder: _('Search or jump to…'),
class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options',
spellcheck: false,
@@ -37,3 +37,6 @@
-# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb
- if ENV['RAILS_ENV'] == 'test'
%noscript= button_tag 'Search'
+ .search-autocomplete-opts.hide{ :'data-autocomplete-path' => search_autocomplete_path,
+ :'data-autocomplete-project-id' => search_context.project.try(:id),
+ :'data-autocomplete-project-ref' => search_context.ref }
diff --git a/app/views/layouts/_startup_js.html.haml b/app/views/layouts/_startup_js.html.haml
new file mode 100644
index 00000000000..3eb68df07c6
--- /dev/null
+++ b/app/views/layouts/_startup_js.html.haml
@@ -0,0 +1,13 @@
+- return unless page_startup_api_calls.present?
+
+= javascript_tag nonce: true do
+ :plain
+ var gl = window.gl || {};
+ gl.startup_calls = #{page_startup_api_calls.to_json};
+ if (gl.startup_calls && window.fetch) {
+ Object.keys(gl.startup_calls).forEach(apiCall => {
+ gl.startup_calls[apiCall] = {
+ fetchCall: fetch(apiCall)
+ };
+ });
+ }
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index eb58115451d..58408ec822c 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -13,6 +13,6 @@
= render 'layouts/page', sidebar: sidebar, nav: nav
= footer_message
- = render_if_exists "shared/onboarding_guide"
+ = render 'layouts/img_loader'
= yield :scripts_body
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index d568086f4a4..4c659241f99 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -35,7 +35,6 @@
= link_to _("Help"), help_path
%li.d-md-none
= link_to _("Support"), support_url
- = render_if_exists "shared/learn_gitlab_menu_item"
%li.d-md-none
= link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback"
- if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile)
diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml
index 2b3f5d266b0..ad4e0f1f4b2 100644
--- a/app/views/layouts/header/_help_dropdown.html.haml
+++ b/app/views/layouts/header/_help_dropdown.html.haml
@@ -9,7 +9,6 @@
%button.js-shortcuts-modal-trigger{ type: "button" }
= _("Keyboard shortcuts")
%span.text-secondary.float-right{ "aria-hidden": true }= '?'.html_safe
- = render_if_exists "shared/learn_gitlab_menu_item"
%li.divider
%li
= link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback"
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index 3cbfb24a868..4bfac76ec5b 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -15,6 +15,7 @@
%li= link_to _('New project'), new_project_path(namespace_id: @group.id)
- if create_group_subgroup
%li= link_to _('New subgroup'), new_group_path(parent_id: @group.id)
+ = render_if_exists 'layouts/header/create_epic_new_dropdown_item'
%li.divider
%li.dropdown-bold-header GitLab
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 0b23a06f5a9..e6cfd7d56bb 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -26,16 +26,16 @@
%ul
- if dashboard_nav_link?(:groups)
%li.d-md-none
- = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups' do
+ = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', data: { qa_selector: 'groups_link' } do
= _('Groups')
- if dashboard_nav_link?(:activity)
= nav_link(path: 'dashboard#activity') do
- = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity' do
+ = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', data: { qa_selector: 'activity_link' } do
= _('Activity')
- if dashboard_nav_link?(:milestones)
= nav_link(controller: 'dashboard/milestones') do
- = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones' do
+ = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', data: { qa_selector: 'milestones_link' } do
= _('Milestones')
- if dashboard_nav_link?(:snippets)
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 28e52dc85db..e72535b8824 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -8,7 +8,7 @@
= _('Admin Area')
%ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } }
= nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers), html_options: {class: 'home'}) do
- = link_to admin_root_path, class: 'shortcuts-tree' do
+ = link_to admin_root_path do
.nav-icon-container
= sprite_icon('overview')
%span.nav-item-name
@@ -216,7 +216,7 @@
%strong.fly-out-top-item-name
= _('Appearance')
- = nav_link(controller: :application_settings) do
+ = nav_link(controller: [:application_settings, :integrations]) do
= link_to general_admin_application_settings_path do
.nav-icon-container
= sprite_icon('settings')
@@ -224,7 +224,7 @@
= _('Settings')
%ul.sidebar-sub-level-items.qa-admin-sidebar-settings-submenu
- = nav_link(controller: :application_settings, html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(controller: [:application_settings, :integrations], html_options: { class: "fly-out-top-item" } ) do
= link_to general_admin_application_settings_path do
%strong.fly-out-top-item-name
= _('Settings')
@@ -233,7 +233,7 @@
= link_to general_admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do
%span
= _('General')
- = nav_link(path: 'application_settings#integrations') do
+ = nav_link(path: ['application_settings#integrations', 'integrations#edit']) do
= link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'integration_settings_link' } do
%span
= _('Integrations')
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index cd9765289a4..909d72edb31 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -81,7 +81,7 @@
- if group_sidebar_link?(:milestones)
= nav_link(path: 'milestones#index') do
- = link_to group_milestones_path(@group), title: _('Milestones') do
+ = link_to group_milestones_path(@group), title: _('Milestones'), data: { qa_selector: 'group_milestones_link' } do
%span
= _('Milestones')
@@ -123,6 +123,9 @@
= render 'layouts/nav/sidebar/analytics_links', links: group_analytics_navbar_links(@group, current_user)
+ - if group_sidebar_link?(:wiki)
+ = render 'layouts/nav/sidebar/wiki_link', wiki_url: @group.wiki.web_url
+
- if group_sidebar_link?(:group_members)
= nav_link(path: 'group_members#index') do
= link_to group_group_members_path(@group) do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 16902ebe1d4..d59c75de6d2 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -37,7 +37,7 @@
- if project_nav_tab? :files
= nav_link(controller: sidebar_repository_paths, unless: -> { current_path?('projects/graphs#charts') }) do
- = link_to project_tree_path(@project), class: 'shortcuts-tree qa-project-menu-repo' do
+ = link_to project_tree_path(@project), class: 'shortcuts-tree', data: { qa_selector: "repository_link" } do
.nav-icon-container
= sprite_icon('doc-text')
%span.nav-item-name#js-onboarding-repo-link
@@ -58,11 +58,11 @@
= _('Commits')
= nav_link(html_options: {class: branches_tab_class}) do
- = link_to project_branches_path(@project), class: 'qa-branches-link', id: 'js-onboarding-branches-link' do
+ = link_to project_branches_path(@project), data: { qa_selector: "branches_link" }, id: 'js-onboarding-branches-link' do
= _('Branches')
= nav_link(controller: [:tags]) do
- = link_to project_tags_path(@project) do
+ = link_to project_tags_path(@project), data: { qa_selector: "tags_link" } do
= _('Tags')
= nav_link(path: 'graphs#show') do
@@ -80,7 +80,7 @@
= render_if_exists 'projects/sidebar/repository_locked_files'
- if project_nav_tab? :issues
- = nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do
+ = nav_link(controller: @project.issues_enabled? ? ['projects/issues', :labels, :milestones, :boards] : 'projects/issues') do
= link_to project_issues_path(@project), class: 'shortcuts-issues qa-issues-item' do
.nav-icon-container
= sprite_icon('issues')
@@ -91,7 +91,7 @@
= number_with_delimiter(@project.open_issues_count(current_user))
%ul.sidebar-sub-level-items
- = nav_link(controller: :issues, action: :index, html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(controller: 'projects/issues', action: :index, html_options: { class: "fly-out-top-item" } ) do
= link_to project_issues_path(@project) do
%strong.fly-out-top-item-name
= _('Issues')
@@ -114,25 +114,29 @@
%span
= _('Labels')
- = render_if_exists 'projects/sidebar/issues_service_desk'
+ = render 'projects/sidebar/issues_service_desk'
= nav_link(controller: :milestones) do
= link_to project_milestones_path(@project), title: _('Milestones'), class: 'qa-milestones-link' do
%span
= _('Milestones')
- - if project_nav_tab? :external_issue_tracker
- = nav_link do
- - issue_tracker = @project.external_issue_tracker
- = link_to issue_tracker.issue_tracker_path, class: 'shortcuts-external_tracker' do
- .nav-icon-container
- = sprite_icon('external-link')
- %span.nav-item-name
- = issue_tracker.title
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(html_options: { class: "fly-out-top-item" } ) do
- = link_to issue_tracker.issue_tracker_path do
- %strong.fly-out-top-item-name
- = issue_tracker.title
+
+ - if project_nav_tab?(:external_issue_tracker)
+ - issue_tracker = @project.external_issue_tracker
+ - if issue_tracker.is_a?(JiraService) && project_jira_issues_integration?
+ = render_if_exists 'layouts/nav/sidebar/project_jira_issues_link', issue_tracker: issue_tracker
+ - else
+ = nav_link do
+ = link_to issue_tracker.issue_tracker_path, target: '_blank', rel: 'noopener noreferrer', class: 'shortcuts-external_tracker' do
+ .nav-icon-container
+ = sprite_icon('external-link')
+ %span.nav-item-name
+ = issue_tracker.title
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(html_options: { class: "fly-out-top-item" } ) do
+ = link_to issue_tracker.issue_tracker_path, target: '_blank', rel: 'noopener noreferrer' do
+ %strong.fly-out-top-item-name
+ = issue_tracker.title
- if (project_nav_tab? :labels) && !@project.issues_enabled?
= nav_link(controller: [:labels]) do
@@ -289,19 +293,22 @@
= render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user)
- - if project_nav_tab? :wiki
- - wiki_url = wiki_path(@project.wiki)
- = nav_link(controller: :wikis) do
- = link_to wiki_url, class: 'shortcuts-wiki', data: { qa_selector: 'wiki_link' } do
+ - if project_nav_tab?(:confluence)
+ - confluence_url = project_wikis_confluence_path(@project)
+ = nav_link do
+ = link_to confluence_url, class: 'shortcuts-confluence' do
.nav-icon-container
- = sprite_icon('book')
+ = image_tag 'confluence.svg', alt: _('Confluence')
%span.nav-item-name
- = _('Wiki')
+ = _('Confluence')
%ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :wikis, html_options: { class: "fly-out-top-item" } ) do
- = link_to wiki_url do
+ = nav_link(html_options: { class: 'fly-out-top-item' } ) do
+ = link_to confluence_url, target: '_blank', rel: 'noopener noreferrer' do
%strong.fly-out-top-item-name
- = _('Wiki')
+ = _('Confluence')
+
+ - if project_nav_tab? :wiki
+ = render 'layouts/nav/sidebar/wiki_link', wiki_url: wiki_path(@project.wiki)
- if project_nav_tab?(:external_wiki)
- external_wiki_url = @project.external_wiki.external_wiki_url
@@ -344,7 +351,7 @@
- if project_nav_tab? :settings
= nav_link(path: sidebar_settings_paths) do
- = link_to edit_project_path(@project), class: 'shortcuts-tree' do
+ = link_to edit_project_path(@project) do
.nav-icon-container
= sprite_icon('settings')
%span.nav-item-name.qa-settings-item#js-onboarding-settings-link
diff --git a/app/views/layouts/nav/sidebar/_wiki_link.html.haml b/app/views/layouts/nav/sidebar/_wiki_link.html.haml
new file mode 100644
index 00000000000..b6b63b75fcc
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_wiki_link.html.haml
@@ -0,0 +1,11 @@
+= nav_link(controller: :wikis) do
+ = link_to wiki_url, class: 'shortcuts-wiki', data: { qa_selector: 'wiki_link' } do
+ .nav-icon-container
+ = sprite_icon('book')
+ %span.nav-item-name
+ = _('Wiki')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :wikis, html_options: { class: "fly-out-top-item" } ) do
+ = link_to wiki_url do
+ %strong.fly-out-top-item-name
+ = _('Wiki')
diff --git a/app/views/layouts/service_desk.html.haml b/app/views/layouts/service_desk.html.haml
new file mode 100644
index 00000000000..26d15a74403
--- /dev/null
+++ b/app/views/layouts/service_desk.html.haml
@@ -0,0 +1,24 @@
+%html{ lang: "en" }
+ %head
+ %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
+ -# haml-lint:disable NoPlainNodes
+ %title
+ GitLab
+ -# haml-lint:enable NoPlainNodes
+ = stylesheet_link_tag 'notify'
+ = yield :head
+ %body
+ .content
+ = yield
+ .footer{ style: "margin-top: 10px;" }
+ %p
+ &mdash;
+ %br
+ = link_to "Unsubscribe", @unsubscribe_url
+
+ -# EE-specific start
+ - if Gitlab::CurrentSettings.email_additional_text.present?
+ %br
+ %br
+ = Gitlab::Utils.nlbr(Gitlab::CurrentSettings.email_additional_text)
+ -# EE-specific end
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index cde2b467392..6cc53ba3342 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -1,3 +1,4 @@
+- page_title _("Snippets")
- header_title _("Snippets"), snippets_path
- snippets_upload_path = snippets_upload_path(@snippet, current_user)
diff --git a/app/views/notify/closed_merge_request_email.html.haml b/app/views/notify/closed_merge_request_email.html.haml
index 2aa753e0d55..6caa0e59e8f 100644
--- a/app/views/notify/closed_merge_request_email.html.haml
+++ b/app/views/notify/closed_merge_request_email.html.haml
@@ -1,2 +1,3 @@
%p
- Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)}
+ Merge Request #{merge_request_reference_link(@merge_request)}
+ was closed by #{sanitize_name(@updated_by.name)}
diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml
index 6e84f9fb355..8546da2d7f0 100644
--- a/app/views/notify/closed_merge_request_email.text.haml
+++ b/app/views/notify/closed_merge_request_email.text.haml
@@ -1,6 +1,6 @@
Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)}
-Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/merge_request_status_email.html.haml b/app/views/notify/merge_request_status_email.html.haml
index ffb416abf72..a15c5a752d4 100644
--- a/app/views/notify/merge_request_status_email.html.haml
+++ b/app/views/notify/merge_request_status_email.html.haml
@@ -1,2 +1,3 @@
%p
- Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)}
+ Merge Request #{merge_request_reference_link(@merge_request)}
+ was #{@mr_status} by #{sanitize_name(@updated_by.name)}
diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml
index e3b24bbd405..3d7115856d4 100644
--- a/app/views/notify/merge_request_status_email.text.haml
+++ b/app/views/notify/merge_request_status_email.text.haml
@@ -1,6 +1,6 @@
Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)}
-Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/merge_request_unmergeable_email.html.haml b/app/views/notify/merge_request_unmergeable_email.html.haml
index 7ec0c1ef390..ee459a26551 100644
--- a/app/views/notify/merge_request_unmergeable_email.html.haml
+++ b/app/views/notify/merge_request_unmergeable_email.html.haml
@@ -1,2 +1,2 @@
%p
- Merge Request #{link_to @merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)} can no longer be merged due to conflict.
+ Merge Request #{merge_request_reference_link(@merge_request)} can no longer be merged due to conflict.
diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml
index e9708a297d7..412a0887186 100644
--- a/app/views/notify/merge_request_unmergeable_email.text.haml
+++ b/app/views/notify/merge_request_unmergeable_email.text.haml
@@ -1,6 +1,6 @@
Merge Request #{@merge_request.to_reference} can no longer be merged due to conflict.
-Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
new file mode 100644
index 00000000000..4db213fb229
--- /dev/null
+++ b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
@@ -0,0 +1,159 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+%html{ lang: "en" }
+ %head
+ %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }
+ %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }
+ %meta{ content: "IE=edge", "http-equiv" => "X-UA-Compatible" }
+ %title= message.subject
+ :css
+ /* CLIENT-SPECIFIC STYLES */
+ body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
+ table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
+ img { -ms-interpolation-mode: bicubic; }
+
+ /* iOS BLUE LINKS */
+ a[x-apple-data-detectors] {
+ color: inherit !important;
+ text-decoration: none !important;
+ font-size: inherit !important;
+ font-family: inherit !important;
+ font-weight: inherit !important;
+ line-height: inherit !important;
+ }
+
+ /* ANDROID MARGIN HACK */
+ body { margin:0 !important; }
+ div[style*="margin: 16px 0"] { margin:0 !important; }
+
+ @media only screen and (max-width: 639px) {
+ body, #body {
+ min-width: 320px !important;
+ }
+ table.wrapper {
+ width: 100% !important;
+ min-width: 320px !important;
+ }
+ table.wrapper > tbody > tr > td {
+ border-left: 0 !important;
+ border-right: 0 !important;
+ border-radius: 0 !important;
+ padding-left: 10px !important;
+ padding-right: 10px !important;
+ }
+ }
+
+ ul.assignees-list {
+ list-style: none;
+ padding: 0px;
+ display: block;
+ margin-top: 0px;
+ }
+ ul.assignees-list li {
+ display: inline-block;
+ padding-right: 12px;
+ padding-top: 8px;
+ }
+
+ %body{ style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;height:100%;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" }
+ %table#body{ border: "0", cellpadding: "0", cellspacing: "0", style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;" }
+ %tbody
+ %tr.line
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" }
+ %tr.header
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
+ %img{ alt: "GitLab", height: "50", src: image_url('mailers/ci_pipeline_notif_v1/gitlab-logo.gif'), width: "55" }
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" }
+ %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:640px;margin:0 auto;border-collapse:separate;border-spacing:0;" }
+ %tbody
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;padding:18px 25px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" }
+ %table.content{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" }
+ %tbody
+ %tr.success
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:10px;border-radius:3px;font-size:14px;line-height:1.3;text-align:center;overflow:hidden;color:#ffffff;background-color:#31af64;" }
+ %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" }
+ %tbody
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;" }
+ %img{ alt: "✓", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-check-green-inverted.gif'), style: "display:block;", width: "13" }
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" }
+ %span= _('Merge request was scheduled to merge after pipeline succeeds')
+ %tr.spacer
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" }
+ &nbsp;
+ %tr.section
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;line-height:1.4;text-align:center;padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" }
+ %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;width:100%;" }
+ %tbody
+ %tr{ style: 'width:100%;' }
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;text-align:center;" }
+ %img{ src: image_url('mailers/approval/icon-merge-request-gray.gif'), style: "height:18px;width:18px;margin-bottom:-4px;", alt: "Merge request icon" }
+ %span{ style: "font-weight: 600;color:#333333;" }= _('Merge request')
+ %a{ href: merge_request_url(@merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none" }= @merge_request.to_reference
+ %span= _('was scheduled to merge after pipeline succeeds by')
+ %img.avatar{ height: "24", src: avatar_icon_for_user(@mwps_set_by, 24, only_path: false), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar" }
+ %a.muted{ href: user_url(@mwps_set_by), style: "color:#333333;text-decoration:none;" }
+ = @mwps_set_by.name
+ %tr.spacer
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" }
+ &nbsp;
+ %tr.section
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" }
+ %table.info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" }
+ %tbody
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" }= _('Project')
+ -# haml-lint:disable NoPlainNodes
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;" }
+ - namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name
+ - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner)
+ %a.muted{ href: namespace_url, style: "color:#333333;text-decoration:none;" }
+ = namespace_name
+ \/
+ %a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" }
+ = @project.name
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }= _('Branch')
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
+ %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
+ %tbody
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
+ %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "Branch icon" }
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
+ %span.muted{ style: "color:#333333;text-decoration:none;" }
+ = @merge_request.source_branch
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }= _('Author')
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
+ %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" }
+ %tbody
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
+ %img.avatar{ height: "24", src: avatar_icon_for_user(@merge_request.author, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
+ %a.muted{ href: user_url(@merge_request.author), style: "color:#333333;text-decoration:none;" }
+ = @merge_request.author.name
+
+ - if @merge_request.assignees.any?
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }
+ = assignees_label(@merge_request, include_value: false)
+ %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; margin: 0; padding: 14px 0 0px 5px; font-size: 15px; line-height: 1.4; color: #333333; font-weight: 400; width: 75%; border-top-style: solid; border-top-color: #ededed; border-top-width: 1px; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; mso-table-lspace: 0pt; mso-table-rspace: 0pt;" }
+ %ul.assignees-list{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-size: 15px; line-height: 1.4; padding-right: 5px; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; mso-table-lspace: 0pt; mso-table-rspace: 0pt;" }
+ - @merge_request.assignees.each do |assignee|
+ %li
+ %img.avatar{ alt: "Avatar", height: "24", src: avatar_icon_for_user(assignee, 24, only_path: false), style: "border-radius: 12px; max-width: 100%; height: auto; -ms-interpolation-mode: bicubic; margin: -2px 0;", width: "24" }
+ %a.muted{ href: user_url(assignee), style: "color: #333333; text-decoration: none; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; vertical-align: top;" }
+ = assignee.name
+
+ = render_if_exists 'layouts/mailer/additional_text'
+
+ %tr.footer
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
+ %img{ alt: "GitLab", height: "33", src: image_url('mailers/ci_pipeline_notif_v1/gitlab-logo-full-horizontal.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }
+ %div
+ - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;")
+ - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;")
+ = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link }
diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
new file mode 100644
index 00000000000..fdc23a6af0f
--- /dev/null
+++ b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
@@ -0,0 +1,8 @@
+Merge Request #{@merge_request.to_reference} was scheduled to merge after pipeline succeeds by #{sanitize_name(@mwps_set_by.name)}
+
+Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+
+= merge_path_description(@merge_request, 'to')
+
+Author: #{sanitize_name(@merge_request.author_name)}
+= assignees_label(@merge_request)
diff --git a/app/views/notify/merged_merge_request_email.html.haml b/app/views/notify/merged_merge_request_email.html.haml
index 341aa6f8103..c84c0d1d14b 100644
--- a/app/views/notify/merged_merge_request_email.html.haml
+++ b/app/views/notify/merged_merge_request_email.html.haml
@@ -1,2 +1,2 @@
%p
- Merge Request #{link_to @merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)} was merged
+ Merge Request #{merge_request_reference_link(@merge_request)} was merged
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index 52e110a98f6..7f0a50e9248 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -1,5 +1,5 @@
%p.details
- #{link_to @issue.author_name, user_url(@issue.author)} created an issue #{link_to @issue.to_reference(full: false), issue_url(@issue)}:
+ #{link_to @issue.author_name, user_url(@issue.author)} created an issue #{issue_reference_link(@issue)}:
- if @issue.assignees.any?
%p
diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml
index b061f9c106e..ddcf287e501 100644
--- a/app/views/notify/new_mention_in_merge_request_email.html.haml
+++ b/app/views/notify/new_mention_in_merge_request_email.html.haml
@@ -1,4 +1,4 @@
%p
- You have been mentioned in Merge Request #{@merge_request.to_reference}
+ You have been mentioned in Merge Request #{merge_request_reference_link(@merge_request)}
= render template: 'notify/new_merge_request_email'
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 97258833cfc..3e9f9b442e0 100644
--- a/app/views/notify/push_to_merge_request_email.html.haml
+++ b/app/views/notify/push_to_merge_request_email.html.haml
@@ -1,7 +1,7 @@
%h3
= sanitize_name(@updated_by_user.name)
pushed new commits to merge request
- = link_to(@merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request))
+ = merge_request_reference_link(@merge_request)
- if @existing_commits.any?
- count = @existing_commits.size
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 10c8e158846..5c2005a47e5 100644
--- a/app/views/notify/push_to_merge_request_email.text.haml
+++ b/app/views/notify/push_to_merge_request_email.text.haml
@@ -1,6 +1,6 @@
#{sanitize_name(@updated_by_user.name)} pushed new commits to merge request #{@merge_request.to_reference}
-\
-#{url_for(project_merge_request_url(@merge_request.target_project, @merge_request))}
+
+Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
\
- if @existing_commits.any?
- count = @existing_commits.size
diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml
index 502b8f21e35..0b3c56c9bd1 100644
--- a/app/views/notify/resolved_all_discussions_email.html.haml
+++ b/app/views/notify/resolved_all_discussions_email.html.haml
@@ -1,2 +1,3 @@
%p
- All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{sanitize_name(@resolved_by.name)}
+ All discussions on Merge Request #{merge_request_reference_link(@merge_request)}
+ were resolved by #{sanitize_name(@resolved_by.name)}
diff --git a/app/views/notify/service_desk_new_note_email.html.haml b/app/views/notify/service_desk_new_note_email.html.haml
new file mode 100644
index 00000000000..7c6be6688d0
--- /dev/null
+++ b/app/views/notify/service_desk_new_note_email.html.haml
@@ -0,0 +1,5 @@
+- if Gitlab::CurrentSettings.email_author_in_body
+ %div
+ #{link_to @note.author_name, user_url(@note.author)} wrote:
+%div
+ = markdown(@note.note, pipeline: :email, author: @note.author)
diff --git a/app/views/notify/service_desk_new_note_email.text.erb b/app/views/notify/service_desk_new_note_email.text.erb
new file mode 100644
index 00000000000..208953a437d
--- /dev/null
+++ b/app/views/notify/service_desk_new_note_email.text.erb
@@ -0,0 +1,6 @@
+New response for issue #<%= @issue.iid %>:
+
+Author: <%= sanitize_name(@note.author_name) %>
+
+<%= @note.note %>
+<%# EE-specific start %><%= render_if_exists 'layouts/mailer/additional_text'%><%# EE-specific end %>
diff --git a/app/views/notify/service_desk_thank_you_email.html.haml b/app/views/notify/service_desk_thank_you_email.html.haml
new file mode 100644
index 00000000000..a3407acd9ba
--- /dev/null
+++ b/app/views/notify/service_desk_thank_you_email.html.haml
@@ -0,0 +1,2 @@
+%p
+ Thank you for your support request! We are tracking your request as ticket ##{@issue.iid}, and will respond as soon as we can.
diff --git a/app/views/notify/service_desk_thank_you_email.text.erb b/app/views/notify/service_desk_thank_you_email.text.erb
new file mode 100644
index 00000000000..8281607a4a8
--- /dev/null
+++ b/app/views/notify/service_desk_thank_you_email.text.erb
@@ -0,0 +1,6 @@
+Thank you for your support request! We are tracking your request as ticket #<%= @issue.iid %>, and will respond as soon as we can.
+
+To unsubscribe from this issue, please paste the following link into your browser:
+
+<%= @unsubscribe_url %>
+<%# EE-specific start %><%= render_if_exists 'layouts/mailer/additional_text' %><%# EE-specific end %>
diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml
index c65c4fd0d81..b952868e4e3 100644
--- a/app/views/profiles/_event_table.html.haml
+++ b/app/views/profiles/_event_table.html.haml
@@ -5,7 +5,7 @@
- events.each do |event|
%li
%span.description
- = audit_icon(event.details[:with], class: "append-right-5")
+ = audit_icon(event.details[:with], class: "gl-mr-2")
= _('Signed in with %{authentication} authentication') % { authentication: event.details[:with]}
%span.float-right= time_ago_with_tooltip(event.created_at)
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index f4a97206a19..ea2f888c129 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -5,7 +5,7 @@
.alert.alert-info
= s_('Profiles|Some options are unavailable for LDAP accounts')
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= s_('Profiles|Two-Factor Authentication')
@@ -22,7 +22,7 @@
%hr
- if display_providers_on_profile?
- .row.prepend-top-default
+ .row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= s_('Profiles|Social sign-in')
@@ -32,7 +32,7 @@
= render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities]
%hr
- if current_user.can_change_username?
- .row.prepend-top-default
+ .row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0.warning-title
= s_('Profiles|Change username')
@@ -45,7 +45,7 @@
#update-username{ data: data }
%hr
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0.danger-title
= s_('Profiles|Delete account')
@@ -72,4 +72,4 @@
- else
%p
= s_("Profiles|You don't have access to delete this user.")
-.append-bottom-default
+.gl-mb-3
diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml
index f3ad0c4c8ad..9ae75fe6b8e 100644
--- a/app/views/profiles/active_sessions/_active_session.html.haml
+++ b/app/views/profiles/active_sessions/_active_session.html.haml
@@ -1,7 +1,7 @@
- is_current_session = active_session.current?(session)
%li.list-group-item
- .float-left.append-right-10{ data: { toggle: 'tooltip' }, title: active_session.human_device_type }
+ .float-left.gl-mr-3{ data: { toggle: 'tooltip' }, title: active_session.human_device_type }
= active_session_device_type_icon(active_session)
.description.float-left
@@ -27,6 +27,6 @@
- unless is_current_session
.float-right
- = link_to profile_active_session_path(active_session.public_id), data: { confirm: _('Are you sure? The device will be signed out of GitLab.') }, method: :delete, class: "btn btn-danger prepend-left-10" do
+ = link_to profile_active_session_path(active_session.public_id), data: { confirm: _('Are you sure? The device will be signed out of GitLab.') }, method: :delete, class: "btn btn-danger gl-ml-3" 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
index 6d01d055f0c..f444f236cfc 100644
--- a/app/views/profiles/active_sessions/index.html.haml
+++ b/app/views/profiles/active_sessions/index.html.haml
@@ -1,14 +1,14 @@
- page_title _('Active Sessions')
- @content_class = "limit-container-width" unless fluid_layout
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-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
+ .gl-mb-3
.card.border-0
%ul.list-group.list-group-flush
diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml
index 02aadcc5c8b..aec855c790e 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -1,7 +1,7 @@
- page_title _('Authentication log')
- @content_class = "limit-container-width" unless fluid_layout
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= page_title
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
index 05870e0e221..e0b0f839455 100644
--- a/app/views/profiles/chat_names/index.html.haml
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -1,7 +1,7 @@
- page_title _('Chat')
- @content_class = "limit-container-width" unless fluid_layout
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= page_title
diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml
index d86941b7a29..5bed9e0d771 100644
--- a/app/views/profiles/chat_names/new.html.haml
+++ b/app/views/profiles/chat_names/new.html.haml
@@ -12,4 +12,4 @@
= submit_tag "Authorize", class: "btn btn-success wide float-left"
= form_tag deny_profile_chat_names_path, method: :delete do
= hidden_field_tag :token, @chat_name_token.token
- = submit_tag "Deny", class: "btn btn-danger prepend-left-10"
+ = submit_tag "Deny", class: "btn btn-danger gl-ml-3"
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index e90bda0e187..fa7ab0666cc 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -1,7 +1,7 @@
- page_title _('Emails')
- @content_class = "limit-container-width" unless fluid_layout
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= page_title
@@ -14,12 +14,12 @@
.form-group
= f.label :email, _('Email'), class: 'label-bold'
= f.text_field :email, class: 'form-control', data: { qa_selector: 'email_address_field' }
- .prepend-top-default
+ .gl-mt-3
= f.submit _('Add email address'), class: 'btn btn-success', data: { qa_selector: 'add_email_address_button' }
%hr
%h4.gl-mt-0
= _('Linked emails (%{email_count})') % { email_count: @emails.load.size + 1 }
- .account-well.append-bottom-default
+ .account-well.gl-mb-3
%ul
%li
= _('Your Primary Email will be used for avatar detection.')
@@ -56,8 +56,8 @@
%span.badge.badge-info= s_('Profiles|Notification email')
- unless email.confirmed?
- confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}"
- = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'btn btn-sm btn-warning prepend-left-10'
+ = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'btn btn-sm btn-warning gl-ml-3'
- = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'btn btn-sm btn-danger prepend-left-10' do
+ = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'btn btn-sm btn-danger gl-ml-3' do
%span.sr-only= _('Remove')
= icon('trash')
diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml
index 225487b2638..2fb07adc006 100644
--- a/app/views/profiles/gpg_keys/_form.html.haml
+++ b/app/views/profiles/gpg_keys/_form.html.haml
@@ -6,5 +6,5 @@
= f.label :key, s_('Profiles|Key'), class: 'label-bold'
= f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: _("Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'.")
- .prepend-top-default
+ .gl-mt-3
= f.submit s_('Profiles|Add key'), class: "btn btn-success"
diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml
index 2de5cf2f506..7bbb0235cd8 100644
--- a/app/views/profiles/gpg_keys/_key.html.haml
+++ b/app/views/profiles/gpg_keys/_key.html.haml
@@ -1,5 +1,5 @@
%li.key-list-item
- .float-left.append-right-10
+ .float-left.gl-mr-3
= icon 'key', class: "settings-list-icon d-none d-sm-block"
.key-list-item-info
- key.emails_with_verified_status.map do |email, verified|
@@ -19,9 +19,9 @@
.float-right
%span.key-created-at
= s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)}
- = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "btn btn-danger prepend-left-10" do
+ = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "btn btn-danger gl-ml-3" do
%span.sr-only= _('Remove')
= icon('trash')
- = link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "btn btn-danger prepend-left-10" do
+ = link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "btn btn-danger gl-ml-3" do
%span.sr-only= _('Revoke')
= _('Revoke')
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
index 31610e7505b..053cb3547ba 100644
--- a/app/views/profiles/gpg_keys/index.html.haml
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -1,7 +1,7 @@
- page_title _('GPG Keys')
- @content_class = "limit-container-width" unless fluid_layout
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= page_title
@@ -17,5 +17,5 @@
%hr
%h5
= _('Your GPG keys (%{count})') % { count:@gpg_keys.count}
- .append-bottom-default
+ .gl-mb-3
= render 'key_table'
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index 7709aa8f4b9..078b5907623 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -10,7 +10,7 @@
.col.form-group
= f.label :title, _('Title'), class: 'label-bold'
= f.text_field :title, class: "form-control input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key')
- %p.form-text.text-muted= s_('Profiles|Give your individual key a title')
+ %p.form-text.text-muted= s_('Profiles|Give your individual key a title. This will be publically visible.')
.col.form-group
= f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold'
@@ -23,5 +23,5 @@
%button.btn.btn-success.js-add-ssh-key-validation-confirm-submit= _("Yes, add it")
- .prepend-top-default
+ .gl-mt-3
= f.submit s_('Profiles|Add key'), class: "btn btn-success js-add-ssh-key-validation-original-submit qa-add-key-button"
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index b227041c9de..c9ab7b6fbd3 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -1,5 +1,5 @@
%li.d-flex.align-items-center.key-list-item
- .append-right-10
+ .gl-mr-3
- if key.valid?
- if key.expired?
%span.d-inline-block.has-tooltip{ title: s_('Profiles|Your key has expired') }
@@ -17,15 +17,15 @@
= key.fingerprint
.key-list-item-dates.d-flex.align-items-start.justify-content-between
- %span.last-used-at.append-right-10
+ %span.last-used-at.gl-mr-3
= s_('Profiles|Last used:')
= key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : _('Never')
- %span.expires.append-right-10
+ %span.expires.gl-mr-3
= s_('Profiles|Expires:')
= key.expires_at ? key.expires_at.to_date : _('Never')
%span.key-created-at
= s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)}
- if key.can_delete?
- = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent prepend-left-10 align-baseline" do
+ = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent gl-ml-3 align-baseline" do
%span.sr-only= _('Remove')
= sprite_icon('remove', size: 16)
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index 88deb0f11cb..59d953678e7 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -1,5 +1,5 @@
- is_admin = defined?(admin) ? true : false
-.row.prepend-top-default
+.row.gl-mt-3
.col-md-4
.card
.card-header
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 788c67b3704..7b7c24f3ac8 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -1,7 +1,7 @@
- page_title _('SSH Keys')
- @content_class = "limit-container-width" unless fluid_layout
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= page_title
@@ -12,7 +12,7 @@
= _('Add an SSH key')
%p.profile-settings-content
- generate_link_url = help_page_path("ssh/README", anchor: 'generating-a-new-ssh-key-pair')
- - existing_link_url = help_page_path("ssh/README", anchor: 'locating-an-existing-ssh-key-pair')
+ - existing_link_url = help_page_path("ssh/README", anchor: 'review-existing-ssh-keys')
- generate_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_link_url }
- existing_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: existing_link_url }
= _('To add an SSH key you need to %{generate_link_start}generate one%{link_end} or use an %{existing_link_start}existing key%{link_end}.').html_safe % { generate_link_start: generate_link_start, existing_link_start: existing_link_start, link_end: '</a>'.html_safe }
@@ -20,5 +20,5 @@
%hr
%h5
= _('Your SSH keys (%{count})') % { count:@keys.count }
- .append-bottom-default
+ .gl-mb-3
= render 'key_table'
diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml
index a25cd78fb0b..404bb224655 100644
--- a/app/views/profiles/notifications/_group_settings.html.haml
+++ b/app/views/profiles/notifications/_group_settings.html.haml
@@ -2,7 +2,7 @@
.gl-responsive-table-row.notification-list-item
.table-section.section-40
- %span.notification.fa.fa-holder.append-right-5
+ %span.notification.fa.fa-holder.gl-mr-2
= notification_icon(notification_icon_level(setting, emails_disabled))
%span.str-truncated
diff --git a/app/views/profiles/notifications/_project_settings.html.haml b/app/views/profiles/notifications/_project_settings.html.haml
index 63a77b335b6..f9172ae87aa 100644
--- a/app/views/profiles/notifications/_project_settings.html.haml
+++ b/app/views/profiles/notifications/_project_settings.html.haml
@@ -1,7 +1,7 @@
- emails_disabled = project.emails_disabled?
%li.notification-list-item
- %span.notification.fa.fa-holder.append-right-5
+ %span.notification.fa.fa-holder.gl-mr-2
= notification_icon(notification_icon_level(setting, emails_disabled))
%span.str-truncated
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 498f80aed2b..ab04d977a4d 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -9,7 +9,7 @@
%li= msg
= hidden_field_tag :notification_type, 'global'
- .row.prepend-top-default
+ .row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= page_title
@@ -21,7 +21,7 @@
%h5.gl-mt-0
= _('Global notification settings')
- = form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f|
+ = form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3' } do |f|
= render_if_exists 'profiles/notifications/email_settings', form: f
= label_tag :global_notification_level, "Global notification level", class: "label-bold"
@@ -47,7 +47,7 @@
= _('Projects (%{count})') % { count: @project_notifications.size }
%p.account-well
= _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.')
- .append-bottom-default
+ .gl-mb-3
%ul.bordered-list
- @project_notifications.each do |setting|
= render 'project_settings', setting: setting, project: setting.source
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index 9deaf7f84be..fe16c2e2f28 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -2,7 +2,7 @@
- page_title _('Password')
- @content_class = "limit-container-width" unless fluid_layout
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= page_title
@@ -29,7 +29,7 @@
.form-group
= f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
= f.password_field :password_confirmation, required: true, class: 'form-control', data: { qa_selector: 'confirm_password_field' }
- .prepend-top-default.append-bottom-default
- = f.submit _('Save password'), class: "btn btn-success append-right-10", data: { qa_selector: 'save_password_button' }
+ .gl-mt-3.gl-mb-3
+ = f.submit _('Save password'), class: "btn btn-success gl-mr-3", data: { qa_selector: 'save_password_button' }
- unless @user.password_automatically_set?
= link_to _('I forgot my password'), reset_profile_password_path, method: :put
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 769502e0026..11750f2a6d5 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -4,7 +4,7 @@
- type_plural = _('personal access tokens')
- @content_class = 'limit-container-width' unless fluid_layout
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= page_title
@@ -33,7 +33,7 @@
revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) }
%hr
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= s_('AccessTokens|Feed token')
@@ -51,7 +51,7 @@
- if incoming_email_token_enabled?
%hr
- .row.prepend-top-default
+ .row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= s_('AccessTokens|Incoming email token')
@@ -69,7 +69,7 @@
- if static_objects_external_storage_enabled?
%hr
- .row.prepend-top-default
+ .row.gl-mt-3
.col-lg-4
%h4.gl-mt-0
= s_('AccessTokens|Static object token')
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index cc44d137848..659b3066603 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,18 +1,19 @@
- page_title _('Preferences')
- @content_class = "limit-container-width" unless fluid_layout
-= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f|
+= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row gl-mt-3 js-preferences-form' } do |f|
.col-lg-4.application-theme
%h4.gl-mt-0
= s_('Preferences|Navigation theme')
%p
= s_('Preferences|Customize the appearance of the application header and navigation sidebar.')
.col-lg-8.application-theme
- - Gitlab::Themes.each do |theme|
- = label_tag do
- .preview{ class: theme.css_class }
- = f.radio_button :theme_id, theme.id, checked: Gitlab::Themes.for_user(@user).id == theme.id
- = theme.name
+ .row
+ - Gitlab::Themes.each do |theme|
+ %label.col-6.col-sm-4.col-md-3.gl-mb-5.gl-text-center
+ .preview{ class: theme.css_class }
+ = f.radio_button :theme_id, theme.id, checked: Gitlab::Themes.for_user(@user).id == theme.id
+ = theme.name
.col-sm-12
%hr
@@ -69,6 +70,13 @@
= f.check_box :show_whitespace_in_diffs, class: 'form-check-input'
= f.label :show_whitespace_in_diffs, class: 'form-check-label' do
= s_('Preferences|Show whitespace changes in diffs')
+ - if Feature.enabled?(:view_diffs_file_by_file)
+ .form-group.form-check
+ = f.check_box :view_diffs_file_by_file, class: 'form-check-input'
+ = f.label :view_diffs_file_by_file, class: 'form-check-label' do
+ = s_("Preferences|Show one file at a time on merge request's Changes tab")
+ .form-text.text-muted
+ = s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
.form-group
= f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold'
= f.number_field :tab_width,
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 78fdcdef3c4..f4aa0b98e37 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -1,8 +1,9 @@
- breadcrumb_title s_("Profiles|Edit Profile")
+- page_title s_("Profiles|Edit Profile")
- @content_class = "limit-container-width" unless fluid_layout
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
-= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f|
+= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f|
= form_errors(@user)
.row
@@ -24,13 +25,13 @@
.md
= brand_profile_image_guidelines
.col-lg-8
- .clearfix.avatar-image.append-bottom-default
+ .clearfix.avatar-image.gl-mb-3
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160'
%h5.gl-mt-0= s_("Profiles|Upload new avatar")
- .prepend-top-5.append-bottom-10
+ .gl-mt-2.append-bottom-10
%button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...")
- %span.avatar-file-name.prepend-left-default.js-avatar-filename= s_("Profiles|No file chosen")
+ %span.avatar-file-name.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen")
= f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
.form-text.text-muted= s_("Profiles|The maximum file size allowed is 200KB.")
- if @user.avatar?
@@ -117,7 +118,7 @@
= f.check_box :include_private_contributions, label: s_('Profiles|Include private contributions on my profile'), wrapper_class: 'mb-2', inline: true
.help-block
= s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information")
- .prepend-top-default.append-bottom-default
+ .gl-mt-3.gl-mb-3
= f.submit s_("Profiles|Update profile settings"), class: 'btn btn-success'
= link_to _("Cancel"), user_path(current_user), class: 'btn btn-cancel'
diff --git a/app/views/profiles/two_factor_auths/_codes.html.haml b/app/views/profiles/two_factor_auths/_codes.html.haml
index be0af977011..68cd4875a33 100644
--- a/app/views/profiles/two_factor_auths/_codes.html.haml
+++ b/app/views/profiles/two_factor_auths/_codes.html.haml
@@ -9,5 +9,5 @@
%span.monospace= code
.d-flex
- = link_to _('Proceed'), profile_account_path, class: 'btn btn-success append-right-10'
+ = link_to _('Proceed'), profile_account_path, class: 'btn btn-success gl-mr-3', data: { qa_selector: 'proceed_button' }
= link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'btn btn-default'
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 7e566361848..0fde3e5fb10 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -3,7 +3,7 @@
- @content_class = "limit-container-width" unless fluid_layout
.js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path }
- .row.prepend-top-default
+ .row.gl-mt-3
.col-lg-4
%h4.gl-mt-0
= _('Register Two-Factor Authenticator')
@@ -19,7 +19,7 @@
= link_to _('Disable two-factor authentication'), profile_two_factor_auth_path,
method: :delete,
data: { confirm: _('Are you sure? This will invalidate your registered applications and U2F devices.') },
- class: 'btn btn-danger append-right-10'
+ class: 'btn btn-danger gl-mr-3'
= form_tag codes_profile_two_factor_auth_path, {style: 'display: inline-block', method: :post} do |f|
= submit_tag _('Regenerate recovery codes'), class: 'btn'
@@ -39,7 +39,7 @@
= _('To add the entry manually, provide the following details to the application on your phone.')
%p.gl-mt-0.gl-mb-0
= _('Account: %{account}') % { account: @account_string }
- %p.gl-mt-0.gl-mb-0
+ %p.gl-mt-0.gl-mb-0{ data: { qa_selector: 'otp_secret_content' } }
= _('Key: %{key}') %{ key: current_user.otp_secret.scan(/.{4}/).join(' ') }
%p.two-factor-new-manual-content
= _('Time based: Yes')
@@ -49,13 +49,13 @@
= @error
.form-group
= label_tag :pin_code, _('Pin code'), class: "label-bold"
- = text_field_tag :pin_code, nil, class: "form-control", required: true
- .prepend-top-default
- = submit_tag _('Register with two-factor app'), class: 'btn btn-success'
+ = text_field_tag :pin_code, nil, class: "form-control", required: true, data: { qa_selector: 'pin_code_field' }
+ .gl-mt-3
+ = submit_tag _('Register with two-factor app'), class: 'btn btn-success', data: { qa_selector: 'register_2fa_app_button' }
%hr
- .row.prepend-top-default
+ .row.gl-mt-3
.col-lg-4
%h4.gl-mt-0
= _('Register Universal Two-Factor (U2F) Device')
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 20d4084f428..1562cc065f1 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -1,27 +1,22 @@
- is_project_overview = local_assigns.fetch(:is_project_overview, false)
-- commit = local_assigns.fetch(:commit) { @repository.commit }
- ref = local_assigns.fetch(:ref) { current_ref }
- project = local_assigns.fetch(:project) { @project }
-- content_url = local_assigns.fetch(:content_url) { @tree.readme ? project_blob_path(@project, tree_join(@ref, @tree.readme.path)) : project_tree_path(@project, @ref) }
- show_auto_devops_callout = show_auto_devops_callout?(@project)
+- add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0)
+- if @tree.readme
+ - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, @tree.readme.path), viewer: "rich", format: "json")
#tree-holder.tree-holder.clearfix
.nav-block
= render 'projects/tree/tree_header', tree: @tree
- - if vue_file_list_enabled?
- #js-last-commit
- - elsif commit
- = render 'shared/commit_well', commit: commit, ref: ref, project: project
+ #js-last-commit
- if is_project_overview
- .project-buttons.append-bottom-default{ class: ("js-show-on-project-root" if vue_file_list_enabled?) }
+ .project-buttons.gl-mb-3.js-show-on-project-root
= render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
- - if vue_file_list_enabled?
- #js-tree-list{ data: vue_file_list_data(project, ref) }
- - if can_edit_tree?
- = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post
- = render 'projects/blob/new_dir'
- - else
- = render 'projects/tree/tree_content', tree: @tree, content_url: content_url
+ #js-tree-list{ data: vue_file_list_data(project, ref) }
+ - if can_edit_tree?
+ = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post
+ = render 'projects/blob/new_dir'
diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml
index 4739689b419..ab8275ba5e4 100644
--- a/app/views/projects/_flash_messages.html.haml
+++ b/app/views/projects/_flash_messages.html.haml
@@ -9,4 +9,4 @@
= render 'shared/auto_devops_implicitly_enabled_banner', project: project
= render_if_exists 'projects/above_size_limit_warning', project: project
= render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)]
- = render 'shared/namespace_storage_limit_alert', namespace: project.namespace, classes: [container_class, ("limit-container-width" unless fluid_layout)]
+ = render_if_exists 'shared/namespace_storage_limit_alert', namespace: project.namespace, classes: [container_class, ("limit-container-width" unless fluid_layout)]
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 6f8375f80be..9966baf78f4 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -3,14 +3,14 @@
- max_project_topic_length = 15
- emails_disabled = @project.emails_disabled?
-.project-home-panel{ class: [("empty-project" if empty_repo), ("js-show-on-project-root" if vue_file_list_enabled?)] }
+.project-home-panel.js-show-on-project-root{ class: [("empty-project" if empty_repo)] }
.row.gl-mb-3
.home-panel-title-row.col-md-12.col-lg-6.d-flex
- .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none
+ .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none
= project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64)
.d-flex.flex-column.flex-wrap.align-items-baseline
.d-inline-flex.align-items-baseline
- %h1.home-panel-title.gl-mt-3.append-bottom-5{ data: { qa_selector: 'project_name_content' } }
+ %h1.home-panel-title.gl-mt-3.gl-mb-2{ data: { qa_selector: 'project_name_content' } }
= @project.name
%span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'})
@@ -24,10 +24,10 @@
= render 'shared/members/access_request_links', source: @project
- if @project.tag_list.present?
%span.home-panel-topic-list.mt-2.w-100.d-inline-flex
- = sprite_icon('tag', size: 16, css_class: 'icon append-right-4')
+ = sprite_icon('tag', size: 16, css_class: 'icon gl-mr-2')
- @project.topics_to_show.each do |topic|
- - project_topics_classes = "badge badge-pill badge-secondary append-right-5"
+ - project_topics_classes = "badge badge-pill badge-secondary gl-mr-2"
- explore_project_topic_path = explore_projects_path(tag: topic)
- if topic.length > max_project_topic_length
%a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path }
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 3ae37254e39..bb278fbf311 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -9,7 +9,8 @@
- 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', **tracking_attrs(track_label, 'click_button', 'gitlab_export') do
- = icon('gitlab', text: 'GitLab export')
+ = sprite_icon('tanuki')
+ = _("GitLab export")
- if github_import_enabled?
%div
@@ -32,7 +33,8 @@
%div
= link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}",
**tracking_attrs(track_label, 'click_button', 'gitlab_com') do
- = icon('gitlab', text: 'GitLab.com')
+ = sprite_icon('tanuki')
+ = _("GitLab.com")
- unless gitlab_import_configured?
= render 'projects/gitlab_import_modal'
diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml
index dc3a3fcc647..5ffdeef3558 100644
--- a/app/views/projects/_merge_request_settings.html.haml
+++ b/app/views/projects/_merge_request_settings.html.haml
@@ -4,6 +4,9 @@
= render 'projects/merge_request_merge_options_settings', project: @project, form: form
+- if Feature.enabled?(:squash_options, @project)
+ = render 'projects/merge_request_squash_options_settings', form: form
+
= render 'projects/merge_request_merge_checks_settings', project: @project, form: form
= render 'projects/merge_request_merge_suggestions_settings', project: @project, form: form
diff --git a/app/views/projects/_merge_request_squash_options_settings.html.haml b/app/views/projects/_merge_request_squash_options_settings.html.haml
new file mode 100644
index 00000000000..a5dbfeb16d8
--- /dev/null
+++ b/app/views/projects/_merge_request_squash_options_settings.html.haml
@@ -0,0 +1,42 @@
+- form = local_assigns.fetch(:form)
+
+= form.fields_for :project_setting do |settings|
+ .form-group
+ %b= s_('ProjectSettings|Squash commits when merging')
+ %p.text-secondary
+ = s_('ProjectSettings|Set the default behavior and availability of this option in merge requests. Changes made are also applied to existing merge requests.')
+ = link_to "What is squashing?",
+ help_page_path('user/project/merge_requests/squash_and_merge.md'),
+ target: '_blank'
+
+ .form-check.gl-mb-2
+ = settings.radio_button :squash_option, :never, class: "form-check-input"
+ = label_tag :project_project_setting_attributes_squash_option_never, class: 'form-check-label' do
+ .gl-font-weight-bold
+ = s_('ProjectSettings|Do not allow')
+ .text-secondary
+ = s_('ProjectSettings|Squashing is never performed and the checkbox is hidden.')
+
+ .form-check.gl-mb-2
+ = settings.radio_button :squash_option, :default_off, class: "form-check-input"
+ = label_tag :project_project_setting_attributes_squash_option_default_off, class: 'form-check-label' do
+ .gl-font-weight-bold
+ = s_('ProjectSettings|Allow')
+ .text-secondary
+ = s_('ProjectSettings|Checkbox is visible and unselected by default.')
+
+ .form-check.gl-mb-2
+ = settings.radio_button :squash_option, :default_on, class: "form-check-input"
+ = label_tag :project_project_setting_attributes_squash_option_default_on, class: 'form-check-label' do
+ .gl-font-weight-bold
+ = s_('ProjectSettings|Encourage')
+ .text-secondary
+ = s_('ProjectSettings|Checkbox is visible and selected by default.')
+
+ .form-check.gl-mb-2
+ = settings.radio_button :squash_option, :always, class: "form-check-input"
+ = label_tag :project_project_setting_attributes_squash_option_always, class: 'form-check-label' do
+ .gl-font-weight-bold
+ = s_('ProjectSettings|Require')
+ .text-secondary
+ = s_('ProjectSettings|Squashing is always performed. Checkbox is visible and selected, and users cannot change it.')
diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml
index 32624ac225b..da3133dfe15 100644
--- a/app/views/projects/_readme.html.haml
+++ b/app/views/projects/_readme.html.haml
@@ -1,10 +1,14 @@
- if (readme = @repository.readme) && readme.rich_viewer
+ .tree-holder
+ .nav-block.mt-0
+ = render 'projects/tree/tree_header', tree: @tree
%article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) }
- .js-file-title.file-title
- = blob_icon readme.mode, readme.name
- = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do
- %strong
- = readme.name
+ .js-file-title.file-title-flex-parent
+ .file-header-content
+ = blob_icon readme.mode, readme.name
+ = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do
+ %strong
+ = readme.name
= render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path), viewer: :rich, format: :json)
- else
diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml
index 6c84fbfeeb3..528d802261c 100644
--- a/app/views/projects/_remove.html.haml
+++ b/app/views/projects/_remove.html.haml
@@ -4,7 +4,6 @@
%h4.danger-title= _('Remove project')
%p
%strong= _('Removing the project will delete its repository and all related resources including issues, merge requests etc.')
- = form_tag(project_path(project), method: :delete) do
- %p
- %strong= _('Removed projects cannot be restored!')
- = button_to _('Remove project'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(project) }
+ %p
+ %strong= _('Removed projects cannot be restored!')
+ #js-confirm-project-remove{ data: { form_path: project_path(project), confirm_phrase: project.path, warning_message: remove_project_message(project) } }
diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml
new file mode 100644
index 00000000000..e6842bbb939
--- /dev/null
+++ b/app/views/projects/_service_desk_settings.html.haml
@@ -0,0 +1,19 @@
+- expanded = expanded_by_default?
+%section.settings.js-service-desk-setting-wrapper.no-animate#js-service-desk{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Service Desk')
+ %button.btn.js-settings-toggle
+ = expanded ? _('Collapse') : _('Expand')
+ - link_start = "<a href='#{help_page_path('user/project/service_desk')}' target='_blank' rel='noopener noreferrer'>".html_safe
+ %p= _('Enable/disable your service desk. %{link_start}Learn more about service desk%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ .settings-content
+ - if ::Gitlab::ServiceDesk.supported?
+ .js-service-desk-setting-root{ data: { endpoint: project_service_desk_path(@project),
+ enabled: "#{@project.service_desk_enabled}",
+ incoming_email: (@project.service_desk_address if @project.service_desk_enabled),
+ selected_template: "#{@project.service_desk_setting&.issue_template_key}",
+ outgoing_name: "#{@project.service_desk_setting&.outgoing_name}",
+ project_key: "#{@project.service_desk_setting&.project_key}",
+ templates: issuable_templates_names(Issue.new) } }
+ - elsif show_callout?('promote_service_desk_dismissed')
+ = render 'shared/promotions/promote_servicedesk'
diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml
index 6f90bf50b91..991c95153da 100644
--- a/app/views/projects/_wiki.html.haml
+++ b/app/views/projects/_wiki.html.haml
@@ -1,6 +1,6 @@
- if @wiki_home.present?
%div{ class: container_class }
- .md.prepend-top-default.append-bottom-default
+ .md.gl-mt-3.gl-mb-3
= render_wiki_content(@wiki_home)
- else
- can_create_wiki = can?(current_user, :create_wiki, @project)
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index 7abac2d14e4..ff56cb53720 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -1,5 +1,5 @@
- breadcrumb_title _('Artifacts')
-- page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
+- page_title @path.presence, _('Artifacts'), "#{@build.name} (##{@build.id})", _('Jobs')
= render "projects/jobs/header"
diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml
index 808b4acc8f3..1ad70506be4 100644
--- a/app/views/projects/artifacts/file.html.haml
+++ b/app/views/projects/artifacts/file.html.haml
@@ -1,4 +1,4 @@
-- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
+- page_title @path, _('Artifacts'), "#{@build.name} (##{@build.id})", _('Jobs')
= render "projects/jobs/header"
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index 0591c3180ea..a2d6b2e18a9 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -1,4 +1,4 @@
-- page_title "Blame", @blob.path, @ref
+- page_title _("Blame"), @blob.path, @ref
- link_icon = icon("link")
#blob-content-holder.tree-holder
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 032df24a603..b06ae31e73f 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -2,19 +2,19 @@
- file_name = params[:id].split("/").last ||= ""
- is_markdown = Gitlab::MarkupHelper.gitlab_markdown?(file_name)
-.file-holder-bottom-radius.file-holder.file.append-bottom-default
+.file-holder-bottom-radius.file-holder.file.gl-mb-3
.js-file-title.file-title.align-items-center.clearfix{ data: { current_action: action } }
- .editor-ref.block-truncated
+ .editor-ref.block-truncated.has-tooltip{ title: ref }
= sprite_icon('fork', size: 12)
= ref
- if current_action?(:edit) || current_action?(:update)
- %span.pull-left.append-right-10
+ %span.pull-left.gl-mr-3
= text_field_tag 'file_path', (params[:file_path] || @path),
class: 'form-control new-file-path js-file-path-name-input'
= render 'template_selectors'
- if current_action?(:new) || current_action?(:create)
- %span.pull-left.append-right-10
+ %span.pull-left.gl-mr-3
\/
= text_field_tag 'file_name', params[:file_name], placeholder: "File name",
required: true, class: 'form-control new-file-name js-file-path-name-input', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : '')
@@ -40,7 +40,7 @@
= select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2', tabindex: '-1'
.file-editor.code
- %pre.js-edit-mode-pane.qa-editor#editor= params[:content] || local_assigns[:blob_data]
+ %pre.js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true } }= params[:content] || local_assigns[:blob_data]
- if local_assigns[:path]
.js-edit-mode-pane#preview.hide
.center
diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml
index 6527c6021a0..32adfb320ff 100644
--- a/app/views/projects/blob/_header_content.html.haml
+++ b/app/views/projects/blob/_header_content.html.haml
@@ -10,4 +10,4 @@
= number_to_human_size(blob.raw_size)
- if blob.stored_externally? && blob.external_storage == :lfs
- %span.badge.label-lfs.append-right-5 LFS
+ %span.badge.label-lfs.gl-mr-2 LFS
diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml
index b9663bbba15..a0d82ffd2c7 100644
--- a/app/views/projects/blob/_viewer.html.haml
+++ b/app/views/projects/blob/_viewer.html.haml
@@ -5,7 +5,7 @@
- external_embed = local_assigns.fetch(:external_embed, false)
- 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) }
+.blob-viewer{ data: { type: viewer.type, rich_type: rich_type, url: viewer_url, path: viewer.blob.path }, class: ('hidden' if hidden) }
- if render_error
= render 'projects/blob/render_error', viewer: viewer
- elsif load_async
diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml
index 5e0d70b2ca9..df81e509c85 100644
--- a/app/views/projects/blob/_viewer_switcher.html.haml
+++ b/app/views/projects/blob/_viewer_switcher.html.haml
@@ -5,8 +5,8 @@
.btn-group.js-blob-viewer-switcher.ml-2{ role: "group" }>
- simple_label = "Display #{simple_viewer.switcher_title}"
%button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }>
- = icon(simple_viewer.switcher_icon)
+ = sprite_icon(simple_viewer.switcher_icon)
- rich_label = "Display #{rich_viewer.switcher_title}"
%button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }>
- = icon(rich_viewer.switcher_icon)
+ = sprite_icon(rich_viewer.switcher_icon)
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 870e37488cf..1319c58eb38 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -1,7 +1,8 @@
-- breadcrumb_title "Repository"
-- page_title "Edit", @blob.path, @ref
-- content_for :page_specific_javascripts do
- = page_specific_javascript_tag('lib/ace.js')
+- breadcrumb_title _("Repository")
+- page_title _("Edit"), @blob.path, @ref
+- unless Feature.enabled?(:monaco_blobs)
+ - content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
- if @conflict
.alert.alert-danger
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index 8f166e9aa16..2420c4a4bd5 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -1,7 +1,9 @@
-- breadcrumb_title "Repository"
-- page_title "New File", @path.presence, @ref
-- content_for :page_specific_javascripts do
- = page_specific_javascript_tag('lib/ace.js')
+- breadcrumb_title _("Repository")
+- page_title _("New File"), @path.presence, @ref
+- unless Feature.enabled?(:monaco_blobs)
+ - content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
+
.editor-title-row
%h3.page-title.blob-new-page-title
New file
diff --git a/app/views/projects/blob/viewers/_license.html.haml b/app/views/projects/blob/viewers/_license.html.haml
index fb9d0b99d09..7ac0e7bb579 100644
--- a/app/views/projects/blob/viewers/_license.html.haml
+++ b/app/views/projects/blob/viewers/_license.html.haml
@@ -1,6 +1,6 @@
- license = viewer.license
-= icon('balance-scale fw')
+= sprite_icon('scale', size: 16)
This project is licensed under the
= succeed '.' do
%strong= license.name
diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml
index df1f3e4e01b..5fbe9b0df0c 100644
--- a/app/views/projects/blob/viewers/_loading.html.haml
+++ b/app/views/projects/blob/viewers/_loading.html.haml
@@ -1,2 +1,2 @@
-.text-center.prepend-top-default.append-bottom-default
+.text-center.gl-mt-3.gl-mb-3
= icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…', class: 'qa-spinner')
diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml
index fc8683e1d19..ecbf6d9005d 100644
--- a/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml
+++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml
@@ -8,4 +8,4 @@
- viewer.errors.messages.each do |error|
%li= error.join(': ')
-= link_to _('Learn more'), help_page_path('user/project/integrations/prometheus.md', anchor: 'defining-custom-dashboards-per-project')
+= link_to _('Learn more'), help_page_path('operations/metrics/dashboards/index.md', anchor: 'defining-custom-dashboards-per-project')
diff --git a/app/views/projects/blob/viewers/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml
index b4b6492b92f..aa8d1dd326f 100644
--- a/app/views/projects/blob/viewers/_sketch.html.haml
+++ b/app/views/projects/blob/viewers/_sketch.html.haml
@@ -1,3 +1,3 @@
.file-content#js-sketch-viewer{ data: { endpoint: blob_raw_path } }
- .js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' }
+ .js-loading-icon.text-center.gl-mt-3.gl-mb-3.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' }
= icon('spinner spin 2x', 'aria-hidden' => 'true');
diff --git a/app/views/projects/blob/viewers/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml
index 55dd8cba7fe..6983c3cc81b 100644
--- a/app/views/projects/blob/viewers/_stl.html.haml
+++ b/app/views/projects/blob/viewers/_stl.html.haml
@@ -1,7 +1,7 @@
.file-content.is-stl-loading
.text-center#js-stl-viewer{ data: { endpoint: blob_raw_path } }
- = icon('spinner spin 2x', class: 'prepend-top-default append-bottom-default', 'aria-hidden' => 'true', 'aria-label' => 'Loading')
- .text-center.prepend-top-default.append-bottom-default.stl-controls
+ = icon('spinner spin 2x', class: 'gl-mt-3 gl-mb-3', 'aria-hidden' => 'true', 'aria-label' => 'Loading')
+ .text-center.gl-mt-3.gl-mb-3.stl-controls
.btn-group
%button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } }
Wireframe
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 2e9be28df86..ed7dbdeae93 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -8,13 +8,13 @@
= link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name gl-ml-3 qa-branch-name' do
= branch.name
- if branch.name == @repository.root_ref
- %span.badge.badge-primary.prepend-left-5 default
+ %span.badge.badge-primary.gl-ml-2 default
- elsif merged
- %span.badge.badge-info.has-tooltip.prepend-left-5{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } }
+ %span.badge.badge-info.has-tooltip.gl-ml-2{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } }
= s_('Branches|merged')
- if protected_branch?(@project, branch)
- %span.badge.badge-success.prepend-left-5
+ %span.badge.badge-success.gl-ml-2
= s_('Branches|protected')
= render_if_exists 'projects/branches/diverged_from_upstream', branch: branch
@@ -41,7 +41,7 @@
- if branch.name != @repository.root_ref
= link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
- class: "btn btn-default js-onboarding-compare-branches #{'prepend-left-10' unless merge_project}",
+ class: "btn btn-default js-onboarding-compare-branches #{'gl-ml-3' unless merge_project}",
method: :post,
title: s_('Branches|Compare') do
= s_('Branches|Compare')
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index af8887b0c39..97e46aaa710 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -1,4 +1,4 @@
-- page_title "New Branch"
+- page_title _("New Branch")
- default_ref = params[:ref] || @project.default_branch
- if @error
diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
index b12be8a91d6..7ce143a86b3 100644
--- a/app/views/projects/buttons/_clone.html.haml
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -3,7 +3,7 @@
.git-clone-holder.js-git-clone-holder
%a#clone-dropdown.btn.btn-primary.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
- %span.append-right-4.js-clone-dropdown-label
+ %span.gl-mr-2.js-clone-dropdown-label
= _('Clone')
= sprite_icon("chevron-down", css_class: "icon")
%ul.p-3.dropdown-menu.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options{ class: dropdown_class }
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index 445752d0a15..1d0ad6dcde6 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -12,13 +12,7 @@
%h5.m-0.dropdown-bold-header= _('Download source code')
.dropdown-menu-content
= render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil
- - if vue_file_list_enabled?
- #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } }
- - elsif directory?
- %section.border-top.pt-1.mt-1
- %h5.m-0.dropdown-bold-header= _('Download this directory')
- .dropdown-menu-content
- = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path
+ #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } }
- if pipeline && pipeline.latest_builds_with_artifacts.any?
%section.border-top.pt-1.mt-1
%h5.m-0.dropdown-bold-header= _('Download artifacts')
diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml
index 02e8bad69b9..52855d7ee12 100644
--- a/app/views/projects/cleanup/_show.html.haml
+++ b/app/views/projects/cleanup/_show.html.haml
@@ -20,7 +20,7 @@
= _("Upload object map")
%button.btn.btn-default.js-choose-file{ type: "button" }
= _("Choose a file")
- %span.prepend-left-default.js-filename
+ %span.gl-ml-3.js-filename
= _("No file selected")
= f.file_field :bfg_object_map, class: "hidden js-object-map-input", required: true
.form-text.text-muted
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 4442bdcdf1d..71cf6ca6922 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -22,10 +22,10 @@
.header-action-buttons
- if defined?(@notes_count) && @notes_count > 0
- %span.btn.disabled.btn-grouped.d-none.d-sm-block.append-right-10.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count }
+ %span.btn.disabled.btn-grouped.d-none.d-sm-block.gl-mr-3.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count }
= sprite_icon('comment')
= @notes_count
- = link_to project_tree_path(@project, @commit), class: "btn btn-default append-right-10 d-none d-sm-none d-md-inline" do
+ = link_to project_tree_path(@project, @commit), class: "btn btn-default gl-mr-3 d-none d-sm-none d-md-inline" do
#{ _('Browse files') }
.dropdown.inline
%a.btn.btn-default.dropdown-toggle.qa-options-button.d-md-inline{ data: { toggle: "dropdown" } }
diff --git a/app/views/projects/commit/_limit_exceeded_message.html.haml b/app/views/projects/commit/_limit_exceeded_message.html.haml
index 7d3c0582d0b..ace1be787fb 100644
--- a/app/views/projects/commit/_limit_exceeded_message.html.haml
+++ b/app/views/projects/commit/_limit_exceeded_message.html.haml
@@ -1,4 +1,4 @@
-.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: _('Project has too many %{label_for_message} to search') % { label_for_message: label_for_message } } }
+.has-tooltip{ class: "limit-box limit-box-#{objects} gl-ml-2", data: { title: _('Project has too many %{label_for_message} to search') % { label_for_message: label_for_message } } }
.limit-icon
- if objects == :branch
= sprite_icon('fork', size: 12)
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 7722a3523a1..737e4f66dd2 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -14,18 +14,18 @@
%ul.breadcrumb.repo-breadcrumb
= commits_breadcrumbs
#js-author-dropdown{ data: { 'commits_path': project_commits_path(@project), 'project_id': @project.id } }
- .tree-controls.d-none.d-sm-none.d-md-block
+ .tree-controls
- if @merge_request.present?
- .control
+ .control.d-none.d-md-block
= link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn'
- elsif create_mr_button?(@repository.root_ref, @ref)
- .control
+ .control.d-none.d-md-block
= link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success'
.control
= form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do
- = search_field_tag :search, params[:search], { placeholder: _('Filter by commit message'), id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false }
- .control
+ = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control search-text-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full', spellcheck: false }
+ .control.d-none.d-md-block
= link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do
= icon("rss")
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index f5a4889b4bb..d10fa69ff47 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -1,7 +1,7 @@
= form_tag project_compare_index_path(@project), method: :post, class: 'form-inline js-requires-input js-signature-container', data: { 'signatures-path' => signatures_namespace_project_compare_index_path } do
- if params[:to] && params[:from]
.compare-switch-container
- = link_to icon('exchange'), { from: params[:to], to: params[:from] }, class: 'commits-compare-switch has-tooltip btn btn-white', title: 'Swap revisions'
+ = link_to sprite_icon('substitute'), { from: params[:to], to: params[:from] }, class: 'commits-compare-switch has-tooltip btn btn-white', title: 'Swap revisions'
.form-group.dropdown.compare-form-group.to.js-compare-to-dropdown
.input-group.inline-input-group
%span.input-group-prepend
@@ -26,6 +26,6 @@
&nbsp;
= button_tag s_("CompareBranches|Compare"), class: "btn btn-success commits-compare-btn"
- if @merge_request.present?
- = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'prepend-left-10 btn'
+ = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'gl-ml-3 btn'
- elsif create_mr_button?
- = link_to _("Create merge request"), create_mr_path, class: 'prepend-left-10 btn'
+ = link_to _("Create merge request"), create_mr_path, class: 'gl-ml-3 btn'
diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml
index 02f2b104ce3..93ee1bed809 100644
--- a/app/views/projects/compare/index.html.haml
+++ b/app/views/projects/compare/index.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title "Compare Revisions"
-- page_title "Compare"
+- breadcrumb_title _("Compare Revisions")
+- page_title _("Compare")
%h3.page-title
= _("Compare Git revisions")
diff --git a/app/views/projects/confluences/show.html.haml b/app/views/projects/confluences/show.html.haml
new file mode 100644
index 00000000000..b87780db4cd
--- /dev/null
+++ b/app/views/projects/confluences/show.html.haml
@@ -0,0 +1,13 @@
+- breadcrumb_title _('Confluence')
+- page_title _('Confluence')
+= render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/wiki_login_empty.svg' } do
+ %h4
+ = s_('WikiEmpty|Confluence is enabled')
+ %p
+ - wiki_confluence_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/3629'
+ - wiki_confluence_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wiki_confluence_epic_link_url }
+ = s_("WikiEmpty|You've enabled the Confluence Workspace integration. Your wiki will be viewable directly within Confluence. We are hard at work integrating Confluence more seamlessly into GitLab. If you'd like to stay up to date, follow our %{wiki_confluence_epic_link_start}Confluence epic%{wiki_confluence_epic_link_end}.").html_safe % { wiki_confluence_epic_link_start: wiki_confluence_epic_link_start, wiki_confluence_epic_link_end: '</a>'.html_safe }
+ = link_to @project.confluence_service.confluence_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn btn-success external-url', title: s_('WikiEmpty|Go to Confluence') do
+ = sprite_icon('external-link')
+ = s_('WikiEmpty|Go to Confluence')
+
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index b6c30c680e4..090fc602ebb 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,4 +1,4 @@
-- page_title "Value Stream Analytics"
+- page_title _("Value Stream Analytics")
#cycle-analytics{ "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- if @cycle_analytics_no_data
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index 6a09004143e..38bec0361b0 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -26,6 +26,6 @@
%strong= _("Auto-close referenced issues on default branch")
.form-text.text-muted
= _("Issues referenced by merge requests and commits within the default branch will be closed automatically")
- = link_to icon('question-circle'), help_page_path('user/project/issues/managing_issues.html', anchor: 'disabling-automatic-issue-closing'), target: '_blank'
+ = link_to icon('question-circle'), help_page_path('user/project/issues/managing_issues.md', anchor: 'disabling-automatic-issue-closing'), target: '_blank'
= f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml
index 0ce93eef369..7fa7036245c 100644
--- a/app/views/projects/deploy_keys/edit.html.haml
+++ b/app/views/projects/deploy_keys/edit.html.haml
@@ -1,4 +1,4 @@
-- page_title 'Edit Deploy Key'
+- page_title _('Edit Deploy Key')
%h3.page-title= _('Edit Deploy Key')
%hr
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index cf7fe36af9d..4b76dde681e 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -16,6 +16,8 @@
= diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'd-none d-sm-inline-block')
- elsif current_controller?(:compare)
= diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'd-none d-sm-inline-block')
+ - elsif current_controller?(:wikis)
+ = toggle_whitespace_link(url_for(params_with_whitespace), class: 'd-none d-sm-inline-block')
.btn-group
= inline_diff_btn
= parallel_diff_btn
diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml
index 6a1bff8640c..f954b09abee 100644
--- a/app/views/projects/diffs/_file_header.html.haml
+++ b/app/views/projects/diffs/_file_header.html.haml
@@ -37,4 +37,4 @@
#{diff_file.a_mode} → #{diff_file.b_mode}
- if diff_file.stored_externally? && diff_file.external_storage == :lfs
- %span.badge.label-lfs.append-right-5 LFS
+ %span.badge.label-lfs.gl-mr-2 LFS
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index 17c1764e8a4..0e2a1165ad3 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -4,7 +4,7 @@
Showing
%button.diff-stats-summary-toggler.js-diff-stats-dropdown{ type: "button", data: { toggle: "dropdown", display: "static" } }<
= pluralize(diff_files.size, "changed file")
- = icon("caret-down", class: "prepend-left-5")
+ = icon("caret-down", class: "gl-ml-2")
%span.diff-stats-additions-deletions-expanded#diff-stats
with
%strong.cgreen= pluralize(sum_added_lines, 'addition')
@@ -30,7 +30,7 @@
- else
%strong.diff-changed-blank-file-name
= s_('Diffs|No file name available')
- %span.diff-changed-file-path.prepend-top-5= diff_file_path_text(diff_file)
+ %span.diff-changed-file-path.gl-mt-2= diff_file_path_text(diff_file)
%span.diff-changed-stats
%span.cgreen<
+#{diff_file.added_lines}
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 3c6fb5b19a4..e63b615115a 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -56,7 +56,7 @@
= render_if_exists 'projects/settings/default_issue_template'
-= render_if_exists 'projects/service_desk_settings'
+= render 'projects/service_desk_settings'
%section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 6b1455acd08..bfb22aa8025 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -1,5 +1,7 @@
- @content_class = "limit-container-width" unless fluid_layout
+- default_branch_name = Gitlab::CurrentSettings.default_branch_name.presence || "master"
- breadcrumb_title _("Details")
+- page_title _("Details")
= render partial: 'flash_messages', locals: { project: @project }
@@ -46,7 +48,7 @@
git commit -m "add README"
- if @project.can_current_user_push_to_default_branch?
%span><
- git push -u origin master
+ git push -u origin #{ default_branch_name }
%fieldset
%h5= _('Push an existing folder')
@@ -59,7 +61,7 @@
git commit -m "Initial commit"
- if @project.can_current_user_push_to_default_branch?
%span><
- git push -u origin master
+ git push -u origin #{ default_branch_name }
%fieldset
%h5= _('Push an existing Git repository')
diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml
index efe80a4877c..39eda493d69 100644
--- a/app/views/projects/environments/_form.html.haml
+++ b/app/views/projects/environments/_form.html.haml
@@ -1,9 +1,9 @@
-.row.prepend-top-default.append-bottom-default
+.row.gl-mt-3.gl-mb-3
.col-lg-3
%h4.gl-mt-0
= _("Environments")
%p
- - link_to_read_more = link_to(_("Read more about environments"), help_page_path("ci/environments/index.md"))
+ - link_to_read_more = link_to(_("More information"), help_page_path("ci/environments/index.md"))
= _("Environments allow you to track deployments of your application %{link_to_read_more}.").html_safe % { link_to_read_more: link_to_read_more }
= form_for [@project.namespace.becomes(Namespace), @project, @environment], html: { class: 'col-lg-9' } do |f|
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index 971107675ab..786af3714a6 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -1,4 +1,4 @@
-- page_title "Find File", @ref
+- page_title _("Find File"), @ref
.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @id || @commit.id)) }
.nav-block
@@ -23,5 +23,5 @@
= _('There are no matching files')
%p.text-secondary
= _('Try using a different search term to find the file you are looking for.')
- .text-center.prepend-top-default.loading
+ .text-center.gl-mt-3.loading
.spinner.spinner-md
diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml
index 70064722832..eec02a50b85 100644
--- a/app/views/projects/forks/_fork_button.html.haml
+++ b/app/views/projects/forks/_fork_button.html.haml
@@ -2,17 +2,17 @@
- can_create_project = current_user.can?(:create_projects, namespace)
- if forked_project = namespace.find_fork_of(@project)
- .bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default.forked
+ .bordered-box.fork-thumbnail.text-center.gl-ml-3.gl-mr-3.gl-mt-3.gl-mb-3.forked
= link_to project_path(forked_project) do
- if /no_((\w*)_)*avatar/.match(avatar)
= group_icon(namespace, class: "avatar rect-avatar s100 identicon mx-auto")
- else
.avatar-container.s100.mx-auto
= image_tag(avatar, class: "avatar s100")
- %h5.prepend-top-default
+ %h5.gl-mt-3
= namespace.human_name
- else
- .bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default{ class: ("disabled" unless can_create_project) }
+ .bordered-box.fork-thumbnail.text-center.gl-ml-3.gl-mr-3.gl-mt-3.gl-mb-3{ class: ("disabled" unless can_create_project) }
= link_to project_forks_path(@project, namespace_key: namespace.id),
method: "POST",
class: ("disabled has-tooltip" unless can_create_project),
@@ -22,5 +22,5 @@
- else
.avatar-container.s100.mx-auto
= image_tag(avatar, class: "avatar s100")
- %h5.prepend-top-default{ data: { qa_selector: 'fork_namespace_content', qa_name: namespace.human_name } }
+ %h5.gl-mt-3{ data: { qa_selector: 'fork_namespace_content', qa_name: namespace.human_name } }
= namespace.human_name
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index 763e31c4a8b..887081d0f35 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -1,6 +1,6 @@
- page_title _("Fork project")
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-3
%h4.gl-mt-0
= _("Fork project")
@@ -9,13 +9,13 @@
.col-lg-9
- if @namespaces.present?
.fork-thumbnail-container.js-fork-content
- %h5.gl-mt-0.gl-mb-0.prepend-left-default.append-right-default
+ %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3
= _("Select a namespace to fork the project")
- @namespaces.each do |namespace|
= render 'fork_button', namespace: namespace
- else
%strong
= _("No available namespaces to fork the project.")
- %p.prepend-top-default
+ %p.gl-mt-3
= _("You must have permission to create a project in a namespace before forking.")
diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml
index e7b924c65bf..a8a4eef65b3 100644
--- a/app/views/projects/hook_logs/_index.html.haml
+++ b/app/views/projects/hook_logs/_index.html.haml
@@ -1,4 +1,4 @@
-.row.gl-mt-7.append-bottom-default
+.row.gl-mt-7.gl-mb-3
.col-lg-3
%h4.gl-mt-0
Recent Deliveries
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
index a6a3f56c28c..8a8c396a9e4 100644
--- a/app/views/projects/hook_logs/show.html.haml
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -2,11 +2,11 @@
- add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path
- page_title _('Webhook Logs')
-.row.prepend-top-default.append-bottom-default
+.row.gl-mt-3.gl-mb-3
.col-lg-3
%h4.gl-mt-0
Request details
.col-lg-9
- = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right prepend-left-10"
+ = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right gl-ml-3"
= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index 15100840c0a..e0ef0c0d3f9 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -2,11 +2,11 @@
- add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path
- page_title _('Webhook')
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-3
= render 'shared/web_hooks/title_and_docs', hook: @hook
- .col-lg-9.append-bottom-default
+ .col-lg-9.gl-mb-3
= form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml
index 169a5cc9d6b..1845bd190d3 100644
--- a/app/views/projects/hooks/index.html.haml
+++ b/app/views/projects/hooks/index.html.haml
@@ -2,11 +2,11 @@
- breadcrumb_title _('Webhook Settings')
- page_title _('Webhooks')
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4
= render 'shared/web_hooks/title_and_docs', hook: @hook
- .col-lg-8.append-bottom-default
+ .col-lg-8.gl-mb-3
= form_for @hook, as: :hook, url: polymorphic_path([@project.namespace.becomes(Namespace), @project, :hooks]) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
= f.submit 'Add webhook', class: 'btn btn-success'
diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml
index fe6cc6fa828..3c0664e4d5f 100644
--- a/app/views/projects/import/jira/show.html.haml
+++ b/app/views/projects/import/jira/show.html.haml
@@ -3,4 +3,5 @@
jira_integration_path: edit_project_service_path(@project, :jira),
is_jira_configured: @project.jira_service&.active? && @project.jira_service&.valid_connection?.to_s,
in_progress_illustration: image_path('illustrations/export-import.svg'),
+ project_id: @project.id,
setup_illustration: image_path('illustrations/manual_action.svg') } }
diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml
index bd0ab2c19f2..58981ca1556 100644
--- a/app/views/projects/imports/new.html.haml
+++ b/app/views/projects/imports/new.html.haml
@@ -1,4 +1,4 @@
-- page_title "Import repository"
+- page_title _("Import repository")
%h3.page-title
Import repository
diff --git a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
new file mode 100644
index 00000000000..a6f969f8b10
--- /dev/null
+++ b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
@@ -0,0 +1,10 @@
+- return unless show_moved_service_desk_issue_warning?(issue)
+- service_desk_link_url = help_page_path('user/project/service_desk')
+- service_desk_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: service_desk_link_url }
+
+.hide.gl-alert.gl-alert-warning.js-alert-moved-from-service-desk-warning.gl-mt-5{ role: 'alert' }
+ = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ .gl-alert-body.gl-mr-3
+ = s_('This project does not have %{service_desk_link_start}Service Desk%{service_desk_link_end} enabled, so the user who created the issue will no longer receive email notifications about new activity.').html_safe % { service_desk_link_start: service_desk_link_start, service_desk_link_end: '</a>'.html_safe }
diff --git a/app/views/projects/issues/_by_email_description.html.haml b/app/views/projects/issues/_by_email_description.html.haml
index f2d58534903..0ff852352e1 100644
--- a/app/views/projects/issues/_by_email_description.html.haml
+++ b/app/views/projects/issues/_by_email_description.html.haml
@@ -1,6 +1,6 @@
The subject will be used as the title of the new issue, and the message will be the description.
-= link_to 'Quick actions', help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1
+= link_to 'Quick actions', help_page_path('user/project/quick_actions'), target: '_blank'
and styling with
-= link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1
+= link_to 'Markdown', help_page_path('user/markdown'), target: '_blank'
are supported.
diff --git a/app/views/projects/issues/_design_management.html.haml b/app/views/projects/issues/_design_management.html.haml
index 96f1dc0155c..045f032e6e7 100644
--- a/app/views/projects/issues/_design_management.html.haml
+++ b/app/views/projects/issues/_design_management.html.haml
@@ -1,15 +1,27 @@
- if @project.design_management_enabled?
- .js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
+ - if Feature.enabled?(:design_management_moved, @project, default_enabled: true)
+ .js-design-management-new{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
+ - else
+ .js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
- else
- .mt-4
- .row.empty-state
- .col-12
- .text-content
- %h4.center
- = _('The one place for your designs')
- %p.center
- - requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements')
- - requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url }
- - support_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: support_url }
- - link_end = '</a>'.html_safe
- = s_("DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance.").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end, support_link_start: support_link_start, support_link_end: link_end }
+ - if Feature.enabled?(:design_management_moved, @project, default_enabled: true)
+ .row.empty-state.design-dropzone-border.gl-mt-5
+ .text-content.center.gl-font-weight-bold
+ - requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements')
+ - requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url }
+ - support_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: support_url }
+ - link_end = '</a>'.html_safe
+ = s_("DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance.").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end, support_link_start: support_link_start, support_link_end: link_end }
+ - else
+ .mt-4
+ .row.empty-state
+ .col-12
+ .text-content
+ %h4.center
+ = _('The one place for your designs')
+ %p.center
+ - requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements')
+ - requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url }
+ - support_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: support_url }
+ - link_end = '</a>'.html_safe
+ = s_("DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance.").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end, support_link_start: support_link_start, support_link_end: link_end }
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 9c129fa9ecc..bcc74e8d1d9 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -7,7 +7,7 @@
%section.issuable-discussion.js-vue-notes-event
#js-vue-notes{ data: { notes_data: notes_data(@issue).to_json,
- noteable_data: serialize_issuable(@issue, with_blocking_issues: Feature.enabled?(:prevent_closing_blocked_issues, @issue.project)),
+ noteable_data: serialize_issuable(@issue, with_blocking_issues: true),
noteable_type: 'Issue',
target_type: 'issue',
current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json } }
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index e325d585d0c..e7cd35497e8 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -47,7 +47,7 @@
.issuable-meta
%ul.controls
- - if issue.moved?
+ - if issue.closed? && issue.moved?
%li.issuable-status
= _('CLOSED (MOVED)')
- elsif issue.closed?
diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml
index 7d539c9d749..c0383c57e63 100644
--- a/app/views/projects/issues/_issues.html.haml
+++ b/app/views/projects/issues/_issues.html.haml
@@ -1,9 +1,14 @@
-- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
+- if Feature.enabled?(:vue_issuables_list, @project)
+ .js-issuables-list{ data: { endpoint: expose_url(api_v4_projects_issues_path(id: @project.id)),
+ 'can-bulk-edit': @can_bulk_update.to_json,
+ 'empty-svg-path': image_path('illustrations/issues.svg'),
+ 'sort-key': @sort } }
+- else
+ - empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
+ %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') }
+ = render partial: "projects/issues/issue", collection: @issues
+ - if @issues.blank?
+ = render empty_state_path
-%ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') }
- = render partial: "projects/issues/issue", collection: @issues
- - if @issues.blank?
- = render empty_state_path
-
-- if @issues.present?
- = paginate @issues, theme: "gitlab", total_pages: @total_pages
+ - if @issues.present?
+ = paginate @issues, theme: "gitlab", total_pages: @total_pages
diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml
index 71c9bb36936..cc6ca4aca4a 100644
--- a/app/views/projects/issues/_nav_btns.html.haml
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -14,7 +14,7 @@
= render 'projects/issues/import_csv/button'
- if @can_bulk_update
- = button_tag _("Edit issues"), class: "btn btn-default append-right-10 js-bulk-update-toggle"
+ = button_tag _("Edit issues"), class: "btn btn-default gl-mr-3 js-bulk-update-toggle"
- if show_new_issue_link?(@project)
= link_to _("New issue"), new_project_issue_path(@project,
issue: { assignee_id: finder.assignee.try(:id),
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 73904354a12..9bbab925f6a 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -41,7 +41,7 @@
= _('Create branch')
%li.divider.droplab-item-ignore
- %li.droplab-item-ignore.gl-ml-3.gl-mr-3.prepend-top-16
+ %li.droplab-item-ignore.gl-ml-3.gl-mr-3.gl-mt-5
- if can_create_confidential_merge_request?
#js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests') } }
.form-group
diff --git a/app/views/projects/issues/_service_desk_info_content.html.haml b/app/views/projects/issues/_service_desk_info_content.html.haml
new file mode 100644
index 00000000000..ddd8e545043
--- /dev/null
+++ b/app/views/projects/issues/_service_desk_info_content.html.haml
@@ -0,0 +1,39 @@
+- is_empty_state = @issues.blank?
+- service_desk_enabled = @project.service_desk_enabled?
+
+- callout_selector = is_empty_state ? 'empty-state' : 'non-empty-state media'
+- svg_path = !is_empty_state ? 'shared/empty_states/icons/service_desk_callout.svg' : 'shared/empty_states/icons/service_desk_empty_state.svg'
+- can_edit_project_settings = can?(current_user, :admin_project, @project)
+- title_text = _("Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab")
+
+- if Gitlab::ServiceDesk.supported?
+ %div{ class: "#{callout_selector}" }
+ .svg-content
+ = render svg_path
+
+ %div{ class: is_empty_state ? "text-content" : "prepend-top-10 gl-ml-3" }
+ - if is_empty_state
+ %h4= title_text
+ - else
+ %h5= title_text
+
+ - if can_edit_project_settings && service_desk_enabled
+ %p
+ = _("Have your users email")
+ %code= @project.service_desk_address
+
+ %span= _("Those emails automatically become issues (with the comments becoming the email conversation) listed here.")
+ = link_to _('Read more'), help_page_path('user/project/service_desk')
+
+ - if can_edit_project_settings && !service_desk_enabled
+ %div{ class: is_empty_state ? "text-center" : "prepend-top-10" }
+ = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'btn btn-success'
+- else
+ .empty-state
+ .svg-content
+ = render 'shared/empty_states/icons/service_desk_setup.svg'
+ .text-content
+ %h4= _('Service Desk is enabled but not yet active')
+ %p
+ = _("You must set up incoming email before it becomes active.")
+ = link_to _('More information'), help_page_path('administration/incoming_email', anchor: 'set-it-up')
diff --git a/app/views/projects/issues/edit.html.haml b/app/views/projects/issues/edit.html.haml
index 1b7d878c38c..353ff9c1cc2 100644
--- a/app/views/projects/issues/edit.html.haml
+++ b/app/views/projects/issues/edit.html.haml
@@ -1,4 +1,4 @@
-- page_title "Edit", "#{@issue.title} (#{@issue.to_reference})", "Issues"
+- page_title _("Edit"), "#{@issue.title} (#{@issue.to_reference})", _("Issues")
%h3.page-title
Edit Issue ##{@issue.iid}
diff --git a/app/views/projects/issues/export_csv/_modal.html.haml b/app/views/projects/issues/export_csv/_modal.html.haml
index 9fdeb901b56..342c3ba27bb 100644
--- a/app/views/projects/issues/export_csv/_modal.html.haml
+++ b/app/views/projects/issues/export_csv/_modal.html.haml
@@ -12,7 +12,7 @@
.modal-body
.modal-subheader
= icon('check', { class: 'checkmark' })
- %strong.prepend-left-10
+ %strong.gl-ml-3
- issues_count = issuables_count_for_state(:issues, params[:state])
= n_('%d issue selected', '%d issues selected', issues_count) % issues_count
.modal-text
diff --git a/app/views/projects/issues/import_csv/_button.html.haml b/app/views/projects/issues/import_csv/_button.html.haml
index 7119b22daef..ea8f53f7342 100644
--- a/app/views/projects/issues/import_csv/_button.html.haml
+++ b/app/views/projects/issues/import_csv/_button.html.haml
@@ -3,7 +3,7 @@
.dropdown.btn-group
%button.btn.rounded-right.text-center{ class: ('has-tooltip' if type == :icon), title: (_('Import issues') if type == :icon),
- data: { toggle: 'dropdown' }, 'aria-label' => _('Import issues'), 'aria-haspopup' => 'true', 'aria-expanded' => 'false' }
+ data: { toggle: 'dropdown', qa_selector: 'import_issues_button' }, 'aria-label' => _('Import issues'), 'aria-haspopup' => 'true', 'aria-expanded' => 'false' }
- if type == :icon
= sprite_icon('import')
- else
@@ -13,4 +13,5 @@
%button{ data: { toggle: 'modal', target: '.issues-import-modal' } }
= _('Import CSV')
- if can_edit
- %li= link_to _('Import from Jira'), project_import_jira_path(@project)
+ %li{ data: { qa_selector: 'import_from_jira_link' } }
+ = link_to _('Import from Jira'), project_import_jira_path(@project)
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 826a62e39d3..cfc423da57a 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -1,6 +1,6 @@
- @can_bulk_update = can?(current_user, :admin_issue, @project)
-- page_title "Issues"
+- page_title _("Issues")
- new_issue_email = @project.new_issuable_address(current_user, 'issue')
= content_for :meta_tags do
diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml
new file mode 100644
index 00000000000..9b0b3ebc9e0
--- /dev/null
+++ b/app/views/projects/issues/service_desk.html.haml
@@ -0,0 +1,21 @@
+- @can_bulk_update = false
+
+- page_title _("Service Desk")
+
+- content_for :breadcrumbs_extra do
+ = render "projects/issues/nav_btns", show_export_button: false, show_rss_button: false
+
+- support_bot_attrs = UserSerializer.new.represent(User.support_bot).to_json
+
+%div{ class: "js-service-desk-issues service-desk-issues", data: { support_bot: support_bot_attrs } }
+ .top-area
+ = render 'shared/issuable/nav', type: :issues
+ .nav-controls.d-block.d-sm-none
+ = render "projects/issues/nav_btns", show_feed_buttons: false, show_import_button: false, show_export_button: false
+
+ - if @issues.present?
+ = render 'shared/issuable/search_bar', type: :issues
+ = render 'service_desk_info_content'
+
+ .issues-holder
+ = render 'projects/issues/issues', empty_state_path: 'service_desk_info_content'
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 4d24b510267..2a0dc5e30b9 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -11,7 +11,7 @@
- can_create_issue = show_new_issue_link?(@project)
= render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user
-= render_if_exists "projects/issues/alert_moved_from_service_desk", issue: @issue
+= render "projects/issues/alert_moved_from_service_desk", issue: @issue
.detail-page-header
.detail-page-header-body
@@ -24,14 +24,11 @@
%span.d-none.d-sm-block Open
.issuable-meta
- - if @issue.confidential
- .issuable-warning-icon.inline= sprite_icon('eye-slash', size: 16, css_class: 'icon')
- - if @issue.discussion_locked?
- .issuable-warning-icon.inline= sprite_icon('lock', size: 16, css_class: 'icon')
+ #js-issuable-header-warnings
= issuable_meta(@issue, @project, "Issue")
%a.btn.btn-default.float-right.d-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
- = icon('angle-double-left')
+ = sprite_icon('chevron-double-lg-left')
.detail-page-header-actions.js-issuable-actions.js-issuable-buttons{ data: { "action": "close-reopen" } }
.clearfix.issue-btn-group.dropdown
@@ -77,6 +74,9 @@
- if @issue.sentry_issue.present?
#js-sentry-error-stack-trace{ data: error_details_data(@project, @issue.sentry_issue.sentry_issue_identifier) }
+ - if Feature.enabled?(:design_management_moved, @project, default_enabled: true)
+ = render 'projects/issues/design_management'
+
= render_if_exists 'projects/issues/related_issues'
#js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
@@ -86,14 +86,17 @@
-# This element is filled in using JavaScript.
.content-block.emoji-block.emoji-block-sticky
- .row
- .col-md-12.col-lg-4.js-noteable-awards
+ .row.gl-m-0.gl-justify-content-space-between
+ .js-noteable-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true
- .col-md-12.col-lg-8.new-branch-col
+ .new-branch-col
#js-vue-sort-issue-discussions
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } }
= render 'new_branch' if show_new_branch_button?
- = render 'projects/issues/tabs'
+ - if Feature.enabled?(:design_management_moved, @project, default_enabled: true)
+ = render 'projects/issues/discussion'
+ - else
+ = render 'projects/issues/tabs'
= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
index 5acb2af08e4..4f537ee8014 100644
--- a/app/views/projects/jobs/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -1,4 +1,4 @@
-- page_title "Jobs"
+- page_title _("Jobs")
.top-area
- build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index 2e322c7db23..df98a1c7cce 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -5,4 +5,6 @@
- content_for :page_specific_javascripts do
= stylesheet_link_tag 'page_bundles/xterm'
+= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
+
#js-job-vue-app{ data: jobs_data }
diff --git a/app/views/projects/jobs/terminal.html.haml b/app/views/projects/jobs/terminal.html.haml
index 5439a4b5d5c..01f40543926 100644
--- a/app/views/projects/jobs/terminal.html.haml
+++ b/app/views/projects/jobs/terminal.html.haml
@@ -1,7 +1,7 @@
-- add_to_breadcrumbs 'Jobs', project_jobs_path(@project)
+- add_to_breadcrumbs _('Jobs'), project_jobs_path(@project)
- add_to_breadcrumbs "##{@build.id}", project_job_path(@project, @build)
-- breadcrumb_title 'Terminal'
-- page_title 'Terminal', "#{@build.name} (##{@build.id})", 'Jobs'
+- breadcrumb_title _('Terminal')
+- page_title _('Terminal'), "#{@build.name} (##{@build.id})", _('Jobs')
- content_for :page_specific_javascripts do
= stylesheet_link_tag "xterm.css"
diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml
index b7996f0dad1..343900359b4 100644
--- a/app/views/projects/labels/edit.html.haml
+++ b/app/views/projects/labels/edit.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs "Labels", project_labels_path(@project)
-- breadcrumb_title "Edit"
-- page_title "Edit", @label.name, "Labels"
+- add_to_breadcrumbs _("Labels"), project_labels_path(@project)
+- breadcrumb_title _("Edit")
+- page_title _("Edit"), @label.name, _("Labels")
%h3.page-title
Edit Label
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 760d81136c6..ba47712211d 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -1,4 +1,4 @@
-- page_title "Labels"
+- page_title _("Labels")
- can_admin_label = can?(current_user, :admin_label, @project)
- search = params[:search]
- subscribed = params[:subscribed]
@@ -52,5 +52,5 @@
= render 'shared/empty_states/labels'
%template#js-badge-item-template
- %li.label-link-item.js-priority-badge.inline.prepend-left-10
+ %li.label-link-item.js-priority-badge.inline.gl-ml-3
.label-badge.label-badge-blue= _('Prioritized label')
diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml
index 96ce0eba2c6..38bd6102437 100644
--- a/app/views/projects/labels/new.html.haml
+++ b/app/views/projects/labels/new.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs "Labels", project_labels_path(@project)
-- breadcrumb_title "New"
-- page_title "New Label"
+- add_to_breadcrumbs _("Labels"), project_labels_path(@project)
+- breadcrumb_title _("New")
+- page_title _("New Label")
%h3.page-title
New Label
diff --git a/app/views/projects/merge_requests/_approvals_count.html.haml b/app/views/projects/merge_requests/_approvals_count.html.haml
new file mode 100644
index 00000000000..464cba1bb2d
--- /dev/null
+++ b/app/views/projects/merge_requests/_approvals_count.html.haml
@@ -0,0 +1,13 @@
+- merge_request = local_assigns.fetch(:merge_request)
+- self_approved = merge_request.approved_by?(current_user)
+- total = merge_request.approvals.size
+
+- if total > 0
+ - final_text = n_("%d approver", "%d approvers", total) % total
+ - final_self_text = n_("%d approver (you've approved)", "%d approvers (you've approved)", total) % total
+
+ - approval_icon = sprite_icon((self_approved ? 'approval-solid' : 'approval'), size: 16, css_class: 'align-middle')
+
+ %li.d-none.d-sm-inline-block.has-tooltip.text-success{ title: self_approved ? final_self_text : final_text }
+ = approval_icon
+ = _("Approved")
diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml
index 3303aa72604..ecb51aca847 100644
--- a/app/views/projects/merge_requests/_discussion.html.haml
+++ b/app/views/projects/merge_requests/_discussion.html.haml
@@ -5,7 +5,7 @@
- if @merge_request.reopenable?
= link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-close js-note-target-reopen", title: "Reopen merge request", data: { original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
%comment-and-resolve-btn{ "inline-template" => true }
- %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } }
+ %button.btn.btn-nr.btn-default.gl-mr-3.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } }
{{ buttonText }}
#notes= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index a753ee50c43..d3e98bac7f9 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -55,7 +55,7 @@
- if merge_request.assignees.any?
%li.d-flex
= render 'shared/issuable/assignees', project: merge_request.project, issuable: merge_request
- = render_if_exists 'projects/merge_requests/approvals_count', merge_request: merge_request
+ = render 'projects/merge_requests/approvals_count', merge_request: merge_request
= render 'shared/issuable_meta_data', issuable: merge_request
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index d1e8dc3a834..72931448432 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -20,7 +20,7 @@
= issuable_meta(@merge_request, @project, "Merge request")
%a.btn.btn-default.float-right.d-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
- = icon('angle-double-left')
+ = sprite_icon('chevron-double-lg-left')
.detail-page-header-actions.js-issuable-actions
.clearfix.issue-btn-group.dropdown
diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml
index b7498216334..2ef10365c18 100644
--- a/app/views/projects/merge_requests/_nav_btns.html.haml
+++ b/app/views/projects/merge_requests/_nav_btns.html.haml
@@ -1,5 +1,5 @@
- if @can_bulk_update
- = button_tag "Edit merge requests", class: "btn append-right-10 js-bulk-update-toggle"
+ = button_tag "Edit merge requests", class: "btn gl-mr-3 js-bulk-update-toggle"
- if merge_project
= link_to new_merge_request_path, class: "btn btn-success", title: "New merge request" do
New merge request
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 6aba5c98d52..16b08cbf648 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -7,10 +7,13 @@
window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)}
window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}';
- window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}';
+ window.gl.mrWidgetData.ci_troubleshooting_docs_path = '#{help_page_path('ci/troubleshooting.md')}';
+ window.gl.mrWidgetData.mr_troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}';
window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}';
- window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}';
+ window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests-ultimate')}';
window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}';
+ window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/merge_request_approvals")}';
window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}';
+ window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}';
#js-vue-mr-widget.mr-widget
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index d933675eac5..6c23661fb86 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -1,4 +1,4 @@
-- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
+- page_title _("Merge Conflicts"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests")
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
= render "projects/merge_requests/mr_title"
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 0fb4d9ae70f..fdf0bfe8e50 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -20,8 +20,8 @@
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
.merge-request-tabs-container
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
+ .fade-left= sprite_icon('chevron-lg-left', size: 12)
+ .fade-right= sprite_icon('chevron-lg-right', size: 12)
%ul.merge-request-tabs.nav.nav-tabs.nav-links.no-top.no-bottom.js-tabs-affix
%li.commits-tab.new-tab
= link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tabvue'} do
diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml
index 0f618826305..ad4980fa57f 100644
--- a/app/views/projects/merge_requests/creations/new.html.haml
+++ b/app/views/projects/merge_requests/creations/new.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project)
-- breadcrumb_title "New"
-- page_title "New Merge Request"
+- add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project)
+- breadcrumb_title _("New")
+- page_title _("New Merge Request")
- if @merge_request.can_be_created && !params[:change_branches]
= render 'new_submit'
diff --git a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml
index 066c8d5dba6..efc052ca791 100644
--- a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml
+++ b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml
@@ -3,7 +3,7 @@
- `assets/javascripts/diffs/components/commit_widget.vue`
-#-----------------------------------------------------------------
- if @commit
- .info-well.d-none.d-sm-block.prepend-top-default
+ .info-well.d-none.d-sm-block.gl-mt-3
.well-segment
%ul.blob-commit-info
= render 'projects/commits/commit', commit: @commit, merge_request: @merge_request, view_details: true
diff --git a/app/views/projects/merge_requests/edit.html.haml b/app/views/projects/merge_requests/edit.html.haml
index 318c9d809c1..a4bb790ce0b 100644
--- a/app/views/projects/merge_requests/edit.html.haml
+++ b/app/views/projects/merge_requests/edit.html.haml
@@ -1,4 +1,4 @@
-- page_title "Edit", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
+- page_title _("Edit"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests")
%h3.page-title
Edit Merge Request #{@merge_request.to_reference}
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 4e30f09b9a2..36b1cf0796f 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -2,7 +2,7 @@
- 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"
+- page_title _("Merge Requests")
- new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request')
= render 'projects/last_push'
diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml
index 749228a9664..7b831aa2d01 100644
--- a/app/views/projects/merge_requests/invalid.html.haml
+++ b/app/views/projects/merge_requests/invalid.html.haml
@@ -1,4 +1,4 @@
-- page_title "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
+- page_title "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests")
.merge-request
= render "projects/merge_requests/mr_title"
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 90bc2504cb4..03fa9758587 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -1,14 +1,15 @@
- @gfm_form = true
- @content_class = "limit-container-width" unless fluid_layout
-- add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project)
+- add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project)
- breadcrumb_title @merge_request.to_reference
-- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
+- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge Requests")
- page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes
- suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes')
- number_of_pipelines = @pipelines.size
+- mr_action = j(params[:tab].presence || 'show')
-.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } }
+.merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } }
= render "projects/merge_requests/mr_title"
.merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
@@ -76,9 +77,11 @@
= render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do
- if number_of_pipelines.nonzero?
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
+ - if mr_action === "diffs"
+ - add_page_startup_api_call @endpoint_metadata_url
= render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: { "is-locked": @merge_request.discussion_locked?,
endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
- endpoint_metadata: diffs_metadata_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
+ endpoint_metadata: @endpoint_metadata_url,
endpoint_batch: diffs_batch_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
endpoint_coverage: @coverage_path,
help_page_path: suggest_changes_help_path,
@@ -88,7 +91,8 @@
is_fluid_layout: fluid_layout.to_s,
dismiss_endpoint: user_callouts_path,
show_suggest_popover: show_suggest_popover?.to_s,
- show_whitespace_default: @show_whitespace_default.to_s }
+ show_whitespace_default: @show_whitespace_default.to_s,
+ file_by_file_default: @file_by_file_default.to_s }
.mr-loading-status
.loading.hide
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index a3083fa2081..eeff91f631c 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -7,13 +7,13 @@
.col-form-label.col-sm-2
= f.label :title, _('Title')
.col-sm-10
- = f.text_field :title, maxlength: 255, class: 'qa-milestone-title form-control', required: true, autofocus: true
+ = f.text_field :title, maxlength: 255, class: 'form-control', data: { qa_selector: 'milestone_title_field' }, required: true, autofocus: true
.form-group.row.milestone-description
.col-form-label.col-sm-2
= f.label :description, _('Description')
.col-sm-10
= render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project) } do
- = render 'shared/zen', f: f, attr: :description, classes: 'qa-milestone-description note-textarea', placeholder: _('Write milestone description...')
+ = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', qa_selector: 'milestone_description_field', placeholder: _('Write milestone description...')
= render 'shared/notes/hints'
.clearfix
.error-alert
@@ -21,7 +21,7 @@
.form-actions
- if @milestone.new_record?
- = f.submit _('Create milestone'), class: 'btn-success btn qa-milestone-create-button'
+ = f.submit _('Create milestone'), class: 'btn-success btn', data: { qa_selector: 'create_milestone_button' }
= link_to _('Cancel'), project_milestones_path(@project), class: 'btn btn-cancel'
- else
= f.submit _('Save changes'), class: 'btn-success btn'
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index c89566dac90..2bab2a0fb03 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -7,7 +7,7 @@
= render 'shared/milestones/search_form'
= render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestone, @project)
- = link_to new_project_milestone_path(@project), class: 'btn btn-success qa-new-project-milestone', title: _('New milestone') do
+ = link_to new_project_milestone_path(@project), class: 'btn btn-success', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
= _('New milestone')
.milestones
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index b83204c27e3..5239af82ba6 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -9,10 +9,10 @@
= render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project
- if can?(current_user, :read_issue, @project) && @milestone.total_issues_count.zero?
- .alert.alert-success.prepend-top-default
+ .alert.alert-success.gl-mt-3
%span= _('Assign some issues to this milestone.')
- elsif @milestone.complete? && @milestone.active?
- .alert.alert-success.prepend-top-default
+ .alert.alert-success.gl-mt-3
%span= _('All issues for this milestone are closed. You may close this milestone now.')
= render 'shared/milestones/tabs', milestone: @milestone
diff --git a/app/views/projects/mirrors/_instructions.html.haml b/app/views/projects/mirrors/_instructions.html.haml
index 7ff6c0a2019..15c9076c1ab 100644
--- a/app/views/projects/mirrors/_instructions.html.haml
+++ b/app/views/projects/mirrors/_instructions.html.haml
@@ -1,4 +1,4 @@
-.account-well.prepend-top-default.append-bottom-default
+.account-well.gl-mt-3.gl-mb-3
%ul
%li
= _('The repository must be accessible over <code>http://</code>,
diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml
index 90236dc0c48..236ede32d31 100644
--- a/app/views/projects/mirrors/_ssh_host_keys.html.haml
+++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml
@@ -3,7 +3,7 @@
- verified_at = mirror.ssh_known_hosts_verified_at
.form-group.js-ssh-host-keys-section{ class: ('collapse' unless mirror.ssh_mirror_url?) }
- %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.append-right-10{ type: 'button', data: { qa_selector: 'detect_host_keys' } }
+ %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.gl-mr-3{ type: 'button', data: { qa_selector: 'detect_host_keys' } }
.js-spinner.d-none.spinner.mr-1
= _('Detect host keys')
.fingerprint-ssh-info.js-fingerprint-ssh-info.prepend-top-10.append-bottom-10{ class: ('collapse' unless mirror.ssh_mirror_url?) }
@@ -28,6 +28,6 @@
= _('Input host keys manually')
%span.label-hide
= _('Hide host keys manual input')
- .js-ssh-known-hosts.collapse.prepend-top-default
+ .js-ssh-known-hosts.collapse.gl-mt-3
= f.label :ssh_known_hosts, _('SSH host keys'), class: 'label-bold'
= f.text_area :ssh_known_hosts, class: 'form-control known-hosts js-known-hosts', rows: '10'
diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml
index 6821453cffa..d134bfb488e 100644
--- a/app/views/projects/network/show.html.haml
+++ b/app/views/projects/network/show.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title "Graph"
-- page_title "Graph", @ref
+- breadcrumb_title _("Graph")
+- page_title _("Graph"), @ref
= render "head"
%div{ class: container_class }
.project-network
@@ -16,5 +16,5 @@
- if @commit
.network-graph{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } }
- .text-center.prepend-top-default
+ .text-center.gl-mt-3
.spinner.spinner-md
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 81a778f76f4..d5099f80ea4 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -4,7 +4,7 @@
- header_title _("Projects"), dashboard_projects_path
- active_tab = local_assigns.fetch(:active_tab, 'blank')
-.project-edit-container.prepend-top-default
+.project-edit-container.gl-mt-3
.project-edit-errors
= render 'projects/errors'
@@ -16,7 +16,7 @@
%h4.gl-mt-0
= _('New project')
%p
- - among_other_things_link = link_to _('among other things'), help_page_path("user/project/index.md", anchor: "projects-features"), target: '_blank'
+ - among_other_things_link = link_to _('among other things'), help_page_path("user/project/index.md", anchor: "project-features"), target: '_blank'
= _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link }
%p
= _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.')
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index 08772a0188b..d5030a02cdd 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -1,4 +1,5 @@
- breadcrumb_title _("Details")
+- page_title _("Details")
%h2
%i.fa.fa-warning
@@ -14,7 +15,7 @@
= link_to project_repository_path(@project), method: :post, class: 'btn btn-primary' do
#{ _('Create empty repository') }
- %strong.prepend-left-10.append-right-10 or
+ %strong.gl-ml-3.gl-mr-3 or
= link_to new_project_import_path(@project), class: 'btn' do
#{ _('Import repository') }
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index 7de7dd3b98b..d725098752d 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -45,7 +45,7 @@
- if note_editable
.note-actions-item
- = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do
+ = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body', qa_selector: 'edit_comment_button' } do
%span.link-highlight
= custom_icon('icon_pencil')
diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml
index 2f0394538bb..8cf1b6b9294 100644
--- a/app/views/projects/notes/_more_actions_dropdown.html.haml
+++ b/app/views/projects/notes/_more_actions_dropdown.html.haml
@@ -2,7 +2,7 @@
- if note_editable || !is_current_user
.dropdown.more-actions.note-actions-item
- = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn btn-transparent', data: { toggle: 'dropdown', container: 'body' } do
+ = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn btn-transparent', data: { toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' } do
%span.icon
= custom_icon('ellipsis_v')
%ul.dropdown-menu.more-actions-dropdown.dropdown-open-left
@@ -14,6 +14,6 @@
= _('Report abuse to admin')
- if note_editable
%li
- = link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?' }, remote: true, class: 'js-note-delete' do
+ = link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?', qa_selector: 'delete_comment_button' }, remote: true, class: 'js-note-delete' do
%span.text-danger
= _('Delete comment')
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index 4b7810ea357..fc69b390bde 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -1,4 +1,4 @@
-- page_title 'Pages'
+- page_title _('Pages')
- if @project.pages_enabled?
%h3.page-title.with-button
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index 8d88f0be083..f48763cb544 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -27,8 +27,8 @@
%td
.float-right.btn-group
- if can?(current_user, :play_pipeline_schedule, pipeline_schedule)
- = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn' do
- = icon('play')
+ = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn btn-svg gl-display-flex gl-align-items-center gl-justify-content-center' do
+ = sprite_icon('play')
- if can?(current_user, :take_ownership_pipeline_schedule, pipeline_schedule)
= link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do
= s_('PipelineSchedules|Take ownership')
diff --git a/app/views/projects/pipelines/_stage.html.haml b/app/views/projects/pipelines/_stage.html.haml
index 3feb99cfcd7..0651ad6fdb8 100644
--- a/app/views/projects/pipelines/_stage.html.haml
+++ b/app/views/projects/pipelines/_stage.html.haml
@@ -1,5 +1,5 @@
- grouped_statuses = @stage.statuses.latest_ordered.group_by(&:status)
-- HasStatus::ORDERED_STATUSES.each do |ordered_status|
+- Ci::HasStatus::ORDERED_STATUSES.each do |ordered_status|
- grouped_statuses.fetch(ordered_status, []).each do |status|
%li
= render 'ci/status/dropdown_graph_badge', subject: status
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 92edde034a6..590ae72a2ff 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -24,7 +24,7 @@
%li.js-tests-tab-link
= link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do
= s_('TestReports|Tests')
- %span.badge.badge-pill.js-test-report-badge-counter
+ %span.badge.badge-pill.js-test-report-badge-counter= Feature.enabled?(:build_report_summary, @project) ? @pipeline.test_report_summary.total_count : ''
= render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project
.tab-content
@@ -83,8 +83,10 @@
- if dag_pipeline_tab_enabled
#js-tab-dag.tab-pane
- #js-pipeline-dag-vue{ data: { pipeline_data_path: dag_project_pipeline_path(@project, @pipeline) } }
+ #js-pipeline-dag-vue{ data: { pipeline_data_path: dag_project_pipeline_path(@project, @pipeline), empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} }
#js-tab-tests.tab-pane
- #js-pipeline-tests-detail
+ #js-pipeline-tests-detail{ data: { full_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json),
+ summary_endpoint: Feature.enabled?(:build_report_summary, @project) ? summary_project_pipeline_tests_path(@project, @pipeline, format: :json) : '',
+ count_endpoint: test_reports_count_project_pipeline_path(@project, @pipeline, format: :json) } }
= render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index fa4a77a692a..05f8a126a02 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -7,6 +7,7 @@
params: params.to_json,
"help-page-path" => help_page_path('ci/quick_start/README'),
"help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
+ "pipeline-schedule-url" => pipeline_schedules_path(@project),
"empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
"error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
"no-pipelines-svg-path" => image_path('illustrations/pipelines_pending.svg'),
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index f39968eecef..2b2133b8296 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -20,6 +20,4 @@
- else
= render "projects/pipelines/with_tabs", pipeline: @pipeline
-.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json),
- test_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json),
- test_reports_count_endpoint: test_reports_count_project_pipeline_path(@project, @pipeline, format: :json) } }
+.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } }
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index c24a9061146..ba964e5cd37 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -1,7 +1,8 @@
- page_title _("Members")
- can_admin_project_members = can?(current_user, :admin_project_member, @project)
-.row.prepend-top-default
+.js-remove-member-modal
+.row.gl-mt-3
.col-lg-12
- if project_can_be_shared?
%h4
diff --git a/app/views/projects/project_templates/_built_in_templates.html.haml b/app/views/projects/project_templates/_built_in_templates.html.haml
index eb41a3e0785..43352952b37 100644
--- a/app/views/projects/project_templates/_built_in_templates.html.haml
+++ b/app/views/projects/project_templates/_built_in_templates.html.haml
@@ -1,6 +1,6 @@
- Gitlab::ProjectTemplate.all.each do |template|
.template-option.d-flex.align-items-center{ data: { qa_selector: 'template_option_row' } }
- .logo.append-right-10.px-1
+ .logo.gl-mr-3.px-1
= image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}"
.description
%strong
@@ -9,7 +9,7 @@
.text-muted
= template.description
.controls.d-flex.align-items-center
- %a.btn.btn-default.append-right-10{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_event: "click_button", track_value: "" } }
+ %a.btn.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_event: "click_button", track_value: "" } }
= _("Preview")
%label.btn.btn-success.template-button.choose-template.gl-mb-0{ for: template.name }
%input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_event: "click_button", track_value: "" } }
diff --git a/app/views/projects/project_templates/_project_fields_form.html.haml b/app/views/projects/project_templates/_project_fields_form.html.haml
index c96010550d8..201e2d5b5fb 100644
--- a/app/views/projects/project_templates/_project_fields_form.html.haml
+++ b/app/views/projects/project_templates/_project_fields_form.html.haml
@@ -5,7 +5,7 @@
.input-group.template-input-group
.input-group-prepend
.input-group-text
- .selected-icon.append-right-10
+ .selected-icon.gl-mr-3
.selected-template
.input-group-append
%button.btn.btn-default.change-template{ type: "button" }
diff --git a/app/views/projects/protected_branches/shared/_matching_branch.html.haml b/app/views/projects/protected_branches/shared/_matching_branch.html.haml
index 2c76bf87945..9145be5d2f2 100644
--- a/app/views/projects/protected_branches/shared/_matching_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_matching_branch.html.haml
@@ -3,7 +3,7 @@
= link_to matching_branch.name, project_ref_path(@project, matching_branch.name), class: 'ref-name'
- if @project.root_ref?(matching_branch.name)
- %span.badge.badge-info.prepend-left-5 default
+ %span.badge.badge-info.gl-ml-2 default
%td
- commit = @project.commit(matching_branch.name)
= link_to(commit.short_id, project_commit_path(@project, commit.id), class: 'commit-sha')
diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml
index ffaf118a5e3..c671757a603 100644
--- a/app/views/projects/protected_branches/show.html.haml
+++ b/app/views/projects/protected_branches/show.html.haml
@@ -1,6 +1,6 @@
-- page_title @protected_ref.name, "Protected Branches"
+- page_title @protected_ref.name, _("Protected Branches")
-.row.prepend-top-default.append-bottom-default
+.row.gl-mt-3.gl-mb-3
.col-lg-3
%h4.gl-mt-0.ref-name
= @protected_ref.name
diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml
index f53b81cada6..d19a6401fc8 100644
--- a/app/views/projects/protected_tags/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -3,6 +3,7 @@
= dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-create wide',
dropdown_class: 'dropdown-menu-selectable capitalize-header',
- data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }})
+ dropdown_qa_selector: 'access_levels_content',
+ data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes', qa_selector: 'access_levels_dropdown' }})
= render 'projects/protected_tags/shared/create_protected_tag'
diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
index 020e6e187a6..8a6ae53a7c4 100644
--- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
@@ -25,4 +25,4 @@
= yield :create_access_levels
.card-footer
- = f.submit 'Protect', class: 'btn-success btn', disabled: true
+ = f.submit 'Protect', class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_tag_button' }
diff --git a/app/views/projects/protected_tags/shared/_dropdown.html.haml b/app/views/projects/protected_tags/shared/_dropdown.html.haml
index 824a8604f6f..9c7f532fa29 100644
--- a/app/views/projects/protected_tags/shared/_dropdown.html.haml
+++ b/app/views/projects/protected_tags/shared/_dropdown.html.haml
@@ -6,7 +6,7 @@
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
selected: params[:protected_tag_name],
- project_id: @project.try(:id) } }) do
+ project_id: @project.try(:id), qa_selector: 'tags_dropdown' } }) do
%ul.dropdown-footer-list
%li
diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml
index b0c87ac8c17..4bf3ce09fc7 100644
--- a/app/views/projects/protected_tags/shared/_index.html.haml
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -1,6 +1,6 @@
- expanded = expanded_by_default?
-%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded) }
+%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded), data: { qa_selector: 'protected_tag_settings_content' } }
.settings-header
%h4
Protected Tags
diff --git a/app/views/projects/protected_tags/shared/_matching_tag.html.haml b/app/views/projects/protected_tags/shared/_matching_tag.html.haml
index 133c76cd2ad..bf030d36cd6 100644
--- a/app/views/projects/protected_tags/shared/_matching_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_matching_tag.html.haml
@@ -3,7 +3,7 @@
= link_to matching_tag.name, project_ref_path(@project, matching_tag.name), class: 'ref-name'
- if @project.root_ref?(matching_tag.name)
- %span.badge.badge-info.prepend-left-5 default
+ %span.badge.badge-info.gl-ml-2 default
%td
- commit = @project.commit(matching_tag.name)
= link_to(commit.short_id, project_commit_path(@project, commit.id), class: 'commit-sha')
diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
index cc6f0309123..b0563163c9c 100644
--- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
@@ -3,7 +3,7 @@
%span.ref-name= protected_tag.name
- if @project.root_ref?(protected_tag.name)
- %span.badge.badge-info.prepend-left-5 default
+ %span.badge.badge-info.gl-ml-2 default
%td
- if protected_tag.wildcard?
- matching_tags = protected_tag.matching(repository.tags)
diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml
index 6f4535a0b3f..c8052e6ae8d 100644
--- a/app/views/projects/protected_tags/show.html.haml
+++ b/app/views/projects/protected_tags/show.html.haml
@@ -1,6 +1,6 @@
-- page_title @protected_ref.name, "Protected Tags"
+- page_title @protected_ref.name, _("Protected Tags")
-.row.prepend-top-default.append-bottom-default
+.row.gl-mt-3.gl-mb-3
.col-lg-3
%h4.gl-mt-0.ref-name
= @protected_ref.name
diff --git a/app/views/projects/refs/logs_tree.js.haml b/app/views/projects/refs/logs_tree.js.haml
deleted file mode 100644
index 506bf54b3f8..00000000000
--- a/app/views/projects/refs/logs_tree.js.haml
+++ /dev/null
@@ -1,23 +0,0 @@
-- @logs.each do |content_data|
- - file_name = content_data[:file_name]
- - commit = content_data[:commit]
- - next unless commit
-
- :plain
- var row = $("table.table_#{@hex_path} tr.file_#{hexdigest(file_name)}");
- row.find("td.tree-time-ago").html('#{escape_javascript time_ago_with_tooltip(commit.committed_date)}');
- row.find("td.tree-commit").html('#{escape_javascript render("projects/tree/tree_commit_column", commit: commit)}');
-
- = render_if_exists 'projects/refs/logs_tree_lock_label', lock_label: content_data[:lock_label]
-
-- if @more_log_url
- :plain
- if($('#tree-slider').length) {
- // Load more commit logs for each file in tree
- // if we still on the same page
- var url = "#{escape_javascript(@more_log_url)}";
- gl.utils.ajaxGet(url);
- }
-
-:plain
- gl.utils.localTimeAgo($('.js-timeago', 'table.table_#{@hex_path} tbody'));
diff --git a/app/views/projects/releases/new.html.haml b/app/views/projects/releases/new.html.haml
new file mode 100644
index 00000000000..4348035a324
--- /dev/null
+++ b/app/views/projects/releases/new.html.haml
@@ -0,0 +1,3 @@
+- page_title s_('Releases|New Release')
+
+#js-new-release-page{ data: data_for_new_release_page }
diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml
index 2f1da453c0a..b21965915a2 100644
--- a/app/views/projects/serverless/functions/index.html.haml
+++ b/app/views/projects/serverless/functions/index.html.haml
@@ -1,6 +1,6 @@
- @content_class = "limit-container-width" unless fluid_layout
-- breadcrumb_title 'Serverless'
-- page_title 'Serverless'
+- breadcrumb_title _('Serverless')
+- page_title _('Serverless')
- status_path = project_serverless_functions_path(@project, format: :json)
- clusters_path = project_clusters_path(@project)
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index e6761807409..2e49e74a9b3 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -1,4 +1,7 @@
-.row.prepend-top-default.append-bottom-default
+- if lookup_context.template_exists?('top', "projects/services/#{@service.to_param}", true)
+ = render "projects/services/#{@service.to_param}/top"
+
+.row.gl-mt-3.gl-mb-3
.col-lg-4
%h4.gl-mt-0
= @service.title
@@ -11,10 +14,10 @@
%p= @service.detailed_description
.col-lg-8
= form_for(@service, as: :service, url: scoped_integration_path(@service), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form|
- = render 'shared/service_settings', form: form, service: @service
- .footer-block.row-content-block
+ = render 'shared/service_settings', form: form, integration: @service
+ .footer-block.row-content-block{ :class => "#{'gl-display-none' if @service.is_a?(AlertsService)}" }
%input{ id: 'services_redirect_to', type: 'hidden', name: 'redirect_to', value: request.referrer }
- = service_save_button
+ = service_save_button(disabled: @service.is_a?(AlertsService))
&nbsp;
= link_to _('Cancel'), project_settings_integrations_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/services/alerts/_help.html.haml b/app/views/projects/services/alerts/_help.html.haml
index 4b09d1d9d0e..7abd198bea5 100644
--- a/app/views/projects/services/alerts/_help.html.haml
+++ b/app/views/projects/services/alerts/_help.html.haml
@@ -1,6 +1 @@
-.js-alerts-service-settings{ data: { activated: @service.activated?.to_s,
- form_path: scoped_integration_path(@service),
- authorization_key: @service.token,
- url: @service.url || _('<namespace / project>'),
- alerts_setup_url: help_page_path('user/project/integrations/generic_alerts.html', anchor: 'setting-up-generic-alerts'),
- alerts_usage_url: help_page_path('user/project/operations/alert_management.html') } }
+.js-alerts-service-settings{ data: alerts_settings_data(disabled: true) }
diff --git a/app/views/projects/services/alerts/_top.html.haml b/app/views/projects/services/alerts/_top.html.haml
new file mode 100644
index 00000000000..ebc93978832
--- /dev/null
+++ b/app/views/projects/services/alerts/_top.html.haml
@@ -0,0 +1,8 @@
+.row
+ .col-lg-12
+ .gl-alert.gl-alert-info.js-alerts-moved-alert{ role: 'alert' }
+ = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ = _('You can now manage alert endpoint configuration in the Alerts section on the Operations settings page. Fields on this page have been deprecated.')
+ .gl-alert-actions
+ = link_to _('Visit settings page'), project_settings_operations_path(@project, anchor: 'js-alert-management-settings'), class: 'btn gl-alert-action btn-info new-gl-button'
diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml
index dfcb1c5d240..b4e8458d8b9 100644
--- a/app/views/projects/services/prometheus/_configuration_banner.html.haml
+++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml
@@ -12,14 +12,14 @@
.svg-container
= image_tag 'illustrations/monitoring/getting_started.svg'
.col-sm-10
- %p.text-success.prepend-top-default
+ %p.text-success.gl-mt-3
= s_('PrometheusService|Prometheus is being automatically managed on your clusters')
= link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn'
- else
.col-sm-2
= image_tag 'illustrations/monitoring/loading.svg'
.col-sm-10
- %p.prepend-top-default
+ %p.gl-mt-3
= s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments')
= link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn btn-success'
diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml
index 210d0f37d65..3642460467b 100644
--- a/app/views/projects/services/prometheus/_custom_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml
@@ -3,7 +3,7 @@
.col-lg-3
%p
= s_('PrometheusService|Custom metrics require Prometheus installed on a cluster with environment scope "*" OR a manually configured Prometheus to be available.')
- = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus', anchor: 'adding-custom-metrics'), target: '_blank', rel: "noopener noreferrer"
+ = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/index.md', anchor: 'adding-custom-metrics'), target: '_blank', rel: "noopener noreferrer"
.col-lg-9
.card.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { qa_selector: 'custom_metrics_container', active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{@service.active}" } }
diff --git a/app/views/projects/services/prometheus/_external_alerts.html.haml b/app/views/projects/services/prometheus/_external_alerts.html.haml
index 24ff0cc88a3..b27b1ab8723 100644
--- a/app/views/projects/services/prometheus/_external_alerts.html.haml
+++ b/app/views/projects/services/prometheus/_external_alerts.html.haml
@@ -3,6 +3,6 @@
- notify_url = notify_project_prometheus_alerts_url(@project, format: :json)
- authorization_key = @project.alerting_setting.try(:token)
-- learn_more_url = help_page_path('user/project/integrations/prometheus', anchor: 'external-prometheus-instances')
+- learn_more_url = help_page_path('operations/metrics/alerts.md', anchor: 'external-prometheus-instances')
-#js-settings-prometheus-alerts{ data: { notify_url: notify_url, authorization_key: authorization_key, change_key_url: reset_alerting_token_project_settings_operations_path(@project), learn_more_url: learn_more_url } }
+#js-settings-prometheus-alerts{ data: { notify_url: notify_url, authorization_key: authorization_key, change_key_url: reset_alerting_token_project_settings_operations_path(@project), learn_more_url: learn_more_url, disabled: true } }
diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml
index 1b5b794a7aa..c5b3fd31efa 100644
--- a/app/views/projects/services/prometheus/_help.html.haml
+++ b/app/views/projects/services/prometheus/_help.html.haml
@@ -1,7 +1,7 @@
- if @project
= render 'projects/services/prometheus/configuration_banner', project: @project, service: @service
-%h4.append-bottom-default
+%h4.gl-mb-3
= s_('PrometheusService|Manual configuration')
%p
= s_('PrometheusService|Select the Active checkbox to override the Auto Configuration with custom settings. If unchecked, Auto Configuration settings are used.')
diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml
index 3bd5f69f67e..9f5160f3dd5 100644
--- a/app/views/projects/services/prometheus/_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_metrics.html.haml
@@ -33,5 +33,5 @@
.flash-notice
.flash-text
= s_("PrometheusService|To set up automatic monitoring, add the environment variable %{variable} to exporter's queries." % { variable: "<code>$CI_ENVIRONMENT_SLUG</code>" }).html_safe
- = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus', anchor: 'metrics-and-labels')
+ = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/dashboards/variables.md', anchor: 'query-variables')
%ul.list-unstyled.metrics-list.js-missing-var-metrics-list
diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml
index 728a52f024f..9ce61ed5c13 100644
--- a/app/views/projects/services/prometheus/_show.html.haml
+++ b/app/views/projects/services/prometheus/_show.html.haml
@@ -3,7 +3,7 @@
%h4.gl-mt-0
= s_('PrometheusService|Metrics')
-.row.append-bottom-default.prometheus-metrics-monitoring.js-prometheus-metrics-monitoring
+.row.gl-mb-3.prometheus-metrics-monitoring.js-prometheus-metrics-monitoring
= render 'projects/services/prometheus/metrics', project: @project
= render 'projects/services/prometheus/external_alerts', project: @project
diff --git a/app/views/projects/services/prometheus/_top.html.haml b/app/views/projects/services/prometheus/_top.html.haml
new file mode 100644
index 00000000000..338414be5ab
--- /dev/null
+++ b/app/views/projects/services/prometheus/_top.html.haml
@@ -0,0 +1,10 @@
+- return unless @service.manual_configuration?
+
+.row
+ .col-lg-12
+ .gl-alert.gl-alert-info.js-alerts-moved-alert{ role: 'alert' }
+ = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ = s_('AlertSettings|You can now set up alert endpoints for manually configured Prometheus instances in the Alerts section on the Operations settings page. Alert endpoint fields on this page have been deprecated.')
+ .gl-alert-actions
+ = link_to _('Visit settings page'), project_settings_operations_path(@project, anchor: 'js-alert-management-settings'), class: 'btn gl-alert-action btn-info gl-button'
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
index 5eeebe4160f..3ffa029a25d 100644
--- a/app/views/projects/settings/_general.html.haml
+++ b/app/views/projects/settings/_general.html.haml
@@ -31,7 +31,7 @@
= render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project
- .form-group.prepend-top-default.append-bottom-20
+ .form-group.gl-mt-3.append-bottom-20
.avatar-container.s90
= project_icon(@project, alt: _('Project avatar'), class: 'avatar project-avatar s90')
= f.label :avatar, _('Project avatar'), class: 'label-bold d-block'
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index 092f9c2333c..4992288a8c8 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -4,7 +4,7 @@
- type_plural = _('project access tokens')
- @content_class = 'limit-container-width' unless fluid_layout
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= page_title
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 8b84acb67c1..7284b4bb55d 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -40,18 +40,18 @@
= form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input'
= form.label :deploy_strategy_continuous, class: 'form-check-label' do
= s_('CICD|Continuous deployment to production')
- = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-deploy'), target: '_blank'
+ = link_to icon('question-circle'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-deploy'), target: '_blank'
.form-check
= form.radio_button :deploy_strategy, 'timed_incremental', class: 'form-check-input'
= form.label :deploy_strategy_timed_incremental, class: 'form-check-label' do
= s_('CICD|Continuous deployment to production using timed incremental rollout')
- = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'timed-incremental-rollout-to-production-premium'), target: '_blank'
+ = link_to icon('question-circle'), help_page_path('topics/autodevops/customize.md', anchor: 'timed-incremental-rollout-to-production-premium'), target: '_blank'
.form-check
= form.radio_button :deploy_strategy, 'manual', class: 'form-check-input'
= form.label :deploy_strategy_manual, class: 'form-check-label' do
= s_('CICD|Automatic deployment to staging, manual deployment to production')
- = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'incremental-rollout-to-production-premium'), target: '_blank'
+ = link_to icon('question-circle'), help_page_path('topics/autodevops/customize.md', anchor: 'incremental-rollout-to-production-premium'), target: '_blank'
= f.submit _('Save changes'), class: "btn btn-success prepend-top-15", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index a1809cecafb..e8e5a5f0256 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -1,4 +1,4 @@
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-12
= form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings') do |f|
= form_errors(@project)
@@ -147,5 +147,5 @@
%hr
-.row.prepend-top-default
+.row.gl-mt-3
= 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 4e14426a069..b5452fcca55 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -66,11 +66,11 @@
%section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) }
.settings-header
%h4
- = _("Container Registry tag expiration policy")
- = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'), target: '_blank', rel: 'noopener noreferrer'
+ = _("Cleanup policy for tags")
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = _("Expiration policy for the Container Registry is a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.")
+ = _("Save space and find tags in the Container Registry more easily. Enable the cleanup policy to remove stale tags and keep only the ones you need.")
+ = link_to _('More information'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy', target: '_blank', rel: 'noopener noreferrer')
.settings-content
= render 'projects/registry/settings/index'
diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml
index e7a509abc8b..d9068bde847 100644
--- a/app/views/projects/settings/integrations/show.html.haml
+++ b/app/views/projects/settings/integrations/show.html.haml
@@ -3,7 +3,7 @@
- page_title _('Integrations')
- if show_webhooks_moved_alert?
- .gl-alert.gl-alert-info.js-webhooks-moved-alert.prepend-top-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::WEBHOOKS_MOVED, dismiss_endpoint: user_callouts_path } }
+ .gl-alert.gl-alert-info.js-webhooks-moved-alert.gl-mt-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::WEBHOOKS_MOVED, dismiss_endpoint: user_callouts_path } }
= sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
%button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
= sprite_icon('close', size: 16, css_class: 'gl-icon')
diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml
new file mode 100644
index 00000000000..f8f3ecb6273
--- /dev/null
+++ b/app/views/projects/settings/operations/_alert_management.html.haml
@@ -0,0 +1,14 @@
+- return unless can?(current_user, :admin_operations, @project)
+- expanded = expanded_by_default?
+
+%section.settings.no-animate#js-alert-management-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h3{ :class => "h4" }
+ = _('Alerts')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = _('Expand')
+ %p
+ = _('Display alerts from all your monitoring tools directly within GitLab.')
+ = link_to _('More information'), help_page_path('user/project/operations/alert_management'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ .js-alerts-settings{ data: alerts_settings_data }
diff --git a/app/views/projects/settings/operations/_configuration_banner.html.haml b/app/views/projects/settings/operations/_configuration_banner.html.haml
index bdbc9b7d69d..69bbd0edac7 100644
--- a/app/views/projects/settings/operations/_configuration_banner.html.haml
+++ b/app/views/projects/settings/operations/_configuration_banner.html.haml
@@ -12,13 +12,13 @@
.svg-container
= image_tag 'illustrations/monitoring/getting_started.svg'
.col-sm-10
- %p.text-success.prepend-top-default
+ %p.text-success.gl-mt-3
= s_('PrometheusService|Prometheus is being automatically managed on your clusters')
= link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn'
- else
.col-sm-2
= image_tag 'illustrations/monitoring/loading.svg'
.col-sm-10
- %p.prepend-top-default
+ %p.gl-mt-3
= s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments')
= link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn btn-success'
diff --git a/app/views/projects/settings/operations/_incidents.html.haml b/app/views/projects/settings/operations/_incidents.html.haml
index 3b1b0a00380..e7236cdec69 100644
--- a/app/views/projects/settings/operations/_incidents.html.haml
+++ b/app/views/projects/settings/operations/_incidents.html.haml
@@ -1,32 +1 @@
-- templates = []
-- setting = project_incident_management_setting
-- templates = setting.available_issue_templates.map { |t| [t.name, t.key] }
-
-%section.settings.no-animate.qa-incident-management-settings{ data: { qa_selector: 'incidents_settings_content' } }
- .settings-header
- %h3{ :class => "h4" }= _('Incidents')
- %button.btn.js-settings-toggle{ type: 'button' }
- = _('Expand')
- %p
- = _('Action to take when receiving an alert.')
- = link_to help_page_path('user/project/integrations/prometheus', anchor: 'taking-action-on-incidents-ultimate') do
- = _('More information')
- .settings-content
- = form_for @project, url: project_settings_operations_path(@project), method: :patch do |f|
- = form_errors(@project.incident_management_setting)
- .form-group
- = f.fields_for :incident_management_setting_attributes, setting do |form|
- .form-group
- = form.check_box :create_issue, data: { qa_selector: 'create_issue_checkbox' }
- = form.label :create_issue, _('Create an issue. Issues are created for each alert triggered.'), class: 'form-check-label'
- .form-group.col-sm-8
- = form.label :issue_template_key, class: 'label-bold' do
- = _('Issue template (optional)')
- = link_to icon('question-circle'), help_page_path('user/project/description_templates', anchor: 'creating-issue-templates'), target: '_blank', rel: 'noopener noreferrer'
- .select-wrapper
- = form.select :issue_template_key, templates, {include_blank: 'No template selected'}, class: "form-control select-control", data: { qa_selector: 'incident_templates_dropdown' }
- = icon('chevron-down')
- .form-group
- = form.check_box :send_email
- = form.label :send_email, _('Send a separate email notification to Developers.'), class: 'form-check-label'
- = f.submit _('Save changes'), class: 'btn btn-success', data: { qa_selector: 'save_changes_button' }
+.js-incidents-settings{ data: operations_settings_data }
diff --git a/app/views/projects/settings/operations/_prometheus.html.haml b/app/views/projects/settings/operations/_prometheus.html.haml
index b0fa750e131..7ccc829662d 100644
--- a/app/views/projects/settings/operations/_prometheus.html.haml
+++ b/app/views/projects/settings/operations/_prometheus.html.haml
@@ -11,7 +11,7 @@
- if @project
= render 'projects/settings/operations/configuration_banner', project: @project, service: service
- %b.append-bottom-default
+ %b.gl-mb-3
= s_('PrometheusService|Manual configuration')
%p
= s_('PrometheusService|Select the Active checkbox to override the Auto Configuration with custom settings. If unchecked, Auto Configuration settings are used.')
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 9e4fbf81ca4..103828ee0a0 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -2,6 +2,7 @@
- page_title _('Operations Settings')
- breadcrumb_title _('Operations Settings')
+= render 'projects/settings/operations/alert_management', alerts_service: alerts_service, prometheus_service: prometheus_service
= render 'projects/settings/operations/incidents'
= render 'projects/settings/operations/error_tracking'
= render 'projects/settings/operations/prometheus', service: prometheus_service if Feature.enabled?(:settings_operations_prometheus_service)
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 17bc10af58a..4a521f2f46e 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -1,4 +1,5 @@
- breadcrumb_title _("Details")
+- page_title _("Projects")
- @content_class = "limit-container-width" unless fluid_layout
= content_for :meta_tags do
@@ -6,10 +7,6 @@
= render partial: 'flash_messages', locals: { project: @project }
-- if !@project.empty_repo? && can?(current_user, :download_code, @project) && !vue_file_list_enabled?
- - signatures_path = project_signatures_path(@project, @project.default_branch)
- .js-signature-container{ data: { 'signatures-path': signatures_path } }
-
%div{ class: [("limit-container-width" unless fluid_layout)] }
= render "projects/last_push"
diff --git a/app/views/projects/sidebar/_issues_service_desk.html.haml b/app/views/projects/sidebar/_issues_service_desk.html.haml
new file mode 100644
index 00000000000..2730fe37f28
--- /dev/null
+++ b/app/views/projects/sidebar/_issues_service_desk.html.haml
@@ -0,0 +1,3 @@
+= nav_link(controller: :issues, action: :service_desk ) do
+ = link_to service_desk_project_issues_path(@project), title: 'Service Desk' do
+ = _('Service Desk')
diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml
index 6aedab36e1b..e4645101765 100644
--- a/app/views/projects/snippets/_actions.html.haml
+++ b/app/views/projects/snippets/_actions.html.haml
@@ -14,7 +14,7 @@
= link_to _('Submit as spam'), mark_as_spam_project_snippet_path(@project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam')
- if can?(current_user, :create_snippet, @project) || can?(current_user, :update_snippet, @snippet)
.d-block.d-sm-none.dropdown
- %button.btn.btn-default.btn-block.gl-mb-0.prepend-top-5{ data: { toggle: "dropdown" } }
+ %button.btn.btn-default.btn-block.gl-mb-0.gl-mt-2{ data: { toggle: "dropdown" } }
= _('Options')
= icon('caret-down')
.dropdown-menu.dropdown-menu-full-width
diff --git a/app/views/projects/starrers/_starrer.html.haml b/app/views/projects/starrers/_starrer.html.haml
index 377d62f8abd..d8a2c72d9ce 100644
--- a/app/views/projects/starrers/_starrer.html.haml
+++ b/app/views/projects/starrers/_starrer.html.haml
@@ -13,7 +13,7 @@
%span.cgray= starrer.user.to_reference
- if starrer.user == current_user
- %span.badge.badge-success.prepend-left-5= _("It's you")
+ %span.badge.badge-success.gl-ml-2= _("It's you")
.block-truncated
= time_ago_with_tooltip(starrer.starred_since)
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 79a00b00fa6..59c7d0401d1 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -26,7 +26,7 @@
= _("Release")
= link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'tag-release-link'
- if release.description.present?
- .md.prepend-top-default
+ .md.gl-mt-3
= markdown_field(release, :description)
.row-fixed-content.controls.flex-row
@@ -38,5 +38,5 @@
- if can?(current_user, :admin_tag, @project)
= link_to edit_project_tag_release_path(@project, tag.name), class: 'btn btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do
= icon("pencil")
- = 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
+ = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip gl-ml-3 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do
= icon("trash-o")
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index 6ad7cf1848f..e3d3f2226a8 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -24,7 +24,7 @@
%li
= link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value)
- if can?(current_user, :admin_tag, @project)
- = link_to new_project_tag_path(@project), class: 'btn btn-success new-tag-btn' do
+ = link_to new_project_tag_path(@project), class: 'btn btn-success new-tag-btn', data: { qa_selector: "new_tag_button" } do
= s_('TagsPage|New tag')
= link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn d-none d-sm-inline-block has-tooltip' do
= icon("rss")
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index 5aabfdd022a..c32318df7cc 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -14,7 +14,7 @@
.form-group.row
= label_tag :tag_name, nil, class: 'col-form-label col-sm-2'
.col-sm-10
- = text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control'
+ = text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control', data: { qa_selector: "tag_name_field" }
.form-group.row
= label_tag :ref, 'Create from', class: 'col-form-label col-sm-2'
.col-sm-10.create-from
@@ -29,7 +29,7 @@
.form-group.row
= label_tag :message, nil, class: 'col-form-label col-sm-2'
.col-sm-10
- = text_area_tag :message, @message, required: false, class: 'form-control', rows: 5
+ = text_area_tag :message, @message, required: false, class: 'form-control', rows: 5, data: { qa_selector: "tag_message_field" }
.form-text.text-muted
= tag_description_help_text
%hr
@@ -40,17 +40,17 @@
- link_start = '<a href="%{url}" rel="noopener noreferrer" target="_blank">'.html_safe
- releases_page_path = project_releases_path(@project)
- releases_page_link_start = link_start % { url: releases_page_path }
- - docs_url = help_page_path('user/project/releases/index.md', anchor: 'creating-a-release')
+ - docs_url = help_page_path('user/project/releases/index.md', anchor: 'create-a-release')
- docs_link_start = link_start % { url: docs_url }
- link_end = '</a>'.html_safe
- replacements = { releases_page_link_start: releases_page_link_start, docs_link_start: docs_link_start, link_end: link_end }
= s_('TagsPage|Optionally, create a public Release of your project, based on this tag. Release notes are displayed on the %{releases_page_link_start}Releases%{link_end} page. %{docs_link_start}More information%{link_end}').html_safe % replacements
= render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
- = render 'shared/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description
+ = render 'shared/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description, qa_selector: 'release_notes_field'
= render 'shared/notes/hints'
.form-actions
- = button_tag s_('TagsPage|Create tag'), class: 'btn btn-success'
+ = button_tag s_('TagsPage|Create tag'), class: 'btn btn-success', data: { qa_selector: "create_tag_button" }
= link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'btn btn-cancel'
-# haml-lint:disable InlineJavaScript
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml
index a3746808440..896dbe454e6 100644
--- a/app/views/projects/tags/releases/edit.html.haml
+++ b/app/views/projects/tags/releases/edit.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs "Tags", project_tags_path(@project)
+- add_to_breadcrumbs _("Tags"), project_tags_path(@project)
- breadcrumb_title @tag.name
-- page_title "Edit", @tag.name, "Tags"
+- page_title _("Edit"), @tag.name, _("Tags")
.sub-header-block.no-bottom-space
.oneline
@@ -14,6 +14,6 @@
= render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…"
= render 'shared/notes/hints'
.error-alert
- .prepend-top-default
+ .gl-mt-3
= f.submit 'Save changes', class: 'btn btn-success'
= link_to "Cancel", project_tag_path(@project, @tag.name), class: "btn btn-default btn-cancel"
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 6f53a687fb9..edb0577cebd 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -9,7 +9,7 @@
.top-area.multi-line.flex-wrap
.nav-text
.title
- %span.item-title.ref-name
+ %span.item-title.ref-name{ data: { qa_selector: 'tag_name_content' } }
= icon('tag')
= @tag.name
- if protected_tag?(@project, @tag)
@@ -56,12 +56,12 @@
%i.fa.fa-trash-o
- if @tag.message.present?
- %pre.wrap
+ %pre.wrap{ data: { qa_selector: 'tag_message_content' } }
= strip_signature(@tag.message)
-.append-bottom-default.prepend-top-default
+.gl-mb-3.gl-mt-3
- if @release.description.present?
- .description.md
+ .description.md{ data: { qa_selector: 'tag_release_notes_content' } }
= markdown_field(@release, :description)
- else
= s_('TagsPage|This tag has no release notes.')
diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml
index 3e3804ae204..6d2bdda8254 100644
--- a/app/views/projects/tree/_readme.html.haml
+++ b/app/views/projects/tree/_readme.html.haml
@@ -1,5 +1,5 @@
- if readme.rich_viewer
- %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-show-on-root" if vue_file_list_enabled?)] }
+ %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout)] }
.js-file-title.file-title-flex-parent
.file-header-content
= blob_icon readme.mode, readme.name
diff --git a/app/views/projects/tree/_tree_commit_column.html.haml b/app/views/projects/tree/_tree_commit_column.html.haml
deleted file mode 100644
index 065fef606d5..00000000000
--- a/app/views/projects/tree/_tree_commit_column.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- full_title = markdown_field(commit, :full_title)
-%span.str-truncated
- = link_to_html full_title, project_commit_path(@project, commit.id), title: full_title, class: 'tree-commit-link'
diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml
index c65420d537b..a4427c6eedb 100644
--- a/app/views/projects/tree/_tree_content.html.haml
+++ b/app/views/projects/tree/_tree_content.html.haml
@@ -10,7 +10,7 @@
- if @path.present?
%tr.tree-item
%td.tree-item-file-name
- = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10'
+ = link_to "..", project_tree_path(@project, up_dir_path), class: 'gl-ml-3'
%td
%td.d-none.d-sm-table-cell
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index d5f7673488f..eab6d750a02 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -5,92 +5,17 @@
.tree-ref-holder
= render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true
- - if on_top_of_branch?
- - addtotree_toggle_attributes = { 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown', 'data-boundary': 'window' }
- - else
- - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' }
-
- - if vue_file_list_enabled?
- #js-repo-breadcrumb{ data: breadcrumb_data_attributes }
- - else
- %ul.breadcrumb.repo-breadcrumb
- %li.breadcrumb-item
- = link_to project_tree_path(@project, @ref) do
- = @project.path
- - path_breadcrumbs do |title, path|
- %li.breadcrumb-item
- = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path))
-
- - if can_collaborate || can_create_mr_from_fork
- %li.breadcrumb-item
- %button.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes, type: 'button' }
- = sprite_icon('plus', size: 16, css_class: 'float-left')
- = sprite_icon('chevron-down', size: 16, css_class: 'float-left')
- - if on_top_of_branch?
- .add-to-tree-dropdown
- %ul.dropdown-menu
- - if can_edit_tree?
- %li.dropdown-header
- #{ _('This directory') }
- %li
- = link_to project_new_blob_path(@project, @id), class: 'qa-new-file-option' do
- #{ _('New file') }
- %li
- = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
- #{ _('Upload file') }
- %li
- = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
- #{ _('New directory') }
- - elsif can_create_mr_from_fork
- %li
- - continue_params = { to: project_new_blob_path(@project, @id),
- notice: edit_in_new_fork_notice,
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
- = link_to fork_path, method: :post do
- #{ _('New file') }
- %li
- - continue_params = { to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to upload a file again.",
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
- = link_to fork_path, method: :post do
- #{ _('Upload file') }
- %li
- - continue_params = { to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to create a new directory again.",
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
- = link_to fork_path, method: :post do
- #{ _('New directory') }
-
- - 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') }
+ #js-repo-breadcrumb{ data: breadcrumb_data_attributes }
.tree-controls
.d-block.d-sm-flex.flex-wrap.align-items-start.gl-children-ml-sm-3<
= render_if_exists 'projects/tree/lock_link'
- - if vue_file_list_enabled?
- #js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } }
- - else
- = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
+ #js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } }
= render 'projects/find_file_link'
- if can_collaborate || current_user&.already_forked?(@project)
- - if vue_file_list_enabled?
- #js-tree-web-ide-link.d-inline-block
- - else
- = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do
- = _('Web IDE')
+ #js-tree-web-ide-link.d-inline-block
- elsif can_create_mr_from_fork
= link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do
= _('Web IDE')
diff --git a/app/views/projects/tree/_tree_row.html.haml b/app/views/projects/tree/_tree_row.html.haml
index 8a27ea66523..300cd5423bf 100644
--- a/app/views/projects/tree/_tree_row.html.haml
+++ b/app/views/projects/tree/_tree_row.html.haml
@@ -14,7 +14,7 @@
%a.str-truncated{ href: fast_project_blob_path(@project, tree_join(@id || @commit.id, tree_row_name)), title: tree_row_name }
%span= tree_row_name
- if @lfs_blob_ids.include?(tree_row.id)
- %span.badge.label-lfs.prepend-left-5 LFS
+ %span.badge.label-lfs.gl-ml-2 LFS
- elsif tree_row_type == :commit
= tree_icon('archive', tree_row.mode, tree_row.name)
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 65f5bc31d2e..3dd12a7b641 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -1,13 +1,9 @@
- breadcrumb_title _("Repository")
- @content_class = "limit-container-width" unless fluid_layout
-- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit)
- page_title @path.presence || _("Files"), @ref
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
-- unless vue_file_list_enabled?
- .js-signature-container{ data: { 'signatures-path': signatures_path } }
-
= render 'projects/last_push'
= render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index 4ca070cb162..4e097f345c2 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -1,4 +1,4 @@
-.row.prepend-top-default.append-bottom-default.triggers-container
+.row.gl-mt-3.gl-mb-3.triggers-container
.col-lg-12
.card
.card-header
@@ -21,7 +21,7 @@
%th
= render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
- else
- %p.settings-message.text-center.append-bottom-default
+ %p.settings-message.text-center.gl-mb-3
No triggers have been created yet. Add one using the form above.
.card-footer
@@ -38,7 +38,7 @@
%code REF_NAME
with the trigger token and the branch or tag name respectively.
- %h5.prepend-top-default
+ %h5.gl-mt-3
Use cURL
%p.light
@@ -51,7 +51,7 @@
-F token=TOKEN \
-F ref=REF_NAME \
#{builds_trigger_url(@project.id)}
- %h5.prepend-top-default
+ %h5.gl-mt-3
Use .gitlab-ci.yml
%p.light
@@ -66,7 +66,7 @@
stage: deploy
script:
- "curl -X POST -F token=TOKEN -F ref=REF_NAME #{builds_trigger_url(@project.id)}"
- %h5.prepend-top-default
+ %h5.gl-mt-3
Use webhook
%p.light
@@ -76,7 +76,7 @@
%pre
:plain
#{builds_trigger_url(@project.id, ref: 'REF_NAME')}?token=TOKEN
- %h5.prepend-top-default
+ %h5.gl-mt-3
Pass job variables
%p.light
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index d80248f7e80..3036e918160 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -31,7 +31,7 @@
- revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?"
- if can?(current_user, :admin_trigger, trigger)
= link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do
- %i.fa.fa-pencil
+ = sprite_icon('pencil')
- if can?(current_user, :manage_trigger, trigger)
= link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
%i.fa.fa-trash
diff --git a/app/views/projects/triggers/edit.html.haml b/app/views/projects/triggers/edit.html.haml
index e287f05fe6a..ee2714e1eb9 100644
--- a/app/views/projects/triggers/edit.html.haml
+++ b/app/views/projects/triggers/edit.html.haml
@@ -1,6 +1,6 @@
-- page_title "Trigger"
+- page_title _("Trigger")
-.row.prepend-top-default.append-bottom-default
+.row.gl-mt-3.gl-mb-3
.col-lg-12
%h4.gl-mt-0
Update trigger
diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml
index 208dedc988b..1db4554541d 100644
--- a/app/views/projects/wikis/git_access.html.haml
+++ b/app/views/projects/wikis/git_access.html.haml
@@ -2,8 +2,7 @@
- page_title s_("WikiClone|Git Access"), _("Wiki")
.wiki-page-header.top-area.has-sidebar-toggle.py-3.flex-column.flex-lg-row
- %button.btn.btn-default.d-block.d-sm-block.d-md-none.float-right.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
- = icon('angle-double-left')
+ = wiki_sidebar_toggle_button
.git-access-header.w-100.d-flex.flex-column.justify-content-center
%span
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index db7769fa743..d6e38ddd5c6 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -3,8 +3,8 @@
= search_filter_link 'users', _("Users")
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
+ .fade-left= sprite_icon('chevron-lg-left', size: 12)
+ .fade-right= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.search-filter.scrolling-tabs.nav.nav-tabs
- if @project
- if project_search_tabs?(:blobs)
diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml
index 1f055cdfa31..b88e9a75053 100644
--- a/app/views/search/results/_issue.html.haml
+++ b/app/views/search/results/_issue.html.haml
@@ -4,7 +4,7 @@
= link_to namespace_project_issue_path(issue.project.namespace.becomes(Namespace), issue.project, issue) do
%span.term.str-truncated= issue.title
- if issue.closed?
- %span.badge.badge-danger.prepend-left-5= _("Closed")
+ %span.badge.badge-danger.gl-ml-2= _("Closed")
.float-right ##{issue.iid}
- if issue.description.present?
.description.term
diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml
index 074bb9bce8d..45b6cb06753 100644
--- a/app/views/search/results/_merge_request.html.haml
+++ b/app/views/search/results/_merge_request.html.haml
@@ -3,9 +3,9 @@
= link_to namespace_project_merge_request_path(merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request) do
%span.term.str-truncated= merge_request.title
- if merge_request.merged?
- %span.badge.badge-primary.prepend-left-5= _("Merged")
+ %span.badge.badge-primary.gl-ml-2= _("Merged")
- elsif merge_request.closed?
- %span.badge.badge-danger.prepend-left-5= _("Closed")
+ %span.badge.badge-danger.gl-ml-2= _("Closed")
.float-right= merge_request.to_reference
- if merge_request.description.present?
.description.term
diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml
index 6717939d034..b67bc71941a 100644
--- a/app/views/search/results/_note.html.haml
+++ b/app/views/search/results/_note.html.haml
@@ -4,7 +4,7 @@
.search-result-row
%h5.note-search-caption.str-truncated
- %i.fa.fa-comment
+ = sprite_icon('comment', size: 16, css_class: 'gl-vertical-align-text-bottom')
= link_to_member(project, note.author, avatar: false)
- link_to_project = link_to(project.full_name, project)
= _("commented on %{link_to_project}").html_safe % { link_to_project: link_to_project }
diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml
index f300e1d4841..869890cdf31 100644
--- a/app/views/search/show.html.haml
+++ b/app/views/search/show.html.haml
@@ -7,7 +7,7 @@
= _('Search')
= render_if_exists 'search/form_elasticsearch', attrs: { class: 'ml-sm-auto' }
-.prepend-top-default
+.gl-mt-3
= render 'search/form'
- if @search_term
= render 'search/category'
diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml
index 1eecbe3bc0e..7aeecf26c39 100644
--- a/app/views/sent_notifications/unsubscribe.html.haml
+++ b/app/views/sent_notifications/unsubscribe.html.haml
@@ -15,5 +15,5 @@
%p
= link_to _('Unsubscribe'), unsubscribe_sent_notification_path(@sent_notification, force: true),
- class: 'btn btn-primary append-right-10'
- = link_to _('Cancel'), new_user_session_path, class: 'btn append-right-10'
+ class: 'btn btn-primary gl-mr-3'
+ = link_to _('Cancel'), new_user_session_path, class: 'btn gl-mr-3'
diff --git a/app/views/shared/_commit_well.html.haml b/app/views/shared/_commit_well.html.haml
index 6f1fe9bfdc5..48fe258d01f 100644
--- a/app/views/shared/_commit_well.html.haml
+++ b/app/views/shared/_commit_well.html.haml
@@ -1,4 +1,4 @@
-.info-well.d-none.d-sm-block.project-last-commit.append-bottom-default
+.info-well.d-none.d-sm-block.project-last-commit.gl-mb-3
.well-segment
%ul.blob-commit-info
= render 'projects/commits/commit', commit: commit, ref: ref, project: project
diff --git a/app/views/shared/_event_filter.html.haml b/app/views/shared/_event_filter.html.haml
index 1b2e8d3799d..03534bf78d1 100644
--- a/app/views/shared/_event_filter.html.haml
+++ b/app/views/shared/_event_filter.html.haml
@@ -1,8 +1,8 @@
- show_group_events = local_assigns.fetch(:show_group_events, false)
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller.flex-fill
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
+ .fade-left= sprite_icon('chevron-lg-left', size: 12)
+ .fade-right= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.event-filter.scrolling-tabs.nav.nav-tabs
= event_filter_link EventFilter::ALL, _('All'), s_('EventFilterBy|Filter by all')
- if event_filter_visible(:repository)
@@ -15,6 +15,8 @@
= render_if_exists 'events/epics_filter'
- if comments_visible?
= event_filter_link EventFilter::COMMENTS, _('Comments'), s_('EventFilterBy|Filter by comments')
- - if Feature.enabled?(:wiki_events) && (@project.nil? || @project.has_wiki?)
+ - if @project.nil? || @project.has_wiki?
= event_filter_link EventFilter::WIKI, _('Wiki'), s_('EventFilterBy|Filter by wiki')
+ - if event_filter_visible(:designs)
+ = event_filter_link EventFilter::DESIGNS, _('Designs'), s_('EventFilterBy|Filter by designs')
= event_filter_link EventFilter::TEAM, _('Team'), s_('EventFilterBy|Filter by team')
diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml
index 2480014ea42..076c87400e0 100644
--- a/app/views/shared/_field.html.haml
+++ b/app/views/shared/_field.html.haml
@@ -22,7 +22,7 @@
- elsif type == 'checkbox'
= form.check_box name
- elsif type == 'select'
- = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control"} # rubocop:disable Style/RedundantCondition
+ = form.select name, options_for_select(choices, value || default_choice), {}, { class: "form-control"}
- elsif type == 'password'
= form.password_field name, autocomplete: "new-password", placeholder: placeholder, class: "form-control", required: value.blank? && required, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" }
- if help
diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml
index 7807371285c..d704eae2090 100644
--- a/app/views/shared/_issuable_meta_data.html.haml
+++ b/app/views/shared/_issuable_meta_data.html.haml
@@ -2,7 +2,7 @@
- issue_votes = @issuable_meta_data[issuable.id]
- upvotes, downvotes = issue_votes.upvotes, issue_votes.downvotes
- issuable_path = issuable_path(issuable, anchor: 'notes')
-- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count(current_user)
+- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count
- if issuable_mr > 0
%li.issuable-mr.d-none.d-sm-block.has-tooltip{ title: _('Related merge requests') }
@@ -11,15 +11,15 @@
- if upvotes > 0
%li.issuable-upvotes.d-none.d-sm-block.has-tooltip{ title: _('Upvotes') }
- = icon('thumbs-up')
+ = sprite_icon('thumb-up', size: 16, css_class: "vertical-align-middle")
= upvotes
- if downvotes > 0
%li.issuable-downvotes.d-none.d-sm-block.has-tooltip{ title: _('Downvotes') }
- = icon('thumbs-down')
+ = sprite_icon('thumb-down', size: 16, css_class: "vertical-align-middle")
= downvotes
%li.issuable-comments.d-none.d-sm-block
= link_to issuable_path, class: ['has-tooltip', ('no-comments' if note_count.zero?)], title: _('Comments') do
- = icon('comments')
+ = sprite_icon('comments', size: 16, css_class: 'gl-vertical-align-text-bottom')
= note_count
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index cd303dd7a3d..3d2ae772135 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -6,7 +6,7 @@
.label-name
= render_label(label, tooltip: false)
.label-description
- .append-right-default.prepend-left-default
+ .label-description-wrapper
- if label.description.present?
.description-text
= markdown_field(label, :description)
@@ -20,6 +20,6 @@
= link_to_label(label, type: :merge_request) { _('Merge requests') }
- if force_priority
&middot;
- %li.label-link-item.priority-badge.js-priority-badge.inline.prepend-left-10
+ %li.label-link-item.priority-badge.js-priority-badge.inline.gl-ml-3
.label-badge.label-badge-blue= _('Prioritized label')
= render_if_exists 'shared/label_row_epics_link', label: label
diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml
index f5f24b2f0ce..c3818b9f7ae 100644
--- a/app/views/shared/_md_preview.html.haml
+++ b/app/views/shared/_md_preview.html.haml
@@ -11,10 +11,10 @@
.md-header
%ul.nav.nav-tabs.nav-links.clearfix
%li.md-header-tab.active
- %button.js-md-write-button{ tabindex: -1 }
+ %button.js-md-write-button
= _("Write")
%li.md-header-tab
- %button.js-md-preview-button{ tabindex: -1 }
+ %button.js-md-preview-button
= _("Preview")
%li.md-header-toolbar.active
diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml
index 48a97a18ca9..2261e9e3121 100644
--- a/app/views/shared/_milestone_expired.html.haml
+++ b/app/views/shared/_milestone_expired.html.haml
@@ -1,6 +1,6 @@
- if milestone.expired? and not milestone.closed?
- .status-box.status-box-expired.append-bottom-5= _('Expired')
+ .status-box.status-box-expired.gl-mb-2= _('Expired')
- if milestone.upcoming?
- .status-box.status-box-mr-merged.append-bottom-5= _('Upcoming')
+ .status-box.status-box-mr-merged.gl-mb-2= _('Upcoming')
- if milestone.closed?
- .status-box.status-box-closed.append-bottom-5= _('Closed')
+ .status-box.status-box-closed.gl-mb-2= _('Closed')
diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml
index 9a1db831ad3..06da990e071 100644
--- a/app/views/shared/_milestones_sort_dropdown.html.haml
+++ b/app/views/shared/_milestones_sort_dropdown.html.haml
@@ -1,4 +1,4 @@
-.dropdown.inline.prepend-left-10
+.dropdown.inline.gl-ml-3
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } }
%span.light
- if @sort.present?
diff --git a/app/views/shared/_namespace_storage_limit_alert.html.haml b/app/views/shared/_namespace_storage_limit_alert.html.haml
deleted file mode 100644
index 95f27cde15b..00000000000
--- a/app/views/shared/_namespace_storage_limit_alert.html.haml
+++ /dev/null
@@ -1,26 +0,0 @@
-- return unless current_user
-
-- payload = namespace_storage_alert(namespace)
-- return if payload.empty?
-
-- alert_level = payload[:alert_level]
-- root_namespace = payload[:root_namespace]
-
-- style = namespace_storage_alert_style(alert_level)
-- icon = namespace_storage_alert_icon(alert_level)
-- link = namespace_storage_usage_link(root_namespace)
-
-%div{ class: [classes, 'js-namespace-storage-alert'] }
- .gl-pt-5.gl-pb-3
- .gl-alert{ class: "gl-alert-#{style}", role: 'alert' }
- = sprite_icon(icon, css_class: "gl-icon gl-alert-icon")
- .gl-alert-title
- %h4.gl-alert-title= payload[:usage_message]
- - if alert_level != :error
- %button.js-namespace-storage-alert-dismiss.gl-alert-dismiss.gl-cursor-pointer{ type: 'button', 'aria-label' => _('Dismiss'), data: { id: root_namespace.id, level: alert_level } }
- = sprite_icon('close', size: 16, css_class: 'gl-icon')
- .gl-alert-body
- = payload[:explanation_message]
- - if link
- .gl-alert-actions
- = link_to(_('Manage storage usage'), link, class: "btn gl-alert-action btn-md gl-button btn-#{style}")
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index 92b86c6fec1..7d93dca22f5 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -1,22 +1,23 @@
-= form_errors(@service)
+= form_errors(integration)
-- if lookup_context.template_exists?('help', "projects/services/#{@service.to_param}", true)
- = render "projects/services/#{@service.to_param}/help", subject: @service
-- elsif @service.help.present?
+- if lookup_context.template_exists?('help', "projects/services/#{integration.to_param}", true)
+ = render "projects/services/#{integration.to_param}/help", subject: integration
+- elsif integration.help.present?
.info-well
.well-segment
- = markdown @service.help
+ = markdown integration.help
.service-settings
- .js-vue-integration-settings{ data: { show_active: @service.show_active_box?.to_s, activated: (@service.active || @service.new_record?).to_s, type: @service.to_param, merge_request_events: @service.merge_requests_events.to_s,
-commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on_event_enabled.to_s, comment_detail: @service.comment_detail, trigger_events: trigger_events_for_service, fields: fields_for_service } }
+ - if @admin_integration
+ .js-vue-admin-integration-settings{ data: integration_form_data(@admin_integration) }
+ .js-vue-integration-settings{ data: integration_form_data(integration) }
- - if show_service_trigger_events?
+ - if show_service_trigger_events?(integration)
.form-group.row
%label.col-form-label.col-sm-2= _('Trigger')
.col-sm-10
- - @service.configurable_events.each do |event|
+ - integration.configurable_events.each do |event|
.form-group
.form-check
= form.check_box service_event_field_name(event), class: 'form-check-input'
@@ -24,14 +25,14 @@ commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on
%strong
= event.humanize
- - field = @service.event_field(event)
+ - field = integration.event_field(event)
- if field
= form.text_field field[:name], class: "form-control", placeholder: field[:placeholder]
%p.text-muted
- = @service.class.event_description(event)
+ = integration.class.event_description(event)
- unless integration_form_refactor?
- - @service.global_fields.each do |field|
+ - integration.global_fields.each do |field|
= render 'shared/field', form: form, field: field
diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml
index c7546073e5c..1431966c83d 100644
--- a/app/views/shared/_sidebar_toggle_button.html.haml
+++ b/app/views/shared/_sidebar_toggle_button.html.haml
@@ -1,6 +1,6 @@
%a.toggle-sidebar-button.js-toggle-sidebar.qa-toggle-sidebar.rspec-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" }
- = sprite_icon('angle-double-left', css_class: 'icon-angle-double-left')
- = sprite_icon('angle-double-right', css_class: 'icon-angle-double-right')
+ = sprite_icon('chevron-double-lg-left', css_class: 'icon-chevron-double-lg-left')
+ = sprite_icon('chevron-double-lg-right', css_class: 'icon-chevron-double-lg-right')
%span.collapse-text= _("Collapse sidebar")
= button_tag class: 'close-nav-button', type: 'button' do
diff --git a/app/views/shared/_zen.html.haml b/app/views/shared/_zen.html.haml
index 8dd0e5a92a7..914409d0e65 100644
--- a/app/views/shared/_zen.html.haml
+++ b/app/views/shared/_zen.html.haml
@@ -14,6 +14,6 @@
supports_autocomplete: supports_autocomplete,
qa_selector: qa_selector }
- else
- = text_area_tag attr, current_text, class: classes, placeholder: placeholder
+ = text_area_tag attr, current_text, data: { qa_selector: qa_selector }, class: classes, placeholder: placeholder
%a.zen-control.zen-control-leave.js-zen-leave.gl-text-gray-700{ href: "#" }
= sprite_icon('compress', size: 16)
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index 680626f7880..820a6cbd15d 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -30,5 +30,5 @@
= f.label :scopes, _('Scopes'), class: 'label-bold'
= render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes
- .prepend-top-default
+ .gl-mt-3
= f.submit _('Create %{type}') % { type: type }, class: 'btn btn-success', data: { qa_selector: 'create_token_button' }
diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml
index 5518c31cb06..55231cb9429 100644
--- a/app/views/shared/access_tokens/_table.html.haml
+++ b/app/views/shared/access_tokens/_table.html.haml
@@ -16,6 +16,9 @@
%tr
%th= _('Name')
%th= s_('AccessTokens|Created')
+ %th
+ = _('Last Used')
+ = link_to icon('question-circle'), help_page_path('user/profile/personal_access_tokens.md', anchor: 'token-activity'), target: '_blank'
%th= _('Expires')
%th= _('Scopes')
%th
@@ -25,9 +28,18 @@
%td= token.name
%td= token.created_at.to_date.to_s(:medium)
%td
+ - if token.last_used_at?
+ %span.token-last-used-label= _(time_ago_with_tooltip(token.last_used_at))
+ - else
+ %span.token-never-used-label= _('Never')
+ %td
- if token.expires?
- %span{ class: ('text-warning' if token.expires_soon?) }
- = _('In %{time_to_now}') % { time_to_now: distance_of_time_in_words_to_now(token.expires_at) }
+ - if token.expires_at.past? || token.expires_at.today?
+ %span{ class: 'text-danger has-tooltip', title: _('Expiration not enforced') }
+ = _('Expired')
+ - else
+ %span{ class: ('text-warning' if token.expires_soon?) }
+ = _('In %{time_to_now}') % { time_to_now: 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>')
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index 902b6d19f82..b68c7cd4d52 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -13,7 +13,6 @@
- content_for :page_specific_javascripts do
-# haml-lint:disable InlineJavaScript
- %script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board"
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal, show_sorting_dropdown: false
%script#js-board-promotion{ type: "text/x-template" }= render_if_exists "shared/promotions/promote_issue_board"
diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml
deleted file mode 100644
index 2a5b72d478a..00000000000
--- a/app/views/shared/boards/components/_board.html.haml
+++ /dev/null
@@ -1,82 +0,0 @@
--# Please have a look at app/assets/javascripts/boards/components/board_column.vue
- This haml file is deprecated and will be deleted soon, please change the Vue app
- https://gitlab.com/gitlab-org/gitlab/-/issues/212300
-.board.h-100.px-2.align-top.ws-normal{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }',
- ":data-id" => "list.id", data: { qa_selector: "board_list" } }
- .board-inner.d-flex.flex-column.position-relative.h-100.rounded
- %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", data: { qa_selector: "board_list_header" } }
- %h3.board-title.m-0.d-flex.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "border-bottom-0": !list.isExpanded }' }
-
- .board-title-caret.no-drag{ "v-if": "list.isExpandable",
- "aria-hidden": "true",
- ":aria-label": "caretTooltip",
- ":title": "caretTooltip",
- "v-tooltip": "",
- data: { placement: "bottom" },
- "@click": "toggleExpanded" }
- %i.fa.fa-fw{ ":class": '{ "fa-caret-right": list.isExpanded, "fa-caret-down": !list.isExpanded }' }
- = render_if_exists "shared/boards/components/list_milestone"
-
- %a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" }
- -# haml-lint:disable AltText
- %img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" }
-
- .board-title-text
- %span.board-title-main-text.block-truncated{ "v-if": "list.type !== \"label\"",
- ":title" => '((list.label && list.label.description) || list.title || "")',
- data: { container: "body" },
- ":class": "{ 'has-tooltip': !['backlog', 'closed'].includes(list.type), 'd-block': list.type === 'milestone' }" }
- {{ list.title }}
-
- %span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"",
- ":title" => '(list.assignee && list.assignee.username || "")' }
- @{{ list.assignee.username }}
-
- %gl-label{ "v-if" => " list.type === \"label\"",
- ":background-color" => "list.label.color",
- ":title" => "list.label.title",
- ":description" => "list.label.description",
- "tooltipPlacement" => "bottom",
- ":size" => '(!list.isExpanded ? "sm" : "")',
- ":scoped" => "showScopedLabels(list.label)" }
-
- - if can?(current_user, :admin_list, current_board_parent)
- %board-delete{ "inline-template" => true,
- ":list" => "list",
- "v-if" => "!list.preset && list.id" }
- %button.board-delete.no-drag.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
- = icon("trash")
-
- .issue-count-badge.pr-0.no-drag.text-secondary{ "v-if" => "showBoardListAndBoardInfo" }
- %span.d-inline-flex
- %gl-tooltip{ ":target" => "() => $refs.issueCount", ":title" => "issuesTooltip" }
- %span.issue-count-badge-count{ "ref" => "issueCount" }
- %icon.mr-1{ name: "issues" }
- %issue-count{ ":maxIssueCount" => "list.maxIssueCount",
- ":issuesSize" => "list.issuesSize" }
- = render_if_exists "shared/boards/components/list_weight"
-
- %gl-button-group.board-list-button-group.pl-2{ "v-if" => "isNewIssueShown || isSettingsShown" }
- %gl-deprecated-button.issue-count-badge-add-button.no-drag{ type: "button",
- "@click" => "showNewIssueForm",
- "v-if" => "isNewIssueShown",
- ":class": "{ 'd-none': !list.isExpanded, 'rounded-right': isNewIssueShown && !isSettingsShown }",
- "aria-label" => _("New issue"),
- "ref" => "newIssueBtn" }
- = icon("plus")
- %gl-tooltip{ ":target" => "() => $refs.newIssueBtn" }
- = _("New Issue")
- = render_if_exists 'shared/boards/components/list_settings'
-
- %board-list{ "v-if" => "showBoardListAndBoardInfo",
- ":list" => "list",
- ":issues" => "list.issues",
- ":loading" => "list.loading",
- ":disabled" => "disabled",
- ":issue-link-base" => "issueLinkBase",
- ":root-path" => "rootPath",
- ":groupId" => ((current_board_parent.id if @group) || 'null'),
- "ref" => "board-list" }
- - if can?(current_user, :admin_list, current_board_parent)
- %board-blank-state{ "v-if" => 'list.id == "blank"' }
- = render_if_exists 'shared/boards/board_promotion_state'
diff --git a/app/views/shared/dashboard/_no_filter_selected.html.haml b/app/views/shared/dashboard/_no_filter_selected.html.haml
index 32246dac4c7..48c844d93e8 100644
--- a/app/views/shared/dashboard/_no_filter_selected.html.haml
+++ b/app/views/shared/dashboard/_no_filter_selected.html.haml
@@ -1,6 +1,6 @@
.row.empty-state.text-center
.col-12
- .svg-130.prepend-top-default
+ .svg-130.gl-mt-3
= image_tag 'illustrations/issue-dashboard_results-without-filter.svg'
.col-12
.text-content
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index 512644518fa..8d74e12e943 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -45,5 +45,5 @@
= label_tag ("deploy_token_write_package_registry"), 'write_package_registry', class: 'label-bold form-check-label'
.text-secondary= s_('DeployTokens|Allows write access to the package registry')
- .prepend-top-default
+ .gl-mt-3
= f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success qa-create-deploy-token'
diff --git a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml
index a9728dc841f..738f2f9db70 100644
--- a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml
+++ b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml
@@ -8,11 +8,11 @@
= text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus qa-deploy-token-user'
.input-group-append
= clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username'), placement: 'left')
- %span.deploy-token-help-block.prepend-top-5.text-success= s_("DeployTokens|Use this username as a login.")
+ %span.deploy-token-help-block.gl-mt-2.text-success= s_("DeployTokens|Use this username as a login.")
.form-group
.input-group
= text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus qa-deploy-token'
.input-group-append
= clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token'), 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.")
+ %span.deploy-token-help-block.gl-mt-2.text-danger= s_("DeployTokens|Use this token as a password. Make sure you save it - you won't be able to access it again.")
diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml
index ff5ee801969..656acafd416 100644
--- a/app/views/shared/empty_states/_wikis.html.haml
+++ b/app/views/shared/empty_states/_wikis.html.haml
@@ -11,6 +11,10 @@
%p.text-left
= messages.dig(:writable, :body)
= create_link
+ - if show_enable_confluence_integration?(@wiki.container)
+ = link_to s_('WikiEmpty|Enable the Confluence Wiki integration'),
+ edit_project_service_path(@project, :confluence),
+ class: 'btn', title: s_('WikiEmpty|Enable the Confluence Wiki integration')
- elsif @project && can?(current_user, :read_issue, @project)
- issues_link = link_to s_('WikiEmptyIssueMessage|issue tracker'), project_issues_path(@project)
diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml
index d44017299b8..3b100f832b2 100644
--- a/app/views/shared/empty_states/_wikis_layout.html.haml
+++ b/app/views/shared/empty_states/_wikis_layout.html.haml
@@ -1,4 +1,4 @@
-.row.empty-state
+.row.empty-state.empty-state-wiki
.col-12
.svg-content.qa-svg-content
= image_tag image_path
diff --git a/app/views/shared/empty_states/icons/_service_desk_callout.svg b/app/views/shared/empty_states/icons/_service_desk_callout.svg
new file mode 100644
index 00000000000..2886388279e
--- /dev/null
+++ b/app/views/shared/empty_states/icons/_service_desk_callout.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><rect width="7" height="1" x="59" y="38" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M60.5 42a3.5 3.5 0 0 0 0-7v7z"/><rect width="7" height="1" x="12" y="38" fill="#E1DBF2" transform="matrix(-1 0 0 1 31 0)" rx=".5"/><path fill="#6B4FBB" d="M17.5 42a3.5 3.5 0 0 1 0-7v7z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M39 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M35 56a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M26.5 40c0 4.143 3.355 7.5 7.494 7.5h10.012A7.497 7.497 0 0 0 51.5 40c0-4.143-3.355-7.5-7.494-7.5H33.994A7.497 7.497 0 0 0 26.5 40zm-3 0c0-5.799 4.698-10.5 10.494-10.5h10.012C49.802 29.5 54.5 34.2 54.5 40c0 5.799-4.698 10.5-10.494 10.5H33.994C28.198 50.5 23.5 45.8 23.5 40z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M35.255 42.406a1 1 0 1 1 1.872-.703 2.001 2.001 0 0 0 3.76-.038 1 1 0 1 1 1.886.665 4 4 0 0 1-7.518.076zM31.5 40a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm15 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/><path fill="#6B4FBB" d="M38 22h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2zm0 3h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z" style="mix-blend-mode:multiply"/></g></svg> \ No newline at end of file
diff --git a/app/views/shared/empty_states/icons/_service_desk_empty_state.svg b/app/views/shared/empty_states/icons/_service_desk_empty_state.svg
new file mode 100644
index 00000000000..04c4870be07
--- /dev/null
+++ b/app/views/shared/empty_states/icons/_service_desk_empty_state.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="226" height="178" viewBox="0 0 226 178"><g fill="none" fill-rule="evenodd"><path fill="#EEE" fill-rule="nonzero" d="M109.496 165.895c2.06.108 4.113.134 6.158.08 1.104-.03 1.975-.95 1.945-2.055-.03-1.104-.95-1.975-2.055-1.945-1.94.053-3.886.028-5.84-.074-1.102-.057-2.043.79-2.1 1.893-.06 1.104.788 2.045 1.89 2.102zm18.408-1.245c2.02-.386 4.023-.853 6-1.4 1.066-.295 1.69-1.396 1.396-2.46-.295-1.066-1.397-1.69-2.46-1.396-1.875.52-3.772.96-5.686 1.327-1.085.208-1.797 1.255-1.59 2.34.207 1.085 1.255 1.797 2.34 1.59zm17.572-5.636c1.865-.86 3.696-1.795 5.486-2.803.962-.54 1.303-1.76.762-2.723-.542-.962-1.762-1.303-2.724-.762-1.697.955-3.43 1.84-5.2 2.656-1.002.464-1.44 1.652-.978 2.655.462 1.003 1.65 1.44 2.654.98zm44.342-74.897c-.142-2.056-.367-4.1-.674-6.127-.165-1.092-1.184-1.844-2.276-1.678-1.092.165-1.844 1.184-1.68 2.276.29 1.92.505 3.857.64 5.805.076 1.102 1.03 1.934 2.133 1.857 1.103-.076 1.934-1.03 1.858-2.133zm-3.505-18.144c-.632-1.956-1.343-3.884-2.13-5.78-.425-1.02-1.595-1.504-2.615-1.08-1.02.424-1.503 1.594-1.08 2.614.747 1.797 1.42 3.624 2.02 5.476.34 1.05 1.467 1.628 2.518 1.288 1.05-.34 1.627-1.466 1.287-2.517zm-7.754-16.73c-1.083-1.745-2.235-3.447-3.454-5.1-.655-.89-1.907-1.08-2.797-.423-.89.655-1.08 1.907-.424 2.796 1.155 1.568 2.247 3.18 3.273 4.835.58.94 1.814 1.23 2.753.647.938-.582 1.228-1.815.646-2.754zm-11.582-14.446c-1.468-1.437-2.993-2.814-4.572-4.128-.85-.708-2.11-.592-2.816.256-.707.85-.592 2.11.257 2.817 1.496 1.246 2.942 2.55 4.334 3.913.79.773 2.057.76 2.83-.03.772-.79.758-2.057-.032-2.83zm-101.422-4.91c-1.6 1.288-3.148 2.64-4.64 4.05-.802.76-.837 2.026-.078 2.828.76.802 2.025.837 2.827.078 1.415-1.338 2.882-2.62 4.4-3.84.86-.692.996-1.95.303-2.812-.692-.86-1.95-.996-2.812-.303zM52.7 43.062c-1.25 1.632-2.433 3.313-3.546 5.04-.6.93-.33 2.167.597 2.765.93.6 2.167.33 2.766-.597 1.055-1.637 2.176-3.23 3.36-4.777.67-.878.504-2.133-.374-2.804-.877-.672-2.132-.505-2.803.372zm-9.373 15.924c-.82 1.882-1.56 3.8-2.226 5.745-.356 1.047.2 2.183 1.247 2.54 1.045.358 2.182-.2 2.54-1.246.63-1.844 1.333-3.66 2.108-5.443.44-1.012-.023-2.19-1.036-2.63-1.014-.44-2.192.023-2.633 1.036zm-5.26 17.74c-.34 2.02-.6 4.058-.777 6.11-.096 1.102.72 2.07 1.82 2.167 1.1.095 2.07-.72 2.165-1.82.17-1.947.415-3.88.737-5.793.183-1.09-.552-2.12-1.64-2.304-1.09-.183-2.122.552-2.305 1.64zM74.87 155.55c1.772 1.038 3.585 2.005 5.437 2.897.995.48 2.19.062 2.67-.933.48-.995.062-2.19-.933-2.67-1.755-.845-3.473-1.76-5.152-2.745-.953-.56-2.178-.24-2.737.714-.558.954-.238 2.18.715 2.738zm16.97 7.34c1.966.578 3.96 1.078 5.975 1.498 1.082.225 2.14-.47 2.366-1.55.226-1.082-.468-2.14-1.55-2.366-1.91-.398-3.798-.872-5.662-1.42-1.06-.312-2.172.294-2.483 1.354-.312 1.06.294 2.17 1.354 2.483z"/><path fill="#F9F9F9" d="M2.12 130c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M39 166c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 92 39 92 4 107.67 4 127s15.67 35 35 35z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M53.925 116.226c-.277-.144-.59-.226-.925-.226H25c-.323 0-.628.076-.898.212l14.663 13.406c.39.357.99.348 1.37-.02l13.79-13.372zm1.075 4.53L42.92 132.47c-1.898 1.84-4.902 1.885-6.854.1L23 120.624V138c0 1.105.895 2 2 2h28c1.105 0 2-.895 2-2v-17.244zM25 112h28c3.314 0 6 2.686 6 6v20c0 3.314-2.686 6-6 6H25c-3.314 0-6-2.686-6-6v-20c0-3.314 2.686-6 6-6z"/><g><path fill="#F9F9F9" d="M150.12 131c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M187 167c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35s-15.67-35-35-35-35 15.67-35 35 15.67 35 35 35z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M180.51 137H199c1.105 0 2-.895 2-2v-16c0-1.105-.895-2-2-2h-24c-1.105 0-2 .895-2 2v22.743l7.51-4.743zm1.157 4l-9.6 6.062c-.32.202-.69.31-1.067.31-1.105 0-2-.896-2-2V119c0-3.314 2.686-6 6-6h24c3.314 0 6 2.686 6 6v16c0 3.314-2.686 6-6 6h-17.333z"/><path fill="#6B4FBB" d="M180 129c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm7 0c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm7 0c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2z"/></g><g><path fill="#F9F9F9" d="M76.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M113 78c-21.54 0-39-17.46-39-39S91.46 0 113 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S132.33 4 113 4 78 19.67 78 39s15.67 35 35 35z"/><g transform="translate(133 35)"><rect width="7" height="1" y="3" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M1.5 7C3.433 7 5 5.433 5 3.5S3.433 0 1.5 0v7z"/></g><g transform="matrix(-1 0 0 1 93 35)"><rect width="7" height="1" y="3" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M1.5 7C3.433 7 5 5.433 5 3.5S3.433 0 1.5 0v7z"/></g><path fill="#E1DBF1" fill-rule="nonzero" d="M113 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M109 56c-.552 0-1-.448-1-1s.448-1 1-1 1 .448 1 1-.448 1-1 1zm4 0c-.552 0-1-.448-1-1s.448-1 1-1 1 .448 1 1-.448 1-1 1zm4 0c-.552 0-1-.448-1-1s.448-1 1-1 1 .448 1 1-.448 1-1 1z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M97.5 40c0-5.8 4.698-10.5 10.494-10.5h10.012c5.796 0 10.494 4.7 10.494 10.5s-4.698 10.5-10.494 10.5h-10.012C102.198 50.5 97.5 45.8 97.5 40zm3 0c0 4.143 3.355 7.5 7.494 7.5h10.012c4.14 0 7.494-3.358 7.494-7.5 0-4.143-3.355-7.5-7.494-7.5h-10.012c-4.14 0-7.494 3.358-7.494 7.5z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M109.255 42.406c-.195-.517.067-1.093.584-1.287.516-.196 1.093.066 1.287.583.29.774 1.033 1.297 1.873 1.297.855 0 1.608-.542 1.887-1.335.184-.52.755-.794 1.276-.61.52.183.794.754.61 1.275-.56 1.587-2.063 2.67-3.773 2.67-1.68 0-3.164-1.046-3.745-2.594zM105.5 40c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5zm15 0c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5z"/><path fill="#6B4FBB" d="M112 22h2c.552 0 1 .448 1 1s-.448 1-1 1h-2c-.552 0-1-.448-1-1s.448-1 1-1zm0 3h2c.552 0 1 .448 1 1s-.448 1-1 1h-2c-.552 0-1-.448-1-1s.448-1 1-1z" style="mix-blend-mode:multiply"/></g></g></svg>
diff --git a/app/views/shared/empty_states/icons/_service_desk_setup.svg b/app/views/shared/empty_states/icons/_service_desk_setup.svg
new file mode 100644
index 00000000000..bb791b58593
--- /dev/null
+++ b/app/views/shared/empty_states/icons/_service_desk_setup.svg
@@ -0,0 +1,39 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="430" height="167" viewBox="0 0 430 167">
+ <defs>
+ <rect id="a" width="81" height="4" x="96" y="88"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd">
+ <g transform="translate(282 2)">
+ <rect width="40" height="4" x="25" y="86" fill="#DFDFDF" rx="2"/>
+ <rect width="22" height="4" y="86" fill="#DFDFDF" rx="2"/>
+ <path stroke="#DFDFDF" stroke-linecap="round" stroke-width="4" d="M63,88 C87.300529,88 107,68.300529 107,44 C107,19.699471 87.300529,0 63,0 C38.699471,0 19,19.699471 19,44 C19,55.4692579 23.3882741,65.9135795 30.5774088,73.7455512"/>
+ <path stroke="#DFDFDF" stroke-linecap="round" stroke-width="4" d="M52,142 L119,142 C133.911688,142 146,129.911688 146,115 C146,100.088312 133.911688,88 119,88 C104.088312,88 92,100.088312 92,115 C92,122.037954 94.6928046,128.446969 99.104319,133.252952" transform="matrix(1 0 0 -1 0 230)"/>
+ <path fill="#A7A7A7" d="M128 106C129.6569 106 131 107.343145 131 109L131 121C131 122.6569 129.6569 124 128 124L114.06641 124 109.250585 126.78325C108.250579 127.3612 107 126.63955 107 125.48455L107 109C107 107.343145 108.343147 106 110 106L128 106zM128 109L110 109 110 122.8852 113.26184 121 128 121 128 109zM114.5 113.5C115.32842 113.5 116 114.17158 116 115 116 115.82842 115.32842 116.5 114.5 116.5 113.67158 116.5 113 115.82842 113 115 113 114.17158 113.67158 113.5 114.5 113.5zM119 113.5C119.82842 113.5 120.5 114.17158 120.5 115 120.5 115.82842 119.82842 116.5 119 116.5 118.17158 116.5 117.5 115.82842 117.5 115 117.5 114.17158 118.17158 113.5 119 113.5zM123.5 113.5C124.32845 113.5 125 114.17158 125 115 125 115.82842 124.32845 116.5 123.5 116.5 122.67155 116.5 122 115.82842 122 115 122 114.17158 122.67155 113.5 123.5 113.5zM47 36C47 33.790862 48.790862 32 51 32L75 32C77.2092 32 79 33.790862 79 36L79 52C79 54.2092 77.2092 56 75 56L51 56C48.790862 56 47 54.2092 47 52L47 36zM51 36L75 36 75 36.0154 63.0079 42.93904 51 36.0063 51 36zM51 40.6251L51 52 75 52 75 40.6342 63.0079 47.55786 51 40.6251z"/>
+ </g>
+ <path stroke="#C2B7E6" stroke-linecap="round" stroke-width="4" d="M276.5,20 L276.5,165"/>
+ <use fill="#6E49CB" xlink:href="#a"/>
+ <use fill="#FFFFFF" fill-opacity=".6" xlink:href="#a"/>
+ <g transform="translate(172 40)">
+ <path fill="#6E49CB" fill-rule="nonzero" d="M64.5083266,2.16939521 C64.5598976,1.31008332 65.1555623,0.580183202 65.9870892,0.357376239 L67.0659897,0.0682857185 C67.8975166,-0.154521245 68.7783275,0.179758436 69.2526452,0.898158883 L71.0838835,3.67168101 C71.8604055,3.69835108 72.6253745,3.80075177 73.3696161,3.97339039 L75.8570965,1.76768551 C76.501214,1.19651341 77.4383928,1.10164098 78.1839968,1.53205032 L79.1513003,2.09052325 C79.8969043,2.52093259 80.2832521,3.38015574 80.1106561,4.22354464 L79.4443144,7.48050479 C79.9657604,8.03872555 80.4370489,8.65007844 80.8482561,9.30920953 L84.1658391,9.50834112 C85.025263,9.55988206 85.7551052,10.1555623 85.9779122,10.9870892 L86.2670027,12.0659897 C86.4898096,12.8975166 86.1555879,13.778312 85.4370754,14.2526597 L82.6635301,16.0839042 C82.6369953,16.86039 82.534498,17.6253848 82.3620332,18.3695798 L84.5676029,20.8570965 C85.1387232,21.5010208 85.2337633,22.4383618 84.8032767,23.1839864 L84.2448038,24.1512899 C83.8142654,24.8967214 82.9552293,25.2832262 82.111821,25.1106354 L78.8547318,24.4441212 C78.2965242,24.9657707 77.6852679,25.4370334 77.0260789,25.8482561 L76.8269473,29.1658391 C76.7754063,30.025263 76.1797261,30.7551052 75.3481992,30.9779122 L74.2692987,31.2670027 C73.4377718,31.4898096 72.5569764,31.1555879 72.0826287,30.4370754 L70.2513842,27.6635301 C69.4749563,27.6369798 68.7098843,27.5345032 67.9657472,27.3620229 L65.478263,29.5677909 C64.8341648,30.1389578 63.89683,30.2337892 63.1512826,29.8032819 L62.1839598,29.2448141 C61.4384642,28.8145 61.0520043,27.9552448 61.2245757,27.1118417 L61.8910899,23.8547525 C61.369479,23.2965346 60.898313,22.6852524 60.486955,22.0260996 L57.1693952,21.8269618 C56.3100833,21.7753908 55.5801832,21.1797261 55.3573762,20.3481992 L55.0682857,19.2692987 C54.8454788,18.4377718 55.1797584,17.5569609 55.8981589,17.0826432 L58.671681,15.2514049 C58.6983614,14.4749215 58.8007311,13.7098367 58.9733555,12.9656196 L56.7676172,10.4781688 C56.1964503,9.83407059 56.1015416,8.89675656 56.5319717,8.15122986 L57.0904394,7.18390704 C57.5208695,6.43838035 58.380086,6.05193078 59.2234504,6.22451259 L62.4805641,6.89104094 C63.0387487,6.36945971 63.6501081,5.89827293 64.3091888,5.48695498 L64.5083266,2.16939521 Z M72.7381966,23.3950508 C77.00585,22.2515365 79.5385651,17.8647453 78.3950508,13.5970918 C77.2515158,9.32936108 72.8647453,6.79672328 68.5970918,7.94023759 C64.3293611,9.0837726 61.7967026,13.4704658 62.9402376,17.7381966 C64.0837519,22.00585 68.4704658,24.5385858 72.7381966,23.3950508 Z"/>
+ <path fill="#EFEDF8" stroke="#6E49CB" stroke-width="4" d="M27.08832,20.735088 C27.63276,19.10172 29.16132,18 30.88304,18 L33.11696,18 C34.83868,18 36.36724,19.10172 36.91168,20.735088 L39.01368,27.04104 C40.5,27.49452 41.9248,28.08832 43.2732,28.80708 L49.2204,25.8336 C50.7604,25.0636 52.62,25.36544 53.8376,26.58288 L55.4172,28.16248 C56.6348,29.37992 56.9364,31.2398 56.1664,32.77976 L53.1932,38.7268 C53.9116,40.07512 54.5056,41.50012 54.9588,42.98632 L61.2648,45.08832 C62.8984,45.63276 64,47.16132 64,48.88304 L64,51.11696 C64,52.83868 62.8984,54.36724 61.2648,54.91168 L54.9588,57.01368 C54.5056,58.5 53.9116,59.9248 53.1932,61.2732 L56.1664,67.2204 C56.9364,68.76 56.6348,70.62 55.4172,71.8376 L53.8376,73.4172 C52.62,74.6344 50.7604,74.9364 49.2204,74.1664 L43.2732,71.1928 C41.9248,71.9116 40.5,72.5056 39.01368,72.9588 L36.91168,79.2648 C36.36724,80.8984 34.83868,82 33.11696,82 L30.88304,82 C29.16132,82 27.63276,80.8984 27.08832,79.2648 L24.98632,72.9588 C23.50012,72.5056 22.07516,71.9116 20.72688,71.1932 L14.77964,74.1668 C13.23968,74.9368 11.3798,74.6348 10.16236,73.4172 L8.58272,71.8376 C7.36528,70.6204 7.06348,68.7604 7.83344,67.2204 L10.80704,61.2732 C10.08832,59.9248 9.49452,58.5 9.04104,57.01368 L2.735088,54.91168 C1.10172,54.36724 0,52.83868 0,51.11696 L0,48.88304 C0,47.16132 1.10172,45.63276 2.735088,45.08832 L9.04104,42.98632 C9.49452,41.50008 10.08832,40.07504 10.80704,38.72668 L7.83348,32.77952 C7.06348,31.23956 7.36532,29.37968 8.58276,28.16224 L10.16236,26.5826 C11.3798,25.36516 13.23972,25.06336 14.77964,25.83332 L20.72688,28.80696 C22.0752,28.08828 23.50016,27.49448 24.98632,27.04104 L27.08832,20.735088 Z M32,66 C40.8364,66 48,58.8364 48,50 C48,41.16344 40.8364,34 32,34 C23.16344,34 16,41.16344 16,50 C16,58.8364 23.16344,66 32,66 Z"/>
+ <circle cx="32" cy="50" r="10" stroke="#6E49CB" stroke-linecap="round" stroke-width="2"/>
+ </g>
+ <g stroke="#FC6D26" transform="translate(123 78)">
+ <circle cx="12" cy="12" r="11" fill="#FFFFFF" stroke-width="2"/>
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M8,12.25 C9.8974359,14.0833333 10.8461538,15 10.8461538,15 C10.8461538,15 12.8974359,13 17,9"/>
+ </g>
+ <g transform="translate(0 40)">
+ <circle cx="50" cy="50" r="48" fill="#FFFFFF" stroke="#FC6D26" stroke-width="4"/>
+ <circle cx="21" cy="50" r="4" fill="#6E49CB"/>
+ <circle cx="79" cy="50" r="4" fill="#6E49CB"/>
+ <circle cx="50" cy="50" r="27" fill="#FFFFFF" stroke="#E1DBF1" stroke-width="4"/>
+ <rect width="38" height="24" x="31" y="38" fill="#FFFFFF" stroke="#E1DBF1" stroke-width="2" rx="12"/>
+ <circle cx="50" cy="69" r="2" fill="#6E49CB"/>
+ <circle cx="50" cy="69" r="2" fill="#6E49CB"/>
+ <circle cx="55" cy="69" r="1" fill="#6E49CB"/>
+ <circle cx="45" cy="69" r="1" fill="#6E49CB"/>
+ <path stroke="#6E49CB" stroke-linecap="round" stroke-width="2" d="M48 30L52 30M15 50L19 50M81 50L85 50M48 33.5L52 33.5"/>
+ <path fill="#6E49CB" d="M54.214 52.70154C54.9314 53.11584 55.177 54.0332 54.7628 54.7506 54.2804 55.5856 53.58722 56.2792 52.7524 56.7618 51.91758 57.2442 50.97058 57.4988 50.00632 57.5000085 49.04208 57.5012 48.09448 57.2488 47.25856 56.768 46.42264 56.2874 45.72774 55.5956 45.24358 54.7616 44.8276 54.0452 45.07118 53.12726 45.7876 52.71128 46.4443183 52.3299833 47.2704031 52.5028667 47.7239338 53.0861543L47.83798 53.2553C48.05804 53.63434 48.3739 53.94886 48.75388 54.1674 49.13384 54.3858 49.56456 54.5006 50.00286 54.5 50.44116 54.4994 50.8716 54.3838 51.25108 54.1644 51.554648 53.988944 51.8170384 53.7520992 52.0220822 53.470055L52.16486 53.2503C52.57918 52.53292 53.49658 52.28722 54.214 52.70154zM41 46C42.10456 46 43 46.89544 43 48 43 49.10456 42.10456 50 41 50 39.89544 50 39 49.10456 39 48 39 46.89544 39.89544 46 41 46zM59 46C60.1046 46 61 46.89544 61 48 61 49.10456 60.1046 50 59 50 57.89544 50 57 49.10456 57 48 57 46.89544 57.89544 46 59 46z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml
index 436bd305df1..cab0adf159b 100644
--- a/app/views/shared/file_hooks/_index.html.haml
+++ b/app/views/shared/file_hooks/_index.html.haml
@@ -1,6 +1,6 @@
- file_hooks = Gitlab::FileHook.files
-.row.prepend-top-default
+.row.gl-mt-3
.col-lg-4
%h4.gl-mt-0
= _('File Hooks')
@@ -9,7 +9,7 @@
= link_to _('For more information, see the File Hooks documentation.'), help_page_path('administration/file_hooks')
- .col-lg-8.append-bottom-default
+ .col-lg-8.gl-mb-3
- if file_hooks.any?
.card
.card-header
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 77af4f09408..413df29da77 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -1,20 +1,16 @@
- project = local_assigns.fetch(:project)
- model = local_assigns.fetch(:model)
-
-
-
- form = local_assigns.fetch(:form)
- placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a comment or drag your files here…')
-- supports_quick_actions = model.new_record?
-- if supports_quick_actions
- - preview_url = preview_markdown_path(project, target_type: model.class.name)
-- else
- - preview_url = preview_markdown_path(project)
+- supports_quick_actions = true
+- preview_url = preview_markdown_path(project, target_type: model.class.name)
.form-group.row.detail-page-description
= form.label :description, 'Description', class: 'col-form-label col-sm-2'
.col-sm-10
+ - if model.is_a?(MergeRequest)
+ = hidden_field_tag :merge_request_diff_head_sha, model.diff_head_sha
- if model.is_a?(Issuable)
= render 'shared/issuable/form/template_selector', issuable: model
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index f4915440cb2..9d2d3ce20c7 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -8,7 +8,7 @@
- else
- default_sort_by = sort_value_recently_created
-.dropdown.inline.js-group-filter-dropdown-wrap.append-right-10
+.dropdown.inline.js-group-filter-dropdown-wrap.gl-mr-3
%button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.dropdown-label
= options_hash[default_sort_by]
diff --git a/app/views/shared/icons/_icon_service_desk.svg b/app/views/shared/icons/_icon_service_desk.svg
new file mode 100644
index 00000000000..2886388279e
--- /dev/null
+++ b/app/views/shared/icons/_icon_service_desk.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><rect width="7" height="1" x="59" y="38" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M60.5 42a3.5 3.5 0 0 0 0-7v7z"/><rect width="7" height="1" x="12" y="38" fill="#E1DBF2" transform="matrix(-1 0 0 1 31 0)" rx=".5"/><path fill="#6B4FBB" d="M17.5 42a3.5 3.5 0 0 1 0-7v7z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M39 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M35 56a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M26.5 40c0 4.143 3.355 7.5 7.494 7.5h10.012A7.497 7.497 0 0 0 51.5 40c0-4.143-3.355-7.5-7.494-7.5H33.994A7.497 7.497 0 0 0 26.5 40zm-3 0c0-5.799 4.698-10.5 10.494-10.5h10.012C49.802 29.5 54.5 34.2 54.5 40c0 5.799-4.698 10.5-10.494 10.5H33.994C28.198 50.5 23.5 45.8 23.5 40z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M35.255 42.406a1 1 0 1 1 1.872-.703 2.001 2.001 0 0 0 3.76-.038 1 1 0 1 1 1.886.665 4 4 0 0 1-7.518.076zM31.5 40a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm15 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/><path fill="#6B4FBB" d="M38 22h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2zm0 3h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z" style="mix-blend-mode:multiply"/></g></svg> \ No newline at end of file
diff --git a/app/views/shared/integrations/edit.html.haml b/app/views/shared/integrations/edit.html.haml
index 927d2410132..a996f72e2f4 100644
--- a/app/views/shared/integrations/edit.html.haml
+++ b/app/views/shared/integrations/edit.html.haml
@@ -1,5 +1,6 @@
- add_to_breadcrumbs _('Integrations'), scoped_integrations_path
- breadcrumb_title @integration.title
- page_title @integration.title, _('Integrations')
+- @content_class = 'limit-container-width' unless fluid_layout
= render 'shared/integrations/form', integration: @integration
diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml
index ae0e5e45afe..b6cf23faff8 100644
--- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml
+++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml
@@ -1,4 +1,4 @@
-.dropdown.prepend-left-10#js-add-list
+.dropdown.gl-ml-3#js-add-list
%button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list
.dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
index 4bc6c1dee37..ec7ff127ed5 100644
--- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -1,4 +1,6 @@
- type = local_assigns.fetch(:type)
+- bulk_issue_health_status_flag = Feature.enabled?(:bulk_update_health_status, @project&.group, default_enabled: true) && type == :issues && @project&.group&.feature_available?(:issuable_health_status)
+- epic_bulk_edit_flag = @project&.group&.feature_available?(:epics) && type == :issues
%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden
@@ -26,6 +28,13 @@
- field_name = "update[assignee_ids][]"
= dropdown_tag(_("Select assignee"), options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: _("Assign to"), filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
placeholder: _("Search authors"), data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } })
+ - if epic_bulk_edit_flag
+ .block
+ .title
+ = _('Epic')
+ .filter-item.epic-bulk-edit
+ #js-epic-select-root{ data: { group_id: @project&.group&.id, show_header: "true" } }
+ %input{ id: 'issue_epic_id', type: 'hidden', name: 'update[epic_id]' }
.block
.title
= _('Milestone')
@@ -36,6 +45,13 @@
= _('Labels')
.filter-item.labels-filter
= render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: _("Apply a label"), show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: _("Labels") }, label_name: _("Select labels"), no_default_styles: true
+ - if bulk_issue_health_status_flag
+ .block
+ .title
+ = _('Health status')
+ .filter-item.health-status.health-status-filter
+ #js-bulk-update-health-status-root
+ %input{ id: 'issue_health_status_value', type: 'hidden', name: 'update[health_status]' }
.block
.title
= _('Subscriptions')
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
index 5f7cfdc9d03..59d0c46b92f 100644
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -1,6 +1,5 @@
- is_current_user = issuable_author_is_current_user(issuable)
- display_issuable_type = issuable_display_type(issuable)
-- button_method = issuable_close_reopen_button_method(issuable)
- are_close_and_open_buttons_hidden = issuable_button_hidden?(issuable, true) && issuable_button_hidden?(issuable, false)
- add_blocked_class = false
- if defined? warn_before_close
@@ -8,11 +7,13 @@
- if is_current_user
- if can_update
- = link_to _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, close_issuable_path(issuable), method: button_method,
- class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, data: { qa_selector: 'close_issue_button' }
+ %button{ class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}",
+ data: { remote: 'true', endpoint: close_issuable_path(issuable), qa_selector: 'close_issue_button' } }
+ = _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }
- if can_reopen
- = link_to _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, reopen_issuable_path(issuable), method: button_method,
- class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, data: { qa_selector: 'reopen_issue_button' }
+ %button{ class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}",
+ data: { remote: 'true', endpoint: reopen_issuable_path(issuable), qa_selector: 'reopen_issue_button' } }
+ = _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }
- else
- if can_update && !are_close_and_open_buttons_hidden
= render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class
diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
index 9d718083d2d..3fc6a3b545b 100644
--- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
+++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
@@ -4,14 +4,13 @@
- button_responsive_class = 'd-none d-sm-none d-md-block'
- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button"
- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle"
-- button_method = issuable_close_reopen_button_method(issuable)
- add_blocked_class = false
- if defined? warn_before_close
- add_blocked_class = !issuable.closed? && warn_before_close
-.float-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown
- = link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_path(issuable),
- method: button_method, class: "#{button_class} btn-#{button_action} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: "#{display_button_action} #{display_issuable_type}", data: { qa_selector: 'close_issue_button' }
+.float-left.btn-group.gl-ml-3.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown
+ %button{ class: "#{button_class} btn-#{button_action} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", data: { qa_selector: 'close_issue_button', endpoint: close_reopen_issuable_path(issuable) } }
+ #{display_button_action} #{display_issuable_type}
= button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color",
data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => _('Toggle dropdown') do
@@ -20,7 +19,7 @@
%ul#issuable-close-menu.js-issuable-close-menu.dropdown-menu{ data: { dropdown: true } }
%li.close-item{ class: "#{issuable_button_visibility(issuable, true) || 'droplab-item-selected'}",
data: { text: _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: close_issuable_path(issuable),
- button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color", method: button_method } }
+ button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color" } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
@@ -30,7 +29,7 @@
%li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}",
data: { text: _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: reopen_issuable_path(issuable),
- button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color", method: button_method } }
+ button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color" } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 1b3ad484bcc..f54457b8b33 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -35,7 +35,7 @@
= render_if_exists 'shared/issuable/approvals', issuable: issuable, presenter: presenter, form: form
-= render 'shared/issuable/form/merge_params', issuable: issuable
+= render 'shared/issuable/form/merge_params', issuable: issuable, project: project
= render 'shared/issuable/form/contribution', issuable: issuable, form: form
@@ -69,7 +69,7 @@
= link_to 'Delete', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable], params: { destroy_confirm: true }), data: { confirm: "#{issuable.human_class_name} will be removed! Are you sure?" }, method: :delete, class: 'btn btn-danger btn-grouped'
= link_to 'Cancel', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), class: 'btn btn-grouped btn-cancel'
- %span.append-right-10
+ %span.gl-mr-3
- if issuable.new_record?
= form.submit "Submit #{issuable.class.model_name.human.downcase}", class: 'btn btn-success qa-issuable-create-button'
- else
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index d53ec4d4eeb..0b5700e5413 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -135,7 +135,7 @@
%li.filter-dropdown-item
%button.btn.btn-link{ type: 'button' }
%gl-emoji
- %span.js-data-value.prepend-left-10
+ %span.js-data-value.gl-ml-3
{{name}}
#js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true } }
@@ -172,7 +172,7 @@
- if user_can_admin_list
= render 'shared/issuable/board_create_list_dropdown', board: board
- if @project
- #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
+ #js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
- if Feature.enabled?(:boards_with_swimlanes, @group)
#js-board-epics-swimlanes-toggle
#js-toggle-focus-btn
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index ab4bd88cfe5..00113b2c2c0 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -42,7 +42,7 @@
= _('Milestone')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" }
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_milestone_link", track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" }
.value.hide-collapsed
- if milestone.present?
= link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link', qa_title: milestone[:title] }
@@ -107,7 +107,7 @@
= _('Labels')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right', data: { track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_labels_link", track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
.value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?), data: { qa_selector: 'labels_block' } }
- if selected_labels.any?
- selected_labels.each do |label_hash|
diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml
index 9c151dc96f3..81dbecb430b 100644
--- a/app/views/shared/issuable/_sort_dropdown.html.haml
+++ b/app/views/shared/issuable/_sort_dropdown.html.haml
@@ -2,7 +2,7 @@
- sort_title = issuable_sort_option_title(sort_value)
- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues'
-.dropdown.inline.prepend-left-10.issue-sort-dropdown
+.dropdown.inline.gl-ml-3.issue-sort-dropdown
.btn-group{ role: 'group' }
.btn-group{ role: 'group' }
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml
index 3794a3b3845..1823c5279e5 100644
--- a/app/views/shared/issuable/form/_branch_chooser.html.haml
+++ b/app/views/shared/issuable/form/_branch_chooser.html.haml
@@ -18,7 +18,7 @@
- elsif issuable.for_fork?
%code= issuable.target_project_path + ":"
- unless issuable.new_record?
- %span.dropdown.prepend-left-5.d-inline-block
+ %span.dropdown.gl-ml-2.d-inline-block
= form.hidden_field(:target_branch,
{ class: 'target_branch js-target-branch-select ref-name mw-xl',
data: { placeholder: _('Select branch'), endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }})
diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml
index a78231b37ce..dc6abfd2c9e 100644
--- a/app/views/shared/issuable/form/_contribution.html.haml
+++ b/app/views/shared/issuable/form/_contribution.html.haml
@@ -11,7 +11,7 @@
%label.col-form-label.col-sm-2
= _('Contribution')
.col-sm-10
- .form-check.prepend-top-5
+ .form-check.gl-mt-2
= form.check_box :allow_collaboration, disabled: !issuable.can_allow_collaboration?(current_user), class: 'form-check-input'
= form.label :allow_collaboration, class: 'form-check-label' do
= _('Allow commits from members who can merge to the target branch.')
diff --git a/app/views/shared/issuable/form/_default_templates.html.haml b/app/views/shared/issuable/form/_default_templates.html.haml
index 49a5ce926b3..3dc244677e2 100644
--- a/app/views/shared/issuable/form/_default_templates.html.haml
+++ b/app/views/shared/issuable/form/_default_templates.html.haml
@@ -1,4 +1,4 @@
%p.form-text.text-muted
Add
- = link_to 'description templates', help_page_path('user/project/description_templates'), tabindex: -1
+ = link_to 'description templates', help_page_path('user/project/description_templates')
to help your contributors communicate effectively!
diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml
index 1b557214e02..6f1023474a1 100644
--- a/app/views/shared/issuable/form/_merge_params.html.haml
+++ b/app/views/shared/issuable/form/_merge_params.html.haml
@@ -1,4 +1,5 @@
- issuable = local_assigns.fetch(:issuable)
+- project = local_assigns.fetch(:project)
- return unless issuable.is_a?(MergeRequest)
- return if issuable.closed_without_fork?
@@ -9,14 +10,22 @@
= _('Merge options')
.col-sm-10
- if issuable.can_remove_source_branch?(current_user)
- .form-check.append-bottom-default
+ .form-check.gl-mb-3
= hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil
= check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input'
= label_tag 'merge_request[force_remove_source_branch]', class: 'form-check-label' do
Delete source branch when merge request is accepted.
- .form-check
- = hidden_field_tag 'merge_request[squash]', '0', id: nil
- = check_box_tag 'merge_request[squash]', '1', issuable.squash, class: 'form-check-input'
- = label_tag 'merge_request[squash]', class: 'form-check-label' do
- Squash commits when merge request is accepted.
- = link_to icon('question-circle'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank'
+ - if !project.squash_never?
+ .form-check
+ - if project.squash_always?
+ = hidden_field_tag 'merge_request[squash]', '1', id: nil
+ = check_box_tag 'merge_request[squash]', '1', project.squash_enabled_by_default?, class: 'form-check-input', disabled: 'true'
+ - else
+ = hidden_field_tag 'merge_request[squash]', '0', id: nil
+ = check_box_tag 'merge_request[squash]', '1', issuable_squash_option?(issuable, project), class: 'form-check-input'
+ = label_tag 'merge_request[squash]', class: 'form-check-label' do
+ Squash commits when merge request is accepted.
+ = link_to icon('question-circle'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank'
+ - if project.squash_always?
+ .gl-text-gray-600
+ = _('Required in this project.')
diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml
index 75e9ab547ce..355a6627b8f 100644
--- a/app/views/shared/issuable/form/_title.html.haml
+++ b/app/views/shared/issuable/form/_title.html.haml
@@ -11,7 +11,7 @@
- if issuable.respond_to?(:work_in_progress?)
.form-text.text-muted
.js-wip-explanation
- %a.js-toggle-wip{ href: '', tabindex: -1 }
+ %a.js-toggle-wip{ href: '' }
Remove the
%code WIP:
prefix from the title
@@ -22,7 +22,7 @@
- if has_wip_commits
It looks like you have some WIP commits in this branch.
%br
- %a.js-toggle-wip{ href: '', tabindex: -1 }
+ %a.js-toggle-wip{ href: '' }
Start the title with
%code WIP:
to prevent a
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index f7d90a588c7..79dc3043e8d 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -62,12 +62,12 @@
- if show_controls && member.source == current_resource
- if member.can_resend_invite?
- = link_to icon('paper-plane'), polymorphic_path([:resend_invite, member]),
+ = link_to sprite_icon('paper-airplane', size: 16), polymorphic_path([:resend_invite, member]),
method: :post,
class: 'btn btn-default align-self-center mr-sm-2',
title: _('Resend invite')
- - if user != current_user && member.can_update? && !user&.project_bot?
+ - if user != current_user && member.can_update?
= form_for member, remote: true, html: { class: "js-edit-member-form form-group #{'d-sm-flex' unless force_mobile_view}" } do |f|
= f.hidden_field :access_level
.member-form-control.dropdown{ class: [("mr-sm-2 d-sm-inline-block" unless force_mobile_view)] }
@@ -117,12 +117,10 @@
method: :delete,
data: { confirm: leave_confirmation_message(member.source) },
class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}"
- - elsif !user&.project_bot?
- = link_to member,
- method: :delete,
- data: { confirm: remove_member_message(member), qa_selector: 'delete_member_button' },
- class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}",
- title: remove_member_title(member) do
+ - else
+ %button{ data: { member_path: member_path(member.member), message: remove_member_message(member), is_access_request: member.request?.to_s, qa_selector: 'delete_member_button' },
+ class: "js-remove-member-button btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}",
+ title: remove_member_title(member) }
%span{ class: ('d-block d-sm-none' unless force_mobile_view) }
= _("Delete")
- unless force_mobile_view
diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml
index 1f62c3cbcf4..e1e7aa36a78 100644
--- a/app/views/shared/members/_requests.html.haml
+++ b/app/views/shared/members/_requests.html.haml
@@ -4,7 +4,7 @@
- return if requesters.empty?
-.card.prepend-top-default{ class: ('card-mobile' if force_mobile_view ) }
+.card.gl-mt-3{ class: ('card-mobile' if force_mobile_view ) }
.card-header
= _("Users requesting access to")
%strong= membership_source.name
diff --git a/app/views/shared/milestones/_deprecation_message.html.haml b/app/views/shared/milestones/_deprecation_message.html.haml
index ba5eb54f017..27cd6d75232 100644
--- a/app/views/shared/milestones/_deprecation_message.html.haml
+++ b/app/views/shared/milestones/_deprecation_message.html.haml
@@ -1,6 +1,6 @@
.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
+ .banner-body.gl-ml-3.gl-mr-3
%h5.banner-title.gl-mt-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'
diff --git a/app/views/shared/milestones/_description.html.haml b/app/views/shared/milestones/_description.html.haml
index 5ff110bf94b..76d6c765ed6 100644
--- a/app/views/shared/milestones/_description.html.haml
+++ b/app/views/shared/milestones/_description.html.haml
@@ -1,8 +1,9 @@
.detail-page-description.milestone-detail
- %h2.title
+ %h2{ data: { qa_selector: "milestone_title_content" } }
+ .title
= markdown_field(milestone, :title)
- if milestone.try(:description).present?
- %div
+ %div{ data: { qa_selector: "milestone_description_content" } }
.description.md
= markdown_field(milestone, :description)
diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml
index 6dbc460d9bf..e995584309a 100644
--- a/app/views/shared/milestones/_form_dates.html.haml
+++ b/app/views/shared/milestones/_form_dates.html.haml
@@ -3,11 +3,11 @@
.col-form-label.col-sm-2
= f.label :start_date, _('Start Date')
.col-sm-10
- = f.text_field :start_date, class: "datepicker form-control", placeholder: _('Select start date'), autocomplete: 'off'
- %a.inline.float-right.prepend-top-5.js-clear-start-date{ href: "#" }= _('Clear start date')
+ = f.text_field :start_date, class: "datepicker form-control", data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off'
+ %a.inline.float-right.gl-mt-2.js-clear-start-date{ href: "#" }= _('Clear start date')
.form-group.row
.col-form-label.col-sm-2
= f.label :due_date, _('Due Date')
.col-sm-10
- = f.text_field :due_date, class: "datepicker form-control", placeholder: _('Select due date'), autocomplete: 'off'
- %a.inline.float-right.prepend-top-5.js-clear-due-date{ href: "#" }= _('Clear due date')
+ = f.text_field :due_date, class: "datepicker form-control", data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off'
+ %a.inline.float-right.gl-mt-2.js-clear-due-date{ href: "#" }= _('Clear due date')
diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml
index 99a46f1fb85..ea90b674b34 100644
--- a/app/views/shared/milestones/_header.html.haml
+++ b/app/views/shared/milestones/_header.html.haml
@@ -33,4 +33,4 @@
= render 'shared/milestones/delete_button'
%button.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ type: 'button' }
- = icon('angle-double-left')
+ = sprite_icon('chevron-double-lg-left')
diff --git a/app/views/shared/milestones/_issues_tab.html.haml b/app/views/shared/milestones/_issues_tab.html.haml
index 6684f6d752a..dc54eefbaa9 100644
--- a/app/views/shared/milestones/_issues_tab.html.haml
+++ b/app/views/shared/milestones/_issues_tab.html.haml
@@ -6,7 +6,7 @@
.flash-warning#milestone-issue-count-warning
= milestone_issues_count_message(@milestone)
-.row.prepend-top-default
+.row.gl-mt-3
.col-md-4
= render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Unstarted Issues (open and unassigned)'), issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true)
.col-md-4
diff --git a/app/views/shared/milestones/_merge_requests_tab.haml b/app/views/shared/milestones/_merge_requests_tab.haml
index 4dba2473efc..0dbf2b27c8d 100644
--- a/app/views/shared/milestones/_merge_requests_tab.haml
+++ b/app/views/shared/milestones/_merge_requests_tab.haml
@@ -1,7 +1,7 @@
- args = { show_project_name: local_assigns.fetch(:show_project_name, false),
show_full_project_name: local_assigns.fetch(:show_full_project_name, false) }
-.row.prepend-top-default
+.row.gl-mt-3
.col-md-3
= render 'shared/milestones/issuables', args.merge(title: _('Work in progress (open and unassigned)'), issuables: merge_requests.opened.unassigned, id: 'unassigned', show_counter: true)
.col-md-3
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 31505d2d9fb..ae5bf9572bd 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -5,17 +5,18 @@
%li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id }
.row
.col-sm-6
- .append-bottom-5
- %strong= link_to truncate(milestone.title, length: 100), milestone_path(milestone)
+ .gl-mb-2
+ %strong{ data: { qa_selector: "milestone_link", qa_milestone_title: milestone.title } }
+ = link_to truncate(milestone.title, length: 100), milestone_path(milestone)
- if @group
= " - #{milestone_type}"
- if milestone.due_date || milestone.start_date
- .text-tertiary.append-bottom-5
+ .text-tertiary.gl-mb-2
= milestone_date_range(milestone)
- recent_releases, total_count, more_count = recent_releases_with_counts(milestone)
- unless total_count.zero?
- .text-tertiary.append-bottom-5.milestone-release-links
+ .text-tertiary.gl-mb-2.milestone-release-links
= sprite_icon("rocket", size: 12)
= n_('Release', 'Releases', total_count)
- recent_releases.each do |release|
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index 160f6487439..7fd657ec2dd 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -24,7 +24,7 @@
- if @project && can?(current_user, :admin_milestone, @project)
= link_to s_('MilestoneSidebar|Edit'), edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link float-right'
.value
- %span.value-content
+ %span.value-content{ data: { qa_selector: 'start_date_content' } }
- if milestone.start_date
%span.bold= milestone.start_date.to_s(:medium)
- else
@@ -60,7 +60,7 @@
- if @project && can?(current_user, :admin_milestone, @project)
= link_to s_('MilestoneSidebar|Edit'), edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link float-right'
.value.hide-collapsed
- %span.value-content
+ %span.value-content{ data: { qa_selector: 'due_date_content' } }
- if milestone.due_date
%span.bold= milestone.due_date.to_s(:medium)
- else
diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml
index dfca6a184be..fe1184114e9 100644
--- a/app/views/shared/milestones/_tab_loading.html.haml
+++ b/app/views/shared/milestones/_tab_loading.html.haml
@@ -1,2 +1,2 @@
-.text-center.prepend-top-default
+.text-center.gl-mt-3
.spinner.spinner-md
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 538ebe79641..34f476241c6 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -1,6 +1,6 @@
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
+ .fade-left= sprite_icon('chevron-lg-left', size: 12)
+ .fade-right= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.scrolling-tabs.js-milestone-tabs.nav.nav-tabs
%li.nav-item
= link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', show: '.tab-issues-buttons' } do
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index 49df00940b7..4d209c30e7b 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -7,7 +7,7 @@
= render 'shared/milestones/description', milestone: milestone
- if milestone.complete? && milestone.active?
- .alert.alert-success.prepend-top-default
+ .alert.alert-success.gl-mt-3
%span
= _('All issues for this milestone are closed.')
= group ? _('You may close the milestone now.') : _('Navigate to the project to close the milestone.')
diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
index 8d74eacc7dc..e151e55d0d2 100644
--- a/app/views/shared/notes/_comment_button.html.haml
+++ b/app/views/shared/notes/_comment_button.html.haml
@@ -1,7 +1,7 @@
- noteable_name = @note.noteable.human_class_name
-.float-left.btn-group.append-right-10.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown
- %input.btn.btn-nr.btn-success.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment') }
+.float-left.btn-group.gl-mr-3.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown
+ %input.btn.btn-nr.btn-success.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } }
- if @note.can_be_discussion_note?
= button_tag type: 'button', class: 'btn btn-nr dropdown-toggle btn-success js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do
diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml
index 244c191af12..79feb12bed5 100644
--- a/app/views/shared/notes/_edit_form.html.haml
+++ b/app/views/shared/notes/_edit_form.html.haml
@@ -3,12 +3,12 @@
= hidden_field_tag :target_id, '', class: 'js-form-target-id'
= hidden_field_tag :target_type, '', class: 'js-form-target-type'
= render layout: 'shared/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do
- = render 'shared/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: _("Write a comment or drag your files here…")
+ = render 'shared/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', qa_selector: 'edit_note_field', placeholder: _("Write a comment or drag your files here…")
= render 'shared/notes/hints'
.note-form-actions.clearfix
.settings-message.note-edit-warning.js-finish-edit-warning
= _("Finish editing this message first!")
- = submit_tag _('Save comment'), class: 'btn btn-nr btn-success js-comment-save-button'
+ = submit_tag _('Save comment'), class: 'btn btn-nr btn-success js-comment-save-button', data: { qa_selector: 'save_comment_button' }
%button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' }
= _("Cancel")
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index 40e36728642..f1686417f8d 100644
--- a/app/views/shared/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -26,7 +26,7 @@
.discussion-form-container.discussion-with-resolve-btn.flex-column.p-0
= render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true } do
- = render 'shared/zen', f: f,
+ = render 'shared/zen', f: f, qa_selector: 'note_field',
attr: :note,
classes: 'note-textarea js-note-text',
placeholder: _("Write a comment or drag your files here…"),
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index 902a6e9b363..abd5d8cd9db 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -1,10 +1,10 @@
- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
.comment-toolbar.clearfix
.toolbar-text
- = link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank', tabindex: -1
+ = link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank'
- if supports_quick_actions
and
- = link_to _('quick actions'), help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1
+ = link_to _('quick actions'), help_page_path('user/project/quick_actions'), target: '_blank'
are
- else
is
@@ -12,24 +12,23 @@
%span.uploading-container
%span.uploading-progress-container.hide
- = icon('file-image-o', class: 'toolbar-button-icon')
+ = sprite_icon('media', size: 16, css_class: 'gl-icon gl-vertical-align-text-bottom')
%span.attaching-file-message
-# Populated by app/assets/javascripts/dropzone_input.js
%span.uploading-progress 0%
- %span.uploading-spinner
- .toolbar-button-icon.spinner.align-text-top
+ = loading_icon(css_class: 'align-text-bottom gl-mr-2')
%span.uploading-error-container.hide
%span.uploading-error-icon
- = icon('file-image-o', class: 'toolbar-button-icon')
+ = sprite_icon('media', size: 16, css_class: 'gl-icon gl-vertical-align-text-bottom')
%span.uploading-error-message
-# Populated by app/assets/javascripts/dropzone_input.js
%button.retry-uploading-link{ type: 'button' }= _("Try again")
or
%button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file")
- %button.markdown-selector.button-attach-file.btn-link{ type: 'button', tabindex: '-1' }
- = icon('file-image-o', class: 'toolbar-button-icon')
+ %button.btn.markdown-selector.button-attach-file.btn-link{ type: 'button', tabindex: '-1' }
+ = sprite_icon('media', size: 16)
%span.text-attach-file<>
= _("Attach a file")
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index e6c8e13c5c1..95450a5df3c 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -34,7 +34,7 @@
%span.note-header-author-name.bold
= note.author.name
= user_status(note.author)
- %span.note-headline-light
+ %span.note-headline-light{ data: { qa_selector: 'note_author_content' } }
= note.author.to_reference
%span.note-headline-light.note-headline-meta
- if note.system
@@ -51,7 +51,7 @@
- else
= render 'projects/notes/actions', note: note, note_editable: note_editable
.note-body{ class: note_editable ? 'js-task-list-container' : '' }
- .note-text.md
+ .note-text.md{ data: { qa_selector: 'note_content' } }
= markdown_field(note, :note)
= edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago')
.original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } }
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index 002189e6ecd..fa103ad447a 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -18,12 +18,12 @@
.timeline-content.timeline-content-form
= render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete
- elsif !current_user
- .disabled-comment.text-center.prepend-top-default
+ .disabled-comment.text-center.gl-mt-3
- link_to_register = link_to(_("register"), new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link')
- link_to_sign_in = link_to(_("sign in"), new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link')
= _("Please %{link_to_register} or %{link_to_sign_in} to comment").html_safe % { link_to_register: link_to_register, link_to_sign_in: link_to_sign_in }
- elsif discussion_locked
- .disabled-comment.text-center.prepend-top-default
+ .disabled-comment.text-center.gl-mt-3
%span.issuable-note-warning
= sprite_icon('lock', size: 16, css_class: 'icon')
%span
diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml
index 796ff095eea..fbcfec5fd96 100644
--- a/app/views/shared/notifications/_new_button.html.haml
+++ b/app/views/shared/notifications/_new_button.html.haml
@@ -8,7 +8,7 @@
- else
- button_title = _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }
- .js-notification-dropdown.notification-dropdown.home-panel-action-button.prepend-top-default.gl-mr-3.dropdown.inline
+ .js-notification-dropdown.notification-dropdown.home-panel-action-button.gl-mt-3.gl-mr-3.dropdown.inline
= form_for notification_setting, remote: true, html: { class: "inline notification-form no-label" } do |f|
= hidden_setting_source_input(notification_setting)
= hidden_field_tag "hide_label", true
diff --git a/app/views/shared/projects/_edit_information.html.haml b/app/views/shared/projects/_edit_information.html.haml
index 9230e045a81..5a2f4328837 100644
--- a/app/views/shared/projects/_edit_information.html.haml
+++ b/app/views/shared/projects/_edit_information.html.haml
@@ -1,5 +1,5 @@
- unless can?(current_user, :push_code, @project)
- .inline.prepend-left-10
+ .inline.gl-ml-3
- if @project.branch_allows_collaboration?(current_user, selected_branch)
= commit_in_single_accessible_branch
- else
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index fc3f1a8d1c1..626e94e0202 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -12,11 +12,10 @@
- css_class += " no-description" if project.description.blank? && !show_last_commit_as_description
- cache_key = project_list_cache_key(project, pipeline_status: pipeline_status)
- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
-- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
+- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) && project.last_pipeline.present?
- css_controls_class = compact_mode ? [] : ["flex-lg-row", "justify-content-lg-between"]
- css_controls_class << "with-pipeline-status" if show_pipeline_status_icon
- avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar'
-- license_name = project_license_name(project)
%li.project-row.d-flex{ class: css_class }
= cache(cache_key) do
@@ -40,13 +39,13 @@
%span.project-name<
= project.name
- %span.metadata-info.visibility-icon.append-right-10.gl-mt-3.text-secondary.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) }
+ %span.metadata-info.visibility-icon.gl-mr-3.gl-mt-3.text-secondary.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) }
= visibility_level_icon(project.visibility_level, fw: true)
- - if explore_projects_tab? && license_name
- %span.metadata-info.d-inline-flex.align-items-center.append-right-10.gl-mt-3
- = sprite_icon('scale', size: 14, css_class: 'append-right-4')
- = license_name
+ - if explore_projects_tab? && project_license_name(project)
+ %span.metadata-info.d-inline-flex.align-items-center.gl-mr-3.gl-mt-3
+ = sprite_icon('scale', size: 14, css_class: 'gl-mr-2')
+ = project_license_name(project)
- if !explore_projects_tab? && access&.nonzero?
-# haml-lint:disable UnnecessaryStringOutput
@@ -59,10 +58,10 @@
= render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project
- if show_last_commit_as_description
- .description.d-none.d-sm-block.append-right-default
+ .description.d-none.d-sm-block.gl-mr-3
= link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
- elsif project.description.present?
- .description.d-none.d-sm-block.append-right-default
+ .description.d-none.d-sm-block.gl-mr-3
= markdown_field(project, :description)
.controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class.join(" ") }
@@ -77,25 +76,25 @@
= link_to project_starrers_path(project),
class: "d-flex align-items-center icon-wrapper stars has-tooltip",
title: _('Stars'), data: { container: 'body', placement: 'top' } do
- = sprite_icon('star', size: 14, css_class: 'append-right-4')
+ = sprite_icon('star', size: 14, css_class: 'gl-mr-2')
= number_with_delimiter(project.star_count)
- if forks
= link_to project_forks_path(project),
class: "align-items-center icon-wrapper forks has-tooltip",
title: _('Forks'), data: { container: 'body', placement: 'top' } do
- = sprite_icon('fork', size: 14, css_class: 'append-right-4')
+ = sprite_icon('fork', size: 14, css_class: 'gl-mr-2')
= number_with_delimiter(project.forks_count)
- if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode)
= link_to project_merge_requests_path(project),
class: "d-none d-xl-flex align-items-center icon-wrapper merge-requests has-tooltip",
title: _('Merge Requests'), data: { container: 'body', placement: 'top' } do
- = sprite_icon('git-merge', size: 14, css_class: 'append-right-4')
+ = sprite_icon('git-merge', size: 14, css_class: 'gl-mr-2')
= number_with_delimiter(project.open_merge_requests_count)
- if show_issue_count?(disabled: !issues, compact_mode: compact_mode)
= link_to project_issues_path(project),
class: "d-none d-xl-flex align-items-center icon-wrapper issues has-tooltip",
title: _('Issues'), data: { container: 'body', placement: 'top' } do
- = sprite_icon('issues', size: 14, css_class: 'append-right-4')
+ = sprite_icon('issues', size: 14, css_class: 'gl-mr-2')
= number_with_delimiter(project.open_issues_count)
.updated-note
%span
diff --git a/app/views/shared/promotions/_promote_servicedesk.html.haml b/app/views/shared/promotions/_promote_servicedesk.html.haml
new file mode 100644
index 00000000000..f7f65c34c75
--- /dev/null
+++ b/app/views/shared/promotions/_promote_servicedesk.html.haml
@@ -0,0 +1,13 @@
+.user-callout.promotion-callout.js-service-desk-callout#promote_service_desk{ data: { uid: 'promote_service_desk_dismissed' } }
+ .bordered-box.content-block
+ %button.btn.btn-default.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss Service Desk promotion' }
+ = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
+ .svg-container
+ = custom_icon('icon_service_desk')
+ .user-callout-copy
+ -# haml-lint:disable NoPlainNodes
+ %h4
+ Improve customer support with GitLab Service Desk.
+ %p
+ GitLab Service Desk is a simple way to allow people to create issues in your GitLab instance without needing their own user account. It provides a unique email address for end users to create issues in a project, and replies can be sent either through the GitLab interface or by email. End users will only see the thread through email.
+ = link_to 'Read more', help_page_path('user/project/service_desk.md'), target: '_blank'
diff --git a/app/views/shared/runners/_runner_description.html.haml b/app/views/shared/runners/_runner_description.html.haml
index a47bbd55325..d3e50cfe92f 100644
--- a/app/views/shared/runners/_runner_description.html.haml
+++ b/app/views/shared/runners/_runner_description.html.haml
@@ -1,4 +1,4 @@
-.light.prepend-top-default
+.light.gl-mt-3
%p
= _("You can set up as many Runners as you need to run your jobs.")
%br
diff --git a/app/views/shared/runners/show.html.haml b/app/views/shared/runners/show.html.haml
index f62eed694d2..8a78f12bdd8 100644
--- a/app/views/shared/runners/show.html.haml
+++ b/app/views/shared/runners/show.html.haml
@@ -1,4 +1,4 @@
-- page_title "#{@runner.description} ##{@runner.id}", "Runners"
+- page_title "#{@runner.description} ##{@runner.id}", _("Runners")
%h3.page-title
Runner ##{@runner.id}
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index 7f213c50de2..36b6bfd061f 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -1,6 +1,6 @@
.detail-page-header
.detail-page-header-body
- .snippet-box.has-tooltip.inline.append-right-5{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } }
+ .snippet-box.has-tooltip.inline.gl-mr-2{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } }
%span.sr-only
= visibility_level_label(@snippet.visibility_level)
= visibility_level_icon(@snippet.visibility_level, fw: false)
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index 128ddbb8e8b..b2c9a74b177 100644
--- a/app/views/shared/snippets/_snippet.html.haml
+++ b/app/views/shared/snippets/_snippet.html.haml
@@ -11,7 +11,7 @@
%ul.controls
%li
= link_to gitlab_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if notes_count.zero?) do
- = icon('comments')
+ = sprite_icon('comments', size: 16, css_class: 'gl-vertical-align-text-bottom')
= notes_count
%li
%span.sr-only
diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml
index 470e2f6b904..a957f9f6dfa 100644
--- a/app/views/shared/web_hooks/_hook.html.haml
+++ b/app/views/shared/web_hooks/_hook.html.haml
@@ -10,7 +10,7 @@
= _('SSL Verification:')
= hook.enable_ssl_verification ? _('enabled') : _('disabled')
- .col-md-4.col-lg-5.text-right-md.prepend-top-5
+ .col-md-4.col-lg-5.text-right-md.gl-mt-2
%span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm gl-mr-3'
%span>= link_to _('Edit'), edit_hook_path(hook), class: 'btn btn-sm gl-mr-3'
= link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-sm'
diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml
index 149f4baeb21..794418b8336 100644
--- a/app/views/shared/web_hooks/_index.html.haml
+++ b/app/views/shared/web_hooks/_index.html.haml
@@ -10,5 +10,5 @@
- hooks.each do |hook|
= render 'shared/web_hooks/hook', hook: hook
- else
- %p.text-center.prepend-top-default.append-bottom-default
+ %p.text-center.gl-mt-3.gl-mb-3
= _('No webhooks found, add one in the form above.')
diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml
index 8ea06d4d6c3..92b9207aaa4 100644
--- a/app/views/shared/wikis/_form.html.haml
+++ b/app/views/shared/wikis/_form.html.haml
@@ -1,4 +1,4 @@
-- form_classes = %w[wiki-form common-note-form prepend-top-default js-quick-submit]
+- form_classes = %w[wiki-form common-note-form gl-mt-3 js-quick-submit]
- if @page.persisted?
- form_action = wiki_page_path(@wiki, @page)
@@ -20,7 +20,7 @@
.col-sm-12= f.label :title, class: 'control-label-full-width'
.col-sm-12
= f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title, required: true, autofocus: !@page.persisted?, placeholder: s_('Wiki|Page title')
- %span.d-inline-block.mw-100.prepend-top-5
+ %span.d-inline-block.mw-100.gl-mt-2
= icon('lightbulb-o')
- if @page.persisted?
= s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.")
diff --git a/app/views/shared/wikis/_pages_wiki_page.html.haml b/app/views/shared/wikis/_pages_wiki_page.html.haml
index 534884eb848..b56ae2bf9b1 100644
--- a/app/views/shared/wikis/_pages_wiki_page.html.haml
+++ b/app/views/shared/wikis/_pages_wiki_page.html.haml
@@ -1,5 +1,5 @@
%li
- = link_to wiki_page.title, wiki_page_path(@wiki, wiki_page)
+ = link_to wiki_page.title, wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug }
%small (#{wiki_page.format})
.float-right
- if wiki_page.last_version
diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml
index 8cfb95cdcf5..cddf19fbc8e 100644
--- a/app/views/shared/wikis/_sidebar.html.haml
+++ b/app/views/shared/wikis/_sidebar.html.haml
@@ -1,12 +1,12 @@
%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" } }
.sidebar-container
- .block.wiki-sidebar-header.append-bottom-default.w-100
+ .block.wiki-sidebar-header.gl-mb-3.w-100
%a.gutter-toggle.float-right.d-block.d-sm-block.d-md-none.js-sidebar-wiki-toggle{ href: "#" }
- = icon('angle-double-right')
+ = sprite_icon('chevron-double-lg-right', size: 16, css_class: 'gl-icon')
- git_access_url = wiki_path(@wiki, action: :git_access)
= link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '', data: { qa_selector: 'clone_repository_link' } do
- = icon('cloud-download', class: 'append-right-5')
+ = sprite_icon('download', size: 16, css_class: 'gl-mr-2')
%span= _("Clone repository")
.blocks-container
@@ -18,5 +18,5 @@
= render @sidebar_wiki_entries, context: 'sidebar'
.block.w-100
- if @sidebar_limited
- = link_to wiki_path(@wiki, action: :pages), class: 'btn btn-block' do
+ = link_to wiki_path(@wiki, action: :pages), class: 'btn btn-block', data: { qa_selector: 'view_all_pages_button' } do
= s_("Wiki|View All Pages")
diff --git a/app/views/shared/wikis/_sidebar_wiki_page.html.haml b/app/views/shared/wikis/_sidebar_wiki_page.html.haml
index 2573471f9f9..4259633280a 100644
--- a/app/views/shared/wikis/_sidebar_wiki_page.html.haml
+++ b/app/views/shared/wikis/_sidebar_wiki_page.html.haml
@@ -1,3 +1,3 @@
%li{ class: active_when(params[:id] == wiki_page.slug) }
- = link_to wiki_page_path(@wiki, wiki_page) do
+ = link_to wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug } do
= wiki_page.human_title
diff --git a/app/views/shared/wikis/diff.html.haml b/app/views/shared/wikis/diff.html.haml
new file mode 100644
index 00000000000..6fce3f5894e
--- /dev/null
+++ b/app/views/shared/wikis/diff.html.haml
@@ -0,0 +1,32 @@
+- wiki_page_title @page, _('Changes')
+- commit = @diffs.diffable
+
+.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
+ = wiki_sidebar_toggle_button
+
+ .nav-text
+ %h2.wiki-page-title
+ = link_to_wiki_page @page
+ %span.light
+ &middot;
+ = _('Changes')
+
+ .nav-controls.pb-md-3.pb-lg-0
+ = link_to wiki_page_path(@wiki, @page, action: :history), class: 'btn', role: 'button', data: { qa_selector: 'page_history_button' } do
+ = s_('Wiki|Page history')
+
+.page-content-header
+ .header-main-content
+ %strong= markdown_field(commit, :title)
+ %span.d-none.d-sm-inline= _('authored')
+ #{time_ago_with_tooltip(commit.authored_date)}
+ %span= s_('ByAuthor|by')
+ = author_avatar(commit, size: 24, has_tooltip: false)
+ %strong
+ = commit_author_link(commit, avatar: true, size: 24)
+ - if commit.description.present?
+ %pre.commit-description<
+ = preserve(markdown_field(commit, :description))
+
+= render 'projects/diffs/diffs', diffs: @diffs
+= render 'shared/wikis/sidebar'
diff --git a/app/views/shared/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml
index 5bda8d85627..64a4816def6 100644
--- a/app/views/shared/wikis/edit.html.haml
+++ b/app/views/shared/wikis/edit.html.haml
@@ -1,18 +1,14 @@
-- @content_class = "limit-container-width" unless fluid_layout
-- add_to_breadcrumbs _("Wiki"), wiki_page_path(@wiki, @page)
-- breadcrumb_title @page.persisted? ? _("Edit") : _("New")
-- page_title @page.persisted? ? _("Edit") : _("New"), @page.human_title, _("Wiki")
+- wiki_page_title @page, @page.persisted? ? _('Edit') : _('New')
= wiki_page_errors(@error)
.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
- %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
- = icon('angle-double-left')
+ = wiki_sidebar_toggle_button
.nav-text
%h2.wiki-page-title
- if @page.persisted?
- = link_to @page.human_title, wiki_page_path(@wiki, @page)
+ = link_to_wiki_page @page
%span.light
&middot;
= s_("Wiki|Edit Page")
diff --git a/app/views/shared/wikis/history.html.haml b/app/views/shared/wikis/history.html.haml
index ec07082bd02..f9d21c8fb57 100644
--- a/app/views/shared/wikis/history.html.haml
+++ b/app/views/shared/wikis/history.html.haml
@@ -1,41 +1,38 @@
-- page_title _("History"), @page.human_title, _("Wiki")
+- wiki_page_title @page, _('History')
.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
- %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
- = icon('angle-double-left')
+ = wiki_sidebar_toggle_button
.nav-text
%h2.wiki-page-title
- = link_to @page.human_title, wiki_page_path(@wiki, @page)
+ = link_to_wiki_page @page
%span.light
&middot;
- = _("History")
+ = _('History')
-.table-holder
- %table.table
- %thead
- %tr
- %th= s_("Wiki|Page version")
- %th= _("Author")
- %th= _("Commit Message")
- %th= _("Last updated")
- %th= _("Format")
- %tbody
- - @page_versions.each_with_index do |version, index|
- - commit = version
+.prepend-top-default.gl-mb-3
+ .table-holder
+ %table.table.wiki-history
+ %thead
%tr
- %td
- = link_to wiki_page_path(@wiki, @page, version_id: index == 0 ? nil : commit.id) do
- = truncate_sha(commit.id)
- %td
- = commit.author_name
- %td
- = commit.message
- %td
- #{time_ago_with_tooltip(version.authored_date)}
- %td
- %strong
- = version.format
-= paginate @page_versions, theme: 'gitlab'
+ %th= s_('Wiki|Page version')
+ %th= _('Author')
+ %th= _('Changes')
+ %th= _('Last updated')
+ %tbody
+ - @page_versions.each do |commit|
+ %tr
+ %td
+ = link_to wiki_page_path(@wiki, @page, version_id: commit.id) do
+ = truncate_sha(commit.id)
+ %td
+ = commit.author_name
+ %td
+ %span.str-truncated-60
+ = link_to wiki_page_path(@wiki, @page, action: :diff, version_id: commit.id), { title: commit.message } do
+ = commit.message
+ %td
+ = time_ago_with_tooltip(commit.authored_date)
+ = paginate @page_versions, theme: 'gitlab'
= render 'shared/wikis/sidebar'
diff --git a/app/views/shared/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml
index 987c696cdfe..35a62ec2bb4 100644
--- a/app/views/shared/wikis/pages.html.haml
+++ b/app/views/shared/wikis/pages.html.haml
@@ -11,7 +11,7 @@
.nav-controls.pb-md-3.pb-lg-0
= link_to wiki_path(@wiki, action: :git_access), class: 'btn' do
- = icon('cloud-download')
+ = sprite_icon('download')
= _("Clone repository")
.dropdown.inline.wiki-sort-dropdown
@@ -19,7 +19,7 @@
.btn-group{ role: 'group' }
%button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
= sort_title
- = icon('chevron-down')
+ = sprite_icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
= sortable_item(s_("Wiki|Title"), wiki_path(@wiki, action: :pages, sort: Wiki::TITLE_ORDER), sort_title)
diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml
index a4f3996e5de..a7c734f5af4 100644
--- a/app/views/shared/wikis/show.html.haml
+++ b/app/views/shared/wikis/show.html.haml
@@ -1,19 +1,14 @@
-- @content_class = "limit-container-width" unless fluid_layout
-- breadcrumb_title @page.human_title
-- wiki_breadcrumb_dropdown_links(@page.slug)
-- page_title @page.human_title, _("Wiki")
-- add_to_breadcrumbs _("Wiki"), wiki_path(@wiki)
+- wiki_page_title @page
.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
- %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
- = icon('angle-double-left')
+ = wiki_sidebar_toggle_button
.nav-text.flex-fill
%h2.wiki-page-title{ data: { qa_selector: 'wiki_page_title' } }= @page.human_title
%span.wiki-last-edit-by
- if @page.last_version
= (_("Last edited by %{name}") % { name: "<strong>#{@page.last_version.author_name}</strong>" }).html_safe
- #{time_ago_with_tooltip(@page.last_version.authored_date)}
+ = time_ago_with_tooltip(@page.last_version.authored_date)
.nav-controls.pb-md-3.pb-lg-0
= render 'shared/wikis/main_links'
@@ -25,8 +20,8 @@
- history_link = link_to s_("WikiHistoricalPage|history"), wiki_page_path(@wiki, @page, action: :history)
= (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe
-.prepend-top-default.append-bottom-default
- .md{ data: { qa_selector: 'wiki_page_content' } }
+.gl-mt-3.gl-mb-3
+ .js-wiki-page-content.md{ data: { qa_selector: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json } }
= render_wiki_content(@page)
= render 'shared/wikis/sidebar'
diff --git a/app/views/sherlock/file_samples/show.html.haml b/app/views/sherlock/file_samples/show.html.haml
index 7255d352775..cc8bdbae55d 100644
--- a/app/views/sherlock/file_samples/show.html.haml
+++ b/app/views/sherlock/file_samples/show.html.haml
@@ -12,7 +12,7 @@
= t('sherlock.file_sample')
= @file_sample.id
-.prepend-top-default
+.gl-mt-3
%p
%span.light
#{t('sherlock.time')}:
@@ -32,7 +32,7 @@
= @file_sample.file
.code.file-content.js-syntax-highlight
.line-numbers
- %table.sherlock-line-samples-table
+ %table.sherlock-line-samples-table.gl-mb-0
%thead
%tr
%th= t('sherlock.line_capitalized')
diff --git a/app/views/sherlock/queries/_backtrace.html.haml b/app/views/sherlock/queries/_backtrace.html.haml
index 38b4d2c6102..ff5a6b73e47 100644
--- a/app/views/sherlock/queries/_backtrace.html.haml
+++ b/app/views/sherlock/queries/_backtrace.html.haml
@@ -1,4 +1,4 @@
-.prepend-top-default
+.gl-mt-3
.card
.card-header
%strong
diff --git a/app/views/sherlock/queries/_general.html.haml b/app/views/sherlock/queries/_general.html.haml
index 1514ad55d71..92f4f16f453 100644
--- a/app/views/sherlock/queries/_general.html.haml
+++ b/app/views/sherlock/queries/_general.html.haml
@@ -1,4 +1,4 @@
-.prepend-top-default
+.gl-mt-3
.card
.card-header
%strong
diff --git a/app/views/sherlock/transactions/_general.html.haml b/app/views/sherlock/transactions/_general.html.haml
index 9c028b5c741..7cf6f27e1af 100644
--- a/app/views/sherlock/transactions/_general.html.haml
+++ b/app/views/sherlock/transactions/_general.html.haml
@@ -1,4 +1,4 @@
-.prepend-top-default
+.gl-mt-3
.card
.card-header
%strong
diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml
index 2ff174971cc..566395133a1 100644
--- a/app/views/snippets/_actions.html.haml
+++ b/app/views/snippets/_actions.html.haml
@@ -13,7 +13,7 @@
- if @snippet.submittable_as_spam_by?(current_user)
= link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam')
.d-block.d-sm-none.dropdown
- %button.btn.btn-default.btn-block.gl-mb-0.prepend-top-5{ data: { toggle: "dropdown" } }
+ %button.btn.btn-default.btn-block.gl-mb-0.gl-mt-2{ data: { toggle: "dropdown" } }
= _("Options")
= icon('caret-down')
.dropdown-menu.dropdown-menu-full-width
diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml
index acc0ce0fff3..2669754cc3a 100644
--- a/app/views/snippets/new.html.haml
+++ b/app/views/snippets/new.html.haml
@@ -6,5 +6,5 @@
.page-title-holder.d-flex.align-items-center
%h1.page-title= _('New Snippet')
-.prepend-top-default
+.gl-mt-3
= render "shared/snippets/form", url: snippets_path(@snippet)
diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml
index 28fbeaa25f0..310d9946663 100644
--- a/app/views/snippets/notes/_actions.html.haml
+++ b/app/views/snippets/notes/_actions.html.haml
@@ -8,7 +8,7 @@
- if note_editable
.note-actions-item
- = button_tag title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body' } do
+ = button_tag title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body', qa_selector: 'edit_comment_button' } do
%span.link-highlight
= custom_icon('icon_pencil')
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index 7bd2d30a35c..5b6d1169b4b 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -1,6 +1,6 @@
.row
.col-12
- .calendar-block.prepend-top-default.append-bottom-default
+ .calendar-block.gl-mt-3.gl-mb-3
.user-calendar.d-none.d-sm-block{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } }
%h4.center.light
.spinner.spinner-md
@@ -9,7 +9,7 @@
.col-md-12.col-lg-6
- if can?(current_user, :read_cross_project)
.activities-block
- .prepend-top-16
+ .gl-mt-5
.d-flex.align-items-center.border-bottom
%h4.flex-grow
= s_('UserProfile|Activity')
@@ -20,7 +20,7 @@
.col-md-12.col-lg-6
.projects-block
- .prepend-top-16
+ .gl-mt-5
.d-flex.align-items-center.border-bottom
%h4.flex-grow
= s_('UserProfile|Personal projects')
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index dc151a61ee1..d2f7ff91f0d 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -85,12 +85,13 @@
- if @user.bio.present?
.cover-desc.cgray
%p.profile-user-bio
- = @user.bio
+ = markdown(@user.bio_html)
+
- unless profile_tabs.empty?
.scrolling-tabs-container
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
+ .fade-left= sprite_icon('chevron-lg-left', size: 12)
+ .fade-right= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs
- if profile_tab?(:overview)
%li.js-overview-tab
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 3baa2166812..5148772c881 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -11,6 +11,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: authorized_project_update:authorized_project_update_project_group_link_create
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_over_user_range
:feature_category: :authentication_and_authorization
:has_external_dependencies:
@@ -227,6 +235,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: cronjob:partition_creation
+ :feature_category: :database
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:personal_access_tokens_expiring
:feature_category: :authentication_and_authorization
:has_external_dependencies:
@@ -331,15 +347,15 @@
:weight: 1
:idempotent:
:tags: []
-- :name: cronjob:stuck_import_jobs
- :feature_category: :importers
+- :name: cronjob:stuck_merge_jobs
+ :feature_category: :source_code_management
:has_external_dependencies:
:urgency: :low
- :resource_boundary: :cpu
+ :resource_boundary: :unknown
:weight: 1
:idempotent:
:tags: []
-- :name: cronjob:stuck_merge_jobs
+- :name: cronjob:trending_projects
:feature_category: :source_code_management
:has_external_dependencies:
:urgency: :low
@@ -347,13 +363,13 @@
:weight: 1
:idempotent:
:tags: []
-- :name: cronjob:trending_projects
- :feature_category: :source_code_management
+- :name: cronjob:update_container_registry_info
+ :feature_category: :container_registry
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent: true
:tags: []
- :name: cronjob:users_create_statistics
:feature_category: :users
@@ -675,6 +691,14 @@
:weight: 2
:idempotent: true
:tags: []
+- :name: incident_management:incident_management_pager_duty_process_incident
+ :feature_category: :incident_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 2
+ :idempotent:
+ :tags: []
- :name: incident_management:incident_management_process_alert
:feature_category: :incident_management
:has_external_dependencies:
@@ -771,14 +795,6 @@
:weight: 2
:idempotent:
:tags: []
-- :name: notifications:new_release
- :feature_category: :release_orchestration
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 2
- :idempotent:
- :tags: []
- :name: object_pool:object_pool_create
:feature_category: :gitaly
:has_external_dependencies:
@@ -827,6 +843,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: package_repositories:packages_nuget_extraction
+ :feature_category: :package_registry
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: pipeline_background:archive_trace
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -859,6 +883,22 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: pipeline_background:ci_pipeline_success_unlock_artifacts
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: pipeline_background:ci_ref_delete_unlock_artifacts
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: pipeline_cache:expire_job_cache
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -970,7 +1010,8 @@
:resource_boundary: :cpu
:weight: 5
:idempotent:
- :tags: []
+ :tags:
+ - :requires_disk_io
- :name: pipeline_processing:build_queue
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1025,7 +1066,7 @@
:urgency: :high
:resource_boundary: :unknown
:weight: 5
- :idempotent:
+ :idempotent: true
:tags: []
- :name: pipeline_processing:stage_update
:feature_category: :continuous_integration
@@ -1107,6 +1148,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: unassign_issuables:members_destroyer_unassign_issuables
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: update_namespace_statistics:namespaces_root_statistics
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1526,10 +1575,10 @@
- :name: project_update_repository_storage
:feature_category: :gitaly
:has_external_dependencies:
- :urgency: :low
+ :urgency: :throttled
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent: true
:tags: []
- :name: prometheus_create_default_alerts
:feature_category: :incident_management
@@ -1635,6 +1684,14 @@
:weight: 2
:idempotent:
:tags: []
+- :name: service_desk_email_receiver
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: system_hook_push
:feature_category: :source_code_management
:has_external_dependencies:
diff --git a/app/workers/authorized_project_update/project_group_link_create_worker.rb b/app/workers/authorized_project_update/project_group_link_create_worker.rb
new file mode 100644
index 00000000000..5fb59efaacb
--- /dev/null
+++ b/app/workers/authorized_project_update/project_group_link_create_worker.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module AuthorizedProjectUpdate
+ class ProjectGroupLinkCreateWorker
+ include ApplicationWorker
+
+ feature_category :authentication_and_authorization
+ urgency :low
+ queue_namespace :authorized_project_update
+
+ idempotent!
+
+ def perform(project_id, group_id)
+ project = Project.find(project_id)
+ group = Group.find(group_id)
+
+ AuthorizedProjectUpdate::ProjectGroupLinkCreateService.new(project, group)
+ .execute
+ end
+ end
+end
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index d38780dd08d..d0f7d65aed6 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -7,6 +7,7 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
queue_namespace :pipeline_processing
urgency :high
worker_resource_boundary :cpu
+ tags :requires_disk_io
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
diff --git a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb
new file mode 100644
index 00000000000..bc31876aa1d
--- /dev/null
+++ b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Ci
+ class PipelineSuccessUnlockArtifactsWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ idempotent!
+
+ def perform(pipeline_id)
+ ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
+ break unless pipeline.has_archive_artifacts?
+
+ ::Ci::UnlockArtifactsService
+ .new(pipeline.project, pipeline.user)
+ .execute(pipeline.ci_ref, pipeline)
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/ref_delete_unlock_artifacts_worker.rb b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb
new file mode 100644
index 00000000000..3b4a6fcf630
--- /dev/null
+++ b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Ci
+ class RefDeleteUnlockArtifactsWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ idempotent!
+
+ def perform(project_id, user_id, ref_path)
+ ::Project.find_by_id(project_id).try do |project|
+ ::User.find_by_id(user_id).try do |user|
+ ::Ci::Ref.find_by_ref_path(ref_path).try do |ci_ref|
+ ::Ci::UnlockArtifactsService
+ .new(project, user)
+ .execute(ci_ref)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/project_export_options.rb b/app/workers/concerns/project_export_options.rb
deleted file mode 100644
index e9318c1ba43..00000000000
--- a/app/workers/concerns/project_export_options.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-module ProjectExportOptions
- extend ActiveSupport::Concern
-
- EXPORT_RETRY_COUNT = 3
-
- included do
- sidekiq_options retry: EXPORT_RETRY_COUNT, status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
-
- # We mark the project export as failed once we have exhausted all retries
- sidekiq_retries_exhausted do |job|
- project = Project.find(job['args'][1])
- # rubocop: disable CodeReuse/ActiveRecord
- job = project.export_jobs.find_by(jid: job["jid"])
- # rubocop: enable CodeReuse/ActiveRecord
-
- if job&.fail_op
- Sidekiq.logger.info "Job #{job['jid']} for project #{project.id} has been set to failed state"
- else
- Sidekiq.logger.error "Failed to set Job #{job['jid']} for project #{project.id} to failed state"
- end
- end
- end
-end
diff --git a/app/workers/concerns/reenqueuer.rb b/app/workers/concerns/reenqueuer.rb
index 5cc13e490d8..bf6f6546c03 100644
--- a/app/workers/concerns/reenqueuer.rb
+++ b/app/workers/concerns/reenqueuer.rb
@@ -60,8 +60,6 @@ module Reenqueuer
5.seconds
end
- # We intend to get rid of sleep:
- # https://gitlab.com/gitlab-org/gitlab/issues/121697
module ReenqueuerSleeper
# The block will run, and then sleep until the minimum duration. Returns the
# block's return value.
@@ -73,7 +71,7 @@ module Reenqueuer
# end
#
def ensure_minimum_duration(minimum_duration)
- start_time = Time.now
+ start_time = Time.current
result = yield
@@ -95,7 +93,7 @@ module Reenqueuer
end
def elapsed_time(start_time)
- Time.now - start_time
+ Time.current - start_time
end
end
end
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index b19217b15de..bb6192166b4 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -2,6 +2,7 @@
module WorkerAttributes
extend ActiveSupport::Concern
+ include Gitlab::ClassAttributes
# Resource boundaries that workers can declare through the
# `resource_boundary` attribute
@@ -30,24 +31,24 @@ module WorkerAttributes
}.stringify_keys.freeze
class_methods do
- def feature_category(value)
+ def feature_category(value, *extras)
raise "Invalid category. Use `feature_category_not_owned!` to mark a worker as not owned" if value == :not_owned
- worker_attributes[:feature_category] = value
+ class_attributes[:feature_category] = value
end
# Special case: mark this work as not associated with a feature category
# this should be used for cross-cutting concerns, such as mailer workers.
def feature_category_not_owned!
- worker_attributes[:feature_category] = :not_owned
+ class_attributes[:feature_category] = :not_owned
end
def get_feature_category
- get_worker_attribute(:feature_category)
+ get_class_attribute(:feature_category)
end
def feature_category_not_owned?
- get_worker_attribute(:feature_category) == :not_owned
+ get_feature_category == :not_owned
end
# This should be set to :high for jobs that need to be run
@@ -61,97 +62,76 @@ module WorkerAttributes
def urgency(urgency)
raise "Invalid urgency: #{urgency}" unless VALID_URGENCIES.include?(urgency)
- worker_attributes[:urgency] = urgency
+ class_attributes[:urgency] = urgency
end
def get_urgency
- worker_attributes[:urgency] || :low
+ class_attributes[:urgency] || :low
end
# Set this attribute on a job when it will call to services outside of the
# application, such as 3rd party applications, other k8s clusters etc See
- # doc/development/sidekiq_style_guide.md#Jobs-with-External-Dependencies for
+ # doc/development/sidekiq_style_guide.md#jobs-with-external-dependencies for
# details
def worker_has_external_dependencies!
- worker_attributes[:external_dependencies] = true
+ class_attributes[:external_dependencies] = true
end
# Returns a truthy value if the worker has external dependencies.
- # See doc/development/sidekiq_style_guide.md#Jobs-with-External-Dependencies
+ # See doc/development/sidekiq_style_guide.md#jobs-with-external-dependencies
# for details
def worker_has_external_dependencies?
- worker_attributes[:external_dependencies]
+ class_attributes[:external_dependencies]
end
def worker_resource_boundary(boundary)
raise "Invalid boundary" unless VALID_RESOURCE_BOUNDARIES.include? boundary
- worker_attributes[:resource_boundary] = boundary
+ class_attributes[:resource_boundary] = boundary
end
def get_worker_resource_boundary
- worker_attributes[:resource_boundary] || :unknown
+ class_attributes[:resource_boundary] || :unknown
end
def idempotent!
- worker_attributes[:idempotent] = true
+ class_attributes[:idempotent] = true
end
def idempotent?
- worker_attributes[:idempotent]
+ class_attributes[:idempotent]
end
def weight(value)
- worker_attributes[:weight] = value
+ class_attributes[:weight] = value
end
def get_weight
- worker_attributes[:weight] ||
+ class_attributes[:weight] ||
NAMESPACE_WEIGHTS[queue_namespace] ||
1
end
def tags(*values)
- worker_attributes[:tags] = values
+ class_attributes[:tags] = values
end
def get_tags
- Array(worker_attributes[:tags])
+ Array(class_attributes[:tags])
end
def deduplicate(strategy, options = {})
- worker_attributes[:deduplication_strategy] = strategy
- worker_attributes[:deduplication_options] = options
+ class_attributes[:deduplication_strategy] = strategy
+ class_attributes[:deduplication_options] = options
end
def get_deduplicate_strategy
- worker_attributes[:deduplication_strategy] ||
+ class_attributes[:deduplication_strategy] ||
Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DEFAULT_STRATEGY
end
def get_deduplication_options
- worker_attributes[:deduplication_options] || {}
- end
-
- protected
-
- # Returns a worker attribute declared on this class or its parent class.
- # This approach allows declared attributes to be inherited by
- # child classes.
- def get_worker_attribute(name)
- worker_attributes[name] || superclass_worker_attributes(name)
- end
-
- private
-
- def worker_attributes
- @attributes ||= {}
- end
-
- def superclass_worker_attributes(name)
- return unless superclass.include? WorkerAttributes
-
- superclass.get_worker_attribute(name)
+ class_attributes[:deduplication_options] || {}
end
end
end
diff --git a/app/workers/delete_merged_branches_worker.rb b/app/workers/delete_merged_branches_worker.rb
index ab3d42e5384..8d7026e2d1e 100644
--- a/app/workers/delete_merged_branches_worker.rb
+++ b/app/workers/delete_merged_branches_worker.rb
@@ -17,7 +17,6 @@ class DeleteMergedBranchesWorker # rubocop:disable Scalability/IdempotentWorker
begin
::Branches::DeleteMergedService.new(project, user).execute
rescue Gitlab::Access::AccessDeniedError
- return
end
end
end
diff --git a/app/workers/gitlab/jira_import/import_issue_worker.rb b/app/workers/gitlab/jira_import/import_issue_worker.rb
index 7709d2ec31b..d1ceda4fd6a 100644
--- a/app/workers/gitlab/jira_import/import_issue_worker.rb
+++ b/app/workers/gitlab/jira_import/import_issue_worker.rb
@@ -62,7 +62,7 @@ module Gitlab
end
def build_label_attrs(issue_id, label_id)
- time = Time.now
+ time = Time.current
{
label_id: label_id,
target_id: issue_id,
diff --git a/app/workers/group_export_worker.rb b/app/workers/group_export_worker.rb
index 6fd977e43d8..e22b691d35e 100644
--- a/app/workers/group_export_worker.rb
+++ b/app/workers/group_export_worker.rb
@@ -6,6 +6,7 @@ class GroupExportWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :importers
loggable_arguments 2
+ sidekiq_options retry: false
def perform(current_user_id, group_id, params = {})
current_user = User.find(current_user_id)
diff --git a/app/workers/incident_management/pager_duty/process_incident_worker.rb b/app/workers/incident_management/pager_duty/process_incident_worker.rb
new file mode 100644
index 00000000000..3f378b012a1
--- /dev/null
+++ b/app/workers/incident_management/pager_duty/process_incident_worker.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ module PagerDuty
+ class ProcessIncidentWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ queue_namespace :incident_management
+ feature_category :incident_management
+
+ def perform(project_id, incident_payload)
+ return unless project_id
+
+ project = find_project(project_id)
+ return unless project
+
+ result = create_issue(project, incident_payload)
+
+ log_error(result) if result.error?
+ end
+
+ private
+
+ def find_project(project_id)
+ Project.find_by_id(project_id)
+ end
+
+ def create_issue(project, incident_payload)
+ ::IncidentManagement::PagerDuty::CreateIncidentIssueService
+ .new(project, incident_payload)
+ .execute
+ end
+
+ def log_error(result)
+ Gitlab::AppLogger.warn(
+ message: 'Cannot create issue for PagerDuty incident',
+ issue_errors: result.message
+ )
+ end
+ end
+ end
+end
diff --git a/app/workers/incident_management/process_alert_worker.rb b/app/workers/incident_management/process_alert_worker.rb
index 0af34fa35d5..bc23dbda693 100644
--- a/app/workers/incident_management/process_alert_worker.rb
+++ b/app/workers/incident_management/process_alert_worker.rb
@@ -7,39 +7,45 @@ module IncidentManagement
queue_namespace :incident_management
feature_category :incident_management
- def perform(project_id, alert_payload, am_alert_id = nil)
- project = find_project(project_id)
- return unless project
+ # `project_id` and `alert_payload` are deprecated and can be removed
+ # starting from 14.0 release
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/224500
+ def perform(_project_id = nil, _alert_payload = nil, alert_id = nil)
+ return unless alert_id
- new_issue = create_issue(project, alert_payload)
- return unless am_alert_id && new_issue&.persisted?
+ alert = find_alert(alert_id)
+ return unless alert
+
+ new_issue = create_issue_for(alert)
+ return unless new_issue&.persisted?
- link_issue_with_alert(am_alert_id, new_issue.id)
+ link_issue_with_alert(alert, new_issue.id)
end
private
- def find_project(project_id)
- Project.find_by_id(project_id)
+ def find_alert(alert_id)
+ AlertManagement::Alert.find_by_id(alert_id)
+ end
+
+ def parsed_payload(alert)
+ Gitlab::Alerting::NotificationPayloadParser.call(alert.payload.to_h, alert.project)
end
- def create_issue(project, alert_payload)
+ def create_issue_for(alert)
IncidentManagement::CreateIssueService
- .new(project, alert_payload)
+ .new(alert.project, parsed_payload(alert))
.execute
.dig(:issue)
end
- def link_issue_with_alert(alert_id, issue_id)
- alert = AlertManagement::Alert.find_by_id(alert_id)
- return unless alert
-
+ def link_issue_with_alert(alert, issue_id)
return if alert.update(issue_id: issue_id)
Gitlab::AppLogger.warn(
message: 'Cannot link an Issue with Alert',
issue_id: issue_id,
- alert_id: alert_id,
+ alert_id: alert.id,
alert_errors: alert.errors.messages
)
end
diff --git a/app/workers/incident_management/process_prometheus_alert_worker.rb b/app/workers/incident_management/process_prometheus_alert_worker.rb
index e405bc2c2d2..4b778f6a621 100644
--- a/app/workers/incident_management/process_prometheus_alert_worker.rb
+++ b/app/workers/incident_management/process_prometheus_alert_worker.rb
@@ -9,68 +9,13 @@ module IncidentManagement
worker_resource_boundary :cpu
def perform(project_id, alert_hash)
- project = find_project(project_id)
- return unless project
-
- parsed_alert = Gitlab::Alerting::Alert.new(project: project, payload: alert_hash)
- event = find_prometheus_alert_event(parsed_alert)
-
- if event&.resolved?
- issue = event.related_issues.order_created_at_desc.detect(&:opened?)
-
- close_issue(project, issue)
- else
- issue = create_issue(project, alert_hash)
-
- relate_issue_to_event(event, issue)
- end
- end
-
- private
-
- def find_project(project_id)
- Project.find_by_id(project_id)
- end
-
- def find_prometheus_alert_event(alert)
- if alert.gitlab_managed?
- find_gitlab_managed_event(alert)
- else
- find_self_managed_event(alert)
- end
- end
-
- def find_gitlab_managed_event(alert)
- PrometheusAlertEvent.find_by_payload_key(alert.gitlab_fingerprint)
- end
-
- def find_self_managed_event(alert)
- SelfManagedPrometheusAlertEvent.find_by_payload_key(alert.gitlab_fingerprint)
- end
-
- def create_issue(project, alert)
- IncidentManagement::CreateIssueService
- .new(project, alert)
- .execute
- .dig(:issue)
- end
-
- def close_issue(project, issue)
- return if issue.blank? || issue.closed?
-
- processed_issue = Issues::CloseService
- .new(project, User.alert_bot)
- .execute(issue, system_note: false)
-
- SystemNoteService.auto_resolve_prometheus_alert(issue, project, User.alert_bot) if processed_issue.reset.closed?
- end
-
- def relate_issue_to_event(event, issue)
- return unless event && issue
-
- if event.related_issues.exclude?(issue)
- event.related_issues << issue
- end
+ # no-op
+ #
+ # This worker is not scheduled anymore since
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35943
+ # and will be removed completely via
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/227146
+ # in 14.0.
end
end
end
diff --git a/app/workers/members_destroyer/unassign_issuables_worker.rb b/app/workers/members_destroyer/unassign_issuables_worker.rb
new file mode 100644
index 00000000000..2c17120bf48
--- /dev/null
+++ b/app/workers/members_destroyer/unassign_issuables_worker.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module MembersDestroyer
+ class UnassignIssuablesWorker
+ include ApplicationWorker
+
+ ENTITY_TYPES = %w(Group Project).freeze
+
+ queue_namespace :unassign_issuables
+ feature_category :authentication_and_authorization
+
+ idempotent!
+
+ def perform(user_id, entity_id, entity_type)
+ unless ENTITY_TYPES.include?(entity_type)
+ logger.error(
+ message: "#{entity_type} is not a supported entity.",
+ entity_type: entity_type,
+ entity_id: entity_id,
+ user_id: user_id
+ )
+
+ return
+ end
+
+ user = User.find(user_id)
+ entity = entity_type.constantize.find(entity_id)
+
+ ::Members::UnassignIssuablesService.new(user, entity).execute
+ end
+ end
+end
diff --git a/app/workers/new_release_worker.rb b/app/workers/new_release_worker.rb
deleted file mode 100644
index fa4703d10f2..00000000000
--- a/app/workers/new_release_worker.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-# TODO: Worker can be removed in 13.2:
-# https://gitlab.com/gitlab-org/gitlab/-/issues/218231
-class NewReleaseWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- queue_namespace :notifications
- feature_category :release_orchestration
- weight 2
-
- def perform(release_id)
- release = Release.preloaded.find_by_id(release_id)
- return unless release
-
- NotificationService.new.send_new_release_notifications(release)
- end
-end
diff --git a/app/workers/packages/nuget/extraction_worker.rb b/app/workers/packages/nuget/extraction_worker.rb
new file mode 100644
index 00000000000..820304a9f3b
--- /dev/null
+++ b/app/workers/packages/nuget/extraction_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ class ExtractionWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ queue_namespace :package_repositories
+ feature_category :package_registry
+
+ def perform(package_file_id)
+ package_file = ::Packages::PackageFile.find_by_id(package_file_id)
+
+ return unless package_file
+
+ ::Packages::Nuget::UpdatePackageFromMetadataService.new(package_file).execute
+
+ rescue ::Packages::Nuget::MetadataExtractionService::ExtractionError,
+ ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError => e
+ Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id)
+ package_file.package.destroy!
+ end
+ end
+ end
+end
diff --git a/app/workers/partition_creation_worker.rb b/app/workers/partition_creation_worker.rb
new file mode 100644
index 00000000000..9101623d93a
--- /dev/null
+++ b/app/workers/partition_creation_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class PartitionCreationWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ feature_category :database
+ idempotent!
+
+ def perform
+ Gitlab::AppLogger.info("Checking state of dynamic postgres partitions")
+
+ Gitlab::Database::Partitioning::PartitionCreator.new.create_partitions
+ end
+end
diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb
index 7f667057af6..267caa5bedd 100644
--- a/app/workers/pipeline_update_worker.rb
+++ b/app/workers/pipeline_update_worker.rb
@@ -1,12 +1,14 @@
# frozen_string_literal: true
-class PipelineUpdateWorker # rubocop:disable Scalability/IdempotentWorker
+class PipelineUpdateWorker
include ApplicationWorker
include PipelineQueue
queue_namespace :pipeline_processing
urgency :high
+ idempotent!
+
def perform(pipeline_id)
Ci::Pipeline.find_by_id(pipeline_id)&.update_legacy_status
end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 62d76294bc0..8f844bd0b47 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -79,7 +79,7 @@ class PostReceive # rubocop:disable Scalability/IdempotentWorker
return false unless user
expire_caches(post_received, snippet.repository)
- snippet.repository.expire_statistics_caches
+ Snippets::UpdateStatisticsService.new(snippet).execute
end
# Expire the repository status, branch, and tag cache once per push.
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index 5756ebb8358..3c7af641f16 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -80,7 +80,7 @@ class ProcessCommitWorker
# manually parse these values.
hash.each do |key, value|
if key.to_s.end_with?(date_suffix) && value.is_a?(String)
- hash[key] = Time.parse(value)
+ hash[key] = Time.zone.parse(value)
end
end
diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb
index d29348e85bc..6c8640138a1 100644
--- a/app/workers/project_export_worker.rb
+++ b/app/workers/project_export_worker.rb
@@ -3,12 +3,13 @@
class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
include ExceptionBacktrace
- include ProjectExportOptions
feature_category :importers
worker_resource_boundary :memory
urgency :throttled
loggable_arguments 2, 3
+ sidekiq_options retry: false
+ sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
def perform(current_user_id, project_id, after_export_strategy = {}, params = {})
current_user = User.find(current_user_id)
diff --git a/app/workers/project_update_repository_storage_worker.rb b/app/workers/project_update_repository_storage_worker.rb
index 5c1a8062f12..7c0b1ae07fa 100644
--- a/app/workers/project_update_repository_storage_worker.rb
+++ b/app/workers/project_update_repository_storage_worker.rb
@@ -1,9 +1,11 @@
# frozen_string_literal: true
-class ProjectUpdateRepositoryStorageWorker # rubocop:disable Scalability/IdempotentWorker
+class ProjectUpdateRepositoryStorageWorker
include ApplicationWorker
+ idempotent!
feature_category :gitaly
+ urgency :throttled
def perform(project_id, new_repository_storage_key, repository_storage_move_id = nil)
repository_storage_move =
diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb
index 1e2cb912598..d47f738ccb0 100644
--- a/app/workers/repository_check/batch_worker.rb
+++ b/app/workers/repository_check/batch_worker.rb
@@ -34,7 +34,7 @@ module RepositoryCheck
end
def perform_repository_checks
- start = Time.now
+ start = Time.current
# This loop will break after a little more than one hour ('a little
# more' because `git fsck` may take a few minutes), or if it runs out of
@@ -42,7 +42,7 @@ module RepositoryCheck
# RepositoryCheckWorker each hour so that as long as there are repositories to
# check, only one (or two) will be checked at a time.
project_ids.each do |project_id|
- break if Time.now - start >= RUN_TIME
+ break if Time.current - start >= RUN_TIME
next unless try_obtain_lease_for_project(project_id)
diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb
index edff7fc31df..d757b87c23a 100644
--- a/app/workers/repository_check/single_repository_worker.rb
+++ b/app/workers/repository_check/single_repository_worker.rb
@@ -17,7 +17,7 @@ module RepositoryCheck
def update_repository_check_status(project, healthy)
project.update_columns(
last_repository_check_failed: !healthy,
- last_repository_check_at: Time.now
+ last_repository_check_at: Time.current
)
end
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 30570a2227e..54052bda675 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -4,10 +4,11 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
include ExceptionBacktrace
include ProjectStartImport
- include ProjectImportOptions
feature_category :importers
worker_has_external_dependencies!
+ sidekiq_options retry: false
+ sidekiq_options status_expiration: Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION
# technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991
sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i
diff --git a/app/workers/service_desk_email_receiver_worker.rb b/app/workers/service_desk_email_receiver_worker.rb
new file mode 100644
index 00000000000..8649034445c
--- /dev/null
+++ b/app/workers/service_desk_email_receiver_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class ServiceDeskEmailReceiverWorker < EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ def perform(raw)
+ return unless ::Gitlab::ServiceDeskEmail.enabled?
+
+ begin
+ Gitlab::Email::ServiceDeskReceiver.new(raw).execute
+ rescue => e
+ handle_failure(raw, e)
+ end
+ end
+end
diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb
deleted file mode 100644
index ce8d5bf0219..00000000000
--- a/app/workers/stuck_import_jobs_worker.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-class StuckImportJobsWorker # rubocop:disable Scalability/IdempotentWorker
- include Gitlab::Import::StuckImportJob
-
- private
-
- def track_metrics(with_jid_count, without_jid_count)
- Gitlab::Metrics.add_event(
- :stuck_import_jobs,
- projects_without_jid_count: without_jid_count,
- projects_with_jid_count: with_jid_count
- )
- end
-
- def enqueued_import_states
- ProjectImportState.with_status([:scheduled, :started])
- end
-end
diff --git a/app/workers/update_container_registry_info_worker.rb b/app/workers/update_container_registry_info_worker.rb
new file mode 100644
index 00000000000..14a816f25ef
--- /dev/null
+++ b/app/workers/update_container_registry_info_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class UpdateContainerRegistryInfoWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ feature_category :container_registry
+ urgency :low
+
+ idempotent!
+
+ def perform
+ UpdateContainerRegistryInfoService.new.execute
+ end
+end