summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-03-16 18:18:33 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-03-16 18:18:33 +0000
commitf64a639bcfa1fc2bc89ca7db268f594306edfd7c (patch)
treea2c3c2ebcc3b45e596949db485d6ed18ffaacfa1 /app
parentbfbc3e0d6583ea1a91f627528bedc3d65ba4b10f (diff)
downloadgitlab-ce-f64a639bcfa1fc2bc89ca7db268f594306edfd7c.tar.gz
Add latest changes from gitlab-org/gitlab@13-10-stable-eev13.10.0-rc40
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/learn_gitlab/code_owners_enabled.svg5
-rw-r--r--app/assets/images/learn_gitlab/git_write.svg16
-rw-r--r--app/assets/images/learn_gitlab/merge_request_created.svg107
-rw-r--r--app/assets/images/learn_gitlab/pipeline_created.svg38
-rw-r--r--app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg70
-rw-r--r--app/assets/images/learn_gitlab/security_scan_enabled.svg36
-rw-r--r--app/assets/images/learn_gitlab/trial_started.svg9
-rw-r--r--app/assets/images/learn_gitlab/user_added.svg4
-rw-r--r--app/assets/javascripts/access_tokens/components/projects_field.vue69
-rw-r--r--app/assets/javascripts/access_tokens/components/projects_token_selector.vue156
-rw-r--r--app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql28
-rw-r--r--app/assets/javascripts/access_tokens/index.js62
-rw-r--r--app/assets/javascripts/admin/dev_ops_report/devops_adoption.js2
-rw-r--r--app/assets/javascripts/admin/users/tabs.js11
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue20
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue43
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue15
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue636
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue189
-rw-r--r--app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json95
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js152
-rw-r--r--app/assets/javascripts/alerts_settings/graphql.js18
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_item.fragment.graphql7
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql3
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql22
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql4
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql4
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql25
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql (renamed from app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql)4
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql4
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql12
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql9
-rw-r--r--app/assets/javascripts/alerts_settings/index.js5
-rw-r--r--app/assets/javascripts/alerts_settings/utils/cache_updates.js35
-rw-r--r--app/assets/javascripts/alerts_settings/utils/error_messages.js2
-rw-r--r--app/assets/javascripts/alerts_settings/utils/mapping_transformations.js28
-rw-r--r--app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue (renamed from app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue)0
-rw-r--r--app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js (renamed from app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js)0
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/charts_config.js87
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue224
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql4
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql34
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/app.vue (renamed from app/assets/javascripts/analytics/instance_statistics/components/app.vue)21
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/charts_config.js106
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue (renamed from app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue)27
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue (renamed from app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue)6
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/users_chart.vue (renamed from app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue)2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/constants.js (renamed from app/assets/javascripts/analytics/instance_statistics/constants.js)0
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql4
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql (renamed from app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql)2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql34
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql (renamed from app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql)2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/index.js (renamed from app/assets/javascripts/analytics/instance_statistics/index.js)6
-rw-r--r--app/assets/javascripts/analytics/usage_trends/utils.js (renamed from app/assets/javascripts/analytics/instance_statistics/utils.js)6
-rw-r--r--app/assets/javascripts/api.js24
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue2
-rw-r--r--app/assets/javascripts/awards_handler.js9
-rw-r--r--app/assets/javascripts/badges/components/badge.vue2
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue4
-rw-r--r--app/assets/javascripts/batch_comments/components/publish_button.vue3
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue5
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js22
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js38
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js99
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js135
-rw-r--r--app/assets/javascripts/behaviors/toggler_behavior.js2
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js1
-rw-r--r--app/assets/javascripts/blob/viewer/index.js9
-rw-r--r--app/assets/javascripts/boards/boards_util.js28
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue143
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_form.vue131
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_trigger.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue83
-rw-r--r--app/assets/javascripts/boards/components/board_card_deprecated.vue61
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue (renamed from app/assets/javascripts/boards/components/issue_card_inner.vue)84
-rw-r--r--app/assets/javascripts/boards/components/board_card_layout.vue98
-rw-r--r--app/assets/javascripts/boards/components/board_card_layout_deprecated.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue21
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue31
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue75
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue91
-rw-r--r--app/assets/javascripts/boards/components/board_list_deprecated.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue51
-rw-r--r--app/assets/javascripts/boards/components/board_list_header_deprecated.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue7
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue_deprecated.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js6
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue43
-rw-r--r--app/assets/javascripts/boards/components/boards_selector_deprecated.vue7
-rw-r--r--app/assets/javascripts/boards/components/config_toggle.vue64
-rw-r--r--app/assets/javascripts/boards/components/filtered_search.vue54
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue6
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue6
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/components/item_count.vue (renamed from app/assets/javascripts/boards/components/issue_count.vue)10
-rw-r--r--app/assets/javascripts/boards/components/modal/list.vue6
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue11
-rw-r--r--app/assets/javascripts/boards/components/project_select_deprecated.vue1
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue2
-rw-r--r--app/assets/javascripts/boards/components/sidebar/remove_issue.vue88
-rw-r--r--app/assets/javascripts/boards/config_toggle.js25
-rw-r--r--app/assets/javascripts/boards/constants.js5
-rw-r--r--app/assets/javascripts/boards/filtered_search.js25
-rw-r--r--app/assets/javascripts/boards/graphql/board_labels.query.graphql4
-rw-r--r--app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql20
-rw-r--r--app/assets/javascripts/boards/graphql/group_projects.query.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/users_search.query.graphql11
-rw-r--r--app/assets/javascripts/boards/index.js29
-rw-r--r--app/assets/javascripts/boards/mixins/board_card_inner.js (renamed from app/assets/javascripts/boards/mixins/issue_card_inner.js)4
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js6
-rw-r--r--app/assets/javascripts/boards/stores/actions.js107
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js4
-rw-r--r--app/assets/javascripts/boards/stores/getters.js24
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js10
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js113
-rw-r--r--app/assets/javascripts/boards/stores/state.js14
-rw-r--r--app/assets/javascripts/captcha/captcha_modal.vue10
-rw-r--r--app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js37
-rw-r--r--app/assets/javascripts/captcha/unsolved_captcha_error.js10
-rw-r--r--app/assets/javascripts/captcha/wait_for_captcha_to_be_solved.js53
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue12
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue1
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue2
-rw-r--r--app/assets/javascripts/clusters/components/fluentd_output_settings.vue20
-rw-r--r--app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue22
-rw-r--r--app/assets/javascripts/clusters/forms/components/integration_form.vue14
-rw-r--r--app/assets/javascripts/clusters_list/store/actions.js2
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js2
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue18
-rw-r--r--app/assets/javascripts/commons/bootstrap.js67
-rw-r--r--app/assets/javascripts/commons/vue.js2
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/constants.js5
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue288
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js156
-rw-r--r--app/assets/javascripts/cycle_analytics/index.js32
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js43
-rw-r--r--app/assets/javascripts/design_management/components/delete_button.vue16
-rw-r--r--app/assets/javascripts/design_management/components/design_destroyer.vue1
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue6
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue1
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue2
-rw-r--r--app/assets/javascripts/design_management/components/upload/button.vue2
-rw-r--r--app/assets/javascripts/design_management/graphql.js1
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue5
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js3
-rw-r--r--app/assets/javascripts/diffs/components/app.vue14
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussion_reply.vue5
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue14
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue6
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue6
-rw-r--r--app/assets/javascripts/diffs/components/settings_dropdown.vue46
-rw-r--r--app/assets/javascripts/diffs/constants.js1
-rw-r--r--app/assets/javascripts/diffs/i18n.js1
-rw-r--r--app/assets/javascripts/diffs/index.js2
-rw-r--r--app/assets/javascripts/diffs/store/actions.js5
-rw-r--r--app/assets/javascripts/diffs/store/utils.js2
-rw-r--r--app/assets/javascripts/diffs/utils/file_reviews.js7
-rw-r--r--app/assets/javascripts/diffs/utils/preferences.js13
-rw-r--r--app/assets/javascripts/editor/constants.js3
-rw-r--r--app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js164
-rw-r--r--app/assets/javascripts/emoji/components/category.vue61
-rw-r--r--app/assets/javascripts/emoji/components/emoji_group.vue35
-rw-r--r--app/assets/javascripts/emoji/components/emoji_list.vue44
-rw-r--r--app/assets/javascripts/emoji/components/picker.vue121
-rw-r--r--app/assets/javascripts/emoji/components/utils.js27
-rw-r--r--app/assets/javascripts/emoji/constants.js14
-rw-r--r--app/assets/javascripts/emoji/index.js16
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue6
-rw-r--r--app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js2
-rw-r--r--app/assets/javascripts/environments/services/environments_service.js4
-rw-r--r--app/assets/javascripts/experimentation/constants.js1
-rw-r--r--app/assets/javascripts/experimentation/experiment_tracking.js24
-rw-r--r--app/assets/javascripts/experimentation/utils.js10
-rw-r--r--app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue13
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue8
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue7
-rw-r--r--app/assets/javascripts/flash.js2
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js78
-rw-r--r--app/assets/javascripts/grafana_integration/components/grafana_integration.vue26
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql6
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql14
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search.query.graphql12
-rw-r--r--app/assets/javascripts/graphql_shared/utils.js34
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue43
-rw-r--r--app/assets/javascripts/groups/components/invite_members_banner.vue4
-rw-r--r--app/assets/javascripts/groups/components/item_caret.vue4
-rw-r--r--app/assets/javascripts/groups_select.js30
-rw-r--r--app/assets/javascripts/helpers/cve_id_request_helper.js50
-rw-r--r--app/assets/javascripts/ide/components/branches/item.vue2
-rw-r--r--app/assets/javascripts/ide/components/branches/search_list.vue7
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue18
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue1
-rw-r--r--app/assets/javascripts/ide/components/file_templates/bar.vue4
-rw-r--r--app/assets/javascripts/ide/components/ide.vue13
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/item.vue2
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/list.vue9
-rw-r--r--app/assets/javascripts/ide/components/nav_form.vue25
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue3
-rw-r--r--app/assets/javascripts/ide/components/preview/navigator.vue2
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue81
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue46
-rw-r--r--app/assets/javascripts/ide/components/repo_tabs.vue6
-rw-r--r--app/assets/javascripts/ide/components/shared/tokened_input.vue4
-rw-r--r--app/assets/javascripts/ide/constants.js1
-rw-r--r--app/assets/javascripts/ide/index.js1
-rw-r--r--app/assets/javascripts/ide/messages.js17
-rw-r--r--app/assets/javascripts/ide/queries/getUserPermissions.query.graphql9
-rw-r--r--app/assets/javascripts/ide/queries/get_ide_project.query.graphql7
-rw-r--r--app/assets/javascripts/ide/queries/ide_project.fragment.graphql7
-rw-r--r--app/assets/javascripts/ide/services/index.js4
-rw-r--r--app/assets/javascripts/ide/stores/getters.js44
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue2
-rw-r--r--app/assets/javascripts/import_entities/constants.js6
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue92
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue147
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js78
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql5
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js83
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js12
-rw-r--r--app/assets/javascripts/import_entities/import_groups/index.js3
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue10
-rw-r--r--app/assets/javascripts/incidents_settings/constants.js4
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_checkbox.vue2
-rw-r--r--app/assets/javascripts/integrations/edit/components/confirmation_modal.vue2
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue17
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue103
-rw-r--r--app/assets/javascripts/invite_members/components/invite_group_trigger.vue34
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue148
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue29
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue4
-rw-r--r--app/assets/javascripts/invite_members/constants.js2
-rw-r--r--app/assets/javascripts/invite_members/init_invite_group_trigger.js20
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_form.js7
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js1
-rw-r--r--app/assets/javascripts/issuable/components/csv_export_modal.vue100
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_export_buttons.vue107
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_modal.vue86
-rw-r--r--app/assets/javascripts/issuable/constants.js6
-rw-r--r--app/assets/javascripts/issuable/init_csv_import_export_buttons.js45
-rw-r--r--app/assets/javascripts/issuable_form.js6
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue65
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_list_root.vue11
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_body.vue70
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_description.vue23
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_discussion.vue15
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_header.vue32
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_show_root.vue36
-rw-r--r--app/assets/javascripts/issue.js9
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue51
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue15
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue16
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue4
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue6
-rw-r--r--app/assets/javascripts/issue_show/components/header_actions.vue3
-rw-r--r--app/assets/javascripts/issue_show/services/index.js2
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js2
-rw-r--r--app/assets/javascripts/issue_show/utils/parse_data.js2
-rw-r--r--app/assets/javascripts/issues_list/components/issuable.vue31
-rw-r--r--app/assets/javascripts/issues_list/components/issue_card_time_info.vue124
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue143
-rw-r--r--app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue (renamed from app/assets/javascripts/issues_list/components/jira_issues_list_root.vue)6
-rw-r--r--app/assets/javascripts/issues_list/index.js42
-rw-r--r--app/assets/javascripts/issues_list/service_desk_helper.js30
-rw-r--r--app/assets/javascripts/jira_connect/components/app.vue48
-rw-r--r--app/assets/javascripts/jira_connect/components/groups_list_item.vue11
-rw-r--r--app/assets/javascripts/jira_connect/constants.js1
-rw-r--r--app/assets/javascripts/jira_connect/index.js5
-rw-r--r--app/assets/javascripts/jira_connect/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/jira_connect/store/mutations.js6
-rw-r--r--app/assets/javascripts/jira_connect/store/state.js2
-rw-r--r--app/assets/javascripts/jira_connect/utils.js33
-rw-r--r--app/assets/javascripts/jira_import/utils/cache_update.js3
-rw-r--r--app/assets/javascripts/jobs/components/artifacts_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue11
-rw-r--r--app/assets/javascripts/jobs/components/job_container_item.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue15
-rw-r--r--app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue4
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue74
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_job_details_container.vue2
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue2
-rw-r--r--app/assets/javascripts/jobs/components/trigger_block.vue2
-rw-r--r--app/assets/javascripts/jobs/store/getters.js2
-rw-r--r--app/assets/javascripts/jobs/svg/scroll_down.svg4
-rw-r--r--app/assets/javascripts/lib/chrome_84_icon_fix.js78
-rw-r--r--app/assets/javascripts/lib/graphql.js3
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js107
-rw-r--r--app/assets/javascripts/lib/utils/experimentation.js3
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js1
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js21
-rw-r--r--app/assets/javascripts/lib/utils/select2_utils.js25
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js6
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/formatter_factory.js70
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/index.js342
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js5
-rw-r--r--app/assets/javascripts/locale/index.js20
-rw-r--r--app/assets/javascripts/main.js2
-rw-r--r--app/assets/javascripts/members.js111
-rw-r--r--app/assets/javascripts/members/components/avatars/user_avatar.vue8
-rw-r--r--app/assets/javascripts/members/utils.js4
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.js115
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue128
-rw-r--r--app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js22
-rw-r--r--app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue47
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js37
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue47
-rw-r--r--app/assets/javascripts/merge_conflicts/constants.js20
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue217
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js26
-rw-r--r--app/assets/javascripts/merge_conflicts/store/actions.js120
-rw-r--r--app/assets/javascripts/merge_conflicts/store/getters.js117
-rw-r--r--app/assets/javascripts/merge_conflicts/store/index.js16
-rw-r--r--app/assets/javascripts/merge_conflicts/store/mutation_types.js8
-rw-r--r--app/assets/javascripts/merge_conflicts/store/mutations.js40
-rw-r--r--app/assets/javascripts/merge_conflicts/store/state.js13
-rw-r--r--app/assets/javascripts/merge_conflicts/utils.js228
-rw-r--r--app/assets/javascripts/merge_request_tabs.js2
-rw-r--r--app/assets/javascripts/milestone.js27
-rw-r--r--app/assets/javascripts/milestone_select.js2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue1
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/embed_group/index.js2
-rw-r--r--app/assets/javascripts/network/branch_graph.js8
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue208
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue4
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_reply_placeholder.vue29
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue92
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue4
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue9
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue3
-rw-r--r--app/assets/javascripts/notes/components/sidebar_subscription.vue58
-rw-r--r--app/assets/javascripts/notes/i18n.js26
-rw-r--r--app/assets/javascripts/notes/stores/actions.js44
-rw-r--r--app/assets/javascripts/notes/stores/utils.js4
-rw-r--r--app/assets/javascripts/notifications/components/custom_notifications_modal.vue17
-rw-r--r--app/assets/javascripts/notifications/components/notifications_dropdown.vue71
-rw-r--r--app/assets/javascripts/notifications/components/notifications_dropdown_item.vue8
-rw-r--r--app/assets/javascripts/notifications/constants.js5
-rw-r--r--app/assets/javascripts/notifications_dropdown.js35
-rw-r--r--app/assets/javascripts/notifications_form.js48
-rw-r--r--app/assets/javascripts/packages/details/components/composer_installation.vue5
-rw-r--r--app/assets/javascripts/packages/details/components/conan_installation.vue5
-rw-r--r--app/assets/javascripts/packages/details/components/installation_title.vue38
-rw-r--r--app/assets/javascripts/packages/details/components/maven_installation.vue128
-rw-r--r--app/assets/javascripts/packages/details/components/npm_installation.vue5
-rw-r--r--app/assets/javascripts/packages/details/components/nuget_installation.vue6
-rw-r--r--app/assets/javascripts/packages/details/components/pypi_installation.vue5
-rw-r--r--app/assets/javascripts/packages/details/constants.js3
-rw-r--r--app/assets/javascripts/packages/details/store/getters.js16
-rw-r--r--app/assets/javascripts/packages/list/constants.js2
-rw-r--r--app/assets/javascripts/packages/shared/components/package_list_row.vue2
-rw-r--r--app/assets/javascripts/packages/shared/utils.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js1
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/index.js7
-rw-r--r--app/assets/javascripts/pages/admin/admin.js32
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/index.js26
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js18
-rw-r--r--app/assets/javascripts/pages/admin/dev_ops_report/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/impersonation_tokens/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/index.js10
-rw-r--r--app/assets/javascripts/pages/admin/instance_statistics/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/projects/index.js12
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/runners/index.js14
-rw-r--r--app/assets/javascripts/pages/admin/services/index/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/usage_trends/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/issues/index.js16
-rw-r--r--app/assets/javascripts/pages/dashboard/milestones/show/index.js2
-rw-r--r--app/assets/javascripts/pages/dashboard/projects/index/index.js7
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/activity/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js9
-rw-r--r--app/assets/javascripts/pages/groups/milestones/show/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js14
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js27
-rw-r--r--app/assets/javascripts/pages/groups/settings/integrations/edit/index.js16
-rw-r--r--app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/repository/show/index.js11
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js13
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js6
-rw-r--r--app/assets/javascripts/pages/profiles/index.js28
-rw-r--r--app/assets/javascripts/pages/profiles/index/index.js7
-rw-r--r--app/assets/javascripts/pages/profiles/notifications/show/index.js4
-rw-r--r--app/assets/javascripts/pages/profiles/personal_access_tokens/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/blob/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/blob/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js98
-rw-r--r--app/assets/javascripts/pages/projects/boards/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/branches/new/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/compare/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/cycle_analytics/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js22
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js28
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js19
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/app.vue72
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue304
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue20
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/index.js48
-rw-r--r--app/assets/javascripts/pages/projects/imports/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/jobs/index/index.js30
-rw-r--r--app/assets/javascripts/pages/projects/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue8
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue109
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue70
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js56
-rw-r--r--app/assets/javascripts/pages/projects/logs/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/milestones/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/milestones/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/milestones/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/milestones/show/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js42
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js110
-rw-r--r--app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/settings/access_tokens/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue42
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js6
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js22
-rw-r--r--app/assets/javascripts/pages/projects/tags/new/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/tags/releases/index.js6
-rw-r--r--app/assets/javascripts/pages/search/show/index.js4
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_alert.vue31
-rw-r--r--app/assets/javascripts/pages/shared/wikis/index.js28
-rw-r--r--app/assets/javascripts/pages/users/index.js10
-rw-r--r--app/assets/javascripts/performance/constants.js21
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue2
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue9
-rw-r--r--app/assets/javascripts/performance_bar/index.js2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue16
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue34
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue47
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue120
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue18
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue56
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js4
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql17
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/resolvers.js23
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js4
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue91
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue1
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue189
-rw-r--r--app/assets/javascripts/pipeline_new/components/refs_dropdown.vue113
-rw-r--r--app/assets/javascripts/pipeline_new/constants.js1
-rw-r--r--app/assets/javascripts/pipeline_new/index.js14
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue29
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue71
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue35
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js97
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/api.js8
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue74
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue115
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue71
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue54
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue119
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue152
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue92
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue85
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue)1
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue212
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue61
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/stage.vue234
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue49
-rw-r--r--app/assets/javascripts/pipelines/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js3
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_graph.js9
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js4
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue8
-rw-r--r--app/assets/javascripts/profile/preferences/components/profile_preferences.vue26
-rw-r--r--app/assets/javascripts/profile/profile.js1
-rw-r--r--app/assets/javascripts/projects/commit/components/branches_dropdown.vue23
-rw-r--r--app/assets/javascripts/projects/commit/components/form_modal.vue30
-rw-r--r--app/assets/javascripts/projects/commit/components/projects_dropdown.vue87
-rw-r--r--app/assets/javascripts/projects/commit/constants.js13
-rw-r--r--app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js8
-rw-r--r--app/assets/javascripts/projects/commit/store/actions.js20
-rw-r--r--app/assets/javascripts/projects/commit/store/getters.js2
-rw-r--r--app/assets/javascripts/projects/commit/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/projects/commit/store/mutations.js9
-rw-r--r--app/assets/javascripts/projects/commit/store/state.js3
-rw-r--r--app/assets/javascripts/projects/commit_box/info/index.js14
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js26
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js2
-rw-r--r--app/assets/javascripts/projects/compare/components/app.vue79
-rw-r--r--app/assets/javascripts/projects/compare/components/app_legacy.vue89
-rw-r--r--app/assets/javascripts/projects/compare/components/repo_dropdown.vue93
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_card.vue65
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown.vue115
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue145
-rw-r--r--app/assets/javascripts/projects/compare/index.js42
-rw-r--r--app/assets/javascripts/projects/details/upload_button.vue59
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue66
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue14
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/index.js6
-rw-r--r--app/assets/javascripts/projects/feature_flags_user_lists/show/index.js23
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue6
-rw-r--r--app/assets/javascripts/projects/project_new.js35
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue2
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue12
-rw-r--r--app/assets/javascripts/projects/upload_file_experiment.js33
-rw-r--r--app/assets/javascripts/projects/upload_file_experiment_tracking.js9
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js7
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js21
-rw-r--r--app/assets/javascripts/ref/components/ref_results_section.vue8
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue239
-rw-r--r--app/assets/javascripts/ref/constants.js5
-rw-r--r--app/assets/javascripts/ref/stores/actions.js17
-rw-r--r--app/assets/javascripts/ref/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/ref/stores/mutations.js3
-rw-r--r--app/assets/javascripts/ref/stores/state.js25
-rw-r--r--app/assets/javascripts/registry/explorer/components/delete_button.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/components/delete_image.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/details_header.vue32
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue7
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue19
-rw-r--r--app/assets/javascripts/registry/explorer/constants/common.js3
-rw-r--r--app/assets/javascripts/registry/explorer/constants/details.js8
-rw-r--r--app/assets/javascripts/registry/explorer/constants/expiration_policies.js3
-rw-r--r--app/assets/javascripts/registry/explorer/constants/index.js1
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue7
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue1
-rw-r--r--app/assets/javascripts/registry/settings/components/expiration_toggle.vue12
-rw-r--r--app/assets/javascripts/registry/settings/graphql/utils/cache_update.js1
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue1
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue11
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue3
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue70
-rw-r--r--app/assets/javascripts/reports/components/grouped_issues_list.vue13
-rw-r--r--app/assets/javascripts/reports/components/issue_body.js2
-rw-r--r--app/assets/javascripts/reports/components/issues_list.vue13
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue5
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue32
-rw-r--r--app/assets/javascripts/reports/components/test_issue_body.vue64
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/components/modal.vue (renamed from app/assets/javascripts/reports/components/modal.vue)2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue64
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue (renamed from app/assets/javascripts/reports/components/grouped_test_reports_app.vue)54
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/actions.js (renamed from app/assets/javascripts/reports/store/actions.js)6
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/getters.js (renamed from app/assets/javascripts/reports/store/getters.js)2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/index.js (renamed from app/assets/javascripts/reports/store/index.js)0
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js (renamed from app/assets/javascripts/reports/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/mutations.js (renamed from app/assets/javascripts/reports/store/mutations.js)0
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/state.js (renamed from app/assets/javascripts/reports/store/state.js)2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/utils.js (renamed from app/assets/javascripts/reports/store/utils.js)2
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue220
-rw-r--r--app/assets/javascripts/repository/index.js19
-rw-r--r--app/assets/javascripts/right_sidebar.js3
-rw-r--r--app/assets/javascripts/security_configuration/components/configuration_table.vue23
-rw-r--r--app/assets/javascripts/security_configuration/components/manage_sast.vue8
-rw-r--r--app/assets/javascripts/security_configuration/components/scanners_constants.js (renamed from app/assets/javascripts/security_configuration/components/features_constants.js)64
-rw-r--r--app/assets/javascripts/security_configuration/components/upgrade.vue10
-rw-r--r--app/assets/javascripts/security_configuration/index.js3
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue4
-rw-r--r--app/assets/javascripts/sentry/sentry_config.js2
-rw-r--r--app/assets/javascripts/sentry/wrapper.js26
-rw-r--r--app/assets/javascripts/shared/popover.js33
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue50
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue113
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form.vue64
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue81
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue64
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue136
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue142
-rw-r--r--app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue84
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue16
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue37
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.vue6
-rw-r--r--app/assets/javascripts/sidebar/constants.js28
-rw-r--r--app/assets/javascripts/sidebar/graphql.js8
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js110
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_reference.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql7
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql9
-rw-r--r--app/assets/javascripts/sidebar/queries/update_issue_confidential.mutation.graphql (renamed from app/assets/javascripts/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql)5
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js15
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js1
-rw-r--r--app/assets/javascripts/single_file_diff.js2
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue5
-rw-r--r--app/assets/javascripts/snippets/components/snippet_visibility_edit.vue7
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js2
-rw-r--r--app/assets/javascripts/tooltips/components/tooltips.vue6
-rw-r--r--app/assets/javascripts/tooltips/index.js1
-rw-r--r--app/assets/javascripts/tracking.js62
-rw-r--r--app/assets/javascripts/user_popovers.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue92
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue49
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue37
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue82
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue36
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue96
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js3
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue10
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue2
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue1
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/code_instruction.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue59
-rw-r--r--app/assets/javascripts/vue_shared/components/select2_select.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/settings/settings_block.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql4
-rw-r--r--app/assets/javascripts/vue_shared/components/tabs/tab.vue47
-rw-r--r--app/assets/javascripts/vue_shared/components/tabs/tabs.js76
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/user_access_role_badge.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue16
-rw-r--r--app/assets/javascripts/vue_shared/directives/tooltip.js35
-rw-r--r--app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js7
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js3
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss2
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss23
-rw-r--r--app/assets/stylesheets/components/avatar.scss5
-rw-r--r--app/assets/stylesheets/components/milestone_combobox.scss18
-rw-r--r--app/assets/stylesheets/components/ref_selector.scss16
-rw-r--r--app/assets/stylesheets/disable_animations.scss6
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/awards.scss2
-rw-r--r--app/assets/stylesheets/framework/buttons.scss40
-rw-r--r--app/assets/stylesheets/framework/diffs.scss1
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss26
-rw-r--r--app/assets/stylesheets/framework/editor-lite.scss5
-rw-r--r--app/assets/stylesheets/framework/emojis.scss23
-rw-r--r--app/assets/stylesheets/framework/filters.scss3
-rw-r--r--app/assets/stylesheets/framework/forms.scss3
-rw-r--r--app/assets/stylesheets/framework/header.scss1
-rw-r--r--app/assets/stylesheets/framework/highlight.scss3
-rw-r--r--app/assets/stylesheets/framework/layout.scss3
-rw-r--r--app/assets/stylesheets/framework/lists.scss11
-rw-r--r--app/assets/stylesheets/framework/mixins.scss1
-rw-r--r--app/assets/stylesheets/framework/modal.scss16
-rw-r--r--app/assets/stylesheets/framework/tooltips.scss6
-rw-r--r--app/assets/stylesheets/framework/typography.scss21
-rw-r--r--app/assets/stylesheets/framework/variables.scss6
-rw-r--r--app/assets/stylesheets/highlight/conflict_colors.scss2
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss2
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss2
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss6
-rw-r--r--app/assets/stylesheets/lazy_bundles/select2_overrides.scss16
-rw-r--r--app/assets/stylesheets/mailer_client_specific.scss1
-rw-r--r--app/assets/stylesheets/mailers/highlighted_diff_email.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss11
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss29
-rw-r--r--app/assets/stylesheets/page_bundles/cycle_analytics.scss32
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss3
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/import.scss7
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss56
-rw-r--r--app/assets/stylesheets/page_bundles/learn_gitlab.scss3
-rw-r--r--app/assets/stylesheets/page_bundles/members.scss (renamed from app/assets/stylesheets/pages/members.scss)2
-rw-r--r--app/assets/stylesheets/page_bundles/oncall_schedules.scss7
-rw-r--r--app/assets/stylesheets/page_bundles/pipelines.scss19
-rw-r--r--app/assets/stylesheets/page_bundles/reports.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/signup.scss5
-rw-r--r--app/assets/stylesheets/pages/clusters.scss1
-rw-r--r--app/assets/stylesheets/pages/groups.scss4
-rw-r--r--app/assets/stylesheets/pages/login.scss11
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss27
-rw-r--r--app/assets/stylesheets/pages/note_form.scss41
-rw-r--r--app/assets/stylesheets/pages/projects.scss71
-rw-r--r--app/assets/stylesheets/pages/trials.scss15
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss1
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss5
-rw-r--r--app/assets/stylesheets/test_environment.scss11
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss5
-rw-r--r--app/assets/stylesheets/themes/theme_light.scss21
-rw-r--r--app/assets/stylesheets/utilities.scss15
-rw-r--r--app/assets/stylesheets/vendors/atwho.scss5
-rw-r--r--app/controllers/admin/application_settings_controller.rb5
-rw-r--r--app/controllers/admin/instance_statistics_controller.rb18
-rw-r--r--app/controllers/admin/usage_trends_controller.rb12
-rw-r--r--app/controllers/application_controller.rb12
-rw-r--r--app/controllers/concerns/boards_responses.rb4
-rw-r--r--app/controllers/concerns/check_rate_limit.rb23
-rw-r--r--app/controllers/concerns/comment_and_close_flag.rb11
-rw-r--r--app/controllers/concerns/multiple_boards_actions.rb2
-rw-r--r--app/controllers/concerns/notes_actions.rb8
-rw-r--r--app/controllers/concerns/security_and_compliance_permissions.rb15
-rw-r--r--app/controllers/concerns/spammable_actions.rb15
-rw-r--r--app/controllers/concerns/wiki_actions.rb3
-rw-r--r--app/controllers/dashboard/snippets_controller.rb1
-rw-r--r--app/controllers/dashboard/todos_controller.rb2
-rw-r--r--app/controllers/explore/projects_controller.rb2
-rw-r--r--app/controllers/explore/snippets_controller.rb1
-rw-r--r--app/controllers/graphql_controller.rb14
-rw-r--r--app/controllers/groups/boards_controller.rb7
-rw-r--r--app/controllers/groups/dependency_proxy_for_containers_controller.rb13
-rw-r--r--app/controllers/groups_controller.rb1
-rw-r--r--app/controllers/help_controller.rb1
-rw-r--r--app/controllers/ide_controller.rb24
-rw-r--r--app/controllers/import/bulk_imports_controller.rb13
-rw-r--r--app/controllers/import/gitlab_groups_controller.rb7
-rw-r--r--app/controllers/jira_connect/subscriptions_controller.rb3
-rw-r--r--app/controllers/notification_settings_controller.rb61
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb4
-rw-r--r--app/controllers/profiles/preferences_controller.rb3
-rw-r--r--app/controllers/projects/blob_controller.rb10
-rw-r--r--app/controllers/projects/boards_controller.rb6
-rw-r--r--app/controllers/projects/ci/daily_build_group_report_results_controller.rb49
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb3
-rw-r--r--app/controllers/projects/commit_controller.rb25
-rw-r--r--app/controllers/projects/compare_controller.rb56
-rw-r--r--app/controllers/projects/forks_controller.rb4
-rw-r--r--app/controllers/projects/graphs_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb3
-rw-r--r--app/controllers/projects/merge_requests_controller.rb7
-rw-r--r--app/controllers/projects/notes_controller.rb17
-rw-r--r--app/controllers/projects/pipelines/tests_controller.rb2
-rw-r--r--app/controllers/projects/pipelines_controller.rb5
-rw-r--r--app/controllers/projects/project_members_controller.rb4
-rw-r--r--app/controllers/projects/protected_branches_controller.rb1
-rw-r--r--app/controllers/projects/security/configuration_controller.rb10
-rw-r--r--app/controllers/projects/services_controller.rb2
-rw-r--r--app/controllers/projects/settings/operations_controller.rb4
-rw-r--r--app/controllers/projects/snippets_controller.rb1
-rw-r--r--app/controllers/projects/templates_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb40
-rw-r--r--app/controllers/repositories/git_http_controller.rb6
-rw-r--r--app/controllers/root_controller.rb4
-rw-r--r--app/controllers/search_controller.rb8
-rw-r--r--app/controllers/snippets_controller.rb1
-rw-r--r--app/controllers/user_callouts_controller.rb11
-rw-r--r--app/experiments/application_experiment.rb106
-rw-r--r--app/experiments/members/invite_email_experiment.rb8
-rw-r--r--app/finders/admin/plans_finder.rb24
-rw-r--r--app/finders/boards/boards_finder.rb (renamed from app/services/boards/list_service.rb)2
-rw-r--r--app/finders/ci/daily_build_group_report_results_finder.rb103
-rw-r--r--app/finders/ci/testing/daily_build_group_report_results_finder.rb88
-rw-r--r--app/finders/issuable_finder.rb52
-rw-r--r--app/finders/issuable_finder/params.rb27
-rw-r--r--app/finders/issuables/author_filter.rb41
-rw-r--r--app/finders/issuables/base_filter.rb36
-rw-r--r--app/finders/merge_request_target_project_finder.rb11
-rw-r--r--app/finders/merge_requests/oldest_per_commit_finder.rb32
-rw-r--r--app/finders/namespaces/projects_finder.rb64
-rw-r--r--app/finders/packages/maven/package_finder.rb5
-rw-r--r--app/finders/packages/npm/package_finder.rb37
-rw-r--r--app/finders/projects/groups_finder.rb68
-rw-r--r--app/finders/repositories/changelog_commits_finder.rb (renamed from app/finders/repositories/commits_with_trailer_finder.rb)25
-rw-r--r--app/finders/repositories/previous_tag_finder.rb12
-rw-r--r--app/finders/security/license_compliance_jobs_finder.rb2
-rw-r--r--app/finders/template_finder.rb16
-rw-r--r--app/finders/users_finder.rb9
-rw-r--r--app/graphql/gitlab_schema.rb2
-rw-r--r--app/graphql/mutations/boards/create.rb4
-rw-r--r--app/graphql/mutations/boards/destroy.rb2
-rw-r--r--app/graphql/mutations/boards/issues/issue_move_list.rb45
-rw-r--r--app/graphql/mutations/boards/lists/create.rb2
-rw-r--r--app/graphql/mutations/boards/lists/destroy.rb2
-rw-r--r--app/graphql/mutations/boards/lists/update.rb4
-rw-r--r--app/graphql/mutations/boards/update.rb2
-rw-r--r--app/graphql/mutations/branches/create.rb2
-rw-r--r--app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb21
-rw-r--r--app/graphql/mutations/custom_emoji/create.rb1
-rw-r--r--app/graphql/mutations/merge_requests/accept.rb88
-rw-r--r--app/graphql/mutations/merge_requests/set_wip.rb2
-rw-r--r--app/graphql/mutations/notes/create/diff_note.rb16
-rw-r--r--app/graphql/mutations/notes/update/base.rb12
-rw-r--r--app/graphql/mutations/notes/update/image_diff_note.rb23
-rw-r--r--app/graphql/mutations/notes/update/note.rb13
-rw-r--r--app/graphql/mutations/release_asset_links/create.rb46
-rw-r--r--app/graphql/mutations/release_asset_links/update.rb65
-rw-r--r--app/graphql/mutations/snippets/create.rb2
-rw-r--r--app/graphql/mutations/snippets/update.rb2
-rw-r--r--app/graphql/mutations/todos/restore_many.rb2
-rw-r--r--app/graphql/mutations/user_callouts/create.rb30
-rw-r--r--app/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver.rb (renamed from app/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver.rb)8
-rw-r--r--app/graphql/resolvers/alert_management/alert_resolver.rb3
-rw-r--r--app/graphql/resolvers/alert_management/http_integrations_resolver.rb23
-rw-r--r--app/graphql/resolvers/base_resolver.rb11
-rw-r--r--app/graphql/resolvers/board_lists_resolver.rb2
-rw-r--r--app/graphql/resolvers/board_resolver.rb2
-rw-r--r--app/graphql/resolvers/boards_resolver.rb2
-rw-r--r--app/graphql/resolvers/branch_commit_resolver.rb11
-rw-r--r--app/graphql/resolvers/ci/config_resolver.rb2
-rw-r--r--app/graphql/resolvers/concerns/resolves_snippets.rb2
-rw-r--r--app/graphql/resolvers/full_path_resolver.rb2
-rw-r--r--app/graphql/resolvers/group_packages_resolver.rb27
-rw-r--r--app/graphql/resolvers/last_commit_resolver.rb2
-rw-r--r--app/graphql/resolvers/metrics/dashboard_resolver.rb21
-rw-r--r--app/graphql/resolvers/namespace_projects_resolver.rb35
-rw-r--r--app/graphql/resolvers/project_merge_requests_resolver.rb2
-rw-r--r--app/graphql/resolvers/project_packages_resolver.rb (renamed from app/graphql/resolvers/packages_resolver.rb)2
-rw-r--r--app/graphql/resolvers/project_pipeline_resolver.rb32
-rw-r--r--app/graphql/resolvers/snippets/blobs_resolver.rb1
-rw-r--r--app/graphql/resolvers/tree_resolver.rb2
-rw-r--r--app/graphql/types/access_level_enum.rb13
-rw-r--r--app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb25
-rw-r--r--app/graphql/types/admin/analytics/usage_trends/measurement_identifier_enum.rb25
-rw-r--r--app/graphql/types/admin/analytics/usage_trends/measurement_type.rb (renamed from app/graphql/types/admin/analytics/instance_statistics/measurement_type.rb)8
-rw-r--r--app/graphql/types/alert_management/alert_sort_enum.rb28
-rw-r--r--app/graphql/types/alert_management/alert_type.rb6
-rw-r--r--app/graphql/types/alert_management/domain_filter_enum.rb4
-rw-r--r--app/graphql/types/alert_management/http_integration_type.rb2
-rw-r--r--app/graphql/types/alert_management/integration_type_enum.rb4
-rw-r--r--app/graphql/types/base_argument.rb1
-rw-r--r--app/graphql/types/base_field.rb55
-rw-r--r--app/graphql/types/blob_viewers/type_enum.rb6
-rw-r--r--app/graphql/types/board_list_type.rb8
-rw-r--r--app/graphql/types/board_type.rb4
-rw-r--r--app/graphql/types/boards/board_issue_input_base_type.rb2
-rw-r--r--app/graphql/types/boards/board_issue_input_type.rb2
-rw-r--r--app/graphql/types/ci/config/status_enum.rb4
-rw-r--r--app/graphql/types/ci/job_artifact_file_type_enum.rb3
-rw-r--r--app/graphql/types/ci/job_type.rb4
-rw-r--r--app/graphql/types/ci/pipeline_type.rb7
-rw-r--r--app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb1
-rw-r--r--app/graphql/types/ci_configuration/sast/entity_input_type.rb1
-rw-r--r--app/graphql/types/ci_configuration/sast/input_type.rb2
-rw-r--r--app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb6
-rw-r--r--app/graphql/types/commit_action_mode_enum.rb10
-rw-r--r--app/graphql/types/commit_action_type.rb2
-rw-r--r--app/graphql/types/commit_encoding_enum.rb4
-rw-r--r--app/graphql/types/concerns/gitlab_style_deprecations.rb2
-rw-r--r--app/graphql/types/container_repository_sort_enum.rb4
-rw-r--r--app/graphql/types/current_user_todos.rb5
-rw-r--r--app/graphql/types/design_management/design_version_event_enum.rb2
-rw-r--r--app/graphql/types/diff_paths_input_type.rb2
-rw-r--r--app/graphql/types/error_tracking/sentry_error_status_enum.rb8
-rw-r--r--app/graphql/types/global_id_type.rb42
-rw-r--r--app/graphql/types/group_type.rb4
-rw-r--r--app/graphql/types/issuable_sort_enum.rb12
-rw-r--r--app/graphql/types/issuable_state_enum.rb8
-rw-r--r--app/graphql/types/issue_sort_enum.rb10
-rw-r--r--app/graphql/types/issue_state_event_enum.rb4
-rw-r--r--app/graphql/types/jira_users_mapping_input_type.rb2
-rw-r--r--app/graphql/types/label_type.rb4
-rw-r--r--app/graphql/types/merge_request_sort_enum.rb4
-rw-r--r--app/graphql/types/merge_request_state_enum.rb2
-rw-r--r--app/graphql/types/merge_request_type.rb6
-rw-r--r--app/graphql/types/merge_strategy_enum.rb9
-rw-r--r--app/graphql/types/milestone_state_enum.rb4
-rw-r--r--app/graphql/types/mutation_operation_mode_enum.rb6
-rw-r--r--app/graphql/types/mutation_type.rb15
-rw-r--r--app/graphql/types/notes/diff_image_position_input_type.rb2
-rw-r--r--app/graphql/types/notes/diff_position_base_input_type.rb2
-rw-r--r--app/graphql/types/notes/diff_position_input_type.rb4
-rw-r--r--app/graphql/types/notes/position_type_enum.rb4
-rw-r--r--app/graphql/types/notes/update_diff_image_position_input_type.rb3
-rw-r--r--app/graphql/types/project_type.rb8
-rw-r--r--app/graphql/types/projects/namespace_project_sort_enum.rb4
-rw-r--r--app/graphql/types/query_type.rb12
-rw-r--r--app/graphql/types/range_input_type.rb2
-rw-r--r--app/graphql/types/release_asset_link_input_type.rb17
-rw-r--r--app/graphql/types/release_asset_link_shared_input_arguments.rb25
-rw-r--r--app/graphql/types/release_assets_input_type.rb1
-rw-r--r--app/graphql/types/release_sort_enum.rb8
-rw-r--r--app/graphql/types/snippets/blob_action_input_type.rb2
-rw-r--r--app/graphql/types/snippets/blob_type.rb1
-rw-r--r--app/graphql/types/sort_enum.rb16
-rw-r--r--app/graphql/types/time_type.rb8
-rw-r--r--app/graphql/types/timeframe_input_type.rb2
-rw-r--r--app/graphql/types/todo_action_enum.rb16
-rw-r--r--app/graphql/types/todo_state_enum.rb4
-rw-r--r--app/graphql/types/todo_target_enum.rb10
-rw-r--r--app/graphql/types/tree/blob_type.rb1
-rw-r--r--app/graphql/types/user_callout_feature_name_enum.rb12
-rw-r--r--app/graphql/types/user_callout_type.rb12
-rw-r--r--app/graphql/types/user_state_enum.rb6
-rw-r--r--app/graphql/types/user_type.rb4
-rw-r--r--app/graphql/types/visibility_levels_enum.rb2
-rw-r--r--app/helpers/analytics/navbar_helper.rb2
-rw-r--r--app/helpers/application_settings_helper.rb31
-rw-r--r--app/helpers/auth_helper.rb24
-rw-r--r--app/helpers/avatars_helper.rb29
-rw-r--r--app/helpers/blob_helper.rb4
-rw-r--r--app/helpers/boards_helper.rb8
-rw-r--r--app/helpers/ci/pipeline_editor_helper.rb3
-rw-r--r--app/helpers/commits_helper.rb14
-rw-r--r--app/helpers/compare_helper.rb37
-rw-r--r--app/helpers/diff_helper.rb6
-rw-r--r--app/helpers/gitlab_routing_helper.rb5
-rw-r--r--app/helpers/groups_helper.rb8
-rw-r--r--app/helpers/ide_helper.rb33
-rw-r--r--app/helpers/in_product_marketing_helper.rb4
-rw-r--r--app/helpers/invite_members_helper.rb10
-rw-r--r--app/helpers/issuables_description_templates_helper.rb17
-rw-r--r--app/helpers/issuables_helper.rb5
-rw-r--r--app/helpers/jira_connect_helper.rb6
-rw-r--r--app/helpers/learn_gitlab_helper.rb17
-rw-r--r--app/helpers/merge_requests_helper.rb12
-rw-r--r--app/helpers/notifications_helper.rb46
-rw-r--r--app/helpers/preferences_helper.rb1
-rw-r--r--app/helpers/projects/project_members_helper.rb4
-rw-r--r--app/helpers/projects/security/configuration_helper.rb13
-rw-r--r--app/helpers/projects_helper.rb7
-rw-r--r--app/helpers/search_helper.rb30
-rw-r--r--app/helpers/snippets_helper.rb14
-rw-r--r--app/helpers/stat_anchors_helper.rb3
-rw-r--r--app/helpers/user_callouts_helper.rb2
-rw-r--r--app/helpers/visibility_level_helper.rb4
-rw-r--r--app/helpers/wiki_helper.rb18
-rw-r--r--app/helpers/workhorse_helper.rb10
-rw-r--r--app/mailers/emails/merge_requests.rb8
-rw-r--r--app/mailers/emails/pipelines.rb2
-rw-r--r--app/mailers/emails/profile.rb5
-rw-r--r--app/mailers/emails/service_desk.rb10
-rw-r--r--app/mailers/previews/notify_preview.rb16
-rw-r--r--app/models/alert_management/http_integration.rb5
-rw-r--r--app/models/analytics/instance_statistics.rb9
-rw-r--r--app/models/analytics/usage_trends/measurement.rb (renamed from app/models/analytics/instance_statistics/measurement.rb)6
-rw-r--r--app/models/application_setting.rb12
-rw-r--r--app/models/application_setting_implementation.rb21
-rw-r--r--app/models/board.rb1
-rw-r--r--app/models/bulk_imports/entity.rb9
-rw-r--r--app/models/bulk_imports/tracker.rb30
-rw-r--r--app/models/ci/build.rb26
-rw-r--r--app/models/ci/build_need.rb1
-rw-r--r--app/models/ci/build_report_result.rb4
-rw-r--r--app/models/ci/daily_build_group_report_result.rb14
-rw-r--r--app/models/ci/deleted_object.rb4
-rw-r--r--app/models/ci/group.rb2
-rw-r--r--app/models/ci/group_variable.rb4
-rw-r--r--app/models/ci/job_artifact.rb10
-rw-r--r--app/models/ci/legacy_stage.rb2
-rw-r--r--app/models/ci/pipeline.rb33
-rw-r--r--app/models/ci/processable.rb4
-rw-r--r--app/models/ci/ref.rb2
-rw-r--r--app/models/ci/runner.rb40
-rw-r--r--app/models/ci/runner_namespace.rb9
-rw-r--r--app/models/ci/stage.rb4
-rw-r--r--app/models/ci/variable.rb1
-rw-r--r--app/models/clusters/agent_token.rb5
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/instance.rb4
-rw-r--r--app/models/commit_status.rb11
-rw-r--r--app/models/commit_with_pipeline.rb38
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stage.rb19
-rw-r--r--app/models/concerns/avatarable.rb8
-rw-r--r--app/models/concerns/boards/listable.rb20
-rw-r--r--app/models/concerns/ci/contextable.rb16
-rw-r--r--app/models/concerns/ci/has_status.rb6
-rw-r--r--app/models/concerns/ci/has_variable.rb1
-rw-r--r--app/models/concerns/issuable.rb1
-rw-r--r--app/models/concerns/project_features_compatibility.rb12
-rw-r--r--app/models/custom_emoji.rb2
-rw-r--r--app/models/dependency_proxy.rb1
-rw-r--r--app/models/dependency_proxy/manifest.rb7
-rw-r--r--app/models/environment.rb48
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb10
-rw-r--r--app/models/experiment.rb20
-rw-r--r--app/models/group.rb64
-rw-r--r--app/models/hooks/web_hook_log.rb2
-rw-r--r--app/models/hooks/web_hook_log_partitioned.rb17
-rw-r--r--app/models/issue.rb8
-rw-r--r--app/models/issue_email_participant.rb2
-rw-r--r--app/models/iteration.rb136
-rw-r--r--app/models/label.rb4
-rw-r--r--app/models/list.rb21
-rw-r--r--app/models/member.rb15
-rw-r--r--app/models/merge_request.rb96
-rw-r--r--app/models/namespace.rb39
-rw-r--r--app/models/namespace/root_storage_statistics.rb5
-rw-r--r--app/models/namespaces/traversal/recursive.rb21
-rw-r--r--app/models/note.rb12
-rw-r--r--app/models/notification_setting.rb3
-rw-r--r--app/models/onboarding_progress.rb8
-rw-r--r--app/models/packages/maven/metadatum.rb7
-rw-r--r--app/models/packages/nuget.rb2
-rw-r--r--app/models/packages/package.rb34
-rw-r--r--app/models/packages/package_file.rb4
-rw-r--r--app/models/packages/rubygems.rb10
-rw-r--r--app/models/packages/rubygems/metadatum.rb1
-rw-r--r--app/models/pages/lookup_path.rb6
-rw-r--r--app/models/personal_access_token.rb5
-rw-r--r--app/models/project.rb62
-rw-r--r--app/models/project_feature.rb48
-rw-r--r--app/models/project_repository_storage_move.rb41
-rw-r--r--app/models/project_services/chat_notification_service.rb15
-rw-r--r--app/models/project_services/discord_service.rb3
-rw-r--r--app/models/project_services/mattermost_service.rb2
-rw-r--r--app/models/project_services/prometheus_service.rb12
-rw-r--r--app/models/project_services/slack_mattermost/notifier.rb24
-rw-r--r--app/models/project_services/slack_service.rb34
-rw-r--r--app/models/project_services/unify_circuit_service.rb2
-rw-r--r--app/models/project_services/webex_teams_service.rb2
-rw-r--r--app/models/projects/repository_storage_move.rb38
-rw-r--r--app/models/protected_branch.rb9
-rw-r--r--app/models/snippet.rb9
-rw-r--r--app/models/snippet_repository_storage_move.rb35
-rw-r--r--app/models/snippets/repository_storage_move.rb32
-rw-r--r--app/models/todo.rb1
-rw-r--r--app/models/user.rb23
-rw-r--r--app/models/user_callout.rb2
-rw-r--r--app/models/user_preference.rb1
-rw-r--r--app/models/users_statistics.rb2
-rw-r--r--app/models/wiki.rb13
-rw-r--r--app/models/wiki_page.rb5
-rw-r--r--app/models/zoom_meeting.rb2
-rw-r--r--app/policies/analytics/usage_trends/measurement_policy.rb (renamed from app/policies/analytics/instance_statistics/measurement_policy.rb)2
-rw-r--r--app/policies/base_policy.rb11
-rw-r--r--app/policies/concerns/readonly_abilities.rb2
-rw-r--r--app/policies/global_policy.rb2
-rw-r--r--app/policies/group_member_policy.rb2
-rw-r--r--app/policies/group_policy.rb8
-rw-r--r--app/policies/merge_request_policy.rb11
-rw-r--r--app/policies/note_policy.rb4
-rw-r--r--app/policies/project_policy.rb25
-rw-r--r--app/presenters/ci/build_runner_presenter.rb4
-rw-r--r--app/presenters/ci/pipeline_presenter.rb2
-rw-r--r--app/presenters/dev_ops_report/metric_presenter.rb2
-rw-r--r--app/presenters/packages/composer/packages_presenter.rb15
-rw-r--r--app/presenters/packages/detail/package_presenter.rb6
-rw-r--r--app/presenters/project_presenter.rb89
-rw-r--r--app/serializers/base_discussion_entity.rb2
-rw-r--r--app/serializers/build_details_entity.rb2
-rw-r--r--app/serializers/ci/pipeline_entity.rb6
-rw-r--r--app/serializers/current_board_entity.rb2
-rw-r--r--app/serializers/merge_request_user_entity.rb12
-rw-r--r--app/serializers/merge_request_widget_entity.rb2
-rw-r--r--app/serializers/merge_requests/pipeline_entity.rb2
-rw-r--r--app/serializers/pipeline_serializer.rb5
-rw-r--r--app/serializers/test_suite_comparer_entity.rb12
-rw-r--r--app/serializers/test_suite_summary_entity.rb2
-rw-r--r--app/services/alert_management/create_alert_issue_service.rb11
-rw-r--r--app/services/authorized_project_update/periodic_recalculate_service.rb4
-rw-r--r--app/services/boards/base_item_move_service.rb72
-rw-r--r--app/services/boards/base_items_list_service.rb16
-rw-r--r--app/services/boards/issues/list_service.rb16
-rw-r--r--app/services/boards/issues/move_service.rb75
-rw-r--r--app/services/boards/lists/list_service.rb21
-rw-r--r--app/services/boards/lists/update_service.rb4
-rw-r--r--app/services/boards/update_service.rb11
-rw-r--r--app/services/bulk_import_service.rb7
-rw-r--r--app/services/ci/build_report_result_service.rb3
-rw-r--r--app/services/ci/create_downstream_pipeline_service.rb2
-rw-r--r--app/services/ci/create_pipeline_service.rb4
-rw-r--r--app/services/ci/destroy_expired_job_artifacts_service.rb49
-rw-r--r--app/services/ci/expire_pipeline_cache_service.rb33
-rw-r--r--app/services/ci/job_artifacts_destroy_batch_service.rb72
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb4
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb2
-rw-r--r--app/services/ci/process_pipeline_service.rb2
-rw-r--r--app/services/ci/register_job_service.rb193
-rw-r--r--app/services/ci/retry_build_service.rb4
-rw-r--r--app/services/ci/retry_pipeline_service.rb2
-rw-r--r--app/services/ci/update_build_queue_service.rb11
-rw-r--r--app/services/clusters/kubernetes.rb2
-rw-r--r--app/services/clusters/kubernetes/create_or_update_service_account_service.rb32
-rw-r--r--app/services/concerns/alert_management/alert_processing.rb19
-rw-r--r--app/services/dependency_proxy/find_or_create_manifest_service.rb7
-rw-r--r--app/services/dependency_proxy/head_manifest_service.rb6
-rw-r--r--app/services/dependency_proxy/pull_manifest_service.rb4
-rw-r--r--app/services/deployments/older_deployments_drop_service.rb2
-rw-r--r--app/services/deployments/update_environment_service.rb21
-rw-r--r--app/services/environments/schedule_to_delete_review_apps_service.rb102
-rw-r--r--app/services/groups/create_service.rb2
-rw-r--r--app/services/groups/destroy_service.rb4
-rw-r--r--app/services/groups/group_links/create_service.rb2
-rw-r--r--app/services/groups/group_links/destroy_service.rb2
-rw-r--r--app/services/groups/group_links/update_service.rb2
-rw-r--r--app/services/import/github_service.rb34
-rw-r--r--app/services/issuable/clone/base_service.rb11
-rw-r--r--app/services/issuable/process_assignees.rb36
-rw-r--r--app/services/issue_rebalancing_service.rb5
-rw-r--r--app/services/issues/clone_service.rb1
-rw-r--r--app/services/issues/create_service.rb1
-rw-r--r--app/services/issues/move_service.rb15
-rw-r--r--app/services/jira_import/users_importer.rb2
-rw-r--r--app/services/members/invite_service.rb97
-rw-r--r--app/services/merge_requests/after_create_service.rb10
-rw-r--r--app/services/merge_requests/base_service.rb2
-rw-r--r--app/services/merge_requests/build_service.rb16
-rw-r--r--app/services/merge_requests/merge_service.rb3
-rw-r--r--app/services/merge_requests/post_merge_service.rb29
-rw-r--r--app/services/merge_requests/refresh_service.rb13
-rw-r--r--app/services/merge_requests/retarget_chain_service.rb34
-rw-r--r--app/services/merge_requests/update_service.rb191
-rw-r--r--app/services/namespaces/in_product_marketing_emails_service.rb5
-rw-r--r--app/services/notes/build_service.rb30
-rw-r--r--app/services/notes/update_service.rb16
-rw-r--r--app/services/notification_service.rb65
-rw-r--r--app/services/onboarding_progress_service.rb18
-rw-r--r--app/services/packages/composer/create_package_service.rb2
-rw-r--r--app/services/packages/conan/search_service.rb2
-rw-r--r--app/services/packages/create_event_service.rb14
-rw-r--r--app/services/packages/create_temporary_package_service.rb21
-rw-r--r--app/services/packages/debian/find_or_create_incoming_service.rb (renamed from app/services/packages/debian/get_or_create_incoming_service.rb)2
-rw-r--r--app/services/packages/debian/find_or_create_package_service.rb33
-rw-r--r--app/services/packages/maven/find_or_create_package_service.rb3
-rw-r--r--app/services/packages/maven/metadata.rb13
-rw-r--r--app/services/packages/maven/metadata/append_package_file_service.rb88
-rw-r--r--app/services/packages/maven/metadata/base_create_xml_service.rb32
-rw-r--r--app/services/packages/maven/metadata/create_plugins_xml_service.rb92
-rw-r--r--app/services/packages/maven/metadata/create_versions_xml_service.rb165
-rw-r--r--app/services/packages/maven/metadata/sync_service.rb123
-rw-r--r--app/services/packages/nuget/create_package_service.rb23
-rw-r--r--app/services/packages/nuget/update_package_from_metadata_service.rb3
-rw-r--r--app/services/packages/rubygems/dependency_resolver_service.rb43
-rw-r--r--app/services/pages/legacy_storage_lease.rb9
-rw-r--r--app/services/projects/autocomplete_service.rb2
-rw-r--r--app/services/projects/create_service.rb2
-rw-r--r--app/services/projects/destroy_service.rb7
-rw-r--r--app/services/projects/group_links/create_service.rb2
-rw-r--r--app/services/projects/schedule_bulk_repository_shard_moves_service.rb2
-rw-r--r--app/services/projects/update_pages_configuration_service.rb2
-rw-r--r--app/services/projects/update_pages_service.rb1
-rw-r--r--app/services/protected_branches/api_service.rb5
-rw-r--r--app/services/repositories/changelog_service.rb6
-rw-r--r--app/services/security/vulnerability_uuid.rb9
-rw-r--r--app/services/snippets/schedule_bulk_repository_shard_moves_service.rb2
-rw-r--r--app/services/spam/spam_action_service.rb8
-rw-r--r--app/services/system_hooks_service.rb32
-rw-r--r--app/services/system_note_service.rb10
-rw-r--r--app/services/system_notes/alert_management_service.rb15
-rw-r--r--app/services/system_notes/issuables_service.rb8
-rw-r--r--app/services/system_notes/merge_requests_service.rb4
-rw-r--r--app/services/terraform/remote_state_handler.rb2
-rw-r--r--app/services/users/build_service.rb3
-rw-r--r--app/services/users/dismiss_user_callout_service.rb11
-rw-r--r--app/services/users/refresh_authorized_projects_service.rb13
-rw-r--r--app/uploaders/dependency_proxy/file_uploader.rb12
-rw-r--r--app/validators/gitlab/utils/zoom_url_validator.rb22
-rw-r--r--app/validators/json_schemas/security_scan_info.json28
-rw-r--r--app/validators/json_schemas/vulnerability_finding_details.json266
-rw-r--r--app/validators/zoom_url_validator.rb18
-rw-r--r--app/views/abuse_reports/new.html.haml2
-rw-r--r--app/views/admin/appearances/_form.html.haml2
-rw-r--r--app/views/admin/appearances/preview_sign_in.html.haml2
-rw-r--r--app/views/admin/application_settings/_abuse.html.haml2
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml2
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml2
-rw-r--r--app/views/admin/application_settings/_diff_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_eks.html.haml6
-rw-r--r--app/views/admin/application_settings/_email.html.haml10
-rw-r--r--app/views/admin/application_settings/_external_authorization_service_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_gitaly.html.haml2
-rw-r--r--app/views/admin/application_settings/_gitpod.html.haml4
-rw-r--r--app/views/admin/application_settings/_grafana.html.haml2
-rw-r--r--app/views/admin/application_settings/_help_page.html.haml2
-rw-r--r--app/views/admin/application_settings/_import_export_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_initial_branch_name.html.haml2
-rw-r--r--app/views/admin/application_settings/_ip_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_issue_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_kroki.html.haml2
-rw-r--r--app/views/admin/application_settings/_localization.html.haml2
-rw-r--r--app/views/admin/application_settings/_note_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_outbound.html.haml2
-rw-r--r--app/views/admin/application_settings/_package_registry.html.haml4
-rw-r--r--app/views/admin/application_settings/_pages.html.haml2
-rw-r--r--app/views/admin/application_settings/_performance.html.haml2
-rw-r--r--app/views/admin/application_settings/_performance_bar.html.haml6
-rw-r--r--app/views/admin/application_settings/_plantuml.html.haml2
-rw-r--r--app/views/admin/application_settings/_prometheus.html.haml2
-rw-r--r--app/views/admin/application_settings/_protected_paths.html.haml2
-rw-r--r--app/views/admin/application_settings/_realtime.html.haml2
-rw-r--r--app/views/admin/application_settings/_registry.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_mirrors_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_static_objects.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml11
-rw-r--r--app/views/admin/application_settings/_signin.html.haml11
-rw-r--r--app/views/admin/application_settings/_signup.html.haml2
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml2
-rw-r--r--app/views/admin/application_settings/_sourcegraph.html.haml4
-rw-r--r--app/views/admin/application_settings/_spam.html.haml2
-rw-r--r--app/views/admin/application_settings/_terminal.html.haml2
-rw-r--r--app/views/admin/application_settings/_terms.html.haml2
-rw-r--r--app/views/admin/application_settings/_third_party_offers.html.haml2
-rw-r--r--app/views/admin/application_settings/_usage.html.haml4
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml2
-rw-r--r--app/views/admin/application_settings/general.html.haml12
-rw-r--r--app/views/admin/applications/_form.html.haml4
-rw-r--r--app/views/admin/applications/index.html.haml2
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml4
-rw-r--r--app/views/admin/dashboard/_billable_users_text.html.haml1
-rw-r--r--app/views/admin/dashboard/index.html.haml2
-rw-r--r--app/views/admin/dashboard/stats.html.haml3
-rw-r--r--app/views/admin/deploy_keys/edit.html.haml4
-rw-r--r--app/views/admin/deploy_keys/index.html.haml2
-rw-r--r--app/views/admin/deploy_keys/new.html.haml4
-rw-r--r--app/views/admin/dev_ops_report/_report.html.haml4
-rw-r--r--app/views/admin/groups/_form.html.haml4
-rw-r--r--app/views/admin/groups/_group.html.haml2
-rw-r--r--app/views/admin/groups/index.html.haml2
-rw-r--r--app/views/admin/groups/show.html.haml3
-rw-r--r--app/views/admin/hooks/edit.html.haml2
-rw-r--r--app/views/admin/hooks/index.html.haml2
-rw-r--r--app/views/admin/labels/_form.html.haml4
-rw-r--r--app/views/admin/labels/destroy.js.haml3
-rw-r--r--app/views/admin/labels/index.html.haml7
-rw-r--r--app/views/admin/projects/_projects.html.haml2
-rw-r--r--app/views/admin/projects/index.html.haml2
-rw-r--r--app/views/admin/projects/show.html.haml1
-rw-r--r--app/views/admin/serverless/domains/_form.html.haml2
-rw-r--r--app/views/admin/usage_trends/index.html.haml (renamed from app/views/admin/instance_statistics/index.html.haml)2
-rw-r--r--app/views/admin/users/_cohorts_table.html.haml2
-rw-r--r--app/views/admin/users/_form.html.haml8
-rw-r--r--app/views/admin/users/_users.html.haml2
-rw-r--r--app/views/admin/users/index.html.haml2
-rw-r--r--app/views/ci/variables/_header.html.haml2
-rw-r--r--app/views/clusters/clusters/_advanced_settings.html.haml2
-rw-r--r--app/views/clusters/clusters/_cluster_list.html.haml4
-rw-r--r--app/views/clusters/clusters/_empty_state.html.haml2
-rw-r--r--app/views/clusters/clusters/_provider_details_form.html.haml2
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml2
-rw-r--r--app/views/clusters/clusters/user/_form.html.haml2
-rw-r--r--app/views/dashboard/_groups_head.html.haml2
-rw-r--r--app/views/dashboard/_projects_head.html.haml2
-rw-r--r--app/views/dashboard/_snippets_head.html.haml2
-rw-r--r--app/views/dashboard/activity.html.haml2
-rw-r--r--app/views/dashboard/groups/index.html.haml2
-rw-r--r--app/views/dashboard/issues.html.haml2
-rw-r--r--app/views/dashboard/merge_requests.html.haml2
-rw-r--r--app/views/dashboard/projects/_projects.html.haml2
-rw-r--r--app/views/dashboard/projects/index.html.haml2
-rw-r--r--app/views/dashboard/projects/shared/_common.html.haml2
-rw-r--r--app/views/dashboard/todos/index.html.haml2
-rw-r--r--app/views/devise/confirmations/almost_there.haml16
-rw-r--r--app/views/devise/confirmations/new.html.haml2
-rw-r--r--app/views/devise/sessions/_new_base.html.haml2
-rw-r--r--app/views/devise/sessions/_new_crowd.html.haml2
-rw-r--r--app/views/devise/sessions/_new_ldap.html.haml2
-rw-r--r--app/views/devise/sessions/two_factor.html.haml2
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml2
-rw-r--r--app/views/devise/shared/_signup_box.html.haml2
-rw-r--r--app/views/devise/shared/_signup_omniauth_providers_top.haml2
-rw-r--r--app/views/devise/unlocks/new.html.haml2
-rw-r--r--app/views/doorkeeper/applications/_form.html.haml2
-rw-r--r--app/views/doorkeeper/authorizations/new.html.haml2
-rw-r--r--app/views/events/event/_push.html.haml7
-rw-r--r--app/views/explore/groups/index.html.haml2
-rw-r--r--app/views/explore/projects/index.html.haml2
-rw-r--r--app/views/explore/projects/page_out_of_bounds.html.haml2
-rw-r--r--app/views/explore/projects/starred.html.haml2
-rw-r--r--app/views/explore/projects/trending.html.haml2
-rw-r--r--app/views/groups/_home_panel.html.haml11
-rw-r--r--app/views/groups/_import_group_from_another_instance_panel.html.haml4
-rw-r--r--app/views/groups/_import_group_from_file_panel.html.haml6
-rw-r--r--app/views/groups/_new_group_fields.html.haml2
-rw-r--r--app/views/groups/dependency_proxies/_url.html.haml2
-rw-r--r--app/views/groups/group_members/index.html.haml6
-rw-r--r--app/views/groups/milestones/_form.html.haml4
-rw-r--r--app/views/groups/milestones/index.html.haml2
-rw-r--r--app/views/groups/projects.html.haml2
-rw-r--r--app/views/groups/registry/repositories/index.html.haml1
-rw-r--r--app/views/groups/settings/_advanced.html.haml2
-rw-r--r--app/views/groups/settings/_general.html.haml4
-rw-r--r--app/views/groups/settings/_pages_settings.html.haml2
-rw-r--r--app/views/groups/settings/_permissions.html.haml2
-rw-r--r--app/views/groups/settings/ci_cd/_auto_devops_form.html.haml2
-rw-r--r--app/views/groups/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml6
-rw-r--r--app/views/groups/settings/packages_and_registries/index.html.haml2
-rw-r--r--app/views/groups/settings/repository/_initial_branch_name.html.haml2
-rw-r--r--app/views/groups/settings/repository/show.html.haml2
-rw-r--r--app/views/groups/show.html.haml5
-rw-r--r--app/views/groups/sidebar/_packages_settings.html.haml2
-rw-r--r--app/views/import/bitbucket_server/new.html.haml2
-rw-r--r--app/views/import/bulk_imports/status.html.haml3
-rw-r--r--app/views/import/fogbugz/new.html.haml2
-rw-r--r--app/views/import/fogbugz/new_user_map.html.haml2
-rw-r--r--app/views/import/gitea/new.html.haml2
-rw-r--r--app/views/import/github/new.html.haml6
-rw-r--r--app/views/import/gitlab_projects/new.html.haml4
-rw-r--r--app/views/import/manifest/_form.html.haml2
-rw-r--r--app/views/import/phabricator/new.html.haml2
-rw-r--r--app/views/import/shared/_new_project_form.html.haml4
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml78
-rw-r--r--app/views/layouts/_head.html.haml5
-rw-r--r--app/views/layouts/_snowplow.html.haml3
-rw-r--r--app/views/layouts/jira_connect.html.haml6
-rw-r--r--app/views/layouts/nav/groups_dropdown/_show.html.haml6
-rw-r--r--app/views/layouts/nav/projects_dropdown/_show.html.haml11
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml9
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml6
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml14
-rw-r--r--app/views/notify/_users_list.html.haml10
-rw-r--r--app/views/notify/access_token_about_to_expire_email.html.haml8
-rw-r--r--app/views/notify/access_token_about_to_expire_email.text.erb6
-rw-r--r--app/views/notify/change_in_merge_request_draft_status_email.html.haml2
-rw-r--r--app/views/notify/change_in_merge_request_draft_status_email.text.erb1
-rw-r--r--app/views/notify/closed_merge_request_email.text.haml1
-rw-r--r--app/views/notify/in_product_marketing_email.html.haml5
-rw-r--r--app/views/notify/merge_request_status_email.text.haml1
-rw-r--r--app/views/notify/merge_request_unmergeable_email.text.haml1
-rw-r--r--app/views/notify/merge_when_pipeline_succeeds_email.html.haml18
-rw-r--r--app/views/notify/merge_when_pipeline_succeeds_email.text.haml1
-rw-r--r--app/views/notify/merged_merge_request_email.text.haml1
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.text.erb3
-rw-r--r--app/views/notify/new_merge_request_email.html.haml7
-rw-r--r--app/views/notify/new_merge_request_email.text.erb1
-rw-r--r--app/views/peek/_bar.html.haml1
-rw-r--r--app/views/profiles/_email_settings.html.haml2
-rw-r--r--app/views/profiles/accounts/show.html.haml2
-rw-r--r--app/views/profiles/notifications/_group_settings.html.haml7
-rw-r--r--app/views/profiles/notifications/_project_settings.html.haml7
-rw-r--r--app/views/profiles/notifications/show.html.haml7
-rw-r--r--app/views/profiles/preferences/show.html.haml9
-rw-r--r--app/views/profiles/show.html.haml8
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml19
-rw-r--r--app/views/projects/_invite_members_link.html.haml4
-rw-r--r--app/views/projects/_invite_members_modal.html.haml2
-rw-r--r--app/views/projects/_merge_request_merge_checks_settings.html.haml12
-rw-r--r--app/views/projects/_merge_request_merge_method_settings.html.haml18
-rw-r--r--app/views/projects/_merge_request_merge_options_settings.html.haml8
-rw-r--r--app/views/projects/_merge_request_merge_suggestions_settings.html.haml12
-rw-r--r--app/views/projects/_merge_request_squash_options_settings.html.haml2
-rw-r--r--app/views/projects/_new_project_fields.html.haml6
-rw-r--r--app/views/projects/_new_project_push_tip.html.haml11
-rw-r--r--app/views/projects/_service_desk_settings.html.haml2
-rw-r--r--app/views/projects/artifacts/browse.html.haml2
-rw-r--r--app/views/projects/blame/_age_map_legend.html.haml12
-rw-r--r--app/views/projects/blame/_blame_group.html.haml26
-rw-r--r--app/views/projects/blame/show.html.haml52
-rw-r--r--app/views/projects/blob/_template_selectors.html.haml2
-rw-r--r--app/views/projects/blob/_upload.html.haml2
-rw-r--r--app/views/projects/branches/_branch.html.haml4
-rw-r--r--app/views/projects/buttons/_clone.html.haml10
-rw-r--r--app/views/projects/buttons/_fork.html.haml13
-rw-r--r--app/views/projects/buttons/_star.html.haml18
-rw-r--r--app/views/projects/ci/pipeline_editor/show.html.haml10
-rw-r--r--app/views/projects/cleanup/_show.html.haml4
-rw-r--r--app/views/projects/commit/_change.html.haml5
-rw-r--r--app/views/projects/commit/_commit_box.html.haml25
-rw-r--r--app/views/projects/commit/_pipelines_list.haml2
-rw-r--r--app/views/projects/commits/_commit.html.haml2
-rw-r--r--app/views/projects/commits/show.html.haml4
-rw-r--r--app/views/projects/compare/_form.html.haml28
-rw-r--r--app/views/projects/compare/index.html.haml6
-rw-r--r--app/views/projects/compare/show.html.haml3
-rw-r--r--app/views/projects/cycle_analytics/_empty_stage.html.haml7
-rw-r--r--app/views/projects/cycle_analytics/_no_access.html.haml7
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml66
-rw-r--r--app/views/projects/default_branch/_show.html.haml4
-rw-r--r--app/views/projects/diffs/_diffs.html.haml2
-rw-r--r--app/views/projects/diffs/_file.html.haml5
-rw-r--r--app/views/projects/edit.html.haml14
-rw-r--r--app/views/projects/empty.html.haml8
-rw-r--r--app/views/projects/forks/_fork_button.html.haml2
-rw-r--r--app/views/projects/forks/new.html.haml45
-rw-r--r--app/views/projects/issues/_form.html.haml2
-rw-r--r--app/views/projects/issues/_issue.html.haml2
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml17
-rw-r--r--app/views/projects/issues/_service_desk_empty_state.html.haml16
-rw-r--r--app/views/projects/issues/_service_desk_info_content.html.haml10
-rw-r--r--app/views/projects/issues/import_csv/_button.html.haml17
-rw-r--r--app/views/projects/issues/import_csv/_modal.html.haml24
-rw-r--r--app/views/projects/issues/index.html.haml27
-rw-r--r--app/views/projects/issues/new.html.haml4
-rw-r--r--app/views/projects/learn_gitlab/index.html.haml1
-rw-r--r--app/views/projects/mattermosts/_no_teams.html.haml4
-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.haml12
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml1
-rw-r--r--app/views/projects/merge_requests/conflicts/_commit_stats.html.haml11
-rw-r--r--app/views/projects/merge_requests/conflicts/_file_actions.html.haml12
-rw-r--r--app/views/projects/merge_requests/conflicts/_submit_form.html.haml24
-rw-r--r--app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml10
-rw-r--r--app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml14
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml32
-rw-r--r--app/views/projects/merge_requests/show.html.haml2
-rw-r--r--app/views/projects/milestones/_form.html.haml8
-rw-r--r--app/views/projects/milestones/index.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml6
-rw-r--r--app/views/projects/new.html.haml5
-rw-r--r--app/views/projects/no_repo.html.haml2
-rw-r--r--app/views/projects/pages/_list.html.haml2
-rw-r--r--app/views/projects/pages/_pages_settings.html.haml2
-rw-r--r--app/views/projects/pages/show.html.haml2
-rw-r--r--app/views/projects/pages_domains/new.html.haml4
-rw-r--r--app/views/projects/pages_domains/show.html.haml4
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml6
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/projects/pipelines/charts.html.haml2
-rw-r--r--app/views/projects/pipelines/index.html.haml2
-rw-r--r--app/views/projects/pipelines/new.html.haml5
-rw-r--r--app/views/projects/pipelines/show.html.haml2
-rw-r--r--app/views/projects/project_members/_groups.html.haml11
-rw-r--r--app/views/projects/project_members/_team.html.haml22
-rw-r--r--app/views/projects/project_members/index.html.haml54
-rw-r--r--app/views/projects/project_templates/_project_fields_form.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_branches_list.html.haml6
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml9
-rw-r--r--app/views/projects/protected_branches/shared/_index.html.haml4
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml4
-rw-r--r--app/views/projects/registry/repositories/index.html.haml1
-rw-r--r--app/views/projects/runners/_runner.html.haml2
-rw-r--r--app/views/projects/security/configuration/show.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_configuration_banner.html.haml6
-rw-r--r--app/views/projects/services/prometheus/_help.html.haml2
-rw-r--r--app/views/projects/settings/_general.html.haml7
-rw-r--r--app/views/projects/settings/ci_cd/_badge.html.haml6
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml20
-rw-r--r--app/views/projects/settings/operations/_configuration_banner.html.haml6
-rw-r--r--app/views/projects/settings/operations/_prometheus.html.haml2
-rw-r--r--app/views/projects/settings/operations/show.html.haml2
-rw-r--r--app/views/projects/settings/repository/show.html.haml2
-rw-r--r--app/views/projects/show.html.haml3
-rw-r--r--app/views/projects/tags/_tag.html.haml2
-rw-r--r--app/views/projects/tree/_tree_header.html.haml2
-rw-r--r--app/views/projects/update.js.haml10
-rw-r--r--app/views/search/results/_issuable.html.haml2
-rw-r--r--app/views/shared/_confirm_fork_modal.html.haml4
-rw-r--r--app/views/shared/_group_form.html.haml2
-rw-r--r--app/views/shared/_mobile_clone_panel.html.haml4
-rw-r--r--app/views/shared/_new_commit_form.html.haml7
-rw-r--r--app/views/shared/_new_project_item_select.html.haml4
-rw-r--r--app/views/shared/_recaptcha_form.html.haml2
-rw-r--r--app/views/shared/access_tokens/_form.html.haml6
-rw-r--r--app/views/shared/boards/_show.html.haml2
-rw-r--r--app/views/shared/boards/_switcher.html.haml2
-rw-r--r--app/views/shared/boards/components/_sidebar.html.haml4
-rw-r--r--app/views/shared/deploy_keys/_index.html.haml4
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_index.html.haml4
-rw-r--r--app/views/shared/empty_states/_deploy_keys.html.haml2
-rw-r--r--app/views/shared/empty_states/_issues.html.haml15
-rw-r--r--app/views/shared/empty_states/_labels.html.haml6
-rw-r--r--app/views/shared/empty_states/_merge_requests.html.haml6
-rw-r--r--app/views/shared/empty_states/_profile_tabs.html.haml4
-rw-r--r--app/views/shared/empty_states/_snippets.html.haml2
-rw-r--r--app/views/shared/empty_states/_wikis.html.haml4
-rw-r--r--app/views/shared/form_elements/_apply_template_warning.html.haml4
-rw-r--r--app/views/shared/gitpod/_enable_gitpod_modal.html.haml4
-rw-r--r--app/views/shared/groups/_empty_state.html.haml2
-rw-r--r--app/views/shared/issuable/_form.html.haml8
-rw-r--r--app/views/shared/issuable/_label_page_create.html.haml2
-rw-r--r--app/views/shared/issuable/_nav.html.haml2
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml338
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml23
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml3
-rw-r--r--app/views/shared/issuable/_sidebar_reviewers.html.haml15
-rw-r--r--app/views/shared/issuable/csv_export/_button.html.haml4
-rw-r--r--app/views/shared/issuable/csv_export/_modal.html.haml29
-rw-r--r--app/views/shared/labels/_form.html.haml6
-rw-r--r--app/views/shared/labels/_nav.html.haml4
-rw-r--r--app/views/shared/members/_invite_group.html.haml2
-rw-r--r--app/views/shared/members/_invite_member.html.haml2
-rw-r--r--app/views/shared/members/_member.html.haml2
-rw-r--r--app/views/shared/milestones/_milestone.html.haml6
-rw-r--r--app/views/shared/notifications/_button.html.haml37
-rw-r--r--app/views/shared/notifications/_custom_notifications.html.haml34
-rw-r--r--app/views/shared/notifications/_new_button.html.haml35
-rw-r--r--app/views/shared/notifications/_notification_dropdown.html.haml12
-rw-r--r--app/views/shared/projects/_list.html.haml8
-rw-r--r--app/views/shared/projects/_project.html.haml9
-rw-r--r--app/views/shared/projects/_search_bar.html.haml2
-rw-r--r--app/views/shared/projects/protected_branches/_update_protected_branch.html.haml3
-rw-r--r--app/views/shared/snippets/_snippet.html.haml2
-rw-r--r--app/views/shared/wikis/_form.html.haml4
-rw-r--r--app/views/shared/wikis/_main_links.html.haml2
-rw-r--r--app/views/shared/wikis/_sidebar_wiki_page.html.haml2
-rw-r--r--app/views/shared/wikis/edit.html.haml3
-rw-r--r--app/views/users/show.html.haml14
-rw-r--r--app/workers/all_queues.yml116
-rw-r--r--app/workers/analytics/instance_statistics/count_job_trigger_worker.rb18
-rw-r--r--app/workers/analytics/instance_statistics/counter_job_worker.rb22
-rw-r--r--app/workers/analytics/usage_trends/count_job_trigger_worker.rb34
-rw-r--r--app/workers/analytics/usage_trends/counter_job_worker.rb35
-rw-r--r--app/workers/archive_trace_worker.rb2
-rw-r--r--app/workers/build_finished_worker.rb3
-rw-r--r--app/workers/chat_notification_worker.rb1
-rw-r--r--app/workers/concerns/worker_attributes.rb8
-rw-r--r--app/workers/emails_on_push_worker.rb2
-rw-r--r--app/workers/error_tracking_issue_link_worker.rb4
-rw-r--r--app/workers/expire_build_instance_artifacts_worker.rb1
-rw-r--r--app/workers/expire_job_cache_worker.rb10
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb2
-rw-r--r--app/workers/merge_requests/delete_source_branch_worker.rb24
-rw-r--r--app/workers/namespaces/in_product_marketing_emails_worker.rb1
-rw-r--r--app/workers/namespaces/onboarding_issue_created_worker.rb20
-rw-r--r--app/workers/namespaces/onboarding_progress_worker.rb20
-rw-r--r--app/workers/packages/composer/cache_update_worker.rb23
-rw-r--r--app/workers/packages/maven/metadata/sync_worker.rb59
-rw-r--r--app/workers/pages_update_configuration_worker.rb6
-rw-r--r--app/workers/personal_access_tokens/expiring_worker.rb19
-rw-r--r--app/workers/post_receive.rb1
-rw-r--r--app/workers/project_schedule_bulk_repository_shard_moves_worker.rb18
-rw-r--r--app/workers/project_update_repository_storage_worker.rb32
-rw-r--r--app/workers/projects/schedule_bulk_repository_shard_moves_worker.rb15
-rw-r--r--app/workers/projects/update_repository_storage_worker.rb25
-rw-r--r--app/workers/releases/create_evidence_worker.rb1
-rw-r--r--app/workers/snippet_schedule_bulk_repository_shard_moves_worker.rb17
-rw-r--r--app/workers/snippet_update_repository_storage_worker.rb32
-rw-r--r--app/workers/snippets/schedule_bulk_repository_shard_moves_worker.rb15
-rw-r--r--app/workers/snippets/update_repository_storage_worker.rb25
-rw-r--r--app/workers/stuck_ci_jobs_worker.rb2
1540 files changed, 20739 insertions, 10473 deletions
diff --git a/app/assets/images/learn_gitlab/code_owners_enabled.svg b/app/assets/images/learn_gitlab/code_owners_enabled.svg
new file mode 100644
index 00000000000..019d74c64cc
--- /dev/null
+++ b/app/assets/images/learn_gitlab/code_owners_enabled.svg
@@ -0,0 +1,5 @@
+<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13 25C19.6274 25 25 19.6274 25 13C25 6.37258 19.6274 1 13 1C6.37258 1 1 6.37258 1 13C1 19.6274 6.37258 25 13 25Z" fill="white" stroke="#C2B7E6" stroke-width="2"/>
+<path d="M1.16748 12.3359C2.88075 11.7701 4.4618 10.8635 5.81545 9.67055C7.16911 8.47763 8.26738 7.02313 9.04415 5.39461M6.94481 2.60461C9.28681 6.43995 13.5115 8.99995 18.3335 8.99995C20.2715 8.99995 22.1135 8.58661 23.7748 7.84261L6.94481 2.60461Z" stroke="#C2B7E6"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1176 15.8941H15.7647C15.7647 17.447 14.4941 18.7176 12.9412 18.7176C11.3882 18.7176 10.1176 17.447 10.1176 15.8941ZM9.05882 15.1882C8.47294 15.1882 8 14.7153 8 14.1294C8 13.5435 8.47294 13.0706 9.05882 13.0706C9.64471 13.0706 10.1176 13.5435 10.1176 14.1294C10.1176 14.7153 9.64471 15.1882 9.05882 15.1882ZM16.8235 15.1882C16.2376 15.1882 15.7647 14.7153 15.7647 14.1294C15.7647 13.5435 16.2376 13.0706 16.8235 13.0706C17.4094 13.0706 17.8824 13.5435 17.8824 14.1294C17.8824 14.7153 17.4094 15.1882 16.8235 15.1882Z" fill="#6B4FBB"/>
+</svg>
diff --git a/app/assets/images/learn_gitlab/git_write.svg b/app/assets/images/learn_gitlab/git_write.svg
new file mode 100644
index 00000000000..ad87b3f3b12
--- /dev/null
+++ b/app/assets/images/learn_gitlab/git_write.svg
@@ -0,0 +1,16 @@
+<svg width="40" height="39" viewBox="0 0 40 39" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0)">
+<path d="M32.2886 3.99573H37.8624C38.1834 3.99573 38.4435 4.25587 38.4435 4.57679V33.9598C38.4435 34.2808 38.1834 34.5409 37.8624 34.5409H32.2886V3.99573Z" fill="#F0F0F0" stroke="#DBDBDB" stroke-width="2"/>
+<path d="M10.757 9.4011L10.7363 4.92686C10.7337 4.35386 11.1491 3.86447 11.7148 3.77395L30.908 0.703095C31.614 0.590124 32.2537 1.13556 32.2537 1.85062V37.0106C32.2537 37.723 31.6184 38.2678 30.9143 38.1591L11.8555 35.2171C11.2908 35.13 10.8733 34.6453 10.8707 34.074L10.8502 29.6368" stroke="#DBDBDB" stroke-width="2"/>
+<path d="M11.2195 29.7561C16.877 29.7561 21.4634 25.1698 21.4634 19.5122C21.4634 13.8547 16.877 9.26831 11.2195 9.26831C5.56194 9.26831 0.975586 13.8547 0.975586 19.5122C0.975586 25.1698 5.56194 29.7561 11.2195 29.7561Z" stroke="#6E49CB"/>
+<path d="M11.2194 27.8048C15.7994 27.8048 19.5121 24.0921 19.5121 19.5122C19.5121 14.9322 15.7994 11.2195 11.2194 11.2195C6.63952 11.2195 2.92676 14.9322 2.92676 19.5122C2.92676 24.0921 6.63952 27.8048 11.2194 27.8048Z" fill="#6E49CB"/>
+<path d="M11.2194 27.8048C15.7994 27.8048 19.5121 24.0921 19.5121 19.5122C19.5121 14.9322 15.7994 11.2195 11.2194 11.2195C6.63952 11.2195 2.92676 14.9322 2.92676 19.5122C2.92676 24.0921 6.63952 27.8048 11.2194 27.8048Z" fill="white" fill-opacity="0.9"/>
+<path d="M10.8843 23.4146V16.276" stroke="#6E49CB" stroke-linecap="round"/>
+<path d="M7.31689 19.6609H14.634" stroke="#6E49CB" stroke-linecap="round"/>
+</g>
+<defs>
+<clipPath id="clip0">
+<rect width="40" height="39.0244" fill="white"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/app/assets/images/learn_gitlab/merge_request_created.svg b/app/assets/images/learn_gitlab/merge_request_created.svg
new file mode 100644
index 00000000000..b8137a60f06
--- /dev/null
+++ b/app/assets/images/learn_gitlab/merge_request_created.svg
@@ -0,0 +1,107 @@
+<svg width="79" height="47" viewBox="0 0 79 47" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0)">
+<path d="M27.0655 1.96289H5.80354C4.48549 1.96289 3.41699 3.03139 3.41699 4.34944C3.41699 5.6675 4.48549 6.73599 5.80354 6.73599H27.0655C28.3836 6.73599 29.4521 5.6675 29.4521 4.34944C29.4521 3.03139 28.3836 1.96289 27.0655 1.96289Z" fill="#F9F9F9"/>
+<path d="M23.1603 11.5092H-0.705247C-2.0233 11.5092 -3.0918 12.5776 -3.0918 13.8957C-3.0918 15.2138 -2.0233 16.2823 -0.705247 16.2823H23.1603C24.4783 16.2823 25.5468 15.2138 25.5468 13.8957C25.5468 12.5776 24.4783 11.5092 23.1603 11.5092Z" fill="#F9F9F9"/>
+<path d="M80.8713 16.2822H44.4222C43.1041 16.2822 42.0356 17.3507 42.0356 18.6688C42.0356 19.9868 43.1041 21.0553 44.4222 21.0553H80.8713C82.1894 21.0553 83.2579 19.9868 83.2579 18.6688C83.2579 17.3507 82.1894 16.2822 80.8713 16.2822Z" fill="#F9F9F9"/>
+<path d="M56.789 44.7039H27.2825C25.9645 44.7039 24.896 45.7724 24.896 47.0904C24.896 48.4085 25.9645 49.477 27.2825 49.477H56.789C58.107 49.477 59.1755 48.4085 59.1755 47.0904C59.1755 45.7724 58.107 44.7039 56.789 44.7039Z" fill="#F9F9F9"/>
+<path d="M43.1205 35.3746H13.6141C12.296 35.3746 11.2275 36.4431 11.2275 37.7612C11.2275 39.0792 12.296 40.1477 13.6141 40.1477H43.1205C44.4386 40.1477 45.5071 39.0792 45.5071 37.7612C45.5071 36.4431 44.4386 35.3746 43.1205 35.3746Z" fill="#F9F9F9"/>
+<path d="M77.1829 25.8284H6.02034C4.70228 25.8284 3.63379 26.8969 3.63379 28.2149C3.63379 29.533 4.70228 30.6015 6.02034 30.6015H77.1829C78.501 30.6015 79.5695 29.533 79.5695 28.2149C79.5695 26.8969 78.501 25.8284 77.1829 25.8284Z" fill="#F9F9F9"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M30.103 6.73596H1.46436H6.12898C7.43073 6.73596 8.51553 7.82076 8.51553 9.12251C8.51553 10.4243 7.43073 11.5091 6.12898 11.5091H1.46436H30.103H22.1839C20.8822 11.5091 19.7974 10.4243 19.7974 9.12251C19.7974 7.82076 20.8822 6.73596 22.1839 6.73596H30.103ZM84.7766 21.0553H59.3924H67.3114C68.6132 21.0553 69.698 22.1401 69.698 23.4418C69.698 24.7436 68.6132 25.8284 67.3114 25.8284H59.3924H84.7766H76.8576C75.5559 25.8284 74.4711 24.7436 74.4711 23.4418C74.4711 22.1401 75.5559 21.0553 76.8576 21.0553H84.7766ZM31.8386 30.6015H6.45441H14.3734C15.6752 30.6015 16.76 31.6862 16.76 32.988C16.76 34.2898 15.6752 35.3746 14.3734 35.3746H6.45441H31.8386H23.9196C22.6179 35.3746 21.5331 34.2898 21.5331 32.988C21.5331 31.6862 22.6179 30.6015 23.9196 30.6015H31.8386ZM48.1106 40.1477H22.7263H27.391C28.6927 40.1477 29.7775 41.2324 29.7775 42.5342C29.7775 43.836 28.6927 44.9207 27.391 44.9207H22.7263H48.1106H36.9372C35.6354 44.9207 34.5506 43.836 34.5506 42.5342C34.5506 41.2324 35.6354 40.1477 36.9372 40.1477H48.1106Z" fill="#F9F9F9"/>
+<path d="M68.0708 4.78333H12.0954C10.8971 4.78333 9.92578 5.75468 9.92578 6.95292V41.4494C9.92578 42.6476 10.8971 43.619 12.0954 43.619H68.0708C69.269 43.619 70.2404 42.6476 70.2404 41.4494V6.95292C70.2404 5.75468 69.269 4.78333 68.0708 4.78333Z" fill="white" stroke="#EEEEEE" stroke-width="2"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M10.7935 11.0751H69.5894V11.9429H10.7935V11.0751Z" fill="#EEEEEE"/>
+<path d="M18.387 18.0178H17.5192C17.3994 18.0178 17.3022 18.115 17.3022 18.2348C17.3022 18.3546 17.3994 18.4517 17.5192 18.4517H18.387C18.5069 18.4517 18.604 18.3546 18.604 18.2348C18.604 18.115 18.5069 18.0178 18.387 18.0178Z" fill="#B5A7DD"/>
+<path d="M23.3771 17.8009H20.9906C20.7509 17.8009 20.5566 17.9952 20.5566 18.2348C20.5566 18.4745 20.7509 18.6687 20.9906 18.6687H23.3771C23.6167 18.6687 23.811 18.4745 23.811 18.2348C23.811 17.9952 23.6167 17.8009 23.3771 17.8009Z" fill="#EEEEEE"/>
+<path d="M35.7438 17.8009H33.3573C33.1176 17.8009 32.9233 17.9952 32.9233 18.2348C32.9233 18.4745 33.1176 18.6687 33.3573 18.6687H35.7438C35.9835 18.6687 36.1777 18.4745 36.1777 18.2348C36.1777 17.9952 35.9835 17.8009 35.7438 17.8009Z" fill="#EEEEEE"/>
+<path d="M28.5841 22.574H26.1976C25.9579 22.574 25.7637 22.7682 25.7637 23.0079C25.7637 23.2475 25.9579 23.4418 26.1976 23.4418H28.5841C28.8238 23.4418 29.0181 23.2475 29.0181 23.0079C29.0181 22.7682 28.8238 22.574 28.5841 22.574Z" fill="#EEEEEE"/>
+<path d="M31.6217 20.1875H29.2352C28.9955 20.1875 28.8013 20.3818 28.8013 20.6214C28.8013 20.8611 28.9955 21.0553 29.2352 21.0553H31.6217C31.8614 21.0553 32.0557 20.8611 32.0557 20.6214C32.0557 20.3818 31.8614 20.1875 31.6217 20.1875Z" fill="#FC6D26"/>
+<path opacity="0.5" d="M31.6216 17.8009H28.1502C27.9106 17.8009 27.7163 17.9952 27.7163 18.2348C27.7163 18.4745 27.9106 18.6687 28.1502 18.6687H31.6216C31.8612 18.6687 32.0555 18.4745 32.0555 18.2348C32.0555 17.9952 31.8612 17.8009 31.6216 17.8009Z" fill="#FC6D26"/>
+<path d="M24.4619 22.574H20.9906C20.7509 22.574 20.5566 22.7682 20.5566 23.0079C20.5566 23.2475 20.7509 23.4418 20.9906 23.4418H24.4619C24.7016 23.4418 24.8958 23.2475 24.8958 23.0079C24.8958 22.7682 24.7016 22.574 24.4619 22.574Z" fill="#EEEEEE"/>
+<path d="M27.4995 20.1875H24.0282C23.7885 20.1875 23.5942 20.3818 23.5942 20.6214C23.5942 20.8611 23.7885 21.0553 24.0282 21.0553H27.4995C27.7392 21.0553 27.9334 20.8611 27.9334 20.6214C27.9334 20.3818 27.7392 20.1875 27.4995 20.1875Z" fill="#EEEEEE"/>
+<path d="M26.4144 17.8009H25.1126C24.873 17.8009 24.6787 17.9952 24.6787 18.2348C24.6787 18.4745 24.873 18.6687 25.1126 18.6687H26.4144C26.654 18.6687 26.8483 18.4745 26.8483 18.2348C26.8483 17.9952 26.654 17.8009 26.4144 17.8009Z" fill="#FC6D26"/>
+<path d="M22.2923 20.1875H20.9906C20.7509 20.1875 20.5566 20.3818 20.5566 20.6214C20.5566 20.8611 20.7509 21.0553 20.9906 21.0553H22.2923C22.532 21.0553 22.7262 20.8611 22.7262 20.6214C22.7262 20.3818 22.532 20.1875 22.2923 20.1875Z" fill="#EEEEEE"/>
+<path d="M18.387 20.4044H17.5192C17.3994 20.4044 17.3022 20.5016 17.3022 20.6214C17.3022 20.7412 17.3994 20.8383 17.5192 20.8383H18.387C18.5069 20.8383 18.604 20.7412 18.604 20.6214C18.604 20.5016 18.5069 20.4044 18.387 20.4044Z" fill="#B5A7DD"/>
+<path d="M18.387 22.791H17.5192C17.3994 22.791 17.3022 22.8882 17.3022 23.008C17.3022 23.1278 17.3994 23.2249 17.5192 23.2249H18.387C18.5069 23.2249 18.604 23.1278 18.604 23.008C18.604 22.8882 18.5069 22.791 18.387 22.791Z" fill="#B5A7DD"/>
+<path d="M18.387 25.1774H17.5192C17.3994 25.1774 17.3022 25.2745 17.3022 25.3943C17.3022 25.5142 17.3994 25.6113 17.5192 25.6113H18.387C18.5069 25.6113 18.604 25.5142 18.604 25.3943C18.604 25.2745 18.5069 25.1774 18.387 25.1774Z" fill="#B5A7DD"/>
+<path d="M23.3771 24.9604H20.9906C20.7509 24.9604 20.5566 25.1547 20.5566 25.3944C20.5566 25.634 20.7509 25.8283 20.9906 25.8283H23.3771C23.6167 25.8283 23.811 25.634 23.811 25.3944C23.811 25.1547 23.6167 24.9604 23.3771 24.9604Z" fill="#FC6D26"/>
+<path d="M35.7438 24.9604H33.3573C33.1176 24.9604 32.9233 25.1547 32.9233 25.3944C32.9233 25.634 33.1176 25.8283 33.3573 25.8283H35.7438C35.9835 25.8283 36.1777 25.634 36.1777 25.3944C36.1777 25.1547 35.9835 24.9604 35.7438 24.9604Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M28.5841 29.7335H26.1976C25.9579 29.7335 25.7637 29.9278 25.7637 30.1674C25.7637 30.4071 25.9579 30.6014 26.1976 30.6014H28.5841C28.8238 30.6014 29.0181 30.4071 29.0181 30.1674C29.0181 29.9278 28.8238 29.7335 28.5841 29.7335Z" fill="#FC6D26"/>
+<path d="M31.6217 27.347H29.2352C28.9955 27.347 28.8013 27.5413 28.8013 27.781C28.8013 28.0206 28.9955 28.2149 29.2352 28.2149H31.6217C31.8614 28.2149 32.0557 28.0206 32.0557 27.781C32.0557 27.5413 31.8614 27.347 31.6217 27.347Z" fill="#EEEEEE"/>
+<path d="M31.6216 24.9604H28.1502C27.9106 24.9604 27.7163 25.1547 27.7163 25.3944C27.7163 25.634 27.9106 25.8283 28.1502 25.8283H31.6216C31.8612 25.8283 32.0555 25.634 32.0555 25.3944C32.0555 25.1547 31.8612 24.9604 31.6216 24.9604Z" fill="#FC6D26"/>
+<path d="M24.4619 29.7335H20.9906C20.7509 29.7335 20.5566 29.9278 20.5566 30.1674C20.5566 30.4071 20.7509 30.6014 20.9906 30.6014H24.4619C24.7016 30.6014 24.8958 30.4071 24.8958 30.1674C24.8958 29.9278 24.7016 29.7335 24.4619 29.7335Z" fill="#FC6D26"/>
+<path d="M27.4995 27.347H24.0282C23.7885 27.347 23.5942 27.5413 23.5942 27.781C23.5942 28.0206 23.7885 28.2149 24.0282 28.2149H27.4995C27.7392 28.2149 27.9334 28.0206 27.9334 27.781C27.9334 27.5413 27.7392 27.347 27.4995 27.347Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M26.4144 24.9604H25.1126C24.873 24.9604 24.6787 25.1547 24.6787 25.3944C24.6787 25.634 24.873 25.8283 25.1126 25.8283H26.4144C26.654 25.8283 26.8483 25.634 26.8483 25.3944C26.8483 25.1547 26.654 24.9604 26.4144 24.9604Z" fill="#FC6D26"/>
+<path d="M22.2923 27.347H20.9906C20.7509 27.347 20.5566 27.5413 20.5566 27.781C20.5566 28.0206 20.7509 28.2149 20.9906 28.2149H22.2923C22.532 28.2149 22.7262 28.0206 22.7262 27.781C22.7262 27.5413 22.532 27.347 22.2923 27.347Z" fill="#EEEEEE"/>
+<path d="M18.387 27.564H17.5192C17.3994 27.564 17.3022 27.6611 17.3022 27.7809C17.3022 27.9007 17.3994 27.9979 17.5192 27.9979H18.387C18.5069 27.9979 18.604 27.9007 18.604 27.7809C18.604 27.6611 18.5069 27.564 18.387 27.564Z" fill="#B5A7DD"/>
+<path d="M18.387 29.9506H17.5192C17.3994 29.9506 17.3022 30.0477 17.3022 30.1675C17.3022 30.2873 17.3994 30.3845 17.5192 30.3845H18.387C18.5069 30.3845 18.604 30.2873 18.604 30.1675C18.604 30.0477 18.5069 29.9506 18.387 29.9506Z" fill="#B5A7DD"/>
+<path d="M18.387 32.337H17.5192C17.3994 32.337 17.3022 32.4342 17.3022 32.554C17.3022 32.6738 17.3994 32.771 17.5192 32.771H18.387C18.5069 32.771 18.604 32.6738 18.604 32.554C18.604 32.4342 18.5069 32.337 18.387 32.337Z" fill="#B5A7DD"/>
+<path d="M23.3771 32.1201H20.9906C20.7509 32.1201 20.5566 32.3144 20.5566 32.554C20.5566 32.7937 20.7509 32.988 20.9906 32.988H23.3771C23.6167 32.988 23.811 32.7937 23.811 32.554C23.811 32.3144 23.6167 32.1201 23.3771 32.1201Z" fill="#EEEEEE"/>
+<path d="M35.7438 32.1201H33.3573C33.1176 32.1201 32.9233 32.3144 32.9233 32.554C32.9233 32.7937 33.1176 32.988 33.3573 32.988H35.7438C35.9835 32.988 36.1777 32.7937 36.1777 32.554C36.1777 32.3144 35.9835 32.1201 35.7438 32.1201Z" fill="#EEEEEE"/>
+<path d="M28.5841 36.8932H26.1976C25.9579 36.8932 25.7637 37.0875 25.7637 37.3271C25.7637 37.5668 25.9579 37.761 26.1976 37.761H28.5841C28.8238 37.761 29.0181 37.5668 29.0181 37.3271C29.0181 37.0875 28.8238 36.8932 28.5841 36.8932Z" fill="#EEEEEE"/>
+<path d="M31.6217 34.5067H29.2352C28.9955 34.5067 28.8013 34.701 28.8013 34.9406C28.8013 35.1803 28.9955 35.3745 29.2352 35.3745H31.6217C31.8614 35.3745 32.0557 35.1803 32.0557 34.9406C32.0557 34.701 31.8614 34.5067 31.6217 34.5067Z" fill="#EEEEEE"/>
+<path d="M31.6216 32.1201H28.1502C27.9106 32.1201 27.7163 32.3144 27.7163 32.554C27.7163 32.7937 27.9106 32.988 28.1502 32.988H31.6216C31.8612 32.988 32.0555 32.7937 32.0555 32.554C32.0555 32.3144 31.8612 32.1201 31.6216 32.1201Z" fill="#FC6D26"/>
+<path d="M24.4619 36.8932H20.9906C20.7509 36.8932 20.5566 37.0875 20.5566 37.3271C20.5566 37.5668 20.7509 37.761 20.9906 37.761H24.4619C24.7016 37.761 24.8958 37.5668 24.8958 37.3271C24.8958 37.0875 24.7016 36.8932 24.4619 36.8932Z" fill="#EEEEEE"/>
+<path d="M27.4995 34.5067H24.0282C23.7885 34.5067 23.5942 34.701 23.5942 34.9406C23.5942 35.1803 23.7885 35.3745 24.0282 35.3745H27.4995C27.7392 35.3745 27.9334 35.1803 27.9334 34.9406C27.9334 34.701 27.7392 34.5067 27.4995 34.5067Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M26.4144 32.1201H25.1126C24.873 32.1201 24.6787 32.3144 24.6787 32.554C24.6787 32.7937 24.873 32.988 25.1126 32.988H26.4144C26.654 32.988 26.8483 32.7937 26.8483 32.554C26.8483 32.3144 26.654 32.1201 26.4144 32.1201Z" fill="#FC6D26"/>
+<path d="M22.2923 34.5067H20.9906C20.7509 34.5067 20.5566 34.701 20.5566 34.9406C20.5566 35.1803 20.7509 35.3745 20.9906 35.3745H22.2923C22.532 35.3745 22.7262 35.1803 22.7262 34.9406C22.7262 34.701 22.532 34.5067 22.2923 34.5067Z" fill="#EEEEEE"/>
+<path d="M18.387 34.7236H17.5192C17.3994 34.7236 17.3022 34.8208 17.3022 34.9406C17.3022 35.0604 17.3994 35.1575 17.5192 35.1575H18.387C18.5069 35.1575 18.604 35.0604 18.604 34.9406C18.604 34.8208 18.5069 34.7236 18.387 34.7236Z" fill="#B5A7DD"/>
+<path d="M18.387 37.1102H17.5192C17.3994 37.1102 17.3022 37.2074 17.3022 37.3272C17.3022 37.447 17.3994 37.5442 17.5192 37.5442H18.387C18.5069 37.5442 18.604 37.447 18.604 37.3272C18.604 37.2074 18.5069 37.1102 18.387 37.1102Z" fill="#B5A7DD"/>
+<path d="M45.073 17.8008H44.2052C44.0854 17.8008 43.9883 17.8979 43.9883 18.0177C43.9883 18.1376 44.0854 18.2347 44.2052 18.2347H45.073C45.1929 18.2347 45.29 18.1376 45.29 18.0177C45.29 17.8979 45.1929 17.8008 45.073 17.8008Z" fill="#FDE5D8"/>
+<path d="M50.0631 17.5839H47.6766C47.4369 17.5839 47.2427 17.7781 47.2427 18.0178C47.2427 18.2574 47.4369 18.4517 47.6766 18.4517H50.0631C50.3028 18.4517 50.4971 18.2574 50.4971 18.0178C50.4971 17.7781 50.3028 17.5839 50.0631 17.5839Z" fill="#EEEEEE"/>
+<path d="M62.4299 17.5839H60.0433C59.8036 17.5839 59.6094 17.7781 59.6094 18.0178C59.6094 18.2574 59.8036 18.4517 60.0433 18.4517H62.4299C62.6695 18.4517 62.8638 18.2574 62.8638 18.0178C62.8638 17.7781 62.6695 17.5839 62.4299 17.5839Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M55.2702 22.3569H52.8836C52.644 22.3569 52.4497 22.5512 52.4497 22.7909C52.4497 23.0305 52.644 23.2248 52.8836 23.2248H55.2702C55.5098 23.2248 55.7041 23.0305 55.7041 22.7909C55.7041 22.5512 55.5098 22.3569 55.2702 22.3569Z" fill="#6B4FBB"/>
+<path d="M58.3078 19.9705H55.9212C55.6816 19.9705 55.4873 20.1647 55.4873 20.4044C55.4873 20.644 55.6816 20.8383 55.9212 20.8383H58.3078C58.5474 20.8383 58.7417 20.644 58.7417 20.4044C58.7417 20.1647 58.5474 19.9705 58.3078 19.9705Z" fill="#6B4FBB"/>
+<path opacity="0.5" d="M58.3076 17.5839H54.8363C54.5966 17.5839 54.4023 17.7781 54.4023 18.0178C54.4023 18.2574 54.5966 18.4517 54.8363 18.4517H58.3076C58.5473 18.4517 58.7415 18.2574 58.7415 18.0178C58.7415 17.7781 58.5473 17.5839 58.3076 17.5839Z" fill="#6B4FBB"/>
+<path d="M51.1479 22.3569H47.6766C47.4369 22.3569 47.2427 22.5512 47.2427 22.7909C47.2427 23.0305 47.4369 23.2248 47.6766 23.2248H51.1479C51.3876 23.2248 51.5819 23.0305 51.5819 22.7909C51.5819 22.5512 51.3876 22.3569 51.1479 22.3569Z" fill="#6B4FBB"/>
+<path d="M54.1855 19.9705H50.7142C50.4745 19.9705 50.2803 20.1647 50.2803 20.4044C50.2803 20.644 50.4745 20.8383 50.7142 20.8383H54.1855C54.4252 20.8383 54.6195 20.644 54.6195 20.4044C54.6195 20.1647 54.4252 19.9705 54.1855 19.9705Z" fill="#EEEEEE"/>
+<path d="M53.1004 17.5839H51.7987C51.559 17.5839 51.3647 17.7781 51.3647 18.0178C51.3647 18.2574 51.559 18.4517 51.7987 18.4517H53.1004C53.3401 18.4517 53.5343 18.2574 53.5343 18.0178C53.5343 17.7781 53.3401 17.5839 53.1004 17.5839Z" fill="#6B4FBB"/>
+<path d="M48.9783 19.9705H47.6766C47.4369 19.9705 47.2427 20.1647 47.2427 20.4044C47.2427 20.644 47.4369 20.8383 47.6766 20.8383H48.9783C49.218 20.8383 49.4123 20.644 49.4123 20.4044C49.4123 20.1647 49.218 19.9705 48.9783 19.9705Z" fill="#EEEEEE"/>
+<path d="M45.073 20.1874H44.2052C44.0854 20.1874 43.9883 20.2845 43.9883 20.4043C43.9883 20.5242 44.0854 20.6213 44.2052 20.6213H45.073C45.1929 20.6213 45.29 20.5242 45.29 20.4043C45.29 20.2845 45.1929 20.1874 45.073 20.1874Z" fill="#FDE5D8"/>
+<path d="M45.073 22.574H44.2052C44.0854 22.574 43.9883 22.6711 43.9883 22.7909C43.9883 22.9108 44.0854 23.0079 44.2052 23.0079H45.073C45.1929 23.0079 45.29 22.9108 45.29 22.7909C45.29 22.6711 45.1929 22.574 45.073 22.574Z" fill="#FDE5D8"/>
+<path d="M45.073 24.9604H44.2052C44.0854 24.9604 43.9883 25.0576 43.9883 25.1774C43.9883 25.2972 44.0854 25.3944 44.2052 25.3944H45.073C45.1929 25.3944 45.29 25.2972 45.29 25.1774C45.29 25.0576 45.1929 24.9604 45.073 24.9604Z" fill="#FDE5D8"/>
+<path d="M50.0631 24.7435H47.6766C47.4369 24.7435 47.2427 24.9378 47.2427 25.1774C47.2427 25.4171 47.4369 25.6114 47.6766 25.6114H50.0631C50.3028 25.6114 50.4971 25.4171 50.4971 25.1774C50.4971 24.9378 50.3028 24.7435 50.0631 24.7435Z" fill="#EEEEEE"/>
+<path d="M59.3922 22.3569H57.0057C56.766 22.3569 56.5718 22.5512 56.5718 22.7909C56.5718 23.0305 56.766 23.2248 57.0057 23.2248H59.3922C59.6319 23.2248 59.8262 23.0305 59.8262 22.7909C59.8262 22.5512 59.6319 22.3569 59.3922 22.3569Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M55.2702 29.5166H52.8836C52.644 29.5166 52.4497 29.7109 52.4497 29.9505C52.4497 30.1902 52.644 30.3844 52.8836 30.3844H55.2702C55.5098 30.3844 55.7041 30.1902 55.7041 29.9505C55.7041 29.7109 55.5098 29.5166 55.2702 29.5166Z" fill="#6B4FBB"/>
+<path d="M53.1007 27.1301H50.7142C50.4745 27.1301 50.2803 27.3244 50.2803 27.564C50.2803 27.8037 50.4745 27.998 50.7142 27.998H53.1007C53.3404 27.998 53.5347 27.8037 53.5347 27.564C53.5347 27.3244 53.3404 27.1301 53.1007 27.1301Z" fill="#6B4FBB"/>
+<path d="M58.3076 24.7435H54.8363C54.5966 24.7435 54.4023 24.9378 54.4023 25.1774C54.4023 25.4171 54.5966 25.6114 54.8363 25.6114H58.3076C58.5473 25.6114 58.7415 25.4171 58.7415 25.1774C58.7415 24.9378 58.5473 24.7435 58.3076 24.7435Z" fill="#6B4FBB"/>
+<path d="M51.1479 29.5166H47.6766C47.4369 29.5166 47.2427 29.7109 47.2427 29.9505C47.2427 30.1902 47.4369 30.3844 47.6766 30.3844H51.1479C51.3876 30.3844 51.5819 30.1902 51.5819 29.9505C51.5819 29.7109 51.3876 29.5166 51.1479 29.5166Z" fill="#EEEEEE"/>
+<path d="M53.1004 24.7435H51.7987C51.559 24.7435 51.3647 24.9378 51.3647 25.1774C51.3647 25.4171 51.559 25.6114 51.7987 25.6114H53.1004C53.3401 25.6114 53.5343 25.4171 53.5343 25.1774C53.5343 24.9378 53.3401 24.7435 53.1004 24.7435Z" fill="#EEEEEE"/>
+<path d="M48.9783 27.1301H47.6766C47.4369 27.1301 47.2427 27.3244 47.2427 27.564C47.2427 27.8037 47.4369 27.998 47.6766 27.998H48.9783C49.218 27.998 49.4123 27.8037 49.4123 27.564C49.4123 27.3244 49.218 27.1301 48.9783 27.1301Z" fill="#EEEEEE"/>
+<path d="M56.138 27.1301H54.8363C54.5966 27.1301 54.4023 27.3244 54.4023 27.564C54.4023 27.8037 54.5966 27.998 54.8363 27.998H56.138C56.3777 27.998 56.5719 27.8037 56.5719 27.564C56.5719 27.3244 56.3777 27.1301 56.138 27.1301Z" fill="#EEEEEE"/>
+<path d="M59.1756 27.1301H57.8739C57.6342 27.1301 57.4399 27.3244 57.4399 27.564C57.4399 27.8037 57.6342 27.998 57.8739 27.998H59.1756C59.4153 27.998 59.6095 27.8037 59.6095 27.564C59.6095 27.3244 59.4153 27.1301 59.1756 27.1301Z" fill="#EEEEEE"/>
+<path d="M62.43 22.3569H61.1283C60.8886 22.3569 60.6943 22.5512 60.6943 22.7909C60.6943 23.0305 60.8886 23.2248 61.1283 23.2248H62.43C62.6697 23.2248 62.8639 23.0305 62.8639 22.7909C62.8639 22.5512 62.6697 22.3569 62.43 22.3569Z" fill="#EEEEEE"/>
+<path d="M45.073 27.347H44.2052C44.0854 27.347 43.9883 27.4442 43.9883 27.564C43.9883 27.6838 44.0854 27.781 44.2052 27.781H45.073C45.1929 27.781 45.29 27.6838 45.29 27.564C45.29 27.4442 45.1929 27.347 45.073 27.347Z" fill="#FDE5D8"/>
+<path d="M45.073 29.7335H44.2052C44.0854 29.7335 43.9883 29.8307 43.9883 29.9505C43.9883 30.0703 44.0854 30.1674 44.2052 30.1674H45.073C45.1929 30.1674 45.29 30.0703 45.29 29.9505C45.29 29.8307 45.1929 29.7335 45.073 29.7335Z" fill="#FDE5D8"/>
+<path d="M45.073 32.1201H44.2052C44.0854 32.1201 43.9883 32.2173 43.9883 32.3371C43.9883 32.4569 44.0854 32.554 44.2052 32.554H45.073C45.1929 32.554 45.29 32.4569 45.29 32.3371C45.29 32.2173 45.1929 32.1201 45.073 32.1201Z" fill="#FDE5D8"/>
+<path d="M50.0631 31.9032H47.6766C47.4369 31.9032 47.2427 32.0975 47.2427 32.3371C47.2427 32.5768 47.4369 32.771 47.6766 32.771H50.0631C50.3028 32.771 50.4971 32.5768 50.4971 32.3371C50.4971 32.0975 50.3028 31.9032 50.0631 31.9032Z" fill="#6B4FBB"/>
+<path d="M55.2702 36.6763H52.8836C52.644 36.6763 52.4497 36.8705 52.4497 37.1102C52.4497 37.3498 52.644 37.5441 52.8836 37.5441H55.2702C55.5098 37.5441 55.7041 37.3498 55.7041 37.1102C55.7041 36.8705 55.5098 36.6763 55.2702 36.6763Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M58.3078 34.2897H55.9212C55.6816 34.2897 55.4873 34.4839 55.4873 34.7236C55.4873 34.9632 55.6816 35.1575 55.9212 35.1575H58.3078C58.5474 35.1575 58.7417 34.9632 58.7417 34.7236C58.7417 34.4839 58.5474 34.2897 58.3078 34.2897Z" fill="#6B4FBB"/>
+<path d="M51.1479 36.6763H47.6766C47.4369 36.6763 47.2427 36.8705 47.2427 37.1102C47.2427 37.3498 47.4369 37.5441 47.6766 37.5441H51.1479C51.3876 37.5441 51.5819 37.3498 51.5819 37.1102C51.5819 36.8705 51.3876 36.6763 51.1479 36.6763Z" fill="#EEEEEE"/>
+<path d="M54.1855 34.2897H50.7142C50.4745 34.2897 50.2803 34.4839 50.2803 34.7236C50.2803 34.9632 50.4745 35.1575 50.7142 35.1575H54.1855C54.4252 35.1575 54.6195 34.9632 54.6195 34.7236C54.6195 34.4839 54.4252 34.2897 54.1855 34.2897Z" fill="#6B4FBB"/>
+<path d="M53.1004 31.9032H51.7987C51.559 31.9032 51.3647 32.0975 51.3647 32.3371C51.3647 32.5768 51.559 32.771 51.7987 32.771H53.1004C53.3401 32.771 53.5343 32.5768 53.5343 32.3371C53.5343 32.0975 53.3401 31.9032 53.1004 31.9032Z" fill="#EEEEEE"/>
+<path d="M61.3451 34.2897H60.0433C59.8036 34.2897 59.6094 34.4839 59.6094 34.7236C59.6094 34.9632 59.8036 35.1575 60.0433 35.1575H61.3451C61.5847 35.1575 61.779 34.9632 61.779 34.7236C61.779 34.4839 61.5847 34.2897 61.3451 34.2897Z" fill="#EEEEEE"/>
+<path d="M48.9783 34.2897H47.6766C47.4369 34.2897 47.2427 34.4839 47.2427 34.7236C47.2427 34.9632 47.4369 35.1575 47.6766 35.1575H48.9783C49.218 35.1575 49.4123 34.9632 49.4123 34.7236C49.4123 34.4839 49.218 34.2897 48.9783 34.2897Z" fill="#EEEEEE"/>
+<path d="M45.073 34.5067H44.2052C44.0854 34.5067 43.9883 34.6039 43.9883 34.7237C43.9883 34.8435 44.0854 34.9406 44.2052 34.9406H45.073C45.1929 34.9406 45.29 34.8435 45.29 34.7237C45.29 34.6039 45.1929 34.5067 45.073 34.5067Z" fill="#FDE5D8"/>
+<path d="M45.073 36.8932H44.2052C44.0854 36.8932 43.9883 36.9903 43.9883 37.1101C43.9883 37.23 44.0854 37.3271 44.2052 37.3271H45.073C45.1929 37.3271 45.29 37.23 45.29 37.1101C45.29 36.9903 45.1929 36.8932 45.073 36.8932Z" fill="#FDE5D8"/>
+<path d="M66.0312 11.2921H75.4472C76.6405 11.2921 77.6168 10.3158 77.6168 9.12254V2.83072C77.6168 1.63745 76.6405 0.661133 75.4472 0.661133H65.2502C64.0569 0.661133 63.0806 1.63745 63.0806 2.83072V11.943C63.0806 13.1363 63.7748 13.4617 64.6427 12.5939L66.0312 11.2921Z" fill="white" stroke="#FDE5D8" stroke-width="2"/>
+<path d="M72.0845 3.69861H66.4436C66.2639 3.69861 66.1182 3.84431 66.1182 4.02405C66.1182 4.20378 66.2639 4.34949 66.4436 4.34949H72.0845C72.2643 4.34949 72.41 4.20378 72.41 4.02405C72.41 3.84431 72.2643 3.69861 72.0845 3.69861Z" fill="#FDB692"/>
+<path d="M74.2541 5.65125H66.4436C66.2639 5.65125 66.1182 5.79695 66.1182 5.97668C66.1182 6.15642 66.2639 6.30212 66.4436 6.30212H74.2541C74.4339 6.30212 74.5796 6.15642 74.5796 5.97668C74.5796 5.79695 74.4339 5.65125 74.2541 5.65125Z" fill="#FDB692"/>
+<path d="M72.0845 7.60388H66.4436C66.2639 7.60388 66.1182 7.74959 66.1182 7.92932C66.1182 8.10906 66.2639 8.25476 66.4436 8.25476H72.0845C72.2643 8.25476 72.41 8.10906 72.41 7.92932C72.41 7.74959 72.2643 7.60388 72.0845 7.60388Z" fill="#FDB692"/>
+<path d="M64.1655 21.0553C65.9629 21.0553 67.4199 19.5982 67.4199 17.8009C67.4199 16.0035 65.9629 14.5465 64.1655 14.5465C62.3682 14.5465 60.9111 16.0035 60.9111 17.8009C60.9111 19.5982 62.3682 21.0553 64.1655 21.0553Z" fill="#FFF7F4" stroke="#FC6D26"/>
+<path d="M62.3867 15.1974C63.0376 16.1303 64.079 16.7161 65.2506 16.7161C65.9665 16.7161 66.6174 16.4991 67.2032 16.1303" stroke="#FC6D26" stroke-width="0.5"/>
+<path d="M62.9724 18.6687C63.1521 18.6687 63.2979 18.523 63.2979 18.3433C63.2979 18.1635 63.1521 18.0178 62.9724 18.0178C62.7927 18.0178 62.647 18.1635 62.647 18.3433C62.647 18.523 62.7927 18.6687 62.9724 18.6687Z" fill="#FC6D26"/>
+<path d="M65.3591 18.6687C65.5389 18.6687 65.6846 18.523 65.6846 18.3433C65.6846 18.1635 65.5389 18.0178 65.3591 18.0178C65.1794 18.0178 65.0337 18.1635 65.0337 18.3433C65.0337 18.523 65.1794 18.6687 65.3591 18.6687Z" fill="#FC6D26"/>
+<path d="M16.0005 39.7137C17.7978 39.7137 19.2549 38.2567 19.2549 36.4593C19.2549 34.662 17.7978 33.205 16.0005 33.205C14.2031 33.205 12.7461 34.662 12.7461 36.4593C12.7461 38.2567 14.2031 39.7137 16.0005 39.7137Z" fill="#F4F1FA" stroke="#6B4FBB"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M18.1701 34.5068H18.7559C18.1701 33.5955 17.1721 32.988 16.0005 32.988C14.8289 32.988 13.8309 33.5955 13.2451 34.5068H13.8309L14.3733 33.8559L14.9157 34.5068L15.4581 33.8559L16.0005 34.5068L16.5429 33.8559L17.0853 34.5068L17.6277 33.8559L18.1701 34.5068Z" fill="#6B4FBB"/>
+<path d="M14.8074 37.1102C14.9871 37.1102 15.1328 36.9645 15.1328 36.7848C15.1328 36.6051 14.9871 36.4594 14.8074 36.4594C14.6276 36.4594 14.4819 36.6051 14.4819 36.7848C14.4819 36.9645 14.6276 37.1102 14.8074 37.1102Z" fill="#6B4FBB"/>
+<path d="M17.1936 37.1102C17.3733 37.1102 17.519 36.9645 17.519 36.7848C17.519 36.6051 17.3733 36.4594 17.1936 36.4594C17.0139 36.4594 16.8682 36.6051 16.8682 36.7848C16.8682 36.9645 17.0139 37.1102 17.1936 37.1102Z" fill="#6B4FBB"/>
+<path d="M13.0498 29.7336H3.63382C2.44055 29.7336 1.46423 28.7573 1.46423 27.5641V21.2722C1.46423 20.079 2.44055 19.1027 3.63382 19.1027H13.8309C15.0242 19.1027 16.0005 20.079 16.0005 21.2722V30.3845C16.0005 31.5778 15.3062 31.9032 14.4384 31.0354L13.0498 29.7336Z" fill="white" stroke="#E2DCF2" stroke-width="2"/>
+<path opacity="0.5" d="M4.82708 22.1401H10.468C10.6478 22.1401 10.7935 22.2858 10.7935 22.4656C10.7935 22.6453 10.6478 22.791 10.468 22.791H4.82708C4.64735 22.791 4.50164 22.6453 4.50164 22.4656C4.50164 22.2858 4.64735 22.1401 4.82708 22.1401Z" fill="#6B4FBB"/>
+<path opacity="0.5" d="M4.82724 24.0928H8.29859C8.47832 24.0928 8.62402 24.2385 8.62402 24.4182C8.62402 24.5979 8.47832 24.7437 8.29859 24.7437H4.82724C4.64751 24.7437 4.50181 24.5979 4.50181 24.4182C4.50181 24.2385 4.64751 24.0928 4.82724 24.0928Z" fill="#6B4FBB"/>
+<path opacity="0.5" d="M4.82724 26.0454H8.29859C8.47832 26.0454 8.62402 26.1911 8.62402 26.3708C8.62402 26.5506 8.47832 26.6963 8.29859 26.6963H4.82724C4.64751 26.6963 4.50181 26.5506 4.50181 26.3708C4.50181 26.1911 4.64751 26.0454 4.82724 26.0454Z" fill="#6B4FBB"/>
+</g>
+<defs>
+<clipPath id="clip0">
+<rect width="83.5292" height="48.8158" fill="white" transform="translate(-2.44092 0.661133)"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/app/assets/images/learn_gitlab/pipeline_created.svg b/app/assets/images/learn_gitlab/pipeline_created.svg
new file mode 100644
index 00000000000..91c716be475
--- /dev/null
+++ b/app/assets/images/learn_gitlab/pipeline_created.svg
@@ -0,0 +1,38 @@
+<svg width="52" height="48" viewBox="0 0 52 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M32.1404 11.789L45.6109 14.1173C47.0988 14.3744 48.0923 15.7653 47.8299 17.2237L44.9797 33.0687C44.7173 34.5272 43.2985 35.5011 41.8106 35.2439L28.34 32.9157C26.8521 32.6585 25.8586 31.2677 26.121 29.8092L28.9712 13.9642C29.2336 12.5057 30.6525 11.5318 32.1404 11.789Z" fill="white"/>
+<path d="M31.9504 12.8453C31.0576 12.691 30.2063 13.2754 30.0489 14.1505L27.1986 29.9955C27.0412 30.8705 27.6373 31.705 28.5301 31.8593L42.0006 34.1876C42.8933 34.3419 43.7446 33.7576 43.9021 32.8825L46.7523 17.0375C46.9097 16.1624 46.3136 15.3279 45.4209 15.1736L31.9504 12.8453ZM32.1404 11.789L45.6109 14.1173C47.0988 14.3744 48.0923 15.7653 47.8299 17.2237L44.9797 33.0687C44.7173 34.5272 43.2985 35.5011 41.8106 35.2439L28.34 32.9157C26.8521 32.6585 25.8586 31.2677 26.121 29.8092L28.9712 13.9642C29.2336 12.5057 30.6525 11.5318 32.1404 11.789Z" fill="#E1D8F9"/>
+<path d="M39.2265 9.7425L25.6003 8.57392C24.0951 8.44485 22.7683 9.53622 22.6366 11.0116L21.206 27.0398C21.0743 28.5151 22.1877 29.8158 23.6928 29.9449L37.3191 31.1134C38.8242 31.2425 40.1511 30.1511 40.2828 28.6758L41.7133 12.6476C41.845 11.1722 40.7316 9.87157 39.2265 9.7425Z" fill="black" fill-opacity="0.03"/>
+<path d="M34.477 47.7322H41.0349C46.4986 47.5866 50.8833 43.2004 50.8833 37.8104C50.8833 32.3306 46.3517 27.8886 40.7614 27.8886C40.4897 27.8886 40.2205 27.8993 39.9544 27.9197C38.6002 24.003 34.8187 21.1847 30.3659 21.1847C28.9201 21.1826 27.4908 21.4853 26.1748 22.0723C24.3556 19.4336 21.2753 17.6986 17.7818 17.6986C12.1915 17.6986 7.65986 22.1406 7.65986 27.6204C7.65986 27.8081 7.66533 27.995 7.67572 28.1803C3.26748 29.253 0 33.1573 0 37.8104C0 43.2902 4.53163 47.7322 10.122 47.7322C10.2106 47.7322 10.2987 47.7311 10.3868 47.729L34.477 47.7322Z" fill="#F4F0FF"/>
+<path d="M40.1486 9.42187L26.547 8.27441C25.0446 8.14767 23.718 9.24259 23.5839 10.72L22.1275 26.7704C21.9934 28.2478 23.1027 29.5482 24.6051 29.6749L38.2068 30.8224C39.7092 30.9491 41.0358 29.8542 41.1698 28.3768L42.6262 12.3264C42.7603 10.849 41.651 9.54862 40.1486 9.42187Z" fill="white"/>
+<path d="M26.4499 9.34444C25.5485 9.26839 24.7525 9.92534 24.6721 10.8118L23.2156 26.8622C23.1352 27.7486 23.8007 28.5288 24.7022 28.6049L38.3038 29.7523C39.2053 29.8284 40.0013 29.1714 40.0817 28.285L41.5381 12.2346C41.6185 11.3482 40.953 10.5679 40.0516 10.4919L26.4499 9.34444ZM26.547 8.27441L40.1486 9.42187C41.651 9.54862 42.7603 10.849 42.6262 12.3264L41.1698 28.3768C41.0358 29.8542 39.7092 30.9491 38.2068 30.8224L24.6051 29.6749C23.1027 29.5482 21.9934 28.2478 22.1275 26.7704L23.5839 10.72C23.718 9.24259 25.0446 8.14767 26.547 8.27441Z" fill="#E1D8F9"/>
+<path d="M28.8145 13.3182L27.7253 13.2263C27.4245 13.2009 27.1589 13.4202 27.1321 13.7161C27.1052 14.012 27.3273 14.2725 27.6281 14.2979L28.7173 14.3898C29.018 14.4152 29.2836 14.1959 29.3105 13.9C29.3373 13.6041 29.1152 13.3436 28.8145 13.3182Z" fill="#FC6D26"/>
+<path d="M33.2417 19.0816L32.1525 18.9897C31.8517 18.9643 31.5862 19.1836 31.5593 19.4795C31.5325 19.7755 31.7545 20.0359 32.0553 20.0613L33.1445 20.1533C33.4453 20.1787 33.7109 19.9593 33.7377 19.6634C33.7645 19.3675 33.5425 19.107 33.2417 19.0816Z" fill="#E1DBF1"/>
+<path d="M36.5464 22.0571L35.4572 21.9652C35.1564 21.9398 34.8908 22.1591 34.864 22.455C34.8372 22.7509 35.0592 23.0114 35.36 23.0368L36.4492 23.1287C36.75 23.1541 37.0155 22.9348 37.0424 22.6389C37.0692 22.343 36.8472 22.0825 36.5464 22.0571Z" fill="#FEE1D3"/>
+<path d="M36.5127 19.3624L35.4235 19.2705C35.1227 19.2451 34.8571 19.4644 34.8303 19.7603C34.8035 20.0562 35.0255 20.3167 35.3263 20.3421L36.4155 20.434C36.7163 20.4594 36.9819 20.2401 37.0087 19.9442C37.0355 19.6483 36.8135 19.3878 36.5127 19.3624Z" fill="#FEF0E8"/>
+<path d="M33.1744 13.6912L30.9954 13.5071C30.6946 13.4817 30.4289 13.7012 30.4021 13.9975C30.3752 14.2938 30.5974 14.5545 30.8982 14.58L33.0772 14.764C33.378 14.7895 33.6437 14.5699 33.6705 14.2736C33.6974 13.9774 33.4752 13.7166 33.1744 13.6912Z" fill="#EFEDF8"/>
+<path d="M27.8257 21.3098L27.2813 21.2638C26.9806 21.2385 26.7151 21.4576 26.6882 21.7534C26.6614 22.0491 26.8834 22.3094 27.1841 22.3348L27.7286 22.3807C28.0293 22.4061 28.2948 22.1869 28.3216 21.8912C28.3484 21.5955 28.1264 21.3351 27.8257 21.3098Z" fill="#FEF0E8"/>
+<path d="M30.5513 21.5437L30.0069 21.4977C29.7062 21.4723 29.4407 21.6915 29.4138 21.9873C29.387 22.283 29.609 22.5433 29.9097 22.5687L30.4541 22.6146C30.7548 22.64 31.0203 22.4208 31.0472 22.1251C31.074 21.8293 30.852 21.569 30.5513 21.5437Z" fill="#FC6D26"/>
+<path d="M33.2764 21.7772L32.732 21.7312C32.4313 21.7059 32.1657 21.925 32.1389 22.2208C32.1121 22.5165 32.3341 22.7768 32.6348 22.8022L33.1792 22.8481C33.4799 22.8735 33.7455 22.6543 33.7723 22.3586C33.7991 22.0629 33.5771 21.8025 33.2764 21.7772Z" fill="#E1D8F9"/>
+<path d="M29.6651 16.0818L27.4861 15.8977C27.1853 15.8723 26.9196 16.0919 26.8928 16.3881C26.866 16.6844 27.0881 16.9452 27.3889 16.9706L29.5679 17.1547C29.8688 17.1801 30.1344 16.9605 30.1613 16.6643C30.1881 16.368 29.966 16.1072 29.6651 16.0818Z" fill="#E1D8F9"/>
+<path d="M37.126 24.7972L34.9471 24.6132C34.6462 24.5877 34.3806 24.8073 34.3537 25.1036C34.3269 25.3998 34.549 25.6606 34.8499 25.686L37.0288 25.8701C37.3297 25.8955 37.5953 25.676 37.6222 25.3797C37.649 25.0834 37.4269 24.8227 37.126 24.7972Z" fill="#6B4FBB"/>
+<path d="M37.8406 16.7796L31.8467 16.2719C31.5457 16.2464 31.28 16.4666 31.2531 16.7638C31.2263 17.0609 31.4485 17.3225 31.7494 17.348L37.7434 17.8558C38.0443 17.8812 38.3101 17.661 38.3369 17.3638C38.3638 17.0667 38.1416 16.8051 37.8406 16.7796Z" fill="#C3B8E3"/>
+<path d="M33.0379 24.4431L27.0439 23.9353C26.743 23.9098 26.4773 24.1301 26.4504 24.4272C26.4235 24.7244 26.6457 24.986 26.9467 25.0114L32.9407 25.5192C33.2416 25.5447 33.5073 25.3245 33.5342 25.0273C33.5611 24.7301 33.3389 24.4686 33.0379 24.4431Z" fill="#FEE1D3"/>
+<path d="M38.0793 14.1112L35.3553 13.881C35.0545 13.8556 34.7888 14.0753 34.7619 14.3717C34.7351 14.6681 34.9572 14.929 35.2581 14.9544L37.982 15.1847C38.2829 15.2101 38.5486 14.9904 38.5754 14.694C38.6023 14.3976 38.3801 14.1367 38.0793 14.1112Z" fill="#E1DBF1"/>
+<path d="M29.9723 18.7993L27.2484 18.5691C26.9475 18.5437 26.6818 18.7634 26.655 19.0598C26.6282 19.3562 26.8503 19.6171 27.1512 19.6426L29.8751 19.8728C30.176 19.8982 30.4417 19.6785 30.4685 19.3821C30.4953 19.0857 30.2732 18.8248 29.9723 18.7993Z" fill="#6B4FBB"/>
+<path d="M35.2983 48H41.8562C47.3199 47.8544 51.7046 43.4682 51.7046 38.0782C51.7046 32.5984 47.173 28.1564 41.5827 28.1564C41.311 28.1564 41.0418 28.1671 40.7757 28.1875C39.4215 24.2708 35.64 21.4525 31.1871 21.4525C29.7414 21.4504 28.3121 21.7532 26.9961 22.3401C25.1769 19.7014 22.0965 17.9664 18.6031 17.9664C13.0128 17.9664 8.48115 22.4085 8.48115 27.8882C8.48115 28.076 8.48662 28.2629 8.49701 28.4482C4.08877 29.5208 0.821289 33.4252 0.821289 38.0782C0.821289 43.558 5.35292 48 10.9432 48C11.0319 48 11.12 47.999 11.2081 47.9968L35.2983 48Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M35.2976 47.4637H41.8407C47.0193 47.3258 51.1567 43.1667 51.1567 38.0782C51.1567 32.8947 46.87 28.6927 41.5819 28.6927C41.3259 28.6927 41.0712 28.7026 40.8179 28.7222L40.3936 28.7549L40.2568 28.3596C38.9505 24.5818 35.3216 21.9887 31.1864 21.9887C29.8189 21.9868 28.4669 22.2731 27.2222 22.8283L26.8009 23.0163L26.5418 22.6406C24.7718 20.0727 21.8233 18.5027 18.6024 18.5027C13.3143 18.5027 9.02753 22.7047 9.02753 27.8882C9.02753 28.0654 9.03246 28.2422 9.04258 28.4183L9.06775 28.8616L8.62813 28.9686C4.39332 29.9986 1.36768 33.7413 1.36768 38.0782C1.36768 43.2616 5.65446 47.4637 10.9425 47.4637C11.0262 47.4637 11.1099 47.4626 11.2073 47.4604L35.2976 47.4637Z" fill="white"/>
+<path d="M41.5827 28.1564C47.1729 28.1564 51.7046 32.5985 51.7046 38.0782C51.7046 43.4542 47.3344 47.854 41.8415 48H35.2983L11.2203 47.9967C11.1169 47.999 11.0296 48 10.9432 48C5.35303 48 0.821289 43.5579 0.821289 38.0782C0.821289 33.5134 3.99981 29.542 8.49703 28.4481C8.48641 28.2631 8.48115 28.0765 8.48115 27.8882C8.48115 22.4086 13.0129 17.9664 18.6031 17.9664C21.9832 17.9664 25.1114 19.6064 26.9958 22.3403C28.3124 21.7532 29.742 21.4504 31.1871 21.4525C35.535 21.4525 39.3871 24.1716 40.7757 28.1874C41.0441 28.1668 41.3131 28.1564 41.5827 28.1564ZM41.8266 46.9276C46.7122 46.7976 50.6104 42.873 50.6104 38.0782C50.6104 33.1909 46.5685 29.229 41.5827 29.229C41.3418 29.229 41.1015 29.2383 40.8615 29.2569L40.4372 29.2896C40.1876 29.3088 39.9566 29.1596 39.8762 28.9272L39.7393 28.5318C38.501 24.9504 35.0651 22.5251 31.1863 22.5251C29.8973 22.5232 28.6228 22.7932 27.4496 23.3165L27.0283 23.5045C26.7852 23.6129 26.4979 23.5336 26.3483 23.3167L26.0892 22.9409C24.4075 20.5012 21.6186 19.0391 18.6031 19.0391C13.6172 19.0391 9.57541 23.001 9.57541 27.8882C9.57541 28.0567 9.58012 28.2233 9.58961 28.3886L9.61478 28.8319C9.62938 29.089 9.45538 29.3201 9.20034 29.3822L8.76063 29.4892C4.75075 30.4645 1.91555 34.007 1.91555 38.0782C1.91555 42.9655 5.95738 46.9274 10.9432 46.9274C11.0209 46.9274 11.1001 46.9264 11.2081 46.9242L35.2984 46.9274L41.8266 46.9276Z" fill="#E1D8F9"/>
+<path d="M19.2501 27.9907C19.8019 27.7404 20.3915 27.5821 20.9945 27.5223L21.1208 27.0649C21.3387 26.2755 22.1656 25.8045 22.9613 26.0114L23.7546 26.2177C24.1383 26.3176 24.4646 26.564 24.6618 26.9026C24.8589 27.2412 24.9107 27.6443 24.8057 28.0231L24.6794 28.4806C25.1755 28.8277 25.6031 29.2528 25.9488 29.7327L26.4143 29.6081C27.2149 29.3942 28.0346 29.8574 28.2469 30.6437L28.4563 31.4252C28.5574 31.8033 28.5015 32.2068 28.3009 32.5468C28.1003 32.8869 27.7714 33.1356 27.3867 33.2383L26.9221 33.3629C26.892 33.6548 26.8373 33.9481 26.7564 34.241C26.6755 34.5339 26.5718 34.8144 26.4475 35.0819L26.7857 35.4149C27.3682 35.9895 27.3647 36.9241 26.7779 37.5044L26.1939 38.0796C25.9113 38.3577 25.5291 38.5148 25.1313 38.5162C24.7336 38.5176 24.3529 38.3633 24.0732 38.0871L23.7343 37.7533C23.1824 38.0036 22.5929 38.1619 21.9899 38.2217L21.8636 38.6791C21.6457 39.4685 20.8188 39.9395 20.0231 39.7326L19.2298 39.5263C18.8461 39.4264 18.5198 39.18 18.3226 38.8414C18.1255 38.5028 18.0737 38.0997 18.1787 37.7209L18.3049 37.2634C17.8136 36.919 17.3846 36.4958 17.0356 36.0113L16.5701 36.1359C15.7695 36.3498 14.9498 35.8866 14.7374 35.1003L14.5281 34.3188C14.427 33.9407 14.4829 33.5372 14.6835 33.1972C14.8841 32.8571 15.2129 32.6084 15.5977 32.5057L16.0623 32.3812C16.0929 32.0847 16.1484 31.791 16.228 31.503C16.3089 31.2101 16.4126 30.9296 16.5369 30.6622L16.1986 30.3291C15.6162 29.7545 15.6197 28.8199 16.2065 28.2397L16.7905 27.6645C17.073 27.3863 17.4553 27.2292 17.853 27.2278C18.2508 27.2264 18.6314 27.3807 18.9112 27.6569L19.2501 27.9907Z" fill="white"/>
+<path d="M20.9944 27.5222L21.121 27.0643C21.3393 26.2769 22.1613 25.806 22.9613 26.0114L23.7546 26.2177C24.5536 26.4255 25.0238 27.2335 24.8057 28.0232L24.6796 28.4802C25.1712 28.8248 25.6002 29.2481 25.9491 29.7327L26.4148 29.6079C27.2147 29.3945 28.0336 29.8569 28.247 30.6437L28.4564 31.4256C28.5575 31.8038 28.5014 32.2073 28.3006 32.5474C28.0999 32.8874 27.7708 33.136 27.3868 33.2383L26.9221 33.3628C26.8918 33.6578 26.8364 33.9512 26.7564 34.241C26.6764 34.5306 26.5733 34.8113 26.4475 35.0819L26.7861 35.4156C27.3676 35.9897 27.3645 36.9229 26.7779 37.5044L26.1937 38.0798C25.911 38.358 25.5287 38.515 25.1308 38.5163C24.733 38.5175 24.3523 38.363 24.0733 38.0872L23.7342 37.7533C23.1825 38.0036 22.593 38.1619 21.99 38.2218L21.8635 38.6797C21.6451 39.4672 20.8231 39.938 20.0231 39.7326L19.2298 39.5263C18.4308 39.3185 17.9606 38.5106 18.1787 37.7209L18.3049 37.2638C17.8132 36.9192 17.3842 36.496 17.0353 36.0114L16.5696 36.1361C15.7697 36.3495 14.9509 35.8871 14.7374 35.1003L14.528 34.3184C14.4269 33.9403 14.483 33.5367 14.6838 33.1967C14.8846 32.8566 15.2136 32.608 15.5977 32.5057L16.0623 32.3813C16.0926 32.0862 16.148 31.7928 16.2279 31.5033C16.3073 31.2153 16.4106 30.9341 16.537 30.6621L16.1983 30.3284C15.6168 29.7543 15.62 28.8211 16.2065 28.2396L16.7907 27.6643C17.0734 27.3861 17.4558 27.229 17.8536 27.2278C18.2515 27.2265 18.6321 27.3811 18.9111 27.6568L19.2501 27.9906C19.8125 27.7376 20.3968 27.5816 20.9944 27.5222ZM19.3541 29.1231C19.1465 29.2172 18.9024 29.1749 18.742 29.0169L18.1356 28.4197C18.0595 28.3445 17.956 28.3025 17.8477 28.3028C17.7395 28.3032 17.6354 28.3459 17.5587 28.4214L16.976 28.9953C16.8161 29.1539 16.8152 29.4089 16.9737 29.5654L17.5783 30.1611C17.7385 30.3189 17.7816 30.5589 17.6864 30.7633L17.5285 31.1027C17.4273 31.3204 17.3445 31.5458 17.2808 31.7768C17.2166 32.0096 17.1723 32.2446 17.148 32.4805L17.1099 32.8514C17.087 33.0746 16.926 33.2615 16.706 33.3205L15.8747 33.5432C15.77 33.5711 15.6805 33.6387 15.6258 33.7312C15.5712 33.8237 15.556 33.9335 15.5834 34.0361L15.7923 34.816C15.8505 35.0306 16.0743 35.1569 16.2923 35.0987L17.124 34.876C17.3443 34.817 17.5775 34.8987 17.7088 35.0811L17.927 35.3841C18.2062 35.7718 18.5493 36.1104 18.942 36.3857L19.2494 36.6007C19.4343 36.7301 19.5173 36.9598 19.4573 37.1768L19.2315 37.9947C19.1719 38.2106 19.2997 38.4302 19.5168 38.4867L20.3085 38.6926C20.5267 38.7486 20.7513 38.6199 20.8108 38.4054L21.0366 37.5874C21.0965 37.3706 21.2864 37.212 21.5131 37.1895L21.89 37.1522C22.3723 37.1043 22.8438 36.9776 23.2856 36.7771L23.6306 36.6208C23.8382 36.5268 24.082 36.5692 24.2424 36.7271L24.8488 37.3243C24.9249 37.3995 25.0285 37.4416 25.1367 37.4412C25.2449 37.4409 25.349 37.3981 25.4257 37.3226L26.0084 36.7487C26.1683 36.5901 26.1692 36.3351 26.0107 36.1786L25.4061 35.5829C25.2459 35.4251 25.2028 35.1851 25.298 34.9807L25.456 34.6412C25.5567 34.4246 25.6393 34.1998 25.7036 33.9672C25.7678 33.7345 25.8122 33.4995 25.8364 33.2636L25.8745 32.8927C25.8974 32.6695 26.0584 32.4825 26.2784 32.4236L27.1097 32.2009C27.2144 32.173 27.3039 32.1053 27.3586 32.0128C27.4132 31.9203 27.4284 31.8105 27.401 31.708L27.1921 30.928C27.1339 30.7135 26.9101 30.5871 26.6921 30.6453L25.8604 30.868C25.6401 30.9271 25.4069 30.8453 25.2756 30.663L25.0574 30.3599C24.7783 29.9722 24.4351 29.6336 24.0427 29.3586L23.7353 29.1438C23.5501 29.0145 23.4671 28.7847 23.527 28.5675L23.7529 27.7494C23.8125 27.5334 23.6847 27.3138 23.4676 27.2573L22.6759 27.0514C22.4578 26.9954 22.2332 27.1241 22.1736 27.3387L21.9478 28.1566C21.8879 28.3735 21.698 28.5321 21.4713 28.5545L21.0944 28.5918C20.6121 28.6397 20.1406 28.7665 19.6992 28.9668C19.696 28.9682 19.5809 29.0203 19.3541 29.1231Z" fill="#7B58CF"/>
+<path d="M19.4256 36.6322C20.4661 37.2185 21.8046 36.8571 22.4152 35.825C23.0258 34.7929 22.6774 33.4808 21.6369 32.8945C20.5964 32.3082 19.2579 32.6696 18.6473 33.7017C18.0367 34.7339 18.3851 36.0459 19.4256 36.6322Z" fill="white"/>
+<path d="M20.2669 34.8677C19.2265 34.2814 18.878 32.9693 19.4886 31.9372C20.0992 30.9051 21.4377 30.5437 22.4782 31.13C23.5187 31.7163 23.8671 33.0283 23.2565 34.0605C22.6459 35.0926 21.3074 35.454 20.2669 34.8677ZM20.8198 33.9333C21.34 34.2264 22.0092 34.0457 22.3145 33.5297C22.6199 33.0136 22.4456 32.3576 21.9254 32.0644C21.4051 31.7713 20.7359 31.952 20.4306 32.468C20.1253 32.9841 20.2995 33.6401 20.8198 33.9333Z" fill="#6B4FBB"/>
+<path d="M30.889 35.0709C31.3313 34.8678 31.8035 34.7389 32.2863 34.6895L32.3883 34.3201C32.5643 33.6826 33.2272 33.3008 33.8637 33.4663L34.4983 33.6313C34.8053 33.7113 35.066 33.9096 35.2232 34.1825C35.3803 34.4555 35.421 34.7807 35.3362 35.0867L35.2342 35.4561C35.6306 35.7354 35.972 36.0777 36.2478 36.4644L36.6207 36.3629C37.262 36.1888 37.9173 36.5612 38.0857 37.1955L38.2517 37.8259C38.3319 38.1309 38.2864 38.4566 38.1251 38.7315C37.9638 39.0063 37.7001 39.2077 37.3919 39.2913L37.0198 39.3927C36.9951 39.6284 36.9507 39.8652 36.8854 40.1018C36.82 40.3384 36.7365 40.565 36.6364 40.7811L36.9065 41.0493C37.3717 41.512 37.367 42.2664 36.8961 42.7358L36.4275 43.2012C36.2007 43.4262 35.8945 43.5537 35.5761 43.5556C35.2577 43.5575 34.9532 43.4336 34.7299 43.2112L34.4592 42.9424C34.017 43.1455 33.5447 43.2744 33.0619 43.3238L32.9599 43.6932C32.7839 44.3307 32.1211 44.7125 31.4845 44.547L30.8499 44.3819C30.543 44.302 30.2822 44.1037 30.1251 43.8308C29.9679 43.5578 29.9273 43.2326 30.0121 42.9266L30.1141 42.5572C29.7214 42.2801 29.3788 41.9393 29.1004 41.5488L28.7276 41.6503C28.0863 41.8245 27.431 41.4521 27.2626 40.8178L27.0965 40.1874C27.0163 39.8824 27.0619 39.5566 27.2231 39.2818C27.3844 39.007 27.6481 38.8056 27.9563 38.722L28.3285 38.6206C28.3536 38.3812 28.3986 38.144 28.4629 37.9115C28.5282 37.6749 28.6118 37.4483 28.7118 37.2322L28.4417 36.964C27.9766 36.5013 27.9813 35.7469 28.4522 35.2775L28.9207 34.8121C29.1475 34.5871 29.4538 34.4595 29.7722 34.4577C30.0906 34.4558 30.395 34.5797 30.6184 34.802L30.889 35.0709Z" fill="white"/>
+<path d="M32.1998 34.5947L32.2833 34.2923C32.4746 33.6024 33.1919 33.1869 33.8914 33.3665L34.526 33.5315C35.2246 33.7132 35.6326 34.4223 35.4416 35.1142L35.3583 35.4159C35.7153 35.6768 36.0302 35.9898 36.2925 36.3444L36.5904 36.2633C37.2909 36.0733 38.0069 36.4799 38.1913 37.1715L38.3575 37.8023C38.4444 38.133 38.3949 38.4857 38.2201 38.7834C38.045 39.0816 37.7581 39.3004 37.423 39.3911L37.1204 39.4735C37.0938 39.6938 37.0505 39.9128 36.9908 40.1294C36.931 40.3458 36.8557 40.5563 36.7651 40.7602L36.9851 40.9788C37.4903 41.4818 37.4856 42.298 36.9752 42.8081L36.5064 43.2737C36.2596 43.5186 35.9255 43.6575 35.5775 43.6595C35.2288 43.6614 34.8954 43.5256 34.6519 43.2828L34.4357 43.0682C34.026 43.2479 33.5922 43.3661 33.1486 43.4189L33.0651 43.7214C32.8738 44.4113 32.1565 44.8267 31.457 44.6472L30.8224 44.4821C30.1238 44.3005 29.7158 43.5913 29.9068 42.8994L29.9901 42.5978C29.6331 42.3368 29.3182 42.0238 29.0559 41.6693L28.758 41.7504C28.0575 41.9403 27.3415 41.5338 27.1571 40.8421L26.9909 40.2114C26.9041 39.8807 26.9535 39.5279 27.1283 39.2303C27.3034 38.9321 27.5903 38.7132 27.9254 38.6225L28.228 38.5401C28.2546 38.3199 28.2979 38.1009 28.3576 37.8845C28.417 37.6691 28.4924 37.4583 28.5833 37.2535L28.3633 37.0348C27.8581 36.5319 27.8628 35.7157 28.3732 35.2055L28.842 34.74C29.0888 34.4951 29.4229 34.3561 29.7709 34.3542C30.1196 34.3522 30.453 34.4881 30.6965 34.7308L30.9127 34.9455C31.3224 34.7658 31.7562 34.6475 32.1998 34.5947ZM31.0182 36.0782C30.8094 36.1741 30.5632 36.1315 30.4022 35.9716L29.9178 35.4906C29.878 35.4508 29.8241 35.4289 29.7676 35.4292C29.7105 35.4295 29.6548 35.4527 29.6133 35.4939L29.1459 35.958C29.057 36.0469 29.0562 36.1897 29.1419 36.275L29.6246 36.7547C29.7833 36.9124 29.8259 37.151 29.7315 37.3546L29.6044 37.6287C29.5253 37.7997 29.4605 37.9767 29.4105 38.1581C29.36 38.3408 29.3251 38.5252 29.3058 38.7103L29.2745 39.0097C29.2514 39.2318 29.0916 39.4178 28.8729 39.4774L28.2068 39.6588C28.1506 39.674 28.1018 39.7112 28.0716 39.7626C28.0411 39.8146 28.0325 39.8763 28.0474 39.9333L28.2131 40.5621C28.2441 40.6786 28.3616 40.7453 28.4764 40.7142L29.1425 40.5328C29.3644 40.4725 29.5996 40.555 29.731 40.7394L29.9051 40.9836C30.1208 41.2862 30.3862 41.5502 30.6899 41.7646L30.9355 41.9376C31.1195 42.0672 31.2018 42.2962 31.142 42.5126L30.9597 43.1732C30.9264 43.2936 30.995 43.4128 31.1094 43.4425L31.7424 43.6071C31.8578 43.6367 31.9794 43.5663 32.0125 43.447L32.1949 42.7864C32.2546 42.5701 32.4437 42.4117 32.6697 42.3887L32.9714 42.3578C33.3452 42.3195 33.711 42.2196 34.054 42.0621L34.3305 41.9353C34.5392 41.8396 34.7853 41.8823 34.9462 42.0421L35.4306 42.5231C35.4704 42.5628 35.5243 42.5848 35.5808 42.5845C35.6379 42.5841 35.6936 42.561 35.7352 42.5197L36.2025 42.0556C36.2914 41.9668 36.2922 41.8239 36.2065 41.7387L35.7238 41.259C35.5651 41.1013 35.5225 40.8626 35.6169 40.6591L35.7441 40.3847C35.8228 40.2147 35.8875 40.0382 35.9379 39.8556C35.9884 39.6728 36.0233 39.4884 36.0427 39.3034L36.0739 39.0039C36.097 38.7819 36.2568 38.5958 36.4755 38.5363L37.1416 38.3549C37.1978 38.3397 37.2466 38.3025 37.2768 38.2511C37.3073 38.1991 37.3159 38.1373 37.301 38.0803L37.1353 37.4515C37.1043 37.335 36.9868 37.2683 36.872 37.2995L36.2059 37.4808C35.9841 37.5412 35.7488 37.4586 35.6174 37.2743L35.4433 37.0301C35.2276 36.7274 34.9622 36.4634 34.6588 36.2493L34.4132 36.0765C34.2289 35.947 34.1465 35.7178 34.2063 35.5012L34.3888 34.8404C34.422 34.7201 34.3534 34.6009 34.239 34.5711L33.606 34.4065C33.4907 34.3769 33.369 34.4473 33.3359 34.5666L33.1535 35.2272C33.0938 35.4435 32.9047 35.6019 32.6788 35.625L32.377 35.6558C32.0032 35.6941 31.6374 35.794 31.2948 35.9514C31.2919 35.9528 31.1997 35.995 31.0182 36.0782Z" fill="#FC6D26"/>
+<path d="M31.0067 42.0443C31.839 42.5134 32.9136 42.2178 33.4068 41.3841C33.9 40.5504 33.6251 39.4944 32.7928 39.0253C31.9604 38.5563 30.8858 38.8519 30.3926 39.6855C29.8994 40.5192 30.1743 41.5753 31.0067 42.0443Z" fill="white"/>
+<path d="M31.6893 40.6162C30.8561 40.1466 30.5815 39.0919 31.073 38.2611C31.5645 37.4302 32.6378 37.135 33.471 37.6045C34.3042 38.074 34.5788 39.1288 34.0873 39.9596C33.5957 40.7905 32.5225 41.0857 31.6893 40.6162ZM32.1315 39.8686C32.5469 40.1027 33.0855 39.9546 33.3337 39.535C33.5819 39.1154 33.4441 38.5861 33.0288 38.3521C32.6134 38.118 32.0748 38.2661 31.8266 38.6857C31.5783 39.1053 31.7162 39.6346 32.1315 39.8686Z" fill="#FC6D26"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16.8736 10.2573C16.9313 9.88553 16.9613 9.50478 16.9613 9.11717C16.9613 4.97039 13.5319 1.60876 9.30146 1.60876C5.07104 1.60876 1.6416 4.97039 1.6416 9.11717C1.6416 13.2639 5.07104 16.6256 9.30146 16.6256C11.7125 16.6256 13.8633 15.5337 15.2674 13.8269L17.582 13.5753C17.637 13.5693 17.6908 13.5552 17.7414 13.5334C18.0182 13.4145 18.1442 13.0982 18.0228 12.8269L16.8736 10.2573Z" fill="#F4F0FF"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17.42 9.18492C17.4777 8.81314 17.5077 8.43239 17.5077 8.04478C17.5077 3.898 14.0783 0.536377 9.84784 0.536377C5.61742 0.536377 2.18799 3.898 2.18799 8.04478C2.18799 12.1916 5.61742 15.5532 9.84784 15.5532C12.2588 15.5532 14.4097 14.4613 15.8138 12.7545L18.1284 12.5029C18.1834 12.4969 18.2371 12.4828 18.2878 12.461C18.5646 12.3421 18.6905 12.0258 18.5692 11.7545L17.42 9.18492Z" fill="white"/>
+<path d="M19.0711 11.5391C19.3137 12.0817 19.0617 12.7143 18.5082 12.9522C18.4069 12.9957 18.2994 13.0239 18.1895 13.0359L16.0945 13.2636C14.5478 15.0412 12.2855 16.0894 9.84859 16.0894C5.31599 16.0894 1.6416 12.4877 1.6416 8.04471C1.6416 3.60174 5.31599 0 9.84859 0C14.3812 0 18.0556 3.60174 18.0556 8.04471C18.0556 8.40289 18.0317 8.75827 17.9843 9.10926L19.0711 11.5391ZM16.8798 9.10409C16.9339 8.75583 16.9613 8.40206 16.9613 8.04471C16.9613 4.19414 13.7768 1.07263 9.84859 1.07263C5.92034 1.07263 2.73587 4.19414 2.73587 8.04471C2.73587 11.8953 5.92034 15.0168 9.84859 15.0168C12.0288 15.0168 14.0459 14.0499 15.3886 12.4178L15.5301 12.2457L18.0689 11.9698L16.9196 9.4002L16.8559 9.2578L16.8798 9.10409Z" fill="#E1D8F9"/>
+<path d="M9.98471 11.9331C7.8695 11.9331 6.15479 10.2523 6.15479 8.17888C6.15479 6.1055 7.8695 4.42468 9.98471 4.42468C12.0999 4.42468 13.8146 6.1055 13.8146 8.17888C13.8146 10.2523 12.0999 11.9331 9.98471 11.9331ZM9.98471 11.1286C11.6467 11.1286 12.9939 9.80798 12.9939 8.17888C12.9939 6.54979 11.6467 5.22915 9.98471 5.22915C8.32276 5.22915 6.97548 6.54979 6.97548 8.17888C6.97548 9.80798 8.32276 11.1286 9.98471 11.1286Z" fill="#31AF64"/>
+<path d="M9.62376 8.52988L9.09523 8.0118C8.93843 7.85825 8.68438 7.85825 8.52758 8.0118C8.4521 8.08542 8.40967 8.1855 8.40967 8.28987C8.40967 8.39425 8.4521 8.49433 8.52758 8.56795L9.31408 9.33864C9.31836 9.34303 9.32274 9.34732 9.32721 9.35151C9.47767 9.499 9.71841 9.49873 9.86751 9.35285L11.4564 7.79539C11.5279 7.725 11.5679 7.62964 11.5677 7.53031C11.5674 7.43098 11.5269 7.33582 11.455 7.26578C11.3837 7.19514 11.2866 7.15528 11.1851 7.15503C11.0837 7.15478 10.9864 7.19415 10.9147 7.26444L9.62376 8.52988Z" fill="#31AF64"/>
+</svg>
diff --git a/app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg b/app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg
new file mode 100644
index 00000000000..027767368a6
--- /dev/null
+++ b/app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg
@@ -0,0 +1,70 @@
+<svg width="80" height="56" viewBox="0 0 80 56" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.1567 40.6485C16.4079 40.6485 20.6649 36.3891 20.6649 31.135C20.6649 25.8808 16.4079 21.6215 11.1567 21.6215C5.9055 21.6215 1.64856 25.8808 1.64856 31.135C1.64856 36.3891 5.9055 40.6485 11.1567 40.6485Z" fill="#F9F9F9"/>
+<path d="M19.0163 30.4864C19.0163 35.1888 15.2065 38.9999 10.5081 38.9999C5.80976 38.9999 2 35.1888 2 30.4864C2 25.784 5.80976 21.9729 10.5081 21.9729C15.2065 21.9729 19.0163 25.784 19.0163 30.4864Z" fill="white" stroke="#EEEEEE" stroke-width="2"/>
+<path d="M10.5075 39.5674C15.52 39.5674 19.5834 35.5017 19.5834 30.4863C19.5834 25.471 15.52 21.4053 10.5075 21.4053C5.49496 21.4053 1.43152 25.471 1.43152 30.4863C1.43152 35.5017 5.49496 39.5674 10.5075 39.5674Z" stroke="#EEEEEE"/>
+<path d="M8.43196 33.0239C8.57802 32.9204 8.75259 32.8648 8.93158 32.8648H13.1009C13.817 32.8648 14.3975 32.284 14.3975 31.5675V28.5405C14.3975 27.824 13.817 27.2432 13.1009 27.2432H8.34686C7.63078 27.2432 7.05029 27.824 7.05029 28.5405V34.0031L8.43196 33.0239ZM8.93158 33.7296L7.20891 34.9505C7.09937 35.0282 6.96844 35.0699 6.8342 35.0699C6.47616 35.0699 6.18591 34.7795 6.18591 34.4212V28.5405C6.18591 27.3463 7.1534 26.3783 8.34686 26.3783H13.1009C14.2944 26.3783 15.2619 27.3463 15.2619 28.5405V31.5675C15.2619 32.7616 14.2944 33.7296 13.1009 33.7296H8.93158Z" fill="#FEE1D3"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.21191 29.6217C9.4506 29.6217 9.6441 29.8153 9.6441 30.0541C9.6441 30.293 9.4506 30.4866 9.21191 30.4866C8.97322 30.4866 8.77972 30.293 8.77972 30.0541C8.77972 29.8153 8.97322 29.6217 9.21191 29.6217ZM10.7246 29.6217C10.9633 29.6217 11.1568 29.8153 11.1568 30.0541C11.1568 30.293 10.9633 30.4866 10.7246 30.4866C10.4859 30.4866 10.2924 30.293 10.2924 30.0541C10.2924 29.8153 10.4859 29.6217 10.7246 29.6217ZM12.2372 29.6217C12.4759 29.6217 12.6694 29.8153 12.6694 30.0541C12.6694 30.293 12.4759 30.4866 12.2372 30.4866C11.9985 30.4866 11.805 30.293 11.805 30.0541C11.805 29.8153 11.9985 29.6217 12.2372 29.6217Z" fill="#FC6D26"/>
+<path d="M18.0716 14.0539C21.7713 14.0539 24.7705 11.0531 24.7705 7.35125C24.7705 3.64946 21.7713 0.64856 18.0716 0.64856C14.3719 0.64856 11.3727 3.64946 11.3727 7.35125C11.3727 11.0531 14.3719 14.0539 18.0716 14.0539Z" fill="#F9F9F9"/>
+<path d="M17.423 13.4054C21.1228 13.4054 24.122 10.4045 24.122 6.70269C24.122 3.0009 21.1228 0 17.423 0C13.7233 0 10.7241 3.0009 10.7241 6.70269C10.7241 10.4045 13.7233 13.4054 17.423 13.4054Z" fill="white"/>
+<path d="M19.1525 6.27026H15.6949C15.4563 6.27026 15.2628 6.46387 15.2628 6.7027C15.2628 6.94152 15.4563 7.13513 15.6949 7.13513H19.1525C19.3911 7.13513 19.5846 6.94152 19.5846 6.7027C19.5846 6.46387 19.3911 6.27026 19.1525 6.27026Z" fill="#6B4FBB"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.7467 48.0001H20.8808C19.4486 48.0001 18.2877 46.8384 18.2877 45.4055V12.5406C18.2877 11.1077 19.4486 9.94604 20.8808 9.94604H65.3962C66.8284 9.94604 67.9893 11.1077 67.9893 12.5406V16.5187C69.077 15.9128 70.3297 15.5677 71.6629 15.5677C75.84 15.5677 79.2262 18.9558 79.2262 23.1352C79.2262 27.3147 75.84 30.7028 71.6629 30.7028C70.3297 30.7028 69.077 30.3576 67.9893 29.7517V45.4055C67.9893 46.8384 66.8284 48.0001 65.3962 48.0001H62.8938C62.9761 48.4198 63.0192 48.8535 63.0192 49.2974C63.0192 52.9991 60.02 56 56.3202 56C52.6205 56 49.6213 52.9991 49.6213 49.2974C49.6213 48.8535 49.6644 48.4198 49.7467 48.0001Z" fill="#F9F9F9"/>
+<path d="M71.0139 30.0543C75.191 30.0543 78.5772 26.6662 78.5772 22.4868C78.5772 18.3073 75.191 14.9192 71.0139 14.9192C66.8368 14.9192 63.4506 18.3073 63.4506 22.4868C63.4506 26.6662 66.8368 30.0543 71.0139 30.0543Z" fill="white"/>
+<path d="M71.0148 29.6218C74.9532 29.6218 78.1459 26.4273 78.1459 22.4867C78.1459 18.5461 74.9532 15.3516 71.0148 15.3516C67.0764 15.3516 63.8837 18.5461 63.8837 22.4867C63.8837 26.4273 67.0764 29.6218 71.0148 29.6218Z" stroke="#EEEEEE" stroke-width="2"/>
+<path d="M71.014 25.7303C72.8042 25.7303 74.2554 24.2782 74.2554 22.487C74.2554 20.6958 72.8042 19.2438 71.014 19.2438C69.2238 19.2438 67.7726 20.6958 67.7726 22.487C67.7726 24.2782 69.2238 25.7303 71.014 25.7303Z" fill="#F4F1FA" stroke="#6B4FBB"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M71.9682 20.5401L71.9667 20.5401L71.4366 19.8925L70.9063 20.5404L70.9051 20.5404L70.3748 19.8925L69.8443 20.5407L69.8436 20.5407L69.3131 19.8925L68.7823 20.541L68.2041 20.5411C68.7671 19.6321 69.7666 19.0276 70.9057 19.0276C72.0443 19.0276 73.0433 19.6315 73.6065 20.5397L73.0282 20.5399L72.4983 19.8925L71.9682 20.5401Z" fill="#6B4FBB"/>
+<path d="M69.8258 23.1357C70.0048 23.1357 70.1499 22.9905 70.1499 22.8114C70.1499 22.6323 70.0048 22.4871 69.8258 22.4871C69.6468 22.4871 69.5016 22.6323 69.5016 22.8114C69.5016 22.9905 69.6468 23.1357 69.8258 23.1357Z" fill="#6B4FBB"/>
+<path d="M72.2021 23.1357C72.3811 23.1357 72.5263 22.9905 72.5263 22.8114C72.5263 22.6323 72.3811 22.4871 72.2021 22.4871C72.0231 22.4871 71.878 22.6323 71.878 22.8114C71.878 22.9905 72.0231 23.1357 72.2021 23.1357Z" fill="#6B4FBB"/>
+<path d="M65.1797 9.29712H19.7999C18.6064 9.29712 17.6389 10.2652 17.6389 11.4593V45.189C17.6389 46.3831 18.6064 47.3511 19.7999 47.3511H65.1797C66.3731 47.3511 67.3406 46.3831 67.3406 45.189V11.4593C67.3406 10.2652 66.3731 9.29712 65.1797 9.29712Z" fill="white"/>
+<path d="M64.7466 9.72986H20.2311C19.0377 9.72986 18.0702 10.6979 18.0702 11.892V44.7568C18.0702 45.951 19.0377 46.919 20.2311 46.919H64.7466C65.94 46.919 66.9075 45.951 66.9075 44.7568V11.892C66.9075 10.6979 65.94 9.72986 64.7466 9.72986Z" stroke="#EEEEEE" stroke-width="2"/>
+<path d="M25.6338 18.8108C25.6338 18.572 25.4403 18.3784 25.2016 18.3784C24.9629 18.3784 24.7694 18.572 24.7694 18.8108V44.3243C24.7694 44.5632 24.9629 44.7568 25.2016 44.7568C25.4403 44.7568 25.6338 44.5632 25.6338 44.3243V18.8108Z" fill="#EEEEEE"/>
+<path d="M22.177 22.0541H20.8804C20.6417 22.0541 20.4482 22.2477 20.4482 22.4865C20.4482 22.7253 20.6417 22.9189 20.8804 22.9189H22.177C22.4157 22.9189 22.6092 22.7253 22.6092 22.4865C22.6092 22.2477 22.4157 22.0541 22.177 22.0541Z" fill="#FEE1D3"/>
+<path d="M22.177 24.6486H20.8804C20.6417 24.6486 20.4482 24.8422 20.4482 25.081C20.4482 25.3198 20.6417 25.5134 20.8804 25.5134H22.177C22.4157 25.5134 22.6092 25.3198 22.6092 25.081C22.6092 24.8422 22.4157 24.6486 22.177 24.6486Z" fill="#F0EDF8"/>
+<path d="M22.177 27.2432H20.8804C20.6417 27.2432 20.4482 27.4368 20.4482 27.6756C20.4482 27.9144 20.6417 28.108 20.8804 28.108H22.177C22.4157 28.108 22.6092 27.9144 22.6092 27.6756C22.6092 27.4368 22.4157 27.2432 22.177 27.2432Z" fill="#FEF0E9"/>
+<path d="M22.177 29.8379H20.8804C20.6417 29.8379 20.4482 30.0315 20.4482 30.2703C20.4482 30.5091 20.6417 30.7028 20.8804 30.7028H22.177C22.4157 30.7028 22.6092 30.5091 22.6092 30.2703C22.6092 30.0315 22.4157 29.8379 22.177 29.8379Z" fill="#FEE1D3"/>
+<path d="M22.177 32.4324H20.8804C20.6417 32.4324 20.4482 32.626 20.4482 32.8648C20.4482 33.1036 20.6417 33.2972 20.8804 33.2972H22.177C22.4157 33.2972 22.6092 33.1036 22.6092 32.8648C22.6092 32.626 22.4157 32.4324 22.177 32.4324Z" fill="#E1DBF1"/>
+<path d="M22.177 35.027H20.8804C20.6417 35.027 20.4482 35.2206 20.4482 35.4594C20.4482 35.6982 20.6417 35.8918 20.8804 35.8918H22.177C22.4157 35.8918 22.6092 35.6982 22.6092 35.4594C22.6092 35.2206 22.4157 35.027 22.177 35.027Z" fill="#F0EDF8"/>
+<path d="M22.177 37.6216H20.8804C20.6417 37.6216 20.4482 37.8152 20.4482 38.054C20.4482 38.2928 20.6417 38.4864 20.8804 38.4864H22.177C22.4157 38.4864 22.6092 38.2928 22.6092 38.054C22.6092 37.8152 22.4157 37.6216 22.177 37.6216Z" fill="#FEF0E9"/>
+<path d="M22.177 40.2161H20.8804C20.6417 40.2161 20.4482 40.4097 20.4482 40.6485C20.4482 40.8873 20.6417 41.0809 20.8804 41.0809H22.177C22.4157 41.0809 22.6092 40.8873 22.6092 40.6485C22.6092 40.4097 22.4157 40.2161 22.177 40.2161Z" fill="#FEE1D3"/>
+<path d="M32.1164 22.0538H29.9555C29.7168 22.0538 29.5233 22.2474 29.5233 22.4863C29.5233 22.7251 29.7168 22.9187 29.9555 22.9187H32.1164C32.3551 22.9187 32.5486 22.7251 32.5486 22.4863C32.5486 22.2474 32.3551 22.0538 32.1164 22.0538Z" fill="#6B4FBB"/>
+<path d="M36.439 22.0537H34.278C34.0393 22.0537 33.8458 22.2473 33.8458 22.4861C33.8458 22.725 34.0393 22.9186 34.278 22.9186H36.439C36.6776 22.9186 36.8711 22.725 36.8711 22.4861C36.8711 22.2473 36.6776 22.0537 36.439 22.0537Z" fill="#F0EDF8"/>
+<path d="M40.7601 22.0537H38.5992C38.3605 22.0537 38.167 22.2473 38.167 22.4861C38.167 22.725 38.3605 22.9186 38.5992 22.9186H40.7601C40.9988 22.9186 41.1923 22.725 41.1923 22.4861C41.1923 22.2473 40.9988 22.0537 40.7601 22.0537Z" fill="#FEF0E9"/>
+<path d="M32.1161 24.6484H29.9551C29.7164 24.6484 29.5229 24.842 29.5229 25.0809C29.5229 25.3197 29.7164 25.5133 29.9551 25.5133H32.1161C32.3548 25.5133 32.5483 25.3197 32.5483 25.0809C32.5483 24.842 32.3548 24.6484 32.1161 24.6484Z" fill="#F0EDF8"/>
+<path d="M40.7601 27.243H38.5992C38.3605 27.243 38.167 27.4366 38.167 27.6755C38.167 27.9143 38.3605 28.1079 38.5992 28.1079H40.7601C40.9988 28.1079 41.1923 27.9143 41.1923 27.6755C41.1923 27.4366 40.9988 27.243 40.7601 27.243Z" fill="#FEF0E9"/>
+<path d="M32.1161 32.4323H29.9551C29.7164 32.4323 29.5229 32.6259 29.5229 32.8647C29.5229 33.1035 29.7164 33.2971 29.9551 33.2971H32.1161C32.3548 33.2971 32.5483 33.1035 32.5483 32.8647C32.5483 32.6259 32.3548 32.4323 32.1161 32.4323Z" fill="#E1DBF1"/>
+<path d="M40.7601 29.8378H38.5992C38.3605 29.8378 38.167 30.0314 38.167 30.2702C38.167 30.509 38.3605 30.7026 38.5992 30.7026H40.7601C40.9988 30.7026 41.1923 30.509 41.1923 30.2702C41.1923 30.0314 40.9988 29.8378 40.7601 29.8378Z" fill="#FEF0E9"/>
+<path d="M34.9263 24.6484H34.278C34.0393 24.6484 33.8458 24.842 33.8458 25.0809C33.8458 25.3197 34.0393 25.5133 34.278 25.5133H34.9263C35.165 25.5133 35.3585 25.3197 35.3585 25.0809C35.3585 24.842 35.165 24.6484 34.9263 24.6484Z" fill="#FEE1D3"/>
+<path d="M36.4379 29.8378H35.7896C35.5509 29.8378 35.3574 30.0314 35.3574 30.2702C35.3574 30.509 35.5509 30.7026 35.7896 30.7026H36.4379C36.6766 30.7026 36.8701 30.509 36.8701 30.2702C36.8701 30.0314 36.6766 29.8378 36.4379 29.8378Z" fill="#6B4FBB"/>
+<path d="M34.9263 32.4323H34.278C34.0393 32.4323 33.8458 32.6259 33.8458 32.8647C33.8458 33.1035 34.0393 33.2971 34.278 33.2971H34.9263C35.165 33.2971 35.3585 33.1035 35.3585 32.8647C35.3585 32.6259 35.165 32.4323 34.9263 32.4323Z" fill="#FEE1D3"/>
+<path d="M30.6034 27.243H29.9551C29.7164 27.243 29.5229 27.4366 29.5229 27.6755C29.5229 27.9143 29.7164 28.1079 29.9551 28.1079H30.6034C30.8421 28.1079 31.0356 27.9143 31.0356 27.6755C31.0356 27.4366 30.8421 27.243 30.6034 27.243Z" fill="#FC6D26"/>
+<path d="M36.4383 27.243H32.7647C32.526 27.243 32.3325 27.4366 32.3325 27.6755C32.3325 27.9143 32.526 28.1079 32.7647 28.1079H36.4383C36.677 28.1079 36.8705 27.9143 36.8705 27.6755C36.8705 27.4366 36.677 27.243 36.4383 27.243Z" fill="#E1DBF1"/>
+<path d="M33.6287 29.8378H29.9551C29.7164 29.8378 29.5229 30.0314 29.5229 30.2702C29.5229 30.509 29.7164 30.7026 29.9551 30.7026H33.6287C33.8674 30.7026 34.0609 30.509 34.0609 30.2702C34.0609 30.0314 33.8674 29.8378 33.6287 29.8378Z" fill="#EEEEEE"/>
+<path d="M37.7342 24.6484H37.0859C36.8472 24.6484 36.6537 24.842 36.6537 25.0809C36.6537 25.3197 36.8472 25.5133 37.0859 25.5133H37.7342C37.9728 25.5133 38.1663 25.3197 38.1663 25.0809C38.1663 24.842 37.9728 24.6484 37.7342 24.6484Z" fill="#6B4FBB"/>
+<path d="M53.2933 22.054H51.1323C50.8936 22.054 50.7001 22.2476 50.7001 22.4864C50.7001 22.7252 50.8936 22.9188 51.1323 22.9188H53.2933C53.532 22.9188 53.7254 22.7252 53.7254 22.4864C53.7254 22.2476 53.532 22.054 53.2933 22.054Z" fill="#FEE1D3"/>
+<path d="M57.6143 22.054H55.4533C55.2146 22.054 55.0211 22.2476 55.0211 22.4864C55.0211 22.7252 55.2146 22.9188 55.4533 22.9188H57.6143C57.8529 22.9188 58.0464 22.7252 58.0464 22.4864C58.0464 22.2476 57.8529 22.054 57.6143 22.054Z" fill="#F0EDF8"/>
+<path d="M61.9367 22.054H59.7758C59.5371 22.054 59.3436 22.2476 59.3436 22.4864C59.3436 22.7252 59.5371 22.9188 59.7758 22.9188H61.9367C62.1754 22.9188 62.3689 22.7252 62.3689 22.4864C62.3689 22.2476 62.1754 22.054 61.9367 22.054Z" fill="#FC6D26"/>
+<path d="M53.2931 24.6486H51.1321C50.8934 24.6486 50.7 24.8422 50.7 25.081C50.7 25.3198 50.8934 25.5134 51.1321 25.5134H53.2931C53.5318 25.5134 53.7253 25.3198 53.7253 25.081C53.7253 24.8422 53.5318 24.6486 53.2931 24.6486Z" fill="#FEF0E9"/>
+<path d="M61.9367 27.2432H59.7758C59.5371 27.2432 59.3436 27.4368 59.3436 27.6756C59.3436 27.9144 59.5371 28.108 59.7758 28.108H61.9367C62.1754 28.108 62.3689 27.9144 62.3689 27.6756C62.3689 27.4368 62.1754 27.2432 61.9367 27.2432Z" fill="#E1DBF1"/>
+<path d="M53.2931 32.4324H51.1321C50.8934 32.4324 50.7 32.626 50.7 32.8648C50.7 33.1036 50.8934 33.2972 51.1321 33.2972H53.2931C53.5318 33.2972 53.7253 33.1036 53.7253 32.8648C53.7253 32.626 53.5318 32.4324 53.2931 32.4324Z" fill="#F0EDF8"/>
+<path d="M61.9367 29.8378H59.7758C59.5371 29.8378 59.3436 30.0314 59.3436 30.2702C59.3436 30.509 59.5371 30.7026 59.7758 30.7026H61.9367C62.1754 30.7026 62.3689 30.509 62.3689 30.2702C62.3689 30.0314 62.1754 29.8378 61.9367 29.8378Z" fill="#FEE1D3"/>
+<path d="M56.1016 24.6486H55.4533C55.2146 24.6486 55.0211 24.8422 55.0211 25.081C55.0211 25.3198 55.2146 25.5134 55.4533 25.5134H56.1016C56.3403 25.5134 56.5338 25.3198 56.5338 25.081C56.5338 24.8422 56.3403 24.6486 56.1016 24.6486Z" fill="#FC6D26"/>
+<path d="M57.6149 29.8378H56.9666C56.7279 29.8378 56.5344 30.0314 56.5344 30.2702C56.5344 30.509 56.7279 30.7026 56.9666 30.7026H57.6149C57.8536 30.7026 58.0471 30.509 58.0471 30.2702C58.0471 30.0314 57.8536 29.8378 57.6149 29.8378Z" fill="#6B4FBB"/>
+<path d="M56.1016 32.4324H55.4533C55.2146 32.4324 55.0211 32.626 55.0211 32.8648C55.0211 33.1036 55.2146 33.2972 55.4533 33.2972H56.1016C56.3403 33.2972 56.5338 33.1036 56.5338 32.8648C56.5338 32.626 56.3403 32.4324 56.1016 32.4324Z" fill="#FC6D26"/>
+<path d="M51.7804 27.2432H51.1321C50.8934 27.2432 50.7 27.4368 50.7 27.6756C50.7 27.9144 50.8934 28.108 51.1321 28.108H51.7804C52.0191 28.108 52.2126 27.9144 52.2126 27.6756C52.2126 27.4368 52.0191 27.2432 51.7804 27.2432Z" fill="#6B4FBB"/>
+<path d="M57.6153 27.2432H53.9417C53.703 27.2432 53.5095 27.4368 53.5095 27.6756C53.5095 27.9144 53.703 28.108 53.9417 28.108H57.6153C57.854 28.108 58.0475 27.9144 58.0475 27.6756C58.0475 27.4368 57.854 27.2432 57.6153 27.2432Z" fill="#FEE1D3"/>
+<path d="M54.8057 29.8378H51.1321C50.8934 29.8378 50.7 30.0314 50.7 30.2702C50.7 30.509 50.8934 30.7026 51.1321 30.7026H54.8057C55.0444 30.7026 55.2379 30.509 55.2379 30.2702C55.2379 30.0314 55.0444 29.8378 54.8057 29.8378Z" fill="#FEF0E9"/>
+<path d="M58.9112 24.6486H58.2629C58.0242 24.6486 57.8307 24.8422 57.8307 25.081C57.8307 25.3198 58.0242 25.5134 58.2629 25.5134H58.9112C59.1499 25.5134 59.3433 25.3198 59.3433 25.081C59.3433 24.8422 59.1499 24.6486 58.9112 24.6486Z" fill="#6B4FBB"/>
+<path d="M32.1163 35.027H29.9553C29.7166 35.027 29.5231 35.2206 29.5231 35.4594C29.5231 35.6982 29.7166 35.8918 29.9553 35.8918H32.1163C32.355 35.8918 32.5484 35.6982 32.5484 35.4594C32.5484 35.2206 32.355 35.027 32.1163 35.027Z" fill="#F0EDF8"/>
+<path d="M36.4372 35.027H34.2763C34.0376 35.027 33.8441 35.2206 33.8441 35.4594C33.8441 35.6982 34.0376 35.8918 34.2763 35.8918H36.4372C36.6759 35.8918 36.8694 35.6982 36.8694 35.4594C36.8694 35.2206 36.6759 35.027 36.4372 35.027Z" fill="#6B4FBB"/>
+<path d="M40.7597 35.027H38.5988C38.3601 35.027 38.1666 35.2206 38.1666 35.4594C38.1666 35.6982 38.3601 35.8918 38.5988 35.8918H40.7597C40.9984 35.8918 41.1919 35.6982 41.1919 35.4594C41.1919 35.2206 40.9984 35.027 40.7597 35.027Z" fill="#E1DBF1"/>
+<path d="M32.1161 37.6216H29.9551C29.7164 37.6216 29.5229 37.8152 29.5229 38.054C29.5229 38.2928 29.7164 38.4864 29.9551 38.4864H32.1161C32.3548 38.4864 32.5483 38.2928 32.5483 38.054C32.5483 37.8152 32.3548 37.6216 32.1161 37.6216Z" fill="#FEF0E9"/>
+<path d="M40.7597 40.2161H38.5988C38.3601 40.2161 38.1666 40.4097 38.1666 40.6485C38.1666 40.8873 38.3601 41.0809 38.5988 41.0809H40.7597C40.9984 41.0809 41.1919 40.8873 41.1919 40.6485C41.1919 40.4097 40.9984 40.2161 40.7597 40.2161Z" fill="#FEE1D3"/>
+<path d="M34.9246 37.6216H34.2763C34.0376 37.6216 33.8441 37.8152 33.8441 38.054C33.8441 38.2928 34.0376 38.4864 34.2763 38.4864H34.9246C35.1633 38.4864 35.3568 38.2928 35.3568 38.054C35.3568 37.8152 35.1633 37.6216 34.9246 37.6216Z" fill="#EEEEEE"/>
+<path d="M30.6034 40.2161H29.9551C29.7164 40.2161 29.5229 40.4097 29.5229 40.6485C29.5229 40.8873 29.7164 41.0809 29.9551 41.0809H30.6034C30.8421 41.0809 31.0356 40.8873 31.0356 40.6485C31.0356 40.4097 30.8421 40.2161 30.6034 40.2161Z" fill="#6B4FBB"/>
+<path d="M36.4383 40.2161H32.7647C32.526 40.2161 32.3325 40.4097 32.3325 40.6485C32.3325 40.8873 32.526 41.0809 32.7647 41.0809H36.4383C36.677 41.0809 36.8705 40.8873 36.8705 40.6485C36.8705 40.4097 36.677 40.2161 36.4383 40.2161Z" fill="#FEF0E9"/>
+<path d="M37.7342 37.6216H37.0859C36.8472 37.6216 36.6537 37.8152 36.6537 38.054C36.6537 38.2928 36.8472 38.4864 37.0859 38.4864H37.7342C37.9728 38.4864 38.1663 38.2928 38.1663 38.054C38.1663 37.8152 37.9728 37.6216 37.7342 37.6216Z" fill="#FC6D26"/>
+<path d="M46.3791 25.2972C46.3791 25.0584 46.1856 24.8647 45.947 24.8647C45.7083 24.8647 45.5148 25.0584 45.5148 25.2972V38.0539C45.5148 38.2927 45.7083 38.4863 45.947 38.4863C46.1856 38.4863 46.3791 38.2927 46.3791 38.0539V25.2972Z" fill="#EEEEEE"/>
+<path d="M66.9082 15.135H18.0709C17.8322 15.135 17.6387 15.3286 17.6387 15.5674C17.6387 15.8063 17.8322 15.9999 18.0709 15.9999H66.9082C67.1469 15.9999 67.3404 15.8063 67.3404 15.5674C67.3404 15.3286 67.1469 15.135 66.9082 15.135Z" fill="#EEEEEE"/>
+<path d="M55.8884 55.3513C59.5881 55.3513 62.5874 52.3504 62.5874 48.6486C62.5874 44.9468 59.5881 41.9459 55.8884 41.9459C52.1887 41.9459 49.1895 44.9468 49.1895 48.6486C49.1895 52.3504 52.1887 55.3513 55.8884 55.3513Z" fill="white"/>
+<path d="M55.8878 54.9188C59.3489 54.9188 62.1546 52.1115 62.1546 48.6486C62.1546 45.1856 59.3489 42.3783 55.8878 42.3783C52.4268 42.3783 49.6211 45.1856 49.6211 48.6486C49.6211 52.1115 52.4268 54.9188 55.8878 54.9188Z" stroke="#EEEEEE" stroke-width="2"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M55.457 48.2161V46.9188C55.457 46.6799 55.6505 46.4863 55.8892 46.4863C56.1278 46.4863 56.3213 46.6799 56.3213 46.9188V48.2161H57.6179C57.8566 48.2161 58.0501 48.4097 58.0501 48.6485C58.0501 48.8873 57.8566 49.0809 57.6179 49.0809H56.3213V50.3782C56.3213 50.617 56.1278 50.8106 55.8892 50.8106C55.6505 50.8106 55.457 50.617 55.457 50.3782V49.0809H54.1604C53.9217 49.0809 53.7282 48.8873 53.7282 48.6485C53.7282 48.4097 53.9217 48.2161 54.1604 48.2161H55.457Z" fill="#FC6D26"/>
+</svg>
diff --git a/app/assets/images/learn_gitlab/security_scan_enabled.svg b/app/assets/images/learn_gitlab/security_scan_enabled.svg
new file mode 100644
index 00000000000..eea0693484c
--- /dev/null
+++ b/app/assets/images/learn_gitlab/security_scan_enabled.svg
@@ -0,0 +1,36 @@
+<svg width="47" height="47" viewBox="0 0 47 47" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M23.0588 47C35.7939 47 46.1176 36.6762 46.1176 23.9411C46.1176 11.2061 35.7939 0.882324 23.0588 0.882324C10.3238 0.882324 0 11.2061 0 23.9411C0 36.6762 10.3238 47 23.0588 47Z" fill="#EEEEEE"/>
+<path d="M23.0588 45.1176C34.7542 45.1176 44.2353 35.6366 44.2353 23.9411C44.2353 12.2457 34.7542 2.76465 23.0588 2.76465C11.3634 2.76465 1.88232 12.2457 1.88232 23.9411C1.88232 35.6366 11.3634 45.1176 23.0588 45.1176Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16.3292 14.5295H16.9802C17.4786 14.5295 17.8827 14.9336 17.8827 15.432V16.8825C17.8827 17.4023 17.4613 17.8237 16.9415 17.8237H13.6556C13.1683 17.8237 12.7732 17.4286 12.7732 16.9413C12.7732 16.6257 12.9419 16.334 13.2155 16.1766L15.8598 14.655C16.0026 14.5728 16.1644 14.5295 16.3292 14.5295ZM12.2356 23.0001H16.9415C17.4613 23.0001 17.8827 23.4215 17.8827 23.9413V25.3531C17.8827 25.8729 17.4613 26.2942 16.9415 26.2942H12.2356C11.7158 26.2942 11.2944 25.8729 11.2944 25.3531V23.9413C11.2944 23.4215 11.7158 23.0001 12.2356 23.0001Z" fill="#EFEDF8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M6.11793 18.7649H10.8238C11.3436 18.7649 11.765 19.1863 11.765 19.7061V21.1178C11.765 21.6376 11.3436 22.059 10.8238 22.059H6.11793C5.59814 22.059 5.17676 21.6376 5.17676 21.1178V19.7061C5.17676 19.1863 5.59814 18.7649 6.11793 18.7649Z" fill="#F9E2D5"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M40.4709 18.7649H35.765C35.2452 18.7649 34.8239 19.1863 34.8239 19.7061V21.1178C34.8239 21.6376 35.2452 22.059 35.765 22.059H40.4709C40.9907 22.059 41.4121 21.6376 41.4121 21.1178V19.7061C41.4121 19.1863 40.9907 18.7649 40.4709 18.7649Z" fill="#F9E2D5"/>
+<path d="M16.9415 31.0002H12.2356C11.7158 31.0002 11.2944 31.4216 11.2944 31.9414V33.3532C11.2944 33.873 11.7158 34.2944 12.2356 34.2944H16.9415C17.4613 34.2944 17.8827 33.873 17.8827 33.3532V31.9414C17.8827 31.4216 17.4613 31.0002 16.9415 31.0002Z" fill="#F9E2D5"/>
+<path d="M29.6474 31.0002H34.3533C34.8731 31.0002 35.2944 31.4216 35.2944 31.9414V33.3532C35.2944 33.873 34.8731 34.2944 34.3533 34.2944H29.6474C29.1276 34.2944 28.7062 33.873 28.7062 33.3532V31.9414C28.7062 31.4216 29.1276 31.0002 29.6474 31.0002Z" fill="#F9E2D5"/>
+<path d="M9.41202 31.0002H8.00026C7.48046 31.0002 7.05908 31.4216 7.05908 31.9414V33.3532C7.05908 33.873 7.48046 34.2944 8.00026 34.2944H9.41202C9.93182 34.2944 10.3532 33.873 10.3532 33.3532V31.9414C10.3532 31.4216 9.93182 31.0002 9.41202 31.0002Z" fill="#F9E2D5"/>
+<path d="M37.1768 31.0002H38.5886C39.1084 31.0002 39.5298 31.4216 39.5298 31.9414V33.3532C39.5298 33.873 39.1084 34.2944 38.5886 34.2944H37.1768C36.657 34.2944 36.2357 33.873 36.2357 33.3532V31.9414C36.2357 31.4216 36.657 31.0002 37.1768 31.0002Z" fill="#F9E2D5"/>
+<path d="M9.41202 23.0002H8.00026C7.48046 23.0002 7.05908 23.4216 7.05908 23.9414V25.3532C7.05908 25.873 7.48046 26.2944 8.00026 26.2944H9.41202C9.93182 26.2944 10.3532 25.873 10.3532 25.3532V23.9414C10.3532 23.4216 9.93182 23.0002 9.41202 23.0002Z" fill="#F9E2D5"/>
+<path d="M37.1768 23.0002H38.5886C39.1084 23.0002 39.5298 23.4216 39.5298 23.9414V25.3532C39.5298 25.873 39.1084 26.2944 38.5886 26.2944H37.1768C36.657 26.2944 36.2357 25.873 36.2357 25.3532V23.9414C36.2357 23.4216 36.657 23.0002 37.1768 23.0002Z" fill="#F9E2D5"/>
+<path d="M9.41202 14.5295H8.00026C7.48046 14.5295 7.05908 14.9509 7.05908 15.4707V16.8825C7.05908 17.4023 7.48046 17.8237 8.00026 17.8237H9.41202C9.93182 17.8237 10.3532 17.4023 10.3532 16.8825V15.4707C10.3532 14.9509 9.93182 14.5295 9.41202 14.5295Z" fill="#F9E2D5"/>
+<path d="M37.1768 14.5295H38.5886C39.1084 14.5295 39.5298 14.9509 39.5298 15.4707V16.8825C39.5298 17.4023 39.1084 17.8237 38.5886 17.8237H37.1768C36.657 17.8237 36.2357 17.4023 36.2357 16.8825V15.4707C36.2357 14.9509 36.657 14.5295 37.1768 14.5295Z" fill="#F9E2D5"/>
+<path d="M10.8238 26.7649H6.11793C5.59814 26.7649 5.17676 27.1863 5.17676 27.7061V29.1178C5.17676 29.6376 5.59814 30.059 6.11793 30.059H10.8238C11.3436 30.059 11.765 29.6376 11.765 29.1178V27.7061C11.765 27.1863 11.3436 26.7649 10.8238 26.7649Z" fill="#F9E2D5"/>
+<path d="M35.765 26.7649H40.4709C40.9907 26.7649 41.4121 27.1863 41.4121 27.7061V29.1178C41.4121 29.6376 40.9907 30.059 40.4709 30.059H35.765C35.2452 30.059 34.8239 29.6376 34.8239 29.1178V27.7061C34.8239 27.1863 35.2452 26.7649 35.765 26.7649Z" fill="#F9E2D5"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2356 14.5295H16.9415C17.4613 14.5295 17.8827 14.9509 17.8827 15.4707V16.8825C17.8827 17.4023 17.4613 17.8237 16.9415 17.8237H12.2356C11.7158 17.8237 11.2944 17.4023 11.2944 16.8825V15.4707C11.2944 14.9509 11.7158 14.5295 12.2356 14.5295Z" fill="#EFEDF8"/>
+<path d="M28.2354 14.5295H23.5296C23.0098 14.5295 22.5884 14.9509 22.5884 15.4707V16.8825C22.5884 17.4023 23.0098 17.8237 23.5296 17.8237H28.2354C28.7552 17.8237 29.1766 17.4023 29.1766 16.8825V15.4707C29.1766 14.9509 28.7552 14.5295 28.2354 14.5295Z" fill="#EFEDF8"/>
+<path d="M28.2354 23.0002H23.5296C23.0098 23.0002 22.5884 23.4216 22.5884 23.9414V25.3532C22.5884 25.873 23.0098 26.2944 23.5296 26.2944H28.2354C28.7552 26.2944 29.1766 25.873 29.1766 25.3532V23.9414C29.1766 23.4216 28.7552 23.0002 28.2354 23.0002Z" fill="#EFEDF8"/>
+<path d="M32.0002 26.7649H29.6472C29.1274 26.7649 28.7061 27.1863 28.7061 27.7061V29.1178C28.7061 29.6376 29.1274 30.059 29.6472 30.059H32.0002C32.52 30.059 32.9413 29.6376 32.9413 29.1178V27.7061C32.9413 27.1863 32.52 26.7649 32.0002 26.7649Z" fill="#EFEDF8"/>
+<path d="M22.5885 18.7649H17.8826C17.3628 18.7649 16.9414 19.1863 16.9414 19.7061V21.1178C16.9414 21.6376 17.3628 22.059 17.8826 22.059H22.5885C23.1083 22.059 23.5296 21.6376 23.5296 21.1178V19.7061C23.5296 19.1863 23.1083 18.7649 22.5885 18.7649Z" fill="#EFEDF8"/>
+<path d="M21.1767 14.5295H19.7649C19.2451 14.5295 18.8237 14.9509 18.8237 15.4707V16.8825C18.8237 17.4023 19.2451 17.8237 19.7649 17.8237H21.1767C21.6965 17.8237 22.1178 17.4023 22.1178 16.8825V15.4707C22.1178 14.9509 21.6965 14.5295 21.1767 14.5295Z" fill="#EFEDF8"/>
+<path d="M21.1767 23.0002H19.7649C19.2451 23.0002 18.8237 23.4216 18.8237 23.9414V25.3532C18.8237 25.873 19.2451 26.2944 19.7649 26.2944H21.1767C21.6965 26.2944 22.1178 25.873 22.1178 25.3532V23.9414C22.1178 23.4216 21.6965 23.0002 21.1767 23.0002Z" fill="#EFEDF8"/>
+<path d="M15.059 26.7649H13.6472C13.1274 26.7649 12.7061 27.1863 12.7061 27.7061V29.1178C12.7061 29.6376 13.1274 30.059 13.6472 30.059H15.059C15.5788 30.059 16.0002 29.6376 16.0002 29.1178V27.7061C16.0002 27.1863 15.5788 26.7649 15.059 26.7649Z" fill="#EFEDF8"/>
+<path d="M21.1767 31.0002H19.7649C19.2451 31.0002 18.8237 31.4216 18.8237 31.9414V33.3532C18.8237 33.873 19.2451 34.2944 19.7649 34.2944H21.1767C21.6965 34.2944 22.1178 33.873 22.1178 33.3532V31.9414C22.1178 31.4216 21.6965 31.0002 21.1767 31.0002Z" fill="#EFEDF8"/>
+<path d="M32.4706 23.0002H31.0589C30.5391 23.0002 30.1177 23.4216 30.1177 23.9414V25.3532C30.1177 25.873 30.5391 26.2944 31.0589 26.2944H32.4706C32.9904 26.2944 33.4118 25.873 33.4118 25.3532V23.9414C33.4118 23.4216 32.9904 23.0002 32.4706 23.0002Z" fill="#EFEDF8"/>
+<path d="M15.059 18.7649H13.6472C13.1274 18.7649 12.7061 19.1863 12.7061 19.7061V21.1178C12.7061 21.6376 13.1274 22.059 13.6472 22.059H15.059C15.5788 22.059 16.0002 21.6376 16.0002 21.1178V19.7061C16.0002 19.1863 15.5788 18.7649 15.059 18.7649Z" fill="#EFEDF8"/>
+<path d="M26.8241 18.7649H25.4124C24.8926 18.7649 24.4712 19.1863 24.4712 19.7061V21.1178C24.4712 21.6376 24.8926 22.059 25.4124 22.059H26.8241C27.3439 22.059 27.7653 21.6376 27.7653 21.1178V19.7061C27.7653 19.1863 27.3439 18.7649 26.8241 18.7649Z" fill="#EFEDF8"/>
+<path d="M32.4706 14.5295H31.0589C30.5391 14.5295 30.1177 14.9509 30.1177 15.4707V16.8825C30.1177 17.4023 30.5391 17.8237 31.0589 17.8237H32.4706C32.9904 17.8237 33.4118 17.4023 33.4118 16.8825V15.4707C33.4118 14.9509 32.9904 14.5295 32.4706 14.5295Z" fill="#EFEDF8"/>
+<path d="M34.3531 18.7649H29.6472C29.1274 18.7649 28.7061 19.1863 28.7061 19.7061V21.1178C28.7061 21.6376 29.1274 22.059 29.6472 22.059H34.3531C34.8729 22.059 35.2943 21.6376 35.2943 21.1178V19.7061C35.2943 19.1863 34.8729 18.7649 34.3531 18.7649Z" fill="#EFEDF8"/>
+<path d="M22.5885 26.7649H17.8826C17.3628 26.7649 16.9414 27.1863 16.9414 27.7061V29.1178C16.9414 29.6376 17.3628 30.059 17.8826 30.059H22.5885C23.1083 30.059 23.5296 29.6376 23.5296 29.1178V27.7061C23.5296 27.1863 23.1083 26.7649 22.5885 26.7649Z" fill="#EFEDF8"/>
+<path d="M28.2354 31.0002H23.5296C23.0098 31.0002 22.5884 31.4216 22.5884 31.9414V33.3532C22.5884 33.873 23.0098 34.2944 23.5296 34.2944H28.2354C28.7552 34.2944 29.1766 33.873 29.1766 33.3532V31.9414C29.1766 31.4216 28.7552 31.0002 28.2354 31.0002Z" fill="#EFEDF8"/>
+<path d="M26.8241 26.7649H25.4124C24.8926 26.7649 24.4712 27.1863 24.4712 27.7061V29.1178C24.4712 29.6376 24.8926 30.059 25.4124 30.059H26.8241C27.3439 30.059 27.7653 29.6376 27.7653 29.1178V27.7061C27.7653 27.1863 27.3439 26.7649 26.8241 26.7649Z" fill="#EFEDF8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M23.5516 10.3323L36.0222 13.8791C36.4267 13.9942 36.7059 14.3638 36.7059 14.7844V21.1043C36.7059 25.151 35.0909 29.032 32.2166 31.8933L23.9581 40.1143C23.5909 40.4798 22.9973 40.4798 22.6301 40.1143L14.3717 31.8933C11.4972 29.032 9.88232 25.151 9.88232 21.1043V14.7844C9.88232 14.3638 10.1614 13.9942 10.566 13.8791L23.0366 10.3323C23.2049 10.2844 23.3833 10.2844 23.5516 10.3323ZM23.1301 12.4046L11.9603 15.5579C11.7575 15.6151 11.6175 15.8001 11.6175 16.0108V20.6639C11.6175 24.3243 13.0892 27.8348 15.7088 30.4231L22.9272 37.5553C23.1105 37.7364 23.4054 37.7364 23.5887 37.5553L30.807 30.4231C33.4268 27.8348 34.8983 24.3243 34.8983 20.6639V16.0108C34.8983 15.8001 34.7583 15.6151 34.5556 15.5579L23.3858 12.4046C23.3022 12.381 23.2137 12.381 23.1301 12.4046Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3165 11.7436L34.14 14.8206C34.5447 14.9356 34.8238 15.3052 34.8238 15.7259V21.1793C34.8238 24.7274 33.4072 28.1301 30.8859 30.6389L23.723 37.7659C23.3558 38.1312 22.7625 38.1312 22.3953 37.7659L15.2325 30.6389C12.711 28.1301 11.2944 24.7274 11.2944 21.1793V15.7259C11.2944 15.3052 11.5736 14.9356 11.9782 14.8206L22.8018 11.7436C22.97 11.6958 23.1483 11.6958 23.3165 11.7436ZM22.8996 13.5561L13.1593 16.3045C12.9566 16.3617 12.8166 16.5467 12.8166 16.7574V20.7932C12.8166 24.0026 14.1075 27.0805 16.4054 29.3498L22.6968 35.5631C22.88 35.7442 23.1748 35.7442 23.3581 35.5631L29.6494 29.3498C31.9474 27.0805 33.2383 24.0026 33.2383 20.7932V16.7574C33.2383 16.5467 33.0983 16.3617 32.8955 16.3045L23.1552 13.5561C23.0717 13.5325 22.9832 13.5325 22.8996 13.5561Z" fill="#6E49CB"/>
+</svg>
diff --git a/app/assets/images/learn_gitlab/trial_started.svg b/app/assets/images/learn_gitlab/trial_started.svg
new file mode 100644
index 00000000000..42d6fb6c013
--- /dev/null
+++ b/app/assets/images/learn_gitlab/trial_started.svg
@@ -0,0 +1,9 @@
+<svg width="32" height="30" viewBox="0 0 32 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9653 29.8263L21.8368 11.6285H10.0933L15.9653 29.8263Z" fill="#E38800"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9656 29.8263L10.0936 11.6285H1.86475L15.9656 29.8263Z" fill="#F7980A"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M1.86441 11.6285L0.0800968 17.1586C-0.0826524 17.663 0.0955967 18.2156 0.521693 18.5273L15.9652 29.8261L1.86441 11.6285Z" fill="#FCA326"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M1.86426 11.6286H10.0933L6.55678 0.668335C6.37489 0.104294 5.58257 0.104447 5.40067 0.668335L1.86426 11.6286Z" fill="#E38800"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9653 29.8263L21.8369 11.6285H30.0658L15.9653 29.8263Z" fill="#F7980A"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M30.0662 11.6285L31.8505 17.1586C32.0132 17.663 31.835 18.2156 31.4089 18.5273L15.9653 29.8261L30.0662 11.6285Z" fill="#FCA326"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M30.066 11.6286H21.8369L25.3735 0.668335C25.5554 0.104294 26.3477 0.104447 26.5296 0.668335L30.066 11.6286Z" fill="#E38800"/>
+</svg>
diff --git a/app/assets/images/learn_gitlab/user_added.svg b/app/assets/images/learn_gitlab/user_added.svg
new file mode 100644
index 00000000000..efbccff0bbb
--- /dev/null
+++ b/app/assets/images/learn_gitlab/user_added.svg
@@ -0,0 +1,4 @@
+<svg width="38" height="24" viewBox="0 0 38 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M33.6353 7.51765H33.8824L32.4706 5.80941L31.0588 7.50353L29.6471 5.80941L28.2353 7.50353L26.8235 5.80941L25.4118 7.50353L24 5.80941L22.5882 7.51765H23.1318C23.6965 8.89412 24 10.4118 24 12C24 14.0047 23.5059 15.8824 22.6447 17.5482C24.0353 19.1576 26.0541 20.1176 28.2353 20.1176C32.3294 20.1176 35.6471 16.7718 35.6471 12.6353C35.6471 10.6588 34.8847 8.85177 33.6353 7.51765ZM22.0094 5.36471C23.7035 3.88235 25.9059 3.03529 28.2353 3.03529C33.5012 3.03529 37.7647 7.34118 37.7647 12.6353C37.7647 17.9294 33.5012 22.2353 28.2353 22.2353C25.6376 22.2353 23.2235 21.1765 21.4588 19.3835C19.2706 22.1929 15.84 24 12 24C5.36471 24 0 18.6353 0 12C0 5.36471 5.36471 0 12 0C14.2729 0 16.3976 0.635295 18.2118 1.72941C19.7153 2.64706 21.0141 3.88235 22.0094 5.37177V5.36471ZM3.52941 8.47059C3.07059 9.55765 2.82353 10.7506 2.82353 12C2.82353 17.0682 6.93177 21.1765 12 21.1765C17.0682 21.1765 21.1765 17.0682 21.1765 12C21.1765 10.7506 20.9294 9.55765 20.4706 8.47059H14.1176C13.7435 8.47059 13.3835 8.32941 13.1294 8.04706L12 6.94588L10.8706 8.06118C10.6165 8.34353 10.2565 8.48471 9.88235 8.48471H3.52941V8.47059ZM18.6212 5.64706C16.9271 3.88235 14.5553 2.82353 12 2.82353C9.44471 2.82353 7.07294 3.88235 5.37882 5.64706H9.29647L11.0047 3.95294C11.5271 3.38824 12.4165 3.38824 12.9812 3.95294L14.6753 5.64706H18.5859H18.6212Z" fill="#E1DBF2"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M25.1292 14.3435C24.6633 14.3435 24.2821 13.9623 24.2821 13.4964C24.2821 13.0164 24.6633 12.6353 25.1292 12.6353C25.5951 12.6353 25.9762 13.0164 25.9762 13.4823C25.9762 13.9623 25.5951 14.3435 25.1292 14.3435ZM31.3409 14.3435C30.8751 14.3435 30.4939 13.9623 30.4939 13.4964C30.4939 13.0164 30.8751 12.6353 31.3409 12.6353C31.8068 12.6353 32.188 13.0164 32.188 13.4823C32.188 13.9623 31.8068 14.3435 31.3409 14.3435ZM9.17624 15.5294H14.8233C14.8233 17.0823 13.5527 18.3529 11.9998 18.3529C10.4468 18.3529 9.17624 17.0823 9.17624 15.5294ZM8.11742 14.8235C7.53153 14.8235 7.05859 14.3505 7.05859 13.7647C7.05859 13.1788 7.53153 12.7058 8.11742 12.7058C8.7033 12.7058 9.17624 13.1788 9.17624 13.7647C9.17624 14.3505 8.7033 14.8235 8.11742 14.8235ZM15.8821 14.8235C15.2962 14.8235 14.8233 14.3505 14.8233 13.7647C14.8233 13.1788 15.2962 12.7058 15.8821 12.7058C16.468 12.7058 16.9409 13.1788 16.9409 13.7647C16.9409 14.3505 16.468 14.8235 15.8821 14.8235Z" fill="#6B4FBB"/>
+</svg>
diff --git a/app/assets/javascripts/access_tokens/components/projects_field.vue b/app/assets/javascripts/access_tokens/components/projects_field.vue
new file mode 100644
index 00000000000..066cea5e90c
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/projects_field.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui';
+import ProjectsTokenSelector from './projects_token_selector.vue';
+
+export default {
+ name: 'ProjectsField',
+ ALL_PROJECTS: 'ALL_PROJECTS',
+ SELECTED_PROJECTS: 'SELECTED_PROJECTS',
+ components: { GlFormGroup, GlFormRadio, GlFormText, ProjectsTokenSelector },
+ props: {
+ inputAttrs: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedRadio: !this.inputAttrs.value
+ ? this.$options.ALL_PROJECTS
+ : this.$options.SELECTED_PROJECTS,
+ selectedProjects: [],
+ };
+ },
+ computed: {
+ allProjectsRadioSelected() {
+ return this.selectedRadio === this.$options.ALL_PROJECTS;
+ },
+ hiddenInputValue() {
+ return this.allProjectsRadioSelected
+ ? null
+ : this.selectedProjects.map((project) => project.id).join(',');
+ },
+ initialProjectIds() {
+ if (!this.inputAttrs.value) {
+ return [];
+ }
+
+ return this.inputAttrs.value.split(',');
+ },
+ },
+ methods: {
+ handleTokenSelectorFocus() {
+ this.selectedRadio = this.$options.SELECTED_PROJECTS;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-form-group :label="__('Projects')" label-class="gl-pb-0!">
+ <gl-form-text class="gl-pb-3">{{
+ __('Set access permissions for this token.')
+ }}</gl-form-text>
+ <gl-form-radio v-model="selectedRadio" :value="$options.ALL_PROJECTS">{{
+ __('All projects')
+ }}</gl-form-radio>
+ <gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{
+ __('Selected projects')
+ }}</gl-form-radio>
+ <input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" :value="hiddenInputValue" />
+ <projects-token-selector
+ v-model="selectedProjects"
+ :initial-project-ids="initialProjectIds"
+ @focus="handleTokenSelectorFocus"
+ />
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue
new file mode 100644
index 00000000000..a746f62b3a1
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue
@@ -0,0 +1,156 @@
+<script>
+import {
+ GlTokenSelector,
+ GlAvatar,
+ GlAvatarLabeled,
+ GlIntersectionObserver,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import produce from 'immer';
+
+import { convertToGraphQLIds, convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils';
+
+import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
+
+const DEBOUNCE_DELAY = 250;
+const PROJECTS_PER_PAGE = 20;
+const GRAPHQL_ENTITY_TYPE = 'Project';
+
+export default {
+ name: 'ProjectsTokenSelector',
+ components: {
+ GlTokenSelector,
+ GlAvatar,
+ GlAvatarLabeled,
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ },
+ model: {
+ prop: 'selectedProjects',
+ },
+ props: {
+ selectedProjects: {
+ type: Array,
+ required: true,
+ },
+ initialProjectIds: {
+ type: Array,
+ required: true,
+ },
+ },
+ apollo: {
+ projects: {
+ query: getProjectsQuery,
+ debounce: DEBOUNCE_DELAY,
+ variables() {
+ return {
+ search: this.searchQuery,
+ after: null,
+ first: PROJECTS_PER_PAGE,
+ };
+ },
+ update({ projects }) {
+ return {
+ list: convertNodeIdsFromGraphQLIds(projects.nodes),
+ pageInfo: projects.pageInfo,
+ };
+ },
+ result() {
+ this.isLoadingMoreProjects = false;
+ this.isSearching = false;
+ },
+ },
+ initialProjects: {
+ query: getProjectsQuery,
+ variables() {
+ return {
+ ids: convertToGraphQLIds(GRAPHQL_ENTITY_TYPE, this.initialProjectIds),
+ };
+ },
+ manual: true,
+ skip() {
+ return !this.initialProjectIds.length;
+ },
+ result({ data: { projects } }) {
+ this.$emit('input', convertNodeIdsFromGraphQLIds(projects.nodes));
+ },
+ },
+ },
+ data() {
+ return {
+ projects: {
+ list: [],
+ pageInfo: {},
+ },
+ searchQuery: '',
+ isLoadingMoreProjects: false,
+ isSearching: false,
+ };
+ },
+ methods: {
+ handleSearch(query) {
+ this.isSearching = true;
+ this.searchQuery = query;
+ },
+ loadMoreProjects() {
+ this.isLoadingMoreProjects = true;
+
+ this.$apollo.queries.projects.fetchMore({
+ variables: {
+ after: this.projects.pageInfo.endCursor,
+ first: PROJECTS_PER_PAGE,
+ },
+ updateQuery(previousResult, { fetchMoreResult: { projects: newProjects } }) {
+ const { projects: previousProjects } = previousResult;
+
+ return produce(previousResult, (draftData) => {
+ draftData.projects.nodes = [...previousProjects.nodes, ...newProjects.nodes];
+ draftData.projects.pageInfo = newProjects.pageInfo;
+ });
+ },
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-relative">
+ <gl-token-selector
+ :selected-tokens="selectedProjects"
+ :dropdown-items="projects.list"
+ :loading="isSearching"
+ :placeholder="__('Select projects')"
+ menu-class="gl-w-full! gl-max-w-full!"
+ @input="$emit('input', $event)"
+ @focus="$emit('focus', $event)"
+ @text-input="handleSearch"
+ @keydown.enter.prevent
+ >
+ <template #token-content="{ token: project }">
+ <gl-avatar
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :src="project.avatarUrl"
+ :size="16"
+ />
+ {{ project.nameWithNamespace }}
+ </template>
+ <template #dropdown-item-content="{ dropdownItem: project }">
+ <gl-avatar-labeled
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :size="32"
+ :src="project.avatarUrl"
+ :label="project.name"
+ :sub-label="project.nameWithNamespace"
+ />
+ </template>
+ <template #dropdown-footer>
+ <gl-intersection-observer v-if="projects.pageInfo.hasNextPage" @appear="loadMoreProjects">
+ <gl-loading-icon v-if="isLoadingMoreProjects" size="md" />
+ </gl-intersection-observer>
+ </template>
+ </gl-token-selector>
+ </div>
+</template>
diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
new file mode 100644
index 00000000000..60110437ecd
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
@@ -0,0 +1,28 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getProjects(
+ $search: String = ""
+ $after: String = ""
+ $first: Int = null
+ $ids: [ID!] = null
+) {
+ projects(
+ search: $search
+ after: $after
+ first: $first
+ ids: $ids
+ membership: true
+ searchNamespaces: true
+ sort: "UPDATED_ASC"
+ ) {
+ nodes {
+ id
+ name
+ nameWithNamespace
+ avatarUrl
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index b4353af30d5..43d56295f78 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -1,4 +1,7 @@
import Vue from 'vue';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
import ExpiresAtField from './components/expires_at_field.vue';
const getInputAttrs = (el) => {
@@ -7,11 +10,12 @@ const getInputAttrs = (el) => {
return {
id: input.id,
name: input.name,
+ value: input.value,
placeholder: input.placeholder,
};
};
-const initExpiresAtField = () => {
+export const initExpiresAtField = () => {
const el = document.querySelector('.js-access-tokens-expires-at');
if (!el) {
@@ -32,4 +36,58 @@ const initExpiresAtField = () => {
});
};
-export default initExpiresAtField;
+export const initProjectsField = () => {
+ const el = document.querySelector('.js-access-tokens-projects');
+
+ if (!el) {
+ return null;
+ }
+
+ const inputAttrs = getInputAttrs(el);
+
+ if (window.gon.features.personalAccessTokensScopedToProjects) {
+ return new Promise((resolve) => {
+ Promise.all([
+ import('./components/projects_field.vue'),
+ import('vue-apollo'),
+ import('~/lib/graphql'),
+ ])
+ .then(
+ ([
+ { default: ProjectsField },
+ { default: VueApollo },
+ { default: createDefaultClient },
+ ]) => {
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ Vue.use(VueApollo);
+
+ resolve(
+ new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(ProjectsField, {
+ props: {
+ inputAttrs,
+ },
+ });
+ },
+ }),
+ );
+ },
+ )
+ .catch(() => {
+ createFlash({
+ message: __(
+ 'An error occurred while loading the access tokens form, please try again.',
+ ),
+ });
+ });
+ });
+ }
+
+ return null;
+};
diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js b/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js
deleted file mode 100644
index ae73033079d..00000000000
--- a/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// EE-specific feature. Find the implementation in the `ee/`-folder
-export default () => {};
diff --git a/app/assets/javascripts/admin/users/tabs.js b/app/assets/javascripts/admin/users/tabs.js
index 9ada77396c7..cbaab7df4e9 100644
--- a/app/assets/javascripts/admin/users/tabs.js
+++ b/app/assets/javascripts/admin/users/tabs.js
@@ -1,11 +1,20 @@
+import Api from '~/api';
import { historyPushState } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
const COHORTS_PANE = 'cohorts';
+const COHORTS_PANE_TAB_CLICK_EVENT = 'i_analytics_cohorts';
const tabClickHandler = (e) => {
const { hash } = e.currentTarget;
- const tab = hash === `#${COHORTS_PANE}` ? COHORTS_PANE : null;
+
+ let tab = null;
+
+ if (hash === `#${COHORTS_PANE}`) {
+ tab = COHORTS_PANE;
+ Api.trackRedisHllUserEvent(COHORTS_PANE_TAB_CLICK_EVENT);
+ }
+
const newUrl = mergeUrlParams({ tab }, window.location.href);
historyPushState(newUrl);
};
diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue
index dd702c4a5d3..79a6bac3ba7 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -42,6 +42,7 @@ export default {
"AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.",
),
unassigned: __('Unassigned'),
+ closed: __('closed'),
},
fields: [
{
@@ -75,7 +76,7 @@ export default {
{
key: 'issue',
label: s__('AlertManagement|Incident'),
- thClass: 'gl-w-12 gl-pointer-events-none',
+ thClass: 'gl-w-15p gl-pointer-events-none',
tdClass,
},
{
@@ -221,8 +222,11 @@ export default {
hasAssignees(assignees) {
return Boolean(assignees.nodes?.length);
},
- getIssueLink(item) {
- return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid);
+ getIssueMeta({ issue: { iid, state } }) {
+ return {
+ state: state === 'closed' ? `(${this.$options.i18n.closed})` : '',
+ link: joinPaths('/', this.projectPath, '-', 'issues/incident', iid),
+ };
},
tbodyTrClass(item) {
return {
@@ -343,8 +347,14 @@ export default {
</template>
<template #cell(issue)="{ item }">
- <gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)">
- #{{ item.issueIid }}
+ <gl-link
+ v-if="item.issue"
+ v-gl-tooltip
+ :title="item.issue.title"
+ data-testid="issueField"
+ :href="getIssueMeta(item).link"
+ >
+ #{{ item.issue.iid }} {{ getIssueMeta(item).state }}
</gl-link>
<div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div>
</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
index 1135562834a..07b2e59671e 100644
--- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -7,15 +7,12 @@ import {
GlSearchBoxByType,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { cloneDeep } from 'lodash';
+import { cloneDeep, isEqual } from 'lodash';
import Vue from 'vue';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { s__, __ } from '~/locale';
-import {
- getMappingData,
- getPayloadFields,
- transformForSave,
-} from '../utils/mapping_transformations';
+import { mappingFields } from '../constants';
+import { getMappingData, transformForSave } from '../utils/mapping_transformations';
export const i18n = {
columns: {
@@ -33,6 +30,7 @@ export const i18n = {
export default {
i18n,
+ mappingFields,
components: {
GlIcon,
GlFormInput,
@@ -73,18 +71,15 @@ export default {
};
},
computed: {
- payloadFields() {
- return getPayloadFields(this.parsedPayload);
- },
mappingData() {
- return getMappingData(this.gitlabFields, this.payloadFields, this.savedMapping);
+ return getMappingData(this.gitlabFields, this.parsedPayload, this.savedMapping);
},
hasFallbackColumn() {
return this.gitlabFields.some(({ numberOfFallbacks }) => Boolean(numberOfFallbacks));
},
},
methods: {
- setMapping(gitlabKey, mappingKey, valueKey) {
+ setMapping(gitlabKey, mappingKey, valueKey = mappingFields.mapping) {
const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey);
const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } };
Vue.set(this.gitlabFields, fieldIndex, updatedField);
@@ -100,11 +95,11 @@ export default {
return fields.filter((field) => field.label.toLowerCase().includes(search));
},
isSelected(fieldValue, mapping) {
- return fieldValue === mapping;
+ return isEqual(fieldValue, mapping);
},
- selectedValue(name) {
+ selectedValue(mapping) {
return (
- this.payloadFields.find((item) => item.name === name)?.label ||
+ this.parsedPayload.find((item) => isEqual(item.path, mapping))?.label ||
this.$options.i18n.makeSelection
);
},
@@ -150,7 +145,7 @@ export default {
:key="gitlabField.name"
class="gl-display-table-row"
>
- <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle">
+ <div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle">
<gl-form-input
aria-labelledby="gitlabFieldsHeader"
disabled
@@ -164,7 +159,7 @@ export default {
</div>
</div>
- <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle">
+ <div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle">
<gl-dropdown
:disabled="!gitlabField.mappingFields.length"
aria-labelledby="parsedFieldsHeader"
@@ -175,10 +170,10 @@ export default {
<gl-search-box-by-type @input="setSearchTerm($event, 'searchTerm', gitlabField.name)" />
<gl-dropdown-item
v-for="mappingField in filterFields(gitlabField.searchTerm, gitlabField.mappingFields)"
- :key="`${mappingField.name}__mapping`"
- :is-checked="isSelected(gitlabField.mapping, mappingField.name)"
+ :key="`${mappingField.path}__mapping`"
+ :is-checked="isSelected(gitlabField.mapping, mappingField.path)"
is-check-item
- @click="setMapping(gitlabField.name, mappingField.name, 'mapping')"
+ @click="setMapping(gitlabField.name, mappingField.path)"
>
{{ mappingField.label }}
</gl-dropdown-item>
@@ -188,7 +183,7 @@ export default {
</gl-dropdown>
</div>
- <div class="gl-display-table-cell gl-py-3 w-30p">
+ <div class="gl-display-table-cell gl-py-3 gl-w-30p">
<gl-dropdown
v-if="Boolean(gitlabField.numberOfFallbacks)"
:disabled="!gitlabField.mappingFields.length"
@@ -205,10 +200,12 @@ export default {
gitlabField.fallbackSearchTerm,
gitlabField.mappingFields,
)"
- :key="`${mappingField.name}__fallback`"
- :is-checked="isSelected(gitlabField.fallback, mappingField.name)"
+ :key="`${mappingField.path}__fallback`"
+ :is-checked="isSelected(gitlabField.fallback, mappingField.path)"
is-check-item
- @click="setMapping(gitlabField.name, mappingField.name, 'fallback')"
+ @click="
+ setMapping(gitlabField.name, mappingField.path, $options.mappingFields.fallback)
+ "
>
{{ mappingField.label }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
index 6cfb4601192..a5e17d80f86 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -10,6 +10,7 @@ import {
GlTooltipDirective,
GlSprintf,
} from '@gitlab/ui';
+import { capitalize } from 'lodash';
import { s__, __ } from '~/locale';
import Tracking from '~/tracking';
import {
@@ -77,6 +78,7 @@ export default {
{
key: 'type',
label: __('Type'),
+ formatter: (value) => (value === typeSet.prometheus ? capitalize(value) : value),
},
{
key: 'actions',
@@ -120,14 +122,17 @@ export default {
const { category, action } = trackAlertIntegrationsViewsOptions;
Tracking.event(category, action);
},
- setIntegrationToDelete({ name, id }) {
- this.integrationToDelete.id = id;
- this.integrationToDelete.name = name;
+ setIntegrationToDelete(integration) {
+ this.integrationToDelete = integration;
},
deleteIntegration() {
- this.$emit('delete-integration', { id: this.integrationToDelete.id });
+ const { id, type } = this.integrationToDelete;
+ this.$emit('delete-integration', { id, type });
this.integrationToDelete = { ...integrationToDeleteDefault };
},
+ editIntegration({ id, type }) {
+ this.$emit('edit-integration', { id, type });
+ },
},
};
</script>
@@ -169,7 +174,7 @@ export default {
<template #cell(actions)="{ item }">
<gl-button-group class="gl-ml-3">
- <gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" />
+ <gl-button icon="settings" @click="editIntegration(item)" />
<gl-button
v-gl-modal.deleteIntegration
:disabled="item.type === $options.typeSet.prometheus"
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index 18372c54b84..5d9513e5b53 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -1,7 +1,6 @@
<script>
import {
GlButton,
- GlCollapse,
GlForm,
GlFormGroup,
GlFormSelect,
@@ -11,98 +10,39 @@ import {
GlModal,
GlModalDirective,
GlToggle,
+ GlTabs,
+ GlTab,
} from '@gitlab/ui';
-import { s__ } from '~/locale';
+import * as Sentry from '@sentry/browser';
+import { isEmpty, omit } from 'lodash';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
integrationTypes,
+ integrationSteps,
+ createStepNumbers,
+ editStepNumbers,
JSON_VALIDATE_DELAY,
targetPrometheusUrlPlaceholder,
typeSet,
+ viewCredentialsTabIndex,
+ i18n,
} from '../constants';
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
+import parseSamplePayloadQuery from '../graphql/queries/parse_sample_payload.query.graphql';
import MappingBuilder from './alert_mapping_builder.vue';
import AlertSettingsFormHelpBlock from './alert_settings_form_help_block.vue';
-// Mocks will be removed when integrating with BE is ready
-// data format is defined and will be the same as mocked (maybe with some minor changes)
-// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171
-import mockedCustomMapping from './mocks/parsedMapping.json';
-
-export const i18n = {
- integrationFormSteps: {
- step1: {
- label: s__('AlertSettings|1. Select integration type'),
- enterprise: s__(
- 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.',
- ),
- },
- step2: {
- label: s__('AlertSettings|2. Name integration'),
- placeholder: s__('AlertSettings|Enter integration name'),
- prometheus: s__('AlertSettings|Prometheus'),
- },
- step3: {
- label: s__('AlertSettings|3. Set up webhook'),
- help: s__(
- "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
- ),
- prometheusHelp: s__(
- 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.',
- ),
- info: s__('AlertSettings|Authorization key'),
- reset: s__('AlertSettings|Reset Key'),
- },
- step4: {
- label: s__('AlertSettings|4. Sample alert payload (optional)'),
- help: s__(
- 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional), or to test the integration (also optional).',
- ),
- prometheusHelp: s__(
- 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).',
- ),
- placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
- resetHeader: s__('AlertSettings|Reset the mapping'),
- resetBody: s__(
- "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.",
- ),
- resetOk: s__('AlertSettings|Proceed with editing'),
- editPayload: s__('AlertSettings|Edit payload'),
- submitPayload: s__('AlertSettings|Submit payload'),
- payloadParsedSucessMsg: s__(
- 'AlertSettings|Sample payload has been parsed. You can now map the fields.',
- ),
- },
- step5: {
- label: s__('AlertSettings|5. Map fields (optional)'),
- intro: s__(
- "AlertSettings|If you've provided a sample alert payload, you can create a custom mapping for your endpoint. The default GitLab alert keys are listed below. Please define which payload key should map to the specified GitLab key.",
- ),
- },
- prometheusFormUrl: {
- label: s__('AlertSettings|Prometheus API base URL'),
- help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
- },
- restKeyInfo: {
- label: s__(
- 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
- ),
- },
- },
-};
export default {
- integrationTypes,
placeholders: {
prometheus: targetPrometheusUrlPlaceholder,
},
JSON_VALIDATE_DELAY,
typeSet,
+ integrationSteps,
i18n,
components: {
ClipboardButton,
GlButton,
- GlCollapse,
GlForm,
GlFormGroup,
GlFormInput,
@@ -111,13 +51,14 @@ export default {
GlFormSelect,
GlModal,
GlToggle,
+ GlTabs,
+ GlTab,
AlertSettingsFormHelpBlock,
MappingBuilder,
},
directives: {
GlModal: GlModalDirective,
},
- mixins: [glFeatureFlagsMixin()],
inject: {
generic: {
default: {},
@@ -128,6 +69,9 @@ export default {
multiIntegrations: {
default: false,
},
+ projectPath: {
+ default: '',
+ },
},
props: {
loading: {
@@ -151,26 +95,40 @@ export default {
},
data() {
return {
- selectedIntegration: integrationTypes[0].value,
+ integrationTypesOptions: Object.values(integrationTypes),
+ selectedIntegration: integrationTypes.none.value,
active: false,
- formVisible: false,
- integrationTestPayload: {
+ samplePayload: {
json: null,
error: null,
},
- resetSamplePayloadConfirmed: false,
- customMapping: null,
+ testPayload: {
+ json: null,
+ error: null,
+ },
+ resetPayloadAndMappingConfirmed: false,
mapping: [],
parsingPayload: false,
currentIntegration: null,
+ parsedPayload: [],
+ activeTabIndex: 0,
};
},
computed: {
isPrometheus() {
return this.selectedIntegration === this.$options.typeSet.prometheus;
},
- jsonIsValid() {
- return this.integrationTestPayload.error === null;
+ isHttp() {
+ return this.selectedIntegration === this.$options.typeSet.http;
+ },
+ isCreating() {
+ return !this.currentIntegration;
+ },
+ isSampePayloadValid() {
+ return this.samplePayload.error === null;
+ },
+ isTestPayloadValid() {
+ return this.testPayload.error === null;
},
selectedIntegrationType() {
switch (this.selectedIntegration) {
@@ -197,90 +155,88 @@ export default {
},
testAlertPayload() {
return {
- data: this.integrationTestPayload.json,
+ data: this.testPayload.json,
endpoint: this.integrationForm.url,
token: this.integrationForm.token,
};
},
showMappingBuilder() {
- return (
- this.multiIntegrations &&
- this.glFeatures.multipleHttpIntegrationsCustomMapping &&
- this.selectedIntegration === typeSet.http &&
- this.alertFields?.length
- );
- },
- parsedSamplePayload() {
- return this.customMapping?.samplePayload?.payloadAlerFields?.nodes;
- },
- savedMapping() {
- return this.customMapping?.storedMapping?.nodes;
+ return this.multiIntegrations && this.isHttp && this.alertFields?.length;
},
hasSamplePayload() {
- return Boolean(this.customMapping?.samplePayload);
+ return this.isValidNonEmptyJSON(this.currentIntegration?.payloadExample);
},
canEditPayload() {
- return this.hasSamplePayload && !this.resetSamplePayloadConfirmed;
+ return this.hasSamplePayload && !this.resetPayloadAndMappingConfirmed;
+ },
+ canParseSamplePayload() {
+ return !this.active || !this.isSampePayloadValid || !this.samplePayload.json;
},
isResetAuthKeyDisabled() {
return !this.active && !this.integrationForm.token !== '';
},
isPayloadEditDisabled() {
- return this.glFeatures.multipleHttpIntegrationsCustomMapping
- ? !this.active || this.canEditPayload
- : !this.active;
- },
- isSubmitTestPayloadDisabled() {
- return (
- !this.active ||
- Boolean(this.integrationTestPayload.error) ||
- this.integrationTestPayload.json === ''
- );
+ return !this.active || this.canEditPayload;
},
isSelectDisabled() {
return this.currentIntegration !== null || !this.canAddIntegration;
},
+ viewCredentialsHelpMsg() {
+ return this.isPrometheus
+ ? i18n.integrationFormSteps.setupCredentials.prometheusHelp
+ : i18n.integrationFormSteps.setupCredentials.help;
+ },
},
watch: {
currentIntegration(val) {
if (val === null) {
- return this.reset();
+ this.reset();
+ return;
+ }
+ const { type, active, payloadExample, payloadAlertFields, payloadAttributeMappings } = val;
+ this.selectedIntegration = type;
+ this.active = active;
+
+ if (type === typeSet.http && this.showMappingBuilder) {
+ this.parsedPayload = payloadAlertFields;
+ this.samplePayload.json = this.isValidNonEmptyJSON(payloadExample) ? payloadExample : null;
+ const mapping = payloadAttributeMappings.map((mappingItem) =>
+ omit(mappingItem, '__typename'),
+ );
+ this.updateMapping(mapping);
}
- this.selectedIntegration = val.type;
- this.active = val.active;
- if (val.type === typeSet.http && this.showMappingBuilder) this.getIntegrationMapping(val.id);
- return this.integrationTypeSelect();
+ this.activeTabIndex = viewCredentialsTabIndex;
+ this.$el.scrollIntoView({ block: 'center' });
},
},
methods: {
- integrationTypeSelect() {
- if (this.selectedIntegration === integrationTypes[0].value) {
- this.formVisible = false;
- } else {
- this.formVisible = true;
+ isValidNonEmptyJSON(JSONString) {
+ if (JSONString) {
+ let parsed;
+ try {
+ parsed = JSON.parse(JSONString);
+ } catch (error) {
+ Sentry.captureException(error);
+ }
+ if (parsed) return !isEmpty(parsed);
}
+ return false;
},
- submitWithTestPayload() {
- this.$emit('set-test-alert-payload', this.testAlertPayload);
- this.submit();
+ sendTestAlert() {
+ this.$emit('test-alert-payload', this.testAlertPayload);
},
submit() {
const { name, apiUrl } = this.integrationForm;
- const customMappingVariables = this.glFeatures.multipleHttpIntegrationsCustomMapping
- ? {
- payloadAttributeMappings: this.mapping,
- payloadExample: this.integrationTestPayload.json,
- }
- : {};
+ const customMappingVariables = {
+ payloadAttributeMappings: this.mapping,
+ payloadExample: this.samplePayload.json || '{}',
+ };
const variables =
this.selectedIntegration === typeSet.http
- ? {
- name,
- active: this.active,
- ...customMappingVariables,
- }
+ ? { name, active: this.active, ...customMappingVariables }
: { apiUrl, active: this.active };
+
const integrationPayload = { type: this.selectedIntegration, variables };
if (this.currentIntegration) {
@@ -291,19 +247,15 @@ export default {
return this.$emit('create-new-integration', integrationPayload);
},
reset() {
- this.selectedIntegration = integrationTypes[0].value;
- this.integrationTypeSelect();
-
- if (this.currentIntegration) {
- return this.$emit('clear-current-integration');
- }
-
- return this.resetFormValues();
+ this.resetFormValues();
+ this.resetPayloadAndMapping();
+ this.$emit('clear-current-integration', { type: this.currentIntegration?.type });
},
resetFormValues() {
+ this.selectedIntegration = integrationTypes.none.value;
this.integrationForm.name = '';
this.integrationForm.apiUrl = '';
- this.integrationTestPayload = {
+ this.samplePayload = {
json: null,
error: null,
};
@@ -319,117 +271,135 @@ export default {
variables: { id: this.currentIntegration.id },
});
},
- validateJson() {
- this.integrationTestPayload.error = null;
- if (this.integrationTestPayload.json === '') {
+ validateJson(isSamplePayload = true) {
+ const payload = isSamplePayload ? this.samplePayload : this.testPayload;
+
+ payload.error = null;
+ if (payload.json === '') {
return;
}
try {
- JSON.parse(this.integrationTestPayload.json);
+ JSON.parse(payload.json);
} catch (e) {
- this.integrationTestPayload.error = JSON.stringify(e.message);
+ payload.error = JSON.stringify(e.message);
}
},
parseMapping() {
- // TODO: replace with real BE mutation when ready;
this.parsingPayload = true;
- return new Promise((resolve) => {
- setTimeout(() => resolve(mockedCustomMapping), 1000);
- })
- .then((res) => {
- const mapping = { ...res };
- delete mapping.storedMapping;
- this.customMapping = res;
- this.integrationTestPayload.json = res?.samplePayload.body;
- this.resetSamplePayloadConfirmed = false;
-
- this.$toast.show(this.$options.i18n.integrationFormSteps.step4.payloadParsedSucessMsg);
+ return this.$apollo
+ .query({
+ query: parseSamplePayloadQuery,
+ variables: { projectPath: this.projectPath, payload: this.samplePayload.json },
+ })
+ .then(
+ ({
+ data: {
+ project: { alertManagementPayloadFields },
+ },
+ }) => {
+ this.parsedPayload = alertManagementPayloadFields;
+ this.resetPayloadAndMappingConfirmed = false;
+
+ this.$toast.show(
+ this.$options.i18n.integrationFormSteps.setSamplePayload.payloadParsedSucessMsg,
+ );
+ },
+ )
+ .catch(({ message }) => {
+ this.samplePayload.error = message;
})
.finally(() => {
this.parsingPayload = false;
});
},
- getIntegrationMapping() {
- // TODO: replace with real BE mutation when ready;
- return Promise.resolve(mockedCustomMapping).then((res) => {
- this.customMapping = res;
- this.integrationTestPayload.json = res?.samplePayload.body;
- });
- },
updateMapping(mapping) {
this.mapping = mapping;
},
+ resetPayloadAndMapping() {
+ this.resetPayloadAndMappingConfirmed = true;
+ this.parsedPayload = [];
+ this.updateMapping([]);
+ },
+ getLabelWithStepNumber(step, label) {
+ let stepNumber = editStepNumbers[step];
+
+ if (this.isCreating) {
+ stepNumber = createStepNumbers[step];
+ }
+
+ return stepNumber ? `${stepNumber}.${label}` : label;
+ },
},
};
</script>
<template>
<gl-form class="gl-mt-6" @submit.prevent="submit" @reset.prevent="reset">
- <h5 class="gl-font-lg gl-my-5">{{ s__('AlertSettings|Add new integrations') }}</h5>
- <gl-form-group
- id="integration-type"
- :label="$options.i18n.integrationFormSteps.step1.label"
- label-for="integration-type"
- >
- <gl-form-select
- v-model="selectedIntegration"
- :disabled="isSelectDisabled"
- class="mw-100"
- :options="$options.integrationTypes"
- @change="integrationTypeSelect"
- />
-
- <div v-if="!canAddIntegration" class="gl-my-4" data-testid="multi-integrations-not-supported">
- <alert-settings-form-help-block
- :message="$options.i18n.integrationFormSteps.step1.enterprise"
- link="https://about.gitlab.com/pricing"
- />
- </div>
- </gl-form-group>
- <gl-collapse v-model="formVisible" class="gl-mt-3">
- <div>
+ <gl-tabs v-model="activeTabIndex">
+ <gl-tab :title="$options.i18n.integrationTabs.configureDetails">
<gl-form-group
- id="name-integration"
- :label="$options.i18n.integrationFormSteps.step2.label"
- label-for="name-integration"
+ v-if="isCreating"
+ id="integration-type"
+ :label="
+ getLabelWithStepNumber(
+ $options.integrationSteps.selectType,
+ $options.i18n.integrationFormSteps.selectType.label,
+ )
+ "
+ label-for="integration-type"
>
- <gl-form-input
- v-model="integrationForm.name"
- :disabled="isPrometheus"
- type="text"
- :placeholder="
- isPrometheus
- ? $options.i18n.integrationFormSteps.step2.prometheus
- : $options.i18n.integrationFormSteps.step2.placeholder
- "
+ <gl-form-select
+ v-model="selectedIntegration"
+ :disabled="isSelectDisabled"
+ class="gl-max-w-full"
+ :options="integrationTypesOptions"
/>
- </gl-form-group>
- <gl-form-group
- id="integration-webhook"
- :label="$options.i18n.integrationFormSteps.step3.label"
- label-for="integration-webhook"
- >
+
<alert-settings-form-help-block
- :message="
- isPrometheus
- ? $options.i18n.integrationFormSteps.step3.prometheusHelp
- : $options.i18n.integrationFormSteps.step3.help
- "
- link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html"
+ v-if="!canAddIntegration"
+ disabled="true"
+ class="gl-display-inline-block gl-my-4"
+ :message="$options.i18n.integrationFormSteps.selectType.enterprise"
+ link="https://about.gitlab.com/pricing"
+ data-testid="multi-integrations-not-supported"
/>
+ </gl-form-group>
+ <div class="gl-mt-3">
+ <gl-form-group
+ v-if="isHttp"
+ id="name-integration"
+ :label="
+ getLabelWithStepNumber(
+ $options.integrationSteps.nameIntegration,
+ $options.i18n.integrationFormSteps.nameIntegration.label,
+ )
+ "
+ label-for="name-integration"
+ >
+ <gl-form-input
+ v-model="integrationForm.name"
+ type="text"
+ :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder"
+ />
+ </gl-form-group>
<gl-toggle
v-model="active"
:is-loading="loading"
- :label="__('Active')"
+ :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle"
class="gl-my-4 gl-font-weight-normal"
/>
<div v-if="isPrometheus" class="gl-my-4">
<span class="gl-font-weight-bold">
- {{ $options.i18n.integrationFormSteps.prometheusFormUrl.label }}
+ {{
+ getLabelWithStepNumber(
+ $options.integrationSteps.setPrometheusApiUrl,
+ $options.i18n.integrationFormSteps.prometheusFormUrl.label,
+ )
+ }}
</span>
<gl-form-input
@@ -444,16 +414,123 @@ export default {
</span>
</div>
+ <template v-if="showMappingBuilder">
+ <gl-form-group
+ data-testid="sample-payload-section"
+ :label="
+ getLabelWithStepNumber(
+ $options.integrationSteps.setSamplePayload,
+ $options.i18n.integrationFormSteps.setSamplePayload.label,
+ )
+ "
+ label-for="sample-payload"
+ class="gl-mb-0!"
+ :invalid-feedback="samplePayload.error"
+ >
+ <alert-settings-form-help-block
+ :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelpHttp"
+ :link="generic.alertsUsageUrl"
+ />
+
+ <gl-form-textarea
+ id="sample-payload"
+ v-model.trim="samplePayload.json"
+ :disabled="isPayloadEditDisabled"
+ :state="isSampePayloadValid"
+ :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder"
+ class="gl-my-3"
+ :debounce="$options.JSON_VALIDATE_DELAY"
+ rows="6"
+ max-rows="10"
+ @input="validateJson"
+ />
+ </gl-form-group>
+
+ <gl-button
+ v-if="canEditPayload"
+ v-gl-modal.resetPayloadModal
+ data-testid="payload-action-btn"
+ :disabled="!active"
+ class="gl-mt-3"
+ >
+ {{ $options.i18n.integrationFormSteps.setSamplePayload.editPayload }}
+ </gl-button>
+
+ <gl-button
+ v-else
+ data-testid="payload-action-btn"
+ :class="{ 'gl-mt-3': samplePayload.error }"
+ :disabled="canParseSamplePayload"
+ :loading="parsingPayload"
+ @click="parseMapping"
+ >
+ {{ $options.i18n.integrationFormSteps.setSamplePayload.parsePayload }}
+ </gl-button>
+ <gl-modal
+ modal-id="resetPayloadModal"
+ :title="$options.i18n.integrationFormSteps.setSamplePayload.resetHeader"
+ :ok-title="$options.i18n.integrationFormSteps.setSamplePayload.resetOk"
+ ok-variant="danger"
+ @ok="resetPayloadAndMapping"
+ >
+ {{ $options.i18n.integrationFormSteps.setSamplePayload.resetBody }}
+ </gl-modal>
+
+ <gl-form-group
+ id="mapping-builder"
+ class="gl-mt-5"
+ :label="
+ getLabelWithStepNumber(
+ $options.integrationSteps.customizeMapping,
+ $options.i18n.integrationFormSteps.mapFields.label,
+ )
+ "
+ label-for="mapping-builder"
+ >
+ <span>{{ $options.i18n.integrationFormSteps.mapFields.intro }}</span>
+ <mapping-builder
+ :parsed-payload="parsedPayload"
+ :saved-mapping="mapping"
+ :alert-fields="alertFields"
+ @onMappingUpdate="updateMapping"
+ />
+ </gl-form-group>
+ </template>
+ </div>
+
+ <div class="gl-display-flex gl-justify-content-start gl-py-3">
+ <gl-button
+ type="submit"
+ variant="confirm"
+ class="js-no-auto-disable"
+ data-testid="integration-form-submit"
+ >
+ {{ $options.i18n.saveIntegration }}
+ </gl-button>
+
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
+ $options.i18n.cancelAndClose
+ }}</gl-button>
+ </div>
+ </gl-tab>
+
+ <gl-tab :title="$options.i18n.integrationTabs.viewCredentials" :disabled="isCreating">
+ <alert-settings-form-help-block
+ :message="viewCredentialsHelpMsg"
+ link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html"
+ />
+
+ <gl-form-group id="integration-webhook">
<div class="gl-my-4">
<span class="gl-font-weight-bold">
- {{ s__('AlertSettings|Webhook URL') }}
+ {{ $options.i18n.integrationFormSteps.setupCredentials.webhookUrl }}
</span>
<gl-form-input-group id="url" readonly :value="integrationForm.url">
<template #append>
<clipboard-button
:text="integrationForm.url || ''"
- :title="__('Copy')"
+ :title="$options.i18n.copy"
class="gl-m-0!"
/>
</template>
@@ -462,7 +539,7 @@ export default {
<div class="gl-my-4">
<span class="gl-font-weight-bold">
- {{ $options.i18n.integrationFormSteps.step3.info }}
+ {{ $options.i18n.integrationFormSteps.setupCredentials.authorizationKey }}
</span>
<gl-form-input-group
@@ -474,124 +551,67 @@ export default {
<template #append>
<clipboard-button
:text="integrationForm.token || ''"
- :title="__('Copy')"
+ :title="$options.i18n.copy"
class="gl-m-0!"
/>
</template>
</gl-form-input-group>
-
- <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled">
- {{ $options.i18n.integrationFormSteps.step3.reset }}
- </gl-button>
- <gl-modal
- modal-id="authKeyModal"
- :title="$options.i18n.integrationFormSteps.step3.reset"
- :ok-title="$options.i18n.integrationFormSteps.step3.reset"
- ok-variant="danger"
- @ok="resetAuthKey"
- >
- {{ $options.i18n.integrationFormSteps.restKeyInfo.label }}
- </gl-modal>
</div>
</gl-form-group>
- <gl-form-group
- id="test-integration"
- :label="$options.i18n.integrationFormSteps.step4.label"
- label-for="test-integration"
- :class="{ 'gl-mb-0!': showMappingBuilder }"
- :invalid-feedback="integrationTestPayload.error"
+ <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled" variant="danger">
+ {{ $options.i18n.integrationFormSteps.setupCredentials.reset }}
+ </gl-button>
+
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
+ $options.i18n.cancelAndClose
+ }}</gl-button>
+
+ <gl-modal
+ modal-id="authKeyModal"
+ :title="$options.i18n.integrationFormSteps.setupCredentials.reset"
+ :ok-title="$options.i18n.integrationFormSteps.setupCredentials.reset"
+ ok-variant="danger"
+ @ok="resetAuthKey"
>
+ {{ $options.i18n.integrationFormSteps.restKeyInfo.label }}
+ </gl-modal>
+ </gl-tab>
+
+ <gl-tab :title="$options.i18n.integrationTabs.sendTestAlert" :disabled="isCreating">
+ <gl-form-group id="test-integration" :invalid-feedback="testPayload.error">
<alert-settings-form-help-block
- :message="
- isPrometheus || !showMappingBuilder
- ? $options.i18n.integrationFormSteps.step4.prometheusHelp
- : $options.i18n.integrationFormSteps.step4.help
- "
+ :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelp"
:link="generic.alertsUsageUrl"
/>
<gl-form-textarea
id="test-payload"
- v-model.trim="integrationTestPayload.json"
- :disabled="isPayloadEditDisabled"
- :state="jsonIsValid"
- :placeholder="$options.i18n.integrationFormSteps.step4.placeholder"
+ v-model.trim="testPayload.json"
+ :state="isTestPayloadValid"
+ :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder"
class="gl-my-3"
:debounce="$options.JSON_VALIDATE_DELAY"
rows="6"
max-rows="10"
- @input="validateJson"
+ @input="validateJson(false)"
/>
</gl-form-group>
- <template v-if="showMappingBuilder">
- <gl-button
- v-if="canEditPayload"
- v-gl-modal.resetPayloadModal
- data-testid="payload-action-btn"
- :disabled="!active"
- class="gl-mt-3"
- >
- {{ $options.i18n.integrationFormSteps.step4.editPayload }}
- </gl-button>
-
- <gl-button
- v-else
- data-testid="payload-action-btn"
- :class="{ 'gl-mt-3': integrationTestPayload.error }"
- :disabled="!active"
- :loading="parsingPayload"
- @click="parseMapping"
- >
- {{ $options.i18n.integrationFormSteps.step4.submitPayload }}
- </gl-button>
- <gl-modal
- modal-id="resetPayloadModal"
- :title="$options.i18n.integrationFormSteps.step4.resetHeader"
- :ok-title="$options.i18n.integrationFormSteps.step4.resetOk"
- ok-variant="danger"
- @ok="resetSamplePayloadConfirmed = true"
- >
- {{ $options.i18n.integrationFormSteps.step4.resetBody }}
- </gl-modal>
- </template>
-
- <gl-form-group
- v-if="showMappingBuilder"
- id="mapping-builder"
- class="gl-mt-5"
- :label="$options.i18n.integrationFormSteps.step5.label"
- label-for="mapping-builder"
- >
- <span>{{ $options.i18n.integrationFormSteps.step5.intro }}</span>
- <mapping-builder
- :parsed-payload="parsedSamplePayload"
- :saved-mapping="savedMapping"
- :alert-fields="alertFields"
- @onMappingUpdate="updateMapping"
- />
- </gl-form-group>
- </div>
- <div class="gl-display-flex gl-justify-content-start gl-py-3">
<gl-button
- type="submit"
- variant="success"
+ :disabled="!isTestPayloadValid"
+ data-testid="send-test-alert"
+ variant="confirm"
class="js-no-auto-disable"
- data-testid="integration-form-submit"
- >{{ s__('AlertSettings|Save integration') }}
- </gl-button>
- <gl-button
- data-testid="integration-test-and-submit"
- :disabled="isSubmitTestPayloadDisabled"
- category="secondary"
- variant="success"
- class="gl-mx-3 js-no-auto-disable"
- @click="submitWithTestPayload"
- >{{ s__('AlertSettings|Save and test payload') }}</gl-button
+ @click="sendTestAlert"
>
- <gl-button type="reset" class="js-no-auto-disable">{{ __('Cancel') }}</gl-button>
- </div>
- </gl-collapse>
+ {{ $options.i18n.send }}
+ </gl-button>
+
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
+ $options.i18n.cancelAndClose
+ }}</gl-button>
+ </gl-tab>
+ </gl-tabs>
</gl-form>
</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
index 366f2209fb2..3ffb652e61b 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -1,22 +1,26 @@
<script>
+import { GlButton } from '@gitlab/ui';
+import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
+import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { s__ } from '~/locale';
import { typeSet } from '../constants';
-import createHttpIntegrationMutation from '../graphql/mutations/create_http_integration.mutation.graphql';
import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql';
import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql';
import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql';
import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql';
-import updateCurrentIntergrationMutation from '../graphql/mutations/update_current_intergration.mutation.graphql';
-import updateHttpIntegrationMutation from '../graphql/mutations/update_http_integration.mutation.graphql';
+import updateCurrentHttpIntegrationMutation from '../graphql/mutations/update_current_http_integration.mutation.graphql';
+import updateCurrentPrometheusIntegrationMutation from '../graphql/mutations/update_current_prometheus_integration.mutation.graphql';
import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql';
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
+import getHttpIntegrationsQuery from '../graphql/queries/get_http_integrations.query.graphql';
import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql';
import service from '../services';
import {
updateStoreAfterIntegrationDelete,
updateStoreAfterIntegrationAdd,
+ updateStoreAfterHttpIntegrationAdd,
} from '../utils/cache_updates';
import {
DELETE_INTEGRATION_ERROR,
@@ -28,20 +32,24 @@ import {
import IntegrationsList from './alerts_integrations_list.vue';
import AlertSettingsForm from './alerts_settings_form.vue';
+export const i18n = {
+ changesSaved: s__(
+ 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.',
+ ),
+ integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'),
+ alertSent: s__(
+ 'AlertsIntegrations|The test alert has been successfully sent, and should now be visible on your alerts list.',
+ ),
+ addNewIntegration: s__('AlertSettings|Add new integration'),
+};
+
export default {
typeSet,
- i18n: {
- changesSaved: s__(
- 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.',
- ),
- integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'),
- alertSent: s__(
- 'AlertsIntegrations|The test alert has been successfully sent, and should now be visible on your alerts list.',
- ),
- },
+ i18n,
components: {
IntegrationsList,
AlertSettingsForm,
+ GlButton,
},
inject: {
generic: {
@@ -84,6 +92,28 @@ export default {
createFlash({ message: err });
},
},
+ // TODO: we'll need to update the logic to request specific http integration by its id on edit
+ // when BE adds support for it https://gitlab.com/gitlab-org/gitlab/-/issues/321674
+ // currently the request for ALL http integrations is made and on specific integration edit we search it in the list
+ httpIntegrations: {
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ query: getHttpIntegrationsQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ const { alertManagementHttpIntegrations: { nodes: list = [] } = {} } = data.project || {};
+
+ return {
+ list,
+ };
+ },
+ error(err) {
+ createFlash({ message: err });
+ },
+ },
currentIntegration: {
query: getCurrentIntegrationQuery,
},
@@ -91,9 +121,10 @@ export default {
data() {
return {
isUpdating: false,
- testAlertPayload: null,
integrations: {},
+ httpIntegrations: {},
currentIntegration: null,
+ formVisible: false,
};
},
computed: {
@@ -105,22 +136,28 @@ export default {
},
},
methods: {
+ isHttp(type) {
+ return type === typeSet.http;
+ },
createNewIntegration({ type, variables }) {
const { projectPath } = this;
+ const isHttp = this.isHttp(type);
this.isUpdating = true;
this.$apollo
.mutate({
- mutation:
- type === this.$options.typeSet.http
- ? createHttpIntegrationMutation
- : createPrometheusIntegrationMutation,
+ mutation: isHttp ? createHttpIntegrationMutation : createPrometheusIntegrationMutation,
variables: {
...variables,
projectPath,
},
update(store, { data }) {
updateStoreAfterIntegrationAdd(store, getIntegrationsQuery, data, { projectPath });
+ if (isHttp) {
+ updateStoreAfterHttpIntegrationAdd(store, getHttpIntegrationsQuery, data, {
+ projectPath,
+ });
+ }
},
})
.then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => {
@@ -128,18 +165,9 @@ export default {
if (error) {
return createFlash({ message: error });
}
+ const { integration } = httpIntegrationCreate || prometheusIntegrationCreate;
- if (this.testAlertPayload) {
- const integration =
- httpIntegrationCreate?.integration || prometheusIntegrationCreate?.integration;
-
- const payload = {
- ...this.testAlertPayload,
- endpoint: integration.url,
- token: integration.token,
- };
- return this.validateAlertPayload(payload);
- }
+ this.editIntegration(integration);
return createFlash({
message: this.$options.i18n.changesSaved,
@@ -157,10 +185,9 @@ export default {
this.isUpdating = true;
this.$apollo
.mutate({
- mutation:
- type === this.$options.typeSet.http
- ? updateHttpIntegrationMutation
- : updatePrometheusIntegrationMutation,
+ mutation: this.isHttp(type)
+ ? updateHttpIntegrationMutation
+ : updatePrometheusIntegrationMutation,
variables: {
...variables,
id: this.currentIntegration.id,
@@ -172,11 +199,7 @@ export default {
return createFlash({ message: error });
}
- if (this.testAlertPayload) {
- return this.validateAlertPayload();
- }
-
- this.clearCurrentIntegration();
+ this.clearCurrentIntegration({ type });
return createFlash({
message: this.$options.i18n.changesSaved,
@@ -188,23 +211,19 @@ export default {
})
.finally(() => {
this.isUpdating = false;
- this.testAlertPayload = null;
});
},
resetToken({ type, variables }) {
this.isUpdating = true;
this.$apollo
.mutate({
- mutation:
- type === this.$options.typeSet.http
- ? resetHttpTokenMutation
- : resetPrometheusTokenMutation,
+ mutation: this.isHttp(type) ? resetHttpTokenMutation : resetPrometheusTokenMutation,
variables,
})
.then(
({ data: { httpIntegrationResetToken, prometheusIntegrationResetToken } = {} } = {}) => {
- const error =
- httpIntegrationResetToken?.errors[0] || prometheusIntegrationResetToken?.errors[0];
+ const [error] =
+ httpIntegrationResetToken?.errors || prometheusIntegrationResetToken?.errors;
if (error) {
return createFlash({ message: error });
}
@@ -214,10 +233,10 @@ export default {
prometheusIntegrationResetToken?.integration;
this.$apollo.mutate({
- mutation: updateCurrentIntergrationMutation,
- variables: {
- ...integration,
- },
+ mutation: this.isHttp(type)
+ ? updateCurrentHttpIntegrationMutation
+ : updateCurrentPrometheusIntegrationMutation,
+ variables: integration,
});
return createFlash({
@@ -233,33 +252,31 @@ export default {
this.isUpdating = false;
});
},
- editIntegration({ id }) {
- const currentIntegration = this.integrations.list.find(
- (integration) => integration.id === id,
- );
+ editIntegration({ id, type }) {
+ let currentIntegration = this.integrations.list.find((integration) => integration.id === id);
+ if (this.isHttp(type)) {
+ const httpIntegrationMappingData = this.httpIntegrations.list.find(
+ (integration) => integration.id === id,
+ );
+ currentIntegration = { ...currentIntegration, ...httpIntegrationMappingData };
+ }
+
this.$apollo.mutate({
- mutation: updateCurrentIntergrationMutation,
- variables: {
- id: currentIntegration.id,
- name: currentIntegration.name,
- active: currentIntegration.active,
- token: currentIntegration.token,
- type: currentIntegration.type,
- url: currentIntegration.url,
- apiUrl: currentIntegration.apiUrl,
- },
+ mutation: this.isHttp(type)
+ ? updateCurrentHttpIntegrationMutation
+ : updateCurrentPrometheusIntegrationMutation,
+ variables: currentIntegration,
});
+ this.setFormVisibility(true);
},
- deleteIntegration({ id }) {
+ deleteIntegration({ id, type }) {
const { projectPath } = this;
this.isUpdating = true;
this.$apollo
.mutate({
mutation: destroyHttpIntegrationMutation,
- variables: {
- id,
- },
+ variables: { id },
update(store, { data }) {
updateStoreAfterIntegrationDelete(store, getIntegrationsQuery, data, { projectPath });
},
@@ -269,7 +286,7 @@ export default {
if (error) {
return createFlash({ message: error });
}
- this.clearCurrentIntegration();
+ this.clearCurrentIntegration({ type });
return createFlash({
message: this.$options.i18n.integrationRemoved,
type: FLASH_TYPES.SUCCESS,
@@ -282,18 +299,20 @@ export default {
this.isUpdating = false;
});
},
- clearCurrentIntegration() {
- this.$apollo.mutate({
- mutation: updateCurrentIntergrationMutation,
- variables: {},
- });
- },
- setTestAlertPayload(payload) {
- this.testAlertPayload = payload;
+ clearCurrentIntegration({ type }) {
+ if (type) {
+ this.$apollo.mutate({
+ mutation: this.isHttp(type)
+ ? updateCurrentHttpIntegrationMutation
+ : updateCurrentPrometheusIntegrationMutation,
+ variables: {},
+ });
+ }
+ this.setFormVisibility(false);
},
- validateAlertPayload(payload) {
+ testAlertPayload(payload) {
return service
- .updateTestAlert(payload ?? this.testAlertPayload)
+ .updateTestAlert(payload)
.then(() => {
return createFlash({
message: this.$options.i18n.alertSent,
@@ -304,6 +323,9 @@ export default {
createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR });
});
},
+ setFormVisibility(visible) {
+ this.formVisible = visible;
+ },
},
};
</script>
@@ -316,7 +338,18 @@ export default {
@edit-integration="editIntegration"
@delete-integration="deleteIntegration"
/>
+ <gl-button
+ v-if="canAddIntegration && !formVisible"
+ category="secondary"
+ variant="confirm"
+ data-testid="add-integration-btn"
+ class="gl-mt-3"
+ @click="setFormVisibility(true)"
+ >
+ {{ $options.i18n.addNewIntegration }}
+ </gl-button>
<alert-settings-form
+ v-if="formVisible"
:loading="isUpdating"
:can-add-integration="canAddIntegration"
:alert-fields="alertFields"
@@ -324,7 +357,7 @@ export default {
@update-integration="updateIntegration"
@reset-token="resetToken"
@clear-current-integration="clearCurrentIntegration"
- @set-test-alert-payload="setTestAlertPayload"
+ @test-alert-payload="testAlertPayload"
/>
</div>
</template>
diff --git a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json
deleted file mode 100644
index 80fbebf2a60..00000000000
--- a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json
+++ /dev/null
@@ -1,95 +0,0 @@
-{
- "samplePayload": {
- "body": "{\n \"dashboardId\":1,\n \"evalMatches\":[\n {\n \"value\":1,\n \"metric\":\"Count\",\n \"tags\":{}\n }\n ],\n \"imageUrl\":\"https://grafana.com/static/assets/img/blog/mixed_styles.png\",\n \"message\":\"Notification Message\",\n \"orgId\":1,\n \"panelId\":2,\n \"ruleId\":1,\n \"ruleName\":\"Panel Title alert\",\n \"ruleUrl\":\"http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\\u0026edit\\u0026tab=alert\\u0026panelId=2\\u0026orgId=1\",\n \"state\":\"alerting\",\n \"tags\":{\n \"tag name\":\"tag value\"\n },\n \"title\":\"[Alerting] Panel Title alert\"\n}\n",
- "payloadAlerFields": {
- "nodes": [
- {
- "path": ["dashboardId"],
- "label": "Dashboard Id",
- "type": "string"
- },
- {
- "path": ["evalMatches"],
- "label": "Eval Matches",
- "type": "array"
- },
- {
- "path": ["createdAt"],
- "label": "Created At",
- "type": "datetime"
- },
- {
- "path": ["imageUrl"],
- "label": "Image Url",
- "type": "string"
- },
- {
- "path": ["message"],
- "label": "Message",
- "type": "string"
- },
- {
- "path": ["orgId"],
- "label": "Org Id",
- "type": "string"
- },
- {
- "path": ["panelId"],
- "label": "Panel Id",
- "type": "string"
- },
- {
- "path": ["ruleId"],
- "label": "Rule Id",
- "type": "string"
- },
- {
- "path": ["ruleName"],
- "label": "Rule Name",
- "type": "string"
- },
- {
- "path": ["ruleUrl"],
- "label": "Rule Url",
- "type": "string"
- },
- {
- "path": ["state"],
- "label": "State",
- "type": "string"
- },
- {
- "path": ["title"],
- "label": "Title",
- "type": "string"
- },
- {
- "path": ["tags", "tag"],
- "label": "Tags",
- "type": "string"
- }
- ]
- }
- },
- "storedMapping": {
- "nodes": [
- {
- "alertFieldName": "title",
- "payloadAlertPaths": "title",
- "fallbackAlertPaths": "ruleUrl"
- },
- {
- "alertFieldName": "description",
- "payloadAlertPaths": "message"
- },
- {
- "alertFieldName": "hosts",
- "payloadAlertPaths": "evalMatches"
- },
- {
- "alertFieldName": "startTime",
- "payloadAlertPaths": "createdAt"
- }
- ]
- }
-}
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index ecd7c921b2f..ce6cf61b5dd 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -1,50 +1,106 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
-// TODO: Remove this as part of the form old removal
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.',
- ),
+ integrationTabs: {
+ configureDetails: s__('AlertSettings|Configure details'),
+ viewCredentials: s__('AlertSettings|View credentials'),
+ sendTestAlert: s__('AlertSettings|Send test alert'),
+ },
+ integrationFormSteps: {
+ selectType: {
+ label: s__('AlertSettings|Select integration type'),
+ enterprise: s__(
+ 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.',
+ ),
+ },
+ nameIntegration: {
+ label: s__('AlertSettings|Name integration'),
+ placeholder: s__('AlertSettings|Enter integration name'),
+ activeToggle: __('Active'),
+ },
+ setupCredentials: {
+ help: s__(
+ "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
+ ),
+ prometheusHelp: s__(
+ 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.',
+ ),
+ webhookUrl: s__('AlertSettings|Webhook URL'),
+ authorizationKey: s__('AlertSettings|Authorization key'),
+ reset: s__('AlertSettings|Reset Key'),
+ },
+ setSamplePayload: {
+ label: s__('AlertSettings|Sample alert payload (optional)'),
+ testPayloadHelpHttp: s__(
+ 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional).',
+ ),
+ testPayloadHelp: s__(
+ 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This will allow you to send an alert to an active GitLab alerting point.',
+ ),
+ placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
+ resetHeader: s__('AlertSettings|Reset the mapping'),
+ resetBody: s__(
+ "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.",
+ ),
+ resetOk: s__('AlertSettings|Proceed with editing'),
+ editPayload: s__('AlertSettings|Edit payload'),
+ parsePayload: s__('AlertSettings|Parse payload for custom mapping'),
+ payloadParsedSucessMsg: s__(
+ 'AlertSettings|Sample payload has been parsed. You can now map the fields.',
+ ),
+ },
+ mapFields: {
+ label: s__('AlertSettings|Customize alert payload mapping (optional)'),
+ intro: s__(
+ 'AlertSettings|If you intend to create a custom mapping, provide an example payload from your monitoring tool and click "parse payload fields" button to continue. The sample payload is required for completing the custom mapping; if you want to skip the mapping step, progress straight to saving your integration.',
+ ),
+ },
+ prometheusFormUrl: {
+ label: s__('AlertSettings|Prometheus API base URL'),
+ help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
+ },
+ restKeyInfo: {
+ label: s__(
+ 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
+ ),
+ },
+ },
+ saveIntegration: s__('AlertSettings|Save integration'),
changesSaved: s__('AlertSettings|Your integration was successfully updated.'),
- prometheusInfo: s__('AlertSettings|Add URL and auth key to your Prometheus config file'),
- integrationsInfo: s__(
- 'AlertSettings|Learn more about our our upcoming %{linkStart}integrations%{linkEnd}',
- ),
- resetKey: s__('AlertSettings|Reset key'),
- copyToClipboard: s__('AlertSettings|Copy'),
- 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. Please save your changes now.',
- ),
- integration: s__('AlertSettings|Integration'),
+ cancelAndClose: __('Cancel and close'),
+ send: s__('AlertSettings|Send'),
+ copy: __('Copy'),
};
-export const integrationTypes = [
- { value: '', text: s__('AlertSettings|Select integration type') },
- { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') },
- { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') },
-];
+export const integrationSteps = {
+ selectType: 'SELECT_TYPE',
+ nameIntegration: 'NAME_INTEGRATION',
+ setPrometheusApiUrl: 'SET_PROMETHEUS_API_URL',
+ setSamplePayload: 'SET_SAMPLE_PAYLOAD',
+ customizeMapping: 'CUSTOMIZE_MAPPING',
+};
+
+export const createStepNumbers = {
+ [integrationSteps.selectType]: 1,
+ [integrationSteps.nameIntegration]: 2,
+ [integrationSteps.setPrometheusApiUrl]: 2,
+ [integrationSteps.setSamplePayload]: 3,
+ [integrationSteps.customizeMapping]: 4,
+};
+
+export const editStepNumbers = {
+ [integrationSteps.selectType]: 1,
+ [integrationSteps.nameIntegration]: 1,
+ [integrationSteps.setPrometheusApiUrl]: null,
+ [integrationSteps.setSamplePayload]: 2,
+ [integrationSteps.customizeMapping]: 3,
+};
+
+export const integrationTypes = {
+ none: { value: '', text: s__('AlertSettings|Select integration type') },
+ http: { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') },
+ prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') },
+};
export const typeSet = {
http: 'HTTP',
@@ -57,14 +113,18 @@ export const JSON_VALIDATE_DELAY = 250;
export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/';
-export const sectionHash = 'js-alert-management-settings';
-
-/* eslint-disable @gitlab/require-i18n-strings */
-
/**
* Tracks snowplow event when user views alerts integration list
*/
export const trackAlertIntegrationsViewsOptions = {
+ /* eslint-disable-next-line @gitlab/require-i18n-strings */
category: 'Alert Integrations',
action: 'view_alert_integrations_list',
};
+
+export const mappingFields = {
+ mapping: 'mapping',
+ fallback: 'fallback',
+};
+
+export const viewCredentialsTabIndex = 1;
diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js
index 5fd05169533..72817f636ff 100644
--- a/app/assets/javascripts/alerts_settings/graphql.js
+++ b/app/assets/javascripts/alerts_settings/graphql.js
@@ -10,16 +10,25 @@ const resolvers = {
Mutation: {
updateCurrentIntegration: (
_,
- { id = null, name, active, token, type, url, apiUrl },
+ {
+ id = null,
+ name,
+ active,
+ token,
+ type,
+ url,
+ apiUrl,
+ payloadExample,
+ payloadAttributeMappings,
+ payloadAlertFields,
+ },
{ cache },
) => {
const sourceData = cache.readQuery({ query: getCurrentIntegrationQuery });
const data = produce(sourceData, (draftData) => {
if (id === null) {
- // eslint-disable-next-line no-param-reassign
draftData.currentIntegration = null;
} else {
- // eslint-disable-next-line no-param-reassign
draftData.currentIntegration = {
id,
name,
@@ -28,6 +37,9 @@ const resolvers = {
type,
url,
apiUrl,
+ payloadExample,
+ payloadAttributeMappings,
+ payloadAlertFields,
};
}
});
diff --git a/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_item.fragment.graphql b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_item.fragment.graphql
new file mode 100644
index 00000000000..742228e2928
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_item.fragment.graphql
@@ -0,0 +1,7 @@
+#import "./integration_item.fragment.graphql"
+#import "ee_else_ce/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql"
+
+fragment HttpIntegrationItem on AlertManagementHttpIntegration {
+ ...IntegrationItem
+ ...HttpIntegrationPayloadData
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql
new file mode 100644
index 00000000000..df6ad0b712d
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql
@@ -0,0 +1,3 @@
+fragment HttpIntegrationPayloadData on AlertManagementHttpIntegration {
+ id
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql
index f3fc10b4bd4..babcdea935d 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql
@@ -1,24 +1,10 @@
-#import "../fragments/integration_item.fragment.graphql"
+#import "../fragments/http_integration_item.fragment.graphql"
-mutation createHttpIntegration(
- $projectPath: ID!
- $name: String!
- $active: Boolean!
- $payloadExample: JsonString
- $payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!]
-) {
- httpIntegrationCreate(
- input: {
- projectPath: $projectPath
- name: $name
- active: $active
- payloadExample: $payloadExample
- payloadAttributeMappings: $payloadAttributeMappings
- }
- ) {
+mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) {
+ httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) {
errors
integration {
- ...IntegrationItem
+ ...HttpIntegrationItem
}
}
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql
index 0a49c140e6a..a3a50651fd0 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql
@@ -1,10 +1,10 @@
-#import "../fragments/integration_item.fragment.graphql"
+#import "../fragments/http_integration_item.fragment.graphql"
mutation destroyHttpIntegration($id: ID!) {
httpIntegrationDestroy(input: { id: $id }) {
errors
integration {
- ...IntegrationItem
+ ...HttpIntegrationItem
}
}
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql
index 178d1e13047..c0754d8e32b 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql
@@ -1,10 +1,10 @@
-#import "../fragments/integration_item.fragment.graphql"
+#import "../fragments/http_integration_item.fragment.graphql"
mutation resetHttpIntegrationToken($id: ID!) {
httpIntegrationResetToken(input: { id: $id }) {
errors
integration {
- ...IntegrationItem
+ ...HttpIntegrationItem
}
}
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql
new file mode 100644
index 00000000000..5f3d305993c
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql
@@ -0,0 +1,25 @@
+mutation updateCurrentHttpIntegration(
+ $id: String
+ $name: String
+ $active: Boolean
+ $token: String
+ $type: String
+ $url: String
+ $apiUrl: String
+ $payloadExample: JsonString
+ $payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!]
+ $payloadAlertFields: [AlertManagementPayloadAlertField!]
+) {
+ updateCurrentIntegration(
+ id: $id
+ name: $name
+ active: $active
+ token: $token
+ type: $type
+ url: $url
+ apiUrl: $apiUrl
+ payloadExample: $payloadExample
+ payloadAttributeMappings: $payloadAttributeMappings
+ payloadAlertFields: $payloadAlertFields
+ ) @client
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql
index 3505241309e..5bd63820629 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql
@@ -1,4 +1,4 @@
-mutation updateCurrentIntegration(
+mutation updateCurrentPrometheusIntegration(
$id: String
$name: String
$active: Boolean
@@ -6,6 +6,7 @@ mutation updateCurrentIntegration(
$type: String
$url: String
$apiUrl: String
+ $samplePayload: String
) {
updateCurrentIntegration(
id: $id
@@ -15,5 +16,6 @@ mutation updateCurrentIntegration(
type: $type
url: $url
apiUrl: $apiUrl
+ samplePayload: $samplePayload
) @client
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql
index bb5b334deeb..37df9ec25eb 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql
@@ -1,10 +1,10 @@
-#import "../fragments/integration_item.fragment.graphql"
+#import "../fragments/http_integration_item.fragment.graphql"
mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) {
httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) {
errors
integration {
- ...IntegrationItem
+ ...HttpIntegrationItem
}
}
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql
new file mode 100644
index 00000000000..833a2d6c12f
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql
@@ -0,0 +1,12 @@
+#import "ee_else_ce/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql"
+
+# TODO: this query need to accept http integration id to request a sepcific integration
+query getHttpIntegrations($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ alertManagementHttpIntegrations {
+ nodes {
+ ...HttpIntegrationPayloadData
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql
new file mode 100644
index 00000000000..159b2661f0b
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql
@@ -0,0 +1,9 @@
+query parsePayloadFields($projectPath: ID!, $payload: String!) {
+ project(fullPath: $projectPath) {
+ alertManagementPayloadFields(payloadExample: $payload) {
+ path
+ label
+ type
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js
index 8506b3fda01..321af9fedb6 100644
--- a/app/assets/javascripts/alerts_settings/index.js
+++ b/app/assets/javascripts/alerts_settings/index.js
@@ -63,10 +63,7 @@ export default (el) => {
render(createElement) {
return createElement('alert-settings-wrapper', {
props: {
- alertFields:
- gon.features?.multipleHttpIntegrationsCustomMapping && parseBoolean(multiIntegrations)
- ? JSON.parse(alertFields)
- : null,
+ alertFields: parseBoolean(multiIntegrations) ? JSON.parse(alertFields) : null,
},
});
},
diff --git a/app/assets/javascripts/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
index 758f3eb6dd4..716c709a931 100644
--- a/app/assets/javascripts/alerts_settings/utils/cache_updates.js
+++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
@@ -15,7 +15,6 @@ const deleteIntegrationFromStore = (store, query, { httpIntegrationDestroy }, va
});
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.project.alertManagementIntegrations.nodes = draftData.project.alertManagementIntegrations.nodes.filter(
({ id }) => id !== integration.id,
);
@@ -46,7 +45,6 @@ const addIntegrationToStore = (
});
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.project.alertManagementIntegrations.nodes = [
integration,
...draftData.project.alertManagementIntegrations.nodes,
@@ -60,6 +58,31 @@ const addIntegrationToStore = (
});
};
+const addHttpIntegrationToStore = (store, query, { httpIntegrationCreate }, variables) => {
+ const integration = httpIntegrationCreate?.integration;
+ if (!integration) {
+ return;
+ }
+
+ const sourceData = store.readQuery({
+ query,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ draftData.project.alertManagementHttpIntegrations.nodes = [
+ integration,
+ ...draftData.project.alertManagementHttpIntegrations.nodes,
+ ];
+ });
+
+ store.writeQuery({
+ query,
+ variables,
+ data,
+ });
+};
+
const onError = (data, message) => {
createFlash({ message });
throw new Error(data.errors);
@@ -82,3 +105,11 @@ export const updateStoreAfterIntegrationAdd = (store, query, data, variables) =>
addIntegrationToStore(store, query, data, variables);
}
};
+
+export const updateStoreAfterHttpIntegrationAdd = (store, query, data, variables) => {
+ if (hasErrors(data)) {
+ onError(data, ADD_INTEGRATION_ERROR);
+ } else {
+ addHttpIntegrationToStore(store, query, data, variables);
+ }
+};
diff --git a/app/assets/javascripts/alerts_settings/utils/error_messages.js b/app/assets/javascripts/alerts_settings/utils/error_messages.js
index 979d1ca3ccc..e380257f983 100644
--- a/app/assets/javascripts/alerts_settings/utils/error_messages.js
+++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js
@@ -17,5 +17,5 @@ export const RESET_INTEGRATION_TOKEN_ERROR = s__(
);
export const INTEGRATION_PAYLOAD_TEST_ERROR = s__(
- 'AlertsIntegrations|Integration payload is invalid. You can still save your changes.',
+ 'AlertsIntegrations|Integration payload is invalid.',
);
diff --git a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js
index a86103540c0..5c4b9bcd505 100644
--- a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js
+++ b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js
@@ -1,3 +1,4 @@
+import { isEqual } from 'lodash';
/**
* Given data for GitLab alert fields, parsed payload fields data and previously stored mapping (if any)
* creates an object in a form convenient to build UI && interact with it
@@ -10,16 +11,19 @@
export const getMappingData = (gitlabFields, payloadFields, savedMapping) => {
return gitlabFields.map((gitlabField) => {
// find fields from payload that match gitlab alert field by type
- const mappingFields = payloadFields.filter(({ type }) => gitlabField.types.includes(type));
+ const mappingFields = payloadFields.filter(({ type }) =>
+ gitlabField.types.includes(type.toLowerCase()),
+ );
// find the mapping that was previously stored
- const foundMapping = savedMapping.find(({ fieldName }) => fieldName === gitlabField.name);
-
- const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {};
+ const foundMapping = savedMapping.find(
+ ({ fieldName }) => fieldName.toLowerCase() === gitlabField.name,
+ );
+ const { path: mapping, fallbackPath: fallback } = foundMapping || {};
return {
- mapping: payloadAlertPaths,
- fallback: fallbackAlertPaths,
+ mapping,
+ fallback,
searchTerm: '',
fallbackSearchTerm: '',
mappingFields,
@@ -36,7 +40,7 @@ export const getMappingData = (gitlabFields, payloadFields, savedMapping) => {
*/
export const transformForSave = (mappingData) => {
return mappingData.reduce((acc, field) => {
- const mapped = field.mappingFields.find(({ name }) => name === field.mapping);
+ const mapped = field.mappingFields.find(({ path }) => isEqual(path, field.mapping));
if (mapped) {
const { path, type, label } = mapped;
acc.push({
@@ -49,13 +53,3 @@ export const transformForSave = (mappingData) => {
return acc;
}, []);
};
-
-/**
- * Adds `name` prop to each provided by BE parsed payload field
- * @param {Object} payload - parsed sample payload
- *
- * @return {Object} same as input with an extra `name` property which basically serves as a key to make a match
- */
-export const getPayloadFields = (payload) => {
- return payload.map((field) => ({ ...field, name: field.path.join('_') }));
-};
diff --git a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue b/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue
index c0ad814172d..c0ad814172d 100644
--- a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue
+++ b/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue
diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js b/app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js
index 0cb8d9be0e4..0cb8d9be0e4 100644
--- a/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js
+++ b/app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js b/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js
deleted file mode 100644
index 6fba3c56cfe..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import { s__, __, sprintf } from '~/locale';
-import query from '../graphql/queries/instance_count.query.graphql';
-
-const noDataMessage = s__('InstanceStatistics|No data available.');
-
-export default [
- {
- loadChartError: sprintf(
- s__(
- 'InstanceStatistics|Could not load the pipelines chart. Please refresh the page to try again.',
- ),
- ),
- noDataMessage,
- chartTitle: s__('InstanceStatistics|Pipelines'),
- yAxisTitle: s__('InstanceStatistics|Items'),
- xAxisTitle: s__('InstanceStatistics|Month'),
- queries: [
- {
- query,
- title: s__('InstanceStatistics|Pipelines total'),
- identifier: 'PIPELINES',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the total pipelines'),
- ),
- },
- {
- query,
- title: s__('InstanceStatistics|Pipelines succeeded'),
- identifier: 'PIPELINES_SUCCEEDED',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the successful pipelines'),
- ),
- },
- {
- query,
- title: s__('InstanceStatistics|Pipelines failed'),
- identifier: 'PIPELINES_FAILED',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the failed pipelines'),
- ),
- },
- {
- query,
- title: s__('InstanceStatistics|Pipelines canceled'),
- identifier: 'PIPELINES_CANCELED',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the cancelled pipelines'),
- ),
- },
- {
- query,
- title: s__('InstanceStatistics|Pipelines skipped'),
- identifier: 'PIPELINES_SKIPPED',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the skipped pipelines'),
- ),
- },
- ],
- },
- {
- loadChartError: sprintf(
- s__(
- 'InstanceStatistics|Could not load the issues and merge requests chart. Please refresh the page to try again.',
- ),
- ),
- noDataMessage,
- chartTitle: s__('InstanceStatistics|Issues & Merge Requests'),
- yAxisTitle: s__('InstanceStatistics|Items'),
- xAxisTitle: s__('InstanceStatistics|Month'),
- queries: [
- {
- query,
- title: __('Issues'),
- identifier: 'ISSUES',
- loadError: sprintf(s__('InstanceStatistics|There was an error fetching the issues')),
- },
- {
- query,
- title: __('Merge requests'),
- identifier: 'MERGE_REQUESTS',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the merge requests'),
- ),
- },
- ],
- },
-];
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue
deleted file mode 100644
index 3ffec90fb68..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue
+++ /dev/null
@@ -1,224 +0,0 @@
-<script>
-import { GlAlert } from '@gitlab/ui';
-import { GlLineChart } from '@gitlab/ui/dist/charts';
-import produce from 'immer';
-import { sortBy } from 'lodash';
-import { formatDateAsMonth } from '~/lib/utils/datetime_utility';
-import { s__, __ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
-import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
-import latestGroupsQuery from '../graphql/queries/groups.query.graphql';
-import latestProjectsQuery from '../graphql/queries/projects.query.graphql';
-import { getAverageByMonth } from '../utils';
-
-const sortByDate = (data) => sortBy(data, (item) => new Date(item[0]).getTime());
-
-const averageAndSortData = (data = [], maxDataPoints) => {
- const averaged = getAverageByMonth(
- data.length > maxDataPoints ? data.slice(0, maxDataPoints) : data,
- { shouldRound: true },
- );
- return sortByDate(averaged);
-};
-
-export default {
- name: 'ProjectsAndGroupsChart',
- components: { GlAlert, GlLineChart, ChartSkeletonLoader },
- props: {
- startDate: {
- type: Date,
- required: true,
- },
- endDate: {
- type: Date,
- required: true,
- },
- totalDataPoints: {
- type: Number,
- required: true,
- },
- },
- data() {
- return {
- loadingError: false,
- errorMessage: '',
- groups: [],
- projects: [],
- groupsPageInfo: null,
- projectsPageInfo: null,
- };
- },
- apollo: {
- groups: {
- query: latestGroupsQuery,
- variables() {
- return {
- first: this.totalDataPoints,
- after: null,
- };
- },
- update(data) {
- return data.groups?.nodes || [];
- },
- result({ data }) {
- const {
- groups: { pageInfo },
- } = data;
- this.groupsPageInfo = pageInfo;
- this.fetchNextPage({
- query: this.$apollo.queries.groups,
- pageInfo: this.groupsPageInfo,
- dataKey: 'groups',
- errorMessage: this.$options.i18n.loadGroupsDataError,
- });
- },
- error(error) {
- this.handleError({
- message: this.$options.i18n.loadGroupsDataError,
- error,
- dataKey: 'groups',
- });
- },
- },
- projects: {
- query: latestProjectsQuery,
- variables() {
- return {
- first: this.totalDataPoints,
- after: null,
- };
- },
- update(data) {
- return data.projects?.nodes || [];
- },
- result({ data }) {
- const {
- projects: { pageInfo },
- } = data;
- this.projectsPageInfo = pageInfo;
- this.fetchNextPage({
- query: this.$apollo.queries.projects,
- pageInfo: this.projectsPageInfo,
- dataKey: 'projects',
- errorMessage: this.$options.i18n.loadProjectsDataError,
- });
- },
- error(error) {
- this.handleError({
- message: this.$options.i18n.loadProjectsDataError,
- error,
- dataKey: 'projects',
- });
- },
- },
- },
- i18n: {
- yAxisTitle: s__('InstanceStatistics|Total projects & groups'),
- xAxisTitle: __('Month'),
- loadChartError: s__(
- 'InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again.',
- ),
- loadProjectsDataError: s__('InstanceStatistics|There was an error while loading the projects'),
- loadGroupsDataError: s__('InstanceStatistics|There was an error while loading the groups'),
- noDataMessage: s__('InstanceStatistics|No data available.'),
- },
- computed: {
- isLoadingGroups() {
- return this.$apollo.queries.groups.loading || this.groupsPageInfo?.hasNextPage;
- },
- isLoadingProjects() {
- return this.$apollo.queries.projects.loading || this.projectsPageInfo?.hasNextPage;
- },
- isLoading() {
- return this.isLoadingProjects && this.isLoadingGroups;
- },
- groupChartData() {
- return averageAndSortData(this.groups, this.totalDataPoints);
- },
- projectChartData() {
- return averageAndSortData(this.projects, this.totalDataPoints);
- },
- hasNoData() {
- const { projectChartData, groupChartData } = this;
- return Boolean(!projectChartData.length && !groupChartData.length);
- },
- options() {
- return {
- xAxis: {
- name: this.$options.i18n.xAxisTitle,
- type: 'category',
- axisLabel: {
- formatter: (value) => {
- return formatDateAsMonth(value);
- },
- },
- },
- yAxis: {
- name: this.$options.i18n.yAxisTitle,
- },
- };
- },
- chartData() {
- return [
- {
- name: s__('InstanceStatistics|Total projects'),
- data: this.projectChartData,
- },
- {
- name: s__('InstanceStatistics|Total groups'),
- data: this.groupChartData,
- },
- ];
- },
- },
- methods: {
- handleError({ error, message = this.$options.i18n.loadChartError, dataKey = null }) {
- this.loadingError = true;
- this.errorMessage = message;
- if (!dataKey) {
- this.projects = [];
- this.groups = [];
- } else {
- this[dataKey] = [];
- }
- Sentry.captureException(error);
- },
- fetchNextPage({ pageInfo, query, dataKey, errorMessage }) {
- if (pageInfo?.hasNextPage) {
- query
- .fetchMore({
- variables: { first: this.totalDataPoints, after: pageInfo.endCursor },
- updateQuery: (previousResult, { fetchMoreResult }) => {
- const results = produce(fetchMoreResult, (newData) => {
- // eslint-disable-next-line no-param-reassign
- newData[dataKey].nodes = [
- ...previousResult[dataKey].nodes,
- ...newData[dataKey].nodes,
- ];
- });
- return results;
- },
- })
- .catch((error) => {
- this.handleError({ error, message: errorMessage, dataKey });
- });
- }
- },
- },
-};
-</script>
-<template>
- <div>
- <h3>{{ $options.i18n.yAxisTitle }}</h3>
- <chart-skeleton-loader v-if="isLoading" />
- <gl-alert v-else-if="hasNoData" variant="info" :dismissible="false" class="gl-mt-3">
- {{ $options.i18n.noDataMessage }}
- </gl-alert>
- <div v-else>
- <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">{{
- errorMessage
- }}</gl-alert>
- <gl-line-chart :option="options" :include-legend-avg-max="true" :data="chartData" />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql
deleted file mode 100644
index 40cef95c2e7..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql
+++ /dev/null
@@ -1,4 +0,0 @@
-fragment Count on InstanceStatisticsMeasurement {
- count
- recordedAt
-}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql
deleted file mode 100644
index ec56d91ffaa..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
-#import "../fragments/count.fragment.graphql"
-
-query getGroupsCount($first: Int, $after: String) {
- groups: instanceStatisticsMeasurements(identifier: GROUPS, first: $first, after: $after) {
- nodes {
- ...Count
- }
- pageInfo {
- ...PageInfo
- }
- }
-}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql
deleted file mode 100644
index f14c2658674..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql
+++ /dev/null
@@ -1,34 +0,0 @@
-#import "../fragments/count.fragment.graphql"
-
-query getInstanceCounts {
- projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: 1) {
- nodes {
- ...Count
- }
- }
- groups: instanceStatisticsMeasurements(identifier: GROUPS, first: 1) {
- nodes {
- ...Count
- }
- }
- users: instanceStatisticsMeasurements(identifier: USERS, first: 1) {
- nodes {
- ...Count
- }
- }
- issues: instanceStatisticsMeasurements(identifier: ISSUES, first: 1) {
- nodes {
- ...Count
- }
- }
- mergeRequests: instanceStatisticsMeasurements(identifier: MERGE_REQUESTS, first: 1) {
- nodes {
- ...Count
- }
- }
- pipelines: instanceStatisticsMeasurements(identifier: PIPELINES, first: 1) {
- nodes {
- ...Count
- }
- }
-}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql
deleted file mode 100644
index 0845b703435..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
-#import "../fragments/count.fragment.graphql"
-
-query getProjectsCount($first: Int, $after: String) {
- projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: $first, after: $after) {
- nodes {
- ...Count
- }
- pageInfo {
- ...PageInfo
- }
- }
-}
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/usage_trends/components/app.vue
index 3bf41eaa008..4c5ddd7f458 100644
--- a/app/assets/javascripts/analytics/instance_statistics/components/app.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/app.vue
@@ -1,18 +1,16 @@
<script>
import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants';
import ChartsConfig from './charts_config';
-import InstanceCounts from './instance_counts.vue';
-import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue';
-import ProjectsAndGroupsChart from './projects_and_groups_chart.vue';
+import UsageCounts from './usage_counts.vue';
+import UsageTrendsCountChart from './usage_trends_count_chart.vue';
import UsersChart from './users_chart.vue';
export default {
- name: 'InstanceStatisticsApp',
+ name: 'UsageTrendsApp',
components: {
- InstanceCounts,
- InstanceStatisticsCountChart,
+ UsageCounts,
+ UsageTrendsCountChart,
UsersChart,
- ProjectsAndGroupsChart,
},
TOTAL_DAYS_TO_SHOW,
START_DATE,
@@ -23,18 +21,13 @@ export default {
<template>
<div>
- <instance-counts />
+ <usage-counts />
<users-chart
:start-date="$options.START_DATE"
:end-date="$options.TODAY"
:total-data-points="$options.TOTAL_DAYS_TO_SHOW"
/>
- <projects-and-groups-chart
- :start-date="$options.START_DATE"
- :end-date="$options.TODAY"
- :total-data-points="$options.TOTAL_DAYS_TO_SHOW"
- />
- <instance-statistics-count-chart
+ <usage-trends-count-chart
v-for="chartOptions in $options.configs"
:key="chartOptions.chartTitle"
:queries="chartOptions.queries"
diff --git a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
new file mode 100644
index 00000000000..014f823cdc4
--- /dev/null
+++ b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
@@ -0,0 +1,106 @@
+import { s__, __ } from '~/locale';
+import query from '../graphql/queries/usage_count.query.graphql';
+
+const noDataMessage = s__('UsageTrends|No data available.');
+
+export default [
+ {
+ loadChartError: s__(
+ 'UsageTrends|Could not load the projects and groups chart. Please refresh the page to try again.',
+ ),
+ noDataMessage,
+ chartTitle: s__('UsageTrends|Total projects & groups'),
+ yAxisTitle: s__('UsageTrends|Total projects & groups'),
+ xAxisTitle: s__('UsageTrends|Month'),
+ queries: [
+ {
+ query,
+ title: s__('UsageTrends|Total projects'),
+ identifier: 'PROJECTS',
+ loadError: s__('UsageTrends|There was an error fetching the projects. Please try again.'),
+ },
+ {
+ query,
+ title: s__('UsageTrends|Total groups'),
+ identifier: 'GROUPS',
+ loadError: s__('UsageTrends|There was an error fetching the groups. Please try again.'),
+ },
+ ],
+ },
+ {
+ loadChartError: s__(
+ 'UsageTrends|Could not load the pipelines chart. Please refresh the page to try again.',
+ ),
+ noDataMessage,
+ chartTitle: s__('UsageTrends|Pipelines'),
+ yAxisTitle: s__('UsageTrends|Items'),
+ xAxisTitle: s__('UsageTrends|Month'),
+ queries: [
+ {
+ query,
+ title: s__('UsageTrends|Pipelines total'),
+ identifier: 'PIPELINES',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the total pipelines. Please try again.',
+ ),
+ },
+ {
+ query,
+ title: s__('UsageTrends|Pipelines succeeded'),
+ identifier: 'PIPELINES_SUCCEEDED',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the successful pipelines. Please try again.',
+ ),
+ },
+ {
+ query,
+ title: s__('UsageTrends|Pipelines failed'),
+ identifier: 'PIPELINES_FAILED',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the failed pipelines. Please try again.',
+ ),
+ },
+ {
+ query,
+ title: s__('UsageTrends|Pipelines canceled'),
+ identifier: 'PIPELINES_CANCELED',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the cancelled pipelines. Please try again.',
+ ),
+ },
+ {
+ query,
+ title: s__('UsageTrends|Pipelines skipped'),
+ identifier: 'PIPELINES_SKIPPED',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the skipped pipelines. Please try again.',
+ ),
+ },
+ ],
+ },
+ {
+ loadChartError: s__(
+ 'UsageTrends|Could not load the issues and merge requests chart. Please refresh the page to try again.',
+ ),
+ noDataMessage,
+ chartTitle: s__('UsageTrends|Issues & Merge Requests'),
+ yAxisTitle: s__('UsageTrends|Items'),
+ xAxisTitle: s__('UsageTrends|Month'),
+ queries: [
+ {
+ query,
+ title: __('Issues'),
+ identifier: 'ISSUES',
+ loadError: s__('UsageTrends|There was an error fetching the issues. Please try again.'),
+ },
+ {
+ query,
+ title: __('Merge requests'),
+ identifier: 'MERGE_REQUESTS',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the merge requests. Please try again.',
+ ),
+ },
+ ],
+ },
+];
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
index f3779ed62e9..0630cca93ae 100644
--- a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
@@ -1,15 +1,15 @@
<script>
+import * as Sentry from '@sentry/browser';
import MetricCard from '~/analytics/shared/components/metric_card.vue';
import { deprecatedCreateFlash as createFlash } from '~/flash';
-import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
+import { number } from '~/lib/utils/unit_format';
import { s__ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
-import instanceStatisticsCountQuery from '../graphql/queries/instance_statistics_count.query.graphql';
+import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql';
const defaultPrecision = 0;
export default {
- name: 'InstanceCounts',
+ name: 'UsageCounts',
components: {
MetricCard,
},
@@ -20,12 +20,11 @@ export default {
},
apollo: {
counts: {
- query: instanceStatisticsCountQuery,
+ query: usageTrendsCountQuery,
update(data) {
return Object.entries(data).map(([key, obj]) => {
const label = this.$options.i18n.labels[key];
- const formatter = getFormatter(SUPPORTED_FORMATS.number);
- const value = obj.nodes?.length ? formatter(obj.nodes[0].count, defaultPrecision) : null;
+ const value = obj.nodes?.length ? number(obj.nodes[0].count, defaultPrecision) : null;
return {
key,
@@ -42,14 +41,14 @@ export default {
},
i18n: {
labels: {
- users: s__('InstanceStatistics|Users'),
- projects: s__('InstanceStatistics|Projects'),
- groups: s__('InstanceStatistics|Groups'),
- issues: s__('InstanceStatistics|Issues'),
- mergeRequests: s__('InstanceStatistics|Merge Requests'),
- pipelines: s__('InstanceStatistics|Pipelines'),
+ users: s__('UsageTrends|Users'),
+ projects: s__('UsageTrends|Projects'),
+ groups: s__('UsageTrends|Groups'),
+ issues: s__('UsageTrends|Issues'),
+ mergeRequests: s__('UsageTrends|Merge Requests'),
+ pipelines: s__('UsageTrends|Pipelines'),
},
- loadCountsError: s__('Could not load instance counts. Please refresh the page to try again.'),
+ loadCountsError: s__('Could not load usage counts. Please refresh the page to try again.'),
},
};
</script>
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue
index e2defe0572d..8d7761694d1 100644
--- a/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue
@@ -1,21 +1,21 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { GlLineChart } from '@gitlab/ui/dist/charts';
+import * as Sentry from '@sentry/browser';
import { some, every } from 'lodash';
import {
differenceInMonths,
formatDateAsMonth,
getDayDifference,
} from '~/lib/utils/datetime_utility';
-import * as Sentry from '~/sentry/wrapper';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { TODAY, START_DATE } from '../constants';
import { getAverageByMonth, getEarliestDate, generateDataKeys } from '../utils';
-const QUERY_DATA_KEY = 'instanceStatisticsMeasurements';
+const QUERY_DATA_KEY = 'usageTrendsMeasurements';
export default {
- name: 'InstanceStatisticsCountChart',
+ name: 'UsageTrendsCountChart',
components: {
GlLineChart,
GlAlert,
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue
index 73940f028a1..09dfcddcb73 100644
--- a/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue
@@ -1,11 +1,11 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import * as Sentry from '@sentry/browser';
import produce from 'immer';
import { sortBy } from 'lodash';
import { formatDateAsMonth } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import usersQuery from '../graphql/queries/users.query.graphql';
import { getAverageByMonth } from '../utils';
diff --git a/app/assets/javascripts/analytics/instance_statistics/constants.js b/app/assets/javascripts/analytics/usage_trends/constants.js
index 846c0ef408b..846c0ef408b 100644
--- a/app/assets/javascripts/analytics/instance_statistics/constants.js
+++ b/app/assets/javascripts/analytics/usage_trends/constants.js
diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
new file mode 100644
index 00000000000..2bde5973600
--- /dev/null
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
@@ -0,0 +1,4 @@
+fragment Count on UsageTrendsMeasurement {
+ count
+ recordedAt
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql
index dd22a16cd51..2a5546efb68 100644
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql
@@ -2,7 +2,7 @@
#import "../fragments/count.fragment.graphql"
query getCount($identifier: MeasurementIdentifier!, $first: Int, $after: String) {
- instanceStatisticsMeasurements(identifier: $identifier, first: $first, after: $after) {
+ usageTrendsMeasurements(identifier: $identifier, first: $first, after: $after) {
nodes {
...Count
}
diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql
new file mode 100644
index 00000000000..8cadcfae380
--- /dev/null
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql
@@ -0,0 +1,34 @@
+#import "../fragments/count.fragment.graphql"
+
+query getInstanceCounts {
+ projects: usageTrendsMeasurements(identifier: PROJECTS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ groups: usageTrendsMeasurements(identifier: GROUPS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ users: usageTrendsMeasurements(identifier: USERS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ issues: usageTrendsMeasurements(identifier: ISSUES, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ mergeRequests: usageTrendsMeasurements(identifier: MERGE_REQUESTS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ pipelines: usageTrendsMeasurements(identifier: PIPELINES, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql
index 6235e36eb89..7c02ac49a42 100644
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql
@@ -2,7 +2,7 @@
#import "../fragments/count.fragment.graphql"
query getUsersCount($first: Int, $after: String) {
- users: instanceStatisticsMeasurements(identifier: USERS, first: $first, after: $after) {
+ users: usageTrendsMeasurements(identifier: USERS, first: $first, after: $after) {
nodes {
...Count
}
diff --git a/app/assets/javascripts/analytics/instance_statistics/index.js b/app/assets/javascripts/analytics/usage_trends/index.js
index 0d7dcf6ace8..d1880b09f15 100644
--- a/app/assets/javascripts/analytics/instance_statistics/index.js
+++ b/app/assets/javascripts/analytics/usage_trends/index.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import InstanceStatisticsApp from './components/app.vue';
+import UsageTrendsApp from './components/app.vue';
Vue.use(VueApollo);
@@ -10,7 +10,7 @@ const apolloProvider = new VueApollo({
});
export default () => {
- const el = document.getElementById('js-instance-statistics-app');
+ const el = document.getElementById('js-usage-trends-app');
if (!el) return false;
@@ -18,7 +18,7 @@ export default () => {
el,
apolloProvider,
render(h) {
- return h(InstanceStatisticsApp);
+ return h(UsageTrendsApp);
},
});
};
diff --git a/app/assets/javascripts/analytics/instance_statistics/utils.js b/app/assets/javascripts/analytics/usage_trends/utils.js
index 396962ffad6..91907877ed6 100644
--- a/app/assets/javascripts/analytics/instance_statistics/utils.js
+++ b/app/assets/javascripts/analytics/usage_trends/utils.js
@@ -41,8 +41,8 @@ export function getAverageByMonth(items = [], options = {}) {
}
/**
- * Takes an array of instance counts and returns the last item in the list
- * @param {Array} arr array of instance counts in the form { count: Number, recordedAt: date String }
+ * Takes an array of usage counts and returns the last item in the list
+ * @param {Array} arr array of usage counts in the form { count: Number, recordedAt: date String }
* @return {String} the 'recordedAt' value of the earliest item
*/
export const getEarliestDate = (arr = []) => {
@@ -54,7 +54,7 @@ export const getEarliestDate = (arr = []) => {
* Takes an array of queries and produces an object with the query identifier as key
* and a supplied defaultValue as its value
* @param {Array} queries array of chart query configs,
- * see ./analytics/instance_statistics/components/charts_config.js
+ * see ./analytics/usage_trends/components/charts_config.js
* @param {any} defaultValue value to set each identifier to
* @return {Object} key value pair of the form { queryIdentifier: defaultValue }
*/
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index c7e6b98a934..48005787d81 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -24,6 +24,7 @@ const Api = {
projectPackagesPath: '/api/:version/projects/:id/packages',
projectPackagePath: '/api/:version/projects/:id/packages/:package_id',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
+ groupSharePath: '/api/:version/groups/:id/share',
projectsPath: '/api/:version/projects.json',
projectPath: '/api/:version/projects/:id',
forkedProjectsPath: '/api/:version/projects/:id/forks',
@@ -39,6 +40,7 @@ const Api = {
projectRunnersPath: '/api/:version/projects/:id/runners',
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
projectSearchPath: '/api/:version/projects/:id/search',
+ projectSharePath: '/api/:version/projects/:id/share',
projectMilestonesPath: '/api/:version/projects/:id/milestones',
projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid',
mergeRequestsPath: '/api/:version/merge_requests',
@@ -365,6 +367,16 @@ const Api = {
});
},
+ projectShareWithGroup(id, options = {}) {
+ const url = Api.buildUrl(Api.projectSharePath).replace(':id', encodeURIComponent(id));
+
+ return axios.post(url, {
+ expires_at: options.expires_at,
+ group_access: options.group_access,
+ group_id: options.group_id,
+ });
+ },
+
projectMilestones(id, params = {}) {
const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id));
@@ -426,6 +438,16 @@ const Api = {
});
},
+ groupShareWithGroup(id, options = {}) {
+ const url = Api.buildUrl(Api.groupSharePath).replace(':id', encodeURIComponent(id));
+
+ return axios.post(url, {
+ expires_at: options.expires_at,
+ group_access: options.group_access,
+ group_id: options.group_id,
+ });
+ },
+
commit(id, sha, params = {}) {
const url = Api.buildUrl(this.commitPath)
.replace(':id', encodeURIComponent(id))
@@ -446,7 +468,7 @@ const Api = {
applySuggestion(id, message = '') {
const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id));
- const params = gon.features?.suggestionsCustomCommit ? { commit_message: message } : false;
+ const params = { commit_message: message };
return axios.put(url, params);
},
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
index 0e589d98668..55642aa64db 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
@@ -162,7 +162,7 @@ export default {
:href="profileAccountPath"
:disabled="proceedButtonDisabled"
:title="$options.i18n.proceedButton"
- variant="success"
+ variant="confirm"
data-qa-selector="proceed_button"
data-track-event="click_button"
:data-track-label="`${$options.trackingLabelPrefix}proceed_button`"
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 6fb90551ed7..dbdc7e43d2d 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -5,12 +5,14 @@ import $ from 'jquery';
import Cookies from 'js-cookie';
import { uniq } from 'lodash';
import * as Emoji from '~/emoji';
+import { scrollToElement } from '~/lib/utils/common_utils';
import { dispose, fixTitle } from '~/tooltips';
import { deprecatedCreateFlash as flash } from './flash';
import axios from './lib/utils/axios_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { __ } from './locale';
+window.axios = axios;
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
@@ -495,12 +497,7 @@ export class AwardsHandler {
}
scrollToAwards() {
- const options = {
- scrollTop: $('.awards').offset().top - 110,
- };
-
- // eslint-disable-next-line no-jquery/no-animate
- return $('body, html').animate(options, 200);
+ scrollToElement('.awards', { offset: -110 });
}
addEmojiToFrequentlyUsedList(emoji) {
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index c3512773457..9e5d70075f3 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -96,7 +96,7 @@ export default {
v-gl-tooltip.hover
:title="s__('Badges|Reload badge image')"
category="tertiary"
- variant="success"
+ variant="confirm"
type="button"
icon="retry"
size="small"
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index 20541ad8ccc..b65a8b4fa9c 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -225,7 +225,7 @@ export default {
<gl-button
:loading="isSaving"
type="submit"
- variant="success"
+ variant="confirm"
category="primary"
data-testid="saveEditing"
>
@@ -233,7 +233,7 @@ export default {
</gl-button>
</div>
<div v-else class="form-group">
- <gl-button :loading="isSaving" type="submit" variant="success" category="primary">
+ <gl-button :loading="isSaving" type="submit" variant="confirm" category="primary">
{{ s__('Badges|Add badge') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue
index 8568dba5947..2a7be605003 100644
--- a/app/assets/javascripts/batch_comments/components/publish_button.vue
+++ b/app/assets/javascripts/batch_comments/components/publish_button.vue
@@ -40,7 +40,8 @@ export default {
<template>
<gl-button
:loading="isPublishing"
- class="js-publish-draft-button qa-submit-review"
+ class="js-publish-draft-button"
+ data-qa-selector="submit_review_button"
:category="category"
:variant="variant"
@click="onClick"
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index 035d6f4e0ab..9ffc5ee34cf 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -27,7 +27,10 @@ export default {
<template>
<div v-show="draftsCount > 0">
<nav class="review-bar-component">
- <div class="review-bar-content qa-review-bar d-flex gl-justify-content-end">
+ <div
+ class="review-bar-content d-flex gl-justify-content-end"
+ data-qa-selector="review_bar_content"
+ >
<preview-dropdown />
<publish-button class="gl-ml-3" show-count />
</div>
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index 36fef06eeff..88be64d0a1a 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -1,3 +1,4 @@
+import { isEmpty } from 'lodash';
import { deprecatedCreateFlash as flash } from '~/flash';
import { scrollToElement } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
@@ -88,18 +89,23 @@ export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGet
export const updateDraft = (
{ commit, getters },
{ note, noteText, resolveDiscussion, position, callback },
-) =>
- service
- .update(getters.getNotesData.draftsPath, {
- draftId: note.id,
- note: noteText,
- resolveDiscussion,
- position: JSON.stringify(position),
- })
+) => {
+ const params = {
+ draftId: note.id,
+ note: noteText,
+ resolveDiscussion,
+ };
+ // Stringifying an empty object yields `{}` which breaks graphql queries
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/298827
+ if (!isEmpty(position)) params.position = JSON.stringify(position);
+
+ return service
+ .update(getters.getNotesData.draftsPath, params)
.then((res) => res.data)
.then((data) => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data))
.then(callback)
.catch(() => flash(__('An error occurred while updating the comment')));
+};
export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
const discussion = draft.discussion_id && rootGetters.getDiscussion(draft.discussion_id);
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index a31bcc2cb41..de248340738 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -1,31 +1,27 @@
import Clipboard from 'clipboard';
import $ from 'jquery';
import { sprintf, __ } from '~/locale';
-import { fixTitle, show } from '~/tooltips';
+import { fixTitle, add, show, once } from '~/tooltips';
function showTooltip(target, title) {
- const { originalTitle } = target.dataset;
- const hideTooltip = () => {
- target.removeEventListener('mouseout', hideTooltip);
- setTimeout(() => {
+ const { title: originalTitle } = target.dataset;
+
+ once('hidden', (tooltip) => {
+ if (tooltip.target === target) {
target.setAttribute('title', originalTitle);
fixTitle(target);
- }, 100);
- };
+ }
+ });
target.setAttribute('title', title);
-
fixTitle(target);
show(target);
-
- target.addEventListener('mouseout', hideTooltip);
+ setTimeout(() => target.blur(), 1000);
}
function genericSuccess(e) {
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
- $(e.trigger).blur();
-
showTooltip(e.trigger, __('Copied'));
}
@@ -88,24 +84,8 @@ export default function initCopyToClipboard() {
* @param {HTMLElement} btnElement
*/
export function clickCopyToClipboardButton(btnElement) {
- const $btnElement = $(btnElement);
-
// Ensure the button has already been tooltip'd.
- // If the use hasn't yet interacted (i.e. hovered or clicked)
- // with the button, Bootstrap hasn't yet initialized
- // the tooltip, and its `data-original-title` will be `undefined`.
- // This value is used in the functions above.
- $btnElement.tooltip();
- btnElement.dispatchEvent(new MouseEvent('mouseover'));
+ add([btnElement], { show: true });
btnElement.click();
-
- // Manually trigger the necessary events to hide the
- // button's tooltip and allow the button to perform its
- // tooltip cleanup (updating the title from "Copied" back
- // to its original title, "Copy branch name").
- setTimeout(() => {
- btnElement.dispatchEvent(new MouseEvent('mouseout'));
- $btnElement.tooltip('hide');
- }, 2000);
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
index 5479866c99a..8238f5523f3 100644
--- a/app/assets/javascripts/behaviors/markdown/render_math.js
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -1,6 +1,6 @@
-import { deprecatedCreateFlash as flash } from '~/flash';
+import { spriteIcon } from '~/lib/utils/common_utils';
import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
-import { s__, sprintf } from '~/locale';
+import { s__ } from '~/locale';
// Renders math using KaTeX in any element with the
// `js-render-math` class
@@ -13,30 +13,10 @@ import { s__, sprintf } from '~/locale';
const MAX_MATH_CHARS = 1000;
const MAX_RENDER_TIME_MS = 2000;
-// These messages might be used with inline errors in the future. Keep them around. For now, we will
-// display a single error message using flash().
-
-// const CHAR_LIMIT_EXCEEDED_MSG = sprintf(
-// s__(
-// 'math|The following math is too long. For performance reasons, math blocks are limited to %{maxChars} characters. Try splitting up this block, or include an image instead.',
-// ),
-// { maxChars: MAX_MATH_CHARS },
-// );
-// const RENDER_TIME_EXCEEDED_MSG = s__(
-// "math|The math in this entry is taking too long to render. Any math below this point won't be shown. Consider splitting it among multiple entries.",
-// );
-
-const RENDER_FLASH_MSG = sprintf(
- s__(
- 'math|The math in this entry is taking too long to render and may not be displayed as expected. For performance reasons, math blocks are also limited to %{maxChars} characters. Consider splitting up large formulae, splitting math blocks among multiple entries, or using an image instead.',
- ),
- { maxChars: MAX_MATH_CHARS },
-);
-
// Wait for the browser to reflow the layout. Reflowing SVG takes time.
// This has to wrap the inner function, otherwise IE/Edge throw "invalid calling object".
const waitForReflow = (fn) => {
- window.requestAnimationFrame(fn);
+ window.requestIdleCallback(fn);
};
/**
@@ -67,37 +47,69 @@ class SafeMathRenderer {
this.renderElement = this.renderElement.bind(this);
this.render = this.render.bind(this);
+ this.attachEvents = this.attachEvents.bind(this);
}
- renderElement() {
- if (!this.queue.length) {
+ renderElement(chosenEl) {
+ if (!this.queue.length && !chosenEl) {
return;
}
- const el = this.queue.shift();
+ const el = chosenEl || this.queue.shift();
+ const forceRender = Boolean(chosenEl);
const text = el.textContent;
el.removeAttribute('style');
-
- if (this.totalMS >= MAX_RENDER_TIME_MS || text.length > MAX_MATH_CHARS) {
- if (!this.flashShown) {
- flash(RENDER_FLASH_MSG);
- this.flashShown = true;
- }
-
+ if (!forceRender && (this.totalMS >= MAX_RENDER_TIME_MS || text.length > MAX_MATH_CHARS)) {
// Show unrendered math code
+ const wrapperElement = document.createElement('div');
const codeElement = document.createElement('pre');
+
codeElement.className = 'code';
codeElement.textContent = el.textContent;
- el.parentNode.replaceChild(codeElement, el);
+
+ const { parentNode } = el;
+ parentNode.replaceChild(wrapperElement, el);
+
+ const html = `
+ <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-math-container js-lazy-render-math-container fade show" role="alert">
+ ${spriteIcon('warning', 'text-warning-600 s16 gl-alert-icon')}
+ <div class="display-flex gl-alert-content">
+ <div>${s__(
+ 'math|Displaying this math block may cause performance issues on this page',
+ )}</div>
+ <div class="gl-alert-actions">
+ <button class="js-lazy-render-math btn gl-alert-action btn-primary btn-md gl-button">Display anyway</button>
+ </div>
+ </div>
+ <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+ ${spriteIcon('close', 's16')}
+ </button>
+ </div>
+ `;
+
+ if (!wrapperElement.classList.contains('lazy-alert-shown')) {
+ wrapperElement.innerHTML = html;
+ wrapperElement.append(codeElement);
+ wrapperElement.classList.add('lazy-alert-shown');
+ }
// Render the next math
this.renderElement();
} else {
this.startTime = Date.now();
+ /* Get the correct reference to the display container when:
+ * a.) Happy path: when the math block is present, and
+ * b.) When we've replace the block with <pre> for lazy rendering
+ */
+ let displayContainer = el;
+ if (el.tagName === 'PRE') {
+ displayContainer = el.parentElement;
+ }
+
try {
- el.innerHTML = this.katex.renderToString(text, {
+ displayContainer.innerHTML = this.katex.renderToString(text, {
displayMode: el.getAttribute('data-math-style') === 'display',
throwOnError: true,
maxSize: 20,
@@ -135,6 +147,22 @@ class SafeMathRenderer {
// and less prone to timeouts.
setTimeout(this.renderElement, 400);
}
+
+ attachEvents() {
+ document.body.addEventListener('click', (event) => {
+ if (!event.target.classList.contains('js-lazy-render-math')) {
+ return;
+ }
+
+ const parent = event.target.closest('.js-lazy-render-math-container');
+
+ const pre = parent.nextElementSibling;
+
+ parent.remove();
+
+ this.renderElement(pre);
+ });
+ }
}
export default function renderMath($els) {
@@ -146,6 +174,7 @@ export default function renderMath($els) {
.then(([katex]) => {
const renderer = new SafeMathRenderer($els.get(), katex);
renderer.render();
+ renderer.attachEvents();
})
.catch(() => {});
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index 0513e807ed6..a8fe00d26e6 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -1,91 +1,80 @@
-import { flatten } from 'lodash';
+import { memoize } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { s__ } from '~/locale';
-import { shouldDisableShortcuts } from './shortcuts_toggle';
-export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations';
-
-let parsedCustomizations = {};
-const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe();
+const isCustomizable = (command) =>
+ 'customizable' in command ? Boolean(command.customizable) : true;
-if (localStorageIsSafe) {
- try {
- parsedCustomizations = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
- } catch (e) {
- /* do nothing */
- }
-}
+export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations';
/**
- * A map of command => keys of all keyboard shortcuts
- * that have been customized by the user.
+ * @returns { Object.<string, string[]> } A map of command ID => keys of all
+ * keyboard shortcuts that have been customized by the user. These
+ * customizations are fetched from `localStorage`. This function is memoized,
+ * so its return value will not reflect changes made to the `localStorage` data
+ * after it has been called once.
*
* @example
* { "globalShortcuts.togglePerformanceBar": ["p e r f"] }
- *
- * @type { Object.<string, string[]> }
*/
-export const customizations = parsedCustomizations;
+export const getCustomizations = memoize(() => {
+ let parsedCustomizations = {};
+ const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe();
+
+ if (localStorageIsSafe) {
+ try {
+ parsedCustomizations = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
+ } catch (e) {
+ /* do nothing */
+ }
+ }
+
+ return parsedCustomizations;
+});
// All available commands
-export const TOGGLE_PERFORMANCE_BAR = 'globalShortcuts.togglePerformanceBar';
-export const TOGGLE_CANARY = 'globalShortcuts.toggleCanary';
+export const TOGGLE_PERFORMANCE_BAR = {
+ id: 'globalShortcuts.togglePerformanceBar',
+ description: s__('KeyboardShortcuts|Toggle the Performance Bar'),
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ defaultKeys: ['p b'],
+};
-/** All keybindings, grouped and ordered with descriptions */
-export const keybindingGroups = [
- {
- groupId: 'globalShortcuts',
- name: s__('KeyboardShortcuts|Global Shortcuts'),
- keybindings: [
- {
- description: s__('KeyboardShortcuts|Toggle the Performance Bar'),
- command: TOGGLE_PERFORMANCE_BAR,
- // eslint-disable-next-line @gitlab/require-i18n-strings
- defaultKeys: ['p b'],
- },
- {
- description: s__('KeyboardShortcuts|Toggle GitLab Next'),
- command: TOGGLE_CANARY,
- // eslint-disable-next-line @gitlab/require-i18n-strings
- defaultKeys: ['g x'],
- },
- ],
- },
-]
+export const TOGGLE_CANARY = {
+ id: 'globalShortcuts.toggleCanary',
+ description: s__('KeyboardShortcuts|Toggle GitLab Next'),
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ defaultKeys: ['g x'],
+};
- // For each keybinding object, add a `customKeys` property populated with the
- // user's custom keybindings (if the command has been customized).
- // `customKeys` will be `undefined` if the command hasn't been customized.
- .map((group) => {
- return {
- ...group,
- keybindings: group.keybindings.map((binding) => ({
- ...binding,
- customKeys: customizations[binding.command],
- })),
- };
- });
+export const WEB_IDE_COMMIT = {
+ id: 'webIDE.commit',
+ description: s__('KeyboardShortcuts|Commit (when editing commit message)'),
+ defaultKeys: ['mod+enter'],
+ customizable: false,
+};
-/**
- * A simple map of command => keys. All user customizations are included in this map.
- * This mapping is used to simplify `keysFor` below.
- *
- * @example
- * { "globalShortcuts.togglePerformanceBar": ["p e r f"] }
- */
-const commandToKeys = flatten(keybindingGroups.map((group) => group.keybindings)).reduce(
- (acc, binding) => {
- acc[binding.command] = binding.customKeys || binding.defaultKeys;
- return acc;
- },
- {},
-);
+// All keybinding groups
+export const GLOBAL_SHORTCUTS_GROUP = {
+ id: 'globalShortcuts',
+ name: s__('KeyboardShortcuts|Global Shortcuts'),
+ keybindings: [TOGGLE_PERFORMANCE_BAR, TOGGLE_CANARY],
+};
+
+export const WEB_IDE_GROUP = {
+ id: 'webIDE',
+ name: s__('KeyboardShortcuts|Web IDE'),
+ keybindings: [WEB_IDE_COMMIT],
+};
+
+/** All keybindings, grouped and ordered with descriptions */
+export const keybindingGroups = [GLOBAL_SHORTCUTS_GROUP, WEB_IDE_GROUP];
/**
* Gets keyboard shortcuts associated with a command
*
- * @param {string} command The command string. All command
- * strings are available as imports from this file.
+ * @param {string} command The command object. All command
+ * objects are available as imports from this file.
*
* @returns {string[]} An array of keyboard shortcut strings bound to the command
*
@@ -95,9 +84,11 @@ const commandToKeys = flatten(keybindingGroups.map((group) => group.keybindings)
* Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), handler);
*/
export const keysFor = (command) => {
- if (shouldDisableShortcuts()) {
- return [];
+ if (!isCustomizable(command)) {
+ // if the command is defined with `customizable: false`,
+ // don't allow this command to be customized.
+ return command.defaultKeys;
}
- return commandToKeys[command];
+ return getCustomizations()[command.id] || command.defaultKeys;
};
diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js
index 4b63143c4ba..30424fee46a 100644
--- a/app/assets/javascripts/behaviors/toggler_behavior.js
+++ b/app/assets/javascripts/behaviors/toggler_behavior.js
@@ -30,7 +30,7 @@ $(() => {
}
$('body').on('click', '.js-toggle-button', function toggleButton(e) {
- e.currentTarget.classList.toggle(e.currentTarget.dataset.toggleOpenClass || 'open');
+ e.currentTarget.classList.toggle(e.currentTarget.dataset.toggleOpenClass || 'selected');
toggleContainer($(this).closest('.js-toggle-container'));
const targetTag = e.currentTarget.tagName.toLowerCase();
diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js
index e72c5c90986..470c679b8ba 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -83,6 +83,7 @@ export default class BlobFileDropzone {
submitButton.on('click', (e) => {
e.preventDefault();
e.stopPropagation();
+
if (dropzone[0].dropzone.getQueuedFiles().length === 0) {
// eslint-disable-next-line no-alert
alert(__('Please select a file'));
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index b4cd0d5d875..4741152afce 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -62,6 +62,7 @@ export default class BlobViewer {
this.switcher = document.querySelector('.js-blob-viewer-switcher');
this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn');
this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn');
+ this.copySourceBtnTooltip = document.querySelector('.js-copy-blob-source-btn-tooltip');
this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]');
this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]');
@@ -109,23 +110,23 @@ export default class BlobViewer {
toggleCopyButtonState() {
if (!this.copySourceBtn) return;
if (this.simpleViewer.getAttribute('data-loaded')) {
- this.copySourceBtn.setAttribute('title', __('Copy file contents'));
+ this.copySourceBtnTooltip.setAttribute('title', __('Copy file contents'));
this.copySourceBtn.classList.remove('disabled');
} else if (this.activeViewer === this.simpleViewer) {
- this.copySourceBtn.setAttribute(
+ this.copySourceBtnTooltip.setAttribute(
'title',
__('Wait for the file to load to copy its contents'),
);
this.copySourceBtn.classList.add('disabled');
} else {
- this.copySourceBtn.setAttribute(
+ this.copySourceBtnTooltip.setAttribute(
'title',
__('Switch to the source to copy the file contents'),
);
this.copySourceBtn.classList.add('disabled');
}
- fixTitle($(this.copySourceBtn));
+ fixTitle($(this.copySourceBtnTooltip));
}
switchToViewer(name) {
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 13ad820477f..2cd25f58770 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -36,11 +36,11 @@ export function formatIssue(issue) {
}
export function formatListIssues(listIssues) {
- const issues = {};
- let listIssuesCount;
+ const boardItems = {};
+ let listItemsCount;
const listData = listIssues.nodes.reduce((map, list) => {
- listIssuesCount = list.issues.count;
+ listItemsCount = list.issues.count;
let sortedIssues = list.issues.edges.map((issueNode) => ({
...issueNode.node,
}));
@@ -58,14 +58,14 @@ export function formatListIssues(listIssues) {
assignees: i.assignees?.nodes || [],
};
- issues[id] = listIssue;
+ boardItems[id] = listIssue;
return id;
}),
};
}, {});
- return { listData, issues, listIssuesCount };
+ return { listData, boardItems, listItemsCount };
}
export function formatListsPageInfo(lists) {
@@ -113,31 +113,31 @@ export function formatIssueInput(issueInput, boardConfig) {
};
}
-export function moveIssueListHelper(issue, fromList, toList) {
- const updatedIssue = issue;
+export function moveItemListHelper(item, fromList, toList) {
+ const updatedItem = item;
if (
toList.listType === ListType.label &&
- !updatedIssue.labels.find((label) => label.id === toList.label.id)
+ !updatedItem.labels.find((label) => label.id === toList.label.id)
) {
- updatedIssue.labels.push(toList.label);
+ updatedItem.labels.push(toList.label);
}
if (fromList?.label && fromList.listType === ListType.label) {
- updatedIssue.labels = updatedIssue.labels.filter((label) => fromList.label.id !== label.id);
+ updatedItem.labels = updatedItem.labels.filter((label) => fromList.label.id !== label.id);
}
if (
toList.listType === ListType.assignee &&
- !updatedIssue.assignees.find((assignee) => assignee.id === toList.assignee.id)
+ !updatedItem.assignees.find((assignee) => assignee.id === toList.assignee.id)
) {
- updatedIssue.assignees.push(toList.assignee);
+ updatedItem.assignees.push(toList.assignee);
}
if (fromList?.assignee && fromList.listType === ListType.assignee) {
- updatedIssue.assignees = updatedIssue.assignees.filter(
+ updatedItem.assignees = updatedItem.assignees.filter(
(assignee) => assignee.id !== fromList.assignee.id,
);
}
- return updatedIssue;
+ return updatedItem;
}
export function isListDraggable(list) {
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue
new file mode 100644
index 00000000000..3c7c792b787
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -0,0 +1,143 @@
+<script>
+import {
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlLabel,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
+import { ListType } from '~/boards/constants';
+import boardsStore from '~/boards/stores/boards_store';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+
+export default {
+ components: {
+ BoardAddNewColumnForm,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlLabel,
+ },
+ directives: {
+ GlTooltip,
+ },
+ inject: ['scopedLabelsAvailable'],
+ data() {
+ return {
+ selectedId: null,
+ };
+ },
+ computed: {
+ ...mapState(['labels', 'labelsLoading']),
+ ...mapGetters(['getListByLabelId', 'shouldUseGraphQL']),
+ selectedLabel() {
+ if (!this.selectedId) {
+ return null;
+ }
+ return this.labels.find(({ id }) => id === this.selectedId);
+ },
+ columnForSelected() {
+ return this.getListByLabelId(this.selectedId);
+ },
+ },
+ created() {
+ this.filterItems();
+ },
+ methods: {
+ ...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']),
+ highlight(listId) {
+ if (this.shouldUseGraphQL) {
+ this.highlightList(listId);
+ } else {
+ const list = boardsStore.state.lists.find(({ id }) => id === listId);
+ list.highlighted = true;
+ setTimeout(() => {
+ list.highlighted = false;
+ }, 2000);
+ }
+ },
+ addList() {
+ if (!this.selectedLabel) {
+ return;
+ }
+
+ this.setAddColumnFormVisibility(false);
+
+ if (this.columnForSelected) {
+ const listId = this.columnForSelected.id;
+ this.highlight(listId);
+ return;
+ }
+
+ if (this.shouldUseGraphQL) {
+ this.createList({ labelId: this.selectedId });
+ } else {
+ const listObj = {
+ labelId: getIdFromGraphQLId(this.selectedId),
+ title: this.selectedLabel.title,
+ position: boardsStore.state.lists.length - 2,
+ list_type: ListType.label,
+ label: this.selectedLabel,
+ };
+
+ boardsStore.new(listObj);
+ }
+ },
+
+ filterItems(searchTerm) {
+ this.fetchLabels(searchTerm);
+ },
+
+ showScopedLabels(label) {
+ return this.scopedLabelsAvailable && isScopedLabel(label);
+ },
+ },
+};
+</script>
+
+<template>
+ <board-add-new-column-form
+ :loading="labelsLoading"
+ :form-description="__('A label list displays issues with the selected label.')"
+ :search-label="__('Select label')"
+ :search-placeholder="__('Search labels')"
+ :selected-id="selectedId"
+ @filter-items="filterItems"
+ @add-list="addList"
+ >
+ <template slot="selected">
+ <gl-label
+ v-if="selectedLabel"
+ v-gl-tooltip
+ :title="selectedLabel.title"
+ :description="selectedLabel.description"
+ :background-color="selectedLabel.color"
+ :scoped="showScopedLabels(selectedLabel)"
+ />
+ </template>
+
+ <template slot="items">
+ <gl-form-radio-group
+ v-if="labels.length > 0"
+ v-model="selectedId"
+ class="gl-overflow-y-auto gl-px-5 gl-pt-3"
+ >
+ <label
+ v-for="label in labels"
+ :key="label.id"
+ class="gl-display-flex gl-flex-align-items-center gl-mb-5 gl-font-weight-normal"
+ >
+ <gl-form-radio :value="label.id" class="gl-mb-0" />
+ <span
+ class="dropdown-label-box gl-top-0"
+ :style="{
+ backgroundColor: label.color,
+ }"
+ ></span>
+ <span>{{ label.title }}</span>
+ </label>
+ </gl-form-radio-group>
+ </template>
+ </board-add-new-column-form>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
new file mode 100644
index 00000000000..d85343a5390
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
@@ -0,0 +1,131 @@
+<script>
+import { GlButton, GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ add: __('Add to board'),
+ cancel: __('Cancel'),
+ newList: __('New list'),
+ noneSelected: __('None'),
+ noResults: __('No matching results'),
+ selected: __('Selected'),
+ },
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlSearchBoxByType,
+ GlSkeletonLoader,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ formDescription: {
+ type: String,
+ required: true,
+ },
+ searchLabel: {
+ type: String,
+ required: true,
+ },
+ searchPlaceholder: {
+ type: String,
+ required: true,
+ },
+ selectedId: {
+ type: [Number, String],
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ searchValue: '',
+ };
+ },
+ methods: {
+ ...mapActions(['setAddColumnFormVisibility']),
+ },
+};
+</script>
+
+<template>
+ <div
+ class="board-add-new-list board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0"
+ data-testid="board-add-new-column"
+ data-qa-selector="board_add_new_list"
+ >
+ <div
+ class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-white"
+ >
+ <h3
+ class="gl-font-base gl-px-5 gl-py-5 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ data-testid="board-add-column-form-title"
+ >
+ {{ $options.i18n.newList }}
+ </h3>
+
+ <div class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-hidden">
+ <slot name="select-list-type">
+ <div class="gl-mb-5"></div>
+ </slot>
+
+ <p class="gl-px-5">{{ formDescription }}</p>
+
+ <div class="gl-px-5 gl-pb-4">
+ <label class="gl-mb-2">{{ $options.i18n.selected }}</label>
+ <slot name="selected">
+ <div class="gl-text-gray-500">{{ $options.i18n.noneSelected }}</div>
+ </slot>
+ </div>
+
+ <gl-form-group
+ class="gl-mx-5 gl-mb-3"
+ :label="searchLabel"
+ label-for="board-available-column-entities"
+ >
+ <gl-search-box-by-type
+ id="board-available-column-entities"
+ v-model="searchValue"
+ debounce="250"
+ :placeholder="searchPlaceholder"
+ @input="$emit('filter-items', $event)"
+ />
+ </gl-form-group>
+
+ <div v-if="loading" class="gl-px-5">
+ <gl-skeleton-loader :width="500" :height="172">
+ <rect width="480" height="20" x="10" y="15" rx="4" />
+ <rect width="380" height="20" x="10" y="50" rx="4" />
+ <rect width="430" height="20" x="10" y="85" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+
+ <slot v-else name="items">
+ <p class="gl-mx-5">{{ $options.i18n.noResults }}</p>
+ </slot>
+ </div>
+ <div
+ class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
+ >
+ <gl-button
+ data-testid="cancelAddNewColumn"
+ class="gl-ml-auto gl-mr-3"
+ @click="setAddColumnFormVisibility(false)"
+ >{{ $options.i18n.cancel }}</gl-button
+ >
+ <gl-button
+ data-testid="addNewColumnButton"
+ :disabled="!selectedId"
+ variant="confirm"
+ class="gl-mr-4"
+ @click="$emit('add-list')"
+ >{{ $options.i18n.add }}</gl-button
+ >
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
index 85fca589279..7c08e33be7e 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
@@ -13,7 +13,7 @@ export default {
</script>
<template>
- <span class="gl-ml-3 gl-display-flex gl-align-items-center">
+ <span class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list">
<gl-button variant="confirm" @click="setAddColumnFormVisibility(true)"
>{{ __('Create list') }}
</gl-button>
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index e6009343626..aacea0b970c 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -1,14 +1,11 @@
<script>
-import sidebarEventHub from '~/sidebar/event_hub';
-import eventHub from '../eventhub';
-import boardsStore from '../stores/boards_store';
-import BoardCardLayout from './board_card_layout.vue';
-import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import BoardCardInner from './board_card_inner.vue';
export default {
- name: 'BoardsIssueCard',
+ name: 'BoardCard',
components: {
- BoardCardLayout: gon.features?.graphqlBoardLists ? BoardCardLayout : BoardCardLayoutDeprecated,
+ BoardCardInner,
},
props: {
list: {
@@ -16,34 +13,46 @@ export default {
default: () => ({}),
required: false,
},
- issue: {
+ item: {
type: Object,
default: () => ({}),
required: false,
},
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ index: {
+ type: Number,
+ default: 0,
+ required: false,
+ },
},
- methods: {
- // These are methods instead of computed's, because boardsStore is not reactive.
+ computed: {
+ ...mapState(['selectedBoardItems', 'activeId']),
+ ...mapGetters(['isSwimlanesOn']),
isActive() {
- return this.getActiveId() === this.issue.id;
+ return this.item.id === this.activeId;
},
- getActiveId() {
- return boardsStore.detail?.issue?.id;
+ multiSelectVisible() {
+ return (
+ !this.activeId &&
+ this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1
+ );
},
- showIssue({ isMultiSelect }) {
- // If no issues are opened, close all sidebars first
- if (!this.getActiveId()) {
- sidebarEventHub.$emit('sidebar.closeAll');
- }
- if (this.isActive()) {
- eventHub.$emit('clearDetailIssue', isMultiSelect);
+ },
+ methods: {
+ ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']),
+ toggleIssue(e) {
+ // Don't do anything if this happened on a no trigger element
+ if (e.target.classList.contains('js-no-trigger')) return;
- if (isMultiSelect) {
- eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
- }
+ const isMultiSelect = e.ctrlKey || e.metaKey;
+ if (isMultiSelect) {
+ this.toggleBoardItemMultiSelection(this.item);
} else {
- eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
- boardsStore.setListDetail(this.list);
+ this.toggleBoardItem({ boardItem: this.item });
}
},
},
@@ -51,12 +60,22 @@ export default {
</script>
<template>
- <board-card-layout
+ <li
data-qa-selector="board_card"
- :issue="issue"
- :list="list"
- :is-active="isActive()"
- v-bind="$attrs"
- @show="showIssue"
- />
+ :class="{
+ 'multi-select': multiSelectVisible,
+ 'user-can-drag': !disabled && item.id,
+ 'is-disabled': disabled || !item.id,
+ 'is-active': isActive,
+ }"
+ :index="index"
+ :data-item-id="item.id"
+ :data-item-iid="item.iid"
+ :data-item-path="item.referencePath"
+ data-testid="board_card"
+ class="board-card gl-p-5 gl-rounded-base"
+ @mouseup="toggleIssue($event)"
+ >
+ <board-card-inner :list="list" :item="item" :update-filters="true" />
+ </li>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card_deprecated.vue b/app/assets/javascripts/boards/components/board_card_deprecated.vue
new file mode 100644
index 00000000000..e12a2836f67
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_card_deprecated.vue
@@ -0,0 +1,61 @@
+<script>
+// This component is being replaced in favor of './board_card.vue' for GraphQL boards
+import sidebarEventHub from '~/sidebar/event_hub';
+import eventHub from '../eventhub';
+import boardsStore from '../stores/boards_store';
+import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
+
+export default {
+ components: {
+ BoardCardLayout: BoardCardLayoutDeprecated,
+ },
+ props: {
+ list: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ issue: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ },
+ methods: {
+ // These are methods instead of computed's, because boardsStore is not reactive.
+ isActive() {
+ return this.getActiveId() === this.issue.id;
+ },
+ getActiveId() {
+ return boardsStore.detail?.issue?.id;
+ },
+ showIssue({ isMultiSelect }) {
+ // If no issues are opened, close all sidebars first
+ if (!this.getActiveId()) {
+ sidebarEventHub.$emit('sidebar.closeAll');
+ }
+ if (this.isActive()) {
+ eventHub.$emit('clearDetailIssue', isMultiSelect);
+
+ if (isMultiSelect) {
+ eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
+ }
+ } else {
+ eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
+ boardsStore.setListDetail(this.list);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <board-card-layout
+ data-qa-selector="board_card"
+ :issue="issue"
+ :list="list"
+ :is-active="isActive()"
+ v-bind="$attrs"
+ @show="showIssue"
+ />
+</template>
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index e5ea30df767..d4d6b17a589 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -1,8 +1,8 @@
<script>
import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { sortBy } from 'lodash';
-import { mapActions, mapState } from 'vuex';
-import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { updateHistory } from '~/lib/utils/url_utility';
import { sprintf, __, n__ } from '~/locale';
@@ -26,10 +26,10 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [issueCardInner],
- inject: ['groupId', 'rootPath', 'scopedLabelsAvailable'],
+ mixins: [boardCardInner],
+ inject: ['rootPath', 'scopedLabelsAvailable'],
props: {
- issue: {
+ item: {
type: Object,
required: true,
},
@@ -53,18 +53,19 @@ export default {
},
computed: {
...mapState(['isShowingLabels']),
+ ...mapGetters(['isEpicBoard']),
cappedAssignees() {
// e.g. maxRender is 4,
// Render up to all 4 assignees if there are only 4 assigness
// Otherwise render up to the limitBeforeCounter
- if (this.issue.assignees.length <= this.maxRender) {
- return this.issue.assignees.slice(0, this.maxRender);
+ if (this.item.assignees.length <= this.maxRender) {
+ return this.item.assignees.slice(0, this.maxRender);
}
- return this.issue.assignees.slice(0, this.limitBeforeCounter);
+ return this.item.assignees.slice(0, this.limitBeforeCounter);
},
numberOverLimit() {
- return this.issue.assignees.length - this.limitBeforeCounter;
+ return this.item.assignees.length - this.limitBeforeCounter;
},
assigneeCounterTooltip() {
const { numberOverLimit, maxCounter } = this;
@@ -79,31 +80,35 @@ export default {
return `+${this.numberOverLimit}`;
},
shouldRenderCounter() {
- if (this.issue.assignees.length <= this.maxRender) {
+ if (this.item.assignees.length <= this.maxRender) {
return false;
}
- return this.issue.assignees.length > this.numberOverLimit;
+ return this.item.assignees.length > this.numberOverLimit;
},
- issueId() {
- if (this.issue.iid) {
- return `#${this.issue.iid}`;
+ itemPrefix() {
+ return this.isEpicBoard ? '&' : '#';
+ },
+
+ itemId() {
+ if (this.item.iid) {
+ return `${this.itemPrefix}${this.item.iid}`;
}
return false;
},
showLabelFooter() {
- return this.isShowingLabels && this.issue.labels.find(this.showLabel);
+ return this.isShowingLabels && this.item.labels.find(this.showLabel);
},
- issueReferencePath() {
- const { referencePath, groupId } = this.issue;
- return !groupId ? referencePath.split('#')[0] : null;
+ itemReferencePath() {
+ const { referencePath } = this.item;
+ return referencePath.split(this.itemPrefix)[0];
},
orderedLabels() {
- return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title');
+ return sortBy(this.item.labels.filter(this.isNonListLabel), 'title');
},
blockedLabel() {
- if (this.issue.blockedByCount) {
- return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount);
+ if (this.item.blockedByCount) {
+ return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.item.blockedByCount);
}
return __('Blocked issue');
},
@@ -160,7 +165,7 @@ export default {
<div class="gl-display-flex" dir="auto">
<h4 class="board-card-title gl-mb-0 gl-mt-0">
<gl-icon
- v-if="issue.blocked"
+ v-if="item.blocked"
v-gl-tooltip
name="issue-block"
:title="blockedLabel"
@@ -169,7 +174,7 @@ export default {
data-testid="issue-blocked-icon"
/>
<gl-icon
- v-if="issue.confidential"
+ v-if="item.confidential"
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
@@ -177,11 +182,11 @@ export default {
:aria-label="__('Confidential')"
/>
<a
- :href="issue.path || issue.webUrl || ''"
- :title="issue.title"
+ :href="item.path || item.webUrl || ''"
+ :title="item.title"
class="js-no-trigger"
@mousemove.stop
- >{{ issue.title }}</a
+ >{{ item.title }}</a
>
</h4>
</div>
@@ -205,29 +210,30 @@ export default {
class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container"
>
<span
- v-if="issue.referencePath"
+ v-if="item.referencePath"
class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3"
+ :class="{ 'gl-font-base': isEpicBoard }"
>
<tooltip-on-truncate
- v-if="issueReferencePath"
- :title="issueReferencePath"
+ v-if="itemReferencePath"
+ :title="itemReferencePath"
placement="bottom"
- class="board-issue-path gl-text-truncate gl-font-weight-bold"
- >{{ issueReferencePath }}</tooltip-on-truncate
+ class="board-item-path gl-text-truncate gl-font-weight-bold"
+ >{{ itemReferencePath }}</tooltip-on-truncate
>
- #{{ issue.iid }}
+ {{ itemId }}
</span>
<span class="board-info-items gl-mt-3 gl-display-inline-block">
<issue-due-date
- v-if="issue.dueDate"
- :date="issue.dueDate"
- :closed="issue.closed || Boolean(issue.closedAt)"
+ v-if="item.dueDate"
+ :date="item.dueDate"
+ :closed="item.closed || Boolean(item.closedAt)"
/>
- <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
+ <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" />
<issue-card-weight
- v-if="validIssueWeight"
- :weight="issue.weight"
- @click="filterByWeight(issue.weight)"
+ v-if="validIssueWeight(item)"
+ :weight="item.weight"
+ @click="filterByWeight(item.weight)"
/>
</span>
</div>
diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue
deleted file mode 100644
index 5e3c3702519..00000000000
--- a/app/assets/javascripts/boards/components/board_card_layout.vue
+++ /dev/null
@@ -1,98 +0,0 @@
-<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
-import { ISSUABLE } from '~/boards/constants';
-import IssueCardInner from './issue_card_inner.vue';
-
-export default {
- name: 'BoardCardLayout',
- components: {
- IssueCardInner,
- },
- props: {
- list: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- issue: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- disabled: {
- type: Boolean,
- default: false,
- required: false,
- },
- index: {
- type: Number,
- default: 0,
- required: false,
- },
- isActive: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- showDetail: false,
- };
- },
- computed: {
- ...mapState(['selectedBoardItems']),
- ...mapGetters(['isSwimlanesOn']),
- multiSelectVisible() {
- return this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1;
- },
- },
- methods: {
- ...mapActions(['setActiveId', 'toggleBoardItemMultiSelection']),
- mouseDown() {
- this.showDetail = true;
- },
- mouseMove() {
- this.showDetail = false;
- },
- showIssue(e) {
- // Don't do anything if this happened on a no trigger element
- if (e.target.classList.contains('js-no-trigger')) return;
-
- const isMultiSelect = e.ctrlKey || e.metaKey;
-
- if (!isMultiSelect) {
- this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE });
- } else {
- this.toggleBoardItemMultiSelection(this.issue);
- }
-
- if (this.showDetail || isMultiSelect) {
- this.showDetail = false;
- }
- },
- },
-};
-</script>
-
-<template>
- <li
- :class="{
- 'multi-select': multiSelectVisible,
- 'user-can-drag': !disabled && issue.id,
- 'is-disabled': disabled || !issue.id,
- 'is-active': isActive,
- }"
- :index="index"
- :data-issue-id="issue.id"
- :data-issue-iid="issue.iid"
- :data-issue-path="issue.referencePath"
- data-testid="board_card"
- class="board-card gl-p-5 gl-rounded-base"
- @mousedown="mouseDown"
- @mousemove="mouseMove"
- @mouseup="showIssue($event)"
- >
- <issue-card-inner :list="list" :issue="issue" :update-filters="true" />
- </li>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue
index f9a726134a3..3381e4c3a7d 100644
--- a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue
@@ -3,13 +3,12 @@ import { mapActions, mapGetters } from 'vuex';
import { ISSUABLE } from '~/boards/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import boardsStore from '../stores/boards_store';
-import IssueCardInner from './issue_card_inner.vue';
import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue';
export default {
name: 'BoardCardLayout',
components: {
- IssueCardInner: gon.features?.graphqlBoardLists ? IssueCardInner : IssueCardInnerDeprecated,
+ IssueCardInner: IssueCardInnerDeprecated,
},
mixins: [glFeatureFlagMixin()],
props: {
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 41b9ee795eb..c9e667d526c 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -32,12 +32,12 @@ export default {
},
computed: {
...mapState(['filterParams', 'highlightedLists']),
- ...mapGetters(['getIssuesByList']),
+ ...mapGetters(['getBoardItemsByList']),
highlighted() {
return this.highlightedLists.includes(this.list.id);
},
- listIssues() {
- return this.getIssuesByList(this.list.id);
+ listItems() {
+ return this.getBoardItemsByList(this.list.id);
},
isListDraggable() {
return isListDraggable(this.list);
@@ -46,11 +46,20 @@ export default {
watch: {
filterParams: {
handler() {
- this.fetchIssuesForList({ listId: this.list.id });
+ if (this.list.id) {
+ this.fetchItemsForList({ listId: this.list.id });
+ }
},
deep: true,
immediate: true,
},
+ 'list.id': {
+ handler(id) {
+ if (id) {
+ this.fetchItemsForList({ listId: this.list.id });
+ }
+ },
+ },
highlighted: {
handler(highlighted) {
if (highlighted) {
@@ -63,7 +72,7 @@ export default {
},
},
methods: {
- ...mapActions(['fetchIssuesForList']),
+ ...mapActions(['fetchItemsForList']),
},
};
</script>
@@ -87,7 +96,7 @@ export default {
<board-list
ref="board-list"
:disabled="disabled"
- :issues="listIssues"
+ :board-items="listItems"
:list="list"
:can-admin-list="canAdminList"
/>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 9b10e7d7db5..e9c4237d759 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -3,6 +3,7 @@ import { GlAlert } from '@gitlab/ui';
import { sortBy } from 'lodash';
import Draggable from 'vuedraggable';
import { mapState, mapGetters, mapActions } from 'vuex';
+import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options';
import defaultSortableConfig from '~/sortable/sortable_config';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -11,7 +12,11 @@ import BoardColumnDeprecated from './board_column_deprecated.vue';
export default {
components: {
- BoardColumn: gon.features?.graphqlBoardLists ? BoardColumn : BoardColumnDeprecated,
+ BoardAddNewColumn,
+ BoardColumn:
+ gon.features?.graphqlBoardLists || gon.features?.epicBoards
+ ? BoardColumn
+ : BoardColumnDeprecated,
BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'),
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
@@ -33,15 +38,18 @@ export default {
},
},
computed: {
- ...mapState(['boardLists', 'error']),
- ...mapGetters(['isSwimlanesOn']),
+ ...mapState(['boardLists', 'error', 'addColumnForm']),
+ ...mapGetters(['isSwimlanesOn', 'isEpicBoard']),
+ addColumnFormVisible() {
+ return this.addColumnForm?.visible;
+ },
boardListsToUse() {
- return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn
+ return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard
? sortBy([...Object.values(this.boardLists)], 'position')
: this.lists;
},
canDragColumns() {
- return this.glFeatures.graphqlBoardLists && this.canAdminList;
+ return !this.isEpicBoard && this.glFeatures.graphqlBoardLists && this.canAdminList;
},
boardColumnWrapper() {
return this.canDragColumns ? Draggable : 'div';
@@ -62,12 +70,17 @@ export default {
},
methods: {
...mapActions(['moveList']),
+ afterFormEnters() {
+ const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
+ el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
+ },
handleDragOnStart() {
sortableStart();
},
handleDragOnEnd(params) {
sortableEnd();
+ if (this.isEpicBoard) return;
const { item, newIndex, oldIndex, to } = params;
@@ -100,13 +113,17 @@ export default {
@end="handleDragOnEnd"
>
<board-column
- v-for="list in boardListsToUse"
- :key="list.id"
+ v-for="(list, index) in boardListsToUse"
+ :key="index"
ref="board"
:can-admin-list="canAdminList"
:list="list"
:disabled="disabled"
/>
+
+ <transition name="slide" @after-enter="afterFormEnters">
+ <board-add-new-column v-if="addColumnFormVisible" />
+ </transition>
</component>
<epics-swimlanes
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index f65f00bcccc..d8504dcfb0f 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal } from '@gitlab/ui';
+import { mapGetters } from 'vuex';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { getParameterByName } from '~/lib/utils/common_utils';
@@ -106,6 +107,7 @@ export default {
};
},
computed: {
+ ...mapGetters(['isEpicBoard', 'isGroupBoard', 'isProjectBoard']),
isNewForm() {
return this.currentPage === formType.new;
},
@@ -161,42 +163,49 @@ export default {
currentMutation() {
return this.board.id ? updateBoardMutation : createBoardMutation;
},
- mutationVariables() {
+ baseMutationVariables() {
const { board } = this;
- /* eslint-disable @gitlab/require-i18n-strings */
- let baseMutationVariables = {
+ const variables = {
name: board.name,
hideBacklogList: board.hide_backlog_list,
hideClosedList: board.hide_closed_list,
};
- if (this.scopedIssueBoardFeatureEnabled) {
- baseMutationVariables = {
- ...baseMutationVariables,
- weight: board.weight,
- assigneeId: board.assignee?.id ? convertToGraphQLId('User', board.assignee.id) : null,
- milestoneId:
- board.milestone?.id || board.milestone?.id === 0
- ? convertToGraphQLId('Milestone', board.milestone.id)
- : null,
- labelIds: board.labels.map(fullLabelId),
- iterationId: board.iteration_id
- ? convertToGraphQLId('Iteration', board.iteration_id)
- : null,
- };
- }
- /* eslint-enable @gitlab/require-i18n-strings */
return board.id
? {
- ...baseMutationVariables,
+ ...variables,
id: fullBoardId(board.id),
}
: {
- ...baseMutationVariables,
- projectPath: this.projectId ? this.fullPath : null,
- groupPath: this.groupId ? this.fullPath : null,
+ ...variables,
+ projectPath: this.isProjectBoard ? this.fullPath : undefined,
+ groupPath: this.isGroupBoard ? this.fullPath : undefined,
};
},
+ boardScopeMutationVariables() {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ return {
+ weight: this.board.weight,
+ assigneeId: this.board.assignee?.id
+ ? convertToGraphQLId('User', this.board.assignee.id)
+ : null,
+ milestoneId:
+ this.board.milestone?.id || this.board.milestone?.id === 0
+ ? convertToGraphQLId('Milestone', this.board.milestone.id)
+ : null,
+ labelIds: this.board.labels.map(fullLabelId),
+ iterationId: this.board.iteration_id
+ ? convertToGraphQLId('Iteration', this.board.iteration_id)
+ : null,
+ };
+ /* eslint-enable @gitlab/require-i18n-strings */
+ },
+ mutationVariables() {
+ return {
+ ...this.baseMutationVariables,
+ ...(this.scopedIssueBoardFeatureEnabled ? this.boardScopeMutationVariables : {}),
+ };
+ },
},
mounted() {
this.resetFormState();
@@ -208,6 +217,16 @@ export default {
setIteration(iterationId) {
this.board.iteration_id = iterationId;
},
+ boardCreateResponse(data) {
+ return data.createBoard.board.webPath;
+ },
+ boardUpdateResponse(data) {
+ const path = data.updateBoard.board.webPath;
+ const param = getParameterByName('group_by')
+ ? `?group_by=${getParameterByName('group_by')}`
+ : '';
+ return `${path}${param}`;
+ },
async createOrUpdateBoard() {
const response = await this.$apollo.mutate({
mutation: this.currentMutation,
@@ -215,14 +234,10 @@ export default {
});
if (!this.board.id) {
- return response.data.createBoard.board.webPath;
+ return this.boardCreateResponse(response.data);
}
- const path = response.data.updateBoard.board.webPath;
- const param = getParameterByName('group_by')
- ? `?group_by=${getParameterByName('group_by')}`
- : '';
- return `${path}${param}`;
+ return this.boardUpdateResponse(response.data);
},
async submit() {
if (this.board.name.length === 0) return;
@@ -309,7 +324,7 @@ export default {
/>
<board-scope
- v-if="scopedIssueBoardFeatureEnabled"
+ v-if="scopedIssueBoardFeatureEnabled && !isEpicBoard"
:collapse-scope="isNewForm"
:board="board"
:can-admin-board="canAdminBoard"
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 7495b1163be..ae8434be312 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import Draggable from 'vuedraggable';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
import { sprintf, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config';
@@ -12,9 +12,10 @@ import BoardNewIssue from './board_new_issue.vue';
export default {
name: 'BoardList',
i18n: {
- loadingIssues: __('Loading issues'),
- loadingMoreissues: __('Loading more issues'),
+ loading: __('Loading'),
+ loadingMoreboardItems: __('Loading more'),
showingAllIssues: __('Showing all issues'),
+ showingAllEpics: __('Showing all epics'),
},
components: {
BoardCard,
@@ -30,7 +31,7 @@ export default {
type: Object,
required: true,
},
- issues: {
+ boardItems: {
type: Array,
required: true,
},
@@ -49,14 +50,19 @@ export default {
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags']),
+ ...mapGetters(['isEpicBoard']),
+ listItemsCount() {
+ return this.isEpicBoard ? this.list.epicsCount : this.list.issuesCount;
+ },
paginatedIssueText() {
- return sprintf(__('Showing %{pageSize} of %{total} issues'), {
- pageSize: this.issues.length,
- total: this.list.issuesCount,
+ return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), {
+ pageSize: this.boardItems.length,
+ total: this.listItemsCount,
+ issuableType: this.isEpicBoard ? 'epics' : 'issues',
});
},
- issuesSizeExceedsMax() {
- return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount;
+ boardItemsSizeExceedsMax() {
+ return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount;
},
hasNextPage() {
return this.pageInfoByListId[this.list.id].hasNextPage;
@@ -71,8 +77,13 @@ export default {
// When list is draggable, the reference to the list needs to be accessed differently
return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
},
- showingAllIssues() {
- return this.issues.length === this.list.issuesCount;
+ showingAllItems() {
+ return this.boardItems.length === this.listItemsCount;
+ },
+ showingAllItemsText() {
+ return this.isEpicBoard
+ ? this.$options.i18n.showingAllEpics
+ : this.$options.i18n.showingAllIssues;
},
treeRootWrapper() {
return this.canAdminList ? Draggable : 'ul';
@@ -85,14 +96,14 @@ export default {
tag: 'ul',
'ghost-class': 'board-card-drag-active',
'data-list-id': this.list.id,
- value: this.issues,
+ value: this.boardItems,
};
return this.canAdminList ? options : {};
},
},
watch: {
- issues() {
+ boardItems() {
this.$nextTick(() => {
this.showCount = this.scrollHeight() > Math.ceil(this.listHeight());
});
@@ -112,7 +123,7 @@ export default {
this.listRef.removeEventListener('scroll', this.onScroll);
},
methods: {
- ...mapActions(['fetchIssuesForList', 'moveIssue']),
+ ...mapActions(['fetchItemsForList', 'moveItem']),
listHeight() {
return this.listRef.getBoundingClientRect().height;
},
@@ -126,7 +137,7 @@ export default {
this.listRef.scrollTop = 0;
},
loadNextPage() {
- this.fetchIssuesForList({ listId: this.list.id, fetchNext: true });
+ this.fetchItemsForList({ listId: this.list.id, fetchNext: true });
},
toggleForm() {
this.showIssueForm = !this.showIssueForm;
@@ -148,40 +159,40 @@ export default {
handleDragOnEnd(params) {
sortableEnd();
const { newIndex, oldIndex, from, to, item } = params;
- const { issueId, issueIid, issuePath } = item.dataset;
+ const { itemId, itemIid, itemPath } = item.dataset;
const { children } = to;
let moveBeforeId;
let moveAfterId;
- const getIssueId = (el) => Number(el.dataset.issueId);
+ const getItemId = (el) => Number(el.dataset.itemId);
- // If issue is being moved within the same list
+ // If item is being moved within the same list
if (from === to) {
if (newIndex > oldIndex && children.length > 1) {
- // If issue is being moved down we look for the issue that ends up before
- moveBeforeId = getIssueId(children[newIndex]);
+ // If item is being moved down we look for the item that ends up before
+ moveBeforeId = getItemId(children[newIndex]);
} else if (newIndex < oldIndex && children.length > 1) {
- // If issue is being moved up we look for the issue that ends up after
- moveAfterId = getIssueId(children[newIndex]);
+ // If item is being moved up we look for the item that ends up after
+ moveAfterId = getItemId(children[newIndex]);
} else {
- // If issue remains in the same list at the same position we do nothing
+ // If item remains in the same list at the same position we do nothing
return;
}
} else {
- // We look for the issue that ends up before the moved issue if it exists
+ // We look for the item that ends up before the moved item if it exists
if (children[newIndex - 1]) {
- moveBeforeId = getIssueId(children[newIndex - 1]);
+ moveBeforeId = getItemId(children[newIndex - 1]);
}
- // We look for the issue that ends up after the moved issue if it exists
+ // We look for the item that ends up after the moved item if it exists
if (children[newIndex]) {
- moveAfterId = getIssueId(children[newIndex]);
+ moveAfterId = getItemId(children[newIndex]);
}
}
- this.moveIssue({
- issueId,
- issueIid,
- issuePath,
+ this.moveItem({
+ itemId,
+ itemIid,
+ itemPath,
fromListId: from.dataset.listId,
toListId: to.dataset.listId,
moveBeforeId,
@@ -201,7 +212,7 @@ export default {
<div
v-if="loading"
class="gl-mt-4 gl-text-center"
- :aria-label="$options.i18n.loadingIssues"
+ :aria-label="$options.i18n.loading"
data-testid="board_list_loading"
>
<gl-loading-icon />
@@ -214,24 +225,28 @@ export default {
v-bind="treeRootOptions"
:data-board="list.id"
:data-board-type="list.listType"
- :class="{ 'bg-danger-100': issuesSizeExceedsMax }"
+ :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }"
class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list"
data-testid="tree-root-wrapper"
@start="handleDragOnStart"
@end="handleDragOnEnd"
>
<board-card
- v-for="(issue, index) in issues"
+ v-for="(item, index) in boardItems"
ref="issue"
- :key="issue.id"
+ :key="item.id"
:index="index"
:list="list"
- :issue="issue"
+ :item="item"
:disabled="disabled"
/>
<li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
- <gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" />
- <span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span>
+ <gl-loading-icon
+ v-if="loadingMore"
+ :label="$options.i18n.loadingMoreboardItems"
+ data-testid="count-loading-icon"
+ />
+ <span v-if="showingAllItems">{{ showingAllItemsText }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
</component>
diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue
index 9b4961d362d..d59fbcc1b31 100644
--- a/app/assets/javascripts/boards/components/board_list_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue
@@ -11,7 +11,7 @@ import {
sortableEnd,
} from '../mixins/sortable_default_options';
import boardsStore from '../stores/boards_store';
-import boardCard from './board_card.vue';
+import boardCard from './board_card_deprecated.vue';
import boardNewIssue from './board_new_issue_deprecated.vue';
// This component is being replaced in favor of './board_list.vue' for GraphQL boards
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index a933370427c..6ccaec4a633 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -8,16 +8,16 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import { isListDraggable } from '~/boards/boards_util';
-import { isScopedLabel } from '~/lib/utils/common_utils';
+import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { n__, s__, __ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
import AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType } from '../constants';
import eventHub from '../eventhub';
-import IssueCount from './issue_count.vue';
+import ItemCount from './item_count.vue';
export default {
i18n: {
@@ -33,7 +33,7 @@ export default {
GlTooltip,
GlIcon,
GlSprintf,
- IssueCount,
+ ItemCount,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -70,6 +70,7 @@ export default {
},
computed: {
...mapState(['activeId']),
+ ...mapGetters(['isEpicBoard']),
isLoggedIn() {
return Boolean(this.currentUserId);
},
@@ -97,11 +98,14 @@ export default {
showListDetails() {
return !this.list.collapsed || !this.isSwimlanesHeader;
},
- issuesCount() {
+ itemsCount() {
return this.list.issuesCount;
},
- issuesTooltipLabel() {
- return n__(`%d issue`, `%d issues`, this.issuesCount);
+ countIcon() {
+ return 'issues';
+ },
+ itemsTooltipLabel() {
+ return n__(`%d issue`, `%d issues`, this.itemsCount);
},
chevronTooltip() {
return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
@@ -110,7 +114,7 @@ export default {
return this.list.collapsed ? 'chevron-down' : 'chevron-right';
},
isNewIssueShown() {
- return this.listType === ListType.backlog || this.showListHeaderButton;
+ return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard;
},
isSettingsShown() {
return (
@@ -131,8 +135,14 @@ export default {
return !this.disabled && isListDraggable(this.list);
},
},
+ created() {
+ const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`));
+ if ((!this.isLoggedIn || this.isEpicBoard) && localCollapsed) {
+ this.toggleListCollapsed({ listId: this.list.id, collapsed: true });
+ }
+ },
methods: {
- ...mapActions(['updateList', 'setActiveId']),
+ ...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']),
openSidebarSettings() {
if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll');
@@ -148,10 +158,10 @@ export default {
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
toggleExpanded() {
- // eslint-disable-next-line vue/no-mutating-props
- this.list.collapsed = !this.list.collapsed;
+ const collapsed = !this.list.collapsed;
+ this.toggleListCollapsed({ listId: this.list.id, collapsed });
- if (!this.isLoggedIn) {
+ if (!this.isLoggedIn || this.isEpicBoard) {
this.addToLocalStorage();
} else {
this.updateListFunction();
@@ -163,7 +173,7 @@ export default {
},
addToLocalStorage() {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
- localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed);
+ localStorage.setItem(`${this.uniqueKey}.collapsed`, this.list.collapsed);
}
},
updateListFunction() {
@@ -203,6 +213,7 @@ export default {
class="board-title-caret no-drag gl-cursor-pointer"
category="tertiary"
size="small"
+ data-testid="board-title-caret"
@click="toggleExpanded"
/>
<!-- EE start -->
@@ -301,11 +312,11 @@ export default {
<div v-if="list.maxIssueCount !== 0">
•
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
- <template #issuesSize>{{ issuesTooltipLabel }}</template>
+ <template #issuesSize>{{ itemsTooltipLabel }}</template>
<template #maxIssueCount>{{ list.maxIssueCount }}</template>
</gl-sprintf>
</div>
- <div v-else>• {{ issuesTooltipLabel }}</div>
+ <div v-else>• {{ itemsTooltipLabel }}</div>
<div v-if="weightFeatureAvailable">
•
<gl-sprintf :message="__('%{totalWeight} total weight')">
@@ -323,13 +334,13 @@ export default {
}"
>
<span class="gl-display-inline-flex">
- <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="issuesCount" :max-issue-count="list.maxIssueCount" />
+ <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" />
+ <span ref="itemCount" class="issue-count-badge-count">
+ <gl-icon class="gl-mr-2" :name="countIcon" />
+ <item-count :items-size="itemsCount" :max-issue-count="list.maxIssueCount" />
</span>
<!-- EE start -->
- <template v-if="weightFeatureAvailable">
+ <template v-if="weightFeatureAvailable && !isEpicBoard">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
<gl-icon class="gl-mr-2" name="weight" />
diff --git a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue
index ff043d3aa01..429ffd4cd06 100644
--- a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue
@@ -17,7 +17,7 @@ import AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType } from '../constants';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
-import IssueCount from './issue_count.vue';
+import IssueCount from './item_count.vue';
// This component is being replaced in favor of './board_list_header.vue' for GraphQL boards
@@ -308,7 +308,7 @@ export default {
<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="issuesCount" :max-issue-count="list.maxIssueCount" />
+ <issue-count :items-size="issuesCount" :max-issue-count="list.maxIssueCount" />
</span>
<!-- The following is only true in EE. -->
<template v-if="weightFeatureAvailable">
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index 1df154688c8..a81c28733cd 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 { GlButton } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -32,8 +32,9 @@ export default {
},
computed: {
...mapState(['selectedProject']),
+ ...mapGetters(['isGroupBoard']),
disabled() {
- if (this.groupId) {
+ if (this.isGroupBoard) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
@@ -98,7 +99,7 @@ export default {
name="issue_title"
autocomplete="off"
/>
- <project-select v-if="groupId" :group-id="groupId" :list="list" />
+ <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" />
<div class="clearfix gl-mt-3">
<gl-button
ref="submitButton"
diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
index eff87ff110e..16f23dfff0e 100644
--- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
+import { mapGetters } from 'vuex';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import ListIssue from 'ee_else_ce/boards/models/issue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -31,8 +32,9 @@ export default {
};
},
computed: {
+ ...mapGetters(['isGroupBoard']),
disabled() {
- if (this.groupId) {
+ if (this.isGroupBoard) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
@@ -110,7 +112,7 @@ export default {
name="issue_title"
autocomplete="off"
/>
- <project-select v-if="groupId" :group-id="groupId" :list="list" />
+ <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" />
<div class="clearfix gl-mt-3">
<gl-button
ref="submitButton"
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 6d5a13be3ac..55bc91cbcff 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -20,7 +20,6 @@ import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'
import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
import eventHub from '~/sidebar/event_hub';
import boardsStore from '../stores/boards_store';
-import RemoveBtn from './sidebar/remove_issue.vue';
export default Vue.extend({
components: {
@@ -29,7 +28,6 @@ export default Vue.extend({
GlLabel,
SidebarEpicsSelect: () =>
import('ee_component/sidebar/components/sidebar_item_epics_select.vue'),
- RemoveBtn,
Subscriptions,
TimeTracker,
SidebarAssigneesWidget,
@@ -107,8 +105,8 @@ export default Vue.extend({
closeSidebar() {
this.detail.issue = {};
},
- setAssignees(data) {
- boardsStore.detail.issue.setAssignees(data.issueSetAssignees.issue.assignees.nodes);
+ setAssignees(assignees) {
+ boardsStore.detail.issue.setAssignees(assignees);
},
showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 2a064aaa885..5124467136e 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -9,6 +9,9 @@ import {
GlModalDirective,
} from '@gitlab/ui';
import { throttle } from 'lodash';
+import { mapGetters, mapState } from 'vuex';
+
+import BoardForm from 'ee_else_ce/boards/components/board_form.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
@@ -18,8 +21,6 @@ import eventHub from '../eventhub';
import groupQuery from '../graphql/group_boards.query.graphql';
import projectQuery from '../graphql/project_boards.query.graphql';
-import BoardForm from './board_form.vue';
-
const MIN_BOARDS_TO_VIEW_RECENT = 10;
export default {
@@ -109,8 +110,10 @@ export default {
};
},
computed: {
+ ...mapState(['boardType']),
+ ...mapGetters(['isGroupBoard']),
parentType() {
- return this.groupId ? 'group' : 'project';
+ return this.boardType;
},
loading() {
return this.loadingRecentBoards || Boolean(this.loadingBoards);
@@ -123,6 +126,9 @@ export default {
board() {
return this.currentBoard;
},
+ showCreate() {
+ return this.multipleIssueBoardsAvailable;
+ },
showDelete() {
return this.boards.length > 1;
},
@@ -158,6 +164,18 @@ export default {
cancel() {
this.showPage('');
},
+ boardUpdate(data) {
+ if (!data?.[this.parentType]) {
+ return [];
+ }
+ return data[this.parentType].boards.edges.map(({ node }) => ({
+ id: getIdFromGraphQLId(node.id),
+ name: node.name,
+ }));
+ },
+ boardQuery() {
+ return this.isGroupBoard ? groupQuery : projectQuery;
+ },
loadBoards(toggleDropdown = true) {
if (toggleDropdown && this.boards.length > 0) {
return;
@@ -167,21 +185,14 @@ export default {
variables() {
return { fullPath: this.fullPath };
},
- query() {
- return this.groupId ? groupQuery : projectQuery;
- },
+ query: this.boardQuery,
loadingKey: 'loadingBoards',
- update(data) {
- if (!data?.[this.parentType]) {
- return [];
- }
- return data[this.parentType].boards.edges.map(({ node }) => ({
- id: getIdFromGraphQLId(node.id),
- name: node.name,
- }));
- },
+ update: this.boardUpdate,
});
+ this.loadRecentBoards();
+ },
+ loadRecentBoards() {
this.loadingRecentBoards = true;
// Follow up to fetch recent boards using GraphQL
// https://gitlab.com/gitlab-org/gitlab/-/issues/300985
@@ -322,7 +333,7 @@ export default {
<gl-dropdown-divider />
<gl-dropdown-item
- v-if="multipleIssueBoardsAvailable"
+ v-if="showCreate"
v-gl-modal-directive="'board-config-modal'"
data-qa-selector="create_new_board_button"
@click.prevent="showPage('new')"
diff --git a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue
index 33ad46a0d29..85c7b27336b 100644
--- a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue
+++ b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue
@@ -9,6 +9,7 @@ import {
GlModalDirective,
} from '@gitlab/ui';
import { throttle } from 'lodash';
+import { mapGetters, mapState } from 'vuex';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import httpStatusCodes from '~/lib/utils/http_status';
@@ -108,8 +109,10 @@ export default {
};
},
computed: {
+ ...mapState(['boardType']),
+ ...mapGetters(['isGroupBoard']),
parentType() {
- return this.groupId ? 'group' : 'project';
+ return this.boardType;
},
loading() {
return this.loadingRecentBoards || Boolean(this.loadingBoards);
@@ -167,7 +170,7 @@ export default {
return { fullPath: this.state.endpoints.fullPath };
},
query() {
- return this.groupId ? groupQuery : projectQuery;
+ return this.isGroupBoard ? groupQuery : projectQuery;
},
loadingKey: 'loadingBoards',
update(data) {
diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue
new file mode 100644
index 00000000000..7ec99e51f5b
--- /dev/null
+++ b/app/assets/javascripts/boards/components/config_toggle.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import { formType } from '~/boards/constants';
+import eventHub from '~/boards/eventhub';
+import { s__, __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModalDirective,
+ },
+ props: {
+ boardsStore: {
+ type: Object,
+ required: true,
+ },
+ canAdminList: {
+ type: Boolean,
+ required: true,
+ },
+ hasScope: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ state: this.boardsStore.state,
+ };
+ },
+ computed: {
+ buttonText() {
+ return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope');
+ },
+ tooltipTitle() {
+ return this.hasScope ? __("This board's scope is reduced") : '';
+ },
+ },
+ methods: {
+ showPage() {
+ eventHub.$emit('showBoardModal', formType.edit);
+ return this.boardsStore.showPage(formType.edit);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-ml-3 gl-display-flex gl-align-items-center">
+ <gl-button
+ v-gl-modal-directive="'board-config-modal'"
+ v-gl-tooltip
+ :title="tooltipTitle"
+ :class="{ 'dot-highlight': hasScope }"
+ data-qa-selector="boards_config_button"
+ @click.prevent="showPage"
+ >
+ {{ buttonText }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/filtered_search.vue b/app/assets/javascripts/boards/components/filtered_search.vue
new file mode 100644
index 00000000000..8505ea39a6b
--- /dev/null
+++ b/app/assets/javascripts/boards/components/filtered_search.vue
@@ -0,0 +1,54 @@
+<script>
+import { mapActions } from 'vuex';
+import { historyPushState } from '~/lib/utils/common_utils';
+import { setUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+
+export default {
+ i18n: {
+ search: __('Search'),
+ },
+ components: { FilteredSearch },
+ props: {
+ search: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ initialSearch() {
+ return [{ type: 'filtered-search-term', value: { data: this.search } }];
+ },
+ },
+ methods: {
+ ...mapActions(['performSearch']),
+ handleSearch(filters) {
+ let itemValue = '';
+ const [item] = filters;
+
+ if (filters.length === 0) {
+ itemValue = '';
+ } else {
+ itemValue = item?.value?.data;
+ }
+
+ historyPushState(setUrlParams({ search: itemValue }, window.location.href));
+
+ this.performSearch();
+ },
+ },
+};
+</script>
+
+<template>
+ <filtered-search
+ class="gl-w-full"
+ namespace=""
+ :tokens="[]"
+ :search-input-placeholder="$options.i18n.search"
+ :initial-filter-value="initialSearch"
+ @onFilter="handleSearch"
+ />
+</template>
diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue
index 069cc2cda22..2652fac1818 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue
@@ -2,7 +2,7 @@
import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { sortBy } from 'lodash';
import { mapState } from 'vuex';
-import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner';
+import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { sprintf, __, n__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
@@ -24,7 +24,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [issueCardInner],
+ mixins: [boardCardInner],
inject: ['groupId', 'rootPath'],
props: {
issue: {
@@ -207,7 +207,7 @@ export default {
/>
<issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
<issue-card-weight
- v-if="validIssueWeight"
+ v-if="validIssueWeight(issue)"
:weight="issue.weight"
@click="filterByWeight(issue.weight)"
/>
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index 7e3f36c8a17..73ec008c2b6 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -86,7 +86,11 @@ export default {
<template>
<span>
<span ref="issueDueDate" :class="cssClass" class="board-card-info card-number">
- <gl-icon :class="{ 'text-danger': isPastDue }" class="board-card-info-icon" name="calendar" />
+ <gl-icon
+ :class="{ 'text-danger': isPastDue }"
+ class="board-card-info-icon gl-mr-2"
+ name="calendar"
+ />
<time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
body
}}</time>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index 42d187b9b40..1ab7deebfaf 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -37,7 +37,7 @@ export default {
<template>
<span>
<span ref="issueTimeEstimate" class="board-card-info card-number">
- <gl-icon name="hourglass" class="board-card-info-icon" />
+ <gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" />
<time class="board-card-info-text">{{ timeEstimate }}</time>
</span>
<gl-tooltip
diff --git a/app/assets/javascripts/boards/components/issue_count.vue b/app/assets/javascripts/boards/components/item_count.vue
index d55f7151d7e..9b1ff254766 100644
--- a/app/assets/javascripts/boards/components/issue_count.vue
+++ b/app/assets/javascripts/boards/components/item_count.vue
@@ -7,7 +7,7 @@ export default {
required: false,
default: 0,
},
- issuesSize: {
+ itemsSize: {
type: Number,
required: false,
default: 0,
@@ -18,16 +18,16 @@ export default {
return this.maxIssueCount !== 0;
},
issuesExceedMax() {
- return this.isMaxLimitSet && this.issuesSize > this.maxIssueCount;
+ return this.isMaxLimitSet && this.itemsSize > this.maxIssueCount;
},
},
};
</script>
<template>
- <div class="issue-count text-nowrap">
- <span class="js-issue-size" :class="{ 'text-danger': issuesExceedMax }">
- {{ issuesSize }}
+ <div class="item-count text-nowrap">
+ <span :class="{ 'text-danger': issuesExceedMax }" data-testid="board-items-count">
+ {{ itemsSize }}
</span>
<span v-if="isMaxLimitSet" class="js-max-issue-size">
{{ maxIssueCount }}
diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue
index bf69f8140d5..e66cae0ce18 100644
--- a/app/assets/javascripts/boards/components/modal/list.vue
+++ b/app/assets/javascripts/boards/components/modal/list.vue
@@ -2,11 +2,11 @@
import { GlIcon } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import ModalStore from '../../stores/modal_store';
-import IssueCardInner from '../issue_card_inner.vue';
+import BoardCardInner from '../board_card_inner.vue';
export default {
components: {
- IssueCardInner,
+ BoardCardInner,
GlIcon,
},
props: {
@@ -126,7 +126,7 @@ export default {
class="board-card position-relative p-3 rounded"
@click="toggleIssue($event, issue)"
>
- <issue-card-inner :issue="issue" />
+ <board-card-inner :item="issue" />
<gl-icon
v-if="issue.selected"
:aria-label="'Issue #' + issue.id + ' selected'"
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index cfc1752a828..77b6af77652 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -7,7 +7,7 @@ import {
GlIntersectionObserver,
GlLoadingIcon,
} from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapState, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import { ListType } from '../constants';
@@ -49,7 +49,8 @@ export default {
};
},
computed: {
- ...mapState(['groupProjects', 'groupProjectsFlags']),
+ ...mapState(['groupProjectsFlags']),
+ ...mapGetters(['activeGroupProjects']),
selectedProjectName() {
return this.selectedProject.name || this.$options.i18n.dropdownText;
},
@@ -65,7 +66,7 @@ export default {
};
},
isFetchResultEmpty() {
- return this.groupProjects.length === 0;
+ return this.activeGroupProjects.length === 0;
},
hasNextPage() {
return this.groupProjectsFlags.pageInfo?.hasNextPage;
@@ -84,7 +85,7 @@ export default {
methods: {
...mapActions(['fetchGroupProjects', 'setSelectedProject']),
selectProject(projectId) {
- this.selectedProject = this.groupProjects.find((project) => project.id === projectId);
+ this.selectedProject = this.activeGroupProjects.find((project) => project.id === projectId);
this.setSelectedProject(this.selectedProject);
},
loadMoreProjects() {
@@ -113,7 +114,7 @@ export default {
:placeholder="$options.i18n.searchPlaceholder"
/>
<gl-dropdown-item
- v-for="project in groupProjects"
+ v-for="project in activeGroupProjects"
v-show="!groupProjectsFlags.isLoading"
:key="project.id"
:name="project.name"
diff --git a/app/assets/javascripts/boards/components/project_select_deprecated.vue b/app/assets/javascripts/boards/components/project_select_deprecated.vue
index 5605e9945ea..afe161d9c54 100644
--- a/app/assets/javascripts/boards/components/project_select_deprecated.vue
+++ b/app/assets/javascripts/boards/components/project_select_deprecated.vue
@@ -25,6 +25,7 @@ export default {
with_shared: false,
include_subgroups: true,
order_by: 'similarity',
+ archived: false,
},
components: {
GlLoadingIcon,
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
index aa4fdcf9a94..f01c8e8fa20 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
@@ -64,6 +64,8 @@ export default {
v-if="!activeIssue.emailsDisabled"
:value="activeIssue.subscribed"
:is-loading="loading"
+ :label="$options.i18n.header.title"
+ label-position="hidden"
data-testid="notification-subscribe-toggle"
@change="handleToggleSubscription"
/>
diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
deleted file mode 100644
index 8d65f3240c8..00000000000
--- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
+++ /dev/null
@@ -1,88 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
-import { deprecatedCreateFlash as Flash } from '../../../flash';
-import { __ } from '../../../locale';
-import boardsStore from '../../stores/boards_store';
-
-export default {
- components: {
- GlButton,
- },
- props: {
- issue: {
- type: Object,
- required: true,
- },
- list: {
- type: Object,
- required: true,
- },
- },
- computed: {
- updateUrl() {
- return this.issue.path;
- },
- },
- methods: {
- removeIssue() {
- const { issue } = this;
- const lists = issue.getLists();
- const req = this.buildPatchRequest(issue, lists);
-
- const data = {
- issue: this.seedPatchRequest(issue, req),
- };
-
- if (data.issue.label_ids.length === 0) {
- data.issue.label_ids = [''];
- }
-
- // Post the remove data
- axios.patch(this.updateUrl, data).catch(() => {
- Flash(__('Failed to remove issue from board, please try again.'));
-
- lists.forEach((list) => {
- list.addIssue(issue);
- });
- });
-
- // Remove from the frontend store
- lists.forEach((list) => {
- list.removeIssue(issue);
- });
-
- boardsStore.clearDetailIssue();
- },
- /**
- * Build the default patch request.
- */
- buildPatchRequest(issue, lists) {
- const listLabelIds = lists.map((list) => list.label.id);
-
- const labelIds = issue.labels
- .map((label) => label.id)
- .filter((id) => !listLabelIds.includes(id));
-
- return {
- label_ids: labelIds,
- };
- },
- /**
- * Seed the given patch request.
- *
- * (This is overridden in EE)
- */
- seedPatchRequest(issue, req) {
- return req;
- },
- },
-};
-</script>
-<template>
- <div class="block list">
- <gl-button variant="default" category="secondary" block="block" @click="removeIssue">
- {{ __('Remove from board') }}
- </gl-button>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js
index 2d1ec238274..7f327c5764d 100644
--- a/app/assets/javascripts/boards/config_toggle.js
+++ b/app/assets/javascripts/boards/config_toggle.js
@@ -1 +1,24 @@
-export default () => {};
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import ConfigToggle from './components/config_toggle.vue';
+
+export default (boardsStore) => {
+ const el = document.querySelector('.js-board-config');
+
+ if (!el) {
+ return;
+ }
+
+ gl.boardConfigToggle = new Vue({
+ el,
+ render(h) {
+ return h(ConfigToggle, {
+ props: {
+ boardsStore,
+ canAdminList: parseBoolean(el.dataset.canAdminList),
+ hasScope: parseBoolean(el.dataset.hasScope),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 3ab89b2c9da..65ebfe7be6c 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,5 +1,10 @@
import { __ } from '~/locale';
+export const issuableTypes = {
+ issue: 'issue',
+ epic: 'epic',
+};
+
export const BoardType = {
project: 'project',
group: 'group',
diff --git a/app/assets/javascripts/boards/filtered_search.js b/app/assets/javascripts/boards/filtered_search.js
new file mode 100644
index 00000000000..182a2cf3724
--- /dev/null
+++ b/app/assets/javascripts/boards/filtered_search.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import store from '~/boards/stores';
+import { queryToObject } from '~/lib/utils/url_utility';
+import FilteredSearch from './components/filtered_search.vue';
+
+export default () => {
+ const queryParams = queryToObject(window.location.search);
+ const el = document.getElementById('js-board-filtered-search');
+
+ /*
+ When https://github.com/vuejs/vue-apollo/pull/1153 is merged and deployed
+ we can remove apolloProvider option from here. Currently without it its causing
+ an error
+ */
+
+ return new Vue({
+ el,
+ store,
+ apolloProvider: {},
+ render: (createElement) =>
+ createElement(FilteredSearch, {
+ props: { search: queryParams.search },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/boards/graphql/board_labels.query.graphql b/app/assets/javascripts/boards/graphql/board_labels.query.graphql
index 42a94419a97..b19a24e8808 100644
--- a/app/assets/javascripts/boards/graphql/board_labels.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_labels.query.graphql
@@ -7,14 +7,14 @@ query BoardLabels(
$isProject: Boolean = false
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
- labels(searchTerm: $searchTerm) {
+ labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) {
nodes {
...Label
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
- labels(searchTerm: $searchTerm) {
+ labels(searchTerm: $searchTerm, includeAncestorGroups: true) {
nodes {
...Label
}
diff --git a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
index f78a21baa7f..3eb23f62940 100644
--- a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
@@ -1,21 +1,7 @@
-#import "ee_else_ce/boards/graphql/board_list.fragment.graphql"
+#import "./board_list.fragment.graphql"
-mutation CreateBoardList(
- $boardId: BoardID!
- $backlog: Boolean
- $labelId: LabelID
- $milestoneId: MilestoneID
- $assigneeId: UserID
-) {
- boardListCreate(
- input: {
- boardId: $boardId
- backlog: $backlog
- labelId: $labelId
- milestoneId: $milestoneId
- assigneeId: $assigneeId
- }
- ) {
+mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) {
+ boardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) {
list {
...BoardListFragment
}
diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
index 1afa6e48547..80a37c9943d 100644
--- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
@@ -8,6 +8,7 @@ query getGroupProjects($fullPath: ID!, $search: String, $after: String) {
name
fullPath
nameWithNamespace
+ archived
}
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/boards/graphql/users_search.query.graphql b/app/assets/javascripts/boards/graphql/users_search.query.graphql
deleted file mode 100644
index ca016495d79..00000000000
--- a/app/assets/javascripts/boards/graphql/users_search.query.graphql
+++ /dev/null
@@ -1,11 +0,0 @@
-query usersSearch($search: String!) {
- users(search: $search) {
- nodes {
- username
- name
- webUrl
- avatarUrl
- id
- }
- }
-}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 859295318ed..ceca5b0a451 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -6,7 +6,6 @@ import 'ee_else_ce/boards/models/issue';
import 'ee_else_ce/boards/models/list';
import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar';
import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown';
-import boardConfigToggle from 'ee_else_ce/boards/config_toggle';
import {
setWeightFetchingState,
setEpicFetchingState,
@@ -24,6 +23,7 @@ import '~/boards/models/milestone';
import '~/boards/models/project';
import '~/boards/filters/due_date_filters';
import BoardAddIssuesModal from '~/boards/components/modal/index.vue';
+import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import FilteredSearchBoards from '~/boards/filtered_search_boards';
import modalMixin from '~/boards/mixins/modal_mixins';
@@ -40,6 +40,7 @@ import {
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
+import boardConfigToggle from './config_toggle';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
Vue.use(VueApollo);
@@ -52,7 +53,6 @@ let issueBoardsApp;
export default () => {
const $boardApp = document.getElementById('board-app');
-
// check for browser back and trigger a hard reload to circumvent browser caching.
window.addEventListener('pageshow', (event) => {
const isNavTypeBackForward =
@@ -72,6 +72,14 @@ export default () => {
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
}
+ if (gon?.features?.boardsFilteredSearch) {
+ import('~/boards/filtered_search')
+ .then(({ default: initFilteredSearch }) => {
+ initFilteredSearch(apolloProvider);
+ })
+ .catch(() => {});
+ }
+
// eslint-disable-next-line @gitlab/no-runtime-template-compiler
issueBoardsApp = new Vue({
el: $boardApp,
@@ -96,6 +104,9 @@ export default () => {
? parseInt($boardApp.dataset.boardWeight, 10)
: null,
scopedLabelsAvailable: parseBoolean($boardApp.dataset.scopedLabels),
+ milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable),
+ assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable),
+ iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable),
},
store,
apolloProvider,
@@ -124,6 +135,7 @@ export default () => {
fullPath: $boardApp.dataset.fullPath,
boardType: this.parent,
disabled: this.disabled,
+ issuableType: issuableTypes.issue,
boardConfig: {
milestoneId: parseInt($boardApp.dataset.boardMilestoneId, 10),
milestoneTitle: $boardApp.dataset.boardMilestoneTitle || '',
@@ -162,8 +174,15 @@ export default () => {
eventHub.$off('initialBoardLoad', this.initialBoardLoad);
},
mounted() {
- this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit);
- this.filterManager.setup();
+ if (!gon.features?.boardsFilteredSearch) {
+ this.filterManager = new FilteredSearchBoards(
+ boardsStore.filter,
+ true,
+ boardsStore.cantEdit,
+ );
+
+ this.filterManager.setup();
+ }
this.performSearch();
@@ -349,7 +368,7 @@ export default () => {
toggleFocusMode(ModalStore, boardsStore);
toggleLabels();
- if (gon.features?.swimlanes) {
+ if (gon.licensed_features?.swimlanes) {
toggleEpicsSwimlanes();
}
diff --git a/app/assets/javascripts/boards/mixins/issue_card_inner.js b/app/assets/javascripts/boards/mixins/board_card_inner.js
index 04e971b756d..a6f278f3bc9 100644
--- a/app/assets/javascripts/boards/mixins/issue_card_inner.js
+++ b/app/assets/javascripts/boards/mixins/board_card_inner.js
@@ -1,10 +1,8 @@
export default {
- computed: {
+ methods: {
validIssueWeight() {
return false;
},
- },
- methods: {
filterByWeight() {},
},
};
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
index fa58af24ba2..7f655091cd0 100644
--- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
+++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapGetters } from 'vuex';
-import BoardsSelector from '~/boards/components/boards_selector.vue';
+import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue';
import BoardsSelectorDeprecated from '~/boards/components/boards_selector_deprecated.vue';
import store from '~/boards/stores';
import createDefaultClient from '~/lib/graphql';
@@ -48,10 +48,10 @@ export default (params = {}) => {
return { boardsSelectorProps };
},
computed: {
- ...mapGetters(['shouldUseGraphQL']),
+ ...mapGetters(['shouldUseGraphQL', 'isEpicBoard']),
},
render(createElement) {
- if (this.shouldUseGraphQL) {
+ if (this.shouldUseGraphQL || this.isEpicBoard) {
return createElement(BoardsSelector, {
props: this.boardsSelectorProps,
});
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index a7cf1e9e647..19b31ee7291 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,6 +1,13 @@
import { pick } from 'lodash';
+import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
-import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants';
+import {
+ BoardType,
+ ListType,
+ inactiveId,
+ flashAnimationDuration,
+ ISSUABLE,
+} from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
@@ -15,7 +22,6 @@ import {
transformNotFilters,
} from '../boards_util';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
-import createBoardListMutation from '../graphql/board_list_create.mutation.graphql';
import destroyBoardListMutation from '../graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from '../graphql/board_list_update.mutation.graphql';
import groupProjectsQuery from '../graphql/group_projects.query.graphql';
@@ -79,7 +85,11 @@ export default {
}
},
- fetchLists: ({ commit, state, dispatch }) => {
+ fetchLists: ({ dispatch }) => {
+ dispatch('fetchIssueLists');
+ },
+
+ fetchIssueLists: ({ commit, state, dispatch }) => {
const { boardType, filterParams, fullPath, boardId } = state;
const variables = {
@@ -118,9 +128,13 @@ export default {
}, flashAnimationDuration);
},
- createList: (
+ createList: ({ dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
+ dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId });
+ },
+
+ createIssueList: (
{ state, commit, dispatch, getters },
- { backlog, labelId, milestoneId, assigneeId },
+ { backlog, labelId, milestoneId, assigneeId, iterationId },
) => {
const { boardId } = state;
@@ -140,25 +154,29 @@ export default {
labelId,
milestoneId,
assigneeId,
+ iterationId,
},
})
.then(({ data }) => {
- if (data?.boardListCreate?.errors.length) {
- commit(types.CREATE_LIST_FAILURE);
+ if (data.boardListCreate?.errors.length) {
+ commit(types.CREATE_LIST_FAILURE, data.boardListCreate.errors[0]);
} else {
const list = data.boardListCreate?.list;
dispatch('addList', list);
dispatch('highlightList', list.id);
}
})
- .catch(() => commit(types.CREATE_LIST_FAILURE));
+ .catch((e) => {
+ commit(types.CREATE_LIST_FAILURE);
+ throw e;
+ });
},
addList: ({ commit }, list) => {
commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list));
},
- fetchLabels: ({ state, commit }, searchTerm) => {
+ fetchLabels: ({ state, commit, getters }, searchTerm) => {
const { fullPath, boardType } = state;
const variables = {
@@ -168,15 +186,29 @@ export default {
isProject: boardType === BoardType.project,
};
+ commit(types.RECEIVE_LABELS_REQUEST);
+
return gqlClient
.query({
query: boardLabelsQuery,
variables,
})
.then(({ data }) => {
- const labels = data[boardType]?.labels.nodes;
+ let labels = data[boardType]?.labels.nodes;
+
+ if (!getters.shouldUseGraphQL && !getters.isEpicBoard) {
+ labels = labels.map((label) => ({
+ ...label,
+ id: getIdFromGraphQLId(label.id),
+ }));
+ }
+
commit(types.RECEIVE_LABELS_SUCCESS, labels);
return labels;
+ })
+ .catch((e) => {
+ commit(types.RECEIVE_LABELS_FAILURE);
+ throw e;
});
},
@@ -225,6 +257,10 @@ export default {
});
},
+ toggleListCollapsed: ({ commit }, { listId, collapsed }) => {
+ commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed });
+ },
+
removeList: ({ state, commit }, listId) => {
const listsBackup = { ...state.boardLists };
@@ -253,8 +289,8 @@ export default {
});
},
- fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => {
- commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext });
+ fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => {
+ commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext });
const { fullPath, boardId, boardType, filterParams } = state;
@@ -279,28 +315,32 @@ export default {
})
.then(({ data }) => {
const { lists } = data[boardType]?.board;
- const listIssues = formatListIssues(lists);
+ const listItems = formatListIssues(lists);
const listPageInfo = formatListsPageInfo(lists);
- commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listPageInfo, listId });
+ commit(types.RECEIVE_ITEMS_FOR_LIST_SUCCESS, { listItems, listPageInfo, listId });
})
- .catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId));
+ .catch(() => commit(types.RECEIVE_ITEMS_FOR_LIST_FAILURE, listId));
},
resetIssues: ({ commit }) => {
commit(types.RESET_ISSUES);
},
+ moveItem: ({ dispatch }) => {
+ dispatch('moveIssue');
+ },
+
moveIssue: (
{ state, commit },
- { issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId },
+ { itemId, itemIid, itemPath, fromListId, toListId, moveBeforeId, moveAfterId },
) => {
- const originalIssue = state.issues[issueId];
- const fromList = state.issuesByListId[fromListId];
- const originalIndex = fromList.indexOf(Number(issueId));
+ const originalIssue = state.boardItems[itemId];
+ const fromList = state.boardItemsByListId[fromListId];
+ const originalIndex = fromList.indexOf(Number(itemId));
commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId });
const { boardId } = state;
- const [fullProjectPath] = issuePath.split(/[#]/);
+ const [fullProjectPath] = itemPath.split(/[#]/);
gqlClient
.mutate({
@@ -308,7 +348,7 @@ export default {
variables: {
projectPath: fullProjectPath,
boardId: fullBoardId(boardId),
- iid: issueIid,
+ iid: itemIid,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
moveBeforeId,
@@ -317,7 +357,7 @@ export default {
})
.then(({ data }) => {
if (data?.issueMoveList?.errors.length) {
- commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex });
+ throw new Error();
} else {
const issue = data.issueMoveList?.issue;
commit(types.MOVE_ISSUE_SUCCESS, { issue });
@@ -532,10 +572,17 @@ export default {
commit(types.SET_SELECTED_PROJECT, project);
},
- toggleBoardItemMultiSelection: ({ commit, state }, boardItem) => {
+ toggleBoardItemMultiSelection: ({ commit, state, dispatch, getters }, boardItem) => {
const { selectedBoardItems } = state;
const index = selectedBoardItems.indexOf(boardItem);
+ // If user already selected an item (activeIssue) without using mult-select,
+ // include that item in the selection and unset state.ActiveId to hide the sidebar.
+ if (getters.activeIssue) {
+ commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeIssue);
+ dispatch('unsetActiveId');
+ }
+
if (index === -1) {
commit(types.ADD_BOARD_ITEM_TO_SELECTION, boardItem);
} else {
@@ -547,6 +594,20 @@ export default {
commit(types.SET_ADD_COLUMN_FORM_VISIBLE, visible);
},
+ resetBoardItemMultiSelection: ({ commit }) => {
+ commit(types.RESET_BOARD_ITEM_SELECTION);
+ },
+
+ toggleBoardItem: ({ state, dispatch }, { boardItem, sidebarType = ISSUABLE }) => {
+ dispatch('resetBoardItemMultiSelection');
+
+ if (boardItem.id === state.activeId) {
+ dispatch('unsetActiveId');
+ } else {
+ dispatch('setActiveId', { id: boardItem.id, sidebarType });
+ }
+ },
+
fetchBacklog: () => {
notImplemented();
},
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index fbff736c7e1..092f81ad279 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -575,7 +575,7 @@ const boardsStore = {
},
saveList(list) {
- const entity = list.label || list.assignee || list.milestone;
+ const entity = list.label || list.assignee || list.milestone || list.iteration;
let entityType = '';
if (list.label) {
entityType = 'label_id';
@@ -583,6 +583,8 @@ const boardsStore = {
entityType = 'assignee_id';
} else if (IS_EE && list.milestone) {
entityType = 'milestone_id';
+ } else if (IS_EE && list.iteration) {
+ entityType = 'iteration_id';
}
return this.createList(entity.id, entityType)
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index cab97088bc6..caa518f91ce 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -1,20 +1,22 @@
import { find } from 'lodash';
-import { inactiveId } from '../constants';
+import { BoardType, inactiveId } from '../constants';
export default {
+ isGroupBoard: (state) => state.boardType === BoardType.group,
+ isProjectBoard: (state) => state.boardType === BoardType.project,
isSidebarOpen: (state) => state.activeId !== inactiveId,
isSwimlanesOn: () => false,
- getIssueById: (state) => (id) => {
- return state.issues[id] || {};
+ getBoardItemById: (state) => (id) => {
+ return state.boardItems[id] || {};
},
- getIssuesByList: (state, getters) => (listId) => {
- const listIssueIds = state.issuesByListId[listId] || [];
- return listIssueIds.map((id) => getters.getIssueById(id));
+ getBoardItemsByList: (state, getters) => (listId) => {
+ const listItemsIds = state.boardItemsByListId[listId] || [];
+ return listItemsIds.map((id) => getters.getBoardItemById(id));
},
activeIssue: (state) => {
- return state.issues[state.activeId] || {};
+ return state.boardItems[state.activeId] || {};
},
groupPathForActiveIssue: (_, getters) => {
@@ -27,6 +29,10 @@ export default {
return referencePath.slice(0, referencePath.indexOf('#'));
},
+ activeGroupProjects: (state) => {
+ return state.groupProjects.filter((p) => !p.archived);
+ },
+
getListByLabelId: (state) => (labelId) => {
if (!labelId) {
return null;
@@ -38,6 +44,10 @@ export default {
return find(state.boardLists, (l) => l.title === title);
},
+ isEpicBoard: () => {
+ return false;
+ },
+
shouldUseGraphQL: () => {
return gon?.features?.graphqlBoardLists;
},
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index a89e961ae2d..e7c034fb087 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -2,7 +2,9 @@ export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA';
export const SET_FILTERS = 'SET_FILTERS';
export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS';
export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
+export const RECEIVE_LABELS_REQUEST = 'RECEIVE_LABELS_REQUEST';
export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
+export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE';
export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_FAILURE';
export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS';
export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE';
@@ -12,11 +14,12 @@ export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';
export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR';
export const MOVE_LIST = 'MOVE_LIST';
export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE';
+export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED';
export const REMOVE_LIST = 'REMOVE_LIST';
export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE';
-export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST';
-export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE';
-export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS';
+export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST';
+export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE';
+export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS';
export const CREATE_ISSUE_FAILURE = 'CREATE_ISSUE_FAILURE';
export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE';
export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS';
@@ -45,3 +48,4 @@ export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTIO
export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE';
export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
+export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 79c98c3d90c..75b60366b6a 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -2,7 +2,8 @@ import { pull, union } from 'lodash';
import Vue from 'vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
-import { formatIssue, moveIssueListHelper } from '../boards_util';
+import { formatIssue, moveItemListHelper } from '../boards_util';
+import { issuableTypes } from '../constants';
import * as mutationTypes from './mutation_types';
const notImplemented = () => {
@@ -10,34 +11,42 @@ const notImplemented = () => {
throw new Error('Not implemented!');
};
-export const removeIssueFromList = ({ state, listId, issueId }) => {
- Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId));
+const updateListItemsCount = ({ state, listId, value }) => {
const list = state.boardLists[listId];
- Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount - 1 });
+ if (state.issuableType === issuableTypes.epic) {
+ Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value });
+ } else {
+ Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + value });
+ }
+};
+
+export const removeItemFromList = ({ state, listId, itemId }) => {
+ Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId));
+ updateListItemsCount({ state, listId, value: -1 });
};
-export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => {
- const listIssues = state.issuesByListId[listId];
+export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }) => {
+ const listIssues = state.boardItemsByListId[listId];
let newIndex = atIndex || 0;
if (moveBeforeId) {
newIndex = listIssues.indexOf(moveBeforeId) + 1;
} else if (moveAfterId) {
newIndex = listIssues.indexOf(moveAfterId);
}
- listIssues.splice(newIndex, 0, issueId);
- Vue.set(state.issuesByListId, listId, listIssues);
- const list = state.boardLists[listId];
- Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + 1 });
+ listIssues.splice(newIndex, 0, itemId);
+ Vue.set(state.boardItemsByListId, listId, listIssues);
+ updateListItemsCount({ state, listId, value: 1 });
};
export default {
[mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
- const { boardType, disabled, boardId, fullPath, boardConfig } = data;
+ const { boardType, disabled, boardId, fullPath, boardConfig, issuableType } = data;
state.boardId = boardId;
state.fullPath = fullPath;
state.boardType = boardType;
state.disabled = disabled;
state.boardConfig = boardConfig;
+ state.issuableType = issuableType;
},
[mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => {
@@ -59,12 +68,25 @@ export default {
state.filterParams = filterParams;
},
- [mutationTypes.CREATE_LIST_FAILURE]: (state) => {
- state.error = s__('Boards|An error occurred while creating the list. Please try again.');
+ [mutationTypes.CREATE_LIST_FAILURE]: (
+ state,
+ error = s__('Boards|An error occurred while creating the list. Please try again.'),
+ ) => {
+ state.error = error;
+ },
+
+ [mutationTypes.RECEIVE_LABELS_REQUEST]: (state) => {
+ state.labelsLoading = true;
},
[mutationTypes.RECEIVE_LABELS_SUCCESS]: (state, labels) => {
state.labels = labels;
+ state.labelsLoading = false;
+ },
+
+ [mutationTypes.RECEIVE_LABELS_FAILURE]: (state) => {
+ state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.');
+ state.labelsLoading = false;
},
[mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: (state) => {
@@ -94,6 +116,10 @@ export default {
Vue.set(state, 'boardLists', backupList);
},
+ [mutationTypes.TOGGLE_LIST_COLLAPSED]: (state, { listId, collapsed }) => {
+ Vue.set(state.boardLists[listId], 'collapsed', collapsed);
+ },
+
[mutationTypes.REMOVE_LIST]: (state, listId) => {
Vue.delete(state.boardLists, listId);
},
@@ -103,26 +129,23 @@ export default {
state.boardLists = listsBackup;
},
- [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => {
+ [mutationTypes.REQUEST_ITEMS_FOR_LIST]: (state, { listId, fetchNext }) => {
Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true });
},
- [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: (
- state,
- { listIssues, listPageInfo, listId },
- ) => {
- const { listData, issues } = listIssues;
- Vue.set(state, 'issues', { ...state.issues, ...issues });
+ [mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listItems, listPageInfo, listId }) => {
+ const { listData, boardItems } = listItems;
+ Vue.set(state, 'boardItems', { ...state.boardItems, ...boardItems });
Vue.set(
- state.issuesByListId,
+ state.boardItemsByListId,
listId,
- union(state.issuesByListId[listId] || [], listData[listId]),
+ union(state.boardItemsByListId[listId] || [], listData[listId]),
);
Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]);
Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false });
},
- [mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => {
+ [mutationTypes.RECEIVE_ITEMS_FOR_LIST_FAILURE]: (state, listId) => {
state.error = s__(
'Boards|An error occurred while fetching the board issues. Please reload the page.',
);
@@ -130,18 +153,18 @@ export default {
},
[mutationTypes.RESET_ISSUES]: (state) => {
- Object.keys(state.issuesByListId).forEach((listId) => {
- Vue.set(state.issuesByListId, listId, []);
+ Object.keys(state.boardItemsByListId).forEach((listId) => {
+ Vue.set(state.boardItemsByListId, listId, []);
});
},
[mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => {
- if (!state.issues[issueId]) {
+ if (!state.boardItems[issueId]) {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
throw new Error('No issue found.');
}
- Vue.set(state.issues[issueId], prop, value);
+ Vue.set(state.boardItems[issueId], prop, value);
},
[mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) {
@@ -167,16 +190,16 @@ export default {
const fromList = state.boardLists[fromListId];
const toList = state.boardLists[toListId];
- const issue = moveIssueListHelper(originalIssue, fromList, toList);
- Vue.set(state.issues, issue.id, issue);
+ const issue = moveItemListHelper(originalIssue, fromList, toList);
+ Vue.set(state.boardItems, issue.id, issue);
- removeIssueFromList({ state, listId: fromListId, issueId: issue.id });
- addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId });
+ removeItemFromList({ state, listId: fromListId, itemId: issue.id });
+ addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId });
},
[mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => {
const issueId = getIdFromGraphQLId(issue.id);
- Vue.set(state.issues, issueId, formatIssue({ ...issue, id: issueId }));
+ Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId }));
},
[mutationTypes.MOVE_ISSUE_FAILURE]: (
@@ -184,12 +207,12 @@ export default {
{ originalIssue, fromListId, toListId, originalIndex },
) => {
state.error = s__('Boards|An error occurred while moving the issue. Please try again.');
- Vue.set(state.issues, originalIssue.id, originalIssue);
- removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id });
- addIssueToList({
+ Vue.set(state.boardItems, originalIssue.id, originalIssue);
+ removeItemFromList({ state, listId: toListId, itemId: originalIssue.id });
+ addItemToList({
state,
listId: fromListId,
- issueId: originalIssue.id,
+ itemId: originalIssue.id,
atIndex: originalIndex,
});
},
@@ -211,23 +234,23 @@ export default {
},
[mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => {
- addIssueToList({
+ addItemToList({
state,
listId: list.id,
- issueId: issue.id,
+ itemId: issue.id,
atIndex: position,
});
- Vue.set(state.issues, issue.id, issue);
+ Vue.set(state.boardItems, issue.id, issue);
},
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => {
state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
- removeIssueFromList({ state, listId: list.id, issueId });
+ removeItemFromList({ state, listId: list.id, itemId: issueId });
},
[mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => {
- removeIssueFromList({ state, listId: list.id, issueId: issue.id });
- Vue.delete(state.issues, issue.id);
+ removeItemFromList({ state, listId: list.id, itemId: issue.id });
+ Vue.delete(state.boardItems, issue.id);
},
[mutationTypes.SET_CURRENT_PAGE]: () => {
@@ -272,7 +295,7 @@ export default {
},
[mutationTypes.SET_ADD_COLUMN_FORM_VISIBLE]: (state, visible) => {
- state.addColumnFormVisible = visible;
+ Vue.set(state.addColumnForm, 'visible', visible);
},
[mutationTypes.ADD_LIST_TO_HIGHLIGHTED_LISTS]: (state, listId) => {
@@ -282,4 +305,8 @@ export default {
[mutationTypes.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS]: (state, listId) => {
state.highlightedLists = state.highlightedLists.filter((id) => id !== listId);
},
+
+ [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => {
+ state.selectedBoardItems = [];
+ },
};
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 91544d6c9c5..19ba2a5df83 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -1,19 +1,22 @@
-import { inactiveId } from '~/boards/constants';
+import { inactiveId, ListType } from '~/boards/constants';
export default () => ({
boardType: null,
+ issuableType: null,
+ fullPath: null,
disabled: false,
isShowingLabels: true,
activeId: inactiveId,
sidebarType: '',
boardLists: {},
listsFlags: {},
- issuesByListId: {},
+ boardItemsByListId: {},
isSettingAssignees: false,
pageInfoByListId: {},
- issues: {},
+ boardItems: {},
filterParams: {},
boardConfig: {},
+ labelsLoading: false,
labels: [],
highlightedLists: [],
selectedBoardItems: [],
@@ -25,7 +28,10 @@ export default () => ({
},
selectedProject: {},
error: undefined,
- addColumnFormVisible: false,
+ addColumnForm: {
+ visible: false,
+ columnType: ListType.label,
+ },
// TODO: remove after ce/ee split of board_content.vue
isShowingEpicsSwimlanes: false,
});
diff --git a/app/assets/javascripts/captcha/captcha_modal.vue b/app/assets/javascripts/captcha/captcha_modal.vue
index e6c73bc9643..a98a52a3130 100644
--- a/app/assets/javascripts/captcha/captcha_modal.vue
+++ b/app/assets/javascripts/captcha/captcha_modal.vue
@@ -41,10 +41,17 @@ export default {
}
},
},
+ mounted() {
+ // If this is true, we need to present the captcha modal to the user.
+ // When the modal is shown we will also initialize and render the form.
+ if (this.needsCaptchaResponse) {
+ this.$refs.modal.show();
+ }
+ },
methods: {
emitReceivedCaptchaResponse(captchaResponse) {
- this.$emit('receivedCaptchaResponse', captchaResponse);
this.$refs.modal.hide();
+ this.$emit('receivedCaptchaResponse', captchaResponse);
},
emitNullReceivedCaptchaResponse() {
this.emitReceivedCaptchaResponse(null);
@@ -103,6 +110,7 @@ export default {
:action-cancel="{ text: __('Cancel') }"
@shown="shown"
@hide="hide"
+ @hidden="$emit('hidden')"
>
<div ref="captcha"></div>
<p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p>
diff --git a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
new file mode 100644
index 00000000000..c9eac44eb28
--- /dev/null
+++ b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
@@ -0,0 +1,37 @@
+const supportedMethods = ['patch', 'post', 'put'];
+
+export function registerCaptchaModalInterceptor(axios) {
+ return axios.interceptors.response.use(
+ (response) => {
+ return response;
+ },
+ (err) => {
+ if (
+ supportedMethods.includes(err?.config?.method) &&
+ err?.response?.data?.needs_captcha_response
+ ) {
+ const { data } = err.response;
+ const captchaSiteKey = data.captcha_site_key;
+ const spamLogId = data.spam_log_id;
+ // eslint-disable-next-line promise/no-promise-in-callback
+ return import('~/captcha/wait_for_captcha_to_be_solved')
+ .then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey))
+ .then((captchaResponse) => {
+ const errConfig = err.config;
+ const originalData = JSON.parse(errConfig.data);
+ return axios({
+ method: errConfig.method,
+ url: errConfig.url,
+ data: {
+ ...originalData,
+ captcha_response: captchaResponse,
+ spam_log_id: spamLogId,
+ },
+ });
+ });
+ }
+
+ return Promise.reject(err);
+ },
+ );
+}
diff --git a/app/assets/javascripts/captcha/unsolved_captcha_error.js b/app/assets/javascripts/captcha/unsolved_captcha_error.js
new file mode 100644
index 00000000000..1e5c2a4d852
--- /dev/null
+++ b/app/assets/javascripts/captcha/unsolved_captcha_error.js
@@ -0,0 +1,10 @@
+import { __ } from '~/locale';
+
+class UnsolvedCaptchaError extends Error {
+ constructor(message) {
+ super(message || __('You must solve the CAPTCHA in order to submit'));
+ this.name = 'UnsolvedCaptchaError';
+ }
+}
+
+export default UnsolvedCaptchaError;
diff --git a/app/assets/javascripts/captcha/wait_for_captcha_to_be_solved.js b/app/assets/javascripts/captcha/wait_for_captcha_to_be_solved.js
new file mode 100644
index 00000000000..0fd0f571d3b
--- /dev/null
+++ b/app/assets/javascripts/captcha/wait_for_captcha_to_be_solved.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import CaptchaModal from '~/captcha/captcha_modal.vue';
+import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error';
+
+/**
+ * Opens a Captcha Modal with provided captchaSiteKey.
+ *
+ * Returns a Promise which resolves if the captcha is solved correctly, and rejects
+ * if the captcha process is aborted.
+ *
+ * @param captchaSiteKey
+ * @returns {Promise}
+ */
+export function waitForCaptchaToBeSolved(captchaSiteKey) {
+ return new Promise((resolve, reject) => {
+ let captchaModalElement = document.createElement('div');
+
+ document.body.append(captchaModalElement);
+
+ let captchaModalVueInstance = new Vue({
+ el: captchaModalElement,
+ render: (createElement) => {
+ return createElement(CaptchaModal, {
+ props: {
+ captchaSiteKey,
+ needsCaptchaResponse: true,
+ },
+ on: {
+ hidden: () => {
+ // Cleaning up the modal from the DOM
+ captchaModalVueInstance.$destroy();
+ captchaModalVueInstance.$el.remove();
+ captchaModalElement.remove();
+
+ captchaModalElement = null;
+ captchaModalVueInstance = null;
+ },
+ receivedCaptchaResponse: (captchaResponse) => {
+ if (captchaResponse) {
+ resolve(captchaResponse);
+ } else {
+ // reject the promise with a custom exception, allowing consuming apps to
+ // adjust their error handling, if appropriate.
+ const error = new UnsolvedCaptchaError();
+ reject(error);
+ }
+ },
+ },
+ });
+ },
+ });
+ });
+}
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
index 104d6672015..ecb39f214ec 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
@@ -20,7 +20,7 @@ export default {
},
data() {
return {
- searchTerm: this.value || '',
+ searchTerm: '',
};
},
computed: {
@@ -38,11 +38,6 @@ export default {
);
},
},
- watch: {
- value(newVal) {
- this.searchTerm = newVal;
- },
- },
methods: {
selectEnvironment(selected) {
this.$emit('selectEnvironment', selected);
@@ -55,11 +50,14 @@ export default {
isSelected(env) {
return this.value === env;
},
+ clearSearch() {
+ this.searchTerm = '';
+ },
},
};
</script>
<template>
- <gl-dropdown :text="value">
+ <gl-dropdown :text="value" @show="clearSearch">
<gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" />
<gl-dropdown-item
v-for="environment in filteredResults"
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 af4349083b6..be7c0b68b4c 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
@@ -265,7 +265,6 @@ export default {
<gl-form-checkbox
ref="masked-ci-variable"
v-model="masked"
- data-qa-selector="ci_variable_masked_checkbox"
data-testid="ci-variable-masked-checkbox"
>
{{ __('Mask variable') }}
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index 47b2745af08..c9943052356 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -84,7 +84,7 @@ export default {
</script>
<template>
- <div class="ci-variable-table">
+ <div class="ci-variable-table" data-testid="ci-variable-table">
<gl-table
:fields="fields"
:items="variables"
diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
index 369cb2fa0f3..aaad0009ef3 100644
--- a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
+++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
@@ -54,15 +54,17 @@ export default {
required: false,
},
},
- data: () => ({
- currentServerSideSettings: {
- host: null,
- port: null,
- protocol: null,
- wafLogEnabled: null,
- ciliumLogEnabled: null,
- },
- }),
+ data() {
+ return {
+ currentServerSideSettings: {
+ host: null,
+ port: null,
+ protocol: null,
+ wafLogEnabled: null,
+ ciliumLogEnabled: null,
+ },
+ };
+ },
computed: {
isSaving() {
return [UPDATING].includes(this.status);
diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
index 26767c32275..1ba28660e5c 100644
--- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
+++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
@@ -18,6 +18,9 @@ import { s__, __ } from '../../locale';
const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS;
export default {
+ i18n: {
+ modSecurityEnabled: s__('ClusterIntegration|ModSecurity enabled'),
+ },
title: __('Web Application Firewall'),
modsecurityUrl: 'https://modsecurity.org/about.html',
components: {
@@ -53,11 +56,13 @@ export default {
}),
},
},
- data: () => ({
- modSecurityLogo,
- initialValue: null,
- initialMode: null,
- }),
+ data() {
+ return {
+ modSecurityLogo,
+ initialValue: null,
+ initialMode: null,
+ };
+ },
computed: {
modSecurityEnabled: {
get() {
@@ -200,7 +205,12 @@ export default {
</strong>
</p>
<div class="form-check form-check-inline mt-3">
- <gl-toggle v-model="modSecurityEnabled" :disabled="saveButtonDisabled" />
+ <gl-toggle
+ v-model="modSecurityEnabled"
+ :disabled="saveButtonDisabled"
+ :label="$options.i18n.modSecurityEnabled"
+ label-position="hidden"
+ />
</div>
<div
v-if="ingress.modsecurity_enabled"
diff --git a/app/assets/javascripts/clusters/forms/components/integration_form.vue b/app/assets/javascripts/clusters/forms/components/integration_form.vue
index f0dafa7ef53..a344f9578fd 100644
--- a/app/assets/javascripts/clusters/forms/components/integration_form.vue
+++ b/app/assets/javascripts/clusters/forms/components/integration_form.vue
@@ -9,8 +9,14 @@ import {
GlButton,
} from '@gitlab/ui';
import { mapState } from 'vuex';
+import { s__ } from '~/locale';
export default {
+ i18n: {
+ toggleLabel: s__(
+ "ClusterIntegration|Enable or disable GitLab's connection to your Kubernetes cluster.",
+ ),
+ },
components: {
GlFormGroup,
GlToggle,
@@ -79,11 +85,9 @@ export default {
data-qa-selector="integration_status_toggle"
aria-describedby="toggleCluster"
:disabled="!editable"
- :title="
- s__(
- 'ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.',
- )
- "
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
+ :title="$options.i18n.toggleLabel"
/>
</div>
</div>
diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js
index 45d3e0cbc23..40a86a1e58c 100644
--- a/app/assets/javascripts/clusters_list/store/actions.js
+++ b/app/assets/javascripts/clusters_list/store/actions.js
@@ -1,9 +1,9 @@
+import * as Sentry from '@sentry/browser';
import { deprecatedCreateFlash as flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
import { MAX_REQUESTS } from '../constants';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 920ffde3e32..2e050c066f1 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -31,10 +31,8 @@ export default () => {
return createElement(CommitPipelinesTable, {
props: {
endpoint: pipelineTableViewEl.dataset.endpoint,
- helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
- autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath,
},
});
},
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 787152d00ef..e1cca5adc73 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -8,6 +8,7 @@ import PipelinesMixin from '~/pipelines/mixins/pipelines_mixin';
import PipelinesService from '~/pipelines/services/pipelines_service';
import PipelineStore from '~/pipelines/stores/pipelines_store';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -19,20 +20,12 @@ export default {
TablePagination,
SvgBlankState,
},
- mixins: [PipelinesMixin],
+ mixins: [PipelinesMixin, glFeatureFlagMixin()],
props: {
endpoint: {
type: String,
required: true,
},
- helpPagePath: {
- type: String,
- required: true,
- },
- autoDevopsHelpPath: {
- type: String,
- required: true,
- },
errorStateSvgPath: {
type: String,
required: true,
@@ -98,6 +91,9 @@ export default {
canRenderPipelineButton() {
return this.latestPipelineDetachedFlag;
},
+ pipelineButtonClass() {
+ return !this.glFeatures.newPipelinesTable ? 'gl-md-display-none' : 'gl-lg-display-none';
+ },
isForkMergeRequest() {
return this.sourceProjectFullPath !== this.targetProjectFullPath;
},
@@ -200,7 +196,8 @@ export default {
<gl-button
v-if="canRenderPipelineButton"
block
- class="gl-mt-3 gl-mb-0 gl-md-display-none"
+ class="gl-mt-3 gl-mb-3"
+ :class="pipelineButtonClass"
variant="success"
data-testid="run_pipeline_button_mobile"
:loading="state.isRunningMergeRequestPipeline"
@@ -212,7 +209,6 @@ export default {
<pipelines-table-component
:pipelines="state.pipelines"
:update-graph-dropdown="updateGraphDropdown"
- :auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
>
<template #table-header-actions>
diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js
index f750c62103e..e5e23f2fb5e 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -6,8 +6,6 @@ import 'bootstrap/js/dist/button';
import 'bootstrap/js/dist/collapse';
import 'bootstrap/js/dist/modal';
import 'bootstrap/js/dist/dropdown';
-import 'bootstrap/js/dist/popover';
-import 'bootstrap/js/dist/tooltip';
import 'bootstrap/js/dist/tab';
// custom jQuery functions
@@ -19,68 +17,3 @@ $.fn.extend({
return $(this).prop('disabled', false).removeClass('disabled');
},
});
-
-/*
- Starting with bootstrap 4.3.1, bootstrap sanitizes html used for tooltips / popovers.
- This extends the default whitelists with more elements / attributes:
- https://getbootstrap.com/docs/4.3/getting-started/javascript/#sanitizer
- */
-const whitelist = $.fn.tooltip.Constructor.Default.whiteList;
-
-const inputAttributes = ['value', 'type'];
-
-const dataAttributes = [
- 'data-toggle',
- 'data-placement',
- 'data-container',
- 'data-title',
- 'data-class',
- 'data-clipboard-text',
- 'data-placement',
-];
-
-// Whitelisting data attributes
-whitelist['*'] = [
- ...whitelist['*'],
- ...dataAttributes,
- 'title',
- 'width height',
- 'abbr',
- 'datetime',
- 'name',
- 'width',
- 'height',
-];
-
-// Whitelist missing elements:
-whitelist.label = ['for'];
-whitelist.button = [...inputAttributes];
-whitelist.input = [...inputAttributes];
-
-whitelist.tt = [];
-whitelist.samp = [];
-whitelist.kbd = [];
-whitelist.var = [];
-whitelist.dfn = [];
-whitelist.cite = [];
-whitelist.big = [];
-whitelist.address = [];
-whitelist.dl = [];
-whitelist.dt = [];
-whitelist.dd = [];
-whitelist.abbr = [];
-whitelist.acronym = [];
-whitelist.blockquote = [];
-whitelist.del = [];
-whitelist.ins = [];
-whitelist['gl-emoji'] = [
- 'data-name',
- 'data-unicode-version',
- 'data-fallback-src',
- 'data-fallback-sprite-class',
-];
-
-// Whitelisting SVG tags and attributes
-whitelist.svg = ['viewBox'];
-whitelist.use = ['xlink:href'];
-whitelist.path = ['d'];
diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js
index 5b5a1507d38..23647d99656 100644
--- a/app/assets/javascripts/commons/vue.js
+++ b/app/assets/javascripts/commons/vue.js
@@ -6,3 +6,5 @@ if (process.env.NODE_ENV !== 'production') {
}
Vue.use(GlFeatureFlagsPlugin);
+
+Vue.config.ignoredElements = ['gl-emoji'];
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
index 0f0db2090c1..1c698cc2796 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
@@ -1,8 +1,9 @@
export const DEFAULT_REGION = 'us-east-2';
export const KUBERNETES_VERSIONS = [
- { name: '1.14', value: '1.14' },
{ name: '1.15', value: '1.15' },
- { name: '1.16', value: '1.16', default: true },
+ { name: '1.16', value: '1.16' },
{ name: '1.17', value: '1.17' },
+ { name: '1.18', value: '1.18' },
+ { name: '1.19', value: '1.19', default: true },
];
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
new file mode 100644
index 00000000000..df77d641e21
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -0,0 +1,288 @@
+<script>
+import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import Cookies from 'js-cookie';
+import { deprecatedCreateFlash as Flash } from '~/flash';
+import { __ } from '~/locale';
+import banner from './banner.vue';
+import stageCodeComponent from './stage_code_component.vue';
+import stageComponent from './stage_component.vue';
+import stageNavItem from './stage_nav_item.vue';
+import stageReviewComponent from './stage_review_component.vue';
+import stageStagingComponent from './stage_staging_component.vue';
+import stageTestComponent from './stage_test_component.vue';
+
+const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
+
+export default {
+ name: 'CycleAnalytics',
+ components: {
+ GlIcon,
+ GlEmptyState,
+ GlLoadingIcon,
+ GlSprintf,
+ banner,
+ 'stage-issue-component': stageComponent,
+ 'stage-plan-component': stageComponent,
+ 'stage-code-component': stageCodeComponent,
+ 'stage-test-component': stageTestComponent,
+ 'stage-review-component': stageReviewComponent,
+ 'stage-staging-component': stageStagingComponent,
+ 'stage-production-component': stageComponent,
+ 'stage-nav-item': stageNavItem,
+ },
+ props: {
+ noDataSvgPath: {
+ type: String,
+ required: true,
+ },
+ noAccessSvgPath: {
+ type: String,
+ required: true,
+ },
+ store: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ state: this.store.state,
+ isLoading: false,
+ isLoadingStage: false,
+ isEmptyStage: false,
+ hasError: true,
+ startDate: 30,
+ isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
+ };
+ },
+ computed: {
+ currentStage() {
+ return this.store.currentActiveStage();
+ },
+ },
+ created() {
+ this.fetchCycleAnalyticsData();
+ },
+ methods: {
+ handleError() {
+ this.store.setErrorState(true);
+ return new Flash(__('There was an error while fetching value stream analytics data.'));
+ },
+ handleDateSelect(startDate) {
+ this.startDate = startDate;
+ this.fetchCycleAnalyticsData({ startDate: this.startDate });
+ },
+ fetchCycleAnalyticsData(options) {
+ const fetchOptions = options || { startDate: this.startDate };
+
+ this.isLoading = true;
+
+ this.service
+ .fetchCycleAnalyticsData(fetchOptions)
+ .then((response) => {
+ this.store.setCycleAnalyticsData(response);
+ this.selectDefaultStage();
+ })
+ .catch(() => {
+ this.handleError();
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ selectDefaultStage() {
+ const stage = this.state.stages[0];
+ this.selectStage(stage);
+ },
+ selectStage(stage) {
+ if (this.isLoadingStage) return;
+ if (this.currentStage === stage) return;
+
+ if (!stage.isUserAllowed) {
+ this.store.setActiveStage(stage);
+ return;
+ }
+
+ this.isLoadingStage = true;
+ this.store.setStageEvents([], stage);
+ this.store.setActiveStage(stage);
+
+ this.service
+ .fetchStageData({
+ stage,
+ startDate: this.startDate,
+ projectIds: this.selectedProjectIds,
+ })
+ .then((response) => {
+ this.isEmptyStage = !response.events.length;
+ this.store.setStageEvents(response.events, stage);
+ })
+ .catch(() => {
+ this.isEmptyStage = true;
+ })
+ .finally(() => {
+ this.isLoadingStage = false;
+ });
+ },
+ dismissOverviewDialog() {
+ this.isOverviewDialogDismissed = true;
+ Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 });
+ },
+ },
+ dayRangeOptions: [7, 30, 90],
+ i18n: {
+ dropdownText: __('Last %{days} days'),
+ },
+};
+</script>
+<template>
+ <div class="cycle-analytics">
+ <gl-loading-icon v-if="isLoading" size="lg" />
+ <div v-else class="wrapper">
+ <div class="card">
+ <div class="card-header">{{ __('Recent Project Activity') }}</div>
+ <div class="d-flex justify-content-between">
+ <div v-for="item in state.summary" :key="item.title" class="flex-grow text-center">
+ <h3 class="header">{{ item.value }}</h3>
+ <p class="text">{{ item.title }}</p>
+ </div>
+ <div class="flex-grow align-self-center text-center">
+ <div class="js-ca-dropdown dropdown inline">
+ <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
+ <span class="dropdown-label">
+ <gl-sprintf :message="$options.i18n.dropdownText">
+ <template #days>{{ startDate }}</template>
+ </gl-sprintf>
+ <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
+ </span>
+ </button>
+ <ul class="dropdown-menu dropdown-menu-right">
+ <li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
+ <a href="#" @click.prevent="handleDateSelect(days)">
+ <gl-sprintf :message="$options.i18n.dropdownText">
+ <template #days>{{ days }}</template>
+ </gl-sprintf>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="stage-panel-container">
+ <div class="card stage-panel">
+ <div class="card-header border-bottom-0">
+ <nav class="col-headers">
+ <ul>
+ <li class="stage-header pl-5">
+ <span class="stage-name font-weight-bold">{{
+ s__('ProjectLifecycle|Stage')
+ }}</span>
+ <span
+ class="has-tooltip"
+ data-placement="top"
+ :title="__('The phase of the development lifecycle.')"
+ aria-hidden="true"
+ >
+ <gl-icon name="question-o" class="gl-text-gray-500" />
+ </span>
+ </li>
+ <li class="median-header">
+ <span class="stage-name font-weight-bold">{{ __('Median') }}</span>
+ <span
+ class="has-tooltip"
+ data-placement="top"
+ :title="
+ __(
+ 'The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.',
+ )
+ "
+ aria-hidden="true"
+ >
+ <gl-icon name="question-o" class="gl-text-gray-500" />
+ </span>
+ </li>
+ <li class="event-header pl-3">
+ <span
+ v-if="currentStage && currentStage.legend"
+ class="stage-name font-weight-bold"
+ >{{ currentStage ? __(currentStage.legend) : __('Related Issues') }}</span
+ >
+ <span
+ class="has-tooltip"
+ data-placement="top"
+ :title="
+ __('The collection of events added to the data gathered for that stage.')
+ "
+ aria-hidden="true"
+ >
+ <gl-icon name="question-o" class="gl-text-gray-500" />
+ </span>
+ </li>
+ <li class="total-time-header pr-5 text-right">
+ <span class="stage-name font-weight-bold">{{ __('Time') }}</span>
+ <span
+ class="has-tooltip"
+ data-placement="top"
+ :title="__('The time taken by each data entry gathered by that stage.')"
+ aria-hidden="true"
+ >
+ <gl-icon name="question-o" class="gl-text-gray-500" />
+ </span>
+ </li>
+ </ul>
+ </nav>
+ </div>
+
+ <div class="stage-panel-body">
+ <nav class="stage-nav">
+ <ul>
+ <stage-nav-item
+ v-for="stage in state.stages"
+ :key="stage.title"
+ :title="stage.title"
+ :is-user-allowed="stage.isUserAllowed"
+ :value="stage.value"
+ :is-active="stage.active"
+ @select="selectStage(stage)"
+ />
+ </ul>
+ </nav>
+ <section class="stage-events overflow-auto">
+ <gl-loading-icon v-show="isLoadingStage" size="lg" />
+ <template v-if="currentStage && !currentStage.isUserAllowed">
+ <gl-empty-state
+ class="js-empty-state"
+ :title="__('You need permission.')"
+ :svg-path="noAccessSvgPath"
+ :description="__('Want to see the data? Please ask an administrator for access.')"
+ />
+ </template>
+ <template v-else>
+ <template v-if="currentStage && isEmptyStage && !isLoadingStage">
+ <gl-empty-state
+ class="js-empty-state"
+ :description="currentStage.emptyStageText"
+ :svg-path="noDataSvgPath"
+ :title="__('We don\'t have enough data to show this stage.')"
+ />
+ </template>
+ <template v-if="state.events.length && !isLoadingStage && !isEmptyStage">
+ <component
+ :is="currentStage.component"
+ :stage="currentStage"
+ :items="state.events"
+ />
+ </template>
+ </template>
+ </section>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
deleted file mode 100644
index 847820c965f..00000000000
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ /dev/null
@@ -1,156 +0,0 @@
-// This is a true violation of @gitlab/no-runtime-template-compiler, as it
-// relies on app/views/projects/cycle_analytics/show.html.haml for its
-// template.
-/* eslint-disable @gitlab/no-runtime-template-compiler */
-import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
-import $ from 'jquery';
-import Cookies from 'js-cookie';
-import Vue from 'vue';
-import { __ } from '~/locale';
-import { deprecatedCreateFlash as Flash } from '../flash';
-import Translate from '../vue_shared/translate';
-import banner from './components/banner.vue';
-import stageCodeComponent from './components/stage_code_component.vue';
-import stageComponent from './components/stage_component.vue';
-import stageNavItem from './components/stage_nav_item.vue';
-import stageReviewComponent from './components/stage_review_component.vue';
-import stageStagingComponent from './components/stage_staging_component.vue';
-import stageTestComponent from './components/stage_test_component.vue';
-import CycleAnalyticsService from './cycle_analytics_service';
-import CycleAnalyticsStore from './cycle_analytics_store';
-
-Vue.use(Translate);
-
-export default () => {
- const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
- const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
-
- // eslint-disable-next-line no-new
- new Vue({
- el: '#cycle-analytics',
- name: 'CycleAnalytics',
- components: {
- GlEmptyState,
- GlLoadingIcon,
- banner,
- 'stage-issue-component': stageComponent,
- 'stage-plan-component': stageComponent,
- 'stage-code-component': stageCodeComponent,
- 'stage-test-component': stageTestComponent,
- 'stage-review-component': stageReviewComponent,
- 'stage-staging-component': stageStagingComponent,
- 'stage-production-component': stageComponent,
- 'stage-nav-item': stageNavItem,
- },
- data() {
- return {
- store: CycleAnalyticsStore,
- state: CycleAnalyticsStore.state,
- isLoading: false,
- isLoadingStage: false,
- isEmptyStage: false,
- hasError: false,
- startDate: 30,
- isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
- service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath),
- };
- },
- computed: {
- currentStage() {
- return this.store.currentActiveStage();
- },
- },
- created() {
- // Conditional check placed here to prevent this method from being called on the
- // new Value Stream Analytics page (i.e. the new page will be initialized blank and only
- // after a group is selected the cycle analyitcs data will be fetched). Once the
- // old (current) page has been removed this entire created method as well as the
- // variable itself can be completely removed.
- // Follow up issue: https://gitlab.com/gitlab-org/gitlab-foss/issues/64490
- if (cycleAnalyticsEl.dataset.requestPath) this.fetchCycleAnalyticsData();
- },
- methods: {
- handleError() {
- this.store.setErrorState(true);
- return new Flash(__('There was an error while fetching value stream analytics data.'));
- },
- initDropdown() {
- const $dropdown = $('.js-ca-dropdown');
- const $label = $dropdown.find('.dropdown-label');
-
- // eslint-disable-next-line @gitlab/no-global-event-off
- $dropdown
- .find('li a')
- .off('click')
- .on('click', (e) => {
- e.preventDefault();
- const $target = $(e.currentTarget);
- this.startDate = $target.data('value');
-
- $label.text($target.text().trim());
- this.fetchCycleAnalyticsData({ startDate: this.startDate });
- });
- },
- fetchCycleAnalyticsData(options) {
- const fetchOptions = options || { startDate: this.startDate };
-
- this.isLoading = true;
-
- this.service
- .fetchCycleAnalyticsData(fetchOptions)
- .then((response) => {
- this.store.setCycleAnalyticsData(response);
- this.selectDefaultStage();
- this.initDropdown();
- this.isLoading = false;
- })
- .catch(() => {
- this.handleError();
- this.isLoading = false;
- });
- },
- selectDefaultStage() {
- const stage = this.state.stages[0];
- this.selectStage(stage);
- },
- selectStage(stage) {
- if (this.isLoadingStage) return;
- if (this.currentStage === stage) return;
-
- if (!stage.isUserAllowed) {
- this.store.setActiveStage(stage);
- return;
- }
-
- this.isLoadingStage = true;
- this.store.setStageEvents([], stage);
- this.store.setActiveStage(stage);
-
- this.service
- .fetchStageData({
- stage,
- startDate: this.startDate,
- projectIds: this.selectedProjectIds,
- })
- .then((response) => {
- this.isEmptyStage = !response.events.length;
- this.store.setStageEvents(response.events, stage);
- this.isLoadingStage = false;
- })
- .catch(() => {
- this.isEmptyStage = true;
- this.isLoadingStage = false;
- });
- },
- dismissOverviewDialog() {
- this.isOverviewDialogDismissed = true;
- Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 });
- },
- createCycleAnalyticsService(requestPath) {
- return new CycleAnalyticsService({
- requestPath,
- });
- },
- },
- });
-};
diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js
new file mode 100644
index 00000000000..42d6700fae1
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import Translate from '../vue_shared/translate';
+import CycleAnalytics from './components/base.vue';
+import CycleAnalyticsService from './cycle_analytics_service';
+import CycleAnalyticsStore from './cycle_analytics_store';
+
+Vue.use(Translate);
+
+const createCycleAnalyticsService = (requestPath) =>
+ new CycleAnalyticsService({
+ requestPath,
+ });
+
+export default () => {
+ const el = document.querySelector('#js-cycle-analytics');
+ const { noAccessSvgPath, noDataSvgPath } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ name: 'CycleAnalytics',
+ render: (createElement) =>
+ createElement(CycleAnalytics, {
+ props: {
+ noDataSvgPath,
+ noAccessSvgPath,
+ store: CycleAnalyticsStore,
+ service: createCycleAnalyticsService(el.dataset.requestPath),
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js
index 162491312a8..a1dd12ff769 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js
@@ -29,6 +29,26 @@ const FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-fil
const NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter';
+let mouseEventListenersAdded = false;
+let mousedownTarget = null;
+let mouseupTarget = null;
+
+function addGlobalMouseEventListeners() {
+ // Remember mousedown and mouseup locations.
+ // Required in the `hide.bs.dropdown` listener for
+ // dropdown close prevention in some cases.
+ document.addEventListener('mousedown', ({ target }) => {
+ mousedownTarget = target;
+ });
+ document.addEventListener('mouseup', ({ target }) => {
+ mouseupTarget = target;
+ });
+ document.addEventListener('click', () => {
+ mousedownTarget = null;
+ mouseupTarget = null;
+ });
+}
+
export class GitLabDropdown {
constructor(el1, options) {
let selector;
@@ -36,9 +56,14 @@ export class GitLabDropdown {
this.el = el1;
this.options = options;
this.updateLabel = this.updateLabel.bind(this);
- this.hidden = this.hidden.bind(this);
this.opened = this.opened.bind(this);
+ this.hide = this.hide.bind(this);
+ this.hidden = this.hidden.bind(this);
this.shouldPropagate = this.shouldPropagate.bind(this);
+ if (!mouseEventListenersAdded) {
+ addGlobalMouseEventListeners();
+ mouseEventListenersAdded = true;
+ }
self = this;
selector = $(this.el).data('target');
this.dropdown = selector != null ? $(selector) : $(this.el).parent();
@@ -132,6 +157,7 @@ export class GitLabDropdown {
}
// Event listeners
this.dropdown.on('shown.bs.dropdown', this.opened);
+ this.dropdown.on('hide.bs.dropdown', this.hide);
this.dropdown.on('hidden.bs.dropdown', this.hidden);
$(this.el).on('update.label', this.updateLabel);
this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate);
@@ -334,6 +360,21 @@ export class GitLabDropdown {
$menu.css('bottom', '100%');
}
+ hide(e) {
+ // Prevent dropdowns with a search from being closed when the
+ // mousedown event happened inside the dropdown box and only
+ // the mouseup event did not.
+ if (this.options.search && mousedownTarget) {
+ const isIn = (element, $possibleContainer) => Boolean($possibleContainer.has(element).length);
+ const $menu = this.dropdown.find('.dropdown-menu');
+ const mousedownInsideDropdown = isIn(mousedownTarget, $menu);
+ const mouseupOutsideDropdown = !isIn(mouseupTarget, $menu);
+ if (mousedownInsideDropdown && mouseupOutsideDropdown) {
+ e.preventDefault();
+ }
+ }
+ }
+
hidden(e) {
this.resetRows();
this.removeArrowKeyEvent();
diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue
index 273fa3f6be2..fbcce22ec1e 100644
--- a/app/assets/javascripts/design_management/components/delete_button.vue
+++ b/app/assets/javascripts/design_management/components/delete_button.vue
@@ -73,21 +73,19 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-align-items-center gl-h-full">
+ <div>
<gl-modal
:modal-id="modalId"
:title="$options.modal.title"
:action-primary="$options.modal.actionPrimary"
:action-cancel="$options.modal.actionCancel"
- @ok="$emit('deleteSelectedDesigns')"
+ @ok="$emit('delete-selected-designs')"
>
- <p>
- {{
- s__(
- 'DesignManagement|Archived designs will still be available in previous versions of the design collection.',
- )
- }}
- </p>
+ {{
+ s__(
+ 'DesignManagement|Archived designs will still be available in previous versions of the design collection.',
+ )
+ }}
</gl-modal>
<gl-button
v-gl-modal-directive="modalId"
diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue
index 01f9cac456d..0178111f651 100644
--- a/app/assets/javascripts/design_management/components/design_destroyer.vue
+++ b/app/assets/javascripts/design_management/components/design_destroyer.vue
@@ -55,6 +55,7 @@ export default {
iid,
}"
:update="updateStoreAfterDelete"
+ :tag="null"
v-on="$listeners"
>
<slot v-bind="{ mutate, loading, error }"></slot>
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 33f0aa00cad..b1c37b0687f 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
@@ -253,12 +253,12 @@ export default {
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
@error="$emit('update-note-error', $event)"
/>
- <li v-show="isReplyPlaceholderVisible" class="reply-wrapper">
+ <li v-show="isReplyPlaceholderVisible" class="reply-wrapper discussion-reply-holder">
<reply-placeholder
v-if="!isFormVisible"
class="qa-discussion-reply"
- :button-text="__('Reply...')"
- @onClick="showForm"
+ :placeholder-text="__('Reply…')"
+ @focus="showForm"
/>
<apollo-mutation
v-else
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index 5eabe9ef1bc..2169c9111d2 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -138,6 +138,7 @@ export default {
<gl-icon
:name="icon.name"
:size="18"
+ use-deprecated-sizes
:class="icon.classes"
data-qa-selector="design_status_icon"
:data-qa-status="icon.name"
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index a3b0f06fb28..8abf1529f3c 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -130,7 +130,7 @@ export default {
button-icon="archive"
button-category="secondary"
:title="s__('DesignManagement|Archive design')"
- @deleteSelectedDesigns="$emit('delete')"
+ @delete-selected-designs="$emit('delete')"
/>
</header>
</template>
diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue
index d7b287f663b..394ccb3c483 100644
--- a/app/assets/javascripts/design_management/components/upload/button.vue
+++ b/app/assets/javascripts/design_management/components/upload/button.vue
@@ -50,7 +50,7 @@ export default {
type="file"
name="design_file"
:accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
- class="hide"
+ class="gl-display-none"
multiple
@change="onFileUploadChange"
/>
diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js
index c6c3e66a01f..9a0547ee9db 100644
--- a/app/assets/javascripts/design_management/graphql.js
+++ b/app/assets/javascripts/design_management/graphql.js
@@ -20,7 +20,6 @@ const resolvers = {
const sourceData = cache.readQuery({ query: activeDiscussionQuery });
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.activeDiscussion = {
__typename: 'ActiveDiscussion',
id,
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index c73c8fb6ca4..99ac38fc554 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -365,7 +365,8 @@ export default {
v-if="isLatestVersion"
variant="link"
size="small"
- class="gl-mr-4 js-select-all"
+ class="gl-mr-3"
+ data-testid="select-all-designs-button"
@click="toggleDesignsSelection"
>{{ selectAllButtonText }}
</gl-button>
@@ -385,7 +386,7 @@ export default {
data-qa-selector="archive_button"
:loading="loading"
:has-selected-designs="hasSelectedDesigns"
- @deleteSelectedDesigns="mutate()"
+ @delete-selected-designs="mutate()"
>
{{ s__('DesignManagement|Archive selected') }}
</delete-button>
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index c561eda12ed..33c4fd5a7d9 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -41,7 +41,6 @@ const addNewVersionToStore = (store, query, version) => {
const sourceData = store.readQuery(query);
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.project.issue.designCollection.versions.nodes = [
version,
...draftData.project.issue.designCollection.versions.nodes,
@@ -168,7 +167,6 @@ const addNewDesignToStore = (store, designManagementUpload, query) => {
nodes: newVersions,
},
};
- // eslint-disable-next-line no-param-reassign
draftData.project.issue.designCollection = updatedDesigns;
});
@@ -182,7 +180,6 @@ const moveDesignInStore = (store, designManagementMove, query) => {
const sourceData = store.readQuery(query);
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.project.issue.designCollection.designs =
designManagementMove.designCollection.designs;
});
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 4323499ef1f..253e1e3b70e 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -23,9 +23,7 @@ import {
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
ALERT_COLLAPSED_FILES,
- EVT_VIEW_FILE_BY_FILE,
} from '../constants';
-import eventHub from '../event_hub';
import { reviewStatuses } from '../utils/file_reviews';
import { diffsApp } from '../utils/performance';
@@ -127,7 +125,7 @@ export default {
required: false,
default: '',
},
- mrReviews: {
+ rehydratedMrReviews: {
type: Object,
required: false,
default: () => ({}),
@@ -166,6 +164,7 @@ export default {
'canMerge',
'hasConflicts',
'viewDiffsFileByFile',
+ 'mrReviews',
]),
...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
@@ -270,7 +269,7 @@ export default {
showSuggestPopover: this.showSuggestPopover,
viewDiffsFileByFile: fileByFile(this.fileByFileUserPreference),
defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage,
- mrReviews: this.mrReviews || {},
+ mrReviews: this.rehydratedMrReviews,
});
if (this.shouldShow) {
@@ -332,16 +331,11 @@ export default {
subscribeToEvents() {
notesEventHub.$once('fetchDiffData', this.fetchData);
notesEventHub.$on('refetchDiffData', this.refetchDiffData);
- eventHub.$on(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener);
},
unsubscribeFromEvents() {
- eventHub.$off(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener);
notesEventHub.$off('refetchDiffData', this.refetchDiffData);
notesEventHub.$off('fetchDiffData', this.fetchData);
},
- fileByFileListener({ setting } = {}) {
- this.setFileByFile({ fileByFile: setting });
- },
navigateToDiffFileNumber(number) {
this.navigateToDiffFileIndex(number - 1);
},
@@ -520,7 +514,7 @@ export default {
v-for="(file, index) in diffs"
:key="file.newPath"
:file="file"
- :reviewed="fileReviews[index]"
+ :reviewed="fileReviews[file.id]"
:is-first-file="index === 0"
:is-last-file="index === diffFilesLength - 1"
:help-page-path="helpPagePath"
diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
index 9027d0c8aa4..3766c125325 100644
--- a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
@@ -35,8 +35,9 @@ export default {
<slot v-if="hasForm" name="form"></slot>
<template v-else-if="renderReplyPlaceholder">
<reply-placeholder
- :button-text="__('Start a new discussion...')"
- @onClick="$emit('showNewDiscussionForm')"
+ :placeholder-text="__('Start a new discussion…')"
+ :label-text="__('New discussion')"
+ @focus="$emit('showNewDiscussionForm')"
/>
</template>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index f77c8d7406b..ca4543f7002 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -150,6 +150,11 @@ export default {
},
},
watch: {
+ 'file.id': {
+ handler: function fileIdHandler() {
+ this.manageViewedEffects();
+ },
+ },
'file.file_hash': {
handler: function hashChangeWatch(newHash, oldHash) {
this.isCollapsed = isCollapsed(this.file);
@@ -186,9 +191,7 @@ export default {
this.postRender();
}
- if (this.reviewed && !this.isCollapsed && this.showLocalFileReviews) {
- this.handleToggle();
- }
+ this.manageViewedEffects();
},
beforeDestroy() {
eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener);
@@ -200,6 +203,11 @@ export default {
'setRenderIt',
'setFileCollapsedByUser',
]),
+ manageViewedEffects() {
+ if (this.reviewed && !this.isCollapsed && this.showLocalFileReviews) {
+ this.handleToggle();
+ }
+ },
expandAllListener() {
if (this.isCollapsed) {
this.handleToggle();
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 1195a7f2565..1f50b3a38a6 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -339,14 +339,12 @@ export default {
v-if="isReviewable && showLocalFileReviews"
v-gl-tooltip.hover
data-testid="fileReviewCheckbox"
- class="gl-mb-0"
+ class="gl-mr-5 gl-display-flex gl-align-items-center"
:title="$options.i18n.fileReviewTooltip"
:checked="reviewed"
@change="toggleReview"
>
- <span class="gl-line-height-20">
- {{ $options.i18n.fileReviewLabel }}
- </span>
+ {{ $options.i18n.fileReviewLabel }}
</gl-form-checkbox>
<gl-button-group class="gl-pt-0!">
<gl-button
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 154f2fdcfc7..fb9202c5aab 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -139,7 +139,8 @@ export default {
v-show="shouldShowCommentButton"
ref="addDiffNoteButton"
type="button"
- class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ class="add-diff-note note-button js-add-diff-note-button"
+ data-qa-selector="diff_comment_button"
:disabled="line.commentsDisabled"
@click="handleCommentButton"
>
@@ -167,10 +168,11 @@ export default {
"
/>
</td>
- <td ref="newTd" class="diff-line-num new_line qa-new-diff-line" :class="classNameMapCell">
+ <td ref="newTd" class="diff-line-num new_line" :class="classNameMapCell">
<a
v-if="line.new_line"
ref="lineNumberRefNew"
+ data-qa-selector="new_diff_line_link"
:data-linenumber="line.new_line"
:href="line.lineHref"
@click="setHighlightedRow(line.lineCode)"
diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue
index 7d74e81257a..879922f86a2 100644
--- a/app/assets/javascripts/diffs/components/settings_dropdown.vue
+++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue
@@ -1,9 +1,6 @@
<script>
import { GlButtonGroup, GlButton, GlDropdown, GlFormCheckbox } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-
-import { EVT_VIEW_FILE_BY_FILE } from '../constants';
-import eventHub from '../event_hub';
import { SETTINGS_DROPDOWN } from '../i18n';
export default {
@@ -24,9 +21,13 @@ export default {
'setParallelDiffViewType',
'setRenderTreeList',
'setShowWhitespace',
+ 'setFileByFile',
]),
toggleFileByFile() {
- eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting: !this.viewDiffsFileByFile });
+ this.setFileByFile({ fileByFile: !this.viewDiffsFileByFile });
+ },
+ toggleWhitespace(updatedSetting) {
+ this.setShowWhitespace({ showWhitespace: updatedSetting, pushState: true });
},
},
};
@@ -82,26 +83,21 @@ export default {
</gl-button>
</gl-button-group>
</div>
- <div class="gl-mt-3 gl-px-3">
- <label class="gl-mb-0">
- <input
- id="show-whitespace"
- type="checkbox"
- :checked="showWhitespace"
- @change="setShowWhitespace({ showWhitespace: $event.target.checked, pushState: true })"
- />
- {{ __('Show whitespace changes') }}
- </label>
- </div>
- <div class="gl-mt-3 gl-px-3">
- <gl-form-checkbox
- data-testid="file-by-file"
- class="gl-mb-0"
- :checked="viewDiffsFileByFile"
- @input="toggleFileByFile"
- >
- {{ $options.i18n.fileByFile }}
- </gl-form-checkbox>
- </div>
+ <gl-form-checkbox
+ data-testid="show-whitespace"
+ class="gl-mt-3 gl-ml-3"
+ :checked="showWhitespace"
+ @input="toggleWhitespace"
+ >
+ {{ $options.i18n.whitespace }}
+ </gl-form-checkbox>
+ <gl-form-checkbox
+ data-testid="file-by-file"
+ class="gl-ml-3 gl-mb-0"
+ :checked="viewDiffsFileByFile"
+ @input="toggleFileByFile"
+ >
+ {{ $options.i18n.fileByFile }}
+ </gl-form-checkbox>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 7080348ee7d..0163f508fea 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -103,7 +103,6 @@ export const RENAMED_DIFF_TRANSITIONS = {
// MR Diffs known events
export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles';
-export const EVT_VIEW_FILE_BY_FILE = 'mr:diffs:preference:fileByFile';
export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart';
export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd';
export const EVT_PERF_MARK_DIFF_FILES_START = 'mr:diffs:perf:filesStart';
diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js
index 2a061876937..b2354af1eec 100644
--- a/app/assets/javascripts/diffs/i18n.js
+++ b/app/assets/javascripts/diffs/i18n.js
@@ -21,5 +21,6 @@ export const DIFF_FILE = {
};
export const SETTINGS_DROPDOWN = {
+ whitespace: __('Show whitespace changes'),
fileByFile: __('Show one file at a time'),
};
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 68fe204d955..87e9af174e5 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -124,7 +124,7 @@ export default function initDiffsApp(store) {
showSuggestPopover: this.showSuggestPopover,
fileByFileUserPreference: this.viewDiffsFileByFile,
defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage,
- mrReviews: getReviewsForMergeRequest(mrPath),
+ rehydratedMrReviews: getReviewsForMergeRequest(mrPath),
},
});
},
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 4b2dc2d45df..8796016def9 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -741,12 +741,7 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => {
export const setFileByFile = ({ commit }, { fileByFile }) => {
const fileViewMode = fileByFile ? DIFF_VIEW_FILE_BY_FILE : DIFF_VIEW_ALL_FILES;
commit(types.SET_FILE_BY_FILE, fileByFile);
-
Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode);
-
- historyPushState(
- mergeUrlParams({ [DIFF_FILE_BY_FILE_COOKIE_NAME]: fileViewMode }, window.location.href),
- );
};
export function reviewFile({ commit, state }, { file, reviewed = true }) {
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 87b4f33c216..b37a75eb2a3 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -283,7 +283,7 @@ export function addContextLines(options) {
* Trims the first char of the `richText` property when it's either a space or a diff symbol.
* @param {Object} line
* @returns {Object}
- * @deprecated
+ * @deprecated Use `line.rich_text = line.rich_text ? line.rich_text.replace(/^[+ -]/, '') : undefined;` instead!. For more information, see https://gitlab.com/gitlab-org/gitlab/-/issues/299329
*/
export function trimFirstCharOfLineContent(line = {}) {
// eslint-disable-next-line no-param-reassign
diff --git a/app/assets/javascripts/diffs/utils/file_reviews.js b/app/assets/javascripts/diffs/utils/file_reviews.js
index 5fafc1714ae..7a4b1aa6b17 100644
--- a/app/assets/javascripts/diffs/utils/file_reviews.js
+++ b/app/assets/javascripts/diffs/utils/file_reviews.js
@@ -9,7 +9,12 @@ export function isFileReviewed(reviews, file) {
}
export function reviewStatuses(files, reviews) {
- return files.map((file) => isFileReviewed(reviews, file));
+ return files.reduce((flat, file) => {
+ return {
+ ...flat,
+ [file.id]: isFileReviewed(reviews, file),
+ };
+ }, {});
}
export function getReviewsForMergeRequest(mrPath) {
diff --git a/app/assets/javascripts/diffs/utils/preferences.js b/app/assets/javascripts/diffs/utils/preferences.js
index e440de3350a..6b4aaf45937 100644
--- a/app/assets/javascripts/diffs/utils/preferences.js
+++ b/app/assets/javascripts/diffs/utils/preferences.js
@@ -1,22 +1,13 @@
import Cookies from 'js-cookie';
-import { getParameterValues } from '~/lib/utils/url_utility';
-
import { DIFF_FILE_BY_FILE_COOKIE_NAME, DIFF_VIEW_FILE_BY_FILE } from '../constants';
export function fileByFile(pref = false) {
- const search = getParameterValues(DIFF_FILE_BY_FILE_COOKIE_NAME)?.[0];
const cookie = Cookies.get(DIFF_FILE_BY_FILE_COOKIE_NAME);
- let viewFileByFile = pref;
// use the cookie first, if it exists
if (cookie) {
- viewFileByFile = cookie === DIFF_VIEW_FILE_BY_FILE;
- }
-
- // the search parameter of the URL should override, if it exists
- if (search) {
- viewFileByFile = search === DIFF_VIEW_FILE_BY_FILE;
+ return cookie === DIFF_VIEW_FILE_BY_FILE;
}
- return viewFileByFile;
+ return pref;
}
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index d9e6a6c13e2..c991316dda2 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -16,6 +16,9 @@ export const EDITOR_READY_EVENT = 'editor-ready';
export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor';
export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor';
+export const EDITOR_CODE_INSTANCE_FN = 'createInstance';
+export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
+
//
// EXTENSIONS' CONSTANTS
//
diff --git a/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js b/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js
new file mode 100644
index 00000000000..83b0386d470
--- /dev/null
+++ b/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js
@@ -0,0 +1,164 @@
+import { debounce } from 'lodash';
+import { KeyCode, KeyMod, Range } from 'monaco-editor';
+import { EDITOR_TYPE_DIFF } from '~/editor/constants';
+import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base';
+import Disposable from '~/ide/lib/common/disposable';
+import { editorOptions } from '~/ide/lib/editor_options';
+import keymap from '~/ide/lib/keymap.json';
+
+const isDiffEditorType = (instance) => {
+ return instance.getEditorType() === EDITOR_TYPE_DIFF;
+};
+
+export const UPDATE_DIMENSIONS_DELAY = 200;
+
+export class EditorWebIdeExtension extends EditorLiteExtension {
+ constructor({ instance, modelManager, ...options } = {}) {
+ super({
+ instance,
+ ...options,
+ modelManager,
+ disposable: new Disposable(),
+ debouncedUpdate: debounce(() => {
+ instance.updateDimensions();
+ }, UPDATE_DIMENSIONS_DELAY),
+ });
+
+ window.addEventListener('resize', instance.debouncedUpdate, false);
+
+ instance.onDidDispose(() => {
+ window.removeEventListener('resize', instance.debouncedUpdate);
+
+ // catch any potential errors with disposing the error
+ // this is mainly for tests caused by elements not existing
+ try {
+ instance.disposable.dispose();
+ } catch (e) {
+ if (process.env.NODE_ENV !== 'test') {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+ }
+ });
+
+ EditorWebIdeExtension.addActions(instance);
+ }
+
+ static addActions(instance) {
+ const { store } = instance;
+ const getKeyCode = (key) => {
+ const monacoKeyMod = key.indexOf('KEY_') === 0;
+
+ return monacoKeyMod ? KeyCode[key] : KeyMod[key];
+ };
+
+ keymap.forEach((command) => {
+ const { bindings, id, label, action } = command;
+
+ const keybindings = bindings.map((binding) => {
+ const keys = binding.split('+');
+
+ // eslint-disable-next-line no-bitwise
+ return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
+ });
+
+ instance.addAction({
+ id,
+ label,
+ keybindings,
+ run() {
+ store.dispatch(action.name, action.params);
+ return null;
+ },
+ });
+ });
+ }
+
+ createModel(file, head = null) {
+ return this.modelManager.addModel(file, head);
+ }
+
+ attachModel(model) {
+ if (isDiffEditorType(this)) {
+ this.setModel({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
+
+ return;
+ }
+
+ this.setModel(model.getModel());
+
+ this.updateOptions(
+ editorOptions.reduce((acc, obj) => {
+ Object.keys(obj).forEach((key) => {
+ Object.assign(acc, {
+ [key]: obj[key](model),
+ });
+ });
+ return acc;
+ }, {}),
+ );
+ }
+
+ attachMergeRequestModel(model) {
+ this.setModel({
+ original: model.getBaseModel(),
+ modified: model.getModel(),
+ });
+ }
+
+ updateDimensions() {
+ this.layout();
+ this.updateDiffView();
+ }
+
+ setPos({ lineNumber, column }) {
+ this.revealPositionInCenter({
+ lineNumber,
+ column,
+ });
+ this.setPosition({
+ lineNumber,
+ column,
+ });
+ }
+
+ onPositionChange(cb) {
+ if (!this.onDidChangeCursorPosition) {
+ return;
+ }
+
+ this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e)));
+ }
+
+ updateDiffView() {
+ if (!isDiffEditorType(this)) {
+ return;
+ }
+
+ this.updateOptions({
+ renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()),
+ });
+ }
+
+ replaceSelectedText(text) {
+ let selection = this.getSelection();
+ const range = new Range(
+ selection.startLineNumber,
+ selection.startColumn,
+ selection.endLineNumber,
+ selection.endColumn,
+ );
+
+ this.executeEdits('', [{ range, text }]);
+
+ selection = this.getSelection();
+ this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
+ }
+
+ static renderSideBySide(domElement) {
+ return domElement.offsetWidth >= 700;
+ }
+}
diff --git a/app/assets/javascripts/emoji/components/category.vue b/app/assets/javascripts/emoji/components/category.vue
new file mode 100644
index 00000000000..a11122d5403
--- /dev/null
+++ b/app/assets/javascripts/emoji/components/category.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlIntersectionObserver } from '@gitlab/ui';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import EmojiGroup from './emoji_group.vue';
+
+export default {
+ components: {
+ GlIntersectionObserver,
+ EmojiGroup,
+ },
+ props: {
+ category: {
+ type: String,
+ required: true,
+ },
+ emojis: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ renderGroup: false,
+ };
+ },
+ computed: {
+ categoryTitle() {
+ return capitalizeFirstCharacter(this.category);
+ },
+ },
+ methods: {
+ categoryAppeared() {
+ this.renderGroup = true;
+ this.$emit('appear', this.category);
+ },
+ categoryDissappeared() {
+ this.renderGroup = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-intersection-observer class="gl-px-5 gl-h-full" @appear="categoryAppeared">
+ <div class="gl-top-0 gl-py-3 gl-w-full emoji-picker-category-header">
+ <b>{{ categoryTitle }}</b>
+ </div>
+ <template v-if="emojis.length">
+ <emoji-group
+ v-for="(emojiGroup, index) in emojis"
+ :key="index"
+ :emojis="emojiGroup"
+ :render-group="renderGroup"
+ :click-emoji="(emoji) => $emit('click', emoji)"
+ />
+ </template>
+ <p v-else>
+ {{ s__('AwardEmoji|No emojis found.') }}
+ </p>
+ </gl-intersection-observer>
+</template>
diff --git a/app/assets/javascripts/emoji/components/emoji_group.vue b/app/assets/javascripts/emoji/components/emoji_group.vue
new file mode 100644
index 00000000000..539cd6963b1
--- /dev/null
+++ b/app/assets/javascripts/emoji/components/emoji_group.vue
@@ -0,0 +1,35 @@
+<script>
+export default {
+ props: {
+ emojis: {
+ type: Array,
+ required: true,
+ },
+ renderGroup: {
+ type: Boolean,
+ required: true,
+ },
+ clickEmoji: {
+ type: Function,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template functional>
+ <div class="gl-display-flex gl-flex-wrap gl-mb-2">
+ <template v-if="props.renderGroup">
+ <button
+ v-for="emoji in props.emojis"
+ :key="emoji"
+ type="button"
+ class="gl-border-0 gl-bg-transparent gl-px-0 gl-py-2 gl-text-center emoji-picker-emoji"
+ data-testid="emoji-button"
+ @click="props.clickEmoji(emoji)"
+ >
+ <gl-emoji :data-name="emoji" />
+ </button>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/emoji/components/emoji_list.vue b/app/assets/javascripts/emoji/components/emoji_list.vue
new file mode 100644
index 00000000000..0d73d751c6d
--- /dev/null
+++ b/app/assets/javascripts/emoji/components/emoji_list.vue
@@ -0,0 +1,44 @@
+<script>
+import { chunk } from 'lodash';
+import { searchEmoji } from '~/emoji';
+import { EMOJIS_PER_ROW } from '../constants';
+import { getEmojiCategories, generateCategoryHeight } from './utils';
+
+export default {
+ props: {
+ searchValue: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return { render: false };
+ },
+ computed: {
+ filteredCategories() {
+ if (this.searchValue !== '') {
+ const emojis = chunk(
+ searchEmoji(this.searchValue).map(({ emoji }) => emoji.name),
+ EMOJIS_PER_ROW,
+ );
+
+ return {
+ search: { emojis, height: generateCategoryHeight(emojis.length) },
+ };
+ }
+
+ return this.categories;
+ },
+ },
+ async mounted() {
+ this.categories = await getEmojiCategories();
+ this.render = true;
+ },
+};
+</script>
+
+<template>
+ <div v-if="render">
+ <slot :filtered-categories="filteredCategories"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue
new file mode 100644
index 00000000000..7cd20d82329
--- /dev/null
+++ b/app/assets/javascripts/emoji/components/picker.vue
@@ -0,0 +1,121 @@
+<script>
+import { GlIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
+import VirtualList from 'vue-virtual-scroll-list';
+import { CATEGORY_NAMES } from '~/emoji';
+import { CATEGORY_ICON_MAP } from '../constants';
+import Category from './category.vue';
+import EmojiList from './emoji_list.vue';
+import { getEmojiCategories } from './utils';
+
+export default {
+ components: {
+ GlIcon,
+ GlDropdown,
+ GlSearchBoxByType,
+ VirtualList,
+ Category,
+ EmojiList,
+ },
+ props: {
+ toggleClass: {
+ type: [Array, String, Object],
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ currentCategory: null,
+ searchValue: '',
+ };
+ },
+ computed: {
+ categoryNames() {
+ return CATEGORY_NAMES.map((category) => ({
+ name: category,
+ icon: CATEGORY_ICON_MAP[category],
+ }));
+ },
+ },
+ methods: {
+ categoryAppeared(category) {
+ this.currentCategory = category;
+ },
+ async scrollToCategory(categoryName) {
+ const categories = await getEmojiCategories();
+ const { top } = categories[categoryName];
+
+ this.$refs.virtualScoller.setScrollTop(top);
+ },
+ selectEmoji(name) {
+ this.$emit('click', name);
+ this.$refs.dropdown.hide();
+ },
+ getBoundaryElement() {
+ return document.querySelector('.content-wrapper') || 'scrollParent';
+ },
+ onSearchInput() {
+ this.$refs.virtualScoller.setScrollTop(0);
+ this.$refs.virtualScoller.forceRender();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="emoji-picker">
+ <gl-dropdown
+ ref="dropdown"
+ :toggle-class="toggleClass"
+ :boundary="getBoundaryElement()"
+ menu-class="dropdown-extended-height"
+ no-flip
+ right
+ lazy
+ >
+ <template #button-content><slot name="button-content"></slot></template>
+ <gl-search-box-by-type
+ v-model="searchValue"
+ class="gl-mx-5! gl-mb-2!"
+ autofocus
+ debounce="500"
+ @input="onSearchInput"
+ />
+ <div
+ v-show="!searchValue"
+ class="gl-display-flex gl-mx-5 gl-border-b-solid gl-border-gray-100 gl-border-b-1"
+ >
+ <button
+ v-for="category in categoryNames"
+ :key="category.name"
+ :class="{
+ 'gl-text-black-normal! emoji-picker-category-active': category.name === currentCategory,
+ }"
+ type="button"
+ class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-fill-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab"
+ @click="scrollToCategory(category.name)"
+ >
+ <gl-icon :name="category.icon" :size="12" />
+ </button>
+ </div>
+ <emoji-list :search-value="searchValue">
+ <template #default="{ filteredCategories }">
+ <virtual-list ref="virtualScoller" :size="258" :remain="1" :bench="2" variable>
+ <div
+ v-for="(category, categoryKey) in filteredCategories"
+ :key="categoryKey"
+ :style="{ height: category.height + 'px' }"
+ >
+ <category
+ :category="categoryKey"
+ :emojis="category.emojis"
+ @appear="categoryAppeared"
+ @click="selectEmoji"
+ />
+ </div>
+ </virtual-list>
+ </template>
+ </emoji-list>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/emoji/components/utils.js b/app/assets/javascripts/emoji/components/utils.js
new file mode 100644
index 00000000000..b95b56a1d6f
--- /dev/null
+++ b/app/assets/javascripts/emoji/components/utils.js
@@ -0,0 +1,27 @@
+import { chunk, memoize } from 'lodash';
+import { initEmojiMap, getEmojiCategoryMap } from '~/emoji';
+import { EMOJIS_PER_ROW, EMOJI_ROW_HEIGHT, CATEGORY_ROW_HEIGHT } from '../constants';
+
+export const generateCategoryHeight = (emojisLength) =>
+ emojisLength * EMOJI_ROW_HEIGHT + CATEGORY_ROW_HEIGHT;
+
+export const getEmojiCategories = memoize(async () => {
+ await initEmojiMap();
+
+ const categories = await getEmojiCategoryMap();
+ let top = 0;
+
+ return Object.freeze(
+ Object.keys(categories).reduce((acc, category) => {
+ const emojis = chunk(categories[category], EMOJIS_PER_ROW);
+ const height = generateCategoryHeight(emojis.length);
+ const newAcc = {
+ ...acc,
+ [category]: { emojis, height, top },
+ };
+ top += height;
+
+ return newAcc;
+ }, {}),
+ );
+});
diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js
new file mode 100644
index 00000000000..bf73d1ca5a9
--- /dev/null
+++ b/app/assets/javascripts/emoji/constants.js
@@ -0,0 +1,14 @@
+export const CATEGORY_ICON_MAP = {
+ activity: 'dumbbell',
+ people: 'smiley',
+ nature: 'nature',
+ food: 'food',
+ travel: 'car',
+ objects: 'object',
+ symbols: 'heart',
+ flags: 'flag',
+};
+
+export const EMOJIS_PER_ROW = 9;
+export const EMOJI_ROW_HEIGHT = 34;
+export const CATEGORY_ROW_HEIGHT = 37;
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index d022fcbeabe..d3b658a4020 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -2,6 +2,7 @@ import { escape, minBy } from 'lodash';
import emojiAliases from 'emojis/aliases.json';
import AccessorUtilities from '../lib/utils/accessor';
import axios from '../lib/utils/axios_utils';
+import { CATEGORY_ICON_MAP } from './constants';
let emojiMap = null;
let validEmojiNames = null;
@@ -155,19 +156,14 @@ export function sortEmoji(items) {
return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue));
}
+export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP);
+
let emojiCategoryMap;
export function getEmojiCategoryMap() {
if (!emojiCategoryMap) {
- emojiCategoryMap = {
- activity: [],
- people: [],
- nature: [],
- food: [],
- travel: [],
- objects: [],
- symbols: [],
- flags: [],
- };
+ emojiCategoryMap = CATEGORY_NAMES.reduce((acc, category) => {
+ return { ...acc, [category]: [] };
+ }, {});
Object.keys(emojiMap).forEach((name) => {
const emoji = emojiMap[name];
if (emojiCategoryMap[emoji.c]) {
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index ed852895f10..602639f09a6 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -85,7 +85,7 @@ export default {
this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', showLoader);
this.service
- .getFolderContent(folder.folder_path)
+ .getFolderContent(folder.folder_path, folder.state)
.then((response) => this.store.setfolderContent(folder, response.data.environments))
.then(() => this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false))
.catch(() => {
@@ -129,7 +129,7 @@ export default {
:href="newEnvironmentPath"
data-testid="new-environment"
category="primary"
- variant="success"
+ variant="confirm"
>{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
>
</div>
@@ -164,7 +164,7 @@ export default {
:href="newEnvironmentPath"
data-testid="new-environment"
category="primary"
- variant="success"
+ variant="confirm"
>{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
>
</div>
diff --git a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js
index b62fe196a6f..a76c8e445ed 100644
--- a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js
@@ -16,6 +16,7 @@ export default {
let params = {
scope,
page: '1',
+ nested: true,
};
params = this.onChangeWithFilter(params);
@@ -27,6 +28,7 @@ export default {
/* URLS parameters are strings, we need to parse to match types */
let params = {
page: Number(page).toString(),
+ nested: true,
};
if (this.scope) {
diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js
index 122c8f84a2c..dbd82a3c985 100644
--- a/app/assets/javascripts/environments/services/environments_service.js
+++ b/app/assets/javascripts/environments/services/environments_service.js
@@ -21,7 +21,7 @@ export default class EnvironmentsService {
return axios.delete(endpoint, {});
}
- getFolderContent(folderUrl) {
- return axios.get(`${folderUrl}.json?per_page=${this.folderResults}`);
+ getFolderContent(folderUrl, scope) {
+ return axios.get(`${folderUrl}.json?per_page=${this.folderResults}&scope=${scope}`);
}
}
diff --git a/app/assets/javascripts/experimentation/constants.js b/app/assets/javascripts/experimentation/constants.js
new file mode 100644
index 00000000000..b7e61d43b11
--- /dev/null
+++ b/app/assets/javascripts/experimentation/constants.js
@@ -0,0 +1 @@
+export const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0';
diff --git a/app/assets/javascripts/experimentation/experiment_tracking.js b/app/assets/javascripts/experimentation/experiment_tracking.js
new file mode 100644
index 00000000000..c721828036e
--- /dev/null
+++ b/app/assets/javascripts/experimentation/experiment_tracking.js
@@ -0,0 +1,24 @@
+import Tracking from '~/tracking';
+import { TRACKING_CONTEXT_SCHEMA } from './constants';
+import { getExperimentData } from './utils';
+
+export default class ExperimentTracking {
+ constructor(experimentName, trackingArgs = {}) {
+ this.trackingArgs = trackingArgs;
+ this.data = getExperimentData(experimentName);
+ }
+
+ event(action) {
+ if (!this.data) {
+ return false;
+ }
+
+ return Tracking.event(document.body.dataset.page, action, {
+ ...this.trackingArgs,
+ context: {
+ schema: TRACKING_CONTEXT_SCHEMA,
+ data: this.data,
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js
new file mode 100644
index 00000000000..d3e7800f643
--- /dev/null
+++ b/app/assets/javascripts/experimentation/utils.js
@@ -0,0 +1,10 @@
+// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment
+import { get } from 'lodash';
+
+export function getExperimentData(experimentName) {
+ return get(window, ['gon', 'experiment', experimentName]);
+}
+
+export function isExperimentVariant(experimentName, variantName) {
+ return getExperimentData(experimentName)?.variant === variantName;
+}
diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
index 5fcca11e695..77e40039b43 100644
--- a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
+++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
@@ -84,6 +84,11 @@ export default {
cancelActionProps() {
return {
text: this.$options.translations.cancelActionLabel,
+ attributes: [
+ {
+ category: 'secondary',
+ },
+ ],
};
},
canRegenerateInstanceId() {
@@ -120,11 +125,11 @@ export default {
<template>
<gl-modal
:modal-id="modalId"
- :action-cancel="cancelActionProps"
- :action-primary="regenerateInstanceIdActionProps"
- @canceled="clearState"
+ :action-primary="cancelActionProps"
+ :action-secondary="regenerateInstanceIdActionProps"
+ @secondary.prevent="rotateToken"
@hide="clearState"
- @primary.prevent="rotateToken"
+ @primary="clearState"
>
<template #modal-title>
{{ $options.translations.modalTitle }}
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
index b1e60066e11..e7f4b51c964 100644
--- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -86,6 +86,8 @@ export default {
data-track-event="click_button"
data-track-label="feature_flag_toggle"
class="gl-mr-4"
+ :label="__('Feature flag status')"
+ label-position="hidden"
@change="toggleActive"
/>
<h3 class="page-title gl-m-0">{{ title }}</h3>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index 348b71dc1c6..115d68de5c9 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -213,7 +213,7 @@ export default {
<gl-button
v-if="newUserListPath"
:href="newUserListPath"
- variant="success"
+ variant="confirm"
category="secondary"
class="gl-mb-3"
data-testid="ff-new-list-button"
@@ -224,7 +224,7 @@ export default {
<gl-button
v-if="hasNewPath"
:href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
- variant="success"
+ variant="confirm"
data-testid="ff-new-button"
@click="onNewFeatureFlagCLick"
>
@@ -301,7 +301,7 @@ export default {
<gl-button
v-if="newUserListPath"
:href="newUserListPath"
- variant="success"
+ variant="confirm"
category="secondary"
class="gl-mb-0 gl-mr-4"
data-testid="ff-new-list-button"
@@ -312,7 +312,7 @@ export default {
<gl-button
v-if="hasNewPath"
:href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
- variant="success"
+ variant="confirm"
data-testid="ff-new-button"
@click="onNewFeatureFlagCLick"
>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
index 04a5e5bc3c5..222815407ea 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -1,11 +1,14 @@
<script>
import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle, GlIcon } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT, NEW_VERSION_FLAG, LEGACY_FLAG } from '../constants';
import { labelForStrategy } from '../utils';
export default {
+ i18n: {
+ toggleLabel: __('Feature flag status'),
+ },
components: {
GlBadge,
GlButton,
@@ -138,6 +141,8 @@ export default {
v-if="featureFlag.update_path"
:value="featureFlag.active"
:disabled="statusToggleDisabled(featureFlag)"
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
data-testid="feature-flag-status-toggle"
data-track-event="click_button"
data-track-label="feature_flag_toggle"
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index d14af53746e..d26a6bc5f6b 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -1,5 +1,5 @@
+import * as Sentry from '@sentry/browser';
import { escape } from 'lodash';
-import * as Sentry from '~/sentry/wrapper';
import { spriteIcon } from './lib/utils/common_utils';
const FLASH_TYPES = {
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index d209a971c39..c5ea4cc92fd 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -3,11 +3,12 @@ import '~/lib/utils/jquery_at_who';
import { escape, template } from 'lodash';
import * as Emoji from '~/emoji';
import axios from '~/lib/utils/axios_utils';
-import { s__ } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import AjaxCache from './lib/utils/ajax_cache';
import { spriteIcon } from './lib/utils/common_utils';
+import { parsePikadayDate } from './lib/utils/datetime_utility';
import glRegexp from './lib/utils/regexp';
function sanitize(str) {
@@ -266,6 +267,7 @@ class GfmAutoComplete {
},
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${username}',
+ limit: 10,
searchKey: 'search',
alwaysHighlightFirst: true,
skipSpecialCharacterTest: true,
@@ -311,6 +313,38 @@ class GfmAutoComplete {
return data;
},
+ sorter(query, items) {
+ // Disable auto-selecting the loading icon
+ this.setting.highlightFirst = this.setting.alwaysHighlightFirst;
+ if (GfmAutoComplete.isLoading(items)) {
+ this.setting.highlightFirst = false;
+ return items;
+ }
+
+ if (!query) {
+ return items;
+ }
+
+ const lowercaseQuery = query.toLowerCase();
+ const members = items.slice();
+ const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members;
+
+ return members.sort((a, b) => {
+ if (nameOrUsernameStartsWith(a, lowercaseQuery)) {
+ return -1;
+ }
+ if (nameOrUsernameStartsWith(b, lowercaseQuery)) {
+ return 1;
+ }
+ if (nameOrUsernameIncludes(a, lowercaseQuery)) {
+ return -1;
+ }
+ if (nameOrUsernameIncludes(b, lowercaseQuery)) {
+ return 1;
+ }
+ return 0;
+ });
+ },
},
});
}
@@ -359,7 +393,7 @@ class GfmAutoComplete {
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
- tmpl = GfmAutoComplete.Milestones.templateFunction(value.title);
+ tmpl = GfmAutoComplete.Milestones.templateFunction(value.title, value.expired);
}
return tmpl;
},
@@ -367,16 +401,39 @@ class GfmAutoComplete {
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(milestones) {
- return $.map(milestones, (m) => {
+ const parsedMilestones = $.map(milestones, (m) => {
if (m.title == null) {
return m;
}
+
+ const dueDate = m.due_date ? parsePikadayDate(m.due_date) : null;
+ const expired = dueDate ? Date.now() > dueDate.getTime() : false;
+
return {
id: m.iid,
title: sanitize(m.title),
search: m.title,
+ expired,
+ dueDate,
};
});
+
+ // Sort milestones by due date when present.
+ if (typeof parsedMilestones[0] === 'object') {
+ return parsedMilestones.sort((mA, mB) => {
+ // Move all expired milestones to the bottom.
+ if (mA.expired) return 1;
+ if (mB.expired) return -1;
+
+ // Move milestones without due dates just above expired milestones.
+ if (!mA.dueDate) return 1;
+ if (!mB.dueDate) return -1;
+
+ // Sort by due date in ascending order.
+ return mA.dueDate - mB.dueDate;
+ });
+ }
+ return parsedMilestones;
},
},
});
@@ -772,6 +829,14 @@ GfmAutoComplete.Members = {
title,
)}${availabilityStatus}</small> ${icon}</li>`;
},
+ nameOrUsernameStartsWith(member, query) {
+ // `member.search` is a name:username string like `MargeSimpson msimpson`
+ return member.search.split(' ').some((name) => name.toLowerCase().startsWith(query));
+ },
+ nameOrUsernameIncludes(member, query) {
+ // `member.search` is a name:username string like `MargeSimpson msimpson`
+ return member.search.toLowerCase().includes(query);
+ },
};
GfmAutoComplete.Labels = {
templateFunction(color, title) {
@@ -792,7 +857,12 @@ GfmAutoComplete.Issues = {
};
// Milestones
GfmAutoComplete.Milestones = {
- templateFunction(title) {
+ templateFunction(title, expired) {
+ if (expired) {
+ return `<li>${sprintf(__('%{milestone} (expired)'), {
+ milestone: escape(title),
+ })}</li>`;
+ }
return `<li>${escape(title)}</li>`;
},
};
diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
index 7b029c6cf54..ab13450bb1e 100644
--- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
+++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
@@ -1,6 +1,7 @@
<script>
-import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlIcon } from '@gitlab/ui';
+import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlIcon, GlLink } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
+import { helpPagePath } from '~/helpers/help_page_helper';
export default {
components: {
@@ -9,9 +10,15 @@ export default {
GlFormGroup,
GlFormInput,
GlIcon,
+ GlLink,
},
data() {
- return { placeholderUrl: 'https://my-url.grafana.net/' };
+ return {
+ helpUrl: helpPagePath('operations/metrics/embed_grafana', {
+ anchor: 'use-integration-with-grafana-api',
+ }),
+ placeholderUrl: 'https://my-grafana.example.com/',
+ };
},
computed: {
...mapState(['operationsSettingsEndpoint', 'grafanaToken', 'grafanaUrl', 'grafanaEnabled']),
@@ -59,7 +66,12 @@ export default {
</h4>
<gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
<p class="js-section-sub-header">
- {{ s__('GrafanaIntegration|Embed Grafana charts in GitLab issues.') }}
+ {{
+ s__(
+ 'GrafanaIntegration|Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown.',
+ )
+ }}
+ <gl-link :href="helpUrl">{{ __('Learn more.') }}</gl-link>
</p>
</div>
<div class="settings-content">
@@ -78,22 +90,22 @@ export default {
>
<gl-form-input id="grafana-url" v-model="localGrafanaUrl" :placeholder="placeholderUrl" />
</gl-form-group>
- <gl-form-group :label="s__('GrafanaIntegration|API Token')" label-for="grafana-token">
+ <gl-form-group :label="s__('GrafanaIntegration|API token')" label-for="grafana-token">
<gl-form-input id="grafana-token" v-model="localGrafanaToken" />
<p class="form-text text-muted">
- {{ s__('GrafanaIntegration|Enter the Grafana API Token.') }}
+ {{ s__('GrafanaIntegration|Enter the Grafana API token.') }}
<a
href="https://grafana.com/docs/http_api/auth/#create-api-token"
target="_blank"
rel="noopener noreferrer"
>
- {{ __('More information') }}
+ {{ __('More information.') }}
<gl-icon name="external-link" class="vertical-align-middle" />
</a>
</p>
</gl-form-group>
<gl-button variant="success" category="primary" @click="updateGrafanaIntegration">
- {{ __('Save Changes') }}
+ {{ __('Save changes') }}
</gl-button>
</form>
</div>
diff --git a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
index 62119177887..101633ef7a7 100644
--- a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
@@ -5,7 +5,11 @@ fragment AlertListItem on AlertManagementAlert {
status
startedAt
eventCount
- issueIid
+ issue {
+ iid
+ state
+ title
+ }
assignees {
nodes {
name
diff --git a/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql
index 286ebbd019e..b5b4ba4e772 100644
--- a/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql
@@ -4,6 +4,7 @@ fragment EpicNode on Epic {
title
state
reference
+ webPath
webUrl
createdAt
closedAt
diff --git a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql
new file mode 100644
index 00000000000..1d9497d65ce
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql
@@ -0,0 +1,14 @@
+query searchProjectMembers($fullPath: ID!, $search: String) {
+ project(fullPath: $fullPath) {
+ projectMembers(search: $search) {
+ nodes {
+ user {
+ id
+ name
+ username
+ avatarUrl
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
index b64ceb8e2c9..aaaaf3485ad 100644
--- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
@@ -1,9 +1,13 @@
#import "../fragments/user.fragment.graphql"
-query usersSearch($search: String!) {
- users(search: $search) {
- nodes {
- ...User
+query usersSearch($search: String!, $fullPath: ID!) {
+ workspace: project(fullPath: $fullPath) {
+ users: projectMembers(search: $search) {
+ nodes {
+ user {
+ ...User
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
index 4715bbc94f6..e64e8009a5f 100644
--- a/app/assets/javascripts/graphql_shared/utils.js
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -1,3 +1,5 @@
+import { isArray } from 'lodash';
+
/**
* Ids generated by GraphQL endpoints are usually in the format
* gid://gitlab/Environments/123. This method extracts Id number
@@ -52,3 +54,35 @@ export const convertToGraphQLId = (type, id) => {
* @returns {Array}
*/
export const convertToGraphQLIds = (type, ids) => ids.map((id) => convertToGraphQLId(type, id));
+
+/**
+ * Ids generated by GraphQL endpoints are usually in the format
+ * gid://gitlab/Groups/123. This method takes an array of
+ * GraphQL Ids and converts them to a number.
+ *
+ * @param {Array} ids An array of GraphQL IDs
+ * @returns {Array}
+ */
+export const convertFromGraphQLIds = (ids) => {
+ if (!isArray(ids)) {
+ throw new TypeError(`ids must be an array; got ${typeof ids}`);
+ }
+
+ return ids.map((id) => getIdFromGraphQLId(id));
+};
+
+/**
+ * Ids generated by GraphQL endpoints are usually in the format
+ * gid://gitlab/Groups/123. This method takes an array of nodes
+ * and converts the `id` properties from a GraphQL Id to a number.
+ *
+ * @param {Array} nodes An array of nodes with an `id` property
+ * @returns {Array}
+ */
+export const convertNodeIdsFromGraphQLIds = (nodes) => {
+ if (!isArray(nodes)) {
+ throw new TypeError(`nodes must be an array; got ${typeof nodes}`);
+ }
+
+ return nodes.map((node) => (node.id ? { ...node, id: getIdFromGraphQLId(node.id) } : node));
+};
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index d41fa0b2410..9d46fcec09b 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -1,29 +1,36 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlLoadingIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui';
-import { visitUrl } from '../../lib/utils/url_utility';
-import identicon from '../../vue_shared/components/identicon.vue';
+import {
+ GlLoadingIcon,
+ GlBadge,
+ GlIcon,
+ GlTooltipDirective,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
+import { visitUrl } from '~/lib/utils/url_utility';
+import identicon from '~/vue_shared/components/identicon.vue';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants';
import eventHub from '../event_hub';
import itemActions from './item_actions.vue';
import itemCaret from './item_caret.vue';
import itemStats from './item_stats.vue';
-import itemStatsValue from './item_stats_value.vue';
import itemTypeIcon from './item_type_icon.vue';
export default {
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
components: {
GlBadge,
GlLoadingIcon,
+ GlIcon,
+ UserAccessRoleBadge,
identicon,
itemCaret,
itemTypeIcon,
itemStats,
- itemStatsValue,
itemActions,
},
props: {
@@ -91,6 +98,7 @@ export default {
}
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
@@ -140,28 +148,31 @@ export default {
data-testid="group-name"
:href="group.relativePath"
:title="group.fullName"
- class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!"
+ class="no-expand gl-mr-3 gl-mt-3 gl-text-gray-900!"
:itemprop="microdata.nameItemprop"
- >{{
+ >
+ {{
// ending bracket must be by closing tag to prevent
// link hover text-decoration from over-extending
group.name
- }}</a
- >
- <item-stats-value
- :icon-name="visibilityIcon"
+ }}
+ </a>
+ <gl-icon
+ v-gl-tooltip.hover.bottom
+ class="gl-display-inline-flex gl-align-items-center gl-mr-3 gl-mt-3 gl-text-gray-500"
+ :name="visibilityIcon"
:title="visibilityTooltip"
- css-class="item-visibility d-inline-flex align-items-center gl-mt-3 gl-mr-2 text-secondary"
+ data-testid="group-visibility-icon"
/>
- <span v-if="group.permission" class="user-access-role gl-mt-3">
+ <user-access-role-badge v-if="group.permission" class="gl-mt-3">
{{ group.permission }}
- </span>
+ </user-access-role-badge>
</div>
<div v-if="group.description" class="description">
<span
+ v-safe-html:[$options.safeHtmlConfig]="group.description"
:itemprop="microdata.descriptionItemprop"
data-testid="group-description"
- v-html="group.description"
>
</span>
</div>
diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue
index 81c5e3ce85d..747cea6a46e 100644
--- a/app/assets/javascripts/groups/components/invite_members_banner.vue
+++ b/app/assets/javascripts/groups/components/invite_members_banner.vue
@@ -35,7 +35,9 @@ export default {
this.track(this.$options.dismissEvent);
},
trackOnShow() {
- if (!this.isDismissed) this.track(this.$options.displayEvent);
+ this.$nextTick(() => {
+ if (!this.isDismissed) this.track(this.$options.displayEvent);
+ });
},
addTrackingAttributesToButton() {
if (this.$refs.banner === undefined) return;
diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue
index e23b0fa7413..9c379d7bf9b 100644
--- a/app/assets/javascripts/groups/components/item_caret.vue
+++ b/app/assets/javascripts/groups/components/item_caret.vue
@@ -21,5 +21,7 @@ export default {
</script>
<template>
- <span class="folder-caret gl-mr-2"> <gl-icon :size="10" :name="iconClass" /> </span>
+ <span class="folder-caret gl-mr-2">
+ <gl-icon :size="10" :name="iconClass" use-deprecated-sizes />
+ </span>
</template>
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index da2890f91fc..93fbbf07ae2 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -2,30 +2,8 @@ import $ from 'jquery';
import { escape } from 'lodash';
import { __ } from '~/locale';
import Api from './api';
-import axios from './lib/utils/axios_utils';
-import { normalizeHeaders } from './lib/utils/common_utils';
import { loadCSSFile } from './lib/utils/css_utils';
-
-const fetchGroups = (params) => {
- axios[params.type.toLowerCase()](params.url, {
- params: params.data,
- })
- .then((res) => {
- const results = res.data || [];
- const headers = normalizeHeaders(res.headers);
- const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
- const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
- const more = currentPage < totalPages;
-
- params.success({
- results,
- pagination: {
- more,
- },
- });
- })
- .catch(params.error);
-};
+import { select2AxiosTransport } from './lib/utils/select2_utils';
const groupsSelect = () => {
loadCSSFile(gon.select2_css_path)
@@ -51,9 +29,7 @@ const groupsSelect = () => {
url: Api.buildUrl(groupsPath),
dataType: 'json',
quietMillis: 250,
- transport(params) {
- fetchGroups(params);
- },
+ transport: select2AxiosTransport,
data(search, page) {
return {
search,
@@ -63,8 +39,6 @@ const groupsSelect = () => {
};
},
results(data, page) {
- if (data.length) return { results: [] };
-
const groups = data.length ? data : data.results || [];
const more = data.pagination ? data.pagination.more : false;
const results = groups.filter((group) => skipGroups.indexOf(group.id) === -1);
diff --git a/app/assets/javascripts/helpers/cve_id_request_helper.js b/app/assets/javascripts/helpers/cve_id_request_helper.js
new file mode 100644
index 00000000000..71d3fd4c4fe
--- /dev/null
+++ b/app/assets/javascripts/helpers/cve_id_request_helper.js
@@ -0,0 +1,50 @@
+export function createCveIdRequestIssueBody(fullPath, iid) {
+ return `### Vulnerability Submission
+
+**NOTE:** Only maintainers of GitLab-hosted projects may request a CVE for
+a vulnerability within their project.
+
+Project issue: ${fullPath}#${iid}
+
+#### Publishing Schedule
+
+After a CVE request is validated, a CVE identifier will be assigned. On what
+schedule should the details of the CVE be published?
+
+* [ ] Publish immediately
+* [ ] Wait to publish
+
+<!--
+Please fill out the yaml codeblock below
+-->
+
+\`\`\`yaml
+reporter:
+ name: "TODO" # "First Last"
+ email: "TODO" # "email@domain.tld"
+vulnerability:
+ description: "TODO" # "[VULNTYPE] in [COMPONENT] in [VENDOR][PRODUCT] [VERSION] allows [ATTACKER] to [IMPACT] via [VECTOR]"
+ cwe: "TODO" # "CWE-22" # Path Traversal
+ product:
+ gitlab_path: "${fullPath}"
+ vendor: "TODO" # "Deluxe Sandwich Maker Company"
+ name: "TODO" # "Deluxe Sandwich Maker 2"
+ affected_versions:
+ - "TODO" # "1.2.3"
+ - "TODO" # ">1.3.0, <=1.3.9"
+ fixed_versions:
+ - "TODO" # "1.2.4"
+ - "TODO" # "1.3.10"
+ impact: "TODO" # "CVSS v3 string" # https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator
+ solution: "TODO" # "Upgrade to version 1.2.4 or 1.3.10"
+ credit: "TODO"
+ references:
+ - "TODO" # "https://some.domain.tld/a/reference"
+\`\`\`
+
+CVSS scores can be computed by means of the [NVD CVSS Calculator](https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator).
+
+/relate ${fullPath}#${iid}
+/label ~"devops::secure" ~"group::vulnerability research" ~"vulnerability research::cve" ~"advisory::queued"
+ `;
+}
diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue
index 2fe435b92ab..35e2f99cb6a 100644
--- a/app/assets/javascripts/ide/components/branches/item.vue
+++ b/app/assets/javascripts/ide/components/branches/item.vue
@@ -34,7 +34,7 @@ export default {
<template>
<a :href="branchHref" class="btn-link d-flex align-items-center">
<span class="d-flex gl-mr-3 ide-search-list-current-icon">
- <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" />
+ <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" use-deprecated-sizes />
</span>
<span>
<strong> {{ item.name }} </strong>
diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue
index 1ae7cf9339d..5e93b7c1bbb 100644
--- a/app/assets/javascripts/ide/components/branches/search_list.vue
+++ b/app/assets/javascripts/ide/components/branches/search_list.vue
@@ -57,7 +57,10 @@ export default {
<template>
<div>
- <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block position-relative" @click.stop>
+ <label
+ class="dropdown-input gl-pt-3 gl-pb-5 gl-mb-0 gl-border-b-1 gl-border-b-solid gl-display-block"
+ @click.stop
+ >
<input
ref="searchInput"
v-model="search"
@@ -66,7 +69,7 @@ export default {
class="form-control dropdown-input-field"
@input="searchBranches"
/>
- <gl-icon :size="18" name="search" class="ml-3 input-icon" />
+ <gl-icon :size="18" name="search" class="ml-3 input-icon" use-deprecated-sizes />
</label>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<gl-loading-icon
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index cdb3eaff207..2897f4cbf77 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -1,17 +1,13 @@
<script>
import { GlModal, GlSafeHtmlDirective, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
-import { n__, s__ } from '~/locale';
+import { n__ } from '~/locale';
import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants';
import { createUnexpectedCommitError } from '../../lib/errors';
import Actions from './actions.vue';
import CommitMessageField from './message_field.vue';
import SuccessMessage from './success_message.vue';
-const MSG_CANNOT_PUSH_CODE = s__(
- 'WebIDE|You need permission to edit files directly in this project.',
-);
-
export default {
components: {
Actions,
@@ -35,14 +31,14 @@ export default {
computed: {
...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']),
...mapState('commit', ['commitMessage', 'submitCommitLoading', 'commitError']),
- ...mapGetters(['someUncommittedChanges', 'canPushCode']),
+ ...mapGetters(['someUncommittedChanges', 'canPushCodeStatus']),
...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']),
commitButtonDisabled() {
- return !this.canPushCode || !this.someUncommittedChanges;
+ return !this.canPushCodeStatus.isAllowed || !this.someUncommittedChanges;
},
commitButtonTooltip() {
- if (!this.canPushCode) {
- return MSG_CANNOT_PUSH_CODE;
+ if (!this.canPushCodeStatus.isAllowed) {
+ return this.canPushCodeStatus.messageShort;
}
return '';
@@ -86,7 +82,7 @@ export default {
commit() {
// Even though the submit button will be disabled, we need to disable the submission
// since hitting enter on the branch name text input also submits the form.
- if (!this.canPushCode) {
+ if (!this.canPushCodeStatus.isAllowed) {
return false;
}
@@ -130,8 +126,6 @@ export default {
this.componentHeight = null;
},
},
- // Expose for tests
- MSG_CANNOT_PUSH_CODE,
};
</script>
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 121dae4b87e..43bf2e1a90c 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
@@ -41,7 +41,6 @@ export default {
:disabled="shouldDisableNewMrOption"
:checked="shouldCreateMR"
type="checkbox"
- data-qa-selector="start_new_mr_checkbox"
@change="toggleShouldCreateMR"
/>
<span class="gl-ml-3 ide-option-label">
diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue
index bd4c4f18141..0803925104d 100644
--- a/app/assets/javascripts/ide/components/file_templates/bar.vue
+++ b/app/assets/javascripts/ide/components/file_templates/bar.vue
@@ -49,7 +49,9 @@ export default {
</script>
<template>
- <div class="d-flex align-items-center ide-file-templates qa-file-templates-bar">
+ <div
+ class="d-flex align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1"
+ >
<strong class="gl-mr-3"> {{ __('File templates') }} </strong>
<dropdown
:data="templateTypes"
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 2816f89d60d..ff2644704d9 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,7 +1,7 @@
<script>
import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { __, s__ } from '~/locale';
+import { __ } from '~/locale';
import {
WEBIDE_MARK_APP_START,
WEBIDE_MARK_FILE_FINISH,
@@ -25,10 +25,6 @@ eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () =>
),
);
-const MSG_CANNOT_PUSH_CODE = s__(
- 'WebIDE|You need permission to edit files directly in this project. Fork this project to make your changes and submit a merge request.',
-);
-
export default {
components: {
IdeSidebar,
@@ -63,7 +59,7 @@ export default {
'loading',
]),
...mapGetters([
- 'canPushCode',
+ 'canPushCodeStatus',
'activeFile',
'someUncommittedChanges',
'isCommitModeActive',
@@ -116,7 +112,6 @@ export default {
this.loadDeferred = true;
},
},
- MSG_CANNOT_PUSH_CODE,
};
</script>
@@ -125,8 +120,8 @@ export default {
class="ide position-relative d-flex flex-column align-items-stretch"
:class="{ [`theme-${themeName}`]: themeName }"
>
- <gl-alert v-if="!canPushCode" :dismissible="false">{{
- $options.MSG_CANNOT_PUSH_CODE
+ <gl-alert v-if="!canPushCodeStatus.isAllowed" :dismissible="false">{{
+ canPushCodeStatus.message
}}</gl-alert>
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue
index 7aa9a4f864a..639937481f3 100644
--- a/app/assets/javascripts/ide/components/merge_requests/item.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/item.vue
@@ -41,7 +41,7 @@ export default {
<template>
<a :href="mergeRequestHref" class="btn-link d-flex align-items-center">
<span class="d-flex gl-mr-3 ide-search-list-current-icon">
- <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" />
+ <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" use-deprecated-sizes />
</span>
<span>
<strong> {{ item.title }} </strong>
diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue
index 680e8841a1f..f7cfe80df5c 100644
--- a/app/assets/javascripts/ide/components/merge_requests/list.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/list.vue
@@ -75,7 +75,10 @@ export default {
<template>
<div>
- <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block" @click.stop>
+ <label
+ class="dropdown-input gl-pt-3 gl-pb-5 gl-mb-0 gl-border-b-1 gl-border-b-solid gl-display-block"
+ @click.stop
+ >
<tokened-input
v-model="search"
:tokens="searchTokens"
@@ -84,7 +87,7 @@ export default {
@input="searchMergeRequests"
@removeToken="setSearchType(null)"
/>
- <gl-icon :size="18" name="search" class="ml-3 input-icon" />
+ <gl-icon :size="18" name="search" class="ml-3 input-icon" use-deprecated-sizes />
</label>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<gl-loading-icon
@@ -102,7 +105,7 @@ export default {
@click.stop="setSearchType(searchType)"
>
<span class="d-flex gl-mr-3 ide-search-list-current-icon">
- <gl-icon :size="18" name="search" />
+ <gl-icon :size="18" name="search" use-deprecated-sizes />
</span>
<span>{{ searchType.label }}</span>
</button>
diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue
index 62bb4841760..98f0504298b 100644
--- a/app/assets/javascripts/ide/components/nav_form.vue
+++ b/app/assets/javascripts/ide/components/nav_form.vue
@@ -1,13 +1,12 @@
<script>
-import Tab from '~/vue_shared/components/tabs/tab.vue';
-import Tabs from '~/vue_shared/components/tabs/tabs';
+import { GlTab, GlTabs } from '@gitlab/ui';
import BranchesSearchList from './branches/search_list.vue';
import MergeRequestSearchList from './merge_requests/list.vue';
export default {
components: {
- Tabs,
- Tab,
+ GlTab,
+ GlTabs,
BranchesSearchList,
MergeRequestSearchList,
},
@@ -23,20 +22,14 @@ export default {
<template>
<div class="ide-nav-form p-0">
- <tabs v-if="showMergeRequests" stop-propagation>
- <tab active>
- <template #title>
- {{ __('Branches') }}
- </template>
+ <gl-tabs v-if="showMergeRequests">
+ <gl-tab :title="__('Branches')">
<branches-search-list />
- </tab>
- <tab>
- <template #title>
- {{ __('Merge Requests') }}
- </template>
+ </gl-tab>
+ <gl-tab :title="__('Merge Requests')">
<merge-request-search-list />
- </tab>
- </tabs>
+ </gl-tab>
+ </gl-tabs>
<branches-search-list v-else />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index 2526db0cd7b..907ac496982 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -32,7 +32,7 @@ export default {
SafeHtml,
},
computed: {
- ...mapState(['pipelinesEmptyStateSvgPath', 'links']),
+ ...mapState(['pipelinesEmptyStateSvgPath']),
...mapGetters(['currentProject']),
...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']),
...mapState('pipelines', [
@@ -85,7 +85,6 @@ export default {
</header>
<empty-state
v-if="!latestPipeline"
- :help-page-path="links.ciHelpPagePath"
:empty-state-svg-path="pipelinesEmptyStateSvgPath"
:can-set-ci="true"
class="mb-auto mt-auto"
diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue
index 0c6cb041095..4d35e946d89 100644
--- a/app/assets/javascripts/ide/components/preview/navigator.vue
+++ b/app/assets/javascripts/ide/components/preview/navigator.vue
@@ -117,7 +117,7 @@ export default {
class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
@click="refresh"
>
- <gl-icon :size="18" name="retry" class="m-auto" />
+ <gl-icon :size="18" name="retry" use-deprecated-sizes class="m-auto" />
</button>
<div class="position-relative w-100 gl-ml-2">
<input
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 690060f5cb0..b57dcd4276c 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -1,6 +1,15 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
+import {
+ EDITOR_TYPE_DIFF,
+ EDITOR_CODE_INSTANCE_FN,
+ EDITOR_DIFF_INSTANCE_FN,
+} from '~/editor/constants';
+import EditorLite from '~/editor/editor_lite';
+import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext';
import { deprecatedCreateFlash as flash } from '~/flash';
+import ModelManager from '~/ide/lib/common/model_manager';
+import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options';
import { __ } from '~/locale';
import {
WEBIDE_MARK_FILE_CLICKED,
@@ -20,7 +29,6 @@ import {
FILE_VIEW_MODE_PREVIEW,
} from '../constants';
import eventHub from '../eventhub';
-import Editor from '../lib/editor';
import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
@@ -46,6 +54,9 @@ export default {
content: '',
images: {},
rules: {},
+ globalEditor: null,
+ modelManager: new ModelManager(),
+ isEditorLoading: true,
};
},
computed: {
@@ -132,6 +143,7 @@ export default {
// Compare key to allow for files opened in review mode to be cached differently
if (oldVal.key !== this.file.key) {
+ this.isEditorLoading = true;
this.initEditor();
if (this.currentActivityView !== leftSidebarViews.edit.name) {
@@ -149,6 +161,7 @@ export default {
}
},
viewer() {
+ this.isEditorLoading = false;
if (!this.file.pending) {
this.createEditorInstance();
}
@@ -181,11 +194,11 @@ export default {
},
},
beforeDestroy() {
- this.editor.dispose();
+ this.globalEditor.dispose();
},
mounted() {
- if (!this.editor) {
- this.editor = Editor.create(this.$store, this.editorOptions);
+ if (!this.globalEditor) {
+ this.globalEditor = new EditorLite();
}
this.initEditor();
@@ -211,8 +224,6 @@ export default {
return;
}
- this.editor.clearEditor();
-
this.registerSchemaForFile();
Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()])
@@ -251,20 +262,45 @@ export default {
return;
}
- this.editor.dispose();
+ const isDiff = this.viewer !== viewerTypes.edit;
+ const shouldDisposeEditor = isDiff !== (this.editor?.getEditorType() === EDITOR_TYPE_DIFF);
- this.$nextTick(() => {
- if (this.viewer === viewerTypes.edit) {
- this.editor.createInstance(this.$refs.editor);
- } else {
- this.editor.createDiffInstance(this.$refs.editor);
+ if (this.editor && !shouldDisposeEditor) {
+ this.setupEditor();
+ } else {
+ if (this.editor && shouldDisposeEditor) {
+ this.editor.dispose();
}
+ const instanceOptions = isDiff ? defaultDiffEditorOptions : defaultEditorOptions;
+ const method = isDiff ? EDITOR_DIFF_INSTANCE_FN : EDITOR_CODE_INSTANCE_FN;
- this.setupEditor();
- });
+ this.editor = this.globalEditor[method]({
+ el: this.$refs.editor,
+ blobPath: this.file.path,
+ blobGlobalId: this.file.key,
+ blobContent: this.content || this.file.content,
+ ...instanceOptions,
+ ...this.editorOptions,
+ });
+
+ this.editor.use(
+ new EditorWebIdeExtension({
+ instance: this.editor,
+ modelManager: this.modelManager,
+ store: this.$store,
+ file: this.file,
+ options: this.editorOptions,
+ }),
+ );
+
+ this.$nextTick(() => {
+ this.setupEditor();
+ });
+ }
},
+
setupEditor() {
- if (!this.file || !this.editor.instance || this.file.loading) return;
+ if (!this.file || !this.editor || this.file.loading) return;
const head = this.getStagedFile(this.file.path);
@@ -279,6 +315,8 @@ export default {
this.editor.attachModel(this.model);
}
+ this.isEditorLoading = false;
+
this.model.updateOptions(this.rules);
this.model.onChange((model) => {
@@ -298,7 +336,7 @@ export default {
});
});
- this.editor.setPosition({
+ this.editor.setPos({
lineNumber: this.fileEditor.editorRow,
column: this.fileEditor.editorColumn,
});
@@ -308,6 +346,10 @@ export default {
fileLanguage: this.model.language,
});
+ this.$nextTick(() => {
+ this.editor.updateDimensions();
+ });
+
this.$emit('editorSetup');
if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) {
eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION);
@@ -344,7 +386,7 @@ export default {
});
},
onPaste(event) {
- const editor = this.editor.instance;
+ const { editor } = this;
const reImage = /^image\/(png|jpg|jpeg|gif)$/;
const file = event.clipboardData.files[0];
@@ -395,6 +437,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
+ data-testid="edit-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
>
{{ __('Edit') }}
@@ -404,6 +447,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
+ data-testid="preview-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
>{{ previewMode.previewTitle }}</a
>
@@ -414,6 +458,7 @@ export default {
<div
v-show="showEditor"
ref="editor"
+ :key="`content-editor`"
:class="{
'is-readonly': isCommitModeActive,
'is-deleted': file.deleted,
@@ -421,6 +466,8 @@ export default {
}"
class="multi-file-editor-holder"
data-qa-selector="editor_container"
+ data-testid="editor-container"
+ :data-editor-loading="isEditorLoading"
@focusout="triggerFilesChange"
></div>
<content-viewer
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index d28751c9571..64ec2cc67c7 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTab } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import { __, sprintf } from '~/locale';
@@ -13,6 +13,7 @@ export default {
FileIcon,
GlIcon,
ChangedFileIcon,
+ GlTab,
},
props: {
tab: {
@@ -71,29 +72,30 @@ export default {
</script>
<template>
- <li
- :class="{
- active: tab.active,
- disabled: tab.pending,
- }"
+ <gl-tab
+ :active="tab.active"
+ :disabled="tab.pending"
+ :title="tab.name"
@click="clickFile(tab)"
@mouseover="mouseOverTab"
@mouseout="mouseOutTab"
>
- <div :title="getUrlForPath(tab.path)" class="multi-file-tab">
- <file-icon :file-name="tab.name" :size="16" />
- {{ tab.name }}
- <file-status-icon :file="tab" />
- </div>
- <button
- :aria-label="closeLabel"
- :disabled="tab.pending"
- type="button"
- class="multi-file-tab-close"
- @click.stop.prevent="closeFile(tab)"
- >
- <gl-icon v-if="!showChangedIcon" :size="12" name="close" />
- <changed-file-icon v-else :file="tab" />
- </button>
- </li>
+ <template #title>
+ <div :title="getUrlForPath(tab.path)" class="multi-file-tab">
+ <file-icon :file-name="tab.name" :size="16" />
+ {{ tab.name }}
+ <file-status-icon :file="tab" />
+ </div>
+ <button
+ :aria-label="closeLabel"
+ :disabled="tab.pending"
+ type="button"
+ class="multi-file-tab-close"
+ @click.stop.prevent="closeFile(tab)"
+ >
+ <gl-icon v-if="!showChangedIcon" :size="12" name="close" />
+ <changed-file-icon v-else :file="tab" />
+ </button>
+ </template>
+ </gl-tab>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue
index c03694e3619..932040c7fa5 100644
--- a/app/assets/javascripts/ide/components/repo_tabs.vue
+++ b/app/assets/javascripts/ide/components/repo_tabs.vue
@@ -1,10 +1,12 @@
<script>
+import { GlTabs } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import RepoTab from './repo_tab.vue';
export default {
components: {
RepoTab,
+ GlTabs,
},
props: {
activeFile: {
@@ -42,8 +44,8 @@ export default {
<template>
<div class="multi-file-tabs">
- <ul ref="tabsScroller" class="list-unstyled gl-mb-0">
+ <gl-tabs>
<repo-tab v-for="tab in files" :key="tab.key" :tab="tab" />
- </ul>
+ </gl-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/shared/tokened_input.vue b/app/assets/javascripts/ide/components/shared/tokened_input.vue
index e7a4c5487d1..ed0dab47947 100644
--- a/app/assets/javascripts/ide/components/shared/tokened_input.vue
+++ b/app/assets/javascripts/ide/components/shared/tokened_input.vue
@@ -81,7 +81,9 @@ export default {
>
<div class="value-container rounded">
<div class="value">{{ token.label }}</div>
- <div class="remove-token inverted"><gl-icon :size="10" name="close" /></div>
+ <div class="remove-token inverted">
+ <gl-icon :size="10" name="close" use-deprecated-sizes />
+ </div>
</div>
</button>
</div>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index ed6b750480b..056df3739ee 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -15,6 +15,7 @@ export const FILE_VIEW_MODE_PREVIEW = 'preview';
export const PERMISSION_CREATE_MR = 'createMergeRequestIn';
export const PERMISSION_READ_MR = 'readMergeRequest';
export const PERMISSION_PUSH_CODE = 'pushCode';
+export const PUSH_RULE_REJECT_UNSIGNED_COMMITS = 'rejectUnsignedCommits';
// The default permission object to use when the project data isn't available yet.
// This helps us encapsulate checks like `canPushCode` without requiring an
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 1b4b59ef62f..f4a0f324e4a 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -53,7 +53,6 @@ export function initIde(el, options = {}) {
promotionSvgPath: el.dataset.promotionSvgPath,
});
this.setLinks({
- ciHelpPagePath: el.dataset.ciHelpPagePath,
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
});
this.setInitialData({
diff --git a/app/assets/javascripts/ide/messages.js b/app/assets/javascripts/ide/messages.js
new file mode 100644
index 00000000000..4298d4c627c
--- /dev/null
+++ b/app/assets/javascripts/ide/messages.js
@@ -0,0 +1,17 @@
+import { s__ } from '~/locale';
+
+export const MSG_CANNOT_PUSH_CODE = s__(
+ 'WebIDE|You need permission to edit files directly in this project. Fork this project to make your changes and submit a merge request.',
+);
+
+export const MSG_CANNOT_PUSH_CODE_SHORT = s__(
+ 'WebIDE|You need permission to edit files directly in this project.',
+);
+
+export const MSG_CANNOT_PUSH_UNSIGNED = s__(
+ 'WebIDE|This project does not accept unsigned commits. You will not be able to commit your changes through the Web IDE.',
+);
+
+export const MSG_CANNOT_PUSH_UNSIGNED_SHORT = s__(
+ 'WebIDE|This project does not accept unsigned commits.',
+);
diff --git a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql
deleted file mode 100644
index f0b50793226..00000000000
--- a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-query getUserPermissions($projectPath: ID!) {
- project(fullPath: $projectPath) {
- userPermissions {
- createMergeRequestIn
- readMergeRequest
- pushCode
- }
- }
-}
diff --git a/app/assets/javascripts/ide/queries/get_ide_project.query.graphql b/app/assets/javascripts/ide/queries/get_ide_project.query.graphql
new file mode 100644
index 00000000000..6ceb36909f3
--- /dev/null
+++ b/app/assets/javascripts/ide/queries/get_ide_project.query.graphql
@@ -0,0 +1,7 @@
+#import "~/ide/queries/ide_project.fragment.graphql"
+
+query getIdeProject($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ ...IdeProject
+ }
+}
diff --git a/app/assets/javascripts/ide/queries/ide_project.fragment.graphql b/app/assets/javascripts/ide/queries/ide_project.fragment.graphql
new file mode 100644
index 00000000000..c107f2376f9
--- /dev/null
+++ b/app/assets/javascripts/ide/queries/ide_project.fragment.graphql
@@ -0,0 +1,7 @@
+fragment IdeProject on Project {
+ userPermissions {
+ createMergeRequestIn
+ readMergeRequest
+ pushCode
+ }
+}
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index e98653aedc2..0aa08323d13 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -1,14 +1,14 @@
+import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
-import getUserPermissions from '../queries/getUserPermissions.query.graphql';
import { query } from './gql';
const fetchApiProjectData = (projectPath) => Api.project(projectPath).then(({ data }) => data);
const fetchGqlProjectData = (projectPath) =>
query({
- query: getUserPermissions,
+ query: getIdeProject,
variables: { projectPath },
}).then(({ data }) => data.project);
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 9b93fc74f76..a5bb32ec44a 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -7,7 +7,14 @@ import {
PERMISSION_READ_MR,
PERMISSION_CREATE_MR,
PERMISSION_PUSH_CODE,
+ PUSH_RULE_REJECT_UNSIGNED_COMMITS,
} from '../constants';
+import {
+ MSG_CANNOT_PUSH_CODE,
+ MSG_CANNOT_PUSH_CODE_SHORT,
+ MSG_CANNOT_PUSH_UNSIGNED,
+ MSG_CANNOT_PUSH_UNSIGNED_SHORT,
+} from '../messages';
import { getChangesCountForFiles, filePathMatches } from './utils';
export const activeFile = (state) => state.openFiles.find((file) => file.active) || null;
@@ -153,14 +160,47 @@ export const getDiffInfo = (state, getters) => (path) => {
export const findProjectPermissions = (state, getters) => (projectId) =>
getters.findProject(projectId)?.userPermissions || DEFAULT_PERMISSIONS;
+export const findPushRules = (state, getters) => (projectId) =>
+ getters.findProject(projectId)?.pushRules || {};
+
export const canReadMergeRequests = (state, getters) =>
Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_READ_MR]);
export const canCreateMergeRequests = (state, getters) =>
Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_CREATE_MR]);
-export const canPushCode = (state, getters) =>
- Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_PUSH_CODE]);
+/**
+ * Returns an object with `isAllowed` and `message` based on why the user cant push code
+ */
+export const canPushCodeStatus = (state, getters) => {
+ const canPushCode = getters.findProjectPermissions(state.currentProjectId)[PERMISSION_PUSH_CODE];
+ const rejectUnsignedCommits = getters.findPushRules(state.currentProjectId)[
+ PUSH_RULE_REJECT_UNSIGNED_COMMITS
+ ];
+
+ if (rejectUnsignedCommits) {
+ return {
+ isAllowed: false,
+ message: MSG_CANNOT_PUSH_UNSIGNED,
+ messageShort: MSG_CANNOT_PUSH_UNSIGNED_SHORT,
+ };
+ }
+ if (!canPushCode) {
+ return {
+ isAllowed: false,
+ message: MSG_CANNOT_PUSH_CODE,
+ messageShort: MSG_CANNOT_PUSH_CODE_SHORT,
+ };
+ }
+
+ return {
+ isAllowed: true,
+ message: '',
+ messageShort: '',
+ };
+};
+
+export const canPushCode = (state, getters) => getters.canPushCodeStatus.isAllowed;
export const entryExists = (state) => (path) =>
Boolean(state.entries[path] && !state.entries[path].deleted);
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index 9e3347a657f..8df51ef7f9b 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -34,7 +34,7 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-display-flex gl-h-7 gl-align-items-center">
<gl-loading-icon
v-if="mappedStatus.loadingIcon"
:inline="true"
diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js
index ad33ca158d2..c2f398cb8a8 100644
--- a/app/assets/javascripts/import_entities/constants.js
+++ b/app/assets/javascripts/import_entities/constants.js
@@ -7,6 +7,7 @@ export const STATUSES = {
FINISHED: 'finished',
FAILED: 'failed',
SCHEDULED: 'scheduled',
+ CREATED: 'created',
STARTED: 'started',
NONE: 'none',
SCHEDULING: 'scheduling',
@@ -23,6 +24,11 @@ const STATUS_MAP = {
text: __('Failed'),
textClass: 'text-danger',
},
+ [STATUSES.CREATED]: {
+ icon: 'pending',
+ text: __('Scheduled'),
+ textClass: 'text-warning',
+ },
[STATUSES.SCHEDULED]: {
icon: 'pending',
text: __('Scheduled'),
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index 7c5f48dcafc..f337520b0db 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -1,13 +1,15 @@
<script>
import {
GlEmptyState,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
GlLink,
GlLoadingIcon,
GlSearchBoxByClick,
GlSprintf,
} from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import importGroupMutation from '../graphql/mutations/import_group.mutation.graphql';
import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql';
@@ -16,9 +18,14 @@ import availableNamespacesQuery from '../graphql/queries/available_namespaces.qu
import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql';
import ImportTableRow from './import_table_row.vue';
+const PAGE_SIZES = [20, 50, 100];
+const DEFAULT_PAGE_SIZE = PAGE_SIZES[0];
+
export default {
components: {
GlEmptyState,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
GlLink,
GlLoadingIcon,
@@ -33,12 +40,17 @@ export default {
type: String,
required: true,
},
+ groupPathRegex: {
+ type: RegExp,
+ required: true,
+ },
},
data() {
return {
filter: '',
page: 1,
+ perPage: DEFAULT_PAGE_SIZE,
};
},
@@ -46,13 +58,17 @@ export default {
bulkImportSourceGroups: {
query: bulkImportSourceGroupsQuery,
variables() {
- return { page: this.page, filter: this.filter };
+ return { page: this.page, filter: this.filter, perPage: this.perPage };
},
},
availableNamespaces: availableNamespacesQuery,
},
computed: {
+ humanizedTotal() {
+ return this.paginationInfo.total >= 1000 ? __('1000+') : this.paginationInfo.total;
+ },
+
hasGroups() {
return this.bulkImportSourceGroups?.nodes?.length > 0;
},
@@ -113,14 +129,20 @@ export default {
variables: { sourceGroupId },
});
},
+
+ setPageSize(size) {
+ this.perPage = size;
+ },
},
+
+ PAGE_SIZES,
};
</script>
<template>
<div>
<div
- class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex gl-align-items-center"
+ class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex"
>
<span>
<gl-sprintf v-if="!$apollo.loading && hasGroups" :message="statusMessage">
@@ -147,12 +169,17 @@ export default {
</div>
<gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" />
<template v-else>
- <gl-empty-state v-if="hasEmptyFilter" :title="__('Sorry, your filter produced no results')" />
+ <gl-empty-state
+ v-if="hasEmptyFilter"
+ :title="__('Sorry, your filter produced no results')"
+ :description="__('To widen your search, change or remove filters above.')"
+ />
<gl-empty-state
v-else-if="!hasGroups"
- :title="s__('BulkImport|No groups available for import')"
+ :title="s__('BulkImport|You have no groups to import')"
+ :description="s__('Check your source instance permissions.')"
/>
- <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center">
+ <template v-else>
<table class="gl-w-full">
<thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1">
<th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th>
@@ -160,12 +187,13 @@ export default {
<th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th>
<th class="gl-py-4 import-jobs-cta-col"></th>
</thead>
- <tbody>
+ <tbody class="gl-vertical-align-top">
<template v-for="group in bulkImportSourceGroups.nodes">
<import-table-row
:key="group.id"
:group="group"
:available-namespaces="availableNamespaces"
+ :group-path-regex="groupPathRegex"
@update-target-namespace="updateTargetNamespace(group.id, $event)"
@update-new-name="updateNewName(group.id, $event)"
@import-group="importGroup(group.id)"
@@ -173,12 +201,50 @@ export default {
</template>
</tbody>
</table>
- <pagination-links
- :change="setPage"
- :page-info="bulkImportSourceGroups.pageInfo"
- class="gl-mt-3"
- />
- </div>
+ <div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center">
+ <pagination-links
+ :change="setPage"
+ :page-info="bulkImportSourceGroups.pageInfo"
+ class="gl-m-0"
+ />
+ <gl-dropdown category="tertiary" class="gl-ml-auto">
+ <template #button-content>
+ <span class="font-weight-bold">
+ <gl-sprintf :message="__('%{count} items per page')">
+ <template #count>
+ {{ perPage }}
+ </template>
+ </gl-sprintf>
+ </span>
+ <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" />
+ </template>
+ <gl-dropdown-item
+ v-for="size in $options.PAGE_SIZES"
+ :key="size"
+ @click="setPageSize(size)"
+ >
+ <gl-sprintf :message="__('%{count} items per page')">
+ <template #count>
+ {{ size }}
+ </template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <div class="gl-ml-2">
+ <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')">
+ <template #start>
+ {{ paginationInfo.start }}
+ </template>
+ <template #end>
+ {{ paginationInfo.end }}
+ </template>
+ <template #total>
+ {{ humanizedTotal }}
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+ </template>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
index 1707ab10c89..aed879e75fb 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
@@ -1,15 +1,29 @@
<script>
-import { GlButton, GlIcon, GlLink, GlFormInput } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlIcon,
+ GlLink,
+ GlFormInput,
+} from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
-import Select2Select from '~/vue_shared/components/select2_select.vue';
import ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants';
+import groupQuery from '../graphql/queries/group.query.graphql';
+
+const DEBOUNCE_INTERVAL = 300;
export default {
components: {
- Select2Select,
ImportStatus,
GlButton,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
GlLink,
GlIcon,
GlFormInput,
@@ -23,9 +37,41 @@ export default {
type: Array,
required: true,
},
+ groupPathRegex: {
+ type: RegExp,
+ required: true,
+ },
+ },
+
+ apollo: {
+ existingGroup: {
+ query: groupQuery,
+ debounce: DEBOUNCE_INTERVAL,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ skip() {
+ return !this.isNameValid || this.isAlreadyImported;
+ },
+ },
},
+
computed: {
- isDisabled() {
+ importTarget() {
+ return this.group.import_target;
+ },
+
+ isInvalid() {
+ return Boolean(!this.isNameValid || this.existingGroup);
+ },
+
+ isNameValid() {
+ return this.groupPathRegex.test(this.importTarget.new_name);
+ },
+
+ isAlreadyImported() {
return this.group.status !== STATUSES.NONE;
},
@@ -33,61 +79,89 @@ export default {
return this.group.status === STATUSES.FINISHED;
},
- select2Options() {
- return {
- data: this.availableNamespaces.map((namespace) => ({
- id: namespace.full_path,
- text: namespace.full_path,
- })),
- };
- },
- },
- methods: {
- getPath(group) {
- return `${group.import_target.target_namespace}/${group.import_target.new_name}`;
+ fullPath() {
+ return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`;
},
- getFullPath(group) {
- return joinPaths(gon.relative_url_root || '/', this.getPath(group));
+ absolutePath() {
+ return joinPaths(gon.relative_url_root || '/', this.fullPath);
},
},
};
</script>
<template>
- <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1">
+ <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid">
<td class="gl-p-4">
- <gl-link :href="group.web_url" target="_blank">
+ <gl-link
+ :href="group.web_url"
+ target="_blank"
+ class="gl-display-flex gl-align-items-center gl-h-7"
+ >
{{ group.full_path }} <gl-icon name="external-link" />
</gl-link>
</td>
<td class="gl-p-4">
- <gl-link v-if="isFinished" :href="getFullPath(group)">{{ getPath(group) }}</gl-link>
+ <gl-link
+ v-if="isFinished"
+ class="gl-display-flex gl-align-items-center gl-h-7"
+ :href="absolutePath"
+ >
+ {{ fullPath }}
+ </gl-link>
<div
v-else
class="import-entities-target-select gl-display-flex gl-align-items-stretch"
:class="{
- disabled: isDisabled,
+ disabled: isAlreadyImported,
}"
>
- <select2-select
- :disabled="isDisabled"
- :options="select2Options"
- :value="group.import_target.target_namespace"
- @input="$emit('update-target-namespace', $event)"
- />
+ <gl-dropdown
+ :text="importTarget.target_namespace"
+ :disabled="isAlreadyImported"
+ toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
+ class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1"
+ >
+ <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{
+ s__('BulkImport|No parent')
+ }}</gl-dropdown-item>
+ <template v-if="availableNamespaces.length">
+ <gl-dropdown-divider />
+ <gl-dropdown-section-header>
+ {{ s__('BulkImport|Existing groups') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="ns in availableNamespaces"
+ :key="ns.full_path"
+ @click="$emit('update-target-namespace', ns.full_path)"
+ >
+ {{ ns.full_path }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
<div
- class="import-entities-target-select-separator gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
+ class="import-entities-target-select-separator gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
>
/
</div>
- <gl-form-input
- class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
- :disabled="isDisabled"
- :value="group.import_target.new_name"
- @input="$emit('update-new-name', $event)"
- />
+ <div class="gl-flex-fill-1">
+ <gl-form-input
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ :class="{ 'is-invalid': isInvalid && !isAlreadyImported }"
+ :disabled="isAlreadyImported"
+ :value="importTarget.new_name"
+ @input="$emit('update-new-name', $event)"
+ />
+ <p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2">
+ <template v-if="!isNameValid">
+ {{ __('Please choose a group URL with no special characters.') }}
+ </template>
+ <template v-else-if="existingGroup">
+ {{ s__('BulkImport|Name already exists.') }}
+ </template>
+ </p>
+ </div>
</div>
</td>
<td class="gl-p-4 gl-white-space-nowrap">
@@ -95,7 +169,8 @@ export default {
</td>
<td class="gl-p-4">
<gl-button
- v-if="!isDisabled"
+ v-if="!isAlreadyImported"
+ :disabled="isInvalid"
variant="success"
category="secondary"
@click="$emit('import-group')"
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
index 8110934efc4..d444cc77aa7 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
@@ -15,52 +15,71 @@ export const clientTypenames = {
BulkImportPageInfo: 'ClientBulkImportPageInfo',
};
-export function createResolvers({ endpoints }) {
+export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
let statusPoller;
+ let sourceGroupManager;
+ const getGroupsManager = (client) => {
+ if (!sourceGroupManager) {
+ sourceGroupManager = new GroupsManager({ client, sourceUrl });
+ }
+ return sourceGroupManager;
+ };
+
return {
Query: {
async bulkImportSourceGroups(_, vars, { client }) {
- const {
- data: { availableNamespaces },
- } = await client.query({ query: availableNamespacesQuery });
-
if (!statusPoller) {
statusPoller = new StatusPoller({
- client,
+ groupManager: getGroupsManager(client),
pollPath: endpoints.jobs,
});
statusPoller.startPolling();
}
- return axios
- .get(endpoints.status, {
+ const groupsManager = getGroupsManager(client);
+ return Promise.all([
+ axios.get(endpoints.status, {
params: {
page: vars.page,
per_page: vars.perPage,
filter: vars.filter,
},
- })
- .then(({ headers, data }) => {
+ }),
+ client.query({ query: availableNamespacesQuery }),
+ ]).then(
+ ([
+ { headers, data },
+ {
+ data: { availableNamespaces },
+ },
+ ]) => {
const pagination = parseIntPagination(normalizeHeaders(headers));
return {
__typename: clientTypenames.BulkImportSourceGroupConnection,
- nodes: data.importable_data.map((group) => ({
- __typename: clientTypenames.BulkImportSourceGroup,
- ...group,
- status: STATUSES.NONE,
- import_target: {
- new_name: group.full_path,
- target_namespace: availableNamespaces[0].full_path,
- },
- })),
+ nodes: data.importable_data.map((group) => {
+ const cachedImportState = groupsManager.getImportStateFromStorageByGroupId(
+ group.id,
+ );
+
+ return {
+ __typename: clientTypenames.BulkImportSourceGroup,
+ ...group,
+ status: cachedImportState?.status ?? STATUSES.NONE,
+ import_target: cachedImportState?.importTarget ?? {
+ new_name: group.full_path,
+ target_namespace: availableNamespaces[0]?.full_path ?? '',
+ },
+ };
+ }),
pageInfo: {
__typename: clientTypenames.BulkImportPageInfo,
...pagination,
},
};
- });
+ },
+ );
},
availableNamespaces: () =>
@@ -73,21 +92,21 @@ export function createResolvers({ endpoints }) {
},
Mutation: {
setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) {
- new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => {
+ getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => {
// eslint-disable-next-line no-param-reassign
sourceGroup.import_target.target_namespace = targetNamespace;
});
},
setNewName(_, { newName, sourceGroupId }, { client }) {
- new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => {
+ getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => {
// eslint-disable-next-line no-param-reassign
sourceGroup.import_target.new_name = newName;
});
},
async importGroup(_, { sourceGroupId }, { client }) {
- const groupManager = new SourceGroupsManager({ client });
+ const groupManager = getGroupsManager(client);
const group = groupManager.findById(sourceGroupId);
groupManager.setImportStatus(group, STATUSES.SCHEDULING);
try {
@@ -101,13 +120,10 @@ export function createResolvers({ endpoints }) {
},
],
});
- groupManager.setImportStatus(group, STATUSES.STARTED);
- SourceGroupsManager.attachImportId(group, response.data.id);
+ groupManager.startImport({ group, importId: response.data.id });
} catch (e) {
- createFlash({
- message: s__('BulkImport|Importing the group failed'),
- });
-
+ const message = e?.response?.data?.error ?? s__('BulkImport|Importing the group failed');
+ createFlash({ message });
groupManager.setImportStatus(group, STATUSES.NONE);
throw e;
}
@@ -116,5 +132,5 @@ export function createResolvers({ endpoints }) {
};
}
-export const createApolloClient = ({ endpoints }) =>
- createDefaultClient(createResolvers({ endpoints }), { assumeImmutableResults: true });
+export const createApolloClient = ({ sourceUrl, endpoints }) =>
+ createDefaultClient(createResolvers({ sourceUrl, endpoints }), { assumeImmutableResults: true });
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql
new file mode 100644
index 00000000000..52df3581ac4
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql
@@ -0,0 +1,5 @@
+query group($fullPath: ID!) {
+ existingGroup: group(fullPath: $fullPath) {
+ id
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
index 261e30edbbb..2c88d25358f 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
@@ -1,5 +1,7 @@
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import produce from 'immer';
+import { debounce, merge } from 'lodash';
+import { STATUSES } from '../../../constants';
import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql';
function extractTypeConditionFromFragment(fragment) {
@@ -13,15 +15,24 @@ function generateGroupId(id) {
});
}
+export const KEY = 'gl-bulk-imports-import-state';
+export const DEBOUNCE_INTERVAL = 200;
+
export class SourceGroupsManager {
- static importMap = new Map();
+ constructor({ client, sourceUrl, storage = window.localStorage }) {
+ this.client = client;
+ this.sourceUrl = sourceUrl;
- static attachImportId(group, importId) {
- SourceGroupsManager.importMap.set(importId, group.id);
+ this.storage = storage;
+ this.importStates = this.loadImportStatesFromStorage();
}
- constructor({ client }) {
- this.client = client;
+ loadImportStatesFromStorage() {
+ try {
+ return JSON.parse(this.storage.getItem(KEY)) ?? {};
+ } catch {
+ return {};
+ }
}
findById(id) {
@@ -42,8 +53,48 @@ export class SourceGroupsManager {
this.update(group, fn);
}
- findByImportId(importId) {
- return this.findById(SourceGroupsManager.importMap.get(importId));
+ saveImportState(importId, group) {
+ this.importStates[this.getStorageKey(importId)] = {
+ id: group.id,
+ importTarget: group.import_target,
+ status: group.status,
+ };
+ this.saveImportStatesToStorage();
+ }
+
+ getImportStateFromStorage(importId) {
+ return this.importStates[this.getStorageKey(importId)];
+ }
+
+ getImportStateFromStorageByGroupId(groupId) {
+ const PREFIX = this.getStorageKey('');
+ const [, importState] =
+ Object.entries(this.importStates).find(
+ ([key, group]) => key.startsWith(PREFIX) && group.id === groupId,
+ ) ?? [];
+
+ return importState;
+ }
+
+ getStorageKey(importId) {
+ return `${this.sourceUrl}|${importId}`;
+ }
+
+ saveImportStatesToStorage = debounce(() => {
+ try {
+ // storage might be changed in other tab so fetch first
+ this.storage.setItem(
+ KEY,
+ JSON.stringify(merge({}, this.loadImportStatesFromStorage(), this.importStates)),
+ );
+ } catch {
+ // empty catch intentional: storage might be unavailable or full
+ }
+ }, DEBOUNCE_INTERVAL);
+
+ startImport({ group, importId }) {
+ this.setImportStatus(group, STATUSES.CREATED);
+ this.saveImportState(importId, group);
}
setImportStatus(group, status) {
@@ -52,4 +103,22 @@ export class SourceGroupsManager {
sourceGroup.status = status;
});
}
+
+ setImportStatusByImportId(importId, status) {
+ const importState = this.getImportStateFromStorage(importId);
+ if (!importState) {
+ return;
+ }
+
+ if (importState.status !== status) {
+ importState.status = status;
+ }
+
+ const group = this.findById(importState.id);
+ if (group?.id) {
+ this.setImportStatus(group, status);
+ }
+
+ this.saveImportStatesToStorage();
+ }
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
index 63cd6b48fc4..b80a575afce 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
@@ -3,12 +3,9 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
import { s__ } from '~/locale';
-import { SourceGroupsManager } from './source_groups_manager';
export class StatusPoller {
- constructor({ client, pollPath }) {
- this.client = client;
-
+ constructor({ groupManager, pollPath }) {
this.eTagPoll = new Poll({
resource: {
fetchJobs: () => axios.get(pollPath),
@@ -29,7 +26,7 @@ export class StatusPoller {
}
});
- this.groupManager = new SourceGroupsManager({ client });
+ this.groupManager = groupManager;
}
startPolling() {
@@ -38,10 +35,7 @@ export class StatusPoller {
async updateImportsStatuses(importStatuses) {
importStatuses.forEach(({ id, status_name: statusName }) => {
- const group = this.groupManager.findByImportId(id);
- if (group.id) {
- this.groupManager.setImportStatus(group, statusName);
- }
+ this.groupManager.setImportStatusByImportId(id, statusName);
});
}
}
diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js
index cd837a840e4..cc60c8cbdb0 100644
--- a/app/assets/javascripts/import_entities/import_groups/index.js
+++ b/app/assets/javascripts/import_entities/import_groups/index.js
@@ -16,9 +16,11 @@ export function mountImportGroupsApp(mountElement) {
createBulkImportPath,
jobsPath,
sourceUrl,
+ groupPathRegex,
} = mountElement.dataset;
const apolloProvider = new VueApollo({
defaultClient: createApolloClient({
+ sourceUrl,
endpoints: {
status: statusPath,
availableNamespaces: availableNamespacesPath,
@@ -35,6 +37,7 @@ export function mountImportGroupsApp(mountElement) {
return createElement(ImportTable, {
props: {
sourceUrl,
+ groupPathRegex: new RegExp(`^(${groupPathRegex})$`),
},
});
},
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index 289c83979bb..ebb09947663 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -101,7 +101,8 @@ export default {
<template>
<tr
- class="qa-project-import-row gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11"
+ class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11"
+ data-qa-selector="project_import_row"
>
<td class="gl-p-4">
<gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink"
@@ -112,6 +113,7 @@ export default {
<td
class="gl-display-flex gl-flex-sm-wrap gl-p-4 gl-pt-5 gl-vertical-align-top"
data-testid="fullPath"
+ data-qa-selector="project_path_content"
>
<template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
<template v-else-if="isImportNotStarted">
@@ -124,7 +126,8 @@ export default {
</div>
<gl-form-input
v-model="newNameInput"
- class="gl-rounded-top-left-none gl-rounded-bottom-left-none qa-project-path-field"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ data-qa-selector="project_path_field"
/>
</div>
</template>
@@ -140,12 +143,13 @@ export default {
:href="repo.importedProject.fullPath"
rel="noreferrer noopener"
target="_blank"
+ data-qa-selector="go_to_project_button"
>{{ __('Go to project') }}
</gl-button>
<gl-button
v-if="isImportNotStarted"
type="button"
- class="qa-import-button"
+ data-qa-selector="import_button"
@click="fetchImport(repo.importSource.id)"
>
{{ importButtonText }}
diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js
index fcac9c519c2..577d8ecb777 100644
--- a/app/assets/javascripts/incidents_settings/constants.js
+++ b/app/assets/javascripts/incidents_settings/constants.js
@@ -40,7 +40,7 @@ export const I18N_ALERT_SETTINGS_FORM = {
label: __('Incident template (optional)'),
},
sendEmail: {
- label: __('Send a separate email notification to Developers.'),
+ label: __('Send a single email notification to Owners and Maintainers for new alerts.'),
},
autoCloseIncidents: {
label: __('Automatically close incidents when the associated Prometheus alert resolves.'),
@@ -51,7 +51,7 @@ export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selec
export const TAKING_INCIDENT_ACTION_DOCS_LINK =
'/help/operations/metrics/alerts#trigger-actions-from-alerts';
export const ISSUE_TEMPLATES_DOCS_LINK =
- '/help/user/project/description_templates#creating-issue-templates';
+ '/help/user/project/description_templates#create-an-issue-template';
/* PagerDuty integration settings constants */
diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
index 8677f139723..1e33ceb7835 100644
--- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
@@ -38,7 +38,7 @@ export default {
<gl-form-checkbox
v-model="activated"
name="service[active]"
- class="gl-display-block gl-line-height-0"
+ class="gl-display-block"
:disabled="isInheriting"
@change="onChange"
>
diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
index bcf4b036fd2..89f7e3b7a89 100644
--- a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
+++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
@@ -13,7 +13,7 @@ export default {
return {
text: __('Save'),
attributes: [
- { variant: 'success' },
+ { variant: 'confirm' },
{ category: 'primary' },
{ disabled: this.isDisabled },
],
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 3ec0c23e55d..91f7c7dabf6 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -61,9 +61,6 @@ export default {
this.customState.integrationLevel === integrationLevels.GROUP
);
},
- showJiraIssuesFields() {
- return this.isJira && this.glFeatures.jiraIssuesIntegration;
- },
showReset() {
return this.isInstanceOrGroupLevel && this.propsSource.resetPath;
},
@@ -129,7 +126,7 @@ export default {
v-bind="field"
/>
<jira-issues-fields
- v-if="showJiraIssuesFields"
+ v-if="isJira && !isInstanceOrGroupLevel"
:key="`${currentKey}-jira-issues-fields`"
v-bind="propsSource.jiraIssuesProps"
/>
@@ -138,7 +135,7 @@ export default {
<gl-button
v-gl-modal.confirmSaveIntegration
category="primary"
- variant="success"
+ variant="confirm"
:loading="isSaving"
:disabled="isDisabled"
data-qa-selector="save_changes_button"
@@ -150,7 +147,7 @@ export default {
<gl-button
v-else
category="primary"
- variant="success"
+ variant="confirm"
type="submit"
:loading="isSaving"
:disabled="isDisabled"
@@ -162,6 +159,8 @@ export default {
<gl-button
v-if="propsSource.canTest"
+ category="secondary"
+ variant="confirm"
:loading="isTesting"
:disabled="isDisabled"
:href="propsSource.testPath"
@@ -174,7 +173,7 @@ export default {
<gl-button
v-gl-modal.confirmResetIntegration
category="secondary"
- variant="default"
+ variant="confirm"
:loading="isResetting"
:disabled="isDisabled"
data-testid="reset-button"
@@ -184,9 +183,7 @@ export default {
<reset-confirmation-modal @reset="onResetClick" />
</template>
- <gl-button class="btn-cancel" :href="propsSource.cancelPath">{{
- __('Cancel')
- }}</gl-button>
+ <gl-button :href="propsSource.cancelPath">{{ __('Cancel') }}</gl-button>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue
new file mode 100644
index 00000000000..4a72e97db8c
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/group_select.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownText, GlSearchBoxByType } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import Api from '~/api';
+import { s__ } from '~/locale';
+import { SEARCH_DELAY } from '../constants';
+
+export default {
+ name: 'GroupSelect',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ },
+ model: {
+ prop: 'selectedGroup',
+ },
+ data() {
+ return {
+ isFetching: false,
+ groups: [],
+ selectedGroup: {},
+ searchTerm: '',
+ };
+ },
+ computed: {
+ selectedGroupName() {
+ return this.selectedGroup.name || this.$options.i18n.dropdownText;
+ },
+ isFetchResultEmpty() {
+ return this.groups.length === 0;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.retrieveGroups();
+ },
+ },
+ mounted() {
+ this.retrieveGroups();
+ },
+ methods: {
+ retrieveGroups: debounce(function debouncedRetrieveGroups() {
+ this.isFetching = true;
+ return Api.groups(this.searchTerm, this.$options.defaultFetchOptions)
+ .then((response) => {
+ this.groups = response.map((group) => ({
+ id: group.id,
+ name: group.full_name,
+ path: group.path,
+ }));
+ this.isFetching = false;
+ })
+ .catch(() => {
+ this.isFetching = false;
+ });
+ }, SEARCH_DELAY),
+ selectGroup(group) {
+ this.selectedGroup = group;
+
+ this.$emit('input', this.selectedGroup);
+ },
+ },
+ i18n: {
+ dropdownText: s__('GroupSelect|Select a group'),
+ searchPlaceholder: s__('GroupSelect|Search groups'),
+ emptySearchResult: s__('GroupSelect|No matching results'),
+ },
+ defaultFetchOptions: {
+ exclude_internal: true,
+ active: true,
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-dropdown
+ data-testid="group-select-dropdown"
+ :text="selectedGroupName"
+ block
+ menu-class="gl-w-full!"
+ >
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :is-loading="isFetching"
+ :placeholder="$options.i18n.searchPlaceholder"
+ data-qa-selector="group_select_dropdown_search_field"
+ />
+ <gl-dropdown-item
+ v-for="group in groups"
+ :key="group.id"
+ :name="group.name"
+ @click="selectGroup(group)"
+ >
+ {{ group.name }}
+ </gl-dropdown-item>
+ <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message">
+ <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
+ </gl-dropdown-text>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
new file mode 100644
index 00000000000..c9de078319a
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ displayText: {
+ type: String,
+ required: false,
+ default: s__('InviteMembers|Invite a group'),
+ },
+ classes: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ openModal() {
+ eventHub.$emit('openModal', { inviteeType: 'group' });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button :class="classes" data-qa-selector="invite_a_group_button" @click="openModal">
+ {{ displayText }}
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index f5a65882fba..47f1405c980 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -11,9 +11,10 @@ import {
} from '@gitlab/ui';
import { partition, isString } from 'lodash';
import Api from '~/api';
+import GroupSelect from '~/invite_members/components/group_select.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { s__, __, sprintf } from '~/locale';
+import { s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
export default {
@@ -28,6 +29,7 @@ export default {
GlButton,
GlFormInput,
MembersTokenSelect,
+ GroupSelect,
},
props: {
id: {
@@ -47,7 +49,7 @@ export default {
required: true,
},
defaultAccessLevel: {
- type: String,
+ type: Number,
required: true,
},
helpLink: {
@@ -60,21 +62,21 @@ export default {
visible: true,
modalId: 'invite-members-modal',
selectedAccessLevel: this.defaultAccessLevel,
+ inviteeType: 'members',
newUsersToInvite: [],
selectedDate: undefined,
+ groupToBeSharedWith: {},
};
},
computed: {
- inviteToName() {
- return this.name.toUpperCase();
- },
- inviteToType() {
- return this.isProject ? __('project') : __('group');
+ isInviteGroup() {
+ return this.inviteeType === 'group';
},
introText() {
- return sprintf(s__("InviteMembersModal|You're inviting members to the %{name} %{type}"), {
- name: this.inviteToName,
- type: this.inviteToType,
+ const inviteTo = this.isProject ? 'toProject' : 'toGroup';
+
+ return sprintf(this.$options.labels[this.inviteeType][inviteTo].introText, {
+ name: this.name,
});
},
toastOptions() {
@@ -82,12 +84,12 @@ export default {
onComplete: () => {
this.selectedAccessLevel = this.defaultAccessLevel;
this.newUsersToInvite = [];
+ this.groupToBeSharedWith = {};
},
};
},
basePostData() {
return {
- access_level: this.selectedAccessLevel,
expires_at: this.selectedDate,
format: 'json',
};
@@ -97,9 +99,16 @@ export default {
(key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
);
},
+ inviteDisabled() {
+ return (
+ this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0
+ );
+ },
},
mounted() {
- eventHub.$on('openModal', this.openModal);
+ eventHub.$on('openModal', (options) => {
+ this.openModal(options);
+ });
},
methods: {
partitionNewUsersToInvite() {
@@ -113,26 +122,42 @@ export default {
usersToAddById.map((user) => user.id).join(','),
];
},
- openModal() {
+ openModal({ inviteeType }) {
+ this.inviteeType = inviteeType;
+
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
sendInvite() {
- this.submitForm();
+ if (this.isInviteGroup) {
+ this.submitShareWithGroup();
+ } else {
+ this.submitInviteMembers();
+ }
this.closeModal();
},
cancelInvite() {
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
- this.newUsersToInvite = '';
+ this.newUsersToInvite = [];
+ this.groupToBeSharedWith = {};
this.closeModal();
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
- submitForm() {
+ submitShareWithGroup() {
+ const apiShareWithGroup = this.isProject
+ ? Api.projectShareWithGroup.bind(Api)
+ : Api.groupShareWithGroup.bind(Api);
+
+ apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
+ .then(this.showToastMessageSuccess)
+ .catch(this.showToastMessageError);
+ },
+ submitInviteMembers() {
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
@@ -155,10 +180,25 @@ export default {
Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError);
},
inviteByEmailPostData(usersToInviteByEmail) {
- return { ...this.basePostData, email: usersToInviteByEmail };
+ return {
+ ...this.basePostData,
+ email: usersToInviteByEmail,
+ access_level: this.selectedAccessLevel,
+ };
},
addByUserIdPostData(usersToAddById) {
- return { ...this.basePostData, user_id: usersToAddById };
+ return {
+ ...this.basePostData,
+ user_id: usersToAddById,
+ access_level: this.selectedAccessLevel,
+ };
+ },
+ shareWithGroupPostData(groupToBeSharedWith) {
+ return {
+ ...this.basePostData,
+ group_id: groupToBeSharedWith,
+ group_access: this.selectedAccessLevel,
+ };
},
showToastMessageSuccess() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
@@ -170,9 +210,36 @@ export default {
},
},
labels: {
- modalTitle: s__('InviteMembersModal|Invite team members'),
- newUsersToInvite: s__('InviteMembersModal|GitLab member or Email address'),
- userPlaceholder: s__('InviteMembersModal|Search for members to invite'),
+ members: {
+ modalTitle: s__('InviteMembersModal|Invite team members'),
+ searchField: s__('InviteMembersModal|GitLab member or Email address'),
+ placeHolder: s__('InviteMembersModal|Search for members to invite'),
+ toGroup: {
+ introText: s__(
+ "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.",
+ ),
+ },
+ toProject: {
+ introText: s__(
+ "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.",
+ ),
+ },
+ },
+ group: {
+ modalTitle: s__('InviteMembersModal|Invite a group'),
+ searchField: s__('InviteMembersModal|Select a group to invite'),
+ placeHolder: s__('InviteMembersModal|Search for a group to invite'),
+ toGroup: {
+ introText: s__(
+ "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group.",
+ ),
+ },
+ toProject: {
+ introText: s__(
+ "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.",
+ ),
+ },
+ },
accessLevel: s__('InviteMembersModal|Choose a role permission'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
@@ -189,31 +256,45 @@ export default {
<gl-modal
:modal-id="modalId"
size="sm"
- :title="$options.labels.modalTitle"
+ data-qa-selector="invite_members_modal_content"
+ :title="$options.labels[inviteeType].modalTitle"
:header-close-label="$options.labels.headerCloseLabel"
>
- <div class="gl-ml-5 gl-mr-5">
- <div>{{ introText }}</div>
+ <div>
+ <p ref="introText">
+ <gl-sprintf :message="introText">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
<label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{
- $options.labels.newUsersToInvite
+ $options.labels[inviteeType].searchField
}}</label>
<div class="gl-mt-2">
<members-token-select
+ v-if="!isInviteGroup"
v-model="newUsersToInvite"
- :label="$options.labels.newUsersToInvite"
:aria-labelledby="$options.membersTokenSelectLabelId"
- :placeholder="$options.labels.userPlaceholder"
+ :placeholder="$options.labels[inviteeType].placeHolder"
/>
+ <group-select v-if="isInviteGroup" v-model="groupToBeSharedWith" />
</div>
- <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label>
+ <label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
- <gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName">
+ <gl-dropdown
+ class="gl-shadow-none gl-w-full"
+ data-qa-selector="access_level_dropdown"
+ v-bind="$attrs"
+ :text="selectedRoleName"
+ >
<template v-for="(key, item) in accessLevels">
<gl-dropdown-item
:key="key"
active-class="is-active"
+ is-check-item
:is-checked="key === selectedAccessLevel"
@click="changeSelectedItem(key)"
>
@@ -223,7 +304,7 @@ export default {
</gl-dropdown>
</div>
- <div class="gl-mt-2">
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-sprintf :message="$options.labels.readMoreText">
<template #link="{ content }">
<gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
@@ -231,7 +312,7 @@ export default {
</gl-sprintf>
</div>
- <label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{
+ <label class="gl-font-weight-bold gl-mt-5 gl-display-block" for="expires_at">{{
$options.labels.accessExpireDate
}}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
@@ -253,15 +334,16 @@ export default {
</div>
<template #modal-footer>
- <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3">
+ <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0">
<gl-button ref="cancelButton" @click="cancelInvite">
{{ $options.labels.cancelButtonText }}
</gl-button>
<div class="gl-mr-3"></div>
<gl-button
ref="inviteButton"
- :disabled="!newUsersToInvite"
+ :disabled="inviteDisabled"
variant="success"
+ data-qa-selector="invite_button"
@click="sendInvite"
>{{ $options.labels.inviteButtonText }}</gl-button
>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index eb97c458f88..666693e934f 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -1,13 +1,10 @@
<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
- components: {
- GlLink,
- GlIcon,
- },
+ components: { GlButton },
props: {
displayText: {
type: String,
@@ -24,20 +21,28 @@ export default {
required: false,
default: '',
},
+ variant: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
methods: {
openModal() {
- eventHub.$emit('openModal');
+ eventHub.$emit('openModal', { inviteeType: 'members' });
},
},
};
</script>
<template>
- <gl-link :class="classes" @click="openModal">
- <div v-if="icon" class="nav-icon-container">
- <gl-icon :size="16" :name="icon" />
- </div>
- <span class="nav-item-name"> {{ displayText }} </span>
- </gl-link>
+ <gl-button
+ :class="classes"
+ :icon="icon"
+ :variant="variant"
+ data-qa-selector="invite_members_button"
+ @click="openModal"
+ >
+ {{ displayText }}
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue
index 233a214013b..db6a7888786 100644
--- a/app/assets/javascripts/invite_members/components/members_token_select.vue
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -3,7 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/u
import { debounce } from 'lodash';
import { __ } from '~/locale';
import { getUsers } from '~/rest_api';
-import { USER_SEARCH_DELAY } from '../constants';
+import { SEARCH_DELAY } from '../constants';
export default {
components: {
@@ -67,7 +67,7 @@ export default {
.catch(() => {
this.loading = false;
});
- }, USER_SEARCH_DELAY),
+ }, SEARCH_DELAY),
handleInput() {
this.$emit('input', this.selectedTokens);
},
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 1ff2125c292..2044dad896f 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1 +1 @@
-export const USER_SEARCH_DELAY = 200;
+export const SEARCH_DELAY = 200;
diff --git a/app/assets/javascripts/invite_members/init_invite_group_trigger.js b/app/assets/javascripts/invite_members/init_invite_group_trigger.js
new file mode 100644
index 00000000000..c01bb1bae28
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_group_trigger.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import InviteGroupTrigger from '~/invite_members/components/invite_group_trigger.vue';
+
+export default function initInviteGroupTrigger() {
+ const el = document.querySelector('.js-invite-group-trigger');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(InviteGroupTrigger, {
+ props: {
+ ...el.dataset,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/init_invite_members_form.js b/app/assets/javascripts/invite_members/init_invite_members_form.js
new file mode 100644
index 00000000000..5f8688755ba
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_members_form.js
@@ -0,0 +1,7 @@
+import { disableButtonIfEmptyField } from '~/lib/utils/common_utils';
+
+// This is only used when `invite_members_group_modal` feature flag is disabled.
+// This file can be removed when `invite_members_group_modal` feature flag is removed
+export default () => {
+ disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
+};
diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
index 3de99dcc546..fc77bd53ba4 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -20,6 +20,7 @@ export default function initInviteMembersModal() {
...el.dataset,
isProject: parseBoolean(el.dataset.isProject),
accessLevels: JSON.parse(el.dataset.accessLevels),
+ defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
},
}),
});
diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue
new file mode 100644
index 00000000000..78987a5c629
--- /dev/null
+++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue
@@ -0,0 +1,100 @@
+<script>
+import { GlButton, GlModal, GlSprintf, GlIcon } from '@gitlab/ui';
+import { ISSUABLE_TYPE } from '../constants';
+
+export default {
+ name: 'CsvExportModal',
+ components: {
+ GlButton,
+ GlModal,
+ GlSprintf,
+ GlIcon,
+ },
+ inject: {
+ issuableType: {
+ default: '',
+ },
+ issuableCount: {
+ default: 0,
+ },
+ email: {
+ default: '',
+ },
+ exportCsvPath: {
+ default: '',
+ },
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
+ };
+ },
+ issueableType: ISSUABLE_TYPE,
+};
+</script>
+
+<template>
+ <gl-modal :modal-id="modalId" body-class="gl-p-0!" data-qa-selector="export_issuable_modal">
+ <template #modal-title>
+ <gl-sprintf :message="__('Export %{name}')">
+ <template #name>{{ issuableName }}</template>
+ </gl-sprintf>
+ </template>
+ <div
+ v-if="issuableCount > -1"
+ data-testid="issuable-count-note"
+ class="gl-justify-content-start gl-align-items-center gl-p-4 gl-border-b-solid gl-border-1 gl-border-gray-50"
+ >
+ <gl-icon name="check" class="gl-color-green-400" />
+ <strong class="gl-m-3">
+ <gl-sprintf
+ v-if="issuableType === $options.issueableType.issues"
+ :message="n__('1 issue selected', '%d issues selected', issuableCount)"
+ >
+ <template #issuableCount>{{ issuableCount }}</template>
+ </gl-sprintf>
+ <gl-sprintf
+ v-else
+ :message="n__('1 merge request selected', '%d merge request selected', issuableCount)"
+ >
+ <template #issuableCount>{{ issuableCount }}</template>
+ </gl-sprintf>
+ </strong>
+ </div>
+ <div class="modal-text gl-px-4 gl-py-5">
+ <gl-sprintf
+ :message="
+ __(
+ `The CSV export will be created in the background. Once finished, it will be sent to %{strongStart}${email}%{strongEnd} in an attachment.`,
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ <template #modal-footer>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="exportCsvPath"
+ data-method="post"
+ :data-qa-selector="`export_${issuableType}_button`"
+ data-track-event="click_button"
+ :data-track-label="`export_${issuableType}_csv`"
+ >
+ <gl-sprintf :message="__('Export %{name}')">
+ <template #name>{{ issuableName }}</template>
+ </gl-sprintf>
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
new file mode 100644
index 00000000000..bbf4160ce35
--- /dev/null
+++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
@@ -0,0 +1,107 @@
+<script>
+import {
+ GlButtonGroup,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlTooltipDirective,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+import { ISSUABLE_TYPE } from '../constants';
+import CsvExportModal from './csv_export_modal.vue';
+import CsvImportModal from './csv_import_modal.vue';
+
+export default {
+ name: 'CsvImportExportButtons',
+ components: {
+ GlButtonGroup,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ CsvExportModal,
+ CsvImportModal,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
+ },
+ inject: {
+ issuableType: {
+ default: ISSUABLE_TYPE.issues,
+ },
+ showExportButton: {
+ default: false,
+ },
+ showImportButton: {
+ default: false,
+ },
+ containerClass: {
+ default: '',
+ },
+ canEdit: {
+ default: false,
+ },
+ projectImportJiraPath: {
+ default: null,
+ },
+ showLabel: {
+ default: false,
+ },
+ },
+ computed: {
+ exportModalId() {
+ return `${this.issuableType}-export-modal`;
+ },
+ importModalId() {
+ return `${this.issuableType}-import-modal`;
+ },
+ importButtonText() {
+ return this.showLabel ? this.$options.importIssuesText : null;
+ },
+ importButtonTooltipText() {
+ return this.showLabel ? null : this.$options.importIssuesText;
+ },
+ importButtonIcon() {
+ return this.showLabel ? null : 'import';
+ },
+ },
+ importIssuesText: __('Import issues'),
+};
+</script>
+
+<template>
+ <div :class="containerClass">
+ <gl-button-group>
+ <gl-button
+ v-if="showExportButton"
+ v-gl-tooltip.hover="__('Export as CSV')"
+ v-gl-modal="exportModalId"
+ icon="export"
+ data-qa-selector="export_as_csv_button"
+ data-testid="export-csv-button"
+ />
+ <gl-dropdown
+ v-if="showImportButton"
+ v-gl-tooltip.hover="importButtonTooltipText"
+ data-qa-selector="import_issues_dropdown"
+ data-testid="import-csv-dropdown"
+ :text="importButtonText"
+ :icon="importButtonIcon"
+ >
+ <gl-dropdown-item v-gl-modal="importModalId" data-testid="import-csv-link">{{
+ __('Import CSV')
+ }}</gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="canEdit"
+ :href="projectImportJiraPath"
+ data-qa-selector="import_from_jira_link"
+ data-testid="import-from-jira-link"
+ >{{ __('Import from Jira') }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </gl-button-group>
+ <csv-export-modal v-if="showExportButton" :modal-id="exportModalId" />
+ <csv-import-modal v-if="showImportButton" :modal-id="importModalId" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable/components/csv_import_modal.vue b/app/assets/javascripts/issuable/components/csv_import_modal.vue
new file mode 100644
index 00000000000..77fc2f31583
--- /dev/null
+++ b/app/assets/javascripts/issuable/components/csv_import_modal.vue
@@ -0,0 +1,86 @@
+<script>
+import { GlModal, GlSprintf, GlFormGroup, GlButton } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { ISSUABLE_TYPE } from '../constants';
+
+export default {
+ name: 'CsvImportModal',
+ components: {
+ GlModal,
+ GlSprintf,
+ GlFormGroup,
+ GlButton,
+ },
+ inject: {
+ issuableType: {
+ default: '',
+ },
+ exportCsvPath: {
+ default: '',
+ },
+ importCsvIssuesPath: {
+ default: '',
+ },
+ maxAttachmentSize: {
+ default: 0,
+ },
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
+ };
+ },
+ methods: {
+ submitForm() {
+ this.$refs.form.submit();
+ },
+ },
+ csrf,
+};
+</script>
+
+<template>
+ <gl-modal :modal-id="modalId" :title="__('Import issues')">
+ <form
+ ref="form"
+ :action="importCsvIssuesPath"
+ enctype="multipart/form-data"
+ method="post"
+ data-testid="import-csv-form"
+ >
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <p>
+ {{
+ __(
+ "Your issues will be imported in the background. Once finished, you'll get a confirmation email.",
+ )
+ }}
+ </p>
+ <gl-form-group :label="__('Upload CSV file')" label-for="file">
+ <input id="file" type="file" name="file" accept=".csv,text/csv" />
+ </gl-form-group>
+ <p class="text-secondary">
+ {{
+ __(
+ 'It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.',
+ )
+ }}
+ <gl-sprintf :message="__('The maximum file size allowed is %{size}.')"
+ ><template #size>{{ maxAttachmentSize }}</template></gl-sprintf
+ >
+ </p>
+ </form>
+ <template #modal-footer>
+ <gl-button category="primary" variant="confirm" @click="submitForm">{{
+ __('Import issues')
+ }}</gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/issuable/constants.js b/app/assets/javascripts/issuable/constants.js
new file mode 100644
index 00000000000..9344f4a7c9a
--- /dev/null
+++ b/app/assets/javascripts/issuable/constants.js
@@ -0,0 +1,6 @@
+export const EVENT_ISSUABLE_VUE_APP_CHANGE = 'issuable_vue_app:change';
+
+export const ISSUABLE_TYPE = {
+ issues: 'issues',
+ mergeRequests: 'merge-requests',
+};
diff --git a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
new file mode 100644
index 00000000000..5a720b89d33
--- /dev/null
+++ b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
@@ -0,0 +1,45 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import ImportExportButtons from './components/csv_import_export_buttons.vue';
+
+export default () => {
+ const el = document.querySelector('.js-csv-import-export-buttons');
+
+ if (!el) return null;
+
+ const {
+ showExportButton,
+ showImportButton,
+ issuableType,
+ issuableCount,
+ email,
+ exportCsvPath,
+ importCsvIssuesPath,
+ containerClass,
+ canEdit,
+ projectImportJiraPath,
+ maxAttachmentSize,
+ showLabel,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ showExportButton: parseBoolean(showExportButton),
+ showImportButton: parseBoolean(showImportButton),
+ issuableType,
+ issuableCount,
+ email,
+ exportCsvPath,
+ importCsvIssuesPath,
+ containerClass,
+ canEdit: parseBoolean(canEdit),
+ projectImportJiraPath,
+ maxAttachmentSize,
+ showLabel,
+ },
+ render(h) {
+ return h(ImportExportButtons);
+ },
+ });
+};
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 1b06dffbae7..153123a005f 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -5,6 +5,7 @@ import Autosave from './autosave';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import { loadCSSFile } from './lib/utils/css_utils';
import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
+import { select2AxiosTransport } from './lib/utils/select2_utils';
import { queryToObject, objectToQuery } from './lib/utils/url_utility';
import UsersSelect from './users_select';
import ZenMode from './zen_mode';
@@ -199,15 +200,16 @@ export default class IssuableForm {
search,
};
},
- results(data) {
+ results({ results }) {
return {
// `data` keys are translated so we can't just access them with a string based key
- results: data[Object.keys(data)[0]].map((name) => ({
+ results: results[Object.keys(results)[0]].map((name) => ({
id: name,
text: name,
})),
};
},
+ transport: select2AxiosTransport,
},
initSelection(el, callback) {
const val = el.val();
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue
index 39852eba71a..92c527c79ff 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue
@@ -5,7 +5,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
-import { __, sprintf } from '~/locale';
+import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -34,6 +34,11 @@ export default {
type: Boolean,
required: true,
},
+ labelFilterParam: {
+ type: String,
+ required: false,
+ default: 'label_name',
+ },
showCheckbox: {
type: Boolean,
required: true,
@@ -81,8 +86,26 @@ export default {
}
return {};
},
+ taskStatus() {
+ const { completedCount, count } = this.issuable.taskCompletionStatus || {};
+ if (!count) {
+ return undefined;
+ }
+
+ return sprintf(
+ n__(
+ '%{completedCount} of %{count} task completed',
+ '%{completedCount} of %{count} tasks completed',
+ count,
+ ),
+ { completedCount, count },
+ );
+ },
+ notesCount() {
+ return this.issuable.userDiscussionsCount ?? this.issuable.userNotesCount;
+ },
showDiscussions() {
- return typeof this.issuable.userDiscussionsCount === 'number';
+ return typeof this.notesCount === 'number';
},
showIssuableMeta() {
return Boolean(
@@ -105,9 +128,8 @@ export default {
},
labelTarget(label) {
if (this.enableLabelPermalinks) {
- const key = encodeURIComponent('label_name[]');
const value = encodeURIComponent(this.labelTitle(label));
- return `?${key}=${value}`;
+ return `?${this.labelFilterParam}[]=${value}`;
}
return '#';
},
@@ -144,19 +166,27 @@ export default {
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
+ :aria-label="__('Confidential')"
/>
<gl-link :href="webUrl" v-bind="issuableTitleProps"
>{{ issuable.title
}}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
/></gl-link>
</span>
+ <span
+ v-if="taskStatus"
+ class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
+ data-testid="task-status"
+ >
+ {{ taskStatus }}
+ </span>
</div>
<div class="issuable-info">
<slot v-if="hasSlotContents('reference')" name="reference"></slot>
<span v-else data-testid="issuable-reference" class="issuable-reference"
>{{ issuableSymbol }}{{ issuable.iid }}</span
>
- <span class="issuable-authored d-none d-sm-inline-block">
+ <span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3">
&middot;
<span
v-gl-tooltip:tooltipcontainer.bottom
@@ -199,6 +229,16 @@ export default {
<li v-if="hasSlotContents('status')" class="issuable-status">
<slot name="status"></slot>
</li>
+ <li v-if="assignees.length" class="gl-display-flex">
+ <issuable-assignees
+ :assignees="assignees"
+ :icon-size="16"
+ :max-visible="4"
+ img-css-classes="gl-mr-2!"
+ class="gl-align-items-center gl-display-flex gl-ml-3"
+ />
+ </li>
+ <slot name="statistics"></slot>
<li
v-if="showDiscussions"
data-testid="issuable-discussions"
@@ -208,26 +248,17 @@ export default {
v-gl-tooltip:tooltipcontainer.top
:title="__('Comments')"
:href="issuableNotesLink"
- :class="{ 'no-comments': !issuable.userDiscussionsCount }"
+ :class="{ 'no-comments': !notesCount }"
class="gl-reset-color!"
>
<gl-icon name="comments" />
- {{ issuable.userDiscussionsCount }}
+ {{ notesCount }}
</gl-link>
</li>
- <li v-if="assignees.length" class="gl-display-flex">
- <issuable-assignees
- :assignees="issuable.assignees"
- :icon-size="16"
- :max-visible="4"
- img-css-classes="gl-mr-2!"
- class="gl-align-items-center gl-display-flex gl-ml-3"
- />
- </li>
</ul>
<div
data-testid="issuable-updated-at"
- class="float-right issuable-updated-at d-none d-sm-inline-block"
+ class="float-right issuable-updated-at gl-display-none gl-sm-display-inline-block"
>
<span
v-gl-tooltip:tooltipcontainer.bottom
diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
index 708e175cdb2..40b0fcbb8c6 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
@@ -122,6 +122,11 @@ export default {
required: false,
default: true,
},
+ labelFilterParam: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -180,7 +185,7 @@ export default {
handler(params) {
if (Object.keys(params).length) {
updateHistory({
- url: setUrlParams(params, window.location.href, true),
+ url: setUrlParams(params, window.location.href, true, false, true),
title: document.title,
replace: true,
});
@@ -258,6 +263,7 @@ export default {
:issuable-symbol="issuableSymbol"
:issuable="issuable"
:enable-label-permalinks="enableLabelPermalinks"
+ :label-filter-param="labelFilterParam"
:show-checkbox="showBulkEditSidebar"
:checked="issuableChecked(issuable)"
@checked-input="handleIssuableCheckedInput(issuable, $event)"
@@ -274,6 +280,9 @@ export default {
<template #status>
<slot name="status" :issuable="issuable"></slot>
</template>
+ <template #statistics>
+ <slot name="statistics" :issuable="issuable"></slot>
+ </template>
</issuable-item>
</ul>
<slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/issuable_show/components/issuable_body.vue
index 02cf7a67727..fe102e942c9 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_body.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_body.vue
@@ -1,6 +1,8 @@
<script>
import { GlLink } from '@gitlab/ui';
+import TaskList from '~/task_list';
+
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import IssuableDescription from './issuable_description.vue';
@@ -40,6 +42,11 @@ export default {
type: Boolean,
required: true,
},
+ enableTaskList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
editFormVisible: {
type: Boolean,
required: true,
@@ -56,6 +63,16 @@ export default {
type: String,
required: true,
},
+ taskListUpdatePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskListLockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
computed: {
isUpdated() {
@@ -65,7 +82,50 @@ export default {
return this.issuable.updatedBy;
},
},
+ watch: {
+ /**
+ * When user switches between view and edit modes,
+ * taskList instance becomes invalid so whenever
+ * view mode is rendered, we need to re-initialize
+ * taskList to ensure the behaviour functional.
+ */
+ editFormVisible(value) {
+ if (!value) {
+ this.$nextTick(() => {
+ this.initTaskList();
+ });
+ }
+ },
+ },
+ mounted() {
+ if (this.enableEdit && this.enableTaskList) {
+ this.initTaskList();
+ }
+ },
methods: {
+ initTaskList() {
+ this.taskList = new TaskList({
+ /**
+ * We have hard-coded dataType to `issue`
+ * as currently only `issue` types can handle
+ * task-lists, however, we can still use
+ * task lists in Issue, Test Cases and Incidents
+ * as all of those are derived from `issue`.
+ */
+ dataType: 'issue',
+ fieldName: 'description',
+ lockVersion: this.taskListLockVersion,
+ selector: '.js-detail-page-description',
+ onSuccess: this.handleTaskListUpdateSuccess.bind(this),
+ onError: this.handleTaskListUpdateFailure.bind(this),
+ });
+ },
+ handleTaskListUpdateSuccess(updatedIssuable) {
+ this.$emit('task-list-update-success', updatedIssuable);
+ },
+ handleTaskListUpdateFailure() {
+ this.$emit('task-list-update-failure');
+ },
handleKeydownTitle(e, issuableMeta) {
this.$emit('keydown-title', e, issuableMeta);
},
@@ -78,7 +138,7 @@ export default {
<template>
<div class="issue-details issuable-details">
- <div class="detail-page-description content-block">
+ <div class="detail-page-description js-detail-page-description content-block">
<issuable-edit-form
v-if="editFormVisible"
:issuable="issuable"
@@ -106,7 +166,13 @@ export default {
<slot name="status-badge"></slot>
</template>
</issuable-title>
- <issuable-description v-if="issuable.descriptionHtml" :issuable="issuable" />
+ <issuable-description
+ v-if="issuable.descriptionHtml"
+ :issuable="issuable"
+ :enable-task-list="enableTaskList"
+ :can-edit="enableEdit"
+ :task-list-update-path="taskListUpdatePath"
+ />
<small v-if="isUpdated" class="edited-text gl-font-sm!">
{{ __('Edited') }}
<time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" />
diff --git a/app/assets/javascripts/issuable_show/components/issuable_description.vue b/app/assets/javascripts/issuable_show/components/issuable_description.vue
index aa7e530972f..f57b5b2deb4 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_description.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_description.vue
@@ -12,6 +12,18 @@ export default {
type: Object,
required: true,
},
+ enableTaskList: {
+ type: Boolean,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ taskListUpdatePath: {
+ type: String,
+ required: true,
+ },
},
mounted() {
this.renderGFM();
@@ -25,7 +37,16 @@ export default {
</script>
<template>
- <div class="description">
+ <div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }">
<div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div>
+ <textarea
+ v-if="issuable.description && enableTaskList"
+ ref="textarea"
+ :value="issuable.description"
+ :data-update-url="taskListUpdatePath"
+ class="gl-display-none js-task-list-field"
+ dir="auto"
+ >
+ </textarea>
</div>
</template>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_discussion.vue b/app/assets/javascripts/issuable_show/components/issuable_discussion.vue
new file mode 100644
index 00000000000..5858af6cc51
--- /dev/null
+++ b/app/assets/javascripts/issuable_show/components/issuable_discussion.vue
@@ -0,0 +1,15 @@
+<script>
+export default {
+ name: 'IssuableDiscussion',
+};
+</script>
+
+<template>
+ <section class="issuable-discussion">
+ <div>
+ <ul class="notes main-notes-list timeline">
+ <slot name="discussion"></slot>
+ </ul>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_header.vue b/app/assets/javascripts/issuable_show/components/issuable_header.vue
index de17f7e7f6b..d7da533d055 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_header.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_header.vue
@@ -3,6 +3,7 @@ import { GlIcon, GlButton, GlTooltipDirective, GlAvatarLink, GlAvatarLabeled } f
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isExternal } from '~/lib/utils/url_utility';
+import { n__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
@@ -45,6 +46,11 @@ export default {
required: false,
default: false,
},
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
computed: {
authorId() {
@@ -53,6 +59,18 @@ export default {
isAuthorExternal() {
return isExternal(this.author.webUrl);
},
+ taskStatusString() {
+ const { count, completedCount } = this.taskCompletionStatus;
+
+ return sprintf(
+ n__(
+ '%{completedCount} of %{count} task completed',
+ '%{completedCount} of %{count} tasks completed',
+ count,
+ ),
+ { completedCount, count },
+ );
+ },
},
mounted() {
this.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button');
@@ -74,8 +92,8 @@ export default {
<gl-icon v-if="statusIcon" :name="statusIcon" class="d-block d-sm-none" />
<span class="d-none d-sm-block"><slot name="status-badge"></slot></span>
</div>
- <div class="issuable-meta gl-display-flex gl-align-items-center">
- <div class="gl-display-inline-block">
+ <div class="issuable-meta gl-display-flex gl-align-items-center d-md-inline-block">
+ <div v-if="blocked || confidential" class="gl-display-inline-block">
<div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline">
<gl-icon name="lock" :aria-label="__('Blocked')" />
</div>
@@ -95,13 +113,13 @@ export default {
:data-name="author.name"
:href="author.webUrl"
target="_blank"
- class="js-user-link gl-ml-2"
+ class="js-user-link gl-vertical-align-middle gl-ml-2"
>
<gl-avatar-labeled
:size="24"
:src="author.avatarUrl"
:label="author.name"
- class="d-none d-sm-inline-flex gl-ml-1"
+ class="d-none d-sm-inline-flex gl-mx-1"
>
<template #meta>
<gl-icon v-if="isAuthorExternal" name="external-link" />
@@ -109,6 +127,12 @@ export default {
</gl-avatar-labeled>
<strong class="author d-sm-none d-inline">@{{ author.username }}</strong>
</gl-avatar-link>
+ <span
+ v-if="taskCompletionStatus"
+ data-testid="task-status"
+ class="gl-display-none gl-md-display-block gl-lg-display-inline-block"
+ >{{ taskStatusString }}</span
+ >
</div>
<gl-button
data-testid="sidebar-toggle"
diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
index 240f35b74c8..b514a6b01d8 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
@@ -2,6 +2,7 @@
import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue';
import IssuableBody from './issuable_body.vue';
+import IssuableDiscussion from './issuable_discussion.vue';
import IssuableHeader from './issuable_header.vue';
export default {
@@ -9,6 +10,7 @@ export default {
IssuableSidebar,
IssuableHeader,
IssuableBody,
+ IssuableDiscussion,
},
props: {
issuable: {
@@ -40,6 +42,11 @@ export default {
required: false,
default: true,
},
+ enableTaskList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
editFormVisible: {
type: Boolean,
required: false,
@@ -60,6 +67,21 @@ export default {
required: false,
default: '',
},
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ taskListUpdatePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskListLockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
methods: {
handleKeydownTitle(e, issuableMeta) {
@@ -81,6 +103,7 @@ export default {
:confidential="issuable.confidential"
:created-at="issuable.createdAt"
:author="issuable.author"
+ :task-completion-status="taskCompletionStatus"
>
<template #status-badge>
<slot name="status-badge"></slot>
@@ -89,6 +112,7 @@ export default {
<slot name="header-actions"></slot>
</template>
</issuable-header>
+
<issuable-body
:issuable="issuable"
:status-badge-class="statusBadgeClass"
@@ -96,11 +120,16 @@ export default {
:enable-edit="enableEdit"
:enable-autocomplete="enableAutocomplete"
:enable-autosave="enableAutosave"
+ :enable-task-list="enableTaskList"
:edit-form-visible="editFormVisible"
:show-field-title="showFieldTitle"
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
+ :task-list-update-path="taskListUpdatePath"
+ :task-list-lock-version="taskListLockVersion"
@edit-issuable="$emit('edit-issuable', $event)"
+ @task-list-update-success="$emit('task-list-update-success', $event)"
+ @task-list-update-failure="$emit('task-list-update-failure')"
@keydown-title="handleKeydownTitle"
@keydown-description="handleKeydownDescription"
>
@@ -111,6 +140,13 @@ export default {
<slot name="edit-form-actions" v-bind="actionsProps"></slot>
</template>
</issuable-body>
+
+ <issuable-discussion>
+ <template #discussion>
+ <slot name="discussion"></slot>
+ </template>
+ </issuable-discussion>
+
<issuable-sidebar @sidebar-toggle="$emit('sidebar-toggle', $event)">
<template #right-sidebar-items="sidebarProps">
<slot name="right-sidebar-items" v-bind="sidebarProps"></slot>
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 3f25682ab8b..f6eff8133a7 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import { joinPaths } from '~/lib/utils/url_utility';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
import { deprecatedCreateFlash as flash } from './flash';
+import { EVENT_ISSUABLE_VUE_APP_CHANGE } from './issuable/constants';
import axios from './lib/utils/axios_utils';
import { addDelimiter } from './lib/utils/text_utility';
import { __ } from './locale';
@@ -23,9 +24,13 @@ export default class Issue {
}
// Listen to state changes in the Vue app
- document.addEventListener('issuable_vue_app:change', (event) => {
+ this.issuableVueAppChangeHandler = (event) =>
this.updateTopState(event.detail.isClosed, event.detail.data);
- });
+ document.addEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, this.issuableVueAppChangeHandler);
+ }
+
+ dispose() {
+ document.removeEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, this.issuableVueAppChangeHandler);
}
/**
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index e70c18040b3..9b978483cc6 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -5,7 +5,6 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
-import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor';
import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants';
import eventHub from '../event_hub';
import Service from '../services/index';
@@ -25,7 +24,6 @@ export default {
formComponent,
PinnedLinks,
},
- mixins: [recaptchaModalImplementor],
props: {
endpoint: {
required: true,
@@ -250,6 +248,7 @@ export default {
},
},
created() {
+ this.flashContainer = null;
this.service = new Service(this.endpoint);
this.poll = new Poll({
resource: this.service,
@@ -289,7 +288,7 @@ export default {
methods: {
handleBeforeUnloadEvent(e) {
const event = e;
- if (this.showForm && this.issueChanged && !this.showRecaptcha) {
+ if (this.showForm && this.issueChanged) {
event.returnValue = __('Are you sure you want to lose your issue information?');
}
return undefined;
@@ -307,7 +306,7 @@ export default {
});
},
- updateAndShowForm(templates = []) {
+ updateAndShowForm(templates = {}) {
if (!this.showForm) {
this.showForm = true;
this.store.setFormState({
@@ -347,10 +346,10 @@ export default {
},
updateIssuable() {
+ this.clearFlash();
return this.service
.updateIssuable(this.store.formState)
.then((res) => res.data)
- .then((data) => this.checkForSpam(data))
.then((data) => {
if (!window.location.pathname.includes(data.web_url)) {
visitUrl(data.web_url);
@@ -361,28 +360,22 @@ export default {
eventHub.$emit('close.form');
})
.catch((error = {}) => {
- const { name, response = {} } = error;
+ const { message, response = {} } = error;
- if (name === 'SpamError') {
- this.openRecaptcha();
- } else {
- let errMsg = this.defaultErrorMessage;
+ this.store.setFormState({
+ updateLoading: false,
+ });
- if (response.data && response.data.errors) {
- errMsg += `. ${response.data.errors.join(' ')}`;
- }
+ let errMsg = this.defaultErrorMessage;
- createFlash(errMsg);
+ if (response.data && response.data.errors) {
+ errMsg += `. ${response.data.errors.join(' ')}`;
+ } else if (message) {
+ errMsg += `. ${message}`;
}
- });
- },
-
- closeRecaptchaModal() {
- this.store.setFormState({
- updateLoading: false,
- });
- this.closeRecaptcha();
+ this.flashContainer = createFlash(errMsg);
+ });
},
deleteIssuable(payload) {
@@ -409,6 +402,13 @@ export default {
showStickyHeader() {
this.isStickyHeaderShowing = true;
},
+
+ clearFlash() {
+ if (this.flashContainer) {
+ this.flashContainer.style.display = 'none';
+ this.flashContainer = null;
+ }
+ },
},
};
</script>
@@ -430,13 +430,6 @@ export default {
:enable-autocomplete="enableAutocomplete"
:issuable-type="issuableType"
/>
-
- <recaptcha-modal
- v-show="showRecaptcha"
- ref="recaptchaModal"
- :html="recaptchaHTML"
- @close="closeRecaptchaModal"
- />
</div>
<div v-else>
<title-component
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 5416d3bebd0..68bc6fe4c0e 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -4,7 +4,6 @@ import $ from 'jquery';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__, sprintf } from '~/locale';
import TaskList from '../../task_list';
-import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
import animateMixin from '../mixins/animate';
export default {
@@ -12,7 +11,7 @@ export default {
SafeHtml,
},
- mixins: [animateMixin, recaptchaModalImplementor],
+ mixins: [animateMixin],
props: {
canUpdate: {
@@ -87,21 +86,11 @@ export default {
fieldName: 'description',
lockVersion: this.lockVersion,
selector: '.detail-page-description',
- onSuccess: this.taskListUpdateSuccess.bind(this),
onError: this.taskListUpdateError.bind(this),
});
}
},
- taskListUpdateSuccess(data) {
- try {
- this.checkForSpam(data);
- this.closeRecaptcha();
- } catch (error) {
- if (error && error.name === 'SpamError') this.openRecaptcha();
- }
- },
-
taskListUpdateError() {
createFlash(
sprintf(
@@ -165,7 +154,5 @@ export default {
>
</textarea>
<!-- eslint-enable vue/no-mutating-props -->
-
- <recaptcha-modal v-show="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptcha" />
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
index dd378c40b46..20c759cfbbd 100644
--- a/app/assets/javascripts/issue_show/components/edit_actions.vue
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -45,15 +45,23 @@ export default {
shouldShowDeleteButton() {
return this.canDestroy && this.showDeleteButton;
},
+ deleteIssuableButtonText() {
+ return sprintf(__('Delete %{issuableType}'), {
+ issuableType: issuableTypes[this.issuableType],
+ });
+ },
},
methods: {
closeForm() {
eventHub.$emit('close.form');
},
deleteIssuable() {
- const confirmMessage = sprintf(__('%{issuableType} will be removed! Are you sure?'), {
- issuableType: issuableTypes[this.issuableType],
- });
+ const confirmMessage =
+ this.issuableType === 'epic'
+ ? __('Delete this epic and all descendants?')
+ : sprintf(__('%{issuableType} will be removed! Are you sure?'), {
+ issuableType: issuableTypes[this.issuableType],
+ });
// eslint-disable-next-line no-alert
if (window.confirm(confirmMessage)) {
this.deleteLoading = true;
@@ -90,7 +98,7 @@ export default {
class="float-right gl-mr-3 qa-delete-button"
@click="deleteIssuable"
>
- {{ __('Delete') }}
+ {{ deleteIssuableButtonText }}
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
index dbec6f15cab..570bc7df3cf 100644
--- a/app/assets/javascripts/issue_show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -13,9 +13,9 @@ export default {
required: true,
},
issuableTemplates: {
- type: Array,
+ type: [Object, Array],
required: false,
- default: () => [],
+ default: () => {},
},
projectPath: {
type: String,
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index b7425448052..76ea489fb86 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -26,9 +26,9 @@ export default {
required: true,
},
issuableTemplates: {
- type: Array,
+ type: [Object, Array],
required: false,
- default: () => [],
+ default: () => {},
},
issuableType: {
type: String,
@@ -72,7 +72,7 @@ export default {
},
computed: {
hasIssuableTemplates() {
- return this.issuableTemplates.length;
+ return Object.values(Object(this.issuableTemplates)).length;
},
showLockedWarning() {
return this.formState.lockedWarningVisible && !this.formState.updateLoading;
diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue
index 9c3988d0469..2f2c4c6e341 100644
--- a/app/assets/javascripts/issue_show/components/header_actions.vue
+++ b/app/assets/javascripts/issue_show/components/header_actions.vue
@@ -2,6 +2,7 @@
import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import createFlash, { FLASH_TYPES } from '~/flash';
+import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import { IssuableType } from '~/issuable_show/constants';
import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
@@ -148,7 +149,7 @@ export default {
};
// Dispatch event which updates open/close state, shared among the issue show page
- document.dispatchEvent(new CustomEvent('issuable_vue_app:change', payload));
+ document.dispatchEvent(new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, payload));
})
.catch(() => createFlash({ message: __('Error occurred while updating the issue status') }))
.finally(() => {
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index b1deeaae0fc..08b04ebfdaf 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -1,9 +1,11 @@
+import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor';
import axios from '../../lib/utils/axios_utils';
export default class Service {
constructor(endpoint) {
this.endpoint = `${endpoint}.json`;
this.realtimeEndpoint = `${endpoint}/realtime_changes`;
+ registerCaptchaModalInterceptor(axios);
}
getData() {
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index 06bbd406e3a..a50913d3455 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -11,7 +11,7 @@ export default class Store {
lockedWarningVisible: false,
updateLoading: false,
lock_version: 0,
- issuableTemplates: [],
+ issuableTemplates: {},
};
}
diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js
index 19d1e0eebcb..f1e6bd2419a 100644
--- a/app/assets/javascripts/issue_show/utils/parse_data.js
+++ b/app/assets/javascripts/issue_show/utils/parse_data.js
@@ -1,5 +1,5 @@
+import * as Sentry from '@sentry/browser';
import { sanitize } from '~/lib/dompurify';
-import * as Sentry from '~/sentry/wrapper';
// We currently load + parse the data from the issue app and related merge request
let cachedParsedData;
diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue
index b7af6e098e1..60b01a6d37f 100644
--- a/app/assets/javascripts/issues_list/components/issuable.vue
+++ b/app/assets/javascripts/issues_list/components/issuable.vue
@@ -25,16 +25,16 @@ import {
newDateAsLocaleTime,
} from '~/lib/utils/datetime_utility';
import { convertToCamelCase } from '~/lib/utils/text_utility';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { mergeUrlParams, setUrlFragment, isExternal } from '~/lib/utils/url_utility';
import { sprintf, __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
export default {
i18n: {
- openedAgo: __('opened %{timeAgoString} by %{user}'),
- openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'),
- openedAgoServiceDesk: __('opened %{timeAgoString} by %{email} via %{user}'),
+ openedAgo: __('created %{timeAgoString} by %{user}'),
+ openedAgoJira: __('created %{timeAgoString} by %{user} in Jira'),
+ openedAgoServiceDesk: __('created %{timeAgoString} by %{email} via %{user}'),
},
components: {
IssueAssignees,
@@ -102,8 +102,14 @@ export default {
isJiraIssue() {
return this.issuable.external_tracker === 'jira';
},
+ webUrl() {
+ return this.issuable.gitlab_web_url || this.issuable.web_url;
+ },
+ isIssuableUrlExternal() {
+ return isExternal(this.webUrl);
+ },
linkTarget() {
- return this.isJiraIssue ? '_blank' : null;
+ return this.isIssuableUrlExternal ? '_blank' : null;
},
issueCreatedToday() {
return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1;
@@ -188,7 +194,7 @@ export default {
value: this.issuable.blocking_issues_count,
title: __('Blocking issues'),
dataTestId: 'blocking-issues',
- href: `${this.issuable.web_url}#related-issues`,
+ href: setUrlFragment(this.webUrl, 'related-issues'),
icon: 'issue-block',
},
{
@@ -197,7 +203,7 @@ export default {
value: this.issuable.user_notes_count,
title: __('Comments'),
dataTestId: 'notes-count',
- href: `${this.issuable.web_url}#notes`,
+ href: setUrlFragment(this.webUrl, 'notes'),
class: { 'no-comments': !this.issuable.user_notes_count, 'issuable-comments': true },
icon: 'comments',
},
@@ -252,7 +258,7 @@ export default {
:class="{ today: issueCreatedToday, closed: isClosed }"
:data-id="issuable.id"
:data-labels="labelIdsString"
- :data-url="issuable.web_url"
+ :data-url="webUrl"
data-qa-selector="issue_container"
:data-qa-issue-title="issuable.title"
>
@@ -284,13 +290,14 @@ export default {
:aria-label="$options.confidentialTooltipText"
/>
<gl-link
- :href="issuable.web_url"
+ :href="webUrl"
:target="linkTarget"
data-testid="issuable-title"
data-qa-selector="issue_link"
- >{{ issuable.title
- }}<gl-icon
- v-if="isJiraIssue"
+ >
+ {{ issuable.title }}
+ <gl-icon
+ v-if="isIssuableUrlExternal"
name="external-link"
class="gl-vertical-align-text-bottom gl-ml-2"
/>
diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
new file mode 100644
index 00000000000..8d00d337bac
--- /dev/null
+++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import {
+ dateInWords,
+ getTimeRemainingInWords,
+ isInFuture,
+ isInPast,
+ isToday,
+} from '~/lib/utils/datetime_utility';
+import { convertToCamelCase } from '~/lib/utils/text_utility';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlLink,
+ GlIcon,
+ IssueHealthStatus: () =>
+ import('ee_component/related_items_tree/components/issue_health_status.vue'),
+ WeightCount: () => import('ee_component/issues/components/weight_count.vue'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: {
+ hasIssuableHealthStatusFeature: {
+ default: false,
+ },
+ },
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ milestoneDate() {
+ if (this.issue.milestone?.dueDate) {
+ const { dueDate, startDate } = this.issue.milestone;
+ const date = dateInWords(new Date(dueDate), true);
+ const remainingTime = this.milestoneRemainingTime(dueDate, startDate);
+ return `${date} (${remainingTime})`;
+ }
+ return __('Milestone');
+ },
+ dueDate() {
+ return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true);
+ },
+ isDueDateInPast() {
+ return isInPast(new Date(this.issue.dueDate));
+ },
+ timeEstimate() {
+ return this.issue.timeStats?.humanTimeEstimate;
+ },
+ showHealthStatus() {
+ return this.hasIssuableHealthStatusFeature && this.issue.healthStatus;
+ },
+ healthStatus() {
+ return convertToCamelCase(this.issue.healthStatus);
+ },
+ },
+ methods: {
+ milestoneRemainingTime(dueDate, startDate) {
+ const due = new Date(dueDate);
+ const start = new Date(startDate);
+
+ if (dueDate && isInPast(due)) {
+ return __('Past due');
+ } else if (dueDate && isToday(due)) {
+ return __('Today');
+ } else if (startDate && isInFuture(start)) {
+ return __('Upcoming');
+ } else if (dueDate) {
+ return getTimeRemainingInWords(due);
+ }
+ return '';
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <span
+ v-if="issue.milestone"
+ class="issuable-milestone gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ data-testid="issuable-milestone"
+ >
+ <gl-link v-gl-tooltip :href="issue.milestone.webUrl" :title="milestoneDate">
+ <gl-icon name="clock" />
+ {{ issue.milestone.title }}
+ </gl-link>
+ </span>
+ <span
+ v-if="issue.dueDate"
+ v-gl-tooltip
+ class="issuable-due-date gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ :class="{ 'gl-text-red-500': isDueDateInPast }"
+ :title="__('Due date')"
+ data-testid="issuable-due-date"
+ >
+ <gl-icon name="calendar" />
+ {{ dueDate }}
+ </span>
+ <span
+ v-if="timeEstimate"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ :title="__('Estimate')"
+ data-testid="time-estimate"
+ >
+ <gl-icon name="timer" />
+ {{ timeEstimate }}
+ </span>
+ <weight-count
+ class="gl-display-none gl-sm-display-inline-block gl-mr-3"
+ :weight="issue.weight"
+ />
+ <issue-health-status
+ v-if="showHealthStatus"
+ class="gl-display-none gl-sm-display-inline-block"
+ :health-status="healthStatus"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue
new file mode 100644
index 00000000000..c57fa5a82fa
--- /dev/null
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { toNumber } from 'lodash';
+import createFlash from '~/flash';
+import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
+import { IssuableStatus } from '~/issue_show/constants';
+import { PAGE_SIZE } from '~/issues_list/constants';
+import axios from '~/lib/utils/axios_utils';
+import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import IssueCardTimeInfo from './issue_card_time_info.vue';
+
+export default {
+ PAGE_SIZE,
+ components: {
+ GlIcon,
+ IssuableList,
+ IssueCardTimeInfo,
+ BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: {
+ endpoint: {
+ default: '',
+ },
+ fullPath: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ currentPage: toNumber(getParameterByName('page')) || 1,
+ isLoading: false,
+ issues: [],
+ totalIssues: 0,
+ };
+ },
+ computed: {
+ urlParams() {
+ return {
+ page: this.currentPage,
+ state: IssuableStatus.Open,
+ };
+ },
+ },
+ mounted() {
+ this.fetchIssues();
+ },
+ methods: {
+ fetchIssues(pageToFetch) {
+ this.isLoading = true;
+
+ return axios
+ .get(this.endpoint, {
+ params: {
+ page: pageToFetch || this.currentPage,
+ per_page: this.$options.PAGE_SIZE,
+ state: IssuableStatus.Open,
+ with_labels_details: true,
+ },
+ })
+ .then(({ data, headers }) => {
+ this.currentPage = Number(headers['x-page']);
+ this.totalIssues = Number(headers['x-total']);
+ this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true }));
+ })
+ .catch(() => {
+ createFlash({ message: __('An error occurred while loading issues') });
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ handlePageChange(page) {
+ this.fetchIssues(page);
+ },
+ },
+};
+</script>
+
+<template>
+ <issuable-list
+ :namespace="fullPath"
+ recent-searches-storage-key="issues"
+ :search-input-placeholder="__('Search or filter results…')"
+ :search-tokens="[]"
+ :sort-options="[]"
+ :issuables="issues"
+ :tabs="[]"
+ current-tab=""
+ :issuables-loading="isLoading"
+ :show-pagination-controls="true"
+ :total-items="totalIssues"
+ :current-page="currentPage"
+ :previous-page="currentPage - 1"
+ :next-page="currentPage + 1"
+ :url-params="urlParams"
+ @page-change="handlePageChange"
+ >
+ <template #timeframe="{ issuable = {} }">
+ <issue-card-time-info :issue="issuable" />
+ </template>
+ <template #statistics="{ issuable = {} }">
+ <li
+ v-if="issuable.mergeRequestsCount"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-block"
+ :title="__('Related merge requests')"
+ data-testid="issuable-mr"
+ >
+ <gl-icon name="merge-request" />
+ {{ issuable.mergeRequestsCount }}
+ </li>
+ <li
+ v-if="issuable.upvotes"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-block"
+ :title="__('Upvotes')"
+ data-testid="issuable-upvotes"
+ >
+ <gl-icon name="thumb-up" />
+ {{ issuable.upvotes }}
+ </li>
+ <li
+ v-if="issuable.downvotes"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-block"
+ :title="__('Downvotes')"
+ data-testid="issuable-downvotes"
+ >
+ <gl-icon name="thumb-down" />
+ {{ issuable.downvotes }}
+ </li>
+ <blocking-issues-count
+ class="gl-display-none gl-sm-display-block"
+ :blocking-issues-count="issuable.blockingIssuesCount"
+ :is-list-item="true"
+ />
+ </template>
+ </issuable-list>
+</template>
diff --git a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue b/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue
index 7396cfe27b3..ba0ca57523a 100644
--- a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue
+++ b/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue
@@ -11,7 +11,7 @@ import { n__ } from '~/locale';
import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql';
export default {
- name: 'JiraIssuesList',
+ name: 'JiraIssuesImportStatus',
components: {
GlAlert,
GlLabel,
@@ -89,13 +89,13 @@ export default {
</script>
<template>
- <div class="issuable-list-root">
+ <div class="gl-my-5">
<gl-alert v-if="jiraImport.shouldShowInProgressAlert" @dismiss="hideInProgressAlert">
{{ __('Import in progress. Refresh page to see newly added issues.') }}
</gl-alert>
<gl-alert
- v-if="jiraImport.shouldShowFinishedAlert"
+ v-else-if="jiraImport.shouldShowFinishedAlert"
variant="success"
@dismiss="hideFinishedAlert"
>
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index 5c3910955bc..a283cbdc86b 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -1,12 +1,13 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import createDefaultClient from '~/lib/graphql';
-import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import IssuablesListApp from './components/issuables_list_app.vue';
-import JiraIssuesListRoot from './components/jira_issues_list_root.vue';
+import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue';
function mountJiraIssuesListApp() {
- const el = document.querySelector('.js-projects-issues-root');
+ const el = document.querySelector('.js-jira-issues-import-status');
if (!el) {
return false;
@@ -23,7 +24,7 @@ function mountJiraIssuesListApp() {
el,
apolloProvider,
render(createComponent) {
- return createComponent(JiraIssuesListRoot, {
+ return createComponent(JiraIssuesImportStatusRoot, {
props: {
canEdit: parseBoolean(el.dataset.canEdit),
isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
@@ -36,7 +37,7 @@ function mountJiraIssuesListApp() {
}
function mountIssuablesListApp() {
- if (!gon.features?.vueIssuablesList && !gon.features?.jiraIssuesIntegration) {
+ if (!gon.features?.vueIssuablesList) {
return;
}
@@ -64,6 +65,37 @@ function mountIssuablesListApp() {
});
}
+export function initIssuesListApp() {
+ const el = document.querySelector('.js-issues-list');
+
+ if (!el) {
+ return false;
+ }
+
+ const {
+ endpoint,
+ fullPath,
+ hasBlockedIssuesFeature,
+ hasIssuableHealthStatusFeature,
+ hasIssueWeightsFeature,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ // Currently does not use Vue Apollo, but need to provide {} for now until the
+ // issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
+ apolloProvider: {},
+ provide: {
+ endpoint,
+ fullPath,
+ hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
+ hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
+ hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
+ },
+ render: (createComponent) => createComponent(IssuesListApp),
+ });
+}
+
export default function initIssuablesList() {
mountJiraIssuesListApp();
mountIssuablesListApp();
diff --git a/app/assets/javascripts/issues_list/service_desk_helper.js b/app/assets/javascripts/issues_list/service_desk_helper.js
index 0a34b754377..f96567ef53b 100644
--- a/app/assets/javascripts/issues_list/service_desk_helper.js
+++ b/app/assets/javascripts/issues_list/service_desk_helper.js
@@ -1,4 +1,4 @@
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
/**
* Generates empty state messages for Service Desk issues list.
@@ -15,23 +15,23 @@ export function generateMessages(emptyStateMeta) {
incomingEmailHelpPage,
} = emptyStateMeta;
- const serviceDeskSupportedTitle = __(
- 'Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab',
+ const serviceDeskSupportedTitle = s__(
+ 'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab',
);
- const serviceDeskSupportedMessage = __(
- 'Those emails automatically become issues (with the comments becoming the email conversation) listed here.',
+ const serviceDeskSupportedMessage = s__(
+ 'ServiceDesk|Issues created from Service Desk emails appear here. Each comment becomes part of the email conversation.',
);
const commonDescription = `
<span>${serviceDeskSupportedMessage}</span>
- <a href="${serviceDeskHelpPage}">${__('Read more')}</a>`;
+ <a href="${serviceDeskHelpPage}">${s__('Learn more.')}</a>`;
return {
serviceDeskEnabledAndCanEditProjectSettings: {
title: serviceDeskSupportedTitle,
svgPath,
- description: `<p>${__('Have your users email')}
+ description: `<p>${s__('ServiceDesk|Your users can send emails to this address:')}
<code>${serviceDeskAddress}</code>
</p>
${commonDescription}`,
@@ -46,7 +46,7 @@ export function generateMessages(emptyStateMeta) {
svgPath,
description: commonDescription,
primaryLink: editProjectPage,
- primaryText: __('Turn on Service Desk'),
+ primaryText: s__('ServiceDesk|Enable Service Desk'),
},
serviceDeskDisabledAndCannotEditProjectSettings: {
title: serviceDeskSupportedTitle,
@@ -54,19 +54,19 @@ export function generateMessages(emptyStateMeta) {
description: commonDescription,
},
serviceDeskIsNotSupported: {
- title: __('Service Desk is not supported'),
+ title: s__('ServiceDesk|Service Desk is not supported'),
svgPath,
- description: __(
- 'In order to enable Service Desk for your instance, you must first set up incoming email.',
+ description: s__(
+ 'ServiceDesk|To enable Service Desk on this instance, an instance administrator must first set up incoming email.',
),
primaryLink: incomingEmailHelpPage,
- primaryText: __('More information'),
+ primaryText: s__('Learn more.'),
},
serviceDeskIsNotEnabled: {
- title: __('Service Desk is not enabled'),
+ title: s__('ServiceDesk|Service Desk is not enabled'),
svgPath,
- description: __(
- 'For help setting up the Service Desk for your instance, please contact an administrator.',
+ description: s__(
+ 'ServiceDesk|For help setting up the Service Desk for your instance, please contact an administrator.',
),
},
};
diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue
index a4ba86dc6a1..fe5ad8b67d7 100644
--- a/app/assets/javascripts/jira_connect/components/app.vue
+++ b/app/assets/javascripts/jira_connect/components/app.vue
@@ -1,10 +1,11 @@
<script>
-import { GlAlert, GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
-import { mapState } from 'vuex';
+import { GlAlert, GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui';
+import { mapState, mapMutations } from 'vuex';
import { getLocation } from '~/jira_connect/api';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
+import { SET_ALERT } from '../store/mutation_types';
+import { retrieveAlert } from '../utils';
import GroupsList from './groups_list.vue';
export default {
@@ -14,6 +15,8 @@ export default {
GlButton,
GlModal,
GroupsList,
+ GlLink,
+ GlSprintf,
},
directives: {
GlModalDirective,
@@ -30,10 +33,7 @@ export default {
};
},
computed: {
- ...mapState(['errorMessage']),
- showNewUI() {
- return this.glFeatures.newJiraConnectUi;
- },
+ ...mapState(['alert']),
usersPathWithReturnTo() {
if (this.location) {
return `${this.usersPath}?return_to=${this.location}`;
@@ -41,6 +41,9 @@ export default {
return this.usersPath;
},
+ shouldShowAlert() {
+ return Boolean(this.alert?.message);
+ },
},
modal: {
cancelProps: {
@@ -48,27 +51,48 @@ export default {
},
},
created() {
+ this.setInitialAlert();
this.setLocation();
},
methods: {
+ ...mapMutations({
+ setAlert: SET_ALERT,
+ }),
async setLocation() {
this.location = await getLocation();
},
+ setInitialAlert() {
+ const { linkUrl, title, message, variant } = retrieveAlert() || {};
+ this.setAlert({ linkUrl, title, message, variant });
+ },
},
};
</script>
<template>
<div>
- <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" :dismissible="false">
- {{ errorMessage }}
+ <gl-alert
+ v-if="shouldShowAlert"
+ class="gl-mb-7"
+ :variant="alert.variant"
+ :title="alert.title"
+ @dismiss="setAlert"
+ >
+ <gl-sprintf v-if="alert.linkUrl" :message="alert.message">
+ <template #link="{ content }">
+ <gl-link :href="alert.linkUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+
+ <template v-else>
+ {{ alert.message }}
+ </template>
</gl-alert>
- <h2>{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
+ <h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
<div
- v-if="showNewUI"
- class="gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
+ class="jira-connect-app-body gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
>
<h5 class="gl-align-self-center gl-mb-0" data-testid="new-jira-connect-ui-heading">
{{ s__('Integrations|Linked namespaces') }}
diff --git a/app/assets/javascripts/jira_connect/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/components/groups_list_item.vue
index 69b09ab0a21..b8959a2a505 100644
--- a/app/assets/javascripts/jira_connect/components/groups_list_item.vue
+++ b/app/assets/javascripts/jira_connect/components/groups_list_item.vue
@@ -1,7 +1,9 @@
<script>
import { GlAvatar, GlButton, GlIcon } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { addSubscription } from '~/jira_connect/api';
import { s__ } from '~/locale';
+import { persistAlert } from '../utils';
export default {
components: {
@@ -31,6 +33,15 @@ export default {
addSubscription(this.subscriptionsPath, this.group.full_path)
.then(() => {
+ persistAlert({
+ title: s__('Integrations|Namespace successfully linked'),
+ message: s__(
+ 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}',
+ ),
+ linkUrl: helpPagePath('integration/jira_development_panel.html', { anchor: 'usage' }),
+ variant: 'success',
+ });
+
AP.navigator.reload();
})
.catch((error) => {
diff --git a/app/assets/javascripts/jira_connect/constants.js b/app/assets/javascripts/jira_connect/constants.js
index 2b3be5cd5cd..63b79581a1b 100644
--- a/app/assets/javascripts/jira_connect/constants.js
+++ b/app/assets/javascripts/jira_connect/constants.js
@@ -1 +1,2 @@
export const defaultPerPage = 10;
+export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert';
diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js
index 7191fce3c33..ecdb41607a4 100644
--- a/app/assets/javascripts/jira_connect/index.js
+++ b/app/assets/javascripts/jira_connect/index.js
@@ -6,7 +6,7 @@ import Translate from '~/vue_shared/translate';
import JiraConnectApp from './components/app.vue';
import createStore from './store';
-import { SET_ERROR_MESSAGE } from './store/mutation_types';
+import { SET_ALERT } from './store/mutation_types';
const store = createStore();
@@ -17,7 +17,7 @@ const reqComplete = () => {
const reqFailed = (res, fallbackErrorMessage) => {
const { error = fallbackErrorMessage } = res || {};
- store.commit(SET_ERROR_MESSAGE, error);
+ store.commit(SET_ALERT, { message: error, variant: 'danger' });
};
const updateSignInLinks = async () => {
@@ -77,6 +77,7 @@ export async function initJiraConnect() {
Vue.use(GlFeatureFlagsPlugin);
const { groupsPath, subscriptionsPath, usersPath } = el.dataset;
+ AP.sizeToParent();
return new Vue({
el,
diff --git a/app/assets/javascripts/jira_connect/store/mutation_types.js b/app/assets/javascripts/jira_connect/store/mutation_types.js
index 7f6ff1256bb..15f36b824d9 100644
--- a/app/assets/javascripts/jira_connect/store/mutation_types.js
+++ b/app/assets/javascripts/jira_connect/store/mutation_types.js
@@ -1 +1 @@
-export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
+export const SET_ALERT = 'SET_ALERT';
diff --git a/app/assets/javascripts/jira_connect/store/mutations.js b/app/assets/javascripts/jira_connect/store/mutations.js
index c3acd07f89f..2a25e0fe25f 100644
--- a/app/assets/javascripts/jira_connect/store/mutations.js
+++ b/app/assets/javascripts/jira_connect/store/mutations.js
@@ -1,7 +1,7 @@
-import { SET_ERROR_MESSAGE } from './mutation_types';
+import { SET_ALERT } from './mutation_types';
export default {
- [SET_ERROR_MESSAGE](state, errorMessage) {
- state.errorMessage = errorMessage;
+ [SET_ALERT](state, { title, message, variant, linkUrl } = {}) {
+ state.alert = { title, message, variant, linkUrl };
},
};
diff --git a/app/assets/javascripts/jira_connect/store/state.js b/app/assets/javascripts/jira_connect/store/state.js
index 079b8350770..c807df03f00 100644
--- a/app/assets/javascripts/jira_connect/store/state.js
+++ b/app/assets/javascripts/jira_connect/store/state.js
@@ -1,3 +1,3 @@
export default () => ({
- errorMessage: undefined,
+ alert: undefined,
});
diff --git a/app/assets/javascripts/jira_connect/utils.js b/app/assets/javascripts/jira_connect/utils.js
new file mode 100644
index 00000000000..2a6c53ba42c
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/utils.js
@@ -0,0 +1,33 @@
+import AccessorUtilities from '~/lib/utils/accessor';
+import { ALERT_LOCALSTORAGE_KEY } from './constants';
+
+/**
+ * Persist alert data to localStorage.
+ */
+export const persistAlert = ({ title, message, linkUrl, variant } = {}) => {
+ if (!AccessorUtilities.isLocalStorageAccessSafe()) {
+ return;
+ }
+
+ const payload = JSON.stringify({ title, message, linkUrl, variant });
+ localStorage.setItem(ALERT_LOCALSTORAGE_KEY, payload);
+};
+
+/**
+ * Return alert data from localStorage.
+ */
+export const retrieveAlert = () => {
+ if (!AccessorUtilities.isLocalStorageAccessSafe()) {
+ return null;
+ }
+
+ const initialAlertJSON = localStorage.getItem(ALERT_LOCALSTORAGE_KEY);
+ // immediately clean up
+ localStorage.removeItem(ALERT_LOCALSTORAGE_KEY);
+
+ if (!initialAlertJSON) {
+ return null;
+ }
+
+ return JSON.parse(initialAlertJSON);
+};
diff --git a/app/assets/javascripts/jira_import/utils/cache_update.js b/app/assets/javascripts/jira_import/utils/cache_update.js
index db7dbb7353f..aa6c6a3ca23 100644
--- a/app/assets/javascripts/jira_import/utils/cache_update.js
+++ b/app/assets/javascripts/jira_import/utils/cache_update.js
@@ -21,8 +21,7 @@ export const addInProgressImportToStore = (store, jiraImportStart, fullPath) =>
store.writeQuery({
...queryDetails,
data: produce(sourceData, (draftData) => {
- draftData.project.jiraImportStatus = IMPORT_STATE.SCHEDULED; // eslint-disable-line no-param-reassign
- // eslint-disable-next-line no-param-reassign
+ draftData.project.jiraImportStatus = IMPORT_STATE.SCHEDULED;
draftData.project.jiraImports.nodes = [
...sourceData.project.jiraImports.nodes,
jiraImportStart.jiraImport,
diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/artifacts_block.vue
index 0f34926f689..2018942a7e8 100644
--- a/app/assets/javascripts/jobs/components/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/artifacts_block.vue
@@ -37,7 +37,7 @@ export default {
};
</script>
<template>
- <div class="block">
+ <div>
<div class="title gl-font-weight-bold">{{ s__('Job|Job artifacts') }}</div>
<p
v-if="isExpired || willExpire"
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index 222fae6d9a8..eae6b5d5419 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -18,20 +18,11 @@ export default {
required: false,
default: null,
},
- isLastBlock: {
- type: Boolean,
- required: true,
- },
},
};
</script>
<template>
- <div
- :class="{
- 'block-last': isLastBlock,
- block: !isLastBlock,
- }"
- >
+ <div>
<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/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue
index e68368919ab..488d838db52 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job_container_item.vue
@@ -63,7 +63,7 @@ export default {
<span class="text-truncate w-100">{{ job.name ? job.name : job.id }}</span>
- <gl-icon v-if="job.retried" name="retry" class="js-retry-icon" />
+ <gl-icon v-if="job.retried" name="retry" />
</gl-link>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index fbdbfddff56..ce4a85b35b7 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -1,9 +1,7 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __, sprintf } from '~/locale';
-import scrollDown from '../svg/scroll_down.svg';
export default {
components: {
@@ -13,7 +11,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- scrollDown,
props: {
erasePath: {
type: String,
@@ -87,7 +84,6 @@ export default {
v-gl-tooltip.body
:title="s__('Job|Show complete raw')"
:href="rawPath"
- class="controllers-buttons"
data-testid="job-raw-link-controller"
icon="doc-text"
/>
@@ -98,7 +94,7 @@ export default {
:title="s__('Job|Erase job log')"
:href="erasePath"
:data-confirm="__('Are you sure you want to erase this build?')"
- class="controllers-buttons"
+ class="gl-ml-3"
data-testid="job-log-erase-link"
data-method="post"
icon="remove"
@@ -106,25 +102,24 @@ export default {
<!-- eo links -->
<!-- scroll buttons -->
- <div v-gl-tooltip :title="s__('Job|Scroll to top')" class="controllers-buttons">
+ <div v-gl-tooltip :title="s__('Job|Scroll to top')" class="gl-ml-3">
<gl-button
:disabled="isScrollTopDisabled"
- class="btn-scroll btn-transparent btn-blank"
+ class="btn-scroll"
data-testid="job-controller-scroll-top"
icon="scroll_up"
@click="handleScrollToTop"
/>
</div>
- <div v-gl-tooltip :title="s__('Job|Scroll to bottom')" class="controllers-buttons">
+ <div v-gl-tooltip :title="s__('Job|Scroll to bottom')" class="gl-ml-3">
<gl-button
:disabled="isScrollBottomDisabled"
- class="js-scroll-bottom btn-scroll btn-transparent btn-blank"
+ class="js-scroll-bottom btn-scroll"
data-testid="job-controller-scroll-bottom"
icon="scroll_down"
:class="{ animate: isScrollingDown }"
@click="handleScrollToBottom"
- v-html="$options.scrollDown"
/>
</div>
<!-- eo scroll buttons -->
diff --git a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
index 258b8cadd63..a43b3297d75 100644
--- a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
+++ b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
@@ -36,10 +36,10 @@ export default {
v-gl-modal="modalId"
:aria-label="$options.i18n.retryLabel"
category="primary"
- variant="info"
+ variant="confirm"
>{{ $options.i18n.retryLabel }}</gl-button
>
- <gl-link v-else :href="href" data-method="post" rel="nofollow"
+ <gl-link v-else :href="href" class="btn gl-button btn-confirm" data-method="post" rel="nofollow"
>{{ $options.i18n.retryLabel }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index f63fe72a71a..fcf03dff34e 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlIcon, GlLink } from '@gitlab/ui';
+import { GlButton, GlIcon } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
@@ -20,12 +20,12 @@ export default {
i18n: {
...JOB_SIDEBAR,
},
+ borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
forwardDeploymentFailureModalId,
components: {
ArtifactsBlock,
CommitBlock,
GlButton,
- GlLink,
GlIcon,
JobsContainer,
JobSidebarRetryButton,
@@ -45,11 +45,8 @@ export default {
computed: {
...mapGetters(['hasForwardDeploymentFailure']),
...mapState(['job', 'stages', 'jobs', 'selectedStage']),
- retryButtonClass() {
- let className = 'btn gl-button gl-text-decoration-none!';
- className +=
- this.job.status && this.job.recoverable ? ' btn-confirm' : ' btn-confirm-secondary';
- return className;
+ retryButtonCategory() {
+ return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
},
hasArtifact() {
return !isEmpty(this.job.artifact);
@@ -76,71 +73,94 @@ export default {
<aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix">
<div class="sidebar-container">
<div class="blocks-container">
- <div class="block d-flex flex-nowrap align-items-center">
+ <div class="gl-py-5 gl-display-flex gl-align-items-center">
<tooltip-on-truncate :title="job.name" truncate-target="child"
><h4 class="my-0 mr-2 gl-text-truncate">
{{ job.name }}
</h4>
</tooltip-on-truncate>
- <div class="flex-grow-1 flex-shrink-0 text-right">
+ <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
<job-sidebar-retry-button
v-if="job.retry_path"
- :class="retryButtonClass"
+ :category="retryButtonCategory"
:href="job.retry_path"
:modal-id="$options.forwardDeploymentFailureModalId"
+ variant="confirm"
data-qa-selector="retry_button"
data-testid="retry-button"
/>
- <gl-link
+ <gl-button
v-if="job.cancel_path"
:href="job.cancel_path"
- class="btn gl-button btn-default gl-text-decoration-none!"
data-method="post"
data-testid="cancel-button"
rel="nofollow"
>{{ $options.i18n.cancel }}
- </gl-link>
+ </gl-button>
</div>
<gl-button
:aria-label="$options.i18n.toggleSidebar"
category="tertiary"
- class="gl-md-display-none gl-ml-2 js-sidebar-build-toggle"
+ class="gl-md-display-none gl-ml-2"
icon="chevron-double-lg-right"
@click="toggleSidebar"
/>
</div>
- <div v-if="job.terminal_path || job.new_issue_path" class="block retry-link">
- <gl-link
+ <div
+ v-if="job.terminal_path || job.new_issue_path"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
+ >
+ <gl-button
v-if="job.new_issue_path"
:href="job.new_issue_path"
- class="btn gl-button btn-success-secondary float-left mr-2 gl-text-decoration-none!"
+ category="secondary"
+ variant="confirm"
data-testid="job-new-issue"
- >{{ $options.i18n.newIssue }}
- </gl-link>
- <gl-link
+ >
+ {{ $options.i18n.newIssue }}
+ </gl-button>
+ <gl-button
v-if="job.terminal_path"
:href="job.terminal_path"
- class="btn btn-primary btn-inverted visible-md-block visible-lg-block float-left"
target="_blank"
data-testid="terminal-link"
>
{{ $options.i18n.debug }}
- <gl-icon :size="14" name="external-link" />
- </gl-link>
+ <gl-icon name="external-link" />
+ </gl-button>
</div>
- <job-sidebar-details-container />
- <artifacts-block v-if="hasArtifact" :artifact="job.artifact" :help-url="artifactHelpUrl" />
- <trigger-block v-if="hasTriggers" :trigger="job.trigger" />
+
+ <job-sidebar-details-container class="gl-py-5" :class="$options.borderTopClass" />
+
+ <artifacts-block
+ v-if="hasArtifact"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
+ :artifact="job.artifact"
+ :help-url="artifactHelpUrl"
+ />
+
+ <trigger-block
+ v-if="hasTriggers"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
+ :trigger="job.trigger"
+ />
+
<commit-block
:commit="commit"
- :is-last-block="hasStages"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
:merge-request="job.merge_request"
/>
<stages-dropdown
v-if="job.pipeline"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
:pipeline="job.pipeline"
:selected-stage="selectedStage"
:stages="stages"
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
index 62cd30fb320..b20d58b6ffe 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
@@ -73,7 +73,7 @@ export default {
</script>
<template>
- <div v-if="shouldRenderBlock" class="block">
+ <div v-if="shouldRenderBlock">
<detail-row v-if="job.duration" :value="duration" title="Duration" />
<detail-row
v-if="job.finished_at"
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 64c4031b002..18de849af88 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -43,7 +43,7 @@ export default {
};
</script>
<template>
- <div class="block-last dropdown">
+ <div class="dropdown">
<div class="js-pipeline-info">
<ci-icon :status="pipeline.details.status" class="vertical-align-middle" />
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue
index f6b98777011..fef5b37015c 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/trigger_block.vue
@@ -61,7 +61,7 @@ export default {
</script>
<template>
- <div class="block">
+ <div>
<p
v-if="trigger.short_token"
:class="{ 'gl-mb-2': hasVariables, 'gl-mb-0': !hasVariables }"
diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js
index 930a225857d..6cb96bee07d 100644
--- a/app/assets/javascripts/jobs/store/getters.js
+++ b/app/assets/javascripts/jobs/store/getters.js
@@ -1,7 +1,7 @@
import { isEmpty, isString } from 'lodash';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
-export const headerTime = (state) => state.job.started ?? state.job.created_at;
+export const headerTime = (state) => (state.job.started ? state.job.started : state.job.created_at);
export const hasForwardDeploymentFailure = (state) =>
state?.job?.failure_reason === 'forward_deployment_failure';
diff --git a/app/assets/javascripts/jobs/svg/scroll_down.svg b/app/assets/javascripts/jobs/svg/scroll_down.svg
deleted file mode 100644
index fb934f68704..00000000000
--- a/app/assets/javascripts/jobs/svg/scroll_down.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path class="scroll-arrow" d="M8 10.4142L4.29289 6.70711C3.90237 6.31658 3.90237 5.68342 4.29289 5.2929C4.68342 4.90237 5.31658 4.90237 5.70711 5.2929L7 6.58579L7 1C7 0.447715 7.44772 0 8 0C8.55229 0 9 0.447715 9 1L9 6.58579L10.2929 5.2929C10.6834 4.90237 11.3166 4.90237 11.7071 5.2929C12.0976 5.68342 12.0976 6.31658 11.7071 6.70711L8 10.4142Z"/>
-<path class="scroll-dot" d="M8 16C9.10457 16 10 15.1046 10 14C10 12.8954 9.10457 12 8 12C6.89543 12 6 12.8954 6 14C6 15.1046 6.89543 16 8 16Z"/>
-</svg>
diff --git a/app/assets/javascripts/lib/chrome_84_icon_fix.js b/app/assets/javascripts/lib/chrome_84_icon_fix.js
deleted file mode 100644
index 20fe9590ce3..00000000000
--- a/app/assets/javascripts/lib/chrome_84_icon_fix.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import { debounce } from 'lodash';
-
-/*
- Chrome and Edge 84 have a bug relating to icon sprite svgs
- https://bugs.chromium.org/p/chromium/issues/detail?id=1107442
-
- If the SVG is loaded, under certain circumstances the icons are not
- shown. We load our sprite icons with JS and add them to the body.
- Then we iterate over all the `use` elements and replace their reference
- to that svg which we added internally. In order to avoid id conflicts,
- those are renamed with a unique prefix.
-
- We do that once the DOMContentLoaded fired and otherwise we use a
- mutation observer to re-trigger this logic.
-
- In order to not have a big impact on performance or to cause flickering
- of of content,
-
- 1. we only do it for each svg once
- 2. we debounce the event handler and just do it in a requestIdleCallback
-
- Before we tried to do it with the library svg4everybody and it had a big
- performance impact. See:
- https://gitlab.com/gitlab-org/quality/performance/-/issues/312
- */
-document.addEventListener('DOMContentLoaded', async () => {
- const GITLAB_SVG_PREFIX = 'chrome-issue-230433-gitlab-svgs';
- const FILE_ICON_PREFIX = 'chrome-issue-230433-file-icons';
- const SKIP_ATTRIBUTE = 'data-replaced-by-chrome-issue-230433';
-
- const fixSVGs = () => {
- requestIdleCallback(() => {
- document.querySelectorAll(`use:not([${SKIP_ATTRIBUTE}])`).forEach((use) => {
- const href = use?.getAttribute('href') ?? use?.getAttribute('xlink:href') ?? '';
-
- if (href.includes(window.gon.sprite_icons)) {
- use.removeAttribute('xlink:href');
- use.setAttribute('href', `#${GITLAB_SVG_PREFIX}-${href.split('#')[1]}`);
- } else if (href.includes(window.gon.sprite_file_icons)) {
- use.removeAttribute('xlink:href');
- use.setAttribute('href', `#${FILE_ICON_PREFIX}-${href.split('#')[1]}`);
- }
-
- use.setAttribute(SKIP_ATTRIBUTE, 'true');
- });
- });
- };
-
- const watchForNewSVGs = () => {
- const observer = new MutationObserver(debounce(fixSVGs, 200));
- observer.observe(document.querySelector('body'), {
- childList: true,
- attributes: false,
- subtree: true,
- });
- };
-
- const retrieveIconSprites = async (url, prefix) => {
- const div = document.createElement('div');
- div.classList.add('hidden');
- const result = await fetch(url);
- div.innerHTML = await result.text();
- div.querySelectorAll('[id]').forEach((node) => {
- node.setAttribute('id', `${prefix}-${node.getAttribute('id')}`);
- });
- document.body.append(div);
- };
-
- if (window.gon && window.gon.sprite_icons) {
- await retrieveIconSprites(window.gon.sprite_icons, GITLAB_SVG_PREFIX);
- if (window.gon.sprite_file_icons) {
- await retrieveIconSprites(window.gon.sprite_file_icons, FILE_ICON_PREFIX);
- }
-
- fixSVGs();
- watchForNewSVGs();
- }
-});
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index bda5550a9f4..e090f9f6e8c 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -2,6 +2,7 @@ import { InMemoryCache } from 'apollo-cache-inmemory';
import { ApolloClient } from 'apollo-client';
import { ApolloLink } from 'apollo-link';
import { BatchHttpLink } from 'apollo-link-batch-http';
+import { createHttpLink } from 'apollo-link-http';
import { createUploadLink } from 'apollo-upload-client';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
@@ -48,7 +49,7 @@ export default (resolvers = {}, config = {}) => {
const uploadsLink = ApolloLink.split(
(operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest,
createUploadLink(httpOptions),
- new BatchHttpLink(httpOptions),
+ config.useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions),
);
const performanceBarLink = new ApolloLink((operation, forward) => {
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 38b3b26dc44..145b419f8f0 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -769,6 +769,19 @@ export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => {
};
/**
+ * Returns the date `n` years after the date provided.
+ *
+ * @param {Date} date the initial date
+ * @param {Number} numberOfYears number of years after
+ * @return {Date} A `Date` object `n` years after the provided `Date`
+ */
+export const nYearsAfter = (date, numberOfYears) => {
+ const clone = newDate(date);
+ clone.setFullYear(clone.getFullYear() + numberOfYears);
+ return clone;
+};
+
+/**
* Returns the date `n` months before the date provided
*
* @param {Date} date the initial date
@@ -993,6 +1006,78 @@ export const isToday = (date) => {
};
/**
+ * Checks whether the date is in the past.
+ *
+ * @param {Date} date
+ * @return {Boolean} Returns true if the date falls before today, otherwise false.
+ */
+export const isInPast = (date) => !isToday(date) && differenceInMilliseconds(date, Date.now()) > 0;
+
+/**
+ * Checks whether the date is in the future.
+ * .
+ * @param {Date} date
+ * @return {Boolean} Returns true if the date falls after today, otherwise false.
+ */
+export const isInFuture = (date) =>
+ !isToday(date) && differenceInMilliseconds(Date.now(), date) > 0;
+
+/**
+ * Checks whether dateA falls before dateB.
+ *
+ * @param {Date} dateA
+ * @param {Date} dateB
+ * @return {Boolean} Returns true if dateA falls before dateB, otherwise false
+ */
+export const fallsBefore = (dateA, dateB) => differenceInMilliseconds(dateA, dateB) > 0;
+
+/**
+ * Removes the time component of the date.
+ *
+ * @param {Date} date
+ * @return {Date} Returns a clone of the date with the time set to midnight
+ */
+export const removeTime = (date) => {
+ const clone = newDate(date);
+ clone.setHours(0, 0, 0, 0);
+ return clone;
+};
+
+/**
+ * Calculates the time remaining from today in words in the format
+ * `n days/weeks/months/years remaining`.
+ *
+ * @param {Date} date A date in future
+ * @return {String} The time remaining in the format `n days/weeks/months/years remaining`
+ */
+export const getTimeRemainingInWords = (date) => {
+ const today = removeTime(new Date());
+ const dateInFuture = removeTime(date);
+
+ const oneWeekFromNow = nWeeksAfter(today, 1);
+ const oneMonthFromNow = nMonthsAfter(today, 1);
+ const oneYearFromNow = nYearsAfter(today, 1);
+
+ if (fallsBefore(dateInFuture, oneWeekFromNow)) {
+ const days = getDayDifference(today, dateInFuture);
+ return n__('1 day remaining', '%d days remaining', days);
+ }
+
+ if (fallsBefore(dateInFuture, oneMonthFromNow)) {
+ const weeks = Math.floor(getDayDifference(today, dateInFuture) / 7);
+ return n__('1 week remaining', '%d weeks remaining', weeks);
+ }
+
+ if (fallsBefore(dateInFuture, oneYearFromNow)) {
+ const months = differenceInMonths(today, dateInFuture);
+ return n__('1 month remaining', '%d months remaining', months);
+ }
+
+ const years = dateInFuture.getFullYear() - today.getFullYear();
+ return n__('1 year remaining', '%d years remaining', years);
+};
+
+/**
* Returns the start of the provided day
*
* @param {Object} [options={}] Additional options for this calculation
@@ -1010,3 +1095,25 @@ export const getStartOfDay = (date, { utc = false } = {}) => {
return new Date(cloneValue);
};
+
+/**
+ * Returns the start of the current week against the provide date
+ *
+ * @param {Date} date The current date instance to calculate against
+ * @param {Object} [options={}] Additional options for this calculation
+ * @param {boolean} [options.utc=false] Performs the calculation using UTC time.
+ * If `true`, the time returned will be midnight UTC. If `false` (the default)
+ * the time returned will be midnight in the user's local time.
+ *
+ * @returns {Date} A new `Date` object that represents the start of the current week
+ * of the provided date
+ */
+export const getStartOfWeek = (date, { utc = false } = {}) => {
+ const cloneValue = utc
+ ? new Date(date.setUTCHours(0, 0, 0, 0))
+ : new Date(date.setHours(0, 0, 0, 0));
+
+ const diff = cloneValue.getDate() - cloneValue.getDay() + (cloneValue.getDay() === 0 ? -6 : 1);
+
+ return new Date(date.setDate(diff));
+};
diff --git a/app/assets/javascripts/lib/utils/experimentation.js b/app/assets/javascripts/lib/utils/experimentation.js
deleted file mode 100644
index 555e76055e0..00000000000
--- a/app/assets/javascripts/lib/utils/experimentation.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export function isExperimentEnabled(experimentKey) {
- return Boolean(window.gon?.experiments?.[experimentKey]);
-}
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 06529f06a66..6b9be34235b 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -19,6 +19,7 @@ const httpStatusCodes = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
+ METHOD_NOT_ALLOWED: 405,
CONFLICT: 409,
GONE: 410,
UNPROCESSABLE_ENTITY: 422,
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index 0f29f538b07..63feb6f9b1d 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -150,3 +150,24 @@ export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-'
return `${change >= 0 ? '+' : ''}${change}%`;
};
+
+/**
+ * Checks whether a value is numerical in nature by converting it using parseInt
+ *
+ * Example outcomes:
+ * - isNumeric(100) = true
+ * - isNumeric('100') = true
+ * - isNumeric(1.0) = true
+ * - isNumeric('1.0') = true
+ * - isNumeric('abc100') = false
+ * - isNumeric('abc') = false
+ * - isNumeric(true) = false
+ * - isNumeric(undefined) = false
+ * - isNumeric(null) = false
+ *
+ * @param value
+ * @returns {boolean}
+ */
+export const isNumeric = (value) => {
+ return !Number.isNaN(parseInt(value, 10));
+};
diff --git a/app/assets/javascripts/lib/utils/select2_utils.js b/app/assets/javascripts/lib/utils/select2_utils.js
new file mode 100644
index 00000000000..03c0e608b79
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/select2_utils.js
@@ -0,0 +1,25 @@
+import axios from './axios_utils';
+import { normalizeHeaders, parseIntPagination } from './common_utils';
+
+// This is used in the select2 config to replace jQuery.ajax with axios
+export const select2AxiosTransport = (params) => {
+ axios({
+ method: params.type?.toLowerCase() || 'get',
+ url: params.url,
+ params: params.data,
+ })
+ .then((res) => {
+ const results = res.data || [];
+ const headers = normalizeHeaders(res.headers);
+ const pagination = parseIntPagination(headers);
+ const more = pagination.nextPage > pagination.page;
+
+ params.success({
+ results,
+ pagination: {
+ more,
+ },
+ });
+ })
+ .catch(params.error);
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 5e66aa05218..345dfaf895b 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -283,9 +283,9 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo
/* eslint-disable @gitlab/require-i18n-strings */
export function keypressNoteText(e) {
- if (this.selectionStart === this.selectionEnd) {
- return;
- }
+ if (!gon.markdown_surround_selection) return;
+ if (this.selectionStart === this.selectionEnd) return;
+
const keys = {
'*': '**{text}**', // wraps with bold character
_: '_{text}_', // wraps with italic character
diff --git a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
index 15f9512fe92..418cc69bf5a 100644
--- a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
+++ b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
@@ -1,39 +1,30 @@
+import { formatNumber } from '~/locale';
+
/**
- * Formats a number as string using `toLocaleString`.
+ * Formats a number as a string using `toLocaleString`.
*
* @param {Number} number to be converted
- * @param {params} Parameters
- * @param {params.fractionDigits} Number of decimal digits
- * to display, defaults to using `toLocaleString` defaults.
- * @param {params.maxLength} Max output char lenght at the
+ *
+ * @param {options.maxCharLength} Max output char length at the
* expense of precision, if the output is longer than this,
* the formatter switches to using exponential notation.
- * @param {params.factor} Value is multiplied by this factor,
- * useful for value normalization.
- * @returns Formatted value
+ *
+ * @param {options.valueFactor} Value is multiplied by this factor,
+ * useful for value normalization or to alter orders of magnitude.
+ *
+ * @param {options} Other options to be passed to
+ * `formatNumber` such as `valueFactor`, `unit` and `style`.
+ *
*/
-function formatNumber(
- value,
- { fractionDigits = undefined, valueFactor = 1, style = undefined, maxLength = undefined },
-) {
- if (value === null) {
- return '';
- }
-
- const locale = document.documentElement.lang || undefined;
- const num = value * valueFactor;
- const formatted = num.toLocaleString(locale, {
- minimumFractionDigits: fractionDigits,
- maximumFractionDigits: fractionDigits,
- style,
- });
+const formatNumberNormalized = (value, { maxCharLength, valueFactor = 1, ...options }) => {
+ const formatted = formatNumber(value * valueFactor, options);
- if (maxLength !== undefined && formatted.length > maxLength) {
+ if (maxCharLength !== undefined && formatted.length > maxCharLength) {
// 123456 becomes 1.23e+8
- return num.toExponential(2);
+ return value.toExponential(2);
}
return formatted;
-}
+};
/**
* Formats a number as a string scaling it up according to units.
@@ -76,7 +67,10 @@ const scaledFormatter = (units, unitFactor = 1000) => {
const unit = units[scale];
- return `${formatNumber(num, { fractionDigits })}${unit}`;
+ return `${formatNumberNormalized(num, {
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })}${unit}`;
};
};
@@ -84,8 +78,14 @@ const scaledFormatter = (units, unitFactor = 1000) => {
* Returns a function that formats a number as a string.
*/
export const numberFormatter = (style = 'decimal', valueFactor = 1) => {
- return (value, fractionDigits, maxLength) => {
- return `${formatNumber(value, { fractionDigits, maxLength, valueFactor, style })}`;
+ return (value, fractionDigits, maxCharLength) => {
+ return `${formatNumberNormalized(value, {
+ maxCharLength,
+ valueFactor,
+ style,
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })}`;
};
};
@@ -93,9 +93,15 @@ export const numberFormatter = (style = 'decimal', valueFactor = 1) => {
* Returns a function that formats a number as a string with a suffix.
*/
export const suffixFormatter = (unit = '', valueFactor = 1) => {
- return (value, fractionDigits, maxLength) => {
- const length = maxLength !== undefined ? maxLength - unit.length : undefined;
- return `${formatNumber(value, { fractionDigits, maxLength: length, valueFactor })}${unit}`;
+ return (value, fractionDigits, maxCharLength) => {
+ const length = maxCharLength !== undefined ? maxCharLength - unit.length : undefined;
+
+ return `${formatNumberNormalized(value, {
+ maxCharLength: length,
+ valueFactor,
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })}${unit}`;
};
};
diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js
index 9f979f7ea4b..bc82c6aa74d 100644
--- a/app/assets/javascripts/lib/utils/unit_format/index.js
+++ b/app/assets/javascripts/lib/utils/unit_format/index.js
@@ -46,227 +46,261 @@ export const SUPPORTED_FORMATS = {
};
/**
- * Returns a function that formats number to different units
- * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation.
+ * Returns a function that formats number to different units.
*
+ * Used for dynamic formatting, for more convenience, use the functions below.
*
+ * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation.
*/
export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
// Number
-
if (format === SUPPORTED_FORMATS.number) {
- /**
- * Formats a number
- *
- * @function
- * @param {Number} value - Number to format
- * @param {Number} fractionDigits - precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return numberFormatter();
}
if (format === SUPPORTED_FORMATS.percent) {
- /**
- * Formats a percentge (0 - 1)
- *
- * @function
- * @param {Number} value - Number to format, `1` is rendered as `100%`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return numberFormatter('percent');
}
if (format === SUPPORTED_FORMATS.percentHundred) {
- /**
- * Formats a percentge (0 to 100)
- *
- * @function
- * @param {Number} value - Number to format, `100` is rendered as `100%`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return numberFormatter('percent', 1 / 100);
}
// Durations
-
if (format === SUPPORTED_FORMATS.seconds) {
- /**
- * Formats a number of seconds
- *
- * @function
- * @param {Number} value - Number to format, `1` is rendered as `1s`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return suffixFormatter(s__('Units|s'));
}
if (format === SUPPORTED_FORMATS.milliseconds) {
- /**
- * Formats a number of milliseconds with ms as units
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1ms`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return suffixFormatter(s__('Units|ms'));
}
// Digital (Metric)
-
if (format === SUPPORTED_FORMATS.decimalBytes) {
- /**
- * Formats a number of bytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1B`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B');
}
if (format === SUPPORTED_FORMATS.kilobytes) {
- /**
- * Formats a number of kilobytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1kB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 1);
}
if (format === SUPPORTED_FORMATS.megabytes) {
- /**
- * Formats a number of megabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1MB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 2);
}
if (format === SUPPORTED_FORMATS.gigabytes) {
- /**
- * Formats a number of gigabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 3);
}
if (format === SUPPORTED_FORMATS.terabytes) {
- /**
- * Formats a number of terabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 4);
}
if (format === SUPPORTED_FORMATS.petabytes) {
- /**
- * Formats a number of petabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1PB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 5);
}
// Digital (IEC)
-
if (format === SUPPORTED_FORMATS.bytes) {
- /**
- * Formats a number of bytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1B`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B');
}
if (format === SUPPORTED_FORMATS.kibibytes) {
- /**
- * Formats a number of kilobytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1kB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 1);
}
if (format === SUPPORTED_FORMATS.mebibytes) {
- /**
- * Formats a number of megabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1MB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 2);
}
if (format === SUPPORTED_FORMATS.gibibytes) {
- /**
- * Formats a number of gigabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 3);
}
if (format === SUPPORTED_FORMATS.tebibytes) {
- /**
- * Formats a number of terabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 4);
}
if (format === SUPPORTED_FORMATS.pebibytes) {
- /**
- * Formats a number of petabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1PB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 5);
}
+ // Default
if (format === SUPPORTED_FORMATS.engineering) {
- /**
- * Formats via engineering notation
- *
- * @function
- * @param {Number} value - Value to format
- * @param {Number} fractionDigits - precision decimals - Defaults to 2
- */
return engineeringNotation;
}
// Fail so client library addresses issue
throw TypeError(`${format} is not a valid number format`);
};
+
+/**
+ * Formats a number
+ *
+ * @function
+ * @param {Number} value - Number to format
+ * @param {Number} fractionDigits - precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const number = getFormatter(SUPPORTED_FORMATS.number);
+
+/**
+ * Formats a percentage (0 - 1)
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is rendered as `100%`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const percent = getFormatter(SUPPORTED_FORMATS.percent);
+
+/**
+ * Formats a percentage (0 to 100)
+ *
+ * @function
+ * @param {Number} value - Number to format, `100` is rendered as `100%`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const percentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred);
+
+/**
+ * Formats a number of seconds
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is rendered as `1s`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const seconds = getFormatter(SUPPORTED_FORMATS.seconds);
+
+/**
+ * Formats a number of milliseconds with ms as units
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1ms`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const milliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds);
+
+/**
+ * Formats a number of bytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1B`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const decimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes);
+
+/**
+ * Formats a number of kilobytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1kB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const kilobytes = getFormatter(SUPPORTED_FORMATS.kilobytes);
+
+/**
+ * Formats a number of megabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1MB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const megabytes = getFormatter(SUPPORTED_FORMATS.megabytes);
+
+/**
+ * Formats a number of gigabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const gigabytes = getFormatter(SUPPORTED_FORMATS.gigabytes);
+
+/**
+ * Formats a number of terabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const terabytes = getFormatter(SUPPORTED_FORMATS.terabytes);
+
+/**
+ * Formats a number of petabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1PB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const petabytes = getFormatter(SUPPORTED_FORMATS.petabytes);
+
+/**
+ * Formats a number of bytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1B`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const bytes = getFormatter(SUPPORTED_FORMATS.bytes);
+
+/**
+ * Formats a number of kilobytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1kB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const kibibytes = getFormatter(SUPPORTED_FORMATS.kibibytes);
+
+/**
+ * Formats a number of megabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1MB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const mebibytes = getFormatter(SUPPORTED_FORMATS.mebibytes);
+
+/**
+ * Formats a number of gigabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const gibibytes = getFormatter(SUPPORTED_FORMATS.gibibytes);
+
+/**
+ * Formats a number of terabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const tebibytes = getFormatter(SUPPORTED_FORMATS.tebibytes);
+
+/**
+ * Formats a number of petabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1PB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const pebibytes = getFormatter(SUPPORTED_FORMATS.pebibytes);
+
+/**
+ * Formats via engineering notation
+ *
+ * @function
+ * @param {Number} value - Value to format
+ * @param {Number} fractionDigits - precision decimals - Defaults to 2
+ */
+export const engineering = getFormatter();
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index cc2cf787a8f..5b3aa3cf9dc 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -473,6 +473,7 @@ export const setUrlParams = (
url = window.location.href,
clearParams = false,
railsArraySyntax = false,
+ decodeParams = false,
) => {
const urlObj = new URL(url);
const queryString = urlObj.search;
@@ -495,7 +496,9 @@ export const setUrlParams = (
}
});
- urlObj.search = searchParams.toString();
+ urlObj.search = decodeParams
+ ? decodeURIComponent(searchParams.toString())
+ : searchParams.toString();
return urlObj.toString();
};
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
index 35087b920c7..10518fa73d9 100644
--- a/app/assets/javascripts/locale/index.js
+++ b/app/assets/javascripts/locale/index.js
@@ -58,10 +58,30 @@ const pgettext = (keyOrContext, key) => {
*/
const createDateTimeFormat = (formatOptions) => Intl.DateTimeFormat(languageCode(), formatOptions);
+/**
+ * Formats a number as a string using `toLocaleString`.
+ *
+ * @param {Number} value - number to be converted
+ * @param {options?} options - options to be passed to
+ * `toLocaleString` such as `unit` and `style`.
+ * @param {langCode?} langCode - If set, forces a different
+ * language code from the one currently in the document.
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat
+ *
+ * @returns If value is a number, the formatted value as a string
+ */
+function formatNumber(value, options = {}, langCode = languageCode()) {
+ if (typeof value !== 'number' && typeof value !== 'bigint') {
+ return value;
+ }
+ return value.toLocaleString(langCode, options);
+}
+
export { languageCode };
export { gettext as __ };
export { ngettext as n__ };
export { pgettext as s__ };
export { sprintf };
export { createDateTimeFormat };
+export { formatNumber };
export default locale;
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 32ecc0036bd..417baddc031 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -272,7 +272,7 @@ document.addEventListener('DOMContentLoaded', () => {
e.preventDefault();
- $this.toggleClass('active');
+ $this.toggleClass('selected');
if ($this.hasClass('active')) {
notesHolders.show().find('.hide, .content').show();
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
deleted file mode 100644
index a8edeaf5176..00000000000
--- a/app/assets/javascripts/members.js
+++ /dev/null
@@ -1,111 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { disableButtonIfEmptyField } from '~/lib/utils/common_utils';
-import { Rails } from '~/lib/utils/rails_ujs';
-import { __, sprintf } from '~/locale';
-
-export default class Members {
- constructor() {
- this.addListeners();
- this.initGLDropdown();
- }
-
- addListeners() {
- // eslint-disable-next-line @gitlab/no-global-event-off
- $('.js-member-update-control').off('change').on('change', this.formSubmit.bind(this));
- // eslint-disable-next-line @gitlab/no-global-event-off
- $('.js-edit-member-form').off('ajax:success').on('ajax:success', this.formSuccess.bind(this));
- disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
- }
-
- dropdownClicked(options) {
- options.e.preventDefault();
-
- this.formSubmit(null, options.$el);
- }
-
- // eslint-disable-next-line class-methods-use-this
- dropdownToggleLabel(selected, $el) {
- return $el.text();
- }
-
- // eslint-disable-next-line class-methods-use-this
- dropdownIsSelectable(selected, $el) {
- return !$el.hasClass('is-active');
- }
-
- initGLDropdown() {
- $('.js-member-permissions-dropdown').each((i, btn) => {
- const $btn = $(btn);
-
- initDeprecatedJQueryDropdown($btn, {
- selectable: true,
- isSelectable: (selected, $el) => this.dropdownIsSelectable(selected, $el),
- fieldName: $btn.data('fieldName'),
- id(selected, $el) {
- return $el.data('id');
- },
- toggleLabel: (selected, $el) => this.dropdownToggleLabel(selected, $el, $btn),
- clicked: (options) => this.dropdownClicked(options),
- });
- });
- }
-
- formSubmit(e, $el = null) {
- const $this = e ? $(e.currentTarget) : $el;
- const { $toggle, $dateInput } = this.getMemberListItems($this);
- const formEl = $this.closest('form').get(0);
-
- Rails.fire(formEl, 'submit');
-
- $toggle.disable();
- $dateInput.disable();
- }
-
- formSuccess(e) {
- const { $toggle, $dateInput, $expiresIn, $expiresInText } = this.getMemberListItems(
- $(e.currentTarget).closest('.js-member'),
- );
-
- const [data] = e.detail;
- const expiresIn = data?.expires_in;
-
- if (expiresIn) {
- $expiresIn.removeClass('gl-display-none');
-
- $expiresInText.text(sprintf(__('Expires in %{expires_at}'), { expires_at: expiresIn }));
-
- const { expires_soon: expiresSoon, expires_at_formatted: expiresAtFormatted } = data;
-
- if (expiresSoon) {
- $expiresInText.addClass('text-warning');
- } else {
- $expiresInText.removeClass('text-warning');
- }
-
- // Update tooltip
- if (expiresAtFormatted) {
- $expiresInText.attr('title', expiresAtFormatted);
- $expiresInText.attr('data-original-title', expiresAtFormatted);
- }
- } else {
- $expiresIn.addClass('gl-display-none');
- }
-
- $toggle.enable();
- $dateInput.enable();
- }
-
- // eslint-disable-next-line class-methods-use-this
- getMemberListItems($el) {
- const $memberListItem = $el.is('.js-member') ? $el : $(`#${$el.data('elId')}`);
-
- return {
- $memberListItem,
- $expiresIn: $memberListItem.find('.js-expires-in'),
- $expiresInText: $memberListItem.find('.js-expires-in-text'),
- $toggle: $memberListItem.find('.dropdown-menu-toggle'),
- $dateInput: $memberListItem.find('.js-access-expiration-date'),
- };
- }
-}
diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue
index 79dda3c1379..658fb43cecb 100644
--- a/app/assets/javascripts/members/components/avatars/user_avatar.vue
+++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue
@@ -5,6 +5,7 @@ import {
GlBadge,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
+import { mapState } from 'vuex';
import { generateBadges } from 'ee_else_ce/members/utils';
import { glEmojiTag } from '~/emoji';
import { __ } from '~/locale';
@@ -34,11 +35,16 @@ export default {
},
},
computed: {
+ ...mapState(['canManageMembers']),
user() {
return this.member.user;
},
badges() {
- return generateBadges(this.member, this.isCurrentUser).filter((badge) => badge.show);
+ return generateBadges({
+ member: this.member,
+ isCurrentUser: this.isCurrentUser,
+ canManageMembers: this.canManageMembers,
+ }).filter((badge) => badge.show);
},
statusEmoji() {
return this.user?.status?.emoji;
diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js
index 4de2dadb490..2bf30dd7b6e 100644
--- a/app/assets/javascripts/members/utils.js
+++ b/app/assets/javascripts/members/utils.js
@@ -13,7 +13,7 @@ import {
GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME,
} from './constants';
-export const generateBadges = (member, isCurrentUser) => [
+export const generateBadges = ({ member, isCurrentUser, canManageMembers }) => [
{
show: isCurrentUser,
text: __("It's you"),
@@ -25,7 +25,7 @@ export const generateBadges = (member, isCurrentUser) => [
variant: 'danger',
},
{
- show: member.user?.twoFactorEnabled,
+ show: member.user?.twoFactorEnabled && (canManageMembers || isCurrentUser),
text: __('2FA'),
variant: 'info',
},
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
deleted file mode 100644
index 6eaabbb3519..00000000000
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
+++ /dev/null
@@ -1,115 +0,0 @@
-// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on
-// app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml
-// for its template.
-/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */
-
-import { debounce } from 'lodash';
-import Vue from 'vue';
-import { deprecatedCreateFlash as flash } from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-
-((global) => {
- global.mergeConflicts = global.mergeConflicts || {};
-
- global.mergeConflicts.diffFileEditor = Vue.extend({
- props: {
- file: {
- type: Object,
- required: true,
- },
- onCancelDiscardConfirmation: {
- type: Function,
- required: true,
- },
- onAcceptDiscardConfirmation: {
- type: Function,
- required: true,
- },
- },
- data() {
- return {
- saved: false,
- fileLoaded: false,
- originalContent: '',
- };
- },
- computed: {
- classObject() {
- return {
- saved: this.saved,
- };
- },
- },
- watch: {
- 'file.showEditor': function showEditorWatcher(val) {
- this.resetEditorContent();
-
- if (!val || this.fileLoaded) {
- return;
- }
-
- this.loadEditor();
- },
- },
- mounted() {
- if (this.file.loadEditor) {
- this.loadEditor();
- }
- },
- methods: {
- loadEditor() {
- const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite');
- const DataPromise = axios.get(this.file.content_path);
-
- Promise.all([EditorPromise, DataPromise])
- .then(
- ([
- { default: EditorLite },
- {
- data: { content, new_path: path },
- },
- ]) => {
- const contentEl = this.$el.querySelector('.editor');
-
- this.originalContent = content;
- this.fileLoaded = true;
-
- this.editor = new EditorLite().createInstance({
- el: contentEl,
- blobPath: path,
- blobContent: content,
- });
- this.editor.onDidChangeModelContent(
- debounce(this.saveDiffResolution.bind(this), 250),
- );
- },
- )
- .catch(() => {
- flash(__('An error occurred while loading the file'));
- });
- },
- saveDiffResolution() {
- this.saved = true;
-
- // This probably be better placed in the data provider
- /* eslint-disable vue/no-mutating-props */
- this.file.content = this.editor.getValue();
- this.file.resolveEditChanged = this.file.content !== this.originalContent;
- this.file.promptDiscardConfirmation = false;
- /* eslint-enable vue/no-mutating-props */
- },
- resetEditorContent() {
- if (this.fileLoaded) {
- this.editor.setValue(this.originalContent);
- }
- },
- cancelDiscardConfirmation(file) {
- this.onCancelDiscardConfirmation(file);
- },
- acceptDiscardConfirmation(file) {
- this.onAcceptDiscardConfirmation(file);
- },
- },
- });
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
new file mode 100644
index 00000000000..2c7c8038af5
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
@@ -0,0 +1,128 @@
+<script>
+import { debounce } from 'lodash';
+import { deprecatedCreateFlash as flash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+
+export default {
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ onCancelDiscardConfirmation: {
+ type: Function,
+ required: true,
+ },
+ onAcceptDiscardConfirmation: {
+ type: Function,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ saved: false,
+ fileLoaded: false,
+ originalContent: '',
+ };
+ },
+ computed: {
+ classObject() {
+ return {
+ saved: this.saved,
+ };
+ },
+ },
+ watch: {
+ 'file.showEditor': function showEditorWatcher(val) {
+ this.resetEditorContent();
+
+ if (!val || this.fileLoaded) {
+ return;
+ }
+
+ this.loadEditor();
+ },
+ },
+ mounted() {
+ if (this.file.loadEditor) {
+ this.loadEditor();
+ }
+ },
+ methods: {
+ loadEditor() {
+ const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite');
+ const DataPromise = axios.get(this.file.content_path);
+
+ Promise.all([EditorPromise, DataPromise])
+ .then(
+ ([
+ { default: EditorLite },
+ {
+ data: { content, new_path: path },
+ },
+ ]) => {
+ const contentEl = this.$el.querySelector('.editor');
+
+ this.originalContent = content;
+ this.fileLoaded = true;
+
+ this.editor = new EditorLite().createInstance({
+ el: contentEl,
+ blobPath: path,
+ blobContent: content,
+ });
+ this.editor.onDidChangeModelContent(debounce(this.saveDiffResolution.bind(this), 250));
+ },
+ )
+ .catch(() => {
+ flash(__('An error occurred while loading the file'));
+ });
+ },
+ saveDiffResolution() {
+ this.saved = true;
+
+ // This probably be better placed in the data provider
+ /* eslint-disable vue/no-mutating-props */
+ this.file.content = this.editor.getValue();
+ this.file.resolveEditChanged = this.file.content !== this.originalContent;
+ this.file.promptDiscardConfirmation = false;
+ /* eslint-enable vue/no-mutating-props */
+ },
+ resetEditorContent() {
+ if (this.fileLoaded) {
+ this.editor.setValue(this.originalContent);
+ }
+ },
+ cancelDiscardConfirmation(file) {
+ this.onCancelDiscardConfirmation(file);
+ },
+ acceptDiscardConfirmation(file) {
+ this.onAcceptDiscardConfirmation(file);
+ },
+ },
+};
+</script>
+<template>
+ <div v-show="file.showEditor" class="diff-editor-wrap">
+ <div v-if="file.promptDiscardConfirmation" class="discard-changes-alert-wrap">
+ <div class="discard-changes-alert">
+ {{ __('Are you sure you want to discard your changes?') }}
+ <div class="discard-actions">
+ <button
+ class="btn btn-sm btn-danger-secondary gl-button"
+ @click="acceptDiscardConfirmation(file)"
+ >
+ {{ __('Discard changes') }}
+ </button>
+ <button class="btn btn-default btn-sm gl-button" @click="cancelDiscardConfirmation(file)">
+ {{ __('Cancel') }}
+ </button>
+ </div>
+ </div>
+ </div>
+ <div :class="classObject" class="editor-wrap">
+ <div class="editor" style="height: 350px" data-editor-loading="true"></div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js
deleted file mode 100644
index 47214e288ae..00000000000
--- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on
-// app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml
-// for its template.
-/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */
-
-import Vue from 'vue';
-import actionsMixin from '../mixins/line_conflict_actions';
-import utilsMixin from '../mixins/line_conflict_utils';
-
-((global) => {
- global.mergeConflicts = global.mergeConflicts || {};
-
- global.mergeConflicts.inlineConflictLines = Vue.extend({
- mixins: [utilsMixin, actionsMixin],
- props: {
- file: {
- type: Object,
- required: true,
- },
- },
- });
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
new file mode 100644
index 00000000000..519fd53af1e
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import actionsMixin from '../mixins/line_conflict_actions';
+import utilsMixin from '../mixins/line_conflict_utils';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ mixins: [utilsMixin, actionsMixin],
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <table class="diff-wrap-lines code code-commit js-syntax-highlight">
+ <tr
+ v-for="line in file.inlineLines"
+ :key="(line.isHeader ? line.id : line.new_line) + line.richText"
+ class="line_holder diff-inline"
+ >
+ <template v-if="line.isHeader">
+ <td :class="lineCssClass(line)" class="diff-line-num header"></td>
+ <td :class="lineCssClass(line)" class="diff-line-num header"></td>
+ <td :class="lineCssClass(line)" class="line_content header">
+ <strong>{{ line.richText }}</strong>
+ <button class="btn" @click="handleSelected(file, line.id, line.section)">
+ {{ line.buttonTitle }}
+ </button>
+ </td>
+ </template>
+ <template v-else>
+ <td :class="lineCssClass(line)" class="diff-line-num new_line">
+ <a>{{ line.new_line }}</a>
+ </td>
+ <td :class="lineCssClass(line)" class="diff-line-num old_line">
+ <a>{{ line.old_line }}</a>
+ </td>
+ <td v-safe-html="line.richText" :class="lineCssClass(line)" class="line_content"></td>
+ </template>
+ </tr>
+ </table>
+</template>
diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
deleted file mode 100644
index 1d5946cd78a..00000000000
--- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/* eslint-disable no-param-reassign */
-
-import Vue from 'vue';
-import actionsMixin from '../mixins/line_conflict_actions';
-import utilsMixin from '../mixins/line_conflict_utils';
-
-((global) => {
- global.mergeConflicts = global.mergeConflicts || {};
-
- global.mergeConflicts.parallelConflictLines = Vue.extend({
- mixins: [utilsMixin, actionsMixin],
- props: {
- file: {
- type: Object,
- required: true,
- },
- },
- // This is a true violation of @gitlab/no-runtime-template-compiler, as it
- // has a template string.
- // eslint-disable-next-line @gitlab/no-runtime-template-compiler
- template: `
- <table class="diff-wrap-lines code js-syntax-highlight">
- <tr class="line_holder parallel" v-for="section in file.parallelLines">
- <template v-for="line in section">
- <td class="diff-line-num header" :class="lineCssClass(line)" v-if="line.isHeader"></td>
- <td class="line_content header" :class="lineCssClass(line)" v-if="line.isHeader">
- <strong>{{line.richText}}</strong>
- <button class="btn" @click="handleSelected(file, line.id, line.section)">{{line.buttonTitle}}</button>
- </td>
- <td class="diff-line-num old_line" :class="lineCssClass(line)" v-if="!line.isHeader">{{line.lineNumber}}</td>
- <td class="line_content parallel" :class="lineCssClass(line)" v-if="!line.isHeader" v-html="line.richText"></td>
- </template>
- </tr>
- </table>
- `,
- });
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
new file mode 100644
index 00000000000..e66f641f70d
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import actionsMixin from '../mixins/line_conflict_actions';
+import utilsMixin from '../mixins/line_conflict_utils';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ mixins: [utilsMixin, actionsMixin],
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <!-- Unfortunately there isn't a good key for these sections -->
+ <!-- eslint-disable vue/require-v-for-key -->
+ <table class="diff-wrap-lines code js-syntax-highlight">
+ <tr v-for="section in file.parallelLines" class="line_holder parallel">
+ <template v-for="line in section">
+ <template v-if="line.isHeader">
+ <td class="diff-line-num header" :class="lineCssClass(line)"></td>
+ <td class="line_content header" :class="lineCssClass(line)">
+ <strong>{{ line.richText }}</strong>
+ <button class="btn" @click="handleSelected(file, line.id, line.section)">
+ {{ line.buttonTitle }}
+ </button>
+ </td>
+ </template>
+ <template v-else>
+ <td class="diff-line-num old_line" :class="lineCssClass(line)">
+ {{ line.lineNumber }}
+ </td>
+ <td
+ v-safe-html="line.richText"
+ class="line_content parallel"
+ :class="lineCssClass(line)"
+ ></td>
+ </template>
+ </template>
+ </tr>
+ </table>
+</template>
diff --git a/app/assets/javascripts/merge_conflicts/constants.js b/app/assets/javascripts/merge_conflicts/constants.js
new file mode 100644
index 00000000000..6f3ee339e36
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/constants.js
@@ -0,0 +1,20 @@
+import { s__ } from '~/locale';
+
+export const CONFLICT_TYPES = {
+ TEXT: 'text',
+ TEXT_EDITOR: 'text-editor',
+};
+
+export const VIEW_TYPES = {
+ INLINE: 'inline',
+ PARALLEL: 'parallel',
+};
+
+export const EDIT_RESOLVE_MODE = 'edit';
+export const INTERACTIVE_RESOLVE_MODE = 'interactive';
+export const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE;
+
+export const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes');
+export const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes');
+export const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours');
+export const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs');
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
new file mode 100644
index 00000000000..16a7cfb2ba8
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -0,0 +1,217 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+import DiffFileEditor from './components/diff_file_editor.vue';
+import InlineConflictLines from './components/inline_conflict_lines.vue';
+import ParallelConflictLines from './components/parallel_conflict_lines.vue';
+
+/**
+ * NOTE: Most of this component is directly using $root, rather than props or a better data store.
+ * This is BAD and one shouldn't copy that behavior. Similarly a lot of the classes below should
+ * be replaced with GitLab UI components.
+ *
+ * We are just doing it temporarily in order to migrate the template from HAML => Vue in an iterative manner
+ * and are going to clean it up as part of:
+ *
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/321090
+ *
+ */
+export default {
+ components: {
+ GlSprintf,
+ FileIcon,
+ DiffFileEditor,
+ InlineConflictLines,
+ ParallelConflictLines,
+ },
+ inject: ['mergeRequestPath', 'sourceBranchPath'],
+ i18n: {
+ commitStatSummary: __('Showing %{conflict} between %{sourceBranch} and %{targetBranch}'),
+ resolveInfo: __(
+ 'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}',
+ ),
+ },
+};
+</script>
+<template>
+ <div id="conflicts">
+ <div v-if="$root.isLoading" class="loading">
+ <div class="spinner spinner-md"></div>
+ </div>
+ <div v-if="$root.hasError" class="nothing-here-block">
+ {{ $root.conflictsData.errorMessage }}
+ </div>
+ <template v-if="!$root.isLoading && !$root.hasError">
+ <div class="content-block oneline-block files-changed">
+ <div v-if="$root.showDiffViewTypeSwitcher" class="inline-parallel-buttons">
+ <div class="btn-group">
+ <button
+ :class="{ active: !$root.isParallel }"
+ class="btn gl-button"
+ @click="$root.handleViewTypeChange('inline')"
+ >
+ {{ __('Inline') }}
+ </button>
+ <button
+ :class="{ active: $root.isParallel }"
+ class="btn gl-button"
+ @click="$root.handleViewTypeChange('parallel')"
+ >
+ {{ __('Side-by-side') }}
+ </button>
+ </div>
+ </div>
+ <div class="js-toggle-container">
+ <div class="commit-stat-summary">
+ <gl-sprintf :message="$options.i18n.commitStatSummary">
+ <template #conflict>
+ <strong class="cred">
+ {{ $root.conflictsCountText }}
+ </strong>
+ </template>
+ <template #sourceBranch>
+ <strong class="ref-name">
+ {{ $root.conflictsData.sourceBranch }}
+ </strong>
+ </template>
+ <template #targetBranch>
+ <strong class="ref-name">
+ {{ $root.conflictsData.targetBranch }}
+ </strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+ </div>
+ <div class="files-wrapper">
+ <div class="files">
+ <div
+ v-for="file in $root.conflictsData.files"
+ :key="file.blobPath"
+ class="diff-file file-holder conflict"
+ >
+ <div class="js-file-title file-title file-title-flex-parent cursor-default">
+ <div class="file-header-content">
+ <file-icon :file-name="file.filePath" :size="18" css-classes="gl-mr-2" />
+ <strong class="file-title-name">{{ file.filePath }}</strong>
+ </div>
+ <div class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start">
+ <div v-if="file.type === 'text'" class="btn-group gl-mr-3">
+ <button
+ :class="{ active: file.resolveMode === 'interactive' }"
+ class="btn gl-button"
+ type="button"
+ @click="$root.onClickResolveModeButton(file, 'interactive')"
+ >
+ {{ __('Interactive mode') }}
+ </button>
+ <button
+ :class="{ active: file.resolveMode === 'edit' }"
+ class="btn gl-button"
+ type="button"
+ @click="$root.onClickResolveModeButton(file, 'edit')"
+ >
+ {{ __('Edit inline') }}
+ </button>
+ </div>
+ <a :href="file.blobPath" class="btn gl-button view-file">
+ <gl-sprintf :message="__('View file @ %{commitSha}')">
+ <template #commitSha>
+ {{ $root.conflictsData.shortCommitSha }}
+ </template>
+ </gl-sprintf>
+ </a>
+ </div>
+ </div>
+ <div class="diff-content diff-wrap-lines">
+ <div
+ v-show="
+ !$root.isParallel && file.resolveMode === 'interactive' && file.type === 'text'
+ "
+ class="file-content"
+ >
+ <inline-conflict-lines :file="file" />
+ </div>
+ <div
+ v-show="
+ $root.isParallel && file.resolveMode === 'interactive' && file.type === 'text'
+ "
+ class="file-content"
+ >
+ <parallel-conflict-lines :file="file" />
+ </div>
+ <div v-show="file.resolveMode === 'edit' || file.type === 'text-editor'">
+ <diff-file-editor
+ :file="file"
+ :on-accept-discard-confirmation="$root.acceptDiscardConfirmation"
+ :on-cancel-discard-confirmation="$root.cancelDiscardConfirmation"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <hr />
+ <div class="resolve-conflicts-form">
+ <div class="form-group row">
+ <div class="col-md-4">
+ <h4>
+ {{ __('Resolve conflicts on source branch') }}
+ </h4>
+ <div class="resolve-info">
+ <gl-sprintf :message="$options.i18n.resolveInfo">
+ <template #use_ours>
+ <code>{{ s__('MergeConflict|Use ours') }}</code>
+ </template>
+ <template #use_theirs>
+ <code>{{ s__('MergeConflict|Use theirs') }}</code>
+ </template>
+ <template #branch_name>
+ <a class="ref-name" :href="sourceBranchPath">
+ {{ $root.conflictsData.sourceBranch }}
+ </a>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+ <div class="col-md-8">
+ <label class="label-bold" for="commit-message">
+ {{ __('Commit message') }}
+ </label>
+ <div class="commit-message-container">
+ <div class="max-width-marker"></div>
+ <textarea
+ id="commit-message"
+ v-model="$root.conflictsData.commitMessage"
+ class="form-control js-commit-message"
+ rows="5"
+ ></textarea>
+ </div>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="offset-md-4 col-md-8">
+ <div class="row">
+ <div class="col-6">
+ <button
+ :disabled="!$root.readyToCommit"
+ class="btn gl-button btn-success js-submit-button"
+ type="button"
+ @click="$root.commit()"
+ >
+ <span>{{ $root.commitButtonText }}</span>
+ </button>
+ </div>
+ <div class="col-6 text-right">
+ <a :href="mergeRequestPath" class="gl-button btn btn-default">
+ {{ __('Cancel') }}
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index e3972b8b574..4b73dd317cd 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -1,19 +1,12 @@
-// This is a true violation of @gitlab/no-runtime-template-compiler, as it
-// relies on app/views/projects/merge_requests/conflicts/show.html.haml for its
-// template.
-/* eslint-disable @gitlab/no-runtime-template-compiler */
import $ from 'jquery';
import Vue from 'vue';
import { __ } from '~/locale';
-import FileIcon from '~/vue_shared/components/file_icon.vue';
import { deprecatedCreateFlash as createFlash } from '../flash';
import initIssuableSidebar from '../init_issuable_sidebar';
import './merge_conflict_store';
import syntaxHighlight from '../syntax_highlight';
+import MergeConflictsResolverApp from './merge_conflict_resolver_app.vue';
import MergeConflictsService from './merge_conflict_service';
-import './components/diff_file_editor';
-import './components/inline_conflict_lines';
-import './components/parallel_conflict_lines';
export default function initMergeConflicts() {
const INTERACTIVE_RESOLVE_MODE = 'interactive';
@@ -24,15 +17,15 @@ export default function initMergeConflicts() {
resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath,
});
+ const { sourceBranchPath, mergeRequestPath } = conflictsEl.dataset;
+
initIssuableSidebar();
- gl.MergeConflictsResolverApp = new Vue({
- el: '#conflicts',
- components: {
- FileIcon,
- 'diff-file-editor': gl.mergeConflicts.diffFileEditor,
- 'inline-conflict-lines': gl.mergeConflicts.inlineConflictLines,
- 'parallel-conflict-lines': gl.mergeConflicts.parallelConflictLines,
+ return new Vue({
+ el: conflictsEl,
+ provide: {
+ sourceBranchPath,
+ mergeRequestPath,
},
data: mergeConflictsStore.state,
computed: {
@@ -103,5 +96,8 @@ export default function initMergeConflicts() {
});
},
},
+ render(createElement) {
+ return createElement(MergeConflictsResolverApp);
+ },
});
}
diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js
new file mode 100644
index 00000000000..8036e90c58c
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/store/actions.js
@@ -0,0 +1,120 @@
+import Cookies from 'js-cookie';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '../constants';
+import { decorateFiles, restoreFileLinesState, markLine } from '../utils';
+import * as types from './mutation_types';
+
+export const fetchConflictsData = async ({ commit, dispatch }, conflictsPath) => {
+ commit(types.SET_LOADING_STATE, true);
+ try {
+ const { data } = await axios.get(conflictsPath);
+ if (data.type === 'error') {
+ commit(types.SET_FAILED_REQUEST, data.message);
+ } else {
+ dispatch('setConflictsData', data);
+ }
+ } catch (e) {
+ commit(types.SET_FAILED_REQUEST);
+ }
+ commit(types.SET_LOADING_STATE, false);
+};
+
+export const setConflictsData = async ({ commit }, data) => {
+ const files = decorateFiles(data.files);
+ commit(types.SET_CONFLICTS_DATA, { ...data, files });
+};
+
+export const submitResolvedConflicts = async ({ commit, getters }, resolveConflictsPath) => {
+ commit(types.SET_SUBMIT_STATE, true);
+ try {
+ const { data } = await axios.post(resolveConflictsPath, getters.getCommitData);
+ window.location.assign(data.redirect_to);
+ } catch (e) {
+ commit(types.SET_SUBMIT_STATE, false);
+ createFlash({ message: __('Failed to save merge conflicts resolutions. Please try again!') });
+ }
+};
+
+export const setLoadingState = ({ commit }, isLoading) => {
+ commit(types.SET_LOADING_STATE, isLoading);
+};
+
+export const setErrorState = ({ commit }, hasError) => {
+ commit(types.SET_ERROR_STATE, hasError);
+};
+
+export const setFailedRequest = ({ commit }, message) => {
+ commit(types.SET_FAILED_REQUEST, message);
+};
+
+export const setViewType = ({ commit }, viewType) => {
+ commit(types.SET_VIEW_TYPE, viewType);
+ Cookies.set('diff_view', viewType);
+};
+
+export const setSubmitState = ({ commit }, isSubmitting) => {
+ commit(types.SET_SUBMIT_STATE, isSubmitting);
+};
+
+export const updateCommitMessage = ({ commit }, commitMessage) => {
+ commit(types.UPDATE_CONFLICTS_DATA, { commitMessage });
+};
+
+export const setFileResolveMode = ({ commit, state, getters }, { file, mode }) => {
+ const index = getters.getFileIndex(file);
+ const updated = { ...state.conflictsData.files[index] };
+ if (mode === INTERACTIVE_RESOLVE_MODE) {
+ updated.showEditor = false;
+ } else if (mode === EDIT_RESOLVE_MODE) {
+ // Restore Interactive mode when switching to Edit mode
+ updated.showEditor = true;
+ updated.loadEditor = true;
+ updated.resolutionData = {};
+
+ const { inlineLines, parallelLines } = restoreFileLinesState(updated);
+ updated.parallelLines = parallelLines;
+ updated.inlineLines = inlineLines;
+ }
+ updated.resolveMode = mode;
+ commit(types.UPDATE_FILE, { file: updated, index });
+};
+
+export const setPromptConfirmationState = (
+ { commit, state, getters },
+ { file, promptDiscardConfirmation },
+) => {
+ const index = getters.getFileIndex(file);
+ const updated = { ...state.conflictsData.files[index], promptDiscardConfirmation };
+ commit(types.UPDATE_FILE, { file: updated, index });
+};
+
+export const handleSelected = ({ commit, state, getters }, { file, line: { id, section } }) => {
+ const index = getters.getFileIndex(file);
+ const updated = { ...state.conflictsData.files[index] };
+ updated.resolutionData = { ...updated.resolutionData, [id]: section };
+
+ updated.inlineLines = file.inlineLines.map((line) => {
+ if (id === line.id && (line.hasConflict || line.isHeader)) {
+ return markLine(line, section);
+ }
+ return line;
+ });
+
+ updated.parallelLines = file.parallelLines.map((lines) => {
+ let left = { ...lines[0] };
+ let right = { ...lines[1] };
+ const hasSameId = right.id === id || left.id === id;
+ const isLeftMatch = left.hasConflict || left.isHeader;
+ const isRightMatch = right.hasConflict || right.isHeader;
+
+ if (hasSameId && (isLeftMatch || isRightMatch)) {
+ left = markLine(left, section);
+ right = markLine(right, section);
+ }
+ return [left, right];
+ });
+
+ commit(types.UPDATE_FILE, { file: updated, index });
+};
diff --git a/app/assets/javascripts/merge_conflicts/store/getters.js b/app/assets/javascripts/merge_conflicts/store/getters.js
new file mode 100644
index 00000000000..03e425fb478
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/store/getters.js
@@ -0,0 +1,117 @@
+import { s__ } from '~/locale';
+import { CONFLICT_TYPES, EDIT_RESOLVE_MODE, INTERACTIVE_RESOLVE_MODE } from '../constants';
+
+export const getConflictsCount = (state) => {
+ if (!state.conflictsData.files.length) {
+ return 0;
+ }
+
+ const { files } = state.conflictsData;
+ let count = 0;
+
+ files.forEach((file) => {
+ if (file.type === CONFLICT_TYPES.TEXT) {
+ file.sections.forEach((section) => {
+ if (section.conflict) {
+ count += 1;
+ }
+ });
+ } else {
+ count += 1;
+ }
+ });
+
+ return count;
+};
+
+export const getConflictsCountText = (state, getters) => {
+ const count = getters.getConflictsCount;
+ const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict');
+
+ return `${count} ${text}`;
+};
+
+export const isReadyToCommit = (state) => {
+ const { files } = state.conflictsData;
+ const hasCommitMessage = state.conflictsData.commitMessage.trim().length;
+ let unresolved = 0;
+
+ for (let i = 0, l = files.length; i < l; i += 1) {
+ const file = files[i];
+
+ if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
+ let numberConflicts = 0;
+ const resolvedConflicts = Object.keys(file.resolutionData).length;
+
+ // We only check for conflicts type 'text'
+ // since conflicts `text_editor` can´t be resolved in interactive mode
+ if (file.type === CONFLICT_TYPES.TEXT) {
+ for (let j = 0, k = file.sections.length; j < k; j += 1) {
+ if (file.sections[j].conflict) {
+ numberConflicts += 1;
+ }
+ }
+
+ if (resolvedConflicts !== numberConflicts) {
+ unresolved += 1;
+ }
+ }
+ } else if (file.resolveMode === EDIT_RESOLVE_MODE) {
+ // Unlikely to happen since switching to Edit mode saves content automatically.
+ // Checking anyway in case the save strategy changes in the future
+ if (!file.content) {
+ unresolved += 1;
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+ }
+ }
+
+ return !state.isSubmitting && hasCommitMessage && !unresolved;
+};
+
+export const getCommitButtonText = (state) => {
+ const initial = s__('MergeConflict|Commit to source branch');
+ const inProgress = s__('MergeConflict|Committing...');
+
+ return state.isSubmitting ? inProgress : initial;
+};
+
+export const getCommitData = (state) => {
+ let commitData = {};
+
+ commitData = {
+ commit_message: state.conflictsData.commitMessage,
+ files: [],
+ };
+
+ state.conflictsData.files.forEach((file) => {
+ const addFile = {
+ old_path: file.old_path,
+ new_path: file.new_path,
+ };
+
+ if (file.type === CONFLICT_TYPES.TEXT) {
+ // Submit only one data for type of editing
+ if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
+ addFile.sections = file.resolutionData;
+ } else if (file.resolveMode === EDIT_RESOLVE_MODE) {
+ addFile.content = file.content;
+ }
+ } else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) {
+ addFile.content = file.content;
+ }
+
+ commitData.files.push(addFile);
+ });
+
+ return commitData;
+};
+
+export const fileTextTypePresent = (state) => {
+ return state.conflictsData?.files.some((f) => f.type === CONFLICT_TYPES.TEXT);
+};
+
+export const getFileIndex = (state) => ({ blobPath }) => {
+ return state.conflictsData.files.findIndex((f) => f.blobPath === blobPath);
+};
diff --git a/app/assets/javascripts/merge_conflicts/store/index.js b/app/assets/javascripts/merge_conflicts/store/index.js
new file mode 100644
index 00000000000..18e3351ed13
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/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 const createStore = () =>
+ new Vuex.Store({
+ state,
+ getters,
+ actions,
+ mutations,
+ });
diff --git a/app/assets/javascripts/merge_conflicts/store/mutation_types.js b/app/assets/javascripts/merge_conflicts/store/mutation_types.js
new file mode 100644
index 00000000000..ab80f8e52ad
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/store/mutation_types.js
@@ -0,0 +1,8 @@
+export const SET_LOADING_STATE = 'SET_LOADING_STATE';
+export const SET_ERROR_STATE = 'SET_ERROR_STATE';
+export const SET_FAILED_REQUEST = 'SET_FAILED_REQUEST';
+export const SET_VIEW_TYPE = 'SET_VIEW_TYPE';
+export const SET_SUBMIT_STATE = 'SET_SUBMIT_STATE';
+export const SET_CONFLICTS_DATA = 'SET_CONFLICTS_DATA';
+export const UPDATE_FILE = 'UPDATE_FILE';
+export const UPDATE_CONFLICTS_DATA = 'UPDATE_CONFLICTS_DATA';
diff --git a/app/assets/javascripts/merge_conflicts/store/mutations.js b/app/assets/javascripts/merge_conflicts/store/mutations.js
new file mode 100644
index 00000000000..2cee55319eb
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/store/mutations.js
@@ -0,0 +1,40 @@
+import { VIEW_TYPES } from '../constants';
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_LOADING_STATE]: (state, value) => {
+ state.isLoading = value;
+ },
+ [types.SET_ERROR_STATE]: (state, value) => {
+ state.hasError = value;
+ },
+ [types.SET_FAILED_REQUEST]: (state, value) => {
+ state.hasError = true;
+ state.conflictsData.errorMessage = value;
+ },
+ [types.SET_VIEW_TYPE]: (state, value) => {
+ state.diffView = value;
+ state.isParallel = value === VIEW_TYPES.PARALLEL;
+ },
+ [types.SET_SUBMIT_STATE]: (state, value) => {
+ state.isSubmitting = value;
+ },
+ [types.SET_CONFLICTS_DATA]: (state, data) => {
+ state.conflictsData = {
+ files: data.files,
+ commitMessage: data.commit_message,
+ sourceBranch: data.source_branch,
+ targetBranch: data.target_branch,
+ shortCommitSha: data.commit_sha.slice(0, 7),
+ };
+ },
+ [types.UPDATE_CONFLICTS_DATA]: (state, payload) => {
+ state.conflictsData = {
+ ...state.conflictsData,
+ ...payload,
+ };
+ },
+ [types.UPDATE_FILE]: (state, { file, index }) => {
+ state.conflictsData.files.splice(index, 1, file);
+ },
+};
diff --git a/app/assets/javascripts/merge_conflicts/store/state.js b/app/assets/javascripts/merge_conflicts/store/state.js
new file mode 100644
index 00000000000..8f700f58e54
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/store/state.js
@@ -0,0 +1,13 @@
+import Cookies from 'js-cookie';
+import { VIEW_TYPES } from '../constants';
+
+const diffViewType = Cookies.get('diff_view');
+
+export default () => ({
+ isLoading: true,
+ hasError: false,
+ isSubmitting: false,
+ isParallel: diffViewType === VIEW_TYPES.PARALLEL,
+ diffViewType,
+ conflictsData: {},
+});
diff --git a/app/assets/javascripts/merge_conflicts/utils.js b/app/assets/javascripts/merge_conflicts/utils.js
new file mode 100644
index 00000000000..e42703ef0a5
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/utils.js
@@ -0,0 +1,228 @@
+import {
+ ORIGIN_HEADER_TEXT,
+ ORIGIN_BUTTON_TITLE,
+ HEAD_HEADER_TEXT,
+ HEAD_BUTTON_TITLE,
+ DEFAULT_RESOLVE_MODE,
+ CONFLICT_TYPES,
+} from './constants';
+
+export const getFilePath = (file) => {
+ const { old_path, new_path } = file;
+ // eslint-disable-next-line babel/camelcase
+ return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
+};
+
+export const checkLineLengths = ({ left, right }) => {
+ const wLeft = [...left];
+ const wRight = [...right];
+ if (left.length !== right.length) {
+ if (left.length > right.length) {
+ const diff = left.length - right.length;
+ for (let i = 0; i < diff; i += 1) {
+ wRight.push({ lineType: 'emptyLine', richText: '' });
+ }
+ } else {
+ const diff = right.length - left.length;
+ for (let i = 0; i < diff; i += 1) {
+ wLeft.push({ lineType: 'emptyLine', richText: '' });
+ }
+ }
+ }
+ return { left: wLeft, right: wRight };
+};
+
+export const getHeadHeaderLine = (id) => {
+ return {
+ id,
+ richText: HEAD_HEADER_TEXT,
+ buttonTitle: HEAD_BUTTON_TITLE,
+ type: 'new',
+ section: 'head',
+ isHeader: true,
+ isHead: true,
+ isSelected: false,
+ isUnselected: false,
+ };
+};
+
+export const decorateLineForInlineView = (line, id, conflict) => {
+ const { type } = line;
+ return {
+ id,
+ hasConflict: conflict,
+ isHead: type === 'new',
+ isOrigin: type === 'old',
+ hasMatch: type === 'match',
+ richText: line.rich_text,
+ isSelected: false,
+ isUnselected: false,
+ };
+};
+
+export const getLineForParallelView = (line, id, lineType, isHead) => {
+ const { old_line, new_line, rich_text } = line;
+ const hasConflict = lineType === 'conflict';
+
+ return {
+ id,
+ lineType,
+ hasConflict,
+ isHead: hasConflict && isHead,
+ isOrigin: hasConflict && !isHead,
+ hasMatch: lineType === 'match',
+ // eslint-disable-next-line babel/camelcase
+ lineNumber: isHead ? new_line : old_line,
+ section: isHead ? 'head' : 'origin',
+ richText: rich_text,
+ isSelected: false,
+ isUnselected: false,
+ };
+};
+
+export const getOriginHeaderLine = (id) => {
+ return {
+ id,
+ richText: ORIGIN_HEADER_TEXT,
+ buttonTitle: ORIGIN_BUTTON_TITLE,
+ type: 'old',
+ section: 'origin',
+ isHeader: true,
+ isOrigin: true,
+ isSelected: false,
+ isUnselected: false,
+ };
+};
+
+export const setInlineLine = (file) => {
+ const inlineLines = [];
+
+ file.sections.forEach((section) => {
+ let currentLineType = 'new';
+ const { conflict, lines, id } = section;
+
+ if (conflict) {
+ inlineLines.push(getHeadHeaderLine(id));
+ }
+
+ lines.forEach((line) => {
+ const { type } = line;
+
+ if ((type === 'new' || type === 'old') && currentLineType !== type) {
+ currentLineType = type;
+ inlineLines.push({ lineType: 'emptyLine', richText: '' });
+ }
+
+ const decoratedLine = decorateLineForInlineView(line, id, conflict);
+ inlineLines.push(decoratedLine);
+ });
+
+ if (conflict) {
+ inlineLines.push(getOriginHeaderLine(id));
+ }
+ });
+
+ return inlineLines;
+};
+
+export const setParallelLine = (file) => {
+ const parallelLines = [];
+ let linesObj = { left: [], right: [] };
+
+ file.sections.forEach((section) => {
+ const { conflict, lines, id } = section;
+
+ if (conflict) {
+ linesObj.left.push(getOriginHeaderLine(id));
+ linesObj.right.push(getHeadHeaderLine(id));
+ }
+
+ lines.forEach((line) => {
+ const { type } = line;
+
+ if (conflict) {
+ if (type === 'old') {
+ linesObj.left.push(getLineForParallelView(line, id, 'conflict'));
+ } else if (type === 'new') {
+ linesObj.right.push(getLineForParallelView(line, id, 'conflict', true));
+ }
+ } else {
+ const lineType = type || 'context';
+
+ linesObj.left.push(getLineForParallelView(line, id, lineType));
+ linesObj.right.push(getLineForParallelView(line, id, lineType, true));
+ }
+ });
+
+ linesObj = checkLineLengths(linesObj);
+ });
+
+ for (let i = 0, len = linesObj.left.length; i < len; i += 1) {
+ parallelLines.push([linesObj.right[i], linesObj.left[i]]);
+ }
+ return parallelLines;
+};
+
+export const decorateFiles = (files) => {
+ return files.map((file) => {
+ const f = { ...file };
+ f.content = '';
+ f.resolutionData = {};
+ f.promptDiscardConfirmation = false;
+ f.resolveMode = DEFAULT_RESOLVE_MODE;
+ f.filePath = getFilePath(file);
+ f.blobPath = f.blob_path;
+
+ if (f.type === CONFLICT_TYPES.TEXT) {
+ f.showEditor = false;
+ f.loadEditor = false;
+
+ f.inlineLines = setInlineLine(file);
+ f.parallelLines = setParallelLine(file);
+ } else if (f.type === CONFLICT_TYPES.TEXT_EDITOR) {
+ f.showEditor = true;
+ f.loadEditor = true;
+ }
+ return f;
+ });
+};
+
+export const restoreFileLinesState = (file) => {
+ const inlineLines = file.inlineLines.map((line) => {
+ if (line.hasConflict || line.isHeader) {
+ return { ...line, isSelected: false, isUnselected: false };
+ }
+ return { ...line };
+ });
+
+ const parallelLines = file.parallelLines.map((lines) => {
+ const left = { ...lines[0] };
+ const right = { ...lines[1] };
+ const isLeftMatch = left.hasConflict || left.isHeader;
+ const isRightMatch = right.hasConflict || right.isHeader;
+
+ if (isLeftMatch || isRightMatch) {
+ left.isSelected = false;
+ left.isUnselected = false;
+ right.isSelected = false;
+ right.isUnselected = false;
+ }
+ return [left, right];
+ });
+ return { inlineLines, parallelLines };
+};
+
+export const markLine = (line, selection) => {
+ const updated = { ...line };
+ if (selection === 'head' && line.isHead) {
+ updated.isSelected = true;
+ updated.isUnselected = false;
+ } else if (selection === 'origin' && updated.isOrigin) {
+ updated.isSelected = true;
+ updated.isUnselected = false;
+ } else {
+ updated.isSelected = false;
+ updated.isUnselected = true;
+ }
+ return updated;
+};
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 4dab796d8a4..81b9db6b4d5 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -361,10 +361,8 @@ export default class MergeRequestTabs {
return createElement(CommitPipelinesTable, {
props: {
endpoint: pipelineTableViewEl.dataset.endpoint,
- helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
- autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath,
canCreatePipelineInTargetProject: Boolean(
mrWidgetData?.can_create_pipeline_in_target_project,
),
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index f249fef5ea4..280613bda49 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -2,7 +2,6 @@ import $ from 'jquery';
import { deprecatedCreateFlash as flash } from './flash';
import axios from './lib/utils/axios_utils';
import { __ } from './locale';
-import { mouseenter, debouncedMouseleave, togglePopover } from './shared/popover';
export default class Milestone {
constructor() {
@@ -43,30 +42,4 @@ export default class Milestone {
.catch(() => flash(__('Error loading milestone tab')));
}
}
-
- static initDeprecationMessage() {
- const deprecationMesssageContainer = document.querySelector(
- '.js-milestone-deprecation-message',
- );
-
- if (!deprecationMesssageContainer) return;
-
- const deprecationMessage = deprecationMesssageContainer.querySelector(
- '.js-milestone-deprecation-message-template',
- ).innerHTML;
- const $popover = $('.js-popover-link', deprecationMesssageContainer);
- const hideOnScroll = togglePopover.bind($popover, false);
-
- $popover
- .popover({
- content: deprecationMessage,
- html: true,
- placement: 'bottom',
- })
- .on('mouseenter', mouseenter)
- .on('mouseleave', debouncedMouseleave())
- .on('show.bs.popover', () => {
- window.addEventListener('scroll', hideOnScroll, { once: true });
- });
- }
}
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 4cbe0a53307..f4b60fc0961 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -119,7 +119,7 @@ export default class MilestoneSelect {
title: __('Any milestone'),
});
}
- if (showNo) {
+ if (showNo && term.trim() === '') {
extraOptions.push({
id: -1,
name: __('No milestone'),
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index 8569a67da34..a995497ab9c 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -86,7 +86,6 @@ export default {
const originalMetricQuery = this.graphData.metrics[0];
const metricQuery = produce(originalMetricQuery, (draftQuery) => {
- // eslint-disable-next-line no-param-reassign
draftQuery.result[0].values = draftQuery.result[0].values.map(([x, y]) => [
x,
y + this.yOffset,
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 8522ac6a57d..a0b4fd0b608 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -1,7 +1,7 @@
+import * as Sentry from '@sentry/browser';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import * as Sentry from '~/sentry/wrapper';
import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
import { s__, sprintf } from '../../locale';
import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants';
diff --git a/app/assets/javascripts/monitoring/stores/embed_group/index.js b/app/assets/javascripts/monitoring/stores/embed_group/index.js
index 773bca9f87e..66c65adc413 100644
--- a/app/assets/javascripts/monitoring/stores/embed_group/index.js
+++ b/app/assets/javascripts/monitoring/stores/embed_group/index.js
@@ -20,5 +20,3 @@ export const createStore = () =>
},
},
});
-
-export default createStore();
diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js
index 1f360e84f68..6ee5d85a09f 100644
--- a/app/assets/javascripts/network/branch_graph.js
+++ b/app/assets/javascripts/network/branch_graph.js
@@ -234,8 +234,7 @@ export default class BranchGraph {
appendAnchor(x, y, commit) {
const { r, top, options } = this;
- const anchor = r
- .circle(x, y, 10)
+ r.circle(x, y, 10)
.attr({
fill: '#000',
opacity: 0,
@@ -245,13 +244,14 @@ export default class BranchGraph {
.hover(
function () {
this.tooltip = r.commitTooltip(x + 5, y, commit);
- return top.push(this.tooltip.insertBefore(this));
+ top.push(this.tooltip.insertBefore(this));
+ return this.tooltip.toFront();
},
function () {
+ top.remove(this.tooltip);
return this.tooltip && this.tooltip.remove() && delete this.tooltip;
},
);
- return top.push(anchor);
}
drawDot(x, y, commit) {
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 50db3b86025..08d7c745791 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,39 +1,61 @@
<script>
-import { GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlButton,
+ GlIcon,
+ GlFormCheckbox,
+ GlTooltipDirective,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+} from '@gitlab/ui';
import Autosize from 'autosize';
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import Autosave from '~/autosave';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { deprecatedCreateFlash as Flash } from '~/flash';
+import httpStatusCodes from '~/lib/utils/http_status';
import {
capitalizeFirstCharacter,
convertToCamelCase,
splitCamelCase,
slugifyWithUnderscore,
} from '~/lib/utils/text_utility';
-import { __, sprintf } from '~/locale';
+import { sprintf } from '~/locale';
import markdownField from '~/vue_shared/components/markdown/field.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
import * as constants from '../constants';
import eventHub from '../event_hub';
+import { COMMENT_FORM } from '../i18n';
+
import issuableStateMixin from '../mixins/issuable_state';
import CommentFieldLayout from './comment_field_layout.vue';
import discussionLockedWidget from './discussion_locked_widget.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue';
+const { UNPROCESSABLE_ENTITY } = httpStatusCodes;
+
export default {
name: 'CommentForm',
+ i18n: COMMENT_FORM,
+ noteTypeComment: constants.COMMENT,
+ noteTypeDiscussion: constants.DISCUSSION,
components: {
noteSignedOutWidget,
discussionLockedWidget,
markdownField,
+ GlAlert,
GlButton,
TimelineEntryItem,
GlIcon,
CommentFieldLayout,
GlFormCheckbox,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -49,6 +71,7 @@ export default {
return {
note: '',
noteType: constants.COMMENT,
+ errors: [],
noteIsConfidential: false,
isSubmitting: false,
};
@@ -63,6 +86,12 @@ export default {
'openState',
]),
...mapState(['isToggleStateButtonLoading']),
+ isNoteTypeComment() {
+ return this.noteType === constants.COMMENT;
+ },
+ isNoteTypeDiscussion() {
+ return this.noteType === constants.DISCUSSION;
+ },
noteableDisplayName() {
return splitCamelCase(this.noteableType).toLowerCase();
},
@@ -70,12 +99,19 @@ export default {
return this.getUserData.id;
},
commentButtonTitle() {
- return this.noteType === constants.COMMENT ? __('Comment') : __('Start thread');
+ return this.noteType === constants.COMMENT
+ ? this.$options.i18n.comment
+ : this.$options.i18n.startThread;
},
startDiscussionDescription() {
return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
- ? __('Discuss a specific suggestion or question that needs to be resolved.')
- : __('Discuss a specific suggestion or question.');
+ ? this.$options.i18n.discussionThatNeedsResolution
+ : this.$options.i18n.discussion;
+ },
+ commentDescription() {
+ return sprintf(this.$options.i18n.submitButton.commentHelp, {
+ noteableDisplayName: this.noteableDisplayName,
+ });
},
isOpen() {
return this.openState === constants.OPENED || this.openState === constants.REOPENED;
@@ -90,21 +126,18 @@ export default {
const openOrClose = this.isOpen ? 'close' : 'reopen';
if (this.note.length) {
- return sprintf(__('%{actionText} & %{openOrClose} %{noteable}'), {
+ return sprintf(this.$options.i18n.actionButtonWithNote, {
actionText: this.commentButtonTitle,
openOrClose,
noteable: this.noteableDisplayName,
});
}
- return sprintf(__('%{openOrClose} %{noteable}'), {
+ return sprintf(this.$options.i18n.actionButton, {
openOrClose: capitalizeFirstCharacter(openOrClose),
noteable: this.noteableDisplayName,
});
},
- buttonVariant() {
- return this.isOpen ? 'warning' : 'default';
- },
actionButtonClassNames() {
return {
'btn-reopen': !this.isOpen,
@@ -140,8 +173,8 @@ export default {
},
issuableTypeTitle() {
return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
- ? __('merge request')
- : __('issue');
+ ? this.$options.i18n.mergeRequest
+ : this.$options.i18n.issue;
},
isIssue() {
return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE;
@@ -149,9 +182,6 @@ export default {
trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
},
- hasCloseAndCommentButton() {
- return !this.glFeatures.removeCommentCloseReopen;
- },
confidentialNotesEnabled() {
return Boolean(this.glFeatures.confidentialNotes);
},
@@ -177,11 +207,19 @@ export default {
'reopenIssuable',
'toggleIssueLocalState',
]),
+ handleSaveError({ data, status }) {
+ if (status === UNPROCESSABLE_ENTITY && data.errors?.commands_only?.length) {
+ this.errors = data.errors.commands_only;
+ } else {
+ this.errors = [this.$options.i18n.GENERIC_UNSUBMITTABLE_NETWORK];
+ }
+ },
handleSave(withIssueAction) {
+ this.errors = [];
+
if (this.note.length) {
const noteData = {
endpoint: this.endpoint,
- flashContainer: this.$el,
data: {
note: {
noteable_type: this.noteableType,
@@ -212,12 +250,10 @@ export default {
this.toggleIssueState();
}
})
- .catch(() => {
+ .catch(({ response }) => {
+ this.handleSaveError(response);
+
this.discard(false);
- const msg = __(
- 'Your comment could not be submitted! Please check your network connection and try again.',
- );
- Flash(msg, 'alert', this.$el);
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
})
@@ -260,6 +296,12 @@ export default {
setNoteType(type) {
this.noteType = type;
},
+ setNoteTypeToComment() {
+ this.setNoteType(constants.COMMENT);
+ },
+ setNoteTypeToDiscussion() {
+ this.setNoteType(constants.DISCUSSION);
+ },
editCurrentUserLastNote() {
if (this.note === '') {
const lastNote = this.getCurrentUserLastNote;
@@ -276,7 +318,7 @@ export default {
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
this.autosave = new Autosave($(this.$refs.textarea), [
- __('Note'),
+ this.$options.i18n.note,
noteableType,
this.getNoteableData.id,
]);
@@ -290,6 +332,9 @@ export default {
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
+ dismissError(index) {
+ this.errors.splice(index, 1);
+ },
},
};
</script>
@@ -300,7 +345,15 @@ export default {
<discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="issuableTypeTitle" />
<ul v-else-if="canCreateNote" class="notes notes-form timeline">
<timeline-entry-item class="note-form">
- <div class="flash-container error-alert timeline-content"></div>
+ <gl-alert
+ v-for="(error, index) in errors"
+ :key="index"
+ variant="danger"
+ class="gl-mb-2"
+ @dismiss="() => dismissError(index)"
+ >
+ {{ error }}
+ </gl-alert>
<div class="timeline-content timeline-content-form">
<form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form">
<comment-field-layout
@@ -329,8 +382,8 @@ export default {
data-qa-selector="comment_field"
data-testid="comment-field"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete"
- :aria-label="__('Description')"
- :placeholder="__('Write a comment or drag your files here…')"
+ :aria-label="$options.i18n.comment"
+ :placeholder="$options.i18n.bodyPlaceholder"
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()"
@keydown.ctrl.enter="handleSave()"
@@ -345,87 +398,52 @@ export default {
class="gl-mb-6"
data-testid="confidential-note-checkbox"
>
- {{ s__('Notes|Make this comment confidential') }}
+ {{ $options.i18n.confidential }}
<gl-icon
v-gl-tooltip:tooltipcontainer.bottom
name="question"
:size="16"
- :title="s__('Notes|Confidential comments are only visible to project members')"
+ :title="$options.i18n.confidentialVisibility"
class="gl-text-gray-500"
/>
</gl-form-checkbox>
- <div
- class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
+ <gl-dropdown
+ split
+ :text="commentButtonTitle"
+ class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown"
+ category="primary"
+ variant="success"
+ :disabled="disableSubmitButton"
+ data-testid="comment-button"
+ data-qa-selector="comment_button"
+ :data-track-label="trackingLabel"
+ data-track-event="click_button"
+ @click="handleSave()"
>
- <gl-button
- :disabled="disableSubmitButton"
- class="js-comment-button js-comment-submit-button"
- data-qa-selector="comment_button"
- data-testid="comment-button"
- type="submit"
- category="primary"
- variant="success"
- :data-track-label="trackingLabel"
- data-track-event="click_button"
- @click.prevent="handleSave()"
- >{{ commentButtonTitle }}</gl-button
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeComment"
+ :selected="isNoteTypeComment"
+ @click="setNoteTypeToComment"
>
- <gl-button
- :disabled="disableSubmitButton"
- name="button"
- category="primary"
- variant="success"
- class="note-type-toggle js-note-new-discussion dropdown-toggle"
- data-qa-selector="note_dropdown"
- data-display="static"
- data-toggle="dropdown"
- icon="chevron-down"
- :aria-label="__('Open comment type dropdown')"
- />
-
- <ul class="note-type-dropdown dropdown-open-top dropdown-menu">
- <li :class="{ 'droplab-item-selected': noteType === 'comment' }">
- <button
- type="button"
- class="btn btn-transparent"
- @click.prevent="setNoteType('comment')"
- >
- <gl-icon name="check" class="icon gl-flex-shrink-0" />
- <div class="description">
- <strong>{{ __('Comment') }}</strong>
- <p>
- {{
- sprintf(__('Add a general comment to this %{noteableDisplayName}.'), {
- noteableDisplayName,
- })
- }}
- </p>
- </div>
- </button>
- </li>
- <li class="divider droplab-item-ignore"></li>
- <li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
- <button
- type="button"
- class="btn btn-transparent"
- data-qa-selector="discussion_menu_item"
- @click.prevent="setNoteType('discussion')"
- >
- <gl-icon name="check" class="icon gl-flex-shrink-0" />
- <div class="description">
- <strong>{{ __('Start thread') }}</strong>
- <p>{{ startDiscussionDescription }}</p>
- </div>
- </button>
- </li>
- </ul>
- </div>
-
+ <strong>{{ $options.i18n.submitButton.comment }}</strong>
+ <p class="gl-m-0">{{ commentDescription }}</p>
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeDiscussion"
+ :selected="isNoteTypeDiscussion"
+ data-qa-selector="discussion_menu_item"
+ @click="setNoteTypeToDiscussion"
+ >
+ <strong>{{ $options.i18n.submitButton.startThread }}</strong>
+ <p class="gl-m-0">{{ startDiscussionDescription }}</p>
+ </gl-dropdown-item>
+ </gl-dropdown>
<gl-button
- v-if="hasCloseAndCommentButton && canToggleIssueState"
+ v-if="canToggleIssueState"
:loading="isToggleStateButtonLoading"
- category="secondary"
- :variant="buttonVariant"
:class="[actionButtonClassNames, 'btn-comment btn-comment-and-close']"
:disabled="isSubmitting"
data-testid="close-reopen-button"
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 27408bc3354..6f0745d4fb0 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -50,8 +50,8 @@ export default {
<div class="discussion-with-resolve-btn clearfix">
<reply-placeholder
data-qa-selector="discussion_reply_tab"
- :button-text="s__('MergeRequests|Reply...')"
- @onClick="$emit('showReplyForm')"
+ :placeholder-text="__('Reply…')"
+ @focus="$emit('showReplyForm')"
/>
<div v-if="userCanResolveDiscussion" class="btn-group discussion-actions" role="group">
diff --git a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
index dfeda4aae7c..663163a7552 100644
--- a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
@@ -20,7 +20,7 @@ export default {
'li',
{
class:
- 'discussion-collapsible gl-border-solid gl-border-gray-100 gl-border-1 gl-rounded-base gl-overflow-hidden clearfix',
+ 'discussion-collapsible gl-border-solid gl-border-gray-100 gl-border-1 gl-rounded-base clearfix',
},
[h('ul', { class: 'notes' }, children)],
);
diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
index 0204169214b..1165a869d2b 100644
--- a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
+++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
@@ -1,23 +1,30 @@
<script>
+import { __ } from '~/locale';
+
export default {
name: 'ReplyPlaceholder',
props: {
- buttonText: {
+ placeholderText: {
+ type: String,
+ required: false,
+ default: __('Reply…'),
+ },
+ labelText: {
type: String,
- required: true,
+ required: false,
+ default: __('Reply to comment'),
},
},
};
</script>
<template>
- <button
- ref="button"
- type="button"
- class="js-vue-discussion-reply btn btn-text-field"
- :title="s__('MergeRequests|Add a reply')"
- @click="$emit('onClick')"
- >
- {{ buttonText }}
- </button>
+ <textarea
+ ref="textarea"
+ rows="1"
+ class="reply-placeholder-text-field js-vue-discussion-reply"
+ :placeholder="placeholderText"
+ :aria-label="labelText"
+ @focus="$emit('focus')"
+ ></textarea>
</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 907a4316a93..ed6701b34e8 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,12 +1,14 @@
<script>
import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
+import { mapActions, mapGetters } from 'vuex';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
import { deprecatedCreateFlash as flash } from '~/flash';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import eventHub from '~/sidebar/event_hub';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { splitCamelCase } from '../../lib/utils/text_utility';
import ReplyButton from './note_actions/reply_button.vue';
@@ -17,11 +19,13 @@ export default {
ReplyButton,
GlButton,
GlDropdownItem,
+ UserAccessRoleBadge,
+ EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [resolvedStatusMixin],
+ mixins: [resolvedStatusMixin, glFeatureFlagsMixin()],
props: {
author: {
type: Object,
@@ -115,6 +119,10 @@ export default {
type: Boolean,
required: true,
},
+ awardPath: {
+ type: String,
+ required: true,
+ },
},
computed: {
...mapGetters(['getUserDataByProp', 'getNoteableData']),
@@ -183,6 +191,7 @@ export default {
},
},
methods: {
+ ...mapActions(['toggleAwardRequest']),
onEdit() {
this.$emit('handleEdit');
},
@@ -220,30 +229,43 @@ export default {
.catch(() => flash(__('Something went wrong while updating assignees')));
}
},
+ setAwardEmoji(awardName) {
+ this.toggleAwardRequest({
+ endpoint: this.awardPath,
+ noteId: this.noteId,
+ awardName,
+ });
+ },
},
};
</script>
<template>
<div class="note-actions">
- <span
+ <user-access-role-badge
v-if="isAuthor"
- class="note-role user-access-role has-tooltip d-none d-md-inline-block"
+ v-gl-tooltip
+ class="gl-mx-3 d-none d-md-inline-block"
:title="displayAuthorBadgeText"
- >{{ __('Author') }}</span
>
- <span
+ {{ __('Author') }}
+ </user-access-role-badge>
+ <user-access-role-badge
v-if="accessLevel"
- class="note-role user-access-role has-tooltip"
+ v-gl-tooltip
+ class="gl-mx-3"
:title="displayMemberBadgeText"
- >{{ accessLevel }}</span
>
- <span
+ {{ accessLevel }}
+ </user-access-role-badge>
+ <user-access-role-badge
v-else-if="isContributor"
- class="note-role user-access-role has-tooltip"
+ v-gl-tooltip
+ class="gl-mx-3"
:title="displayContributorBadgeText"
- >{{ __('Contributor') }}</span
>
+ {{ __('Contributor') }}
+ </user-access-role-badge>
<gl-button
v-if="canResolve"
ref="resolveButton"
@@ -259,19 +281,41 @@ export default {
class="line-resolve-btn note-action-button"
@click="onResolve"
/>
- <a
- v-if="canAwardEmoji"
- v-gl-tooltip
- :class="{ 'js-user-authored': isAuthoredByCurrentUser }"
- class="note-action-button note-emoji-button js-add-award js-note-emoji gl-text-gray-600 gl-m-2"
- href="#"
- title="Add reaction"
- data-position="right"
- >
- <gl-icon class="link-highlight award-control-icon-neutral" name="slight-smile" />
- <gl-icon class="link-highlight award-control-icon-positive" name="smiley" />
- <gl-icon class="link-highlight award-control-icon-super-positive" name="smile" />
- </a>
+ <template v-if="canAwardEmoji">
+ <emoji-picker
+ v-if="glFeatures.improvedEmojiPicker"
+ toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-2 gl-p-0! gl-shadow-none! gl-bg-transparent!"
+ @click="setAwardEmoji"
+ >
+ <template #button-content>
+ <gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" />
+ <gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" />
+ <gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" />
+ </template>
+ </emoji-picker>
+ <gl-button
+ v-else
+ v-gl-tooltip
+ :class="{ 'js-user-authored': isAuthoredByCurrentUser }"
+ class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji"
+ category="tertiary"
+ variant="default"
+ size="small"
+ title="Add reaction"
+ data-position="right"
+ :aria-label="__('Add reaction')"
+ >
+ <span class="reaction-control-icon reaction-control-icon-neutral">
+ <gl-icon name="slight-smile" />
+ </span>
+ <span class="reaction-control-icon reaction-control-icon-positive">
+ <gl-icon name="smiley" />
+ </span>
+ <span class="reaction-control-icon reaction-control-icon-super-positive">
+ <gl-icon name="smile" />
+ </span>
+ </gl-button>
+ </template>
<reply-button
v-if="showReply"
ref="replyButton"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 653bc450d0b..d74ade15de1 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -201,7 +201,7 @@ export default {
changedCommentText() {
return sprintf(
__(
- 'This comment has changed since you started editing, please review the %{startTag}updated comment%{endTag} to ensure information is not lost.',
+ 'This comment changed after you started editing it. Review the %{startTag}updated comment%{endTag} to ensure information is not lost.',
),
{
startTag: `<a href="${this.noteHash}" target="_blank" rel="noopener noreferrer">`,
@@ -345,7 +345,7 @@ export default {
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field"
dir="auto"
- :aria-label="__('Description')"
+ :aria-label="__('Reply to comment')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="handleKeySubmit()"
@keydown.ctrl.enter="handleKeySubmit()"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 4343fac3cfa..185f4a70367 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -1,7 +1,7 @@
<script>
import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
-import { escape } from 'lodash';
+import { escape, isEmpty } from 'lodash';
import { mapGetters, mapActions } from 'vuex';
import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
import httpStatusCodes from '~/lib/utils/http_status';
@@ -282,9 +282,13 @@ export default {
note: {
target_type: this.getNoteableData.targetType,
target_id: this.note.noteable_id,
- note: { note: noteText, position: JSON.stringify(position) },
+ note: { note: noteText },
},
};
+
+ // Stringifying an empty object yields `{}` which breaks graphql queries
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/298827
+ if (!isEmpty(position)) data.note.note.position = JSON.stringify(position);
this.isRequesting = true;
this.oldContent = this.note.note_html;
// eslint-disable-next-line vue/no-mutating-props
@@ -416,6 +420,7 @@ export default {
:is-draft="note.isDraft"
:resolve-discussion="note.isDraft && note.resolve_discussion"
:discussion-id="discussionId"
+ :award-path="note.toggle_award_path"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
@handleResolve="resolveHandler"
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 2d66e0d24e3..58cfd150659 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -17,6 +17,7 @@ import commentForm from './comment_form.vue';
import discussionFilterNote from './discussion_filter_note.vue';
import noteableDiscussion from './noteable_discussion.vue';
import noteableNote from './noteable_note.vue';
+import SidebarSubscription from './sidebar_subscription.vue';
export default {
name: 'NotesApp',
@@ -30,6 +31,7 @@ export default {
skeletonLoadingContainer,
discussionFilterNote,
OrderedLayout,
+ SidebarSubscription,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -261,6 +263,7 @@ export default {
<template>
<div v-show="shouldShow" id="notes">
+ <sidebar-subscription :iid="noteableData.iid" :noteable-data="noteableData" />
<ordered-layout :slot-keys="slotKeys">
<template #form>
<comment-form
diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue
new file mode 100644
index 00000000000..047c04c8482
--- /dev/null
+++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue
@@ -0,0 +1,58 @@
+<script>
+import { mapActions } from 'vuex';
+import { IssuableType } from '~/issue_show/constants';
+import { fetchPolicies } from '~/lib/graphql';
+import { confidentialityQueries } from '~/sidebar/constants';
+import { defaultClient as gqlClient } from '~/sidebar/graphql';
+
+export default {
+ props: {
+ noteableData: {
+ type: Object,
+ required: true,
+ },
+ iid: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ fullPath() {
+ if (this.noteableData.web_url) {
+ return this.noteableData.web_url.split('/-/')[0].substring(1).replace('groups/', '');
+ }
+ return null;
+ },
+ issuableType() {
+ return this.noteableData.noteableType.toLowerCase();
+ },
+ },
+ created() {
+ if (this.issuableType !== IssuableType.Issue && this.issuableType !== IssuableType.Epic) {
+ return;
+ }
+
+ gqlClient
+ .watchQuery({
+ query: confidentialityQueries[this.issuableType].query,
+ variables: {
+ iid: String(this.iid),
+ fullPath: this.fullPath,
+ },
+ fetchPolicy: fetchPolicies.CACHE_ONLY,
+ })
+ .subscribe((res) => {
+ const issuable = res.data?.workspace?.issuable;
+ if (issuable) {
+ this.setConfidentiality(issuable.confidential);
+ }
+ });
+ },
+ methods: {
+ ...mapActions(['setConfidentiality']),
+ },
+ render() {
+ return null;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js
new file mode 100644
index 00000000000..1ffb94d11ad
--- /dev/null
+++ b/app/assets/javascripts/notes/i18n.js
@@ -0,0 +1,26 @@
+import { __, s__ } from '~/locale';
+
+export const COMMENT_FORM = {
+ GENERIC_UNSUBMITTABLE_NETWORK: __(
+ 'Your comment could not be submitted! Please check your network connection and try again.',
+ ),
+ note: __('Note'),
+ comment: __('Comment'),
+ issue: __('issue'),
+ startThread: __('Start thread'),
+ mergeRequest: __('merge request'),
+ bodyPlaceholder: __('Write a comment or drag your files here…'),
+ confidential: s__('Notes|Make this comment confidential'),
+ confidentialVisibility: s__('Notes|Confidential comments are only visible to project members'),
+ discussionThatNeedsResolution: __(
+ 'Discuss a specific suggestion or question that needs to be resolved.',
+ ),
+ discussion: __('Discuss a specific suggestion or question.'),
+ actionButtonWithNote: __('%{actionText} & %{openOrClose} %{noteable}'),
+ actionButton: __('%{openOrClose} %{noteable}'),
+ submitButton: {
+ startThread: __('Start thread'),
+ comment: __('Comment'),
+ commentHelp: __('Add a general comment to this %{noteableDisplayName}.'),
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 19403c29cda..1204d68159f 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -2,9 +2,10 @@ import $ from 'jquery';
import Visibility from 'visibilityjs';
import Vue from 'vue';
import Api from '~/api';
+import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
-import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql';
+import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql';
import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql';
import loadAwardsHandler from '../../awards_handler';
@@ -267,7 +268,7 @@ export const toggleStateButtonLoading = ({ commit }, value) =>
commit(types.TOGGLE_STATE_BUTTON_LOADING, value);
export const emitStateChangedEvent = ({ getters }, data) => {
- const event = new CustomEvent('issuable_vue_app:change', {
+ const event = new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, {
detail: {
data,
isClosed: getters.openState === constants.CLOSED,
@@ -340,6 +341,15 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
if (hasQuickActions && message) {
eTagPoll.makeRequest();
+ // synchronizing the quick action with the sidebar widget
+ // this is a temporary solution until we have confidentiality real-time updates
+ if (
+ confidentialWidget.setConfidentiality &&
+ message.some((m) => m.includes('confidential'))
+ ) {
+ confidentialWidget.setConfidentiality();
+ }
+
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
Flash(message || __('Commands applied'), 'notice', noteData.flashContainer);
@@ -719,33 +729,3 @@ export const updateAssignees = ({ commit }, assignees) => {
export const updateDiscussionPosition = ({ commit }, updatedPosition) => {
commit(types.UPDATE_DISCUSSION_POSITION, updatedPosition);
};
-
-export const updateConfidentialityOnIssuable = (
- { getters, commit },
- { 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, errors },
- } = data;
-
- if (errors?.length) {
- Flash(errors[0], 'alert');
- } else {
- setConfidentiality({ commit }, issue.confidential);
- }
- });
-};
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
index 627e405c75c..592e634e034 100644
--- a/app/assets/javascripts/notes/stores/utils.js
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -1,4 +1,4 @@
-import { trimFirstCharOfLineContent } from '~/diffs/store/utils';
+import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; // eslint-disable-line import/no-deprecated
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import AjaxCache from '~/lib/utils/ajax_cache';
import { sprintf, __ } from '~/locale';
@@ -34,7 +34,7 @@ export const hasQuickActions = (note) => createQuickActionsRegex().test(note);
export const stripQuickActions = (note) => note.replace(createQuickActionsRegex(), '').trim();
export const prepareDiffLines = (diffLines) =>
- diffLines.map((line) => ({ ...trimFirstCharOfLineContent(line) }));
+ diffLines.map((line) => ({ ...trimFirstCharOfLineContent(line) })); // eslint-disable-line import/no-deprecated
export const gqClient = createGqClient(
{},
diff --git a/app/assets/javascripts/notifications/components/custom_notifications_modal.vue b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue
index 0f628897e17..2b5cff35fc8 100644
--- a/app/assets/javascripts/notifications/components/custom_notifications_modal.vue
+++ b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue
@@ -24,12 +24,21 @@ export default {
default: '',
},
},
+ model: {
+ prop: 'visible',
+ event: 'change',
+ },
props: {
modalId: {
type: String,
required: false,
default: 'custom-notifications-modal',
},
+ visible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -95,9 +104,11 @@ export default {
<template>
<gl-modal
ref="modal"
+ :visible="visible"
:modal-id="modalId"
:title="$options.i18n.customNotificationsModal.title"
@show="onOpen"
+ v-on="$listeners"
>
<div class="container-fluid">
<div class="row">
@@ -115,7 +126,11 @@ export default {
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<template v-else>
<gl-form-group v-for="event in events" :key="event.id">
- <gl-form-checkbox v-model="event.enabled" @change="updateEvent($event, event)">
+ <gl-form-checkbox
+ v-model="event.enabled"
+ :data-testid="`notification-setting-${event.id}`"
+ @change="updateEvent($event, event)"
+ >
<strong>{{ event.name }}</strong
><gl-loading-icon v-if="event.loading" :inline="true" class="gl-ml-2" />
</gl-form-checkbox>
diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown.vue b/app/assets/javascripts/notifications/components/notifications_dropdown.vue
index e4cedfdb810..4963b9386c1 100644
--- a/app/assets/javascripts/notifications/components/notifications_dropdown.vue
+++ b/app/assets/javascripts/notifications/components/notifications_dropdown.vue
@@ -1,12 +1,5 @@
<script>
-import {
- GlButtonGroup,
- GlButton,
- GlDropdown,
- GlDropdownDivider,
- GlTooltipDirective,
- GlModalDirective,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownDivider, GlTooltipDirective } from '@gitlab/ui';
import Api from '~/api';
import { sprintf } from '~/locale';
import { CUSTOM_LEVEL, i18n } from '../constants';
@@ -16,8 +9,6 @@ import NotificationsDropdownItem from './notifications_dropdown_item.vue';
export default {
name: 'NotificationsDropdown',
components: {
- GlButtonGroup,
- GlButton,
GlDropdown,
GlDropdownDivider,
NotificationsDropdownItem,
@@ -25,7 +16,6 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
- 'gl-modal': GlModalDirective,
},
inject: {
containerClass: {
@@ -57,6 +47,7 @@ export default {
return {
selectedNotificationLevel: this.initialNotificationLevel,
isLoading: false,
+ notificationsModalVisible: false,
};
},
computed: {
@@ -95,6 +86,11 @@ export default {
},
},
methods: {
+ openNotificationsModal() {
+ if (this.isCustomNotification) {
+ this.notificationsModalVisible = true;
+ }
+ },
selectItem(level) {
if (level !== this.selectedNotificationLevel) {
this.updateNotificationLevel(level);
@@ -106,10 +102,7 @@ export default {
try {
await Api.updateNotificationSettings(this.projectId, this.groupId, { level });
this.selectedNotificationLevel = level;
-
- if (level === CUSTOM_LEVEL) {
- this.$refs.customNotificationsModal.open();
- }
+ this.openNotificationsModal();
} catch (error) {
this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' });
} finally {
@@ -125,52 +118,16 @@ export default {
<template>
<div :class="containerClass">
- <gl-button-group
- v-if="isCustomNotification"
- v-gl-tooltip="{ title: buttonTooltip }"
- data-testid="notificationButton"
- :size="buttonSize"
- >
- <gl-button
- v-gl-modal="$options.modalId"
- :size="buttonSize"
- :icon="buttonIcon"
- :loading="isLoading"
- :disabled="disabled"
- >
- <template v-if="buttonText">{{ buttonText }}</template>
- </gl-button>
- <gl-dropdown :size="buttonSize" :disabled="disabled">
- <notifications-dropdown-item
- v-for="item in notificationLevels"
- :key="item.level"
- :level="item.level"
- :title="item.title"
- :description="item.description"
- :notification-level="selectedNotificationLevel"
- @item-selected="selectItem"
- />
- <gl-dropdown-divider />
- <notifications-dropdown-item
- :key="$options.customLevel"
- :level="$options.customLevel"
- :title="$options.i18n.notificationTitles.custom"
- :description="$options.i18n.notificationDescriptions.custom"
- :notification-level="selectedNotificationLevel"
- @item-selected="selectItem"
- />
- </gl-dropdown>
- </gl-button-group>
-
<gl-dropdown
- v-else
v-gl-tooltip="{ title: buttonTooltip }"
- data-testid="notificationButton"
- :text="buttonText"
+ data-testid="notification-dropdown"
+ :size="buttonSize"
:icon="buttonIcon"
:loading="isLoading"
- :size="buttonSize"
:disabled="disabled"
+ :split="isCustomNotification"
+ :text="buttonText"
+ @click="openNotificationsModal"
>
<notifications-dropdown-item
v-for="item in notificationLevels"
@@ -191,6 +148,6 @@ export default {
@item-selected="selectItem"
/>
</gl-dropdown>
- <custom-notifications-modal ref="customNotificationsModal" :modal-id="$options.modalId" />
+ <custom-notifications-modal v-model="notificationsModalVisible" :modal-id="$options.modalId" />
</div>
</template>
diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue
index 73bb9c1b36f..2138372d8ad 100644
--- a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue
+++ b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue
@@ -33,7 +33,13 @@ export default {
</script>
<template>
- <gl-dropdown-item is-check-item :is-checked="isActive" @click="$emit('item-selected', level)">
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isActive"
+ :class="{ 'is-active': isActive }"
+ data-testid="notification-item"
+ @click="$emit('item-selected', level)"
+ >
<div class="gl-display-flex gl-flex-direction-column">
<span class="gl-font-weight-bold">{{ title }}</span>
<span class="gl-text-gray-500">{{ description }}</span>
diff --git a/app/assets/javascripts/notifications/constants.js b/app/assets/javascripts/notifications/constants.js
index 07c569a0293..4f875977d78 100644
--- a/app/assets/javascripts/notifications/constants.js
+++ b/app/assets/javascripts/notifications/constants.js
@@ -22,10 +22,10 @@ export const i18n = {
owner_disabled: __('Notifications have been disabled by the project or group owner'),
},
updateNotificationLevelErrorMessage: __(
- 'An error occured while updating the notification settings. Please try again.',
+ 'An error occurred while updating the notification settings. Please try again.',
),
loadNotificationLevelErrorMessage: __(
- 'An error occured while loading the notification settings. Please try again.',
+ 'An error occurred while loading the notification settings. Please try again.',
),
customNotificationsModal: {
title: __('Custom notification events'),
@@ -53,6 +53,7 @@ export const i18n = {
reassign_merge_request: s__('NotificationEvent|Reassign merge request'),
reopen_issue: s__('NotificationEvent|Reopen issue'),
reopen_merge_request: s__('NotificationEvent|Reopen merge request'),
+ merge_when_pipeline_succeeds: s__('NotificationEvent|Merge when pipeline succeeds'),
success_pipeline: s__('NotificationEvent|Successful pipeline'),
},
};
diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js
deleted file mode 100644
index d61defed14d..00000000000
--- a/app/assets/javascripts/notifications_dropdown.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import $ from 'jquery';
-import { Rails } from '~/lib/utils/rails_ujs';
-import { __ } from '~/locale';
-import { deprecatedCreateFlash as Flash } from './flash';
-
-export default function notificationsDropdown() {
- $(document).on('click', '.update-notification', function updateNotificationCallback(e) {
- e.preventDefault();
-
- if ($(this).is('.is-active') && $(this).data('notificationLevel') === 'custom') {
- return;
- }
-
- const notificationLevel = $(this).data('notificationLevel');
- const form = $(this).parents('.notification-form').first();
-
- form.find('.js-notification-loading').toggleClass('spinner');
- if (form.hasClass('no-label')) {
- form.find('.js-notification-loading').toggleClass('hidden');
- form.find('.js-notifications-icon').toggleClass('hidden');
- }
- form.find('#notification_setting_level').val(notificationLevel);
- Rails.fire(form[0], 'submit');
- });
-
- $(document).on('ajax:success', '.notification-form', (e) => {
- const data = e.detail[0];
-
- if (data.saved) {
- $(e.currentTarget).closest('.js-notification-dropdown').replaceWith(data.html);
- } else {
- Flash(__('Failed to save new settings'), 'alert');
- }
- });
-}
diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js
deleted file mode 100644
index 8b90da71bef..00000000000
--- a/app/assets/javascripts/notifications_form.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import $ from 'jquery';
-import { deprecatedCreateFlash as flash } from './flash';
-import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
-
-export default class NotificationsForm {
- constructor() {
- this.toggleCheckbox = this.toggleCheckbox.bind(this);
- this.initEventListeners();
- }
-
- initEventListeners() {
- $(document).on('change', '.js-custom-notification-event', this.toggleCheckbox);
- }
-
- toggleCheckbox(e) {
- const $checkbox = $(e.currentTarget);
- const $parent = $checkbox.closest('.form-check');
-
- this.saveEvent($checkbox, $parent);
- }
-
- // eslint-disable-next-line class-methods-use-this
- showCheckboxLoadingSpinner($parent) {
- $parent.find('.is-loading').removeClass('gl-display-none');
- $parent.find('.is-done').addClass('gl-display-none');
- }
-
- saveEvent($checkbox, $parent) {
- const form = $parent.parents('form').first();
-
- this.showCheckboxLoadingSpinner($parent);
-
- axios[form.attr('method')](form.attr('action'), form.serialize())
- .then(({ data }) => {
- $checkbox.enable();
- if (data.saved) {
- $parent.find('.is-loading').addClass('gl-display-none');
- $parent.find('.is-done').removeClass('gl-display-none');
-
- setTimeout(() => {
- $parent.find('.is-done').addClass('gl-display-none');
- }, 2000);
- }
- })
- .catch(() => flash(__('There was an error saving your notification settings.')));
- }
-}
diff --git a/app/assets/javascripts/packages/details/components/composer_installation.vue b/app/assets/javascripts/packages/details/components/composer_installation.vue
index 9d87ae8f836..bf1e5083e12 100644
--- a/app/assets/javascripts/packages/details/components/composer_installation.vue
+++ b/app/assets/javascripts/packages/details/components/composer_installation.vue
@@ -2,12 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'ComposerInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
@@ -27,12 +29,13 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [{ value: 'composer', label: s__('PackageRegistry|Show Composer commands') }],
};
</script>
<template>
<div v-if="groupExists" data-testid="root-node">
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title package-type="composer" :options="$options.installOptions" />
<code-instruction
:label="$options.i18n.registryInclude"
diff --git a/app/assets/javascripts/packages/details/components/conan_installation.vue b/app/assets/javascripts/packages/details/components/conan_installation.vue
index a5df87c9c5b..1d855f6cf3e 100644
--- a/app/assets/javascripts/packages/details/components/conan_installation.vue
+++ b/app/assets/javascripts/packages/details/components/conan_installation.vue
@@ -2,12 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'ConanInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
@@ -23,12 +25,13 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [{ value: 'conan', label: s__('PackageRegistry|Show Conan commands') }],
};
</script>
<template>
<div>
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title package-type="conan" :options="$options.installOptions" />
<code-instruction
:label="s__('PackageRegistry|Conan Command')"
diff --git a/app/assets/javascripts/packages/details/components/installation_title.vue b/app/assets/javascripts/packages/details/components/installation_title.vue
new file mode 100644
index 00000000000..43133bf7825
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/installation_title.vue
@@ -0,0 +1,38 @@
+<script>
+import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue';
+
+export default {
+ name: 'InstallationTitle',
+ components: {
+ PersistedDropdownSelection,
+ },
+ props: {
+ packageType: {
+ type: String,
+ required: true,
+ },
+ options: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ storageKey() {
+ return `package_${this.packageType}_installation_instructions`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
+ <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <div>
+ <persisted-dropdown-selection
+ :storage-key="storageKey"
+ :options="options"
+ @change="$emit('change', $event)"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages/details/components/maven_installation.vue b/app/assets/javascripts/packages/details/components/maven_installation.vue
index c2f6f76967b..b9532cb7e72 100644
--- a/app/assets/javascripts/packages/details/components/maven_installation.vue
+++ b/app/assets/javascripts/packages/details/components/maven_installation.vue
@@ -2,19 +2,36 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
+
import { TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'MavenInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
},
+ data() {
+ return {
+ instructionType: 'maven',
+ };
+ },
computed: {
...mapState(['mavenHelpPath']),
- ...mapGetters(['mavenInstallationXml', 'mavenInstallationCommand', 'mavenSetupXml']),
+ ...mapGetters([
+ 'mavenInstallationXml',
+ 'mavenInstallationCommand',
+ 'mavenSetupXml',
+ 'gradleGroovyInstalCommand',
+ 'gradleGroovyAddSourceCommand',
+ ]),
+ showMaven() {
+ return this.instructionType === 'maven';
+ },
},
i18n: {
xmlText: s__(
@@ -29,57 +46,84 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [
+ { value: 'maven', label: s__('PackageRegistry|Show Maven commands') },
+ { value: 'groovy', label: s__('PackageRegistry|Show Gradle Groovy DSL commands') },
+ ],
};
</script>
<template>
<div>
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title
+ package-type="maven"
+ :options="$options.installOptions"
+ @change="instructionType = $event"
+ />
- <p>
- <gl-sprintf :message="$options.i18n.xmlText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
+ <template v-if="showMaven">
+ <p>
+ <gl-sprintf :message="$options.i18n.xmlText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
- <code-instruction
- :label="s__('PackageRegistry|Maven XML')"
- :instruction="mavenInstallationXml"
- :copy-text="s__('PackageRegistry|Copy Maven XML')"
- multiline
- :tracking-action="$options.trackingActions.COPY_MAVEN_XML"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
+ <code-instruction
+ :instruction="mavenInstallationXml"
+ :copy-text="s__('PackageRegistry|Copy Maven XML')"
+ :tracking-action="$options.trackingActions.COPY_MAVEN_XML"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ multiline
+ />
- <code-instruction
- :label="s__('PackageRegistry|Maven Command')"
- :instruction="mavenInstallationCommand"
- :copy-text="s__('PackageRegistry|Copy Maven command')"
- :tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
+ <code-instruction
+ :label="s__('PackageRegistry|Maven Command')"
+ :instruction="mavenInstallationCommand"
+ :copy-text="s__('PackageRegistry|Copy Maven command')"
+ :tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ />
- <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
- <p>
- <gl-sprintf :message="$options.i18n.setupText">
- <template #code="{ content }">
- <code>{{ content }}</code>
+ <h3 class="gl-font-lg">{{ s__('PackageRegistry|Registry setup') }}</h3>
+ <p>
+ <gl-sprintf :message="$options.i18n.setupText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <code-instruction
+ :instruction="mavenSetupXml"
+ :copy-text="s__('PackageRegistry|Copy Maven registry XML')"
+ :tracking-action="$options.trackingActions.COPY_MAVEN_SETUP"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ multiline
+ />
+ <gl-sprintf :message="$options.i18n.helpText">
+ <template #link="{ content }">
+ <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
- </p>
- <code-instruction
- :instruction="mavenSetupXml"
- :copy-text="s__('PackageRegistry|Copy Maven registry XML')"
- multiline
- :tracking-action="$options.trackingActions.COPY_MAVEN_SETUP"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ </template>
+ <template v-else>
+ <code-instruction
+ class="gl-mb-5"
+ :label="s__('PackageRegistry|Gradle Groovy DSL install command')"
+ :instruction="gradleGroovyInstalCommand"
+ :copy-text="s__('PackageRegistry|Copy Gradle Groovy DSL install command')"
+ :tracking-action="$options.trackingActions.COPY_GRADLE_INSTALL_COMMAND"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ />
+ <code-instruction
+ :label="s__('PackageRegistry|Add Gradle Groovy DSL repository command')"
+ :instruction="gradleGroovyAddSourceCommand"
+ :copy-text="s__('PackageRegistry|Copy add Gradle Groovy DSL repository command')"
+ :tracking-action="$options.trackingActions.COPY_GRADLE_ADD_TO_SOURCE_COMMAND"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ multiline
+ />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/packages/details/components/npm_installation.vue b/app/assets/javascripts/packages/details/components/npm_installation.vue
index 37ba279d098..18f15e2c63e 100644
--- a/app/assets/javascripts/packages/details/components/npm_installation.vue
+++ b/app/assets/javascripts/packages/details/components/npm_installation.vue
@@ -2,12 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import { NpmManager, TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'NpmInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
@@ -35,12 +37,13 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [{ value: 'npm', label: s__('PackageRegistry|Show NPM commands') }],
};
</script>
<template>
<div>
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title package-type="npm" :options="$options.installOptions" />
<code-instruction
:label="s__('PackageRegistry|npm command')"
diff --git a/app/assets/javascripts/packages/details/components/nuget_installation.vue b/app/assets/javascripts/packages/details/components/nuget_installation.vue
index 36887703716..d5e64722f24 100644
--- a/app/assets/javascripts/packages/details/components/nuget_installation.vue
+++ b/app/assets/javascripts/packages/details/components/nuget_installation.vue
@@ -2,12 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'NugetInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
@@ -23,12 +25,14 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [{ value: 'nuget', label: s__('PackageRegistry|Show Nuget commands') }],
};
</script>
<template>
<div>
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title package-type="nuget" :options="$options.installOptions" />
+
<code-instruction
:label="s__('PackageRegistry|NuGet Command')"
:instruction="nugetInstallationCommand"
diff --git a/app/assets/javascripts/packages/details/components/pypi_installation.vue b/app/assets/javascripts/packages/details/components/pypi_installation.vue
index f87be68be48..fe4709d5feb 100644
--- a/app/assets/javascripts/packages/details/components/pypi_installation.vue
+++ b/app/assets/javascripts/packages/details/components/pypi_installation.vue
@@ -2,12 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'PyPiInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
@@ -26,12 +28,13 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [{ value: 'pypi', label: s__('PackageRegistry|Show PyPi commands') }],
};
</script>
<template>
<div>
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title package-type="pypi" :options="$options.installOptions" />
<code-instruction
:label="s__('PackageRegistry|Pip Command')"
diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js
index 986b0667356..f0300ee29b4 100644
--- a/app/assets/javascripts/packages/details/constants.js
+++ b/app/assets/javascripts/packages/details/constants.js
@@ -35,6 +35,9 @@ export const TrackingActions = {
COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND: 'copy_composer_registry_include_command',
COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND: 'copy_composer_package_include_command',
+
+ COPY_GRADLE_INSTALL_COMMAND: 'copy_gradle_install_command',
+ COPY_GRADLE_ADD_TO_SOURCE_COMMAND: 'copy_gradle_add_to_source_command',
};
export const NpmManager = {
diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js
index 14e76ac84bd..fb9b7d61fd2 100644
--- a/app/assets/javascripts/packages/details/store/getters.js
+++ b/app/assets/javascripts/packages/details/store/getters.js
@@ -110,4 +110,20 @@ export const composerPackageInclude = ({ packageEntity }) =>
// eslint-disable-next-line @gitlab/require-i18n-strings
`composer req ${[packageEntity.name]}:${packageEntity.version}`;
+export const gradleGroovyInstalCommand = ({ packageEntity }) => {
+ const {
+ app_group: group = '',
+ app_name: name = '',
+ app_version: version = '',
+ } = packageEntity.maven_metadatum;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `implementation '${group}:${name}:${version}'`;
+};
+
+export const gradleGroovyAddSourceCommand = ({ mavenPath }) =>
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ `maven {
+ url '${mavenPath}'
+}`;
+
export const groupExists = ({ groupListUrl }) => groupListUrl.length > 0;
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js
index d47eb8c3421..25a55200df2 100644
--- a/app/assets/javascripts/packages/list/constants.js
+++ b/app/assets/javascripts/packages/list/constants.js
@@ -71,7 +71,7 @@ export const PACKAGE_TYPES = [
type: PackageType.MAVEN,
},
{
- title: s__('PackageRegistry|NPM'),
+ title: s__('PackageRegistry|npm'),
type: PackageType.NPM,
},
{
diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue
index cdfe042c39f..172b356227a 100644
--- a/app/assets/javascripts/packages/shared/components/package_list_row.vue
+++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue
@@ -123,7 +123,7 @@ export default {
<gl-button
data-testid="action-delete"
icon="remove"
- category="primary"
+ category="secondary"
variant="danger"
:title="s__('PackageRegistry|Remove package')"
:aria-label="s__('PackageRegistry|Remove package')"
diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js
index 677550f77ec..d34372e89b6 100644
--- a/app/assets/javascripts/packages/shared/utils.js
+++ b/app/assets/javascripts/packages/shared/utils.js
@@ -14,7 +14,7 @@ export const getPackageTypeLabel = (packageType) => {
case PackageType.MAVEN:
return s__('PackageType|Maven');
case PackageType.NPM:
- return s__('PackageType|NPM');
+ return s__('PackageType|npm');
case PackageType.NUGET:
return s__('PackageType|NuGet');
case PackageType.PYPI:
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index 933cbeaedce..4f5c53ed4a3 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -111,7 +111,10 @@ export default {
{{ alertMessage }}
</gl-alert>
- <settings-block :default-expanded="defaultExpanded">
+ <settings-block
+ :default-expanded="defaultExpanded"
+ data-qa-selector="package_registry_settings_content"
+ >
<template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template>
<template #description>
<span data-testid="description">
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
index aa7c6e9d8d6..d4f51b83e1e 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
@@ -79,11 +79,12 @@ export default {
<form>
<div class="gl-display-flex">
<gl-toggle
+ data-qa-selector="allow_duplicates_toggle"
:value="mavenDuplicatesAllowed"
@change="update($options.modelNames.MAVEN_DUPLICATES_ALLOWED, $event)"
/>
<div class="gl-ml-5">
- <div data-testid="toggle-label">
+ <div data-testid="toggle-label" data-qa-selector="allow_duplicates_label">
<gl-sprintf :message="enabledButtonLabel">
<template #bold="{ content }">
<strong>{{ content }}</strong>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
index 06b57fe013f..fb06f557d66 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
@@ -9,7 +9,6 @@ export const updateGroupPackageSettings = (fullPath) => (client, { data: updated
const sourceData = client.readQuery(queryAndParams);
const data = produce(sourceData, (draftState) => {
- // eslint-disable-next-line no-param-reassign
draftState.group.packageSettings = {
...updatedData.updateNamespacePackageSettings.packageSettings,
};
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js
index 5649c47d7e8..0a4311ec73a 100644
--- a/app/assets/javascripts/pages/admin/abuse_reports/index.js
+++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js
@@ -1,8 +1,5 @@
-/* eslint-disable no-new */
import UsersSelect from '~/users_select';
import AbuseReports from './abuse_reports';
-document.addEventListener('DOMContentLoaded', () => {
- new AbuseReports();
- new UsersSelect();
-});
+new AbuseReports(); /* eslint-disable-line no-new */
+new UsersSelect(); /* eslint-disable-line no-new */
diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js
index e92262852cf..2732fc191be 100644
--- a/app/assets/javascripts/pages/admin/admin.js
+++ b/app/assets/javascripts/pages/admin/admin.js
@@ -12,8 +12,6 @@ function showDenylistType() {
}
export default function adminInit() {
- const modal = $('.change-owner-holder');
-
$('input#user_force_random_password').on('change', function randomPasswordClick() {
const $elems = $('#user_password, #user_password_confirmation');
if ($(this).attr('checked')) {
@@ -28,36 +26,6 @@ export default function adminInit() {
$('.js-toggle-colors-container').toggleClass('hide');
});
- $('.log-tabs a').on('click', function logTabsClick(e) {
- e.preventDefault();
- $(this).tab('show');
- });
-
- $('.log-bottom').on('click', (e) => {
- e.preventDefault();
- const $visibleLog = $('.file-content:visible');
-
- // eslint-disable-next-line no-jquery/no-animate
- $visibleLog.animate(
- {
- scrollTop: $visibleLog.find('ol').height(),
- },
- 'fast',
- );
- });
-
- $('.change-owner-link').on('click', function changeOwnerLinkClick(e) {
- e.preventDefault();
- $(this).hide();
- modal.show();
- });
-
- $('.change-owner-cancel-link').on('click', (e) => {
- e.preventDefault();
- modal.hide();
- $('.change-owner-link').show();
- });
-
$('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage);
$("input[name='denylist_type']").on('click', showDenylistType);
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js
index f7bd32880ff..eda1a9d3599 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js
@@ -1,25 +1,27 @@
-// This is a true violation of @gitlab/no-runtime-template-compiler, as it
-// relies on app/views/admin/application_settings/_gitpod.html.haml for its
-// template.
-/* eslint-disable @gitlab/no-runtime-template-compiler */
import Vue from 'vue';
import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
import initUserInternalRegexPlaceholder from '../account_and_limits';
-document.addEventListener('DOMContentLoaded', () => {
+(() => {
initUserInternalRegexPlaceholder();
- const gitpodSettingEl = document.querySelector('#js-gitpod-settings-help-text');
- if (!gitpodSettingEl) {
+ const el = document.querySelector('#js-gitpod-settings-help-text');
+ if (!el) {
return;
}
+ const { message, messageUrl } = el.dataset;
+
// eslint-disable-next-line no-new
new Vue({
- el: gitpodSettingEl,
- name: 'GitpodSettings',
- components: {
- IntegrationHelpText,
+ el,
+ render(createElement) {
+ return createElement(IntegrationHelpText, {
+ props: {
+ message,
+ messageUrl,
+ },
+ });
},
});
-});
+})();
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index e7e74588bec..e3c6b0f6f5b 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -4,13 +4,11 @@ import initSearchSettings from '~/search_settings';
import selfMonitor from '~/self_monitor';
import initSettingsPanels from '~/settings_panels';
-document.addEventListener('DOMContentLoaded', () => {
- if (gon.features?.ciInstanceVariablesUi) {
- initVariableList('js-instance-variables');
- }
- selfMonitor();
- // Initialize expandable settings panels
- initSettingsPanels();
- projectSelect();
- initSearchSettings();
-});
+if (gon.features?.ciInstanceVariablesUi) {
+ initVariableList('js-instance-variables');
+}
+selfMonitor();
+// Initialize expandable settings panels
+initSettingsPanels();
+projectSelect();
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/admin/dev_ops_report/index.js b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
index 220fc049562..cf06ee2c22a 100644
--- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js
+++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
@@ -1,5 +1,3 @@
-import initDevopAdoption from 'ee_else_ce/admin/dev_ops_report/devops_adoption';
-import initDevOpsScoreEmptyState from '~/admin/dev_ops_report/devops_score_empty_state';
+import initDevOpsScoreEmptyState from '~/analytics/devops_report/devops_score_empty_state';
initDevOpsScoreEmptyState();
-initDevopAdoption();
diff --git a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
index ae2209b0292..dc1bb88bf4b 100644
--- a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
+++ b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
@@ -1,3 +1,3 @@
-import initExpiresAtField from '~/access_tokens';
+import { initExpiresAtField } from '~/access_tokens';
-document.addEventListener('DOMContentLoaded', initExpiresAtField);
+initExpiresAtField();
diff --git a/app/assets/javascripts/pages/admin/index.js b/app/assets/javascripts/pages/admin/index.js
index 792a6eda14e..8d5dfd689e8 100644
--- a/app/assets/javascripts/pages/admin/index.js
+++ b/app/assets/javascripts/pages/admin/index.js
@@ -2,10 +2,8 @@ import initAdminStatisticsPanel from '../../admin/statistics_panel/index';
import initVueAlerts from '../../vue_alerts';
import initAdmin from './admin';
-document.addEventListener('DOMContentLoaded', initVueAlerts);
+initVueAlerts();
-document.addEventListener('DOMContentLoaded', () => {
- const statisticsPanelContainer = document.getElementById('js-admin-statistics-container');
- initAdmin();
- initAdminStatisticsPanel(statisticsPanelContainer);
-});
+const statisticsPanelContainer = document.getElementById('js-admin-statistics-container');
+initAdmin();
+initAdminStatisticsPanel(statisticsPanelContainer);
diff --git a/app/assets/javascripts/pages/admin/instance_statistics/index.js b/app/assets/javascripts/pages/admin/instance_statistics/index.js
deleted file mode 100644
index d6b0a834ce3..00000000000
--- a/app/assets/javascripts/pages/admin/instance_statistics/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import initInstanceStatisticsApp from '~/analytics/instance_statistics';
-
-document.addEventListener('DOMContentLoaded', () => initInstanceStatisticsApp());
diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js
index 2286a085143..042ff7808f1 100644
--- a/app/assets/javascripts/pages/admin/projects/index.js
+++ b/app/assets/javascripts/pages/admin/projects/index.js
@@ -17,12 +17,10 @@ function mountRemoveMemberModal() {
});
}
-document.addEventListener('DOMContentLoaded', () => {
- mountRemoveMemberModal();
+mountRemoveMemberModal();
- new ProjectsList(); // eslint-disable-line no-new
+new ProjectsList(); // eslint-disable-line no-new
- document
- .querySelectorAll('.js-namespace-select')
- .forEach((dropdown) => new NamespaceSelect({ dropdown }));
-});
+document
+ .querySelectorAll('.js-namespace-select')
+ .forEach((dropdown) => new NamespaceSelect({ dropdown }));
diff --git a/app/assets/javascripts/pages/admin/projects/index/index.js b/app/assets/javascripts/pages/admin/projects/index/index.js
index 1971323abac..cc9a9b6cc38 100644
--- a/app/assets/javascripts/pages/admin/projects/index/index.js
+++ b/app/assets/javascripts/pages/admin/projects/index/index.js
@@ -6,7 +6,7 @@ import Translate from '~/vue_shared/translate';
import deleteProjectModal from './components/delete_project_modal.vue';
-document.addEventListener('DOMContentLoaded', () => {
+(() => {
Vue.use(Translate);
const deleteProjectModalEl = document.getElementById('delete-project-modal');
@@ -39,4 +39,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
-});
+})();
diff --git a/app/assets/javascripts/pages/admin/runners/index.js b/app/assets/javascripts/pages/admin/runners/index.js
index f07ac1d8674..45ed3ac6bd8 100644
--- a/app/assets/javascripts/pages/admin/runners/index.js
+++ b/app/assets/javascripts/pages/admin/runners/index.js
@@ -3,12 +3,10 @@ import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
-document.addEventListener('DOMContentLoaded', () => {
- initFilteredSearch({
- page: FILTERED_SEARCH.ADMIN_RUNNERS,
- filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys,
- useDefaultState: true,
- });
-
- initInstallRunner();
+initFilteredSearch({
+ page: FILTERED_SEARCH.ADMIN_RUNNERS,
+ filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys,
+ useDefaultState: true,
});
+
+initInstallRunner();
diff --git a/app/assets/javascripts/pages/admin/services/index/index.js b/app/assets/javascripts/pages/admin/services/index/index.js
index b2dfbb5a9fc..b695cf70c5d 100644
--- a/app/assets/javascripts/pages/admin/services/index/index.js
+++ b/app/assets/javascripts/pages/admin/services/index/index.js
@@ -1,6 +1,4 @@
import PersistentUserCallout from '~/persistent_user_callout';
-document.addEventListener('DOMContentLoaded', () => {
- const callout = document.querySelector('.js-service-templates-deprecated');
- PersistentUserCallout.factory(callout);
-});
+const callout = document.querySelector('.js-service-templates-deprecated');
+PersistentUserCallout.factory(callout);
diff --git a/app/assets/javascripts/pages/admin/usage_trends/index.js b/app/assets/javascripts/pages/admin/usage_trends/index.js
new file mode 100644
index 00000000000..23d2bd85979
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/usage_trends/index.js
@@ -0,0 +1,3 @@
+import initUsageTrendsApp from '~/analytics/usage_trends';
+
+initUsageTrendsApp();
diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js
index 3ad95fb1318..3e09b1796b1 100644
--- a/app/assets/javascripts/pages/dashboard/issues/index.js
+++ b/app/assets/javascripts/pages/dashboard/issues/index.js
@@ -4,13 +4,11 @@ import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import projectSelect from '~/project_select';
-document.addEventListener('DOMContentLoaded', () => {
- initFilteredSearch({
- page: FILTERED_SEARCH.ISSUES,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
- useDefaultState: true,
- });
-
- projectSelect();
- initManualOrdering();
+initFilteredSearch({
+ page: FILTERED_SEARCH.ISSUES,
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
+ useDefaultState: true,
});
+
+projectSelect();
+initManualOrdering();
diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
index 8b529585898..397149aaa9e 100644
--- a/app/assets/javascripts/pages/dashboard/milestones/show/index.js
+++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
@@ -6,6 +6,4 @@ document.addEventListener('DOMContentLoaded', () => {
new Milestone(); // eslint-disable-line no-new
new Sidebar(); // eslint-disable-line no-new
new MountMilestoneSidebar(); // eslint-disable-line no-new
-
- Milestone.initDeprecationMessage();
});
diff --git a/app/assets/javascripts/pages/dashboard/projects/index/index.js b/app/assets/javascripts/pages/dashboard/projects/index/index.js
index b3c95f4ac1f..c34d15b869a 100644
--- a/app/assets/javascripts/pages/dashboard/projects/index/index.js
+++ b/app/assets/javascripts/pages/dashboard/projects/index/index.js
@@ -1,8 +1,5 @@
import ProjectsList from '~/projects_list';
import initCustomizeHomepageBanner from './init_customize_homepage_banner';
-document.addEventListener('DOMContentLoaded', () => {
- new ProjectsList(); // eslint-disable-line no-new
-
- initCustomizeHomepageBanner();
-});
+new ProjectsList(); // eslint-disable-line no-new
+initCustomizeHomepageBanner();
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/index.js b/app/assets/javascripts/pages/dashboard/todos/index/index.js
index 9d2c2f2994f..2fe90c24e77 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/index.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/index.js
@@ -1,3 +1,3 @@
import Todos from './todos';
-document.addEventListener('DOMContentLoaded', () => new Todos());
+new Todos(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/activity/index.js b/app/assets/javascripts/pages/groups/activity/index.js
index 1b887cad496..8b7c36a0976 100644
--- a/app/assets/javascripts/pages/groups/activity/index.js
+++ b/app/assets/javascripts/pages/groups/activity/index.js
@@ -1,3 +1,4 @@
import Activities from '~/activities';
-document.addEventListener('DOMContentLoaded', () => new Activities());
+// eslint-disable-next-line no-new
+new Activities();
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index 95ee512b71a..176d2406751 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -6,6 +6,7 @@ import TransferDropdown from '~/groups/transfer_dropdown';
import groupsSelect from '~/groups_select';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import projectSelect from '~/project_select';
+import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
import setupTransferEdit from '~/transfer_edit';
@@ -24,5 +25,7 @@ document.addEventListener('DOMContentLoaded', () => {
projectSelect();
+ initSearchSettings();
+
return new TransferDropdown();
});
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 3496f699b06..ab70fa572ba 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -1,11 +1,13 @@
import Vue from 'vue';
import { groupMemberRequestFormatter } from '~/groups/members/utils';
import groupsSelect from '~/groups_select';
+import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
+import initInviteMembersForm from '~/invite_members/init_invite_members_form';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date';
-import { initMembersApp } from '~/members/index';
+import { initMembersApp } from '~/members';
import { groupLinkRequestFormatter } from '~/members/utils';
import UsersSelect from '~/users_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
@@ -70,5 +72,10 @@ memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
initInviteMembersModal();
initInviteMembersTrigger();
+initInviteGroupTrigger();
+
+// This is only used when `invite_members_group_modal` feature flag is disabled.
+// This can be removed when `invite_members_group_modal` feature flag is removed.
+initInviteMembersForm();
new UsersSelect(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js
index 3094fe5cd21..2a2cc5faebe 100644
--- a/app/assets/javascripts/pages/groups/milestones/show/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/show/index.js
@@ -1,9 +1,7 @@
-import Milestone from '~/milestone';
import initDeleteMilestoneModal from '~/pages/milestones/shared/delete_milestone_modal_init';
import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
document.addEventListener('DOMContentLoaded', () => {
initMilestonesShow();
initDeleteMilestoneModal();
- Milestone.initDeprecationMessage();
});
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 63515abe698..322ad2c79e7 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import BindInOut from '~/behaviors/bind_in_out';
import initFilePickers from '~/file_pickers';
import Group from '~/group';
+import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
import GroupPathValidator from './group_path_validator';
const parentId = $('#group_parent_id');
@@ -12,3 +13,16 @@ BindInOut.initAll();
initFilePickers();
new Group(); // eslint-disable-line no-new
+
+const CONTAINER_SELECTOR = '.group-edit-container .nav-tabs';
+const DEFAULT_ACTION = '#create-group-pane';
+// eslint-disable-next-line no-new
+new LinkedTabs({
+ defaultAction: DEFAULT_ACTION,
+ parentEl: CONTAINER_SELECTOR,
+ hashedTabs: true,
+});
+
+if (window.location.hash) {
+ $(CONTAINER_SELECTOR).find(`a[href="${window.location.hash}"]`).tab('show');
+}
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 378b8663777..0c3fdcf3e75 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
@@ -4,21 +4,22 @@ import initSharedRunnersForm from '~/group_settings/mount_shared_runners';
import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
+import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
-document.addEventListener('DOMContentLoaded', () => {
- // Initialize expandable settings panels
- initSettingsPanels();
+// Initialize expandable settings panels
+initSettingsPanels();
- initFilteredSearch({
- page: FILTERED_SEARCH.ADMIN_RUNNERS,
- filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys,
- anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR,
- useDefaultState: false,
- });
+initFilteredSearch({
+ page: FILTERED_SEARCH.ADMIN_RUNNERS,
+ filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys,
+ anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR,
+ useDefaultState: false,
+});
- initSharedRunnersForm();
- initVariableList();
+initSharedRunnersForm();
+initVariableList();
- initInstallRunner();
-});
+initInstallRunner();
+
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js
index ba4b271f09e..a8698e10c57 100644
--- a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js
+++ b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js
@@ -1,13 +1,11 @@
import IntegrationSettingsForm from '~/integrations/integration_settings_form';
import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
-document.addEventListener('DOMContentLoaded', () => {
- const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring');
- const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
- integrationSettingsForm.init();
+const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring');
+const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+integrationSettingsForm.init();
- if (prometheusSettingsWrapper) {
- const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
- prometheusMetrics.loadActiveMetrics();
- }
-});
+if (prometheusSettingsWrapper) {
+ const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ prometheusMetrics.loadActiveMetrics();
+}
diff --git a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
index 3b922622d2c..d13bf026777 100644
--- a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
+++ b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
@@ -1,3 +1,6 @@
import bundle from '~/packages_and_registries/settings/group/bundle';
+import initSearchSettings from '~/search_settings';
bundle();
+
+document.addEventListener('DOMContentLoaded', initSearchSettings);
diff --git a/app/assets/javascripts/pages/groups/settings/repository/show/index.js b/app/assets/javascripts/pages/groups/settings/repository/show/index.js
index a1bcf6dbf57..2c9867653de 100644
--- a/app/assets/javascripts/pages/groups/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js
@@ -1,9 +1,10 @@
import DueDateSelectors from '~/due_date_select';
+import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
-document.addEventListener('DOMContentLoaded', () => {
- // Initialize expandable settings panels
- initSettingsPanels();
+// Initialize expandable settings panels
+initSettingsPanels();
- new DueDateSelectors(); // eslint-disable-line no-new
-});
+new DueDateSelectors(); // eslint-disable-line no-new
+
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 8c272e561db..9e75985c130 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -3,12 +3,8 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
import initInviteMembersBanner from '~/groups/init_invite_members_banner';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { getPagePath, getDashPath } from '~/lib/utils/common_utils';
import initNotificationsDropdown from '~/notifications';
-import notificationsDropdown from '~/notifications_dropdown';
-import NotificationsForm from '~/notifications_form';
import ProjectsList from '~/projects_list';
import GroupTabs from './group_tabs';
@@ -22,17 +18,10 @@ export default function initGroupDetails(actionName = 'show') {
new GroupTabs({ parentEl: '.groups-listing', action });
new ShortcutsNavigation();
- new NotificationsForm();
- if (gon.features?.vueNotificationDropdown) {
- initNotificationsDropdown();
- } else {
- notificationsDropdown();
- }
+ initNotificationsDropdown();
new ProjectsList();
initInviteMembersBanner();
- initInviteMembersModal();
- initInviteMembersTrigger();
}
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
index 82ee5ead83d..e4a84dd5eec 100644
--- a/app/assets/javascripts/pages/groups/show/index.js
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -1,7 +1,5 @@
import leaveByUrl from '~/namespaces/leave_by_url';
import initGroupDetails from '../shared/group_details';
-document.addEventListener('DOMContentLoaded', () => {
- leaveByUrl('group');
- initGroupDetails();
-});
+leaveByUrl('group');
+initGroupDetails();
diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js
index 535fe5fa4eb..80bc32dd43f 100644
--- a/app/assets/javascripts/pages/profiles/index.js
+++ b/app/assets/javascripts/pages/profiles/index.js
@@ -3,21 +3,19 @@ import '~/profile/gl_crop';
import Profile from '~/profile/profile';
import initSearchSettings from '~/search_settings';
-document.addEventListener('DOMContentLoaded', () => {
- // eslint-disable-next-line func-names
- $(document).on('input.ssh_key', '#key_key', function () {
- const $title = $('#key_title');
- const comment = $(this)
- .val()
- .match(/^\S+ \S+ (.+)\n?$/);
+// eslint-disable-next-line func-names
+$(document).on('input.ssh_key', '#key_key', function () {
+ const $title = $('#key_title');
+ const comment = $(this)
+ .val()
+ .match(/^\S+ \S+ (.+)\n?$/);
- // Extract the SSH Key title from its comment
- if (comment && comment.length > 1) {
- $title.val(comment[1]).change();
- }
- });
+ // Extract the SSH Key title from its comment
+ if (comment && comment.length > 1) {
+ $title.val(comment[1]).change();
+ }
+});
- new Profile(); // eslint-disable-line no-new
+new Profile(); // eslint-disable-line no-new
- initSearchSettings();
-});
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/profiles/index/index.js b/app/assets/javascripts/pages/profiles/index/index.js
deleted file mode 100644
index 586b3be8661..00000000000
--- a/app/assets/javascripts/pages/profiles/index/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import notificationsDropdown from '../../../notifications_dropdown';
-import NotificationsForm from '../../../notifications_form';
-
-document.addEventListener('DOMContentLoaded', () => {
- new NotificationsForm(); // eslint-disable-line no-new
- notificationsDropdown();
-});
diff --git a/app/assets/javascripts/pages/profiles/notifications/show/index.js b/app/assets/javascripts/pages/profiles/notifications/show/index.js
index 639f5deb72c..51ba6c7a01e 100644
--- a/app/assets/javascripts/pages/profiles/notifications/show/index.js
+++ b/app/assets/javascripts/pages/profiles/notifications/show/index.js
@@ -1,9 +1,5 @@
import initNotificationsDropdown from '~/notifications';
-import notificationsDropdown from '../../../../notifications_dropdown';
-import NotificationsForm from '../../../../notifications_form';
document.addEventListener('DOMContentLoaded', () => {
- new NotificationsForm(); // eslint-disable-line no-new
- notificationsDropdown();
initNotificationsDropdown();
});
diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
index ae2209b0292..fdbfc35456f 100644
--- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
+++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
@@ -1,3 +1,4 @@
-import initExpiresAtField from '~/access_tokens';
+import { initExpiresAtField, initProjectsField } from '~/access_tokens';
-document.addEventListener('DOMContentLoaded', initExpiresAtField);
+initExpiresAtField();
+initProjectsField();
diff --git a/app/assets/javascripts/pages/projects/blob/edit/index.js b/app/assets/javascripts/pages/projects/blob/edit/index.js
index 189053f3ed7..ed416610173 100644
--- a/app/assets/javascripts/pages/projects/blob/edit/index.js
+++ b/app/assets/javascripts/pages/projects/blob/edit/index.js
@@ -1,3 +1,3 @@
import initBlobBundle from '~/blob_edit/blob_bundle';
-document.addEventListener('DOMContentLoaded', initBlobBundle);
+initBlobBundle();
diff --git a/app/assets/javascripts/pages/projects/blob/new/index.js b/app/assets/javascripts/pages/projects/blob/new/index.js
index 189053f3ed7..ed416610173 100644
--- a/app/assets/javascripts/pages/projects/blob/new/index.js
+++ b/app/assets/javascripts/pages/projects/blob/new/index.js
@@ -1,3 +1,3 @@
import initBlobBundle from '~/blob_edit/blob_bundle';
-document.addEventListener('DOMContentLoaded', initBlobBundle);
+initBlobBundle();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 61ff1c95a38..10bac6d60c2 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -7,61 +7,59 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import '~/sourcegraph/load';
-document.addEventListener('DOMContentLoaded', () => {
- new BlobViewer(); // eslint-disable-line no-new
- initBlob();
+new BlobViewer(); // eslint-disable-line no-new
+initBlob();
- const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
- const statusLink = document.querySelector('.commit-actions .ci-status-link');
- if (statusLink) {
- statusLink.remove();
- // eslint-disable-next-line no-new
- new Vue({
- el: CommitPipelineStatusEl,
- components: {
- commitPipelineStatus,
- },
- render(createElement) {
- return createElement('commit-pipeline-status', {
- props: {
- endpoint: CommitPipelineStatusEl.dataset.endpoint,
- },
- });
- },
- });
- }
+const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
+const statusLink = document.querySelector('.commit-actions .ci-status-link');
+if (statusLink) {
+ statusLink.remove();
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: CommitPipelineStatusEl,
+ components: {
+ commitPipelineStatus,
+ },
+ render(createElement) {
+ return createElement('commit-pipeline-status', {
+ props: {
+ endpoint: CommitPipelineStatusEl.dataset.endpoint,
+ },
+ });
+ },
+ });
+}
- initWebIdeLink({ el: document.getElementById('js-blob-web-ide-link') });
+initWebIdeLink({ el: document.getElementById('js-blob-web-ide-link') });
- GpgBadges.fetch();
+GpgBadges.fetch();
- const codeNavEl = document.getElementById('js-code-navigation');
+const codeNavEl = document.getElementById('js-code-navigation');
- if (codeNavEl) {
- const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset;
+if (codeNavEl) {
+ const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset;
- // eslint-disable-next-line promise/catch-or-return
- import('~/code_navigation').then((m) =>
- m.default({
- blobs: [{ path: blobPath, codeNavigationPath }],
- definitionPathPrefix,
- }),
- );
- }
+ // eslint-disable-next-line promise/catch-or-return
+ import('~/code_navigation').then((m) =>
+ m.default({
+ blobs: [{ path: blobPath, codeNavigationPath }],
+ definitionPathPrefix,
+ }),
+ );
+}
- const successPipelineEl = document.querySelector('.js-success-pipeline-modal');
+const successPipelineEl = document.querySelector('.js-success-pipeline-modal');
- if (successPipelineEl) {
- // eslint-disable-next-line no-new
- new Vue({
- el: successPipelineEl,
- render(createElement) {
- return createElement(PipelineTourSuccessModal, {
- props: {
- ...successPipelineEl.dataset,
- },
- });
- },
- });
- }
-});
+if (successPipelineEl) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: successPipelineEl,
+ render(createElement) {
+ return createElement(PipelineTourSuccessModal, {
+ props: {
+ ...successPipelineEl.dataset,
+ },
+ });
+ },
+ });
+}
diff --git a/app/assets/javascripts/pages/projects/boards/index.js b/app/assets/javascripts/pages/projects/boards/index.js
index 3a06d0faa3e..bde0007ec6a 100644
--- a/app/assets/javascripts/pages/projects/boards/index.js
+++ b/app/assets/javascripts/pages/projects/boards/index.js
@@ -2,8 +2,6 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initBoards from '~/boards';
import UsersSelect from '~/users_select';
-document.addEventListener('DOMContentLoaded', () => {
- new UsersSelect(); // eslint-disable-line no-new
- new ShortcutsNavigation(); // eslint-disable-line no-new
- initBoards();
-});
+new UsersSelect(); // eslint-disable-line no-new
+new ShortcutsNavigation(); // eslint-disable-line no-new
+initBoards();
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
index 623d0a10606..72861855c5a 100644
--- a/app/assets/javascripts/pages/projects/branches/index/index.js
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -2,8 +2,6 @@ import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
import DeleteModal from '~/branches/branches_delete_modal';
import initDiverganceGraph from '~/branches/divergence_graph';
-document.addEventListener('DOMContentLoaded', () => {
- AjaxLoadingSpinner.init();
- new DeleteModal(); // eslint-disable-line no-new
- initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint);
-});
+AjaxLoadingSpinner.init();
+new DeleteModal(); // eslint-disable-line no-new
+initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint);
diff --git a/app/assets/javascripts/pages/projects/branches/new/index.js b/app/assets/javascripts/pages/projects/branches/new/index.js
index 13ff47d53c2..364223f1898 100644
--- a/app/assets/javascripts/pages/projects/branches/new/index.js
+++ b/app/assets/javascripts/pages/projects/branches/new/index.js
@@ -1,11 +1,8 @@
import $ from 'jquery';
import NewBranchForm from '~/new_branch_form';
-document.addEventListener(
- 'DOMContentLoaded',
- () =>
- new NewBranchForm(
- $('.js-create-branch-form'),
- JSON.parse(document.getElementById('availableRefs').innerHTML),
- ),
+// eslint-disable-next-line no-new
+new NewBranchForm(
+ $('.js-create-branch-form'),
+ JSON.parse(document.getElementById('availableRefs').innerHTML),
);
diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js
index f1cf9caa28b..549e596cb8d 100644
--- a/app/assets/javascripts/pages/projects/compare/show/index.js
+++ b/app/assets/javascripts/pages/projects/compare/show/index.js
@@ -1,6 +1,9 @@
import Diff from '~/diff';
import GpgBadges from '~/gpg_badges';
import initChangesDropdown from '~/init_changes_dropdown';
+import initCompareSelector from '~/projects/compare';
+
+initCompareSelector();
document.addEventListener('DOMContentLoaded', () => {
new Diff(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
index df58e9dd072..255d05b39be 100644
--- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
+++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
@@ -1,3 +1,3 @@
-import initCycleAnalytics from '~/cycle_analytics/cycle_analytics_bundle';
+import initCycleAnalytics from '~/cycle_analytics';
document.addEventListener('DOMContentLoaded', initCycleAnalytics);
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
index bbe84322462..43fd5375222 100644
--- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
@@ -1,3 +1,5 @@
+/* eslint-disable no-new */
+
import Vue from 'vue';
import Vuex from 'vuex';
import EditUserList from '~/user_lists/components/edit_user_list.vue';
@@ -5,15 +7,13 @@ import createStore from '~/user_lists/store/edit';
Vue.use(Vuex);
-document.addEventListener('DOMContentLoaded', () => {
- const el = document.getElementById('js-edit-user-list');
- const { userListsDocsPath } = el.dataset;
- return new Vue({
- el,
- store: createStore(el.dataset),
- provide: { userListsDocsPath },
- render(h) {
- return h(EditUserList, {});
- },
- });
+const el = document.getElementById('js-edit-user-list');
+const { userListsDocsPath } = el.dataset;
+new Vue({
+ el,
+ store: createStore(el.dataset),
+ provide: { userListsDocsPath },
+ render(h) {
+ return h(EditUserList, {});
+ },
});
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
index 679f0af8efc..e855447d5ce 100644
--- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
@@ -1,3 +1,5 @@
+/* eslint-disable no-new */
+
import Vue from 'vue';
import Vuex from 'vuex';
import NewUserList from '~/user_lists/components/new_user_list.vue';
@@ -5,18 +7,16 @@ import createStore from '~/user_lists/store/new';
Vue.use(Vuex);
-document.addEventListener('DOMContentLoaded', () => {
- const el = document.getElementById('js-new-user-list');
- const { userListsDocsPath, featureFlagsPath } = el.dataset;
- return new Vue({
- el,
- store: createStore(el.dataset),
- provide: {
- userListsDocsPath,
- featureFlagsPath,
- },
- render(h) {
- return h(NewUserList);
- },
- });
+const el = document.getElementById('js-new-user-list');
+const { userListsDocsPath, featureFlagsPath } = el.dataset;
+new Vue({
+ el,
+ store: createStore(el.dataset),
+ provide: {
+ userListsDocsPath,
+ featureFlagsPath,
+ },
+ render(h) {
+ return h(NewUserList);
+ },
});
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js
index bccd9dce2ec..2dca0ea7f29 100644
--- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js
@@ -1,18 +1,3 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import UserList from '~/user_lists/components/user_list.vue';
-import createStore from '~/user_lists/store/show';
+import featureFlagsUserListInit from '~/projects/feature_flags_user_lists/show/index';
-Vue.use(Vuex);
-
-document.addEventListener('DOMContentLoaded', () => {
- const el = document.getElementById('js-edit-user-list');
- return new Vue({
- el,
- store: createStore(el.dataset),
- render(h) {
- const { emptyStatePath } = el.dataset;
- return h(UserList, { props: { emptyStatePath } });
- },
- });
-});
+featureFlagsUserListInit();
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/app.vue b/app/assets/javascripts/pages/projects/forks/new/components/app.vue
new file mode 100644
index 00000000000..02b357d389b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/components/app.vue
@@ -0,0 +1,72 @@
+<script>
+import ForkForm from './fork_form.vue';
+
+export default {
+ components: {
+ ForkForm,
+ },
+ props: {
+ forkIllustration: {
+ type: String,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ projectFullPath: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ projectName: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectDescription: {
+ type: String,
+ required: true,
+ },
+ projectVisibility: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row gl-mt-5">
+ <div class="col-lg-3">
+ <img :src="forkIllustration" />
+ <h4 class="">{{ s__('ForkProject|Fork project') }}</h4>
+ <p>
+ {{ s__('ForkProject|A fork is a copy of a project.') }}
+ <br />
+ {{
+ s__(
+ 'ForkProject|Forking a repository allows you to make changes without affecting the original project.',
+ )
+ }}
+ </p>
+ </div>
+ <div class="col-lg-9">
+ <fork-form
+ :endpoint="endpoint"
+ :project-full-path="projectFullPath"
+ :project-id="projectId"
+ :project-name="projectName"
+ :project-path="projectPath"
+ :project-description="projectDescription"
+ :project-visibility="projectVisibility"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
new file mode 100644
index 00000000000..7112b23775d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -0,0 +1,304 @@
+<script>
+import {
+ GlIcon,
+ GlLink,
+ GlForm,
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlFormInput,
+ GlFormGroup,
+ GlFormTextarea,
+ GlButton,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlFormSelect,
+} from '@gitlab/ui';
+import { buildApiUrl } from '~/api/api_utils';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import csrf from '~/lib/utils/csrf';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+
+const PRIVATE_VISIBILITY = 'private';
+const INTERNAL_VISIBILITY = 'internal';
+const PUBLIC_VISIBILITY = 'public';
+
+const ALLOWED_VISIBILITY = {
+ private: [PRIVATE_VISIBILITY],
+ internal: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY],
+ public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY],
+};
+
+export default {
+ components: {
+ GlForm,
+ GlIcon,
+ GlLink,
+ GlButton,
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlFormInput,
+ GlFormTextarea,
+ GlFormGroup,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlFormSelect,
+ },
+ inject: {
+ newGroupPath: {
+ default: '',
+ },
+ visibilityHelpPath: {
+ default: '',
+ },
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ projectFullPath: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ projectName: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectDescription: {
+ type: String,
+ required: true,
+ },
+ projectVisibility: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isSaving: false,
+ namespaces: [],
+ selectedNamespace: {},
+ fork: {
+ name: this.projectName,
+ slug: this.projectPath,
+ description: this.projectDescription,
+ visibility: this.projectVisibility,
+ },
+ };
+ },
+ computed: {
+ projectUrl() {
+ return `${gon.gitlab_url}/`;
+ },
+ projectAllowedVisibility() {
+ return ALLOWED_VISIBILITY[this.projectVisibility];
+ },
+ namespaceAllowedVisibility() {
+ return (
+ ALLOWED_VISIBILITY[this.selectedNamespace.visibility] ||
+ ALLOWED_VISIBILITY[PUBLIC_VISIBILITY]
+ );
+ },
+ visibilityLevels() {
+ return [
+ {
+ text: s__('ForkProject|Private'),
+ value: PRIVATE_VISIBILITY,
+ icon: 'lock',
+ help: s__('ForkProject|The project can be accessed without any authentication.'),
+ disabled: this.isVisibilityLevelDisabled(PRIVATE_VISIBILITY),
+ },
+ {
+ text: s__('ForkProject|Internal'),
+ value: INTERNAL_VISIBILITY,
+ icon: 'shield',
+ help: s__('ForkProject|The project can be accessed by any logged in user.'),
+ disabled: this.isVisibilityLevelDisabled(INTERNAL_VISIBILITY),
+ },
+ {
+ text: s__('ForkProject|Public'),
+ value: PUBLIC_VISIBILITY,
+ icon: 'earth',
+ help: s__(
+ 'ForkProject|Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
+ ),
+ disabled: this.isVisibilityLevelDisabled(PUBLIC_VISIBILITY),
+ },
+ ];
+ },
+ },
+ watch: {
+ selectedNamespace(newVal) {
+ const { visibility } = newVal;
+
+ if (this.projectAllowedVisibility.includes(visibility)) {
+ this.fork.visibility = visibility;
+ }
+ },
+ },
+ mounted() {
+ this.fetchNamespaces();
+ },
+ methods: {
+ async fetchNamespaces() {
+ const { data } = await axios.get(this.endpoint);
+ this.namespaces = data.namespaces;
+ },
+ isVisibilityLevelDisabled(visibilityLevel) {
+ return !(
+ this.projectAllowedVisibility.includes(visibilityLevel) &&
+ this.namespaceAllowedVisibility.includes(visibilityLevel)
+ );
+ },
+ async onSubmit() {
+ this.isSaving = true;
+
+ const { projectId } = this;
+ const { name, slug, description, visibility } = this.fork;
+ const { id: namespaceId } = this.selectedNamespace;
+
+ const postParams = {
+ id: projectId,
+ name,
+ namespace_id: namespaceId,
+ path: slug,
+ description,
+ visibility,
+ };
+
+ const forkProjectPath = `/api/:version/projects/:id/fork`;
+ const url = buildApiUrl(forkProjectPath).replace(':id', encodeURIComponent(this.projectId));
+
+ try {
+ const { data } = await axios.post(url, postParams);
+ redirectTo(data.web_url);
+ return;
+ } catch (error) {
+ createFlash({ message: error });
+ }
+ },
+ },
+ csrf,
+};
+</script>
+
+<template>
+ <gl-form method="POST" @submit.prevent="onSubmit">
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+
+ <gl-form-group label="Project name" label-for="fork-name">
+ <gl-form-input id="fork-name" v-model="fork.name" data-testid="fork-name-input" required />
+ </gl-form-group>
+
+ <div class="gl-md-display-flex">
+ <div class="gl-flex-basis-half">
+ <gl-form-group label="Project URL" label-for="fork-url" class="gl-md-mr-3">
+ <gl-form-input-group>
+ <template #prepend>
+ <gl-input-group-text>
+ {{ projectUrl }}
+ </gl-input-group-text>
+ </template>
+ <gl-form-select
+ id="fork-url"
+ v-model="selectedNamespace"
+ data-testid="fork-url-input"
+ required
+ >
+ <template slot="first">
+ <option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option>
+ </template>
+ <option v-for="namespace in namespaces" :key="namespace.id" :value="namespace">
+ {{ namespace.name }}
+ </option>
+ </gl-form-select>
+ </gl-form-input-group>
+ </gl-form-group>
+ </div>
+ <div class="gl-flex-basis-half">
+ <gl-form-group label="Project slug" label-for="fork-slug" class="gl-md-ml-3">
+ <gl-form-input
+ id="fork-slug"
+ v-model="fork.slug"
+ data-testid="fork-slug-input"
+ required
+ />
+ </gl-form-group>
+ </div>
+ </div>
+
+ <p class="gl-mt-n5 gl-text-gray-500">
+ {{ s__('ForkProject|Want to house several dependent projects under the same namespace?') }}
+ <gl-link :href="newGroupPath" target="_blank">
+ {{ s__('ForkProject|Create a group') }}
+ </gl-link>
+ </p>
+
+ <gl-form-group label="Project description (optional)" label-for="fork-description">
+ <gl-form-textarea
+ id="fork-description"
+ v-model="fork.description"
+ data-testid="fork-description-textarea"
+ />
+ </gl-form-group>
+
+ <gl-form-group>
+ <label>
+ {{ s__('ForkProject|Visibility level') }}
+ <gl-link :href="visibilityHelpPath" target="_blank">
+ <gl-icon name="question-o" />
+ </gl-link>
+ </label>
+ <gl-form-radio-group
+ v-model="fork.visibility"
+ data-testid="fork-visibility-radio-group"
+ required
+ >
+ <gl-form-radio
+ v-for="{ text, value, icon, help, disabled } in visibilityLevels"
+ :key="value"
+ :value="value"
+ :disabled="disabled"
+ :data-testid="`radio-${value}`"
+ >
+ <div>
+ <gl-icon :name="icon" />
+ <span>{{ text }}</span>
+ </div>
+ <template #help>{{ help }}</template>
+ </gl-form-radio>
+ </gl-form-radio-group>
+ </gl-form-group>
+
+ <div class="gl-display-flex gl-justify-content-space-between gl-mt-8">
+ <gl-button
+ type="submit"
+ category="primary"
+ variant="confirm"
+ data-testid="submit-button"
+ :loading="isSaving"
+ >
+ {{ s__('ForkProject|Fork project') }}
+ </gl-button>
+ <gl-button
+ type="reset"
+ class="gl-mr-3"
+ data-testid="cancel-button"
+ :disabled="isSaving"
+ :href="projectFullPath"
+ >
+ {{ s__('ForkProject|Cancel') }}
+ </gl-button>
+ </div>
+ </gl-form>
+</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
index 46d1696b88b..88f4bba5e2a 100644
--- 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
@@ -11,6 +11,7 @@ import {
} from '@gitlab/ui';
import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/groups/constants';
import csrf from '~/lib/utils/csrf';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
export default {
components: {
@@ -20,6 +21,7 @@ export default {
GlButton,
GlTooltip,
GlLink,
+ UserAccessRoleBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -72,7 +74,9 @@ export default {
<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">
+ <div
+ class="folder-toggle-wrap gl-mr-3 gl-display-flex gl-align-items-center gl-text-gray-500"
+ >
<gl-icon name="folder-o" />
</div>
<gl-link
@@ -84,12 +88,12 @@ export default {
<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-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"
+ class="gl-display-inline-flex gl-mt-3 gl-mr-3 gl-text-gray-500"
:name="visibilityIcon"
:title="visibilityTooltip"
/>
@@ -99,11 +103,11 @@ export default {
class="gl-display-none gl-sm-display-flex gl-mt-3 gl-mr-1"
>{{ __('pending removal') }}</gl-badge
>
- <span v-if="group.permission" class="user-access-role gl-mt-3">
+ <user-access-role-badge v-if="group.permission" class="gl-mt-3">
{{ group.permission }}
- </span>
+ </user-access-role-badge>
</div>
- <div v-if="group.description" class="description">
+ <div v-if="group.description" class="description gl-line-height-20">
<span v-safe-html="group.markdown_description"> </span>
</div>
</div>
diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js
index a018d7e0926..372967c8a1e 100644
--- a/app/assets/javascripts/pages/projects/forks/new/index.js
+++ b/app/assets/javascripts/pages/projects/forks/new/index.js
@@ -1,12 +1,52 @@
import Vue from 'vue';
+import App from './components/app.vue';
import ForkGroupsList from './components/fork_groups_list.vue';
-document.addEventListener('DOMContentLoaded', () => {
- const mountElement = document.getElementById('fork-groups-mount-element');
+const mountElement = document.getElementById('fork-groups-mount-element');
+if (gon.features.forkProjectForm) {
+ const {
+ forkIllustration,
+ endpoint,
+ newGroupPath,
+ projectFullPath,
+ visibilityHelpPath,
+ projectId,
+ projectName,
+ projectPath,
+ projectDescription,
+ projectVisibility,
+ } = mountElement.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: mountElement,
+ provide: {
+ newGroupPath,
+ visibilityHelpPath,
+ },
+ render(h) {
+ return h(App, {
+ props: {
+ forkIllustration,
+ endpoint,
+ newGroupPath,
+ projectFullPath,
+ visibilityHelpPath,
+ projectId,
+ projectName,
+ projectPath,
+ projectDescription,
+ projectVisibility,
+ },
+ });
+ },
+ });
+} else {
const { endpoint } = mountElement.dataset;
- return new Vue({
+ // eslint-disable-next-line no-new
+ new Vue({
el: mountElement,
render(h) {
return h(ForkGroupsList, {
@@ -16,4 +56,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
-});
+}
diff --git a/app/assets/javascripts/pages/projects/imports/show/index.js b/app/assets/javascripts/pages/projects/imports/show/index.js
index d5f92baf054..8397826f8eb 100644
--- a/app/assets/javascripts/pages/projects/imports/show/index.js
+++ b/app/assets/javascripts/pages/projects/imports/show/index.js
@@ -1,5 +1,3 @@
import ProjectImport from '~/project_import';
-document.addEventListener('DOMContentLoaded', () => {
- new ProjectImport(); // eslint-disable-line no-new
-});
+new ProjectImport(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index 525d90e162d..366f8dc61bc 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -2,9 +2,10 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
+import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
import initIssuableByEmail from '~/issuable/init_issuable_by_email';
import IssuableIndex from '~/issuable_index';
-import initIssuablesList from '~/issues_list';
+import initIssuablesList, { initIssuesListApp } from '~/issues_list';
import initManualOrdering from '~/manual_ordering';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
@@ -26,3 +27,5 @@ new UsersSelect();
initManualOrdering();
initIssuablesList();
initIssuableByEmail();
+initCsvImportExportButtons();
+initIssuesListApp();
diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js
index 6a70d4cf26d..681d151b77f 100644
--- a/app/assets/javascripts/pages/projects/jobs/index/index.js
+++ b/app/assets/javascripts/pages/projects/jobs/index/index.js
@@ -1,19 +1,17 @@
import Vue from 'vue';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
-document.addEventListener('DOMContentLoaded', () => {
- const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
- remainingTimeElements.forEach(
- (el) =>
- new Vue({
- el,
- render(h) {
- return h(GlCountdown, {
- props: {
- endDateString: el.dateTime,
- },
- });
- },
- }),
- );
-});
+const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
+remainingTimeElements.forEach(
+ (el) =>
+ new Vue({
+ el,
+ render(h) {
+ return h(GlCountdown, {
+ props: {
+ endDateString: el.dateTime,
+ },
+ });
+ },
+ }),
+);
diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js
index 83d6ac9fd14..3b7562deed9 100644
--- a/app/assets/javascripts/pages/projects/labels/edit/index.js
+++ b/app/assets/javascripts/pages/projects/labels/edit/index.js
@@ -1,3 +1,3 @@
import Labels from 'ee_else_ce/labels';
-document.addEventListener('DOMContentLoaded', () => new Labels());
+new Labels(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
index 0393793bfe1..32ca623ca45 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
@@ -1,11 +1,11 @@
<script>
import { GlLink } from '@gitlab/ui';
-import { ACTION_TEXT } from '../constants';
+import { ACTION_LABELS } from '../constants';
export default {
components: { GlLink },
i18n: {
- ACTION_TEXT,
+ ACTION_LABELS,
},
props: {
actions: {
@@ -18,9 +18,9 @@ export default {
<template>
<ul>
<li v-for="(value, action) in actions" :key="action">
- <span v-if="value.completed">{{ $options.i18n.ACTION_TEXT[action] }}</span>
+ <span v-if="value.completed">{{ $options.i18n.ACTION_LABELS[action].title }}</span>
<span v-else>
- <gl-link :href="value.url">{{ $options.i18n.ACTION_TEXT[action] }}</gl-link>
+ <gl-link :href="value.url">{{ $options.i18n.ACTION_LABELS[action].title }}</gl-link>
</span>
</li>
</ul>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
index 0393793bfe1..230054ff76e 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
@@ -1,11 +1,35 @@
<script>
-import { GlLink } from '@gitlab/ui';
-import { ACTION_TEXT } from '../constants';
+import { GlProgressBar, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { ACTION_LABELS } from '../constants';
+import LearnGitlabInfoCard from './learn_gitlab_info_card.vue';
export default {
- components: { GlLink },
+ components: { LearnGitlabInfoCard, GlProgressBar, GlSprintf },
i18n: {
- ACTION_TEXT,
+ title: s__('LearnGitLab|Learn GitLab'),
+ description: s__(
+ 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.',
+ ),
+ percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`),
+ workspace: {
+ title: s__('LearnGitLab|Set up your workspace'),
+ description: s__(
+ "LearnGitLab|Complete these tasks first so you can enjoy GitLab's features to their fullest:",
+ ),
+ },
+ plan: {
+ title: s__('LearnGitLab|Plan and execute'),
+ description: s__(
+ 'LearnGitLab|Create a workflow for your new workspace, and learn how GitLab features work together:',
+ ),
+ },
+ deploy: {
+ title: s__('LearnGitLab|Deploy'),
+ description: s__(
+ 'LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:',
+ ),
+ },
},
props: {
actions: {
@@ -13,15 +37,76 @@ export default {
type: Object,
},
},
+ maxValue: Object.keys(ACTION_LABELS).length,
+ methods: {
+ infoProps(action) {
+ return {
+ ...this.actions[action],
+ ...ACTION_LABELS[action],
+ };
+ },
+ progressValue() {
+ return Object.values(this.actions).filter((a) => a.completed).length;
+ },
+ progressPercentage() {
+ return Math.round((this.progressValue() / this.$options.maxValue) * 100);
+ },
+ },
};
</script>
<template>
- <ul>
- <li v-for="(value, action) in actions" :key="action">
- <span v-if="value.completed">{{ $options.i18n.ACTION_TEXT[action] }}</span>
- <span v-else>
- <gl-link :href="value.url">{{ $options.i18n.ACTION_TEXT[action] }}</gl-link>
- </span>
- </li>
- </ul>
+ <div>
+ <div class="row">
+ <div class="gl-mb-7 col-md-8 col-lg-7">
+ <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
+ <p class="gl-text-gray-700 gl-mb-0">{{ $options.i18n.description }}</p>
+ </div>
+ </div>
+
+ <div class="gl-mb-3">
+ <p class="gl-text-gray-500 gl-mb-2" data-testid="completion-percentage">
+ <gl-sprintf :message="$options.i18n.percentageCompleted">
+ <template #percentage>{{ progressPercentage() }}</template>
+ <template #percentSymbol>%</template>
+ </gl-sprintf>
+ </p>
+ <gl-progress-bar :value="progressValue()" :max="$options.maxValue" />
+ </div>
+
+ <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.workspace.title }}</h2>
+ <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.workspace.description }}</p>
+
+ <div class="row row-cols-2 row-cols-md-3 row-cols-lg-4">
+ <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('userAdded')" /></div>
+ <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('gitWrite')" /></div>
+ <div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('pipelineCreated')" />
+ </div>
+ <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('trialStarted')" /></div>
+ <div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('codeOwnersEnabled')" />
+ </div>
+ <div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('requiredMrApprovalsEnabled')" />
+ </div>
+ </div>
+
+ <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.plan.title }}</h2>
+ <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.plan.description }}</p>
+
+ <div class="row row-cols-2 row-cols-md-3 row-cols-lg-4">
+ <div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('mergeRequestCreated')" />
+ </div>
+ </div>
+
+ <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.deploy.title }}</h2>
+ <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.deploy.description }}</p>
+
+ <div class="row row-cols-2 row-cols-lg-4 g-2 g-lg-3">
+ <div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('securityScanEnabled')" />
+ </div>
+ </div>
+ </div>
</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
new file mode 100644
index 00000000000..3d2a8eed9d4
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlLink, GlCard, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'LearnGitlabInfoCard',
+ components: { GlLink, GlCard, GlIcon },
+ i18n: {
+ trial: s__('Learn GitLab|Trial only'),
+ },
+ props: {
+ title: {
+ required: true,
+ type: String,
+ },
+ description: {
+ required: true,
+ type: String,
+ },
+ actionLabel: {
+ required: true,
+ type: String,
+ },
+ url: {
+ required: true,
+ type: String,
+ },
+ completed: {
+ required: true,
+ type: Boolean,
+ },
+ svg: {
+ required: true,
+ type: String,
+ },
+ trialRequired: {
+ default: false,
+ required: false,
+ type: Boolean,
+ },
+ },
+};
+</script>
+<template>
+ <gl-card class="gl-pt-0">
+ <div class="gl-text-right gl-h-5">
+ <gl-icon
+ v-if="completed"
+ name="check-circle-filled"
+ class="gl-text-green-500"
+ :size="16"
+ data-testid="completed-icon"
+ />
+ <span
+ v-else-if="trialRequired"
+ class="gl-text-gray-500 gl-font-sm gl-font-style-italic"
+ data-testid="trial-only"
+ >{{ $options.i18n.trial }}</span
+ >
+ </div>
+ <div
+ class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
+ >
+ <img :src="svg" />
+ <h6>{{ title }}</h6>
+ <p class="gl-font-sm gl-text-gray-700">{{ description }}</p>
+ <gl-link :href="url" target="_blank">{{ actionLabel }}</gl-link>
+ </div>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
index 8606af29785..80f04b0cf44 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
@@ -1,12 +1,50 @@
import { s__ } from '~/locale';
-export const ACTION_TEXT = {
- gitWrite: s__('LearnGitLab|Create a repository'),
- userAdded: s__('LearnGitLab|Invite your colleagues'),
- pipelineCreated: s__('LearnGitLab|Set-up CI/CD'),
- trialStarted: s__('LearnGitLab|Start a free trial of GitLab Gold'),
- codeOwnersEnabled: s__('LearnGitLab|Add code owners'),
- requiredMrApprovalsEnabled: s__('LearnGitLab|Enable require merge approvals'),
- mergeRequestCreated: s__('LearnGitLab|Submit a merge request (MR)'),
- securityScanEnabled: s__('LearnGitLab|Run a Security scan using CI/CD'),
+export const ACTION_LABELS = {
+ gitWrite: {
+ title: s__('LearnGitLab|Create or import a repository'),
+ actionLabel: s__('LearnGitLab|Create or import a repository'),
+ description: s__('LearnGitLab|Create or import your first repository into your new project.'),
+ },
+ userAdded: {
+ title: s__('LearnGitLab|Invite your colleagues'),
+ actionLabel: s__('LearnGitLab|Invite your colleagues'),
+ description: s__(
+ 'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.',
+ ),
+ },
+ pipelineCreated: {
+ title: s__('LearnGitLab|Set up CI/CD'),
+ actionLabel: s__('LearnGitLab|Set-up CI/CD'),
+ description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'),
+ },
+ trialStarted: {
+ title: s__('LearnGitLab|Start a free Ultimate trial'),
+ actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'),
+ description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'),
+ },
+ codeOwnersEnabled: {
+ title: s__('LearnGitLab|Add code owners'),
+ actionLabel: s__('LearnGitLab|Add code owners'),
+ description: s__(
+ 'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.',
+ ),
+ trialRequired: true,
+ },
+ requiredMrApprovalsEnabled: {
+ title: s__('LearnGitLab|Add merge request approval'),
+ actionLabel: s__('LearnGitLab|Enable require merge approvals'),
+ description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'),
+ trialRequired: true,
+ },
+ mergeRequestCreated: {
+ title: s__('LearnGitLab|Submit a merge request'),
+ actionLabel: s__('LearnGitLab|Submit a merge request (MR)'),
+ description: s__('LearnGitLab|Review and edit proposed changes to source code.'),
+ },
+ securityScanEnabled: {
+ title: s__('LearnGitLab|Run a security scan'),
+ actionLabel: s__('LearnGitLab|Run a Security scan'),
+ description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
+ },
};
diff --git a/app/assets/javascripts/pages/projects/logs/index.js b/app/assets/javascripts/pages/projects/logs/index.js
index 36747069ebb..0cff1ffc27e 100644
--- a/app/assets/javascripts/pages/projects/logs/index.js
+++ b/app/assets/javascripts/pages/projects/logs/index.js
@@ -1,3 +1,3 @@
import logsBundle from '~/logs';
-document.addEventListener('DOMContentLoaded', logsBundle);
+logsBundle();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
index 399aebb0c83..ec21d8c84e0 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
@@ -1,7 +1,5 @@
import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
import initCheckFormState from './check_form_state';
-document.addEventListener('DOMContentLoaded', () => {
- initMergeRequest();
- initCheckFormState();
-});
+initMergeRequest();
+initCheckFormState();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
index 76705256fe2..d279086df7b 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -1,6 +1,7 @@
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
import initIssuableByEmail from '~/issuable/init_issuable_by_email';
import IssuableIndex from '~/issuable_index';
import { FILTERED_SEARCH } from '~/pages/constants';
@@ -22,3 +23,4 @@ new UsersSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initIssuableByEmail();
+initCsvImportExportButtons();
diff --git a/app/assets/javascripts/pages/projects/milestones/edit/index.js b/app/assets/javascripts/pages/projects/milestones/edit/index.js
index 9a4ebf9890d..4f8514a9a1d 100644
--- a/app/assets/javascripts/pages/projects/milestones/edit/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/edit/index.js
@@ -1,3 +1,3 @@
-import initForm from '../../../../shared/milestones/form';
+import initForm from '~/shared/milestones/form';
-document.addEventListener('DOMContentLoaded', () => initForm());
+initForm();
diff --git a/app/assets/javascripts/pages/projects/milestones/index/index.js b/app/assets/javascripts/pages/projects/milestones/index/index.js
index 38789365a67..150b506b121 100644
--- a/app/assets/javascripts/pages/projects/milestones/index/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/index/index.js
@@ -1,3 +1,3 @@
import milestones from '~/pages/milestones/shared';
-document.addEventListener('DOMContentLoaded', milestones);
+milestones();
diff --git a/app/assets/javascripts/pages/projects/milestones/new/index.js b/app/assets/javascripts/pages/projects/milestones/new/index.js
index 9a4ebf9890d..364b0d95d9c 100644
--- a/app/assets/javascripts/pages/projects/milestones/new/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/new/index.js
@@ -1,3 +1,3 @@
import initForm from '../../../../shared/milestones/form';
-document.addEventListener('DOMContentLoaded', () => initForm());
+initForm();
diff --git a/app/assets/javascripts/pages/projects/milestones/show/index.js b/app/assets/javascripts/pages/projects/milestones/show/index.js
index a853413e1f7..3c755e9b98c 100644
--- a/app/assets/javascripts/pages/projects/milestones/show/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/show/index.js
@@ -1,7 +1,5 @@
import milestones from '~/pages/milestones/shared';
import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
-document.addEventListener('DOMContentLoaded', () => {
- initMilestonesShow();
- milestones();
-});
+initMilestonesShow();
+milestones();
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index 437594fdf11..e10e2872dce 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -3,28 +3,26 @@ import { __ } from '~/locale';
import initProjectVisibilitySelector from '../../../project_visibility';
import initProjectNew from '../../../projects/project_new';
-document.addEventListener('DOMContentLoaded', () => {
- initProjectVisibilitySelector();
- initProjectNew.bindEvents();
+initProjectVisibilitySelector();
+initProjectNew.bindEvents();
- import(
- /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation'
- )
- .then((m) => {
- const el = document.querySelector('.js-experiment-new-project-creation');
+import(
+ /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation'
+)
+ .then((m) => {
+ const el = document.querySelector('.js-experiment-new-project-creation');
- if (!el) {
- return;
- }
+ if (!el) {
+ return;
+ }
- const config = {
- hasErrors: 'hasErrors' in el.dataset,
- isCiCdAvailable: 'isCiCdAvailable' in el.dataset,
- newProjectGuidelines: el.dataset.newProjectGuidelines,
- };
- m.default(el, config);
- })
- .catch(() => {
- createFlash(__('An error occurred while loading project creation UI'));
- });
-});
+ const config = {
+ hasErrors: 'hasErrors' in el.dataset,
+ isCiCdAvailable: 'isCiCdAvailable' in el.dataset,
+ newProjectGuidelines: el.dataset.newProjectGuidelines,
+ };
+ m.default(el, config);
+ })
+ .catch(() => {
+ createFlash(__('An error occurred while loading project creation UI'));
+ });
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index ed11b07be4a..4aea5614bfb 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,11 +1,14 @@
import Vue from 'vue';
-import { deprecatedCreateFlash as flash } from '~/flash';
import groupsSelect from '~/groups_select';
+import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
+import initInviteMembersForm from '~/invite_members/init_invite_members_form';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date';
-import Members from '~/members';
+import { initMembersApp } from '~/members';
+import { groupLinkRequestFormatter } from '~/members/utils';
+import { projectMemberRequestFormatter } from '~/projects/members/utils';
import UsersSelect from '~/users_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
@@ -29,68 +32,51 @@ memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
initInviteMembersModal();
initInviteMembersTrigger();
+initInviteGroupTrigger();
-new Members(); // eslint-disable-line no-new
-new UsersSelect(); // eslint-disable-line no-new
+// This is only used when `invite_members_group_modal` feature flag is disabled.
+// This can be removed when `invite_members_group_modal` feature flag is removed.
+initInviteMembersForm();
-if (window.gon.features.vueProjectMembersList) {
- const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+new UsersSelect(); // eslint-disable-line no-new
- Promise.all([
- import('~/members/index'),
- import('~/members/utils'),
- import('~/projects/members/utils'),
- import('~/locale'),
- ])
- .then(
- ([
- { initMembersApp },
- { groupLinkRequestFormatter },
- { projectMemberRequestFormatter },
- { s__ },
- ]) => {
- initMembersApp(document.querySelector('.js-project-members-list'), {
- tableFields: SHARED_FIELDS.concat(['source', 'granted']),
- tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
- tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
- requestFormatter: projectMemberRequestFormatter,
- filteredSearchBar: {
- show: true,
- tokens: ['with_inherited_permissions'],
- searchParam: 'search',
- placeholder: s__('Members|Filter members'),
- recentSearchesStorageKey: 'project_members',
- },
- });
+const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+initMembersApp(document.querySelector('.js-project-members-list'), {
+ tableFields: SHARED_FIELDS.concat(['source', 'granted']),
+ tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
+ tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
+ requestFormatter: projectMemberRequestFormatter,
+ filteredSearchBar: {
+ show: true,
+ tokens: ['with_inherited_permissions'],
+ searchParam: 'search',
+ placeholder: s__('Members|Filter members'),
+ recentSearchesStorageKey: 'project_members',
+ },
+});
- initMembersApp(document.querySelector('.js-project-group-links-list'), {
- tableFields: SHARED_FIELDS.concat('granted'),
- tableAttrs: {
- table: { 'data-qa-selector': 'groups_list' },
- tr: { 'data-qa-selector': 'group_row' },
- },
- requestFormatter: groupLinkRequestFormatter,
- filteredSearchBar: {
- show: true,
- tokens: [],
- searchParam: 'search_groups',
- placeholder: s__('Members|Search groups'),
- recentSearchesStorageKey: 'project_group_links',
- },
- });
+initMembersApp(document.querySelector('.js-project-group-links-list'), {
+ tableFields: SHARED_FIELDS.concat('granted'),
+ tableAttrs: {
+ table: { 'data-qa-selector': 'groups_list' },
+ tr: { 'data-qa-selector': 'group_row' },
+ },
+ requestFormatter: groupLinkRequestFormatter,
+ filteredSearchBar: {
+ show: true,
+ tokens: [],
+ searchParam: 'search_groups',
+ placeholder: s__('Members|Search groups'),
+ recentSearchesStorageKey: 'project_group_links',
+ },
+});
- initMembersApp(document.querySelector('.js-project-invited-members-list'), {
- tableFields: SHARED_FIELDS.concat('invited'),
- requestFormatter: projectMemberRequestFormatter,
- });
+initMembersApp(document.querySelector('.js-project-invited-members-list'), {
+ tableFields: SHARED_FIELDS.concat('invited'),
+ requestFormatter: projectMemberRequestFormatter,
+});
- initMembersApp(document.querySelector('.js-project-access-requests-list'), {
- tableFields: SHARED_FIELDS.concat('requested'),
- requestFormatter: projectMemberRequestFormatter,
- });
- },
- )
- .catch(() => {
- flash(__('An error occurred while loading the members, please try again.'));
- });
-}
+initMembersApp(document.querySelector('.js-project-access-requests-list'), {
+ tableFields: SHARED_FIELDS.concat('requested'),
+ requestFormatter: projectMemberRequestFormatter,
+});
diff --git a/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js b/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js
index 2fd047675b9..82856c1c8b9 100644
--- a/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js
+++ b/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js
@@ -1,3 +1,3 @@
-import customMetrics from '~/custom_metrics';
+import CustomMetrics from '~/custom_metrics';
-document.addEventListener('DOMContentLoaded', customMetrics);
+CustomMetrics();
diff --git a/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js b/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js
index 2fd047675b9..82856c1c8b9 100644
--- a/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js
+++ b/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js
@@ -1,3 +1,3 @@
-import customMetrics from '~/custom_metrics';
+import CustomMetrics from '~/custom_metrics';
-document.addEventListener('DOMContentLoaded', customMetrics);
+CustomMetrics();
diff --git a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
index 22dddb72f98..dc1bb88bf4b 100644
--- a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
+++ b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
@@ -1,3 +1,3 @@
-import initExpiresAtField from '~/access_tokens';
+import { initExpiresAtField } from '~/access_tokens';
initExpiresAtField();
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index be9259ec3ca..b7e8d4b03ac 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -6,6 +6,7 @@ import initDeployFreeze from '~/deploy_freeze';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
+import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
document.addEventListener('DOMContentLoaded', () => {
@@ -42,4 +43,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
initInstallRunner();
+
+ initSearchSettings();
});
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 3a46241e2eb..4a800ab150d 100644
--- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -3,6 +3,7 @@ import mountErrorTrackingForm from '~/error_tracking_settings';
import mountGrafanaIntegration from '~/grafana_integration';
import initIncidentsSettings from '~/incidents_settings';
import mountOperationSettings from '~/operation_settings';
+import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
initIncidentsSettings();
@@ -13,3 +14,7 @@ if (!IS_EE) {
initSettingsPanels();
}
mountAlertsSettings(document.querySelector('.js-alerts-settings'));
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSearchSettings();
+});
diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
index e90954c14c5..c7bcbb83051 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -1,4 +1,5 @@
import MirrorRepos from '~/mirrors/mirror_repos';
+import initSearchSettings from '~/search_settings';
import initForm from '../form';
document.addEventListener('DOMContentLoaded', () => {
@@ -6,4 +7,6 @@ document.addEventListener('DOMContentLoaded', () => {
const mirrorReposContainer = document.querySelector('.js-mirror-settings');
if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init();
+
+ initSearchSettings();
});
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 94a9bc168e5..0b58cb4731d 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
@@ -11,6 +11,7 @@ import {
featureAccessLevelEveryone,
featureAccessLevel,
featureAccessLevelNone,
+ CVE_ID_REQUEST_BUTTON_I18N,
} from '../constants';
import { toggleHiddenClassBySelector } from '../external';
import projectFeatureSetting from './project_feature_setting.vue';
@@ -19,6 +20,10 @@ import projectSettingRow from './project_setting_row.vue';
const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone');
export default {
+ i18n: {
+ ...CVE_ID_REQUEST_BUTTON_I18N,
+ },
+
components: {
projectFeatureSetting,
projectSettingRow,
@@ -31,6 +36,11 @@ export default {
mixins: [settingsMixin, glFeatureFlagsMixin()],
props: {
+ requestCveAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
currentSettings: {
type: Object,
required: true,
@@ -74,11 +84,6 @@ export default {
required: false,
default: false,
},
- securityAndComplianceAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
visibilityHelpPath: {
type: String,
required: false,
@@ -99,6 +104,11 @@ export default {
required: false,
default: '',
},
+ cveIdRequestHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
registryHelpPath: {
type: String,
required: false,
@@ -152,6 +162,7 @@ export default {
requestAccessEnabled: true,
highlightChangesClass: false,
emailsDisabled: false,
+ cveIdRequestEnabled: true,
featureAccessLevelEveryone,
featureAccessLevelMembers,
};
@@ -230,6 +241,9 @@ export default {
'ProjectSettings|View and edit files in this project. Non-project members will only have read access.',
);
},
+ cveIdRequestIsDisabled() {
+ return this.visibilityLevel !== visibilityOptions.PUBLIC;
+ },
},
watch: {
@@ -417,6 +431,19 @@ export default {
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][issues_access_level]"
/>
+ <project-setting-row
+ v-if="requestCveAvailable"
+ :help-path="cveIdRequestHelpPath"
+ :help-text="$options.i18n.cve_request_toggle_label"
+ >
+ <gl-toggle
+ v-model="cveIdRequestEnabled"
+ class="gl-my-2"
+ :disabled="cveIdRequestIsDisabled"
+ name="project[project_setting_attributes][cve_id_request_enabled]"
+ data-testid="cve_id_request_toggle"
+ />
+ </project-setting-row>
</project-setting-row>
<project-setting-row
ref="repository-settings"
@@ -563,7 +590,6 @@ export default {
/>
</project-setting-row>
<project-setting-row
- v-if="securityAndComplianceAvailable"
:label="s__('ProjectSettings|Security & Compliance')"
:help-text="s__('ProjectSettings|Security & Compliance for this project')"
>
@@ -613,7 +639,9 @@ export default {
<project-setting-row
ref="operations-settings"
:label="s__('ProjectSettings|Operations')"
- :help-text="s__('ProjectSettings|Environments, logs, cluster management, and more.')"
+ :help-text="
+ s__('ProjectSettings|Configure your project resources and monitor their health.')
+ "
>
<project-feature-setting
v-model="operationsAccessLevel"
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index 6771391254e..e160fdacca6 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -1,4 +1,4 @@
-import { __ } from '~/locale';
+import { s__, __ } from '~/locale';
export const visibilityOptions = {
PRIVATE: 0,
@@ -42,3 +42,7 @@ export const featureAccessLevelEveryone = [
featureAccessLevel.EVERYONE,
featureAccessLevelDescriptions[featureAccessLevel.EVERYONE],
];
+
+export const CVE_ID_REQUEST_BUTTON_I18N = {
+ cve_request_toggle_label: s__('CVE|Enable CVE ID requests in the issue sidebar'),
+};
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 0494dad6e33..83e43d7ac48 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -3,20 +3,16 @@ import Activities from '~/activities';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import BlobViewer from '~/blob/viewer/index';
import { initUploadForm } from '~/blob_edit/blob_bundle';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import leaveByUrl from '~/namespaces/leave_by_url';
import initVueNotificationsDropdown from '~/notifications';
-import NotificationsForm from '~/notifications_form';
+import { initUploadFileTrigger } from '~/projects/upload_file_experiment';
import initReadMore from '~/read_more';
import UserCallout from '~/user_callout';
-import notificationsDropdown from '../../../notifications_dropdown';
import Star from '../../../star';
initReadMore();
new Star(); // eslint-disable-line no-new
-new NotificationsForm(); // eslint-disable-line no-new
// eslint-disable-next-line no-new
new UserCallout({
setCalloutPerProject: false,
@@ -24,9 +20,12 @@ new UserCallout({
});
// Project show page loads different overview content based on user preferences
-const treeSlider = document.getElementById('js-tree-list');
-if (treeSlider) {
+
+if (document.querySelector('.js-upload-blob-form')) {
initUploadForm();
+}
+
+if (document.getElementById('js-tree-list')) {
initTree();
}
@@ -40,15 +39,8 @@ if (document.querySelector('.project-show-activity')) {
leaveByUrl('project');
-if (gon.features?.vueNotificationDropdown) {
- initVueNotificationsDropdown();
-} else {
- notificationsDropdown();
-}
-
initVueNotificationsDropdown();
new ShortcutsNavigation(); // eslint-disable-line no-new
-initInviteMembersTrigger();
-initInviteMembersModal();
+initUploadFileTrigger();
diff --git a/app/assets/javascripts/pages/projects/tags/new/index.js b/app/assets/javascripts/pages/projects/tags/new/index.js
index 11a19a673b1..b071e7a45fc 100644
--- a/app/assets/javascripts/pages/projects/tags/new/index.js
+++ b/app/assets/javascripts/pages/projects/tags/new/index.js
@@ -3,8 +3,6 @@ import GLForm from '../../../../gl_form';
import RefSelectDropdown from '../../../../ref_select_dropdown';
import ZenMode from '../../../../zen_mode';
-document.addEventListener('DOMContentLoaded', () => {
- new ZenMode(); // eslint-disable-line no-new
- new GLForm($('.tag-form')); // eslint-disable-line no-new
- new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new
-});
+new ZenMode(); // eslint-disable-line no-new
+new GLForm($('.tag-form')); // eslint-disable-line no-new
+new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/tags/releases/index.js b/app/assets/javascripts/pages/projects/tags/releases/index.js
index abdc97f62d0..cafd880b4be 100644
--- a/app/assets/javascripts/pages/projects/tags/releases/index.js
+++ b/app/assets/javascripts/pages/projects/tags/releases/index.js
@@ -2,7 +2,5 @@ import $ from 'jquery';
import GLForm from '~/gl_form';
import ZenMode from '~/zen_mode';
-document.addEventListener('DOMContentLoaded', () => {
- new ZenMode(); // eslint-disable-line no-new
- new GLForm($('.release-form')); // eslint-disable-line no-new
-});
+new ZenMode(); // eslint-disable-line no-new
+new GLForm($('.release-form')); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js
index a8c288c3663..2ee33584ee1 100644
--- a/app/assets/javascripts/pages/search/show/index.js
+++ b/app/assets/javascripts/pages/search/show/index.js
@@ -1,5 +1,3 @@
import { initSearchApp } from '~/search';
-document.addEventListener('DOMContentLoaded', () => {
- initSearchApp();
-});
+initSearchApp();
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_alert.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_alert.vue
new file mode 100644
index 00000000000..6cea26f2bed
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_alert.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ error: {
+ type: String,
+ required: true,
+ },
+ wikiPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert variant="danger" :dismissible="false">
+ <gl-sprintf :message="error">
+ <template #wikiLink="{ content }">
+ <gl-link :href="wikiPagePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/index.js
index c8dc75828e4..c382a372260 100644
--- a/app/assets/javascripts/pages/shared/wikis/index.js
+++ b/app/assets/javascripts/pages/shared/wikis/index.js
@@ -6,9 +6,10 @@ import Translate from '~/vue_shared/translate';
import GLForm from '../../../gl_form';
import ZenMode from '../../../zen_mode';
import deleteWikiModal from './components/delete_wiki_modal.vue';
+import wikiAlert from './components/wiki_alert.vue';
import Wikis from './wikis';
-export default () => {
+const createModalVueApp = () => {
new Wikis(); // eslint-disable-line no-new
new ShortcutsWiki(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
@@ -39,3 +40,28 @@ export default () => {
});
}
};
+
+const createAlertVueApp = () => {
+ const el = document.getElementById('js-wiki-error');
+ if (el) {
+ const { error, wikiPagePath } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(wikiAlert, {
+ props: {
+ error,
+ wikiPagePath,
+ },
+ });
+ },
+ });
+ }
+};
+
+export default () => {
+ createModalVueApp();
+ createAlertVueApp();
+};
diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js
index b22287a0093..58ceb524360 100644
--- a/app/assets/javascripts/pages/users/index.js
+++ b/app/assets/javascripts/pages/users/index.js
@@ -15,9 +15,7 @@ function initUserProfile(action) {
});
}
-document.addEventListener('DOMContentLoaded', () => {
- const page = $('body').attr('data-page');
- const action = page.split(':')[1];
- initUserProfile(action);
- new UserCallout(); // eslint-disable-line no-new
-});
+const page = $('body').attr('data-page');
+const action = page.split(':')[1];
+initUserProfile(action);
+new UserCallout(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js
index 069f3c265f3..4ac758550e0 100644
--- a/app/assets/javascripts/performance/constants.js
+++ b/app/assets/javascripts/performance/constants.js
@@ -54,3 +54,24 @@ export const MR_DIFFS_MARK_DIFF_FILES_END = 'mr-diffs-mark-diff-files-end';
// Measures
export const MR_DIFFS_MEASURE_FILE_TREE_DONE = 'mr-diffs-measure-file-tree-done';
export const MR_DIFFS_MEASURE_DIFF_FILES_DONE = 'mr-diffs-measure-diff-files-done';
+
+//
+// Pipelines Detail namespace
+//
+
+// Marks
+export const PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START =
+ 'pipelines-detail-links-mark-calculate-start';
+export const PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END =
+ 'pipelines-detail-links-mark-calculate-end';
+
+// Measures
+export const PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION =
+ 'Pipelines Detail Graph: Links Calculation';
+
+// Metrics
+// Note: These strings must match the backend
+// (defined in: app/services/ci/prometheus_metrics/observe_histograms_service.rb)
+export const PIPELINES_DETAIL_LINK_DURATION = 'pipeline_graph_link_calculation_duration_seconds';
+export const PIPELINES_DETAIL_LINKS_TOTAL = 'pipeline_graph_links_total';
+export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_link_per_job_ratio';
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index de4bbb36141..9bf77239a6b 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -109,7 +109,7 @@ export default {
<div
v-for="(key, keyIndex) in keys"
:key="key"
- class="break-word"
+ class="text-break-word"
:class="{ 'mb-3 bold': keyIndex == 0 }"
>
{{ item[key] }}
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 85789cd1fdf..6b446eb6073 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -30,13 +30,17 @@ export default {
type: String,
required: true,
},
+ statsUrl: {
+ type: String,
+ required: true,
+ },
},
detailedMetrics: [
{
metric: 'active-record',
title: 'pg',
header: s__('PerformanceBar|SQL queries'),
- keys: ['sql', 'cached'],
+ keys: ['sql', 'cached', 'db_role'],
},
{
metric: 'bullet',
@@ -169,6 +173,9 @@ export default {
class="ml-auto"
@change-current-request="changeCurrentRequest"
/>
+ <div v-if="statsUrl" id="peek-stats" class="view">
+ <a class="gl-text-blue-300" :href="statsUrl">{{ s__('PerformanceBar|Stats') }}</a>
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 522e34753e9..51b6108868f 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -29,6 +29,7 @@ const initPerformanceBar = (el) => {
requestId: performanceBarData.requestId,
peekUrl: performanceBarData.peekUrl,
profileUrl: performanceBarData.profileUrl,
+ statsUrl: performanceBarData.statsUrl,
};
},
mounted() {
@@ -119,6 +120,7 @@ const initPerformanceBar = (el) => {
requestId: this.requestId,
peekUrl: this.peekUrl,
profileUrl: this.profileUrl,
+ statsUrl: this.statsUrl,
},
on: {
'add-request': this.addRequestManually,
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
index 9279273283e..b088678fee8 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
@@ -21,7 +21,7 @@ export default {
GlSprintf,
},
props: {
- defaultBranch: {
+ currentBranch: {
type: String,
required: false,
default: '',
@@ -40,23 +40,23 @@ export default {
data() {
return {
message: this.defaultMessage,
- branch: this.defaultBranch,
openMergeRequest: false,
+ targetBranch: this.currentBranch,
};
},
computed: {
- isDefaultBranch() {
- return this.branch === this.defaultBranch;
+ isCurrentBranchTarget() {
+ return this.targetBranch === this.currentBranch;
},
submitDisabled() {
- return !(this.message && this.branch);
+ return !(this.message && this.targetBranch);
},
},
methods: {
onSubmit() {
this.$emit('submit', {
message: this.message,
- branch: this.branch,
+ targetBranch: this.targetBranch,
openMergeRequest: this.openMergeRequest,
});
},
@@ -100,12 +100,12 @@ export default {
>
<gl-form-input
id="target-branch-field"
- v-model="branch"
+ v-model="targetBranch"
class="gl-font-monospace!"
required
/>
<gl-form-checkbox
- v-if="!isDefaultBranch"
+ v-if="!isCurrentBranchTarget"
v-model="openMergeRequest"
data-testid="new-mr-checkbox"
class="gl-mt-3"
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
index b40c9a48903..14a4a9d5710 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
@@ -1,9 +1,16 @@
<script>
import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
-import { COMMIT_FAILURE, COMMIT_SUCCESS } from '../../constants';
+import {
+ COMMIT_ACTION_CREATE,
+ COMMIT_ACTION_UPDATE,
+ COMMIT_FAILURE,
+ COMMIT_SUCCESS,
+} from '../../constants';
import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql';
import getCommitSha from '../../graphql/queries/client/commit_sha.graphql';
+import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql';
+import getIsNewCiConfigFile from '../../graphql/queries/client/is_new_ci_config_file.graphql';
import CommitForm from './commit_form.vue';
@@ -21,7 +28,7 @@ export default {
components: {
CommitForm,
},
- inject: ['projectFullPath', 'ciConfigPath', 'defaultBranch', 'newMergeRequestPath'],
+ inject: ['projectFullPath', 'ciConfigPath', 'newMergeRequestPath'],
props: {
ciFileContent: {
type: String,
@@ -31,15 +38,25 @@ export default {
data() {
return {
commit: {},
+ isNewCiConfigFile: false,
isSaving: false,
};
},
apollo: {
+ isNewCiConfigFile: {
+ query: getIsNewCiConfigFile,
+ },
commitSha: {
query: getCommitSha,
},
+ currentBranch: {
+ query: getCurrentBranch,
+ },
},
computed: {
+ action() {
+ return this.isNewCiConfigFile ? COMMIT_ACTION_CREATE : COMMIT_ACTION_UPDATE;
+ },
defaultCommitMessage() {
return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath });
},
@@ -49,13 +66,13 @@ export default {
const url = mergeUrlParams(
{
[MR_SOURCE_BRANCH]: sourceBranch,
- [MR_TARGET_BRANCH]: this.defaultBranch,
+ [MR_TARGET_BRANCH]: this.currentBranch,
},
this.newMergeRequestPath,
);
redirectTo(url);
},
- async onCommitSubmit({ message, branch, openMergeRequest }) {
+ async onCommitSubmit({ message, targetBranch, openMergeRequest }) {
this.isSaving = true;
try {
@@ -66,9 +83,10 @@ export default {
} = await this.$apollo.mutate({
mutation: commitCIFile,
variables: {
+ action: this.action,
projectPath: this.projectFullPath,
- branch,
- startBranch: this.defaultBranch,
+ branch: targetBranch,
+ startBranch: this.currentBranch,
message,
filePath: this.ciConfigPath,
content: this.ciFileContent,
@@ -86,7 +104,7 @@ export default {
if (errors?.length) {
this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors });
} else if (openMergeRequest) {
- this.redirectToNewMergeRequest(branch);
+ this.redirectToNewMergeRequest(targetBranch);
} else {
this.$emit('commit', { type: COMMIT_SUCCESS });
}
@@ -105,7 +123,7 @@ export default {
<template>
<commit-form
- :default-branch="defaultBranch"
+ :current-branch="currentBranch"
:default-message="defaultCommitMessage"
:is-saving="isSaving"
@cancel="onCommitCancel"
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
index 007faa4ed0d..f36b22f33c3 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
@@ -82,7 +82,7 @@ export default {
</gl-alert>
<div v-else>
<div class="gl-display-flex gl-align-items-center">
- <gl-icon :size="18" name="lock" class="gl-text-gray-500 gl-mr-3" />
+ <gl-icon :size="18" name="lock" use-deprecated-sizes class="gl-text-gray-500 gl-mr-3" />
{{ $options.i18n.viewOnlyMessage }}
</div>
<div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
index ab41c0170e9..7a35e31e9ce 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
@@ -1,13 +1,40 @@
<script>
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import PipelineStatus from './pipeline_status.vue';
import ValidationSegment from './validation_segment.vue';
+const baseClasses = ['gl-p-5', 'gl-bg-gray-10', 'gl-border-solid', 'gl-border-gray-100'];
+
+const pipelineStatusClasses = [
+ ...baseClasses,
+ 'gl-border-1',
+ 'gl-border-b-0!',
+ 'gl-rounded-top-base',
+];
+
+const validationSegmentClasses = [...baseClasses, 'gl-border-1', 'gl-rounded-base'];
+
+const validationSegmentWithPipelineStatusClasses = [
+ ...baseClasses,
+ 'gl-border-1',
+ 'gl-rounded-bottom-left-base',
+ 'gl-rounded-bottom-right-base',
+];
+
export default {
- validationSegmentClasses:
- 'gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base',
+ pipelineStatusClasses,
+ validationSegmentClasses,
+ validationSegmentWithPipelineStatusClasses,
components: {
+ PipelineStatus,
ValidationSegment,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
ciConfigData: {
type: Object,
required: true,
@@ -17,13 +44,27 @@ export default {
required: true,
},
},
+ computed: {
+ showPipelineStatus() {
+ return this.glFeatures.pipelineStatusForPipelineEditor;
+ },
+ // make sure corners are rounded correctly depending on if
+ // pipeline status is rendered
+ validationStyling() {
+ return this.showPipelineStatus
+ ? this.$options.validationSegmentWithPipelineStatusClasses
+ : this.$options.validationSegmentClasses;
+ },
+ },
};
</script>
<template>
<div class="gl-mb-5">
+ <pipeline-status v-if="showPipelineStatus" :class="$options.pipelineStatusClasses" />
<validation-segment
- :class="$options.validationSegmentClasses"
+ :class="validationStyling"
:loading="isCiConfigDataLoading"
+ :ci-file-content="ciFileContent"
:ci-config="ciConfigData"
/>
</div>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
new file mode 100644
index 00000000000..b1ea464be99
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -0,0 +1,120 @@
+<script>
+import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { s__ } from '~/locale';
+import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql';
+import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+
+const POLL_INTERVAL = 10000;
+export const i18n = {
+ fetchError: s__('Pipeline|We are currently unable to fetch pipeline data'),
+ fetchLoading: s__('Pipeline|Checking pipeline status'),
+ pipelineInfo: s__(
+ `Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`,
+ ),
+};
+
+export default {
+ i18n,
+ components: {
+ CiIcon,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ },
+ inject: ['projectFullPath'],
+ apollo: {
+ commitSha: {
+ query: getCommitSha,
+ },
+ pipeline: {
+ query: getPipelineQuery,
+ variables() {
+ return {
+ fullPath: this.projectFullPath,
+ sha: this.commitSha,
+ };
+ },
+ update: (data) => {
+ const { id, commitPath = '', shortSha = '', detailedStatus = {} } =
+ data.project?.pipeline || {};
+
+ return {
+ id,
+ commitPath,
+ shortSha,
+ detailedStatus,
+ };
+ },
+ error() {
+ this.hasError = true;
+ },
+ pollInterval: POLL_INTERVAL,
+ },
+ },
+ data() {
+ return {
+ hasError: false,
+ };
+ },
+ computed: {
+ hasPipelineData() {
+ return Boolean(this.$apollo.queries.pipeline?.id);
+ },
+ isQueryLoading() {
+ return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
+ },
+ status() {
+ return this.pipeline.detailedStatus;
+ },
+ pipelineId() {
+ return getIdFromGraphQLId(this.pipeline.id);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-white-space-nowrap gl-max-w-full">
+ <template v-if="isQueryLoading">
+ <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" />
+ <span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span>
+ </template>
+ <template v-else-if="hasError">
+ <gl-icon class="gl-mr-auto" name="warning-solid" />
+ <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span>
+ </template>
+ <template v-else>
+ <a :href="status.detailsPath" class="gl-mr-auto">
+ <ci-icon :status="status" :size="18" />
+ </a>
+ <span class="gl-font-weight-bold">
+ <gl-sprintf :message="$options.i18n.pipelineInfo">
+ <template #id="{ content }">
+ <gl-link
+ :href="status.detailsPath"
+ class="pipeline-id gl-font-weight-normal pipeline-number"
+ target="_blank"
+ data-testid="pipeline-id"
+ >
+ {{ content }}{{ pipelineId }}</gl-link
+ >
+ </template>
+ <template #status>{{ status.text }}</template>
+ <template #commit>
+ <gl-link
+ :href="pipeline.commitPath"
+ class="commit-sha gl-font-weight-normal"
+ target="_blank"
+ data-testid="pipeline-commit"
+ >
+ {{ pipeline.shortSha }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
index 94fb3a66fdd..541ab74b177 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
@@ -5,6 +5,9 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { CI_CONFIG_STATUS_VALID } from '../../constants';
export const i18n = {
+ empty: __(
+ "We'll continuously validate your pipeline configuration. The validation results will appear here.",
+ ),
learnMore: __('Learn more'),
loading: s__('Pipelines|Validating GitLab CI configuration…'),
invalid: s__('Pipelines|This GitLab CI configuration is invalid.'),
@@ -26,6 +29,10 @@ export default {
},
},
props: {
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
ciConfig: {
type: Object,
required: false,
@@ -38,17 +45,22 @@ export default {
},
},
computed: {
+ isEmpty() {
+ return !this.ciFileContent;
+ },
isValid() {
return this.ciConfig?.status === CI_CONFIG_STATUS_VALID;
},
icon() {
- if (this.isValid) {
+ if (this.isValid || this.isEmpty) {
return 'check';
}
return 'warning-solid';
},
message() {
- if (this.isValid) {
+ if (this.isEmpty) {
+ return this.$options.i18n.empty;
+ } else if (this.isValid) {
return this.$options.i18n.valid;
}
@@ -74,7 +86,7 @@ export default {
<tooltip-on-truncate :title="message" class="gl-text-truncate">
<gl-icon :name="icon" /> <span data-testid="validationMsg">{{ message }}</span>
</tooltip-on-truncate>
- <span class="gl-flex-shrink-0 gl-pl-2">
+ <span v-if="!isEmpty" class="gl-flex-shrink-0 gl-pl-2">
<gl-link data-testid="learnMoreLink" :href="ymlHelpPagePath">
{{ $options.i18n.learnMore }}
</gl-link>
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
new file mode 100644
index 00000000000..d4f04a0d055
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ components: {
+ GlButton,
+ GlSprintf,
+ },
+ i18n: {
+ title: __('Optimize your workflow with CI/CD Pipelines'),
+ body: __(
+ 'Create a new %{codeStart}.gitlab-ci.yml%{codeEnd} file at the root of the repository to get started.',
+ ),
+ btnText: __('Create new CI/CD pipeline'),
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ emptyStateIllustrationPath: {
+ default: '',
+ },
+ },
+ computed: {
+ showCTAButton() {
+ return this.glFeatures.pipelineEditorEmptyStateAction;
+ },
+ },
+ methods: {
+ createEmptyConfigFile() {
+ this.$emit('createEmptyConfigFile');
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
+ <img :src="emptyStateIllustrationPath" />
+ <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
+ <p class="gl-mt-3">
+ <gl-sprintf :message="$options.i18n.body">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-button
+ v-if="showCTAButton"
+ variant="confirm"
+ class="gl-mt-3"
+ @click="createEmptyConfigFile"
+ >
+ {{ $options.i18n.btnText }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index e676fdeae02..353deafe770 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -5,7 +5,6 @@ export const COMMIT_FAILURE = 'COMMIT_FAILURE';
export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
export const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
-export const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE';
export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
export const CREATE_TAB = 'CREATE_TAB';
@@ -14,3 +13,6 @@ export const MERGED_TAB = 'MERGED_TAB';
export const VISUALIZE_TAB = 'VISUALIZE_TAB';
export const TABS_WITH_COMMIT_FORM = [CREATE_TAB, LINT_TAB, VISUALIZE_TAB];
+
+export const COMMIT_ACTION_CREATE = 'CREATE';
+export const COMMIT_ACTION_UPDATE = 'UPDATE';
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
index 2af0cd5f6d4..3b2daa45a18 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
@@ -1,4 +1,5 @@
mutation commitCIFile(
+ $action: CommitActionMode!
$projectPath: ID!
$branch: String!
$startBranch: String
@@ -14,7 +15,7 @@ mutation commitCIFile(
startBranch: $startBranch
message: $message
actions: [
- { action: UPDATE, filePath: $filePath, lastCommitId: $lastCommitId, content: $content }
+ { action: $action, filePath: $filePath, lastCommitId: $lastCommitId, content: $content }
]
}
) {
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql
new file mode 100644
index 00000000000..acd46013f5b
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql
@@ -0,0 +1,3 @@
+query getCurrentBranch {
+ currentBranch @client
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql
new file mode 100644
index 00000000000..8c2ca276f50
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql
@@ -0,0 +1,3 @@
+query getIsNewCiConfigFile {
+ isNewCiConfigFile @client
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
new file mode 100644
index 00000000000..7cc7f92fb60
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
@@ -0,0 +1,17 @@
+query getPipeline($fullPath: ID!, $sha: String!) {
+ project(fullPath: $fullPath) @client {
+ pipeline(sha: $sha) {
+ commitPath
+ id
+ iid
+ shortSha
+ status
+ detailedStatus {
+ detailsPath
+ icon
+ group
+ text
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
index 81e75c32846..13f6200693b 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
+++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
@@ -11,6 +11,29 @@ export const resolvers = {
}),
};
},
+
+ /* eslint-disable @gitlab/require-i18n-strings */
+ project() {
+ return {
+ __typename: 'Project',
+ pipeline: {
+ __typename: 'Pipeline',
+ commitPath: `/-/commit/aabbccdd`,
+ id: 'gid://gitlab/Ci::Pipeline/118',
+ iid: '28',
+ shortSha: 'aabbccdd',
+ status: 'SUCCESS',
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ detailsPath: '/root/sample-ci-project/-/pipelines/118"',
+ group: 'success',
+ icon: 'status_success',
+ text: 'passed',
+ },
+ },
+ };
+ },
+ /* eslint-enable @gitlab/require-i18n-strings */
},
Mutation: {
lintCI: (_, { endpoint, content, dry_run }) => {
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index dc427f55b5f..b17ec2d5c25 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -22,9 +22,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
const {
// Add to apollo cache as it can be updated by future queries
commitSha,
+ initialBranchName,
// Add to provide/inject API for static values
ciConfigPath,
defaultBranch,
+ emptyStateIllustrationPath,
lintHelpPagePath,
newMergeRequestPath,
projectFullPath,
@@ -41,6 +43,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
apolloProvider.clients.defaultClient.cache.writeData({
data: {
+ currentBranch: initialBranchName || defaultBranch,
commitSha,
},
});
@@ -51,6 +54,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
provide: {
ciConfigPath,
defaultBranch,
+ emptyStateIllustrationPath,
lintHelpPagePath,
newMergeRequestPath,
projectFullPath,
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index b4a818e2472..c1168979e9f 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -1,19 +1,16 @@
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import httpStatusCodes from '~/lib/utils/http_status';
-import { __, s__, sprintf } from '~/locale';
+import { __, s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue';
-import {
- COMMIT_FAILURE,
- COMMIT_SUCCESS,
- DEFAULT_FAILURE,
- LOAD_FAILURE_NO_FILE,
- LOAD_FAILURE_UNKNOWN,
-} from './constants';
+import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue';
+import { COMMIT_FAILURE, COMMIT_SUCCESS, DEFAULT_FAILURE, LOAD_FAILURE_UNKNOWN } from './constants';
import getBlobContent from './graphql/queries/blob_content.graphql';
import getCiConfigData from './graphql/queries/ci_config.graphql';
+import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
+import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql';
import PipelineEditorHome from './pipeline_editor_home.vue';
export default {
@@ -21,15 +18,13 @@ export default {
ConfirmUnsavedChangesDialog,
GlAlert,
GlLoadingIcon,
+ PipelineEditorEmptyState,
PipelineEditorHome,
},
inject: {
ciConfigPath: {
default: '',
},
- defaultBranch: {
- default: null,
- },
projectFullPath: {
default: '',
},
@@ -40,6 +35,8 @@ export default {
// Success and failure state
failureType: null,
failureReasons: [],
+ showStartScreen: false,
+ isNewCiConfigFile: false,
initialCiFileContent: '',
lastCommittedContent: '',
currentCiFileContent: '',
@@ -51,11 +48,18 @@ export default {
apollo: {
initialCiFileContent: {
query: getBlobContent,
+ // If it's a brand new file, we don't want to fetch the content.
+ // Then when the user commits the first time, the query would run
+ // to get the initial file content, but we already have it in `lastCommitedContent`
+ // so we skip the loading altogether.
+ skip({ isNewCiConfigFile, lastCommittedContent }) {
+ return isNewCiConfigFile || lastCommittedContent;
+ },
variables() {
return {
projectPath: this.projectFullPath,
path: this.ciConfigPath,
- ref: this.defaultBranch,
+ ref: this.currentBranch,
};
},
update(data) {
@@ -94,6 +98,12 @@ export default {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
},
},
+ currentBranch: {
+ query: getCurrentBranch,
+ },
+ isNewCiConfigFile: {
+ query: getIsNewCiConfigFile,
+ },
},
computed: {
hasUnsavedChanges() {
@@ -102,21 +112,11 @@ export default {
isBlobContentLoading() {
return this.$apollo.queries.initialCiFileContent.loading;
},
- isBlobContentError() {
- return this.failureType === LOAD_FAILURE_NO_FILE;
- },
isCiConfigDataLoading() {
return this.$apollo.queries.ciConfigData.loading;
},
failure() {
switch (this.failureType) {
- case LOAD_FAILURE_NO_FILE:
- return {
- text: sprintf(this.$options.errorTexts[LOAD_FAILURE_NO_FILE], {
- filePath: this.ciConfigPath,
- }),
- variant: 'danger',
- };
case LOAD_FAILURE_UNKNOWN:
return {
text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN],
@@ -154,9 +154,6 @@ export default {
errorTexts: {
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
[DEFAULT_FAILURE]: __('Something went wrong on our end.'),
- [LOAD_FAILURE_NO_FILE]: s__(
- 'Pipelines|There is no %{filePath} file in this repository, please add one and visit the Pipeline Editor again.',
- ),
[LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
},
successTexts: {
@@ -173,7 +170,7 @@ export default {
response?.status === httpStatusCodes.NOT_FOUND ||
response?.status === httpStatusCodes.BAD_REQUEST
) {
- this.reportFailure(LOAD_FAILURE_NO_FILE);
+ this.showStartScreen = true;
} else {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
}
@@ -186,17 +183,25 @@ export default {
this.showSuccessAlert = false;
},
reportFailure(type, reasons = []) {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
this.showFailureAlert = true;
this.failureType = type;
this.failureReasons = reasons;
},
reportSuccess(type) {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
this.showSuccessAlert = true;
this.successType = type;
},
resetContent() {
this.currentCiFileContent = this.lastCommittedContent;
},
+ setNewEmptyCiConfigFile() {
+ this.$apollo
+ .getClient()
+ .writeQuery({ query: getIsNewCiConfigFile, data: { isNewCiConfigFile: true } });
+ this.showStartScreen = false;
+ },
showErrorAlert({ type, reasons = [] }) {
this.reportFailure(type, reasons);
},
@@ -205,6 +210,12 @@ export default {
},
updateOnCommit({ type }) {
this.reportSuccess(type);
+
+ if (this.isNewCiConfigFile) {
+ this.$apollo
+ .getClient()
+ .writeQuery({ query: getIsNewCiConfigFile, data: { isNewCiConfigFile: false } });
+ }
// Keep track of the latest commited content to know
// if the user has made changes to the file that are unsaved.
this.lastCommittedContent = this.currentCiFileContent;
@@ -214,18 +225,22 @@ export default {
</script>
<template>
- <div class="gl-mt-4">
- <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess">
- {{ success.text }}
- </gl-alert>
- <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure">
- {{ failure.text }}
- <ul v-if="failureReasons.length" class="gl-mb-0">
- <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li>
- </ul>
- </gl-alert>
+ <div class="gl-mt-4 gl-relative">
<gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" />
- <div v-else-if="!isBlobContentError" class="gl-mt-4">
+ <pipeline-editor-empty-state
+ v-else-if="showStartScreen"
+ @createEmptyConfigFile="setNewEmptyCiConfigFile"
+ />
+ <div v-else>
+ <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess">
+ {{ success.text }}
+ </gl-alert>
+ <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure">
+ {{ failure.text }}
+ <ul v-if="failureReasons.length" class="gl-mb-0">
+ <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li>
+ </ul>
+ </gl-alert>
<pipeline-editor-home
:is-ci-config-data-loading="isCiConfigDataLoading"
:ci-config-data="ciConfigData"
@@ -235,7 +250,7 @@ export default {
@showError="showErrorAlert"
@updateCiConfig="updateCiConfig"
/>
+ <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" />
</div>
- <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" />
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index 8c9aad6ed87..ef46040153f 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -45,6 +45,7 @@ export default {
<template>
<div>
<pipeline-editor-header
+ :ci-file-content="ciFileContent"
:ci-config-data="ciConfigData"
:is-ci-config-data-loading="isCiConfigDataLoading"
/>
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index 5070971c563..ff6a354f673 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -9,14 +9,11 @@ import {
GlFormSelect,
GlFormTextarea,
GlLink,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
GlSprintf,
GlLoadingIcon,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { uniqueId } from 'lodash';
import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
@@ -24,21 +21,27 @@ import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__, __, n__ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants';
+import RefsDropdown from './refs_dropdown.vue';
+
+const i18n = {
+ variablesDescription: s__(
+ 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.',
+ ),
+ defaultError: __('Something went wrong on our end. Please try again.'),
+ refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'),
+ submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'),
+ warningTitle: __('The form contains the following warning:'),
+ maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
+};
export default {
typeOptions: [
{ value: VARIABLE_TYPE, text: __('Variable') },
{ value: FILE_TYPE, text: __('File') },
],
- variablesDescription: s__(
- 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.',
- ),
+ i18n,
formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0',
- errorTitle: __('Pipeline cannot be run.'),
- warningTitle: __('The form contains the following warning:'),
- maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
// this height value is used inline on the textarea to match the input field height
// it's used to prevent the overwrite if 'gl-h-7' or 'gl-h-7!' were used
textAreaStyle: { height: '32px' },
@@ -52,12 +55,9 @@ export default {
GlFormSelect,
GlFormTextarea,
GlLink,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
GlSprintf,
GlLoadingIcon,
+ RefsDropdown,
},
directives: { SafeHtml },
props: {
@@ -77,14 +77,6 @@ export default {
type: String,
required: true,
},
- branches: {
- type: Array,
- required: true,
- },
- tags: {
- type: Array,
- required: true,
- },
settingsLink: {
type: String,
required: true,
@@ -111,11 +103,11 @@ export default {
},
data() {
return {
- searchTerm: '',
refValue: {
shortName: this.refParam,
},
form: {},
+ errorTitle: null,
error: null,
warnings: [],
totalWarnings: 0,
@@ -125,22 +117,6 @@ export default {
};
},
computed: {
- lowerCasedSearchTerm() {
- return this.searchTerm.toLowerCase();
- },
- filteredBranches() {
- return this.branches.filter((branch) =>
- branch.shortName.toLowerCase().includes(this.lowerCasedSearchTerm),
- );
- },
- filteredTags() {
- return this.tags.filter((tag) =>
- tag.shortName.toLowerCase().includes(this.lowerCasedSearchTerm),
- );
- },
- hasTags() {
- return this.tags.length > 0;
- },
overMaxWarningsLimit() {
return this.totalWarnings > this.maxWarnings;
},
@@ -148,7 +124,7 @@ export default {
return n__('%d warning found:', '%d warnings found:', this.warnings.length);
},
summaryMessage() {
- return this.overMaxWarningsLimit ? this.$options.maxWarningsSummary : this.warningsSummary;
+ return this.overMaxWarningsLimit ? i18n.maxWarningsSummary : this.warningsSummary;
},
shouldShowWarning() {
return this.warnings.length > 0 && !this.isWarningDismissed;
@@ -166,6 +142,11 @@ export default {
return this.form[this.refFullName]?.descriptions ?? {};
},
},
+ watch: {
+ refValue() {
+ this.loadConfigVariablesForm();
+ },
+ },
created() {
// this is needed until we add support for ref type in url query strings
// ensure default branch is called with full ref on load
@@ -174,7 +155,7 @@ export default {
this.refValue.fullName = `refs/heads/${this.refValue.shortName}`;
}
- this.setRefSelected(this.refValue);
+ this.loadConfigVariablesForm();
},
methods: {
addEmptyVariable(refValue) {
@@ -213,49 +194,47 @@ export default {
this.setVariable(refValue, type, key, value);
});
},
- setRefSelected(refValue) {
- this.refValue = refValue;
-
- if (!this.form[this.refFullName]) {
- this.fetchConfigVariables(this.refFullName || this.refShortName)
- .then(({ descriptions, params }) => {
- Vue.set(this.form, this.refFullName, {
- variables: [],
- descriptions,
- });
-
- // Add default variables from yml
- this.setVariableParams(this.refFullName, VARIABLE_TYPE, params);
- })
- .catch(() => {
- Vue.set(this.form, this.refFullName, {
- variables: [],
- descriptions: {},
- });
- })
- .finally(() => {
- // Add/update variables, e.g. from query string
- if (this.variableParams) {
- this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams);
- }
- if (this.fileParams) {
- this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams);
- }
-
- // Adds empty var at the end of the form
- this.addEmptyVariable(this.refFullName);
- });
- }
- },
- isSelected(ref) {
- return ref.fullName === this.refValue.fullName;
- },
removeVariable(index) {
this.variables.splice(index, 1);
},
canRemove(index) {
return index < this.variables.length - 1;
},
+ loadConfigVariablesForm() {
+ // Skip when variables already cached in `form`
+ if (this.form[this.refFullName]) {
+ return;
+ }
+
+ this.fetchConfigVariables(this.refFullName || this.refShortName)
+ .then(({ descriptions, params }) => {
+ Vue.set(this.form, this.refFullName, {
+ variables: [],
+ descriptions,
+ });
+
+ // Add default variables from yml
+ this.setVariableParams(this.refFullName, VARIABLE_TYPE, params);
+ })
+ .catch(() => {
+ Vue.set(this.form, this.refFullName, {
+ variables: [],
+ descriptions: {},
+ });
+ })
+ .finally(() => {
+ // Add/update variables, e.g. from query string
+ if (this.variableParams) {
+ this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams);
+ }
+ if (this.fileParams) {
+ this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams);
+ }
+
+ // Adds empty var at the end of the form
+ this.addEmptyVariable(this.refFullName);
+ });
+ },
fetchConfigVariables(refValue) {
this.isLoading = true;
@@ -330,11 +309,25 @@ export default {
} = err?.response?.data;
const [error] = errors;
- this.error = error;
- this.warnings = warnings;
- this.totalWarnings = totalWarnings;
+ this.reportError({
+ title: i18n.submitErrorTitle,
+ error,
+ warnings,
+ totalWarnings,
+ });
});
},
+ onRefsLoadingError(error) {
+ this.reportError({ title: i18n.refsLoadingErrorTitle });
+
+ Sentry.captureException(error);
+ },
+ reportError({ title = null, error = i18n.defaultError, warnings = [], totalWarnings = 0 }) {
+ this.errorTitle = title;
+ this.error = error;
+ this.warnings = warnings;
+ this.totalWarnings = totalWarnings;
+ },
},
};
</script>
@@ -343,7 +336,7 @@ export default {
<gl-form @submit.prevent="createPipeline">
<gl-alert
v-if="error"
- :title="$options.errorTitle"
+ :title="errorTitle"
:dismissible="false"
variant="danger"
class="gl-mb-4"
@@ -353,7 +346,7 @@ export default {
</gl-alert>
<gl-alert
v-if="shouldShowWarning"
- :title="$options.warningTitle"
+ :title="$options.i18n.warningTitle"
variant="warning"
class="gl-mb-4"
data-testid="run-pipeline-warning-alert"
@@ -380,31 +373,7 @@ export default {
</details>
</gl-alert>
<gl-form-group :label="s__('Pipeline|Run for branch name or tag')">
- <gl-dropdown :text="refShortName" block>
- <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search refs')" />
- <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="branch in filteredBranches"
- :key="branch.fullName"
- class="gl-font-monospace"
- is-check-item
- :is-checked="isSelected(branch)"
- @click="setRefSelected(branch)"
- >
- {{ branch.shortName }}
- </gl-dropdown-item>
- <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="tag in filteredTags"
- :key="tag.fullName"
- class="gl-font-monospace"
- is-check-item
- :is-checked="isSelected(tag)"
- @click="setRefSelected(tag)"
- >
- {{ tag.shortName }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" />
</gl-form-group>
<gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" />
@@ -465,7 +434,7 @@ export default {
</div>
<template #description
- ><gl-sprintf :message="$options.variablesDescription">
+ ><gl-sprintf :message="$options.i18n.variablesDescription">
<template #link="{ content }">
<gl-link :href="settingsLink">{{ content }}</gl-link>
</template>
diff --git a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue
new file mode 100644
index 00000000000..ed5c659d1df
--- /dev/null
+++ b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue
@@ -0,0 +1,113 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import axios from '~/lib/utils/axios_utils';
+import { BRANCH_REF_TYPE, TAG_REF_TYPE, DEBOUNCE_REFS_SEARCH_MS } from '../constants';
+import formatRefs from '../utils/format_refs';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ },
+ inject: ['projectRefsEndpoint'],
+ props: {
+ value: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ searchTerm: '',
+ branches: [],
+ tags: [],
+ };
+ },
+ computed: {
+ lowerCasedSearchTerm() {
+ return this.searchTerm.toLowerCase();
+ },
+ refShortName() {
+ return this.value.shortName;
+ },
+ hasTags() {
+ return this.tags.length > 0;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.debouncedLoadRefs();
+ },
+ },
+ methods: {
+ loadRefs() {
+ this.isLoading = true;
+
+ axios
+ .get(this.projectRefsEndpoint, {
+ params: {
+ search: this.lowerCasedSearchTerm,
+ },
+ })
+ .then(({ data }) => {
+ // Note: These keys are uppercase in API
+ const { Branches = [], Tags = [] } = data;
+
+ this.branches = formatRefs(Branches, BRANCH_REF_TYPE);
+ this.tags = formatRefs(Tags, TAG_REF_TYPE);
+ })
+ .catch((e) => {
+ this.$emit('loadingError', e);
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ debouncedLoadRefs: debounce(function debouncedLoadRefs() {
+ this.loadRefs();
+ }, DEBOUNCE_REFS_SEARCH_MS),
+ setRefSelected(ref) {
+ this.$emit('input', ref);
+ },
+ isSelected(ref) {
+ return ref.fullName === this.value.fullName;
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown :text="refShortName" block @show.once="loadRefs">
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :is-loading="isLoading"
+ :placeholder="__('Search refs')"
+ />
+ <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="branch in branches"
+ :key="branch.fullName"
+ class="gl-font-monospace"
+ is-check-item
+ :is-checked="isSelected(branch)"
+ @click="setRefSelected(branch)"
+ >
+ {{ branch.shortName }}
+ </gl-dropdown-item>
+ <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="tag in tags"
+ :key="tag.fullName"
+ class="gl-font-monospace"
+ is-check-item
+ :is-checked="isSelected(tag)"
+ @click="setRefSelected(tag)"
+ >
+ {{ tag.shortName }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js
index 004bbe7daf4..681755dc6ab 100644
--- a/app/assets/javascripts/pipeline_new/constants.js
+++ b/app/assets/javascripts/pipeline_new/constants.js
@@ -1,5 +1,6 @@
export const VARIABLE_TYPE = 'env_var';
export const FILE_TYPE = 'file';
+export const DEBOUNCE_REFS_SEARCH_MS = 250;
export const CONFIG_VARIABLES_TIMEOUT = 5000;
export const BRANCH_REF_TYPE = 'branch';
export const TAG_REF_TYPE = 'tag';
diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js
index 0b85184ec90..a645ea8603b 100644
--- a/app/assets/javascripts/pipeline_new/index.js
+++ b/app/assets/javascripts/pipeline_new/index.js
@@ -1,10 +1,13 @@
import Vue from 'vue';
import PipelineNewForm from './components/pipeline_new_form.vue';
-import formatRefs from './utils/format_refs';
export default () => {
const el = document.getElementById('js-new-pipeline');
const {
+ // provide/inject
+ projectRefsEndpoint,
+
+ // props
projectId,
pipelinesPath,
configVariablesPath,
@@ -12,19 +15,18 @@ export default () => {
refParam,
varParam,
fileParam,
- branchRefs,
- tagRefs,
settingsLink,
maxWarnings,
} = el?.dataset;
const variableParams = JSON.parse(varParam);
const fileParams = JSON.parse(fileParam);
- const branches = formatRefs(JSON.parse(branchRefs), 'branch');
- const tags = formatRefs(JSON.parse(tagRefs), 'tag');
return new Vue({
el,
+ provide: {
+ projectRefsEndpoint,
+ },
render(createElement) {
return createElement(PipelineNewForm, {
props: {
@@ -35,8 +37,6 @@ export default () => {
refParam,
variableParams,
fileParams,
- branches,
- tags,
settingsLink,
maxWarnings: Number(maxWarnings),
},
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 93156d5d05b..363226a0d85 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -4,7 +4,7 @@ import LinksLayer from '../graph_shared/links_layer.vue';
import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH } from './constants';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue';
-import { reportToSentry } from './utils';
+import { reportToSentry, validateConfigPaths } from './utils';
export default {
name: 'PipelineGraph',
@@ -15,15 +15,20 @@ export default {
StageColumnComponent,
},
props: {
- isLinkedPipeline: {
- type: Boolean,
- required: false,
- default: false,
+ configPaths: {
+ type: Object,
+ required: true,
+ validator: validateConfigPaths,
},
pipeline: {
type: Object,
required: true,
},
+ isLinkedPipeline: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
type: {
type: String,
required: false,
@@ -66,6 +71,12 @@ export default {
hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0);
},
+ metricsConfig() {
+ return {
+ path: this.configPaths.metricsPath,
+ collectMetrics: true,
+ };
+ },
// The show downstream check prevents showing redundant linked columns
showDownstreamPipelines() {
return (
@@ -95,8 +106,8 @@ export default {
height: this.$refs[this.containerId].scrollHeight,
};
},
- onError(errorType) {
- this.$emit('error', errorType);
+ onError(payload) {
+ this.$emit('error', payload);
},
setJob(jobName) {
this.hoveredJobName = jobName;
@@ -131,6 +142,7 @@ export default {
<template #upstream>
<linked-pipelines-column
v-if="showUpstreamPipelines"
+ :config-paths="configPaths"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:type="$options.pipelineTypeConstants.UPSTREAM"
@@ -145,6 +157,8 @@ export default {
:container-id="containerId"
:container-measurements="measurements"
:highlighted-job="hoveredJobName"
+ :metrics-config="metricsConfig"
+ :never-show-links="true"
default-link-color="gl-stroke-transparent"
@error="onError"
@highlightedJobsChange="updateHighlightedJobs"
@@ -170,6 +184,7 @@ export default {
<linked-pipelines-column
v-if="showDownstreamPipelines"
class="gl-mr-6"
+ :config-paths="configPaths"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index f596333237d..962f2ca2a4c 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -2,9 +2,15 @@
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import { __ } from '~/locale';
-import { DEFAULT, LOAD_FAILURE } from '../../constants';
+import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants';
import PipelineGraph from './graph_component.vue';
-import { unwrapPipelineData, toggleQueryPollingByVisibility, reportToSentry } from './utils';
+import {
+ getQueryHeaders,
+ reportToSentry,
+ serializeLoadErrors,
+ toggleQueryPollingByVisibility,
+ unwrapPipelineData,
+} from './utils';
export default {
name: 'PipelineGraphWrapper',
@@ -14,6 +20,12 @@ export default {
PipelineGraph,
},
inject: {
+ graphqlResourceEtag: {
+ default: '',
+ },
+ metricsPath: {
+ default: '',
+ },
pipelineIid: {
default: '',
},
@@ -29,11 +41,15 @@ export default {
};
},
errorTexts: {
+ [DRAW_FAILURE]: __('An error occurred while drawing job relationship links.'),
[LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'),
[DEFAULT]: __('An unknown error occurred while loading this graph.'),
},
apollo: {
pipeline: {
+ context() {
+ return getQueryHeaders(this.graphqlResourceEtag);
+ },
query: getPipelineDetails,
pollInterval: 10000,
variables() {
@@ -43,16 +59,45 @@ export default {
};
},
update(data) {
+ /*
+ This check prevents the pipeline from being overwritten
+ when a poll times out and the data returned is empty.
+ This can be removed once the timeout behavior is updated.
+ See: https://gitlab.com/gitlab-org/gitlab/-/issues/323213.
+ */
+
+ if (!data?.project?.pipeline) {
+ return this.pipeline;
+ }
+
return unwrapPipelineData(this.pipelineProjectPath, data);
},
- error() {
- this.reportFailure(LOAD_FAILURE);
+ error(err) {
+ this.reportFailure({ type: LOAD_FAILURE, skipSentry: true });
+ reportToSentry(
+ this.$options.name,
+ `type: ${LOAD_FAILURE}, info: ${serializeLoadErrors(err)}`,
+ );
+ },
+ result({ error }) {
+ /*
+ If there is a successful load after a failure, clear
+ the failure notification to avoid confusion.
+ */
+ if (!error && this.alertType === LOAD_FAILURE) {
+ this.hideAlert();
+ }
},
},
},
computed: {
alert() {
switch (this.alertType) {
+ case DRAW_FAILURE:
+ return {
+ text: this.$options.errorTexts[DRAW_FAILURE],
+ variant: 'danger',
+ };
case LOAD_FAILURE:
return {
text: this.$options.errorTexts[LOAD_FAILURE],
@@ -65,6 +110,12 @@ export default {
};
}
},
+ configPaths() {
+ return {
+ graphqlResourceEtag: this.graphqlResourceEtag,
+ metricsPath: this.metricsPath,
+ };
+ },
showLoadingIcon() {
/*
Shows the icon only when the graph is empty, not when it is is
@@ -82,15 +133,20 @@ export default {
methods: {
hideAlert() {
this.showAlert = false;
+ this.alertType = null;
},
refreshPipelineGraph() {
this.$apollo.queries.pipeline.refetch();
},
- reportFailure(type) {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ reportFailure({ type, err = 'No error string passed.', skipSentry = false }) {
this.showAlert = true;
- this.failureType = type;
- reportToSentry(this.$options.name, this.failureType);
+ this.alertType = type;
+ if (!skipSentry) {
+ reportToSentry(this.$options.name, `type: ${type}, info: ${err}`);
+ }
},
+ /* eslint-enable @gitlab/require-i18n-strings */
},
};
</script>
@@ -102,6 +158,7 @@ export default {
<gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" />
<pipeline-graph
v-if="pipeline"
+ :config-paths="configPaths"
:pipeline="pipeline"
@error="reportFailure"
@refreshPipelineGraph="refreshPipelineGraph"
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 3ce77a1c60a..b55a77a3c4f 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -3,7 +3,14 @@ import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.qu
import { LOAD_FAILURE } from '../../constants';
import { ONE_COL_WIDTH, UPSTREAM } from './constants';
import LinkedPipeline from './linked_pipeline.vue';
-import { unwrapPipelineData, toggleQueryPollingByVisibility, reportToSentry } from './utils';
+import {
+ getQueryHeaders,
+ reportToSentry,
+ serializeLoadErrors,
+ toggleQueryPollingByVisibility,
+ unwrapPipelineData,
+ validateConfigPaths,
+} from './utils';
export default {
components: {
@@ -15,6 +22,11 @@ export default {
type: String,
required: true,
},
+ configPaths: {
+ type: Object,
+ required: true,
+ validator: validateConfigPaths,
+ },
linkedPipelines: {
type: Array,
required: true,
@@ -72,6 +84,9 @@ export default {
this.$apollo.addSmartQuery('currentPipeline', {
query: getPipelineDetails,
pollInterval: 10000,
+ context() {
+ return getQueryHeaders(this.configPaths.graphqlResourceEtag);
+ },
variables() {
return {
projectPath,
@@ -79,18 +94,29 @@ export default {
};
},
update(data) {
+ /*
+ This check prevents the pipeline from being overwritten
+ when a poll times out and the data returned is empty.
+ This can be removed once the timeout behavior is updated.
+ See: https://gitlab.com/gitlab-org/gitlab/-/issues/323213.
+ */
+
+ if (!data?.project?.pipeline) {
+ return this.currentPipeline;
+ }
+
return unwrapPipelineData(projectPath, data);
},
result() {
this.loadingPipelineId = null;
this.$emit('scrollContainer');
},
- error(err, _vm, _key, type) {
- this.$emit('error', LOAD_FAILURE);
+ error(err) {
+ this.$emit('error', { type: LOAD_FAILURE, skipSentry: true });
reportToSentry(
'linked_pipelines_column',
- `error type: ${LOAD_FAILURE}, error: ${err}, apollo error type: ${type}`,
+ `error type: ${LOAD_FAILURE}, error: ${serializeLoadErrors(err)}`,
);
},
});
@@ -175,6 +201,7 @@ export default {
v-if="isExpanded(pipeline.id)"
:type="type"
class="d-inline-block gl-mt-n2"
+ :config-paths="configPaths"
:pipeline="currentPipeline"
:is-linked-pipeline="true"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
index 1a935599bfa..b9a8e2638bc 100644
--- a/app/assets/javascripts/pipelines/components/graph/utils.js
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -1,6 +1,6 @@
+import * as Sentry from '@sentry/browser';
import Visibility from 'visibilityjs';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import * as Sentry from '~/sentry/wrapper';
import { unwrapStagesWithNeeds } from '../unwrapping_utils';
const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
@@ -10,6 +10,73 @@ const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
};
};
+/* eslint-disable @gitlab/require-i18n-strings */
+const getQueryHeaders = (etagResource) => {
+ return {
+ fetchOptions: {
+ method: 'GET',
+ },
+ headers: {
+ 'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': 'verify/ci/pipeline-graph',
+ 'X-GITLAB-GRAPHQL-RESOURCE-ETAG': etagResource,
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ };
+};
+
+const reportToSentry = (component, failureType) => {
+ Sentry.withScope((scope) => {
+ scope.setTag('component', component);
+ Sentry.captureException(failureType);
+ });
+};
+
+const serializeGqlErr = (gqlError) => {
+ const { locations = [], message = '', path = [] } = gqlError;
+
+ return `
+ ${message}.
+ Locations: ${locations
+ .flatMap((loc) => Object.entries(loc))
+ .flat(2)
+ .join(' ')}.
+ Path: ${path.join(', ')}.
+ `;
+};
+
+const serializeLoadErrors = (errors) => {
+ const { gqlError, graphQLErrors, networkError, message } = errors;
+
+ if (graphQLErrors) {
+ return graphQLErrors.map((err) => serializeGqlErr(err)).join('; ');
+ }
+
+ if (gqlError) {
+ return serializeGqlErr(gqlError);
+ }
+
+ if (networkError) {
+ return `Network error: ${networkError.message}`;
+ }
+
+ return message;
+};
+
+/* eslint-enable @gitlab/require-i18n-strings */
+
+const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => {
+ const stopStartQuery = (query) => {
+ if (!Visibility.hidden()) {
+ query.startPolling(interval);
+ } else {
+ query.stopPolling();
+ }
+ };
+
+ stopStartQuery(queryRef);
+ Visibility.change(stopStartQuery.bind(null, queryRef));
+};
+
const transformId = (linkedPipeline) => {
return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) };
};
@@ -42,24 +109,14 @@ const unwrapPipelineData = (mainPipelineProjectPath, data) => {
};
};
-const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => {
- const stopStartQuery = (query) => {
- if (!Visibility.hidden()) {
- query.startPolling(interval);
- } else {
- query.stopPolling();
- }
- };
-
- stopStartQuery(queryRef);
- Visibility.change(stopStartQuery.bind(null, queryRef));
-};
-
-export { unwrapPipelineData, toggleQueryPollingByVisibility };
+const validateConfigPaths = (value) => value.graphqlResourceEtag?.length > 0;
-export const reportToSentry = (component, failureType) => {
- Sentry.withScope((scope) => {
- scope.setTag('component', component);
- Sentry.captureException(failureType);
- });
+export {
+ getQueryHeaders,
+ reportToSentry,
+ serializeGqlErr,
+ serializeLoadErrors,
+ toggleQueryPollingByVisibility,
+ unwrapPipelineData,
+ validateConfigPaths,
};
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js
new file mode 100644
index 00000000000..04ac15ae24c
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js
@@ -0,0 +1,8 @@
+import axios from '~/lib/utils/axios_utils';
+import { reportToSentry } from '../graph/utils';
+
+export const reportPerformance = (path, stats) => {
+ axios.post(path, stats).catch((err) => {
+ reportToSentry('links_inner_perf', `error: ${err}`);
+ });
+};
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
index 289e04e02c5..fad57084992 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
@@ -1,8 +1,19 @@
<script>
import { isEmpty } from 'lodash';
+import {
+ PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
+ PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
+ PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ PIPELINES_DETAIL_LINK_DURATION,
+ PIPELINES_DETAIL_LINKS_TOTAL,
+ PIPELINES_DETAIL_LINKS_JOB_RATIO,
+} from '~/performance/constants';
+import { performanceMarkAndMeasure } from '~/performance/utils';
import { DRAW_FAILURE } from '../../constants';
import { createJobsHash, generateJobNeedsDict } from '../../utils';
+import { reportToSentry } from '../graph/utils';
import { parseData } from '../parsing_utils';
+import { reportPerformance } from './api';
import { generateLinksData } from './drawing_utils';
export default {
@@ -25,6 +36,15 @@ export default {
type: Array,
required: true,
},
+ totalGroups: {
+ type: Number,
+ required: true,
+ },
+ metricsConfig: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
defaultLinkColor: {
type: String,
required: false,
@@ -43,6 +63,9 @@ export default {
};
},
computed: {
+ shouldCollectMetrics() {
+ return this.metricsConfig.collectMetrics && this.metricsConfig.path;
+ },
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
@@ -87,23 +110,70 @@ export default {
this.$emit('highlightedJobsChange', jobs);
},
},
+ errorCaptured(err, _vm, info) {
+ reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
+ },
mounted() {
if (!isEmpty(this.pipelineData)) {
this.prepareLinkData();
}
},
methods: {
+ beginPerfMeasure() {
+ if (this.shouldCollectMetrics) {
+ performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START });
+ }
+ },
+ finishPerfMeasureAndSend() {
+ if (this.shouldCollectMetrics) {
+ performanceMarkAndMeasure({
+ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
+ measures: [
+ {
+ name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
+ },
+ ],
+ });
+ }
+
+ window.requestAnimationFrame(() => {
+ const duration = window.performance.getEntriesByName(
+ PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ )[0]?.duration;
+
+ if (!duration) {
+ return;
+ }
+
+ const data = {
+ histograms: [
+ { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 },
+ { name: PIPELINES_DETAIL_LINKS_TOTAL, value: this.links.length },
+ {
+ name: PIPELINES_DETAIL_LINKS_JOB_RATIO,
+ value: this.links.length / this.totalGroups,
+ },
+ ],
+ };
+
+ reportPerformance(this.metricsConfig.path, data);
+ });
+ },
isLinkHighlighted(linkRef) {
return this.highlightedLinks.includes(linkRef);
},
prepareLinkData() {
+ this.beginPerfMeasure();
try {
const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
const parsedData = parseData(arrayOfJobs);
this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`);
- } catch {
- this.$emit('error', DRAW_FAILURE);
+ } catch (err) {
+ this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false });
+ reportToSentry(this.$options.name, err);
}
+ this.finishPerfMeasureAndSend();
},
getLinkClasses(link) {
return [
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
index 1c1bc7ecb2a..42eab13b0bd 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
@@ -1,6 +1,19 @@
<script>
import { GlAlert } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
import { __ } from '~/locale';
+import {
+ PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
+ PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
+ PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ PIPELINES_DETAIL_LINK_DURATION,
+ PIPELINES_DETAIL_LINKS_TOTAL,
+ PIPELINES_DETAIL_LINKS_JOB_RATIO,
+} from '~/performance/constants';
+import { performanceMarkAndMeasure } from '~/performance/utils';
+import { reportToSentry } from '../graph/utils';
+import { parseData } from '../parsing_utils';
+import { reportPerformance } from './api';
import LinksInner from './links_inner.vue';
export default {
@@ -19,6 +32,16 @@ export default {
type: Array,
required: true,
},
+ metricsConfig: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ neverShowLinks: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -41,16 +64,93 @@ export default {
return acc + Number(groups.length);
}, 0);
},
+ shouldCollectMetrics() {
+ return this.metricsConfig.collectMetrics && this.metricsConfig.path;
+ },
showAlert() {
- return !this.showLinkedLayers && !this.alertDismissed;
+ /*
+ This is a hard override that allows us to turn off the links without
+ needing to remove the component entirely for iteration or based on graph type.
+ */
+ if (this.neverShowLinks) {
+ return false;
+ }
+
+ return !this.containerZero && !this.showLinkedLayers && !this.alertDismissed;
},
showLinkedLayers() {
+ /*
+ This is a hard override that allows us to turn off the links without
+ needing to remove the component entirely for iteration or based on graph type.
+ */
+ if (this.neverShowLinks) {
+ return false;
+ }
+
return (
!this.containerZero && (this.showLinksOverride || this.numGroups < this.$options.MAX_GROUPS)
);
},
},
+ errorCaptured(err, _vm, info) {
+ reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
+ },
+ mounted() {
+ /*
+ This is code to get metrics for the graph (to observe links performance).
+ It is currently here because we want values for links without drawing them.
+ It can be removed when https://gitlab.com/gitlab-org/gitlab/-/issues/298930
+ is closed and functionality is enabled by default.
+ */
+
+ if (this.neverShowLinks && !isEmpty(this.pipelineData)) {
+ window.requestAnimationFrame(() => {
+ this.prepareLinkData();
+ });
+ }
+ },
methods: {
+ beginPerfMeasure() {
+ if (this.shouldCollectMetrics) {
+ performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START });
+ }
+ },
+ finishPerfMeasureAndSend(numLinks) {
+ if (this.shouldCollectMetrics) {
+ performanceMarkAndMeasure({
+ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
+ measures: [
+ {
+ name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
+ },
+ ],
+ });
+ }
+
+ window.requestAnimationFrame(() => {
+ const duration = window.performance.getEntriesByName(
+ PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ )[0]?.duration;
+
+ if (!duration) {
+ return;
+ }
+
+ const data = {
+ histograms: [
+ { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 },
+ { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks },
+ {
+ name: PIPELINES_DETAIL_LINKS_JOB_RATIO,
+ value: numLinks / this.numGroups,
+ },
+ ],
+ };
+
+ reportPerformance(this.metricsConfig.path, data);
+ });
+ },
dismissAlert() {
this.alertDismissed = true;
},
@@ -58,6 +158,17 @@ export default {
this.dismissAlert();
this.showLinksOverride = true;
},
+ prepareLinkData() {
+ this.beginPerfMeasure();
+ let numLinks;
+ try {
+ const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
+ numLinks = parseData(arrayOfJobs).links.length;
+ } catch (err) {
+ reportToSentry(this.$options.name, err);
+ }
+ this.finishPerfMeasureAndSend(numLinks);
+ },
},
};
</script>
@@ -66,6 +177,8 @@ export default {
v-if="showLinkedLayers"
:container-measurements="containerMeasurements"
:pipeline-data="pipelineData"
+ :total-groups="numGroups"
+ :metrics-config="metricsConfig"
v-bind="$attrs"
v-on="$listeners"
>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
index 4a7ee3b2af7..707d6966e77 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -64,13 +64,6 @@ export default {
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
- alert() {
- if (this.hasError) {
- return this.failure;
- }
-
- return this.warning;
- },
failure() {
switch (this.failureType) {
case DRAW_FAILURE:
@@ -210,11 +203,11 @@ export default {
<div>
<gl-alert
v-if="hasError"
- :variant="alert.variant"
- :dismissible="alert.dismissible"
- @dismiss="alert.dismissible ? resetFailure : null"
+ :variant="failure.variant"
+ :dismissible="failure.dismissible"
+ @dismiss="resetFailure"
>
- {{ alert.text }}
+ {{ failure.text }}
</gl-alert>
<div
v-if="!hideGraph"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
index 8a656bb47f4..f8107d288d9 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -1,23 +1,22 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlEmptyState } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
export default {
i18n: {
- infoMessage: s__(`Pipelines|GitLab CI/CD can automatically build,
- test, and deploy your code. Let GitLab take care of time
- consuming tasks, so you can spend more time creating.`),
- buttonMessage: s__('Pipelines|Get started with CI/CD'),
+ title: s__('Pipelines|Build with confidence'),
+ description: s__(`Pipelines|GitLab CI/CD can automatically build,
+ test, and deploy your code. Let GitLab take care of time
+ consuming tasks, so you can spend more time creating.`),
+ btnText: s__('Pipelines|Get started with CI/CD'),
+ noCiDescription: s__('Pipelines|This project is not currently set up to run pipelines.'),
},
name: 'PipelinesEmptyState',
components: {
- GlButton,
+ GlEmptyState,
},
props: {
- helpPagePath: {
- type: String,
- required: true,
- },
emptyStateSvgPath: {
type: String,
required: true,
@@ -27,40 +26,28 @@ export default {
required: true,
},
},
+ computed: {
+ ciHelpPagePath() {
+ return helpPagePath('ci/quick_start/index.md');
+ },
+ },
};
</script>
<template>
- <div class="row empty-state js-empty-state">
- <div class="col-12">
- <div class="svg-content svg-250"><img :src="emptyStateSvgPath" /></div>
- </div>
-
- <div class="col-12">
- <div class="text-content">
- <template v-if="canSetCi">
- <h4 data-testid="header-text" class="gl-text-center">
- {{ s__('Pipelines|Build with confidence') }}
- </h4>
- <p data-testid="info-text">
- {{ $options.i18n.infoMessage }}
- </p>
-
- <div class="gl-text-center">
- <gl-button
- :href="helpPagePath"
- variant="info"
- category="primary"
- data-testid="get-started-pipelines"
- >
- {{ $options.i18n.buttonMessage }}
- </gl-button>
- </div>
- </template>
-
- <p v-else class="gl-text-center">
- {{ s__('Pipelines|This project is not currently set up to run pipelines.') }}
- </p>
- </div>
- </div>
+ <div>
+ <gl-empty-state
+ v-if="canSetCi"
+ :title="$options.i18n.title"
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.description"
+ :primary-button-text="$options.i18n.btnText"
+ :primary-button-link="ciHelpPagePath"
+ />
+ <gl-empty-state
+ v-else
+ title=""
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.noCiDescription"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
new file mode 100644
index 00000000000..05372010d0f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
@@ -0,0 +1,54 @@
+<script>
+import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue';
+/**
+ * Renders the pipeline mini graph.
+ */
+export default {
+ components: {
+ PipelineStage,
+ },
+ props: {
+ stages: {
+ type: Array,
+ required: true,
+ },
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ stagesClass: {
+ type: [Array, Object, String],
+ required: false,
+ default: '',
+ },
+ isMergeTrain: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ onPipelineActionRequestComplete() {
+ this.$emit('pipelineActionRequestComplete');
+ },
+ },
+};
+</script>
+<template>
+ <div data-testid="widget-mini-pipeline-graph">
+ <div
+ v-for="stage in stages"
+ :key="stage.name"
+ :class="stagesClass"
+ class="stage-container dropdown"
+ >
+ <pipeline-stage
+ :stage="stage"
+ :update-dropdown="updateDropdown"
+ :is-merge-train="isMergeTrain"
+ @pipelineActionRequestComplete="onPipelineActionRequestComplete"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
new file mode 100644
index 00000000000..81eeead2171
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import eventHub from '../../event_hub';
+import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
+import PipelinesManualActions from './pipelines_manual_actions.vue';
+
+export default {
+ i18n: {
+ cancelTitle: __('Cancel'),
+ redeployTitle: __('Retry'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModalDirective,
+ },
+ components: {
+ GlButton,
+ PipelinesManualActions,
+ PipelinesArtifactsComponent,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ cancelingPipeline: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ isRetrying: false,
+ };
+ },
+ computed: {
+ displayPipelineActions() {
+ return (
+ this.pipeline.flags.retryable ||
+ this.pipeline.flags.cancelable ||
+ this.pipeline.details.manual_actions.length ||
+ this.pipeline.details.artifacts.length
+ );
+ },
+ actions() {
+ if (!this.pipeline || !this.pipeline.details) {
+ return [];
+ }
+ const { details } = this.pipeline;
+ return [...(details.manual_actions || []), ...(details.scheduled_actions || [])];
+ },
+ isCancelling() {
+ return this.cancelingPipeline === this.pipeline.id;
+ },
+ },
+ watch: {
+ pipeline() {
+ this.isRetrying = false;
+ },
+ },
+ methods: {
+ handleCancelClick() {
+ eventHub.$emit('openConfirmationModal', {
+ pipeline: this.pipeline,
+ endpoint: this.pipeline.cancel_path,
+ });
+ },
+ handleRetryClick() {
+ this.isRetrying = true;
+ eventHub.$emit('retryPipeline', this.pipeline.retry_path);
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="displayPipelineActions" class="gl-text-right">
+ <div class="btn-group">
+ <pipelines-manual-actions v-if="actions.length > 0" :actions="actions" />
+
+ <pipelines-artifacts-component
+ v-if="pipeline.details.artifacts.length"
+ :artifacts="pipeline.details.artifacts"
+ />
+
+ <gl-button
+ v-if="pipeline.flags.retryable"
+ v-gl-tooltip.hover
+ :aria-label="$options.i18n.redeployTitle"
+ :title="$options.i18n.redeployTitle"
+ :disabled="isRetrying"
+ :loading="isRetrying"
+ class="js-pipelines-retry-button"
+ data-qa-selector="pipeline_retry_button"
+ icon="repeat"
+ variant="default"
+ category="secondary"
+ @click="handleRetryClick"
+ />
+
+ <gl-button
+ v-if="pipeline.flags.cancelable"
+ v-gl-tooltip.hover
+ v-gl-modal-directive="'confirmation-modal'"
+ :aria-label="$options.i18n.cancelTitle"
+ :title="$options.i18n.cancelTitle"
+ :loading="isCancelling"
+ :disabled="isCancelling"
+ icon="close"
+ variant="danger"
+ category="primary"
+ class="js-pipelines-cancel-button"
+ @click="handleCancelClick"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
new file mode 100644
index 00000000000..bdb7dd06620
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
@@ -0,0 +1,152 @@
+<script>
+/**
+ * Renders each stage of the pipeline mini graph.
+ *
+ * Given the provided endpoint will make a request to
+ * fetch the dropdown data when the stage is clicked.
+ *
+ * Request is made inside this component to make it reusable between:
+ * 1. Pipelines main table
+ * 2. Pipelines table in commit and Merge request views
+ * 3. Merge request widget
+ * 4. Commit widget
+ */
+
+import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { deprecatedCreateFlash as Flash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import eventHub from '../../event_hub';
+import JobItem from '../graph/job_item.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlLoadingIcon,
+ GlDropdown,
+ JobItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ stage: {
+ type: Object,
+ required: true,
+ },
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isMergeTrain: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ dropdownContent: [],
+ };
+ },
+ computed: {
+ triggerButtonClass() {
+ return `ci-status-icon-${this.stage.status.group}`;
+ },
+ borderlessIcon() {
+ return `${this.stage.status.icon}_borderless`;
+ },
+ },
+ watch: {
+ updateDropdown() {
+ if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) {
+ this.fetchJobs();
+ }
+ },
+ },
+ methods: {
+ onShowDropdown() {
+ eventHub.$emit('clickedDropdown');
+ this.isLoading = true;
+ this.fetchJobs();
+ },
+ fetchJobs() {
+ axios
+ .get(this.stage.dropdown_path)
+ .then(({ data }) => {
+ this.dropdownContent = data.latest_statuses;
+ this.isLoading = false;
+ })
+ .catch(() => {
+ this.$refs.dropdown.hide();
+ this.isLoading = false;
+
+ Flash(__('Something went wrong on our end.'));
+ });
+ },
+ isDropdownOpen() {
+ return this.$el.classList.contains('show');
+ },
+ pipelineActionRequestComplete() {
+ // close the dropdown in MR widget
+ this.$refs.dropdown.hide();
+
+ // warn the pipelines table to update
+ this.$emit('pipelineActionRequestComplete');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ v-gl-tooltip.hover
+ data-testid="mini-pipeline-graph-dropdown"
+ :title="stage.title"
+ variant="link"
+ :lazy="true"
+ :popper-opts="{ placement: 'bottom' }"
+ :toggle-class="['mini-pipeline-graph-dropdown-toggle', triggerButtonClass]"
+ menu-class="mini-pipeline-graph-dropdown-menu"
+ @show="onShowDropdown"
+ >
+ <template #button-content>
+ <span class="gl-pointer-events-none">
+ <gl-icon :name="borderlessIcon" />
+ </span>
+ </template>
+ <gl-loading-icon v-if="isLoading" />
+ <ul
+ v-else
+ class="js-builds-dropdown-list scrollable-menu"
+ data-testid="mini-pipeline-graph-dropdown-menu-list"
+ >
+ <li v-for="job in dropdownContent" :key="job.id">
+ <job-item
+ :dropdown-length="dropdownContent.length"
+ :job="job"
+ css-class-job-name="mini-pipeline-graph-dropdown-item"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </li>
+ <template v-if="isMergeTrain">
+ <li class="gl-new-dropdown-divider" role="presentation">
+ <hr role="separator" aria-orientation="horizontal" class="dropdown-divider" />
+ </li>
+ <li>
+ <div
+ class="gl-display-flex gl-align-items-center"
+ data-testid="warning-message-merge-trains"
+ >
+ <div class="menu-item gl-font-sm gl-text-gray-300!">
+ {{ s__('Pipeline|Merge train pipeline jobs can not be retried') }}
+ </div>
+ </div>
+ </li>
+ </template>
+ </ul>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
index 6ac60727f23..c707b395192 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
@@ -1,10 +1,12 @@
<script>
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
UserAvatarLink,
},
+ mixins: [glFeatureFlagMixin()],
props: {
pipeline: {
type: Object,
@@ -15,11 +17,19 @@ export default {
user() {
return this.pipeline.user;
},
+ classes() {
+ const triggererClass = 'pipeline-triggerer';
+
+ if (this.glFeatures.newPipelinesTable) {
+ return triggererClass;
+ }
+ return `table-section section-10 d-none d-md-block ${triggererClass}`;
+ },
},
};
</script>
<template>
- <div class="table-section section-10 d-none d-md-block pipeline-triggerer">
+ <div :class="classes" data-testid="pipeline-triggerer">
<user-avatar-link
v-if="user"
:link-href="user.path"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index 823ada133d2..0de520a2ca7 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -1,5 +1,7 @@
<script>
import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { SCHEDULE_ORIGIN } from '../../constants';
export default {
@@ -12,6 +14,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
targetProjectFullPath: {
default: '',
@@ -26,10 +29,6 @@ export default {
type: String,
required: true,
},
- autoDevopsHelpPath: {
- type: String,
- required: true,
- },
},
computed: {
user() {
@@ -44,11 +43,25 @@ export default {
this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`,
);
},
+ autoDevopsTagId() {
+ return `pipeline-url-autodevops-${this.pipeline.id}`;
+ },
+ autoDevopsHelpPath() {
+ return helpPagePath('topics/autodevops/index.md');
+ },
+ classes() {
+ const tagsClass = 'pipeline-tags';
+
+ if (this.glFeatures.newPipelinesTable) {
+ return tagsClass;
+ }
+ return `table-section section-10 d-none d-md-block ${tagsClass}`;
+ },
},
};
</script>
<template>
- <div class="table-section section-10 d-none d-md-block pipeline-tags">
+ <div :class="classes" data-testid="pipeline-url-table-cell">
<gl-link
:href="pipeline.path"
data-testid="pipeline-url-link"
@@ -103,38 +116,43 @@ export default {
data-testid="pipeline-url-failure"
>{{ __('error') }}</gl-badge
>
- <gl-link
- v-if="pipeline.flags.auto_devops"
- :id="`pipeline-url-autodevops-${pipeline.id}`"
- tabindex="0"
- data-testid="pipeline-url-autodevops"
- role="button"
- ><gl-badge variant="info" size="sm">{{ __('Auto DevOps') }}</gl-badge></gl-link
- >
- <gl-popover
- :target="`pipeline-url-autodevops-${pipeline.id}`"
- triggers="focus"
- placement="top"
- >
- <template #title>
- <div class="gl-font-weight-normal gl-line-height-normal">
- <gl-sprintf
- :message="
- __(
- 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}',
- )
- "
- >
- <template #strong="{ content }">
- <b>{{ content }}</b>
- </template>
- </gl-sprintf>
- </div>
- </template>
- <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">{{
- __('Learn more about Auto DevOps')
- }}</gl-link>
- </gl-popover>
+ <template v-if="pipeline.flags.auto_devops">
+ <gl-link
+ :id="autoDevopsTagId"
+ tabindex="0"
+ data-testid="pipeline-url-autodevops"
+ role="button"
+ >
+ <gl-badge variant="info" size="sm">
+ {{ __('Auto DevOps') }}
+ </gl-badge>
+ </gl-link>
+ <gl-popover :target="autoDevopsTagId" triggers="focus" placement="top">
+ <template #title>
+ <div class="gl-font-weight-normal gl-line-height-normal">
+ <gl-sprintf
+ :message="
+ __(
+ 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}',
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
+ </div>
+ </template>
+ <gl-link
+ :href="autoDevopsHelpPath"
+ data-testid="pipeline-url-autodevops-link"
+ target="_blank"
+ >
+ {{ __('Learn more about Auto DevOps') }}
+ </gl-link>
+ </gl-popover>
+ </template>
+
<gl-badge
v-if="pipeline.flags.stuck"
variant="warning"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 48009a9fcb8..19d93e7d083 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -52,10 +52,6 @@ export default {
required: false,
default: '',
},
- helpPagePath: {
- type: String,
- required: true,
- },
emptyStateSvgPath: {
type: String,
required: true,
@@ -68,10 +64,6 @@ export default {
type: String,
required: true,
},
- autoDevopsHelpPath: {
- type: String,
- required: true,
- },
hasGitlabCi: {
type: Boolean,
required: true,
@@ -337,7 +329,6 @@ export default {
<empty-state
v-else-if="stateToRender === $options.stateMap.emptyState"
- :help-page-path="helpPagePath"
:empty-state-svg-path="emptyStateSvgPath"
:can-set-ci="canCreatePipeline"
/>
@@ -362,7 +353,6 @@ export default {
:pipelines="state.pipelines"
:pipeline-schedule-url="pipelineScheduleUrl"
:update-graph-dropdown="updateGraphDropdown"
- :auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
index b13460b4c68..9c3990f82df 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
@@ -31,6 +31,8 @@ export default {
:text="$options.translations.artifacts"
:aria-label="$options.translations.artifacts"
icon="download"
+ right
+ lazy
text-sr-only
>
<gl-dropdown-item
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue
new file mode 100644
index 00000000000..cc676883c1d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue
@@ -0,0 +1,85 @@
+<script>
+import { CHILD_VIEW } from '~/pipelines/constants';
+import CommitComponent from '~/vue_shared/components/commit.vue';
+
+export default {
+ components: {
+ CommitComponent,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ viewType: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ commitAuthor() {
+ let commitAuthorInformation;
+
+ if (!this.pipeline || !this.pipeline.commit) {
+ return null;
+ }
+
+ // 1. person who is an author of a commit might be a GitLab user
+ if (this.pipeline.commit.author) {
+ // 2. if person who is an author of a commit is a GitLab user
+ // they can have a GitLab avatar
+ if (this.pipeline.commit.author.avatar_url) {
+ commitAuthorInformation = this.pipeline.commit.author;
+
+ // 3. If GitLab user does not have avatar, they might have a Gravatar
+ } else if (this.pipeline.commit.author_gravatar_url) {
+ commitAuthorInformation = {
+ ...this.pipeline.commit.author,
+ avatar_url: this.pipeline.commit.author_gravatar_url,
+ };
+ }
+ // 4. If committer is not a GitLab User, they can have a Gravatar
+ } else {
+ commitAuthorInformation = {
+ avatar_url: this.pipeline.commit.author_gravatar_url,
+ path: `mailto:${this.pipeline.commit.author_email}`,
+ username: this.pipeline.commit.author_name,
+ };
+ }
+
+ return commitAuthorInformation;
+ },
+ commitTag() {
+ return this.pipeline?.ref?.tag;
+ },
+ commitRef() {
+ return this.pipeline?.ref;
+ },
+ commitUrl() {
+ return this.pipeline?.commit?.commit_path;
+ },
+ commitShortSha() {
+ return this.pipeline?.commit?.short_id;
+ },
+ commitTitle() {
+ return this.pipeline?.commit?.title;
+ },
+ isChildView() {
+ return this.viewType === CHILD_VIEW;
+ },
+ },
+};
+</script>
+
+<template>
+ <commit-component
+ :tag="commitTag"
+ :commit-ref="commitRef"
+ :commit-url="commitUrl"
+ :merge-request-ref="pipeline.merge_request"
+ :short-sha="commitShortSha"
+ :title="commitTitle"
+ :author="commitAuthor"
+ :show-ref-info="!isChildView"
+ />
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
index 6890cbb9bed..b94f1a42039 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
@@ -82,6 +82,7 @@ export default {
:loading="isLoading"
data-testid="pipelines-manual-actions-dropdown"
right
+ lazy
icon="play"
>
<gl-dropdown-item
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
new file mode 100644
index 00000000000..cc3c8d522b3
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
@@ -0,0 +1,37 @@
+<script>
+import { CHILD_VIEW } from '~/pipelines/constants';
+import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+
+export default {
+ components: {
+ CiBadge,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ viewType: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ pipelineStatus() {
+ return this.pipeline?.details?.status ?? {};
+ },
+ isChildView() {
+ return this.viewType === CHILD_VIEW;
+ },
+ },
+};
+</script>
+
+<template>
+ <ci-badge
+ :status="pipelineStatus"
+ :show-text="!isChildView"
+ :icon-classes="'gl-vertical-align-middle!'"
+ data-qa-selector="pipeline_commit_status"
+ />
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index 24c67184e56..aa27aa7e50d 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,22 +1,97 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import { GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
+import PipelineMiniGraph from './pipeline_mini_graph.vue';
+import PipelineOperations from './pipeline_operations.vue';
import PipelineStopModal from './pipeline_stop_modal.vue';
+import PipelineTriggerer from './pipeline_triggerer.vue';
+import PipelineUrl from './pipeline_url.vue';
+import PipelinesCommit from './pipelines_commit.vue';
+import PipelinesStatusBadge from './pipelines_status_badge.vue';
import PipelinesTableRowComponent from './pipelines_table_row.vue';
+import PipelinesTimeago from './time_ago.vue';
+
+const DEFAULT_TD_CLASS = 'gl-p-5!';
+const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!';
+const DEFAULT_TH_CLASSES =
+ 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1! gl-font-sm!';
-/**
- * Pipelines Table Component.
- *
- * Given an array of objects, renders a table.
- */
export default {
+ fields: [
+ {
+ key: 'status',
+ label: s__('Pipeline|Status'),
+ thClass: DEFAULT_TH_CLASSES,
+ columnClass: 'gl-w-10p',
+ tdClass: DEFAULT_TD_CLASS,
+ thAttr: { 'data-testid': 'status-th' },
+ },
+ {
+ key: 'pipeline',
+ label: s__('Pipeline|Pipeline'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
+ columnClass: 'gl-w-10p',
+ thAttr: { 'data-testid': 'pipeline-th' },
+ },
+ {
+ key: 'triggerer',
+ label: s__('Pipeline|Triggerer'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
+ columnClass: 'gl-w-10p',
+ thAttr: { 'data-testid': 'triggerer-th' },
+ },
+ {
+ key: 'commit',
+ label: s__('Pipeline|Commit'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: DEFAULT_TD_CLASS,
+ columnClass: 'gl-w-20p',
+ thAttr: { 'data-testid': 'commit-th' },
+ },
+ {
+ key: 'stages',
+ label: s__('Pipeline|Stages'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: DEFAULT_TD_CLASS,
+ columnClass: 'gl-w-15p',
+ thAttr: { 'data-testid': 'stages-th' },
+ },
+ {
+ key: 'timeago',
+ label: s__('Pipeline|Duration'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: DEFAULT_TD_CLASS,
+ columnClass: 'gl-w-15p',
+ thAttr: { 'data-testid': 'timeago-th' },
+ },
+ {
+ key: 'actions',
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: DEFAULT_TD_CLASS,
+ columnClass: 'gl-w-20p',
+ thAttr: { 'data-testid': 'actions-th' },
+ },
+ ],
components: {
- PipelinesTableRowComponent,
+ GlTable,
+ PipelinesCommit,
+ PipelineMiniGraph,
+ PipelineOperations,
+ PipelinesStatusBadge,
PipelineStopModal,
+ PipelinesTableRowComponent,
+ PipelinesTimeago,
+ PipelineTriggerer,
+ PipelineUrl,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
pipelines: {
type: Array,
@@ -32,10 +107,6 @@ export default {
required: false,
default: false,
},
- autoDevopsHelpPath: {
- type: String,
- required: true,
- },
viewType: {
type: String,
required: true,
@@ -70,42 +141,103 @@ export default {
eventHub.$emit('postAction', this.endpoint);
this.cancelingPipeline = this.pipelineId;
},
+ onPipelineActionRequestComplete() {
+ eventHub.$emit('refreshPipelinesTable');
+ },
},
};
</script>
<template>
<div class="ci-table">
- <div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-10 js-pipeline-status" role="rowheader">
- {{ s__('Pipeline|Status') }}
- </div>
- <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader">
- {{ s__('Pipeline|Pipeline') }}
- </div>
- <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader">
- {{ s__('Pipeline|Triggerer') }}
- </div>
- <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader">
- {{ s__('Pipeline|Commit') }}
- </div>
- <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
- {{ s__('Pipeline|Stages') }}
- </div>
- <div class="table-section section-15" role="rowheader"></div>
- <div class="table-section section-20" role="rowheader">
- <slot name="table-header-actions"></slot>
+ <div v-if="!glFeatures.newPipelinesTable" data-testid="legacy-ci-table">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-10 js-pipeline-status" role="rowheader">
+ {{ s__('Pipeline|Status') }}
+ </div>
+ <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader">
+ {{ s__('Pipeline|Pipeline') }}
+ </div>
+ <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader">
+ {{ s__('Pipeline|Triggerer') }}
+ </div>
+ <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader">
+ {{ s__('Pipeline|Commit') }}
+ </div>
+ <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
+ {{ s__('Pipeline|Stages') }}
+ </div>
+ <div class="table-section section-15" role="rowheader"></div>
+ <div class="table-section section-20" role="rowheader">
+ <slot name="table-header-actions"></slot>
+ </div>
</div>
+ <pipelines-table-row-component
+ v-for="model in pipelines"
+ :key="model.id"
+ :pipeline="model"
+ :pipeline-schedule-url="pipelineScheduleUrl"
+ :update-graph-dropdown="updateGraphDropdown"
+ :view-type="viewType"
+ :canceling-pipeline="cancelingPipeline"
+ />
</div>
- <pipelines-table-row-component
- 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"
- :canceling-pipeline="cancelingPipeline"
- />
+
+ <gl-table
+ v-else
+ :fields="$options.fields"
+ :items="pipelines"
+ tbody-tr-class="commit"
+ :tbody-tr-attr="{ 'data-testid': 'pipeline-table-row' }"
+ stacked="lg"
+ fixed
+ >
+ <template #head(actions)>
+ <span class="gl-display-block gl-lg-display-none!">{{ s__('Pipeline|Actions') }}</span>
+ <slot name="table-header-actions"></slot>
+ </template>
+
+ <template #table-colgroup="{ fields }">
+ <col v-for="field in fields" :key="field.key" :class="field.columnClass" />
+ </template>
+
+ <template #cell(status)="{ item }">
+ <pipelines-status-badge :pipeline="item" :view-type="viewType" />
+ </template>
+
+ <template #cell(pipeline)="{ item }">
+ <pipeline-url :pipeline="item" :pipeline-schedule-url="pipelineScheduleUrl" />
+ </template>
+
+ <template #cell(triggerer)="{ item }">
+ <pipeline-triggerer :pipeline="item" />
+ </template>
+
+ <template #cell(commit)="{ item }">
+ <pipelines-commit :pipeline="item" :view-type="viewType" />
+ </template>
+
+ <template #cell(stages)="{ item }">
+ <div class="stage-cell">
+ <!-- This empty div should be removed, see https://gitlab.com/gitlab-org/gitlab/-/issues/323488 -->
+ <div></div>
+ <pipeline-mini-graph
+ v-if="item.details && item.details.stages && item.details.stages.length > 0"
+ :stages="item.details.stages"
+ :update-dropdown="updateGraphDropdown"
+ @pipelineActionRequestComplete="onPipelineActionRequestComplete"
+ />
+ </div>
+ </template>
+
+ <template #cell(timeago)="{ item }">
+ <pipelines-timeago :pipeline="item" />
+ </template>
+
+ <template #cell(actions)="{ item }">
+ <pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" />
+ </template>
+ </gl-table>
+
<pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
index 572abe2a24a..f684a0b0fcd 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
@@ -3,13 +3,12 @@ import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
-import { PIPELINES_TABLE } from '../../constants';
import eventHub from '../../event_hub';
+import PipelineMiniGraph from './pipeline_mini_graph.vue';
import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelineUrl from './pipeline_url.vue';
-import PipelinesActionsComponent from './pipelines_actions.vue';
import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
-import PipelineStage from './stage.vue';
+import PipelinesManualActionsComponent from './pipelines_manual_actions.vue';
import PipelinesTimeago from './time_ago.vue';
export default {
@@ -22,10 +21,10 @@ export default {
GlModalDirective,
},
components: {
- PipelinesActionsComponent,
+ PipelinesManualActionsComponent,
PipelinesArtifactsComponent,
CommitComponent,
- PipelineStage,
+ PipelineMiniGraph,
PipelineUrl,
PipelineTriggerer,
CiBadge,
@@ -47,10 +46,6 @@ export default {
required: false,
default: false,
},
- autoDevopsHelpPath: {
- type: String,
- required: true,
- },
viewType: {
type: String,
required: true,
@@ -61,7 +56,6 @@ export default {
default: null,
},
},
- pipelinesTable: PIPELINES_TABLE,
data() {
return {
isRetrying: false,
@@ -137,15 +131,12 @@ export default {
commitTitle() {
return this.pipeline?.commit?.title;
},
- pipelineDuration() {
- return this.pipeline?.details?.duration ?? 0;
- },
- pipelineFinishedAt() {
- return this.pipeline?.details?.finished_at ?? '';
- },
pipelineStatus() {
return this.pipeline?.details?.status ?? {};
},
+ hasStages() {
+ return this.pipeline?.details?.stages?.length > 0;
+ },
displayPipelineActions() {
return (
this.pipeline.flags.retryable ||
@@ -177,6 +168,10 @@ export default {
this.isRetrying = true;
eventHub.$emit('retryPipeline', this.pipeline.retry_path);
},
+ handlePipelineActionRequestComplete() {
+ // warn the pipelines table to update
+ eventHub.$emit('refreshPipelinesTable');
+ },
},
};
</script>
@@ -194,11 +189,7 @@ export default {
</div>
</div>
- <pipeline-url
- :pipeline="pipeline"
- :pipeline-schedule-url="pipelineScheduleUrl"
- :auto-devops-help-path="autoDevopsHelpPath"
- />
+ <pipeline-url :pipeline="pipeline" :pipeline-schedule-url="pipelineScheduleUrl" />
<pipeline-triggerer :pipeline="pipeline" />
<div class="table-section section-wrap section-20">
@@ -220,35 +211,23 @@ export default {
<div class="table-section section-wrap section-15 stage-cell">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Stages') }}</div>
<div class="table-mobile-content">
- <template v-if="pipeline.details.stages.length > 0">
- <div
- v-for="(stage, index) in pipeline.details.stages"
- :key="index"
- class="stage-container dropdown"
- data-testid="widget-mini-pipeline-graph"
- >
- <pipeline-stage
- :type="$options.pipelinesTable"
- :stage="stage"
- :update-dropdown="updateGraphDropdown"
- />
- </div>
- </template>
+ <pipeline-mini-graph
+ v-if="hasStages"
+ :stages="pipeline.details.stages"
+ :update-dropdown="updateGraphDropdown"
+ @pipelineActionRequestComplete="handlePipelineActionRequestComplete"
+ />
</div>
</div>
- <pipelines-timeago
- class="gl-text-right"
- :duration="pipelineDuration"
- :finished-time="pipelineFinishedAt"
- />
+ <pipelines-timeago class="gl-text-right" :pipeline="pipeline" />
<div
v-if="displayPipelineActions"
class="table-section section-20 table-button-footer pipeline-actions"
>
<div class="btn-group table-action-buttons">
- <pipelines-actions-component v-if="actions.length > 0" :actions="actions" />
+ <pipelines-manual-actions-component v-if="actions.length > 0" :actions="actions" />
<pipelines-artifacts-component
v-if="pipeline.details.artifacts.length"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue
deleted file mode 100644
index f5dfb9e72d5..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue
+++ /dev/null
@@ -1,234 +0,0 @@
-<script>
-/**
- * Renders each stage of the pipeline mini graph.
- *
- * Given the provided endpoint will make a request to
- * fetch the dropdown data when the stage is clicked.
- *
- * Request is made inside this component to make it reusable between:
- * 1. Pipelines main table
- * 2. Pipelines table in commit and Merge request views
- * 3. Merge request widget
- * 4. Commit widget
- */
-import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import $ from 'jquery';
-import { deprecatedCreateFlash as Flash } from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { PIPELINES_TABLE } from '../../constants';
-import eventHub from '../../event_hub';
-import JobItem from '../graph/job_item.vue';
-
-export default {
- components: {
- GlIcon,
- GlLoadingIcon,
- GlDropdown,
- JobItem,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- mixins: [glFeatureFlagsMixin()],
- props: {
- stage: {
- type: Object,
- required: true,
- },
-
- updateDropdown: {
- type: Boolean,
- required: false,
- default: false,
- },
-
- type: {
- type: String,
- required: false,
- default: '',
- },
- },
- data() {
- return {
- isLoading: false,
- dropdownContent: [],
- };
- },
- computed: {
- isCiMiniPipelineGlDropdown() {
- // Feature flag ci_mini_pipeline_gl_dropdown
- // See more at https://gitlab.com/gitlab-org/gitlab/-/issues/300400
- return this.glFeatures?.ciMiniPipelineGlDropdown;
- },
- triggerButtonClass() {
- return `ci-status-icon-${this.stage.status.group}`;
- },
- borderlessIcon() {
- return `${this.stage.status.icon}_borderless`;
- },
- },
- watch: {
- updateDropdown() {
- if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) {
- this.fetchJobs();
- }
- },
- },
- updated() {
- if (!this.isCiMiniPipelineGlDropdown && this.dropdownContent.length) {
- this.stopDropdownClickPropagation();
- }
- },
- methods: {
- onShowDropdown() {
- eventHub.$emit('clickedDropdown');
- this.isLoading = true;
- this.fetchJobs();
- },
- onClickStage() {
- if (!this.isDropdownOpen()) {
- eventHub.$emit('clickedDropdown');
- this.isLoading = true;
- this.fetchJobs();
- }
- },
- fetchJobs() {
- axios
- .get(this.stage.dropdown_path)
- .then(({ data }) => {
- this.dropdownContent = data.latest_statuses;
- this.isLoading = false;
- })
- .catch(() => {
- if (this.isCiMiniPipelineGlDropdown) {
- this.$refs.stageGlDropdown.hide();
- } else {
- this.closeDropdown();
- }
- this.isLoading = false;
-
- Flash(__('Something went wrong on our end.'));
- });
- },
- /**
- * When the user right clicks or cmd/ctrl + click in the job name
- * the dropdown should not be closed and the link should open in another tab,
- * so we stop propagation of the click event inside the dropdown.
- *
- * Since this component is rendered multiple times per page we need to guarantee we only
- * target the click event of this component.
- *
- * Note: This should be removed once ci_mini_pipeline_gl_dropdown FF is removed as true.
- */
- stopDropdownClickPropagation() {
- $(
- '.js-builds-dropdown-list button, .js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item',
- this.$el,
- ).on('click', (e) => {
- e.stopPropagation();
- });
- },
- closeDropdown() {
- if (this.isDropdownOpen()) {
- $(this.$refs.dropdown).dropdown('toggle');
- }
- },
- isDropdownOpen() {
- return this.$el.classList.contains('show');
- },
- pipelineActionRequestComplete() {
- if (this.type === PIPELINES_TABLE) {
- // warn the table to update
- eventHub.$emit('refreshPipelinesTable');
- return;
- }
- // close the dropdown in mr widget
- if (this.isCiMiniPipelineGlDropdown) {
- this.$refs.stageGlDropdown.hide();
- } else {
- $(this.$refs.dropdown).dropdown('toggle');
- }
- },
- },
-};
-</script>
-
-<template>
- <div class="dropdown">
- <gl-dropdown
- v-if="isCiMiniPipelineGlDropdown"
- ref="stageGlDropdown"
- v-gl-tooltip.hover
- data-testid="mini-pipeline-graph-dropdown"
- :title="stage.title"
- variant="link"
- :lazy="true"
- :popper-opts="{ placement: 'bottom' }"
- :toggle-class="['mini-pipeline-graph-gl-dropdown-toggle', triggerButtonClass]"
- menu-class="mini-pipeline-graph-dropdown-menu"
- @show="onShowDropdown"
- >
- <template #button-content>
- <span class="gl-pointer-events-none">
- <gl-icon :name="borderlessIcon" />
- </span>
- </template>
- <gl-loading-icon v-if="isLoading" />
- <ul
- v-else
- class="js-builds-dropdown-list scrollable-menu"
- data-testid="mini-pipeline-graph-dropdown-menu-list"
- >
- <li v-for="job in dropdownContent" :key="job.id">
- <job-item
- :dropdown-length="dropdownContent.length"
- :job="job"
- css-class-job-name="mini-pipeline-graph-dropdown-item"
- @pipelineActionRequestComplete="pipelineActionRequestComplete"
- />
- </li>
- </ul>
- </gl-dropdown>
-
- <template v-else>
- <button
- id="stageDropdown"
- ref="dropdown"
- v-gl-tooltip.hover
- :class="triggerButtonClass"
- :title="stage.title"
- class="mini-pipeline-graph-dropdown-toggle"
- data-testid="mini-pipeline-graph-dropdown-toggle"
- data-toggle="dropdown"
- data-display="static"
- type="button"
- aria-haspopup="true"
- aria-expanded="false"
- @click="onClickStage"
- >
- <span :aria-label="stage.title" aria-hidden="true" class="gl-pointer-events-none">
- <gl-icon :name="borderlessIcon" />
- </span>
- </button>
-
- <div
- class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"
- aria-labelledby="stageDropdown"
- >
- <gl-loading-icon v-if="isLoading" />
- <ul v-else class="js-builds-dropdown-list scrollable-menu">
- <li v-for="job in dropdownContent" :key="job.id">
- <job-item
- :dropdown-length="dropdownContent.length"
- :job="job"
- css-class-job-name="mini-pipeline-graph-dropdown-item"
- @pipelineActionRequestComplete="pipelineActionRequestComplete"
- />
- </li>
- </ul>
- </div>
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index 5548a1021f5..543bdf94307 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
@@ -7,23 +8,19 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: { GlIcon },
- mixins: [timeagoMixin],
+ mixins: [timeagoMixin, glFeatureFlagMixin()],
props: {
- finishedTime: {
- type: String,
- required: true,
- },
- duration: {
- type: Number,
+ pipeline: {
+ type: Object,
required: true,
},
},
computed: {
- hasDuration() {
- return this.duration > 0;
+ duration() {
+ return this.pipeline?.details?.duration;
},
- hasFinishedTime() {
- return this.finishedTime !== '';
+ finishedTime() {
+ return this.pipeline?.details?.finished_at;
},
durationFormatted() {
const date = new Date(this.duration * 1000);
@@ -45,20 +42,36 @@ export default {
return `${hh}:${mm}:${ss}`;
},
+ legacySectionClass() {
+ return !this.glFeatures.newPipelinesTable ? 'table-section section-15' : '';
+ },
+ legacyTableMobileClass() {
+ return !this.glFeatures.newPipelinesTable ? 'table-mobile-content' : '';
+ },
+ showInProgress() {
+ return !this.duration && !this.finishedTime;
+ },
},
};
</script>
<template>
- <div class="table-section section-15">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div>
- <div class="table-mobile-content">
- <p v-if="hasDuration" class="duration">
- <gl-icon name="timer" class="gl-vertical-align-baseline!" />
+ <div :class="legacySectionClass">
+ <div v-if="!glFeatures.newPipelinesTable" class="table-mobile-header" role="rowheader">
+ {{ s__('Pipeline|Duration') }}
+ </div>
+ <div :class="legacyTableMobileClass">
+ <span v-if="showInProgress" data-testid="pipeline-in-progress">
+ <gl-icon name="hourglass" class="gl-vertical-align-baseline! gl-mr-2" :size="12" />
+ {{ s__('Pipeline|In progress') }}
+ </span>
+
+ <p v-if="duration" class="duration">
+ <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" />
{{ durationFormatted }}
</p>
- <p v-if="hasFinishedTime" class="finished-at d-none d-md-block">
- <gl-icon name="calendar" class="gl-vertical-align-baseline!" />
+ <p v-if="finishedTime" class="finished-at d-none d-md-block">
+ <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" />
<time
v-gl-tooltip
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 757d285ef19..21b114825a6 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -1,7 +1,6 @@
import { s__, __ } from '~/locale';
export const CANCEL_REQUEST = 'CANCEL_REQUEST';
-export const PIPELINES_TABLE = 'PIPELINES_TABLE';
export const LAYOUT_CHANGE_DELAY = 300;
export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const ANY_TRIGGER_AUTHOR = 'Any';
@@ -34,3 +33,5 @@ export const LOAD_FAILURE = 'load_failure';
export const PARSE_FAILURE = 'parse_failure';
export const POST_FAILURE = 'post_failure';
export const UNSUPPORTED_DATA = 'unsupported_data';
+
+export const CHILD_VIEW = 'child';
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index f837851e5c1..c3444f38ea0 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -93,8 +93,7 @@ export default async function initPipelineDetailsBundle() {
/* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph'
);
- const { pipelineProjectPath, pipelineIid } = dataset;
- createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, pipelineProjectPath, pipelineIid);
+ createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, dataset);
} catch {
Flash(__('An error occurred while loading the pipeline.'));
}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js
index 55f3731a3ca..9eba39738dc 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_graph.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js
@@ -11,12 +11,15 @@ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
- batchMax: 2,
+ useGet: true,
},
),
});
-const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => {
+const createPipelinesDetailApp = (
+ selector,
+ { pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {},
+) => {
// eslint-disable-next-line no-new
new Vue({
el: selector,
@@ -25,8 +28,10 @@ const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) =>
},
apolloProvider,
provide: {
+ metricsPath,
pipelineProjectPath,
pipelineIid,
+ graphqlResourceEtag,
dataMethod: GRAPHQL,
},
errorCaptured(err, _vm, info) {
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index 7bcc51e18e5..0e2e9785956 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -23,11 +23,9 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
const {
endpoint,
pipelineScheduleUrl,
- helpPagePath,
emptyStateSvgPath,
errorStateSvgPath,
noPipelinesSvgPath,
- autoDevopsHelpPath,
newPipelinePath,
canCreatePipeline,
hasGitlabCi,
@@ -56,11 +54,9 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
store: this.store,
endpoint,
pipelineScheduleUrl,
- helpPagePath,
emptyStateSvgPath,
errorStateSvgPath,
noPipelinesSvgPath,
- autoDevopsHelpPath,
newPipelinePath,
canCreatePipeline: parseBoolean(canCreatePipeline),
hasGitlabCi: parseBoolean(hasGitlabCi),
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index c5478aa0226..f18c4d8f03e 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -90,7 +90,10 @@ Please update your Git repository remotes as soon as possible.`),
this.isRequestPending = false;
})
.catch((error) => {
- Flash(error.response.data.message);
+ Flash(
+ error?.response?.data?.message ||
+ s__('Profiles|An error occurred while updating your username, please try again.'),
+ );
this.isRequestPending = false;
throw error;
});
@@ -121,7 +124,8 @@ Please update your Git repository remotes as soon as possible.`),
</div>
<gl-button
v-gl-modal-directive="$options.modalId"
- :disabled="isRequestPending || newUsername === username"
+ :disabled="newUsername === username"
+ :loading="isRequestPending"
category="primary"
variant="warning"
data-testid="username-change-confirmation-modal"
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
index 184ee3810ac..07d8f3cc5f1 100644
--- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
+++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
@@ -44,6 +44,8 @@ export default {
data() {
return {
isSubmitEnabled: true,
+ darkModeOnCreate: null,
+ darkModeOnSubmit: null,
};
},
computed: {
@@ -58,6 +60,7 @@ export default {
this.formEl.addEventListener('ajax:beforeSend', this.handleLoading);
this.formEl.addEventListener('ajax:success', this.handleSuccess);
this.formEl.addEventListener('ajax:error', this.handleError);
+ this.darkModeOnCreate = this.darkModeSelected();
},
beforeDestroy() {
this.formEl.removeEventListener('ajax:beforeSend', this.handleLoading);
@@ -65,16 +68,27 @@ export default {
this.formEl.removeEventListener('ajax:error', this.handleError);
},
methods: {
+ darkModeSelected() {
+ const theme = this.getSelectedTheme();
+ return theme ? theme.css_class === 'gl-dark' : null;
+ },
+ getSelectedTheme() {
+ const themeId = new FormData(this.formEl).get('user[theme_id]');
+ return this.applicationThemes[themeId] ?? null;
+ },
handleLoading() {
this.isSubmitEnabled = false;
+ this.darkModeOnSubmit = this.darkModeSelected();
},
handleSuccess(customEvent) {
- const formData = new FormData(this.formEl);
- updateClasses(
- this.bodyClasses,
- this.applicationThemes[formData.get('user[theme_id]')].css_class,
- this.selectedLayout,
- );
+ // Reload the page if the theme has changed from light to dark mode or vice versa
+ // to correctly load all required styles.
+ const modeChanged = this.darkModeOnCreate ? !this.darkModeOnSubmit : this.darkModeOnSubmit;
+ if (modeChanged) {
+ window.location.reload();
+ return;
+ }
+ updateClasses(this.bodyClasses, this.getSelectedTheme().css_class, this.selectedLayout);
const { message = this.$options.i18n.defaultSuccess, type = FLASH_TYPES.NOTICE } =
customEvent?.detail?.[0] || {};
createFlash({ message, type });
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index a7332b81b9f..dad2c18fb18 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -95,6 +95,7 @@ export default class Profile {
updateHeaderAvatar() {
$('.header-user-avatar').attr('src', this.avatarGlCrop.dataURL);
+ $('.js-sidebar-user-avatar').attr('src', this.avatarGlCrop.dataURL);
}
setRepoRadio() {
diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
index 3527bcb04c6..cc5bc703994 100644
--- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
@@ -7,7 +7,11 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { I18N_DROPDOWN } from '../constants';
+import {
+ I18N_NO_RESULTS_MESSAGE,
+ I18N_BRANCH_HEADER,
+ I18N_BRANCH_SEARCH_PLACEHOLDER,
+} from '../constants';
export default {
name: 'BranchesDropdown',
@@ -25,7 +29,11 @@ export default {
default: '',
},
},
- i18n: I18N_DROPDOWN,
+ i18n: {
+ noResultsMessage: I18N_NO_RESULTS_MESSAGE,
+ branchHeaderTitle: I18N_BRANCH_HEADER,
+ branchSearchPlaceholder: I18N_BRANCH_SEARCH_PLACEHOLDER,
+ },
data() {
return {
searchTerm: this.value,
@@ -41,6 +49,13 @@ export default {
);
},
},
+ watch: {
+ // Parent component can set the branch value (e.g. when the user selects a different project)
+ // and we need to keep the search term in sync with the selected value
+ value(val) {
+ this.searchTermChanged(val);
+ },
+ },
mounted() {
this.fetchBranches(this.searchTerm);
},
@@ -61,13 +76,13 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="value" :header-text="$options.i18n.headerTitle">
+ <gl-dropdown :text="value" :header-text="$options.i18n.branchHeaderTitle">
<gl-search-box-by-type
:value="searchTerm"
trim
autocomplete="off"
:debounce="250"
- :placeholder="$options.i18n.searchPlaceholder"
+ :placeholder="$options.i18n.branchSearchPlaceholder"
data-testid="dropdown-search-box"
@input="searchTermChanged"
/>
diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue
index ed216a91ca0..30968d29cde 100644
--- a/app/assets/javascripts/projects/commit/components/form_modal.vue
+++ b/app/assets/javascripts/projects/commit/components/form_modal.vue
@@ -3,18 +3,22 @@ import { GlModal, GlForm, GlFormCheckbox, GlSprintf, GlFormGroup } from '@gitlab
import { mapActions, mapState } from 'vuex';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import csrf from '~/lib/utils/csrf';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import BranchesDropdown from './branches_dropdown.vue';
+import ProjectsDropdown from './projects_dropdown.vue';
export default {
components: {
BranchesDropdown,
+ ProjectsDropdown,
GlModal,
GlForm,
GlFormCheckbox,
GlSprintf,
GlFormGroup,
},
+ mixins: [glFeatureFlagsMixin()],
inject: {
prependedText: {
default: '',
@@ -60,13 +64,17 @@ export default {
'modalTitle',
'existingBranch',
'prependedText',
+ 'targetProjectId',
+ 'targetProjectName',
+ 'branchesEndpoint',
]),
},
mounted() {
+ this.setSelectedProject(this.targetProjectId);
eventHub.$on(this.openModal, this.show);
},
methods: {
- ...mapActions(['clearModal', 'setBranch', 'setSelectedBranch']),
+ ...mapActions(['clearModal', 'setBranch', 'setSelectedBranch', 'setSelectedProject']),
show() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@@ -102,6 +110,26 @@ export default {
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<gl-form-group
+ v-if="glFeatures.pickIntoProject"
+ :label="i18n.projectLabel"
+ label-for="start_project"
+ data-testid="dropdown-group"
+ >
+ <input
+ id="target_project_id"
+ type="hidden"
+ name="target_project_id"
+ :value="targetProjectId"
+ />
+
+ <projects-dropdown
+ class="gl-w-half"
+ :value="targetProjectName"
+ @selectProject="setSelectedProject"
+ />
+ </gl-form-group>
+
+ <gl-form-group
:label="i18n.branchLabel"
label-for="start_branch"
data-testid="dropdown-group"
diff --git a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
new file mode 100644
index 00000000000..6288bcdaad0
--- /dev/null
+++ b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
@@ -0,0 +1,87 @@
+<script>
+import { GlDropdown, GlSearchBoxByType, GlDropdownItem, GlDropdownText } from '@gitlab/ui';
+import { mapGetters, mapState } from 'vuex';
+import {
+ I18N_NO_RESULTS_MESSAGE,
+ I18N_PROJECT_HEADER,
+ I18N_PROJECT_SEARCH_PLACEHOLDER,
+} from '../constants';
+
+export default {
+ name: 'ProjectsDropdown',
+ components: {
+ GlDropdown,
+ GlSearchBoxByType,
+ GlDropdownItem,
+ GlDropdownText,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ i18n: {
+ noResultsMessage: I18N_NO_RESULTS_MESSAGE,
+ projectHeaderTitle: I18N_PROJECT_HEADER,
+ projectSearchPlaceholder: I18N_PROJECT_SEARCH_PLACEHOLDER,
+ },
+ data() {
+ return {
+ filterTerm: this.value,
+ };
+ },
+ computed: {
+ ...mapGetters(['sortedProjects']),
+ ...mapState(['targetProjectId']),
+ filteredResults() {
+ const lowerCasedFilterTerm = this.filterTerm.toLowerCase();
+ return this.sortedProjects.filter((project) =>
+ project.name.toLowerCase().includes(lowerCasedFilterTerm),
+ );
+ },
+ selectedProject() {
+ return this.sortedProjects.find((project) => project.id === this.targetProjectId) || {};
+ },
+ },
+ methods: {
+ selectProject(project) {
+ this.$emit('selectProject', project.id);
+ this.filterTerm = project.name; // when we select a project, we want the dropdown to filter to the selected project
+ },
+ isSelected(selectedProject) {
+ return selectedProject === this.selectedProject;
+ },
+ filterTermChanged(value) {
+ this.filterTerm = value;
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown :text="selectedProject.name" :header-text="$options.i18n.projectHeaderTitle">
+ <gl-search-box-by-type
+ :value="filterTerm"
+ trim
+ autocomplete="off"
+ :placeholder="$options.i18n.projectSearchPlaceholder"
+ data-testid="dropdown-search-box"
+ @input="filterTermChanged"
+ />
+ <gl-dropdown-item
+ v-for="project in filteredResults"
+ :key="project.name"
+ :name="project.name"
+ :is-checked="isSelected(project)"
+ is-check-item
+ data-testid="dropdown-item"
+ @click="selectProject(project)"
+ >
+ {{ project.name }}
+ </gl-dropdown-item>
+ <gl-dropdown-text v-if="!filteredResults.length" data-testid="empty-result-message">
+ <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span>
+ </gl-dropdown-text>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/projects/commit/constants.js b/app/assets/javascripts/projects/commit/constants.js
index b47c744e5fb..d6bb4e9483f 100644
--- a/app/assets/javascripts/projects/commit/constants.js
+++ b/app/assets/javascripts/projects/commit/constants.js
@@ -26,6 +26,7 @@ export const I18N_REVERT_MODAL = {
export const I18N_CHERRY_PICK_MODAL = {
branchLabel: s__('ChangeTypeAction|Pick into branch'),
+ projectLabel: s__('ChangeTypeAction|Pick into project'),
actionPrimaryText: s__('ChangeTypeAction|Cherry-pick'),
};
@@ -33,10 +34,12 @@ export const PREPENDED_MODAL_TEXT = s__(
'ChangeTypeAction|This will create a new commit in order to revert the existing changes.',
);
-export const I18N_DROPDOWN = {
- noResultsMessage: __('No matching results'),
- headerTitle: s__('ChangeTypeAction|Switch branch'),
- searchPlaceholder: s__('ChangeTypeAction|Search branches'),
-};
+export const I18N_NO_RESULTS_MESSAGE = __('No matching results');
+
+export const I18N_PROJECT_HEADER = s__('ChangeTypeAction|Switch project');
+export const I18N_PROJECT_SEARCH_PLACEHOLDER = s__('ChangeTypeAction|Search projects');
+
+export const I18N_BRANCH_HEADER = s__('ChangeTypeAction|Switch branch');
+export const I18N_BRANCH_SEARCH_PLACEHOLDER = s__('ChangeTypeAction|Search branches');
export const PROJECT_BRANCHES_ERROR = __('Something went wrong while fetching branches');
diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
index 24baa27ff70..ad31ad14b2a 100644
--- a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
+++ b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import CommitFormModal from './components/form_modal.vue';
import {
I18N_MODAL,
@@ -19,21 +19,27 @@ export default function initInviteMembersModal() {
title,
endpoint,
branch,
+ targetProjectId,
+ targetProjectName,
pushCode,
branchCollaboration,
existingBranch,
branchesEndpoint,
+ projects,
} = el.dataset;
const store = createStore({
endpoint,
branchesEndpoint,
branch,
+ targetProjectId,
+ targetProjectName,
pushCode: parseBoolean(pushCode),
branchCollaboration: parseBoolean(branchCollaboration),
defaultBranch: branch,
modalTitle: title,
existingBranch,
+ projects: convertObjectPropsToCamelCase(JSON.parse(projects), { deep: true }),
});
return new Vue({
diff --git a/app/assets/javascripts/projects/commit/store/actions.js b/app/assets/javascripts/projects/commit/store/actions.js
index 10135e55351..c72704303ca 100644
--- a/app/assets/javascripts/projects/commit/store/actions.js
+++ b/app/assets/javascripts/projects/commit/store/actions.js
@@ -11,6 +11,10 @@ export const requestBranches = ({ commit }) => {
commit(types.REQUEST_BRANCHES);
};
+export const setBranchesEndpoint = ({ commit }, endpoint) => {
+ commit(types.SET_BRANCHES_ENDPOINT, endpoint);
+};
+
export const fetchBranches = ({ commit, dispatch, state }, query) => {
dispatch('requestBranches');
@@ -18,8 +22,8 @@ export const fetchBranches = ({ commit, dispatch, state }, query) => {
.get(state.branchesEndpoint, {
params: { search: query },
})
- .then((res) => {
- commit(types.RECEIVE_BRANCHES_SUCCESS, res.data);
+ .then(({ data }) => {
+ commit(types.RECEIVE_BRANCHES_SUCCESS, data.Branches || []);
})
.catch(() => {
createFlash({ message: PROJECT_BRANCHES_ERROR });
@@ -34,3 +38,15 @@ export const setBranch = ({ commit, dispatch }, branch) => {
export const setSelectedBranch = ({ commit }, branch) => {
commit(types.SET_SELECTED_BRANCH, branch);
};
+
+export const setSelectedProject = ({ commit, dispatch, state }, id) => {
+ let { branchesEndpoint } = state;
+
+ if (state.projects?.length) {
+ branchesEndpoint = state.projects.find((p) => p.id === id).refsUrl;
+ }
+
+ commit(types.SET_SELECTED_PROJECT, id);
+ dispatch('setBranchesEndpoint', branchesEndpoint);
+ dispatch('fetchBranches');
+};
diff --git a/app/assets/javascripts/projects/commit/store/getters.js b/app/assets/javascripts/projects/commit/store/getters.js
index 664eaca32cf..e0c36df8a75 100644
--- a/app/assets/javascripts/projects/commit/store/getters.js
+++ b/app/assets/javascripts/projects/commit/store/getters.js
@@ -3,3 +3,5 @@ import { uniq } from 'lodash';
export const joinedBranches = (state) => {
return uniq(state.branches).sort();
};
+
+export const sortedProjects = (state) => uniq(state.projects).sort();
diff --git a/app/assets/javascripts/projects/commit/store/mutation_types.js b/app/assets/javascripts/projects/commit/store/mutation_types.js
index de0bb47e18d..d0cf9f25ee9 100644
--- a/app/assets/javascripts/projects/commit/store/mutation_types.js
+++ b/app/assets/javascripts/projects/commit/store/mutation_types.js
@@ -1,6 +1,9 @@
export const CLEAR_MODAL = 'CLEAR_MODAL';
+export const SET_BRANCHES_ENDPOINT = 'SET_BRANCHES_ENDPOINT';
export const REQUEST_BRANCHES = 'REQUEST_BRANCHES';
export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS';
export const SET_BRANCH = 'SET_BRANCH';
export const SET_SELECTED_BRANCH = 'SET_SELECTED_BRANCH';
+
+export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT';
diff --git a/app/assets/javascripts/projects/commit/store/mutations.js b/app/assets/javascripts/projects/commit/store/mutations.js
index 6add00deadb..43f8f173014 100644
--- a/app/assets/javascripts/projects/commit/store/mutations.js
+++ b/app/assets/javascripts/projects/commit/store/mutations.js
@@ -1,6 +1,10 @@
import * as types from './mutation_types';
export default {
+ [types.SET_BRANCHES_ENDPOINT](state, endpoint) {
+ state.branchesEndpoint = endpoint;
+ },
+
[types.REQUEST_BRANCHES](state) {
state.isFetching = true;
},
@@ -22,4 +26,9 @@ export default {
[types.SET_SELECTED_BRANCH](state, branch) {
state.selectedBranch = branch;
},
+
+ [types.SET_SELECTED_PROJECT](state, projectId) {
+ state.targetProjectId = projectId;
+ state.branch = state.defaultBranch;
+ },
};
diff --git a/app/assets/javascripts/projects/commit/store/state.js b/app/assets/javascripts/projects/commit/store/state.js
index 78c294324df..660fd2ef17c 100644
--- a/app/assets/javascripts/projects/commit/store/state.js
+++ b/app/assets/javascripts/projects/commit/store/state.js
@@ -3,6 +3,7 @@ export default () => ({
branchesEndpoint: null,
isFetching: false,
branches: [],
+ projects: [],
selectedBranch: '',
pushCode: false,
branchCollaboration: false,
@@ -10,4 +11,6 @@ export default () => ({
existingBranch: '',
defaultBranch: '',
branch: '',
+ targetProjectId: '',
+ targetProjectName: '',
});
diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js
index 4bbdb5c2357..17c63ecf66b 100644
--- a/app/assets/javascripts/projects/commit_box/info/index.js
+++ b/app/assets/javascripts/projects/commit_box/info/index.js
@@ -1,5 +1,6 @@
import { fetchCommitMergeRequests } from '~/commit_merge_requests';
import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
+import { initCommitPipelineMiniGraph } from './init_commit_pipeline_mini_graph';
import { initDetailsButton } from './init_details_button';
import { loadBranches } from './load_branches';
@@ -12,10 +13,15 @@ export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') =>
// Related merge requests to this commit
fetchCommitMergeRequests();
- // Display pipeline info for this commit
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
+ // Display pipeline mini graph for this commit
+ // Feature flag ci_commit_pipeline_mini_graph_vue
+ if (gon.features.ciCommitPipelineMiniGraphVue) {
+ initCommitPipelineMiniGraph();
+ } else {
+ new MiniPipelineGraph({
+ container: '.js-commit-pipeline-graph',
+ }).bindEvents();
+ }
initDetailsButton();
};
diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js
new file mode 100644
index 00000000000..9173f5c771f
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+
+export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipeline-mini-graph') => {
+ const el = document.querySelector(selector);
+ if (!el) {
+ return;
+ }
+
+ // Some commits have no pipeline, code splitting to load the pipeline optionally
+ const { stages } = el.dataset;
+ const { default: PipelineMiniGraph } = await import(
+ /* webpackChunkName: 'pipelineMiniGraph' */ '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'
+ );
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(PipelineMiniGraph, {
+ props: {
+ stages: JSON.parse(stages),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
index 72d4f0c31e5..741dc20b1f1 100644
--- a/app/assets/javascripts/projects/commits/store/actions.js
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -1,8 +1,8 @@
+import * as Sentry from '@sentry/browser';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
import * as types from './mutation_types';
export default {
diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue
index 05bd0f1370b..d2fb524489e 100644
--- a/app/assets/javascripts/projects/compare/components/app.vue
+++ b/app/assets/javascripts/projects/compare/components/app.vue
@@ -1,12 +1,12 @@
<script>
import { GlButton } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
-import RevisionDropdown from './revision_dropdown.vue';
+import RevisionCard from './revision_card.vue';
export default {
csrf,
components: {
- RevisionDropdown,
+ RevisionCard,
GlButton,
},
props: {
@@ -48,42 +48,53 @@ export default {
<template>
<form
ref="form"
- class="form-inline js-requires-input js-signature-container"
+ class="js-requires-input js-signature-container"
method="POST"
:action="projectCompareIndexPath"
>
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <revision-dropdown
- :refs-project-path="refsProjectPath"
- revision-text="Source"
- params-name="to"
- :params-branch="paramsTo"
- />
- <div class="compare-ellipsis gl-display-inline" data-testid="ellipsis">...</div>
- <revision-dropdown
- :refs-project-path="refsProjectPath"
- revision-text="Target"
- params-name="from"
- :params-branch="paramsFrom"
- />
- <gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit">
- {{ s__('CompareRevisions|Compare') }}
- </gl-button>
- <a
- v-if="projectMergeRequestPath"
- :href="projectMergeRequestPath"
- data-testid="projectMrButton"
- class="btn btn-default gl-button gl-ml-3"
+ <div
+ class="gl-lg-flex-direction-row gl-lg-display-flex gl-align-items-center compare-revision-cards"
>
- {{ s__('CompareRevisions|View open merge request') }}
- </a>
- <a
- v-else-if="createMrPath"
- :href="createMrPath"
- data-testid="createMrButton"
- class="btn btn-default gl-button gl-ml-3"
- >
- {{ s__('CompareRevisions|Create merge request') }}
- </a>
+ <revision-card
+ :refs-project-path="refsProjectPath"
+ revision-text="Source"
+ params-name="to"
+ :params-branch="paramsTo"
+ />
+ <div
+ class="compare-ellipsis gl-display-flex gl-justify-content-center gl-align-items-center gl-my-4 gl-md-my-0"
+ data-testid="ellipsis"
+ >
+ ...
+ </div>
+ <revision-card
+ :refs-project-path="refsProjectPath"
+ revision-text="Target"
+ params-name="from"
+ :params-branch="paramsFrom"
+ />
+ </div>
+ <div class="gl-mt-4">
+ <gl-button category="primary" variant="success" @click="onSubmit">
+ {{ s__('CompareRevisions|Compare') }}
+ </gl-button>
+ <gl-button
+ v-if="projectMergeRequestPath"
+ :href="projectMergeRequestPath"
+ data-testid="projectMrButton"
+ class="btn btn-default gl-button"
+ >
+ {{ s__('CompareRevisions|View open merge request') }}
+ </gl-button>
+ <gl-button
+ v-else-if="createMrPath"
+ :href="createMrPath"
+ data-testid="createMrButton"
+ class="btn btn-default gl-button"
+ >
+ {{ s__('CompareRevisions|Create merge request') }}
+ </gl-button>
+ </div>
</form>
</template>
diff --git a/app/assets/javascripts/projects/compare/components/app_legacy.vue b/app/assets/javascripts/projects/compare/components/app_legacy.vue
new file mode 100644
index 00000000000..c0ff58ee074
--- /dev/null
+++ b/app/assets/javascripts/projects/compare/components/app_legacy.vue
@@ -0,0 +1,89 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import RevisionDropdown from './revision_dropdown_legacy.vue';
+
+export default {
+ csrf,
+ components: {
+ RevisionDropdown,
+ GlButton,
+ },
+ props: {
+ projectCompareIndexPath: {
+ type: String,
+ required: true,
+ },
+ refsProjectPath: {
+ type: String,
+ required: true,
+ },
+ paramsFrom: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ paramsTo: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ projectMergeRequestPath: {
+ type: String,
+ required: true,
+ },
+ createMrPath: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.$refs.form.submit();
+ },
+ },
+};
+</script>
+
+<template>
+ <form
+ ref="form"
+ class="form-inline js-requires-input js-signature-container"
+ method="POST"
+ :action="projectCompareIndexPath"
+ >
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <revision-dropdown
+ :refs-project-path="refsProjectPath"
+ revision-text="Source"
+ params-name="to"
+ :params-branch="paramsTo"
+ />
+ <div class="compare-ellipsis gl-display-inline" data-testid="ellipsis">...</div>
+ <revision-dropdown
+ :refs-project-path="refsProjectPath"
+ revision-text="Target"
+ params-name="from"
+ :params-branch="paramsFrom"
+ />
+ <gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit">
+ {{ s__('CompareRevisions|Compare') }}
+ </gl-button>
+ <gl-button
+ v-if="projectMergeRequestPath"
+ :href="projectMergeRequestPath"
+ data-testid="projectMrButton"
+ class="btn btn-default gl-button gl-ml-3"
+ >
+ {{ s__('CompareRevisions|View open merge request') }}
+ </gl-button>
+ <gl-button
+ v-else-if="createMrPath"
+ :href="createMrPath"
+ data-testid="createMrButton"
+ class="btn btn-default gl-button gl-ml-3"
+ >
+ {{ s__('CompareRevisions|Create merge request') }}
+ </gl-button>
+ </form>
+</template>
diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
new file mode 100644
index 00000000000..822dfc09d81
--- /dev/null
+++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
@@ -0,0 +1,93 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+
+const SOURCE_PARAM_NAME = 'to';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ },
+ inject: ['projectTo', 'projectsFrom'],
+ props: {
+ paramsName: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ selectedRepo: {},
+ };
+ },
+ computed: {
+ filteredRepos() {
+ const lowerCaseSearchTerm = this.searchTerm.toLowerCase();
+
+ return this?.projectsFrom.filter(({ name }) =>
+ name.toLowerCase().includes(lowerCaseSearchTerm),
+ );
+ },
+ isSourceRevision() {
+ return this.paramsName === SOURCE_PARAM_NAME;
+ },
+ inputName() {
+ return `${this.paramsName}_project_id`;
+ },
+ },
+ mounted() {
+ this.setDefaultRepo();
+ },
+ methods: {
+ onClick(repo) {
+ this.selectedRepo = repo;
+ this.emitTargetProject(repo.name);
+ },
+ setDefaultRepo() {
+ if (this.isSourceRevision) {
+ this.selectedRepo = this.projectTo;
+ return;
+ }
+
+ const [defaultTargetProject] = this.projectsFrom;
+ this.emitTargetProject(defaultTargetProject.name);
+ this.selectedRepo = defaultTargetProject;
+ },
+ emitTargetProject(name) {
+ if (!this.isSourceRevision) {
+ this.$emit('changeTargetProject', name);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <input type="hidden" :name="inputName" :value="selectedRepo.id" />
+ <gl-dropdown
+ :text="selectedRepo.name"
+ :header-text="s__(`CompareRevisions|Select target project`)"
+ class="gl-w-full gl-font-monospace gl-sm-pr-3"
+ toggle-class="gl-min-w-0"
+ :disabled="isSourceRevision"
+ >
+ <template #header>
+ <gl-search-box-by-type v-if="!isSourceRevision" v-model.trim="searchTerm" />
+ </template>
+ <template v-if="!isSourceRevision">
+ <gl-dropdown-item
+ v-for="repo in filteredRepos"
+ :key="repo.id"
+ is-check-item
+ :is-checked="selectedRepo.id === repo.id"
+ @click="onClick(repo)"
+ >
+ {{ repo.name }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue
new file mode 100644
index 00000000000..15d24792310
--- /dev/null
+++ b/app/assets/javascripts/projects/compare/components/revision_card.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlCard } from '@gitlab/ui';
+import RepoDropdown from './repo_dropdown.vue';
+import RevisionDropdown from './revision_dropdown.vue';
+
+export default {
+ components: {
+ RepoDropdown,
+ RevisionDropdown,
+ GlCard,
+ },
+ props: {
+ refsProjectPath: {
+ type: String,
+ required: true,
+ },
+ revisionText: {
+ type: String,
+ required: true,
+ },
+ paramsName: {
+ type: String,
+ required: true,
+ },
+ paramsBranch: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ selectedRefsProjectPath: this.refsProjectPath,
+ };
+ },
+ methods: {
+ onChangeTargetProject(targetProjectName) {
+ if (this.paramsName === 'from') {
+ this.selectedRefsProjectPath = `/${targetProjectName}/refs`;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-card header-class="gl-py-2 gl-px-3 gl-font-weight-bold" body-class="gl-px-3">
+ <template #header>
+ {{ s__(`CompareRevisions|${revisionText}`) }}
+ </template>
+ <div class="gl-sm-display-flex gl-align-items-center">
+ <repo-dropdown
+ class="gl-sm-w-half"
+ :params-name="paramsName"
+ @changeTargetProject="onChangeTargetProject"
+ />
+ <revision-dropdown
+ class="gl-sm-w-half gl-mt-3 gl-sm-mt-0"
+ :refs-project-path="selectedRefsProjectPath"
+ :params-name="paramsName"
+ :params-branch="paramsBranch"
+ />
+ </div>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
index 13d80b5ae0b..a175af2f32e 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
@@ -4,6 +4,8 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
+const emptyDropdownText = s__('CompareRevisions|Select branch/tag');
+
export default {
components: {
GlDropdown,
@@ -16,10 +18,6 @@ export default {
type: String,
required: true,
},
- revisionText: {
- type: String,
- required: true,
- },
paramsName: {
type: String,
required: true,
@@ -55,12 +53,24 @@ export default {
return this.filteredTags.length;
},
},
+ watch: {
+ refsProjectPath(newRefsProjectPath, oldRefsProjectPath) {
+ if (newRefsProjectPath !== oldRefsProjectPath) {
+ this.fetchBranchesAndTags(true);
+ }
+ },
+ },
mounted() {
this.fetchBranchesAndTags();
},
methods: {
- fetchBranchesAndTags() {
+ fetchBranchesAndTags(reset = false) {
const endpoint = this.refsProjectPath;
+ this.loading = true;
+
+ if (reset) {
+ this.selectedRevision = this.getDefaultBranch();
+ }
return axios
.get(endpoint)
@@ -70,9 +80,9 @@ export default {
})
.catch(() => {
createFlash({
- message: `${s__(
- 'CompareRevisions|There was an error while updating the branch/tag list. Please try again.',
- )}`,
+ message: s__(
+ 'CompareRevisions|There was an error while loading the branch/tag list. Please try again.',
+ ),
});
})
.finally(() => {
@@ -80,7 +90,7 @@ export default {
});
},
getDefaultBranch() {
- return this.paramsBranch || s__('CompareRevisions|Select branch/tag');
+ return this.paramsBranch || emptyDropdownText;
},
onClick(revision) {
this.selectedRevision = revision;
@@ -93,53 +103,46 @@ export default {
</script>
<template>
- <div class="form-group compare-form-group" :class="`js-compare-${paramsName}-dropdown`">
- <div class="input-group inline-input-group">
- <span class="input-group-prepend">
- <div class="input-group-text">
- {{ revisionText }}
- </div>
- </span>
- <input type="hidden" :name="paramsName" :value="selectedRevision" />
- <gl-dropdown
- class="gl-flex-grow-1 gl-flex-basis-0 gl-min-w-0 gl-font-monospace"
- toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!"
- :text="selectedRevision"
- header-text="Select Git revision"
- :loading="loading"
+ <div :class="`js-compare-${paramsName}-dropdown`">
+ <input type="hidden" :name="paramsName" :value="selectedRevision" />
+ <gl-dropdown
+ class="gl-w-full gl-font-monospace"
+ toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0"
+ :text="selectedRevision"
+ :header-text="s__('CompareRevisions|Select Git revision')"
+ :loading="loading"
+ >
+ <template #header>
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :placeholder="s__('CompareRevisions|Filter by Git revision')"
+ @keyup.enter="onSearchEnter"
+ />
+ </template>
+ <gl-dropdown-section-header v-if="hasFilteredBranches">
+ {{ s__('CompareRevisions|Branches') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(branch, index) in filteredBranches"
+ :key="`branch${index}`"
+ is-check-item
+ :is-checked="selectedRevision === branch"
+ @click="onClick(branch)"
+ >
+ {{ branch }}
+ </gl-dropdown-item>
+ <gl-dropdown-section-header v-if="hasFilteredTags">
+ {{ s__('CompareRevisions|Tags') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(tag, index) in filteredTags"
+ :key="`tag${index}`"
+ is-check-item
+ :is-checked="selectedRevision === tag"
+ @click="onClick(tag)"
>
- <template #header>
- <gl-search-box-by-type
- v-model.trim="searchTerm"
- :placeholder="s__('CompareRevisions|Filter by Git revision')"
- @keyup.enter="onSearchEnter"
- />
- </template>
- <gl-dropdown-section-header v-if="hasFilteredBranches">
- {{ s__('CompareRevisions|Branches') }}
- </gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="(branch, index) in filteredBranches"
- :key="`branch${index}`"
- is-check-item
- :is-checked="selectedRevision === branch"
- @click="onClick(branch)"
- >
- {{ branch }}
- </gl-dropdown-item>
- <gl-dropdown-section-header v-if="hasFilteredTags">
- {{ s__('CompareRevisions|Tags') }}
- </gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="(tag, index) in filteredTags"
- :key="`tag${index}`"
- is-check-item
- :is-checked="selectedRevision === tag"
- @click="onClick(tag)"
- >
- {{ tag }}
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
+ {{ tag }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
new file mode 100644
index 00000000000..13d80b5ae0b
--- /dev/null
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
@@ -0,0 +1,145 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ },
+ props: {
+ refsProjectPath: {
+ type: String,
+ required: true,
+ },
+ revisionText: {
+ type: String,
+ required: true,
+ },
+ paramsName: {
+ type: String,
+ required: true,
+ },
+ paramsBranch: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ branches: [],
+ tags: [],
+ loading: true,
+ searchTerm: '',
+ selectedRevision: this.getDefaultBranch(),
+ };
+ },
+ computed: {
+ filteredBranches() {
+ return this.branches.filter((branch) =>
+ branch.toLowerCase().includes(this.searchTerm.toLowerCase()),
+ );
+ },
+ hasFilteredBranches() {
+ return this.filteredBranches.length;
+ },
+ filteredTags() {
+ return this.tags.filter((tag) => tag.toLowerCase().includes(this.searchTerm.toLowerCase()));
+ },
+ hasFilteredTags() {
+ return this.filteredTags.length;
+ },
+ },
+ mounted() {
+ this.fetchBranchesAndTags();
+ },
+ methods: {
+ fetchBranchesAndTags() {
+ const endpoint = this.refsProjectPath;
+
+ return axios
+ .get(endpoint)
+ .then(({ data }) => {
+ this.branches = data.Branches || [];
+ this.tags = data.Tags || [];
+ })
+ .catch(() => {
+ createFlash({
+ message: `${s__(
+ 'CompareRevisions|There was an error while updating the branch/tag list. Please try again.',
+ )}`,
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ getDefaultBranch() {
+ return this.paramsBranch || s__('CompareRevisions|Select branch/tag');
+ },
+ onClick(revision) {
+ this.selectedRevision = revision;
+ },
+ onSearchEnter() {
+ this.selectedRevision = this.searchTerm;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="form-group compare-form-group" :class="`js-compare-${paramsName}-dropdown`">
+ <div class="input-group inline-input-group">
+ <span class="input-group-prepend">
+ <div class="input-group-text">
+ {{ revisionText }}
+ </div>
+ </span>
+ <input type="hidden" :name="paramsName" :value="selectedRevision" />
+ <gl-dropdown
+ class="gl-flex-grow-1 gl-flex-basis-0 gl-min-w-0 gl-font-monospace"
+ toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!"
+ :text="selectedRevision"
+ header-text="Select Git revision"
+ :loading="loading"
+ >
+ <template #header>
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :placeholder="s__('CompareRevisions|Filter by Git revision')"
+ @keyup.enter="onSearchEnter"
+ />
+ </template>
+ <gl-dropdown-section-header v-if="hasFilteredBranches">
+ {{ s__('CompareRevisions|Branches') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(branch, index) in filteredBranches"
+ :key="`branch${index}`"
+ is-check-item
+ :is-checked="selectedRevision === branch"
+ @click="onClick(branch)"
+ >
+ {{ branch }}
+ </gl-dropdown-item>
+ <gl-dropdown-section-header v-if="hasFilteredTags">
+ {{ s__('CompareRevisions|Tags') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(tag, index) in filteredTags"
+ :key="`tag${index}`"
+ is-check-item
+ :is-checked="selectedRevision === tag"
+ @click="onClick(tag)"
+ >
+ {{ tag }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js
index 4337eecb667..4ba4e308cd4 100644
--- a/app/assets/javascripts/projects/compare/index.js
+++ b/app/assets/javascripts/projects/compare/index.js
@@ -1,8 +1,46 @@
import Vue from 'vue';
import CompareApp from './components/app.vue';
+import CompareAppLegacy from './components/app_legacy.vue';
export default function init() {
const el = document.getElementById('js-compare-selector');
+
+ if (gon.features?.compareRepoDropdown) {
+ const {
+ refsProjectPath,
+ paramsFrom,
+ paramsTo,
+ projectCompareIndexPath,
+ projectMergeRequestPath,
+ createMrPath,
+ projectTo,
+ projectsFrom,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ components: {
+ CompareApp,
+ },
+ provide: {
+ projectTo: JSON.parse(projectTo),
+ projectsFrom: JSON.parse(projectsFrom),
+ },
+ render(createElement) {
+ return createElement(CompareApp, {
+ props: {
+ refsProjectPath,
+ paramsFrom,
+ paramsTo,
+ projectCompareIndexPath,
+ projectMergeRequestPath,
+ createMrPath,
+ },
+ });
+ },
+ });
+ }
+
const {
refsProjectPath,
paramsFrom,
@@ -15,10 +53,10 @@ export default function init() {
return new Vue({
el,
components: {
- CompareApp,
+ CompareAppLegacy,
},
render(createElement) {
- return createElement(CompareApp, {
+ return createElement(CompareAppLegacy, {
props: {
refsProjectPath,
paramsFrom,
diff --git a/app/assets/javascripts/projects/details/upload_button.vue b/app/assets/javascripts/projects/details/upload_button.vue
new file mode 100644
index 00000000000..5b19f15c233
--- /dev/null
+++ b/app/assets/javascripts/projects/details/upload_button.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
+import { trackFileUploadEvent } from '../upload_file_experiment_tracking';
+
+const UPLOAD_BLOB_MODAL_ID = 'details-modal-upload-blob';
+
+export default {
+ components: {
+ GlButton,
+ UploadBlobModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ inject: {
+ targetBranch: {
+ default: '',
+ },
+ originalBranch: {
+ default: '',
+ },
+ canPushCode: {
+ default: false,
+ },
+ path: {
+ default: '',
+ },
+ projectPath: {
+ default: '',
+ },
+ },
+ methods: {
+ trackOpenModal() {
+ trackFileUploadEvent('click_upload_modal_trigger');
+ },
+ },
+ uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
+};
+</script>
+<template>
+ <span>
+ <gl-button
+ v-gl-modal="$options.uploadBlobModalId"
+ icon="upload"
+ data-testid="upload-file-button"
+ @click="trackOpenModal"
+ >{{ __('Upload File') }}</gl-button
+ >
+ <upload-blob-modal
+ :modal-id="$options.uploadBlobModalId"
+ :commit-message="__('Upload New File')"
+ :target-branch="targetBranch"
+ :original-branch="originalBranch"
+ :can-push-code="canPushCode"
+ :path="path"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue
new file mode 100644
index 00000000000..e42d9154866
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlPopover, GlFormInputGroup } from '@gitlab/ui';
+import { __ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ components: {
+ GlPopover,
+ GlFormInputGroup,
+ ClipboardButton,
+ },
+ inject: ['pushToCreateProjectCommand', 'workingWithProjectsHelpPath'],
+ props: {
+ target: {
+ type: [Function, HTMLElement],
+ required: true,
+ },
+ },
+ i18n: {
+ clipboardButtonTitle: __('Copy command'),
+ commandInputAriaLabel: __('Push project from command line'),
+ helpLinkText: __('What does this command do?'),
+ labelText: __('Private projects can be created in your personal namespace with:'),
+ popoverTitle: __('Push to create a project'),
+ },
+};
+</script>
+<template>
+ <gl-popover
+ :target="target"
+ :title="$options.i18n.popoverTitle"
+ triggers="click blur"
+ placement="top"
+ >
+ <p>
+ <label for="push-to-create-tip" class="gl-font-weight-normal">
+ {{ $options.i18n.labelText }}
+ </label>
+ </p>
+ <p>
+ <gl-form-input-group
+ id="push-to-create-tip"
+ :value="pushToCreateProjectCommand"
+ readonly
+ select-on-click
+ :aria-label="$options.i18n.commandInputAriaLabel"
+ >
+ <template #append>
+ <clipboard-button
+ :text="pushToCreateProjectCommand"
+ :title="$options.i18n.clipboardButtonTitle"
+ tooltip-placement="right"
+ />
+ </template>
+ </gl-form-input-group>
+ </p>
+ <p>
+ <a
+ :href="`${workingWithProjectsHelpPath}#push-to-create-a-new-project`"
+ class="gl-font-sm"
+ target="_blank"
+ >{{ $options.i18n.helpLinkText }}</a
+ >
+ </p>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
index 63a65975fff..ed82a635b1f 100644
--- a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
@@ -1,15 +1,13 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlPopover } from '@gitlab/ui';
import Tracking from '~/tracking';
-import LegacyContainer from './legacy_container.vue';
+import NewProjectPushTipPopover from './new_project_push_tip_popover.vue';
const trackingMixin = Tracking.mixin(gon.tracking_data);
export default {
components: {
- GlPopover,
- LegacyContainer,
+ NewProjectPushTipPopover,
},
mixins: [trackingMixin],
props: {
@@ -52,19 +50,15 @@ export default {
<p>
{{ __('You can also create a project from the command line.') }}
<a
- id="cli-tip"
+ ref="clipTip"
href="#"
click.prevent
class="push-new-project-tip"
- data-title="Push to create a project"
rel="noopener noreferrer"
>
{{ __('Show command') }}
</a>
-
- <gl-popover target="cli-tip" triggers="click blur" placement="top">
- <legacy-container selector=".push-new-project-tip-template" />
- </gl-popover>
+ <new-project-push-tip-popover :target="() => $refs.clipTip" />
</p>
</div>
</div>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/index.js b/app/assets/javascripts/projects/experiment_new_project_creation/index.js
index 0414f7ef6a5..ea686d4e1e8 100644
--- a/app/assets/javascripts/projects/experiment_new_project_creation/index.js
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/index.js
@@ -2,11 +2,17 @@ import Vue from 'vue';
import NewProjectCreationApp from './components/app.vue';
export default function initNewProjectCreation(el, props) {
+ const { pushToCreateProjectCommand, workingWithProjectsHelpPath } = el.dataset;
+
return new Vue({
el,
components: {
NewProjectCreationApp,
},
+ provide: {
+ workingWithProjectsHelpPath,
+ pushToCreateProjectCommand,
+ },
render(h) {
return h(NewProjectCreationApp, { props });
},
diff --git a/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js
new file mode 100644
index 00000000000..2bd3e57322d
--- /dev/null
+++ b/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import UserList from '~/user_lists/components/user_list.vue';
+import createStore from '~/user_lists/store/show';
+
+Vue.use(Vuex);
+
+export default function featureFlagsUserListInit() {
+ const el = document.getElementById('js-edit-user-list');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ store: createStore(el.dataset),
+ render(h) {
+ const { emptyStatePath } = el.dataset;
+ return h(UserList, { props: { emptyStatePath } });
+ },
+ });
+}
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
index 733f833d51a..6a963616224 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
@@ -252,10 +252,10 @@ export default {
},
errorTexts: {
[LOAD_ANALYTICS_FAILURE]: s__(
- 'PipelineCharts|An error has ocurred when retrieving the analytics data',
+ 'PipelineCharts|An error has occurred when retrieving the analytics data',
),
[LOAD_PIPELINES_FAILURE]: s__(
- 'PipelineCharts|An error has ocurred when retrieving the pipelines data',
+ 'PipelineCharts|An error has occurred when retrieving the pipelines data',
),
[PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'),
[DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'),
@@ -292,7 +292,7 @@ export default {
failure.text
}}</gl-alert>
<div class="gl-mb-3">
- <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3>
+ <h3>{{ s__('PipelineCharts|CI/CD Analytics') }}</h3>
</div>
<h4 class="gl-my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4>
<div class="row">
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index e3ba84102a8..04ea6f760f6 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
-import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
import {
convertToTitleCase,
humanize,
@@ -81,7 +80,6 @@ const bindEvents = () => {
const $selectedTemplateText = $('.selected-template');
const $changeTemplateBtn = $('.change-template');
const $selectedIcon = $('.selected-icon');
- const $pushNewProjectTipTrigger = $('.push-new-project-tip');
const $projectTemplateButtons = $('.project-templates-buttons');
const $projectName = $('.tab-pane.active #project_name');
@@ -108,39 +106,6 @@ const bindEvents = () => {
);
});
- if ($pushNewProjectTipTrigger) {
- $pushNewProjectTipTrigger
- .removeAttr('rel')
- .removeAttr('target')
- .on('click', (e) => {
- e.preventDefault();
- })
- .popover({
- title: $pushNewProjectTipTrigger.data('title'),
- placement: 'bottom',
- html: true,
- content: $('.push-new-project-tip-template').html(),
- })
- .on('shown.bs.popover', () => {
- $(document).on('click.popover touchstart.popover', (event) => {
- if ($(event.target).closest('.popover').length === 0) {
- $pushNewProjectTipTrigger.trigger('click');
- }
- });
-
- const target = $(`#${$pushNewProjectTipTrigger.attr('aria-describedby')}`).find(
- '.js-select-on-focus',
- );
- addSelectOnFocusBehaviour(target);
-
- target.focus();
- })
- .on('hide.bs.popover', () => {
- // eslint-disable-next-line @gitlab/no-global-event-off
- $(document).off('click.popover touchstart.popover');
- });
- }
-
function chooseTemplate() {
$projectTemplateButtons.addClass('hidden');
$projectFieldsForm.addClass('selected');
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
index 9b3c0dd2755..fb00f58abae 100644
--- 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
@@ -95,7 +95,7 @@ export default {
})
.catch((err) => {
this.showAlert(
- sprintf(__('An error occured while saving changes: %{error}'), {
+ sprintf(__('An error occurred while saving changes: %{error}'), {
error: err?.response?.data?.message,
}),
);
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
index 39d9a6a4239..3294a37c26a 100644
--- 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
@@ -4,6 +4,9 @@ import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
+ i18n: {
+ toggleLabel: __('Activate Service Desk'),
+ },
components: {
ClipboardButton,
GlButton,
@@ -99,11 +102,12 @@ export default {
id="service-desk-checkbox"
:value="isEnabled"
class="d-inline-block align-middle mr-1"
- label-position="left"
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
@change="onCheckboxToggle"
/>
- <label class="align-middle" for="service-desk-checkbox">
- {{ __('Activate Service Desk') }}
+ <label class="align-middle">
+ {{ $options.i18n.toggleLabel }}
</label>
<div v-if="isEnabled" class="row mt-3">
<div class="col-md-9 mb-0">
@@ -162,6 +166,7 @@ export default {
<gl-form-select
id="service-desk-template-select"
v-model="selectedTemplate"
+ data-qa-selector="service_desk_template_dropdown"
:options="templateOptions"
/>
<label for="service-desk-email-from-name" class="mt-3">
@@ -175,6 +180,7 @@ export default {
<gl-button
variant="success"
class="gl-mt-5"
+ data-qa-selector="save_service_desk_settings_button"
:disabled="isTemplateSaving"
@click="onSaveTemplate"
>
diff --git a/app/assets/javascripts/projects/upload_file_experiment.js b/app/assets/javascripts/projects/upload_file_experiment.js
new file mode 100644
index 00000000000..a7519f2bce8
--- /dev/null
+++ b/app/assets/javascripts/projects/upload_file_experiment.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import createRouter from '~/repository/router';
+import UploadButton from './details/upload_button.vue';
+
+export const initUploadFileTrigger = () => {
+ const uploadFileTriggerEl = document.querySelector('.js-upload-file-experiment-trigger');
+
+ if (!uploadFileTriggerEl) return false;
+
+ const {
+ targetBranch,
+ originalBranch,
+ canPushCode,
+ path,
+ projectPath,
+ } = uploadFileTriggerEl.dataset;
+
+ return new Vue({
+ el: uploadFileTriggerEl,
+ router: createRouter(projectPath, originalBranch),
+ provide: {
+ targetBranch,
+ originalBranch,
+ canPushCode: parseBoolean(canPushCode),
+ path,
+ projectPath,
+ },
+ render(h) {
+ return h(UploadButton);
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/upload_file_experiment_tracking.js b/app/assets/javascripts/projects/upload_file_experiment_tracking.js
new file mode 100644
index 00000000000..c5e93f19b32
--- /dev/null
+++ b/app/assets/javascripts/projects/upload_file_experiment_tracking.js
@@ -0,0 +1,9 @@
+import ExperimentTracking from '~/experimentation/experiment_tracking';
+
+export const trackFileUploadEvent = (eventName) => {
+ const isEmpty = Boolean(document.querySelector('.project-home-panel.empty-project'));
+ const property = isEmpty ? 'empty' : 'nonempty';
+ const label = 'blob-upload-modal';
+ const FileUploadTracking = new ExperimentTracking('empty_repo_upload', { label, property });
+ FileUploadTracking.event(eventName);
+};
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index c9474f0516c..726ddba1014 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -15,17 +15,23 @@ export default class ProtectedBranchCreate {
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.currentProjectUserDefaults = {};
this.buildDropdowns();
+ this.$forcePushToggle = this.$form.find('.js-force-push-toggle');
this.$codeOwnerToggle = this.$form.find('.js-code-owner-toggle');
this.bindEvents();
}
bindEvents() {
+ this.$forcePushToggle.on('click', this.onForcePushToggleClick.bind(this));
if (this.hasLicense) {
this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this));
}
this.$form.on('submit', this.onFormSubmit.bind(this));
}
+ onForcePushToggleClick() {
+ this.$forcePushToggle.toggleClass('is-checked');
+ }
+
onCodeOwnerToggleClick() {
this.$codeOwnerToggle.toggleClass('is-checked');
}
@@ -86,6 +92,7 @@ export default class ProtectedBranchCreate {
authenticity_token: this.$form.find('input[name="authenticity_token"]').val(),
protected_branch: {
name: this.$form.find('input[name="protected_branch[name]"]').val(),
+ allow_force_push: this.$forcePushToggle.hasClass('is-checked'),
code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'),
},
};
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 986abeecafa..bd2694e0cf7 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -14,6 +14,7 @@ export default class ProtectedBranchEdit {
this.$wrap = options.$wrap;
this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
+ this.$forcePushToggle = this.$wrap.find('.js-force-push-toggle');
this.$codeOwnerToggle = this.$wrap.find('.js-code-owner-toggle');
this.$wraps[ACCESS_LEVELS.MERGE] = this.$allowedToMergeDropdown.closest(
@@ -28,11 +29,23 @@ export default class ProtectedBranchEdit {
}
bindEvents() {
+ this.$forcePushToggle.on('click', this.onForcePushToggleClick.bind(this));
if (this.hasLicense) {
this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this));
}
}
+ onForcePushToggleClick() {
+ this.$forcePushToggle.toggleClass('is-checked');
+ this.$forcePushToggle.prop('disabled', true);
+
+ const formData = {
+ allow_force_push: this.$forcePushToggle.hasClass('is-checked'),
+ };
+
+ this.updateProtectedBranch(formData, () => this.$forcePushToggle.prop('disabled', false));
+ }
+
onCodeOwnerToggleClick() {
this.$codeOwnerToggle.toggleClass('is-checked');
this.$codeOwnerToggle.prop('disabled', true);
@@ -41,17 +54,15 @@ export default class ProtectedBranchEdit {
code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'),
};
- this.updateCodeOwnerApproval(formData);
+ this.updateProtectedBranch(formData, () => this.$codeOwnerToggle.prop('disabled', false));
}
- updateCodeOwnerApproval(formData) {
+ updateProtectedBranch(formData, callback) {
axios
.patch(this.$wrap.data('url'), {
protected_branch: formData,
})
- .then(() => {
- this.$codeOwnerToggle.prop('disabled', false);
- })
+ .then(callback)
.catch(() => {
flash(__('Failed to update branch!'));
});
diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue
index 87ce4f1a49c..4fa2a92ff03 100644
--- a/app/assets/javascripts/ref/components/ref_results_section.vue
+++ b/app/assets/javascripts/ref/components/ref_results_section.vue
@@ -11,6 +11,12 @@ export default {
GlIcon,
},
props: {
+ showHeader: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+
sectionTitle: {
type: String,
required: true,
@@ -84,7 +90,7 @@ export default {
<template>
<div>
- <gl-dropdown-section-header>
+ <gl-dropdown-section-header v-if="showHeader">
<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>
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index 8f2805b36f6..82963fe98fd 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -2,32 +2,48 @@
import {
GlDropdown,
GlDropdownDivider,
- GlDropdownSectionHeader,
GlSearchBoxByType,
GlSprintf,
- GlIcon,
GlLoadingIcon,
} from '@gitlab/ui';
-import { debounce } from 'lodash';
+import { debounce, isArray } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { SEARCH_DEBOUNCE_MS, DEFAULT_I18N } from '../constants';
+import {
+ ALL_REF_TYPES,
+ SEARCH_DEBOUNCE_MS,
+ DEFAULT_I18N,
+ REF_TYPE_BRANCHES,
+ REF_TYPE_TAGS,
+ REF_TYPE_COMMITS,
+} from '../constants';
import createStore from '../stores';
import RefResultsSection from './ref_results_section.vue';
export default {
name: 'RefSelector',
- store: createStore(),
components: {
GlDropdown,
GlDropdownDivider,
- GlDropdownSectionHeader,
GlSearchBoxByType,
GlSprintf,
- GlIcon,
GlLoadingIcon,
RefResultsSection,
},
props: {
+ enabledRefTypes: {
+ type: Array,
+ required: false,
+ default: () => ALL_REF_TYPES,
+ validator: (val) =>
+ // It has to be an arrray
+ isArray(val) &&
+ // with at least one item
+ val.length > 0 &&
+ // and only "REF_TYPE_BRANCHES", "REF_TYPE_TAGS", and "REF_TYPE_COMMITS" are allowed
+ val.every((item) => ALL_REF_TYPES.includes(item)) &&
+ // and no duplicates are allowed
+ val.length === new Set(val).size,
+ },
value: {
type: String,
required: false,
@@ -42,6 +58,13 @@ export default {
required: false,
default: () => ({}),
},
+
+ /** The validation state of this component. */
+ state: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -62,17 +85,45 @@ export default {
};
},
showBranchesSection() {
- return Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error);
+ return (
+ this.enabledRefTypes.includes(REF_TYPE_BRANCHES) &&
+ Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error)
+ );
},
showTagsSection() {
- return Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error);
+ return (
+ this.enabledRefTypes.includes(REF_TYPE_TAGS) &&
+ Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error)
+ );
},
showCommitsSection() {
- return Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error);
+ return (
+ this.enabledRefTypes.includes(REF_TYPE_COMMITS) &&
+ Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error)
+ );
},
showNoResults() {
return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection;
},
+ showSectionHeaders() {
+ return this.enabledRefTypes.length > 1;
+ },
+ toggleButtonClass() {
+ return {
+ 'gl-inset-border-1-red-500!': !this.state,
+ 'gl-font-monospace': Boolean(this.selectedRef),
+ };
+ },
+ footerSlotProps() {
+ return {
+ isLoading: this.isLoading,
+ matches: this.matches,
+ query: this.lastQuery,
+ };
+ },
+ buttonText() {
+ return this.selectedRef || this.i18n.noRefSelected;
+ },
},
watch: {
// Keep the Vuex store synchronized if the parent
@@ -86,6 +137,14 @@ export default {
},
},
},
+ beforeCreate() {
+ // Setting the store here instead of using
+ // the built in `store` component option because
+ // we need each new `RefSelector` instance to
+ // create a new Vuex store instance.
+ // See https://github.com/vuejs/vuex/issues/414#issue-184491718.
+ this.$store = createStore();
+ },
created() {
// This method is defined here instead of in `methods`
// because we need to access the .cancel() method
@@ -93,20 +152,29 @@ export default {
// made inaccessible by Vue. More info:
// https://stackoverflow.com/a/52988020/1063392
this.debouncedSearch = debounce(function search() {
- this.search(this.query);
+ this.search();
}, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId);
- this.search(this.query);
+
+ this.$watch(
+ 'enabledRefTypes',
+ () => {
+ this.setEnabledRefTypes(this.enabledRefTypes);
+ this.search();
+ },
+ { immediate: true },
+ );
},
methods: {
- ...mapActions(['setProjectId', 'setSelectedRef', 'search']),
+ ...mapActions(['setEnabledRefTypes', 'setProjectId', 'setSelectedRef']),
+ ...mapActions({ storeSearch: 'search' }),
focusSearchBox() {
this.$refs.searchBox.$el.querySelector('input').focus();
},
onSearchBoxEnter() {
this.debouncedSearch.cancel();
- this.search(this.query);
+ this.search();
},
onSearchBoxInput() {
this.debouncedSearch();
@@ -115,97 +183,96 @@ export default {
this.setSelectedRef(ref);
this.$emit('input', this.selectedRef);
},
+ search() {
+ this.storeSearch(this.query);
+ },
},
};
</script>
<template>
- <gl-dropdown v-bind="$attrs" class="ref-selector" @shown="focusSearchBox">
- <template slot="button-content">
- <span class="gl-flex-grow-1 gl-ml-2 gl-text-gray-400" 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-dropdown-section-header>
- <span class="gl-text-center gl-display-block">{{ i18n.dropdownHeader }}</span>
- </gl-dropdown-section-header>
-
- <gl-dropdown-divider />
-
+ <gl-dropdown
+ :header-text="i18n.dropdownHeader"
+ :toggle-class="toggleButtonClass"
+ :text="buttonText"
+ class="ref-selector"
+ v-bind="$attrs"
+ v-on="$listeners"
+ @shown="focusSearchBox"
+ >
+ <template #header>
<gl-search-box-by-type
ref="searchBox"
v-model.trim="query"
:placeholder="i18n.searchPlaceholder"
+ autocomplete="off"
@input="onSearchBoxInput"
@keydown.enter.prevent="onSearchBoxEnter"
/>
+ </template>
- <div class="gl-flex-grow-1 gl-overflow-y-auto">
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" />
+ <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>
+ <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>
+ <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)"
- />
+ <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"
+ :show-header="showSectionHeaders"
+ data-testid="branches-section"
+ @selected="selectRef($event)"
+ />
- <gl-dropdown-divider v-if="showTagsSection || showCommitsSection" />
- </template>
+ <gl-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)"
- />
+ <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"
+ :show-header="showSectionHeaders"
+ data-testid="tags-section"
+ @selected="selectRef($event)"
+ />
- <gl-dropdown-divider v-if="showCommitsSection" />
- </template>
+ <gl-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>
+ <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"
+ :show-header="showSectionHeaders"
+ data-testid="commits-section"
+ @selected="selectRef($event)"
+ />
+ </template>
+ </template>
+
+ <template #footer>
+ <slot name="footer" v-bind="footerSlotProps"></slot>
+ </template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js
index ca82b951377..44d0f50b832 100644
--- a/app/assets/javascripts/ref/constants.js
+++ b/app/assets/javascripts/ref/constants.js
@@ -1,5 +1,10 @@
import { __ } from '~/locale';
+export const REF_TYPE_BRANCHES = 'REF_TYPE_BRANCHES';
+export const REF_TYPE_TAGS = 'REF_TYPE_TAGS';
+export const REF_TYPE_COMMITS = 'REF_TYPE_COMMITS';
+export const ALL_REF_TYPES = Object.freeze([REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS]);
+
export const X_TOTAL_HEADER = 'x-total';
export const SEARCH_DEBOUNCE_MS = 250;
diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js
index d9bdd64ace5..3832cc0c21d 100644
--- a/app/assets/javascripts/ref/stores/actions.js
+++ b/app/assets/javascripts/ref/stores/actions.js
@@ -1,17 +1,26 @@
import Api from '~/api';
+import { REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS } from '../constants';
import * as types from './mutation_types';
+export const setEnabledRefTypes = ({ commit }, refTypes) =>
+ commit(types.SET_ENABLED_REF_TYPES, refTypes);
+
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) => {
+export const search = ({ state, dispatch, commit }, query) => {
commit(types.SET_QUERY, query);
- dispatch('searchBranches');
- dispatch('searchTags');
- dispatch('searchCommits');
+ const dispatchIfRefTypeEnabled = (refType, action) => {
+ if (state.enabledRefTypes.includes(refType)) {
+ dispatch(action);
+ }
+ };
+ dispatchIfRefTypeEnabled(REF_TYPE_BRANCHES, 'searchBranches');
+ dispatchIfRefTypeEnabled(REF_TYPE_TAGS, 'searchTags');
+ dispatchIfRefTypeEnabled(REF_TYPE_COMMITS, 'searchCommits');
};
export const searchBranches = ({ commit, state }) => {
diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js
index 9f6195f5f3f..c26f4fa00c7 100644
--- a/app/assets/javascripts/ref/stores/mutation_types.js
+++ b/app/assets/javascripts/ref/stores/mutation_types.js
@@ -1,3 +1,5 @@
+export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES';
+
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_SELECTED_REF = 'SET_SELECTED_REF';
export const SET_QUERY = 'SET_QUERY';
diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js
index 4dc73dabfe2..f91cbae8462 100644
--- a/app/assets/javascripts/ref/stores/mutations.js
+++ b/app/assets/javascripts/ref/stores/mutations.js
@@ -4,6 +4,9 @@ import { X_TOTAL_HEADER } from '../constants';
import * as types from './mutation_types';
export default {
+ [types.SET_ENABLED_REF_TYPES](state, refTypes) {
+ state.enabledRefTypes = refTypes;
+ },
[types.SET_PROJECT_ID](state, projectId) {
state.projectId = projectId;
},
diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js
index 65b9d6449d7..3affa8f8d03 100644
--- a/app/assets/javascripts/ref/stores/state.js
+++ b/app/assets/javascripts/ref/stores/state.js
@@ -1,23 +1,18 @@
+const createRefTypeState = () => ({
+ list: [],
+ totalCount: 0,
+ error: null,
+});
+
export default () => ({
+ enabledRefTypes: [],
projectId: null,
query: '',
matches: {
- branches: {
- list: [],
- totalCount: 0,
- error: null,
- },
- tags: {
- list: [],
- totalCount: 0,
- error: null,
- },
- commits: {
- list: [],
- totalCount: 0,
- error: null,
- },
+ branches: createRefTypeState(),
+ tags: createRefTypeState(),
+ commits: createRefTypeState(),
},
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
index ee856a3e546..e4a1a1a8266 100644
--- a/app/assets/javascripts/registry/explorer/components/delete_button.vue
+++ b/app/assets/javascripts/registry/explorer/components/delete_button.vue
@@ -48,6 +48,7 @@ export default {
:title="title"
:aria-label="title"
variant="danger"
+ category="secondary"
icon="remove"
@click="$emit('delete')"
/>
diff --git a/app/assets/javascripts/registry/explorer/components/delete_image.vue b/app/assets/javascripts/registry/explorer/components/delete_image.vue
index 22fe9fc1da6..a313854f5e4 100644
--- a/app/assets/javascripts/registry/explorer/components/delete_image.vue
+++ b/app/assets/javascripts/registry/explorer/components/delete_image.vue
@@ -29,7 +29,6 @@ export default {
});
const data = produce(sourceData, (draftState) => {
- // eslint-disable-next-line no-param-reassign
draftState.containerRepository.status =
destroyContainerRepository.containerRepository.status;
});
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
index a4b4c08bc34..f46068acd68 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
@@ -1,11 +1,10 @@
<script>
-import { GlSprintf, GlButton } from '@gitlab/ui';
+import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, n__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import {
- DETAILS_PAGE_TITLE,
UPDATED_AT,
CLEANUP_UNSCHEDULED_TEXT,
CLEANUP_SCHEDULED_TEXT,
@@ -20,11 +19,16 @@ import {
UNSCHEDULED_STATUS,
SCHEDULED_STATUS,
ONGOING_STATUS,
+ ROOT_IMAGE_TEXT,
+ ROOT_IMAGE_TOOLTIP,
} from '../../constants/index';
export default {
name: 'DetailsHeader',
- components: { GlSprintf, GlButton, TitleArea, MetadataItem },
+ components: { GlButton, GlIcon, TitleArea, MetadataItem },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [timeagoMixin],
props: {
image: {
@@ -73,9 +77,12 @@ export default {
deleteButtonDisabled() {
return this.disabled || !this.image.canDelete;
},
- },
- i18n: {
- DETAILS_PAGE_TITLE,
+ rootImageTooltip() {
+ return !this.image.name ? ROOT_IMAGE_TOOLTIP : '';
+ },
+ imageName() {
+ return this.image.name || ROOT_IMAGE_TEXT;
+ },
},
};
</script>
@@ -84,12 +91,15 @@ export default {
<title-area :metadata-loading="metadataLoading">
<template #title>
<span data-testid="title">
- <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE">
- <template #imageName>
- {{ image.name }}
- </template>
- </gl-sprintf>
+ {{ imageName }}
</span>
+ <gl-icon
+ v-if="rootImageTooltip"
+ v-gl-tooltip="rootImageTooltip"
+ class="gl-text-blue-600"
+ name="information-o"
+ :aria-label="rootImageTooltip"
+ />
</template>
<template #metadata-tags-count>
<metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" />
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
index c66f92bdd67..74027a376a7 100644
--- 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
@@ -166,6 +166,7 @@ export default {
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP"
:tooltip-disabled="tag.canDelete"
+ data-qa-selector="tag_delete_button"
data-testid="single-delete-button"
@delete="$emit('delete')"
/>
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 10ad99d5956..5bd13322ebb 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,6 @@ export default {
v-for="(listItem, index) in images"
:key="index"
:item="listItem"
- :first="index === 0"
:metadata-loading="metadataLoading"
@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 9ae5b0f9eb1..0373a84b271 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
@@ -13,6 +13,7 @@ import {
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
+ ROOT_IMAGE_TEXT,
} from '../../constants/index';
import DeleteButton from '../delete_button.vue';
@@ -74,6 +75,9 @@ export default {
}
return null;
},
+ imageName() {
+ return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`;
+ },
},
};
</script>
@@ -92,9 +96,10 @@ export default {
<router-link
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
+ data-qa-selector="registry_image_content"
:to="{ name: 'details', params: { id } }"
>
- {{ item.path }}
+ {{ imageName }}
</router-link>
<clipboard-button
v-if="item.location"
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
index 8d7505dfbae..6d2ff9ea7b6 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
@@ -9,7 +9,6 @@ import {
LIST_INTRO_TEXT,
EXPIRATION_POLICY_WILL_RUN_IN,
EXPIRATION_POLICY_DISABLED_TEXT,
- EXPIRATION_POLICY_DISABLED_MESSAGE,
} from '../../constants/index';
export default {
@@ -34,11 +33,6 @@ export default {
default: '',
required: false,
},
- expirationPolicyHelpPagePath: {
- type: String,
- default: '',
- required: false,
- },
hideExpirationPolicyData: {
type: Boolean,
required: false,
@@ -79,19 +73,8 @@ export default {
? sprintf(EXPIRATION_POLICY_WILL_RUN_IN, { time: this.timeTillRun })
: EXPIRATION_POLICY_DISABLED_TEXT;
},
- showExpirationPolicyTip() {
- return (
- !this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData
- );
- },
infoMessages() {
- const base = [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }];
- return this.showExpirationPolicyTip
- ? [
- ...base,
- { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: this.expirationPolicyHelpPagePath },
- ]
- : base;
+ return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }];
},
},
};
diff --git a/app/assets/javascripts/registry/explorer/constants/common.js b/app/assets/javascripts/registry/explorer/constants/common.js
new file mode 100644
index 00000000000..dc71ef8450b
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/constants/common.js
@@ -0,0 +1,3 @@
+import { s__ } from '~/locale';
+
+export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image');
diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js
index 3f04538a18b..7220f9646db 100644
--- a/app/assets/javascripts/registry/explorer/constants/details.js
+++ b/app/assets/javascripts/registry/explorer/constants/details.js
@@ -2,7 +2,6 @@ import { helpPagePath } from '~/helpers/help_page_helper';
import { s__, __ } from '~/locale';
// Translations strings
-export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags');
export const DELETE_TAG_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while marking the tag for deletion.',
);
@@ -53,7 +52,8 @@ export const MISSING_OR_DELETED_IMAGE_TITLE = s__(
export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__(
'ContainerRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.',
);
-export const MISSING_OR_DELETE_IMAGE_BREADCRUMB = s__(
+
+export const MISSING_OR_DELETED_IMAGE_BREADCRUMB = s__(
'ContainerRegistry|Image repository not found',
);
@@ -112,6 +112,10 @@ export const FAILED_DELETION_STATUS_MESSAGE = s__(
'ContainerRegistry|This image repository has failed to be deleted',
);
+export const ROOT_IMAGE_TOOLTIP = s__(
+ 'ContainerRegistry|Image repository with no name located at the project URL.',
+);
+
// Parameters
export const DEFAULT_PAGE = 1;
diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
index 48a6a015461..40f9b09a982 100644
--- a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
+++ b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
@@ -6,9 +6,6 @@ export const EXPIRATION_POLICY_WILL_RUN_IN = s__(
export const EXPIRATION_POLICY_DISABLED_TEXT = s__(
'ContainerRegistry|Expiration policy is disabled',
);
-export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__(
- 'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}',
-);
export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted');
export const DELETE_ALERT_LINK_TEXT = s__(
'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}',
diff --git a/app/assets/javascripts/registry/explorer/constants/index.js b/app/assets/javascripts/registry/explorer/constants/index.js
index 10816e12ead..6886356d8e2 100644
--- a/app/assets/javascripts/registry/explorer/constants/index.js
+++ b/app/assets/javascripts/registry/explorer/constants/index.js
@@ -1,3 +1,4 @@
+export * from './common';
export * from './expiration_policies';
export * from './quick_start';
export * from './list';
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index 0403467468a..2f515356fa7 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -24,7 +24,8 @@ import {
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
- MISSING_OR_DELETE_IMAGE_BREADCRUMB,
+ MISSING_OR_DELETED_IMAGE_BREADCRUMB,
+ ROOT_IMAGE_TEXT,
} from '../constants/index';
import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
@@ -116,7 +117,9 @@ export default {
},
methods: {
updateBreadcrumb() {
- const name = this.image?.name || MISSING_OR_DELETE_IMAGE_BREADCRUMB;
+ const name = this.image?.id
+ ? this.image?.name || ROOT_IMAGE_TEXT
+ : MISSING_OR_DELETED_IMAGE_BREADCRUMB;
this.breadCrumbState.updateName(name);
},
deleteTags(toBeDeleted) {
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 8cad9b4ecfc..625d491db6a 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -288,7 +288,6 @@ export default {
:images-count="containerRepositoriesCount"
:expiration-policy="config.expirationPolicy"
:help-page-path="config.helpPagePath"
- :expiration-policy-help-page-path="config.expirationPolicyHelpPagePath"
:hide-expiration-policy-data="config.isGroupPage"
>
<template #commands>
diff --git a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue
index 0ffd8216ab1..6aa78c69ba9 100644
--- a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue
+++ b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue
@@ -1,8 +1,12 @@
<script>
import { GlFormGroup, GlToggle, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
import { ENABLED_TOGGLE_DESCRIPTION, DISABLED_TOGGLE_DESCRIPTION } from '../constants';
export default {
+ i18n: {
+ toggleLabel: s__('ContainerRegistry|Enable expiration policy'),
+ },
components: {
GlFormGroup,
GlToggle,
@@ -39,7 +43,13 @@ export default {
<template>
<gl-form-group id="expiration-policy-toggle-group" label-for="expiration-policy-toggle">
<div class="gl-display-flex">
- <gl-toggle id="expiration-policy-toggle" v-model="enabled" :disabled="disabled" />
+ <gl-toggle
+ id="expiration-policy-toggle"
+ v-model="enabled"
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
+ :disabled="disabled"
+ />
<span class="gl-ml-5 gl-line-height-24" data-testid="description">
<gl-sprintf :message="toggleText">
<template #strong="{ content }">
diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
index 6becaa38c7e..c4b2af13862 100644
--- a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
+++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
@@ -9,7 +9,6 @@ export const updateContainerExpirationPolicy = (projectPath) => (client, { data:
const sourceData = client.readQuery(queryAndParams);
const data = produce(sourceData, (draftState) => {
- // eslint-disable-next-line no-param-reassign
draftState.project.containerExpirationPolicy = {
...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy,
};
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 2dc56c3110b..46b97370d66 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -223,6 +223,7 @@ export default {
type="text"
class="js-add-issuable-form-input add-issuable-form-input"
data-qa-selector="add_issue_field"
+ autocomplete="off"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index b16bb76c305..a8c7b7c857a 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -86,12 +86,11 @@ export default {
];
},
},
- mounted() {
- // eslint-disable-next-line promise/catch-or-return
- this.initializeRelease().then(() => {
- // Focus the first non-disabled input element
- this.$el.querySelector('input:enabled').focus();
- });
+ async mounted() {
+ await this.initializeRelease();
+
+ // Focus the first non-disabled input or button element
+ this.$el.querySelector('input:enabled, button:enabled').focus();
},
methods: {
...mapActions('detail', [
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index 9e095c8a9c2..cfcb9f6978d 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -141,6 +141,7 @@ export default {
:value="link.url"
type="text"
class="form-control"
+ name="asset-url"
:state="isUrlValid(link)"
@change="updateUrl(link, $event)"
@keydown.ctrl.enter="updateUrl(link, $event.target.value)"
@@ -180,6 +181,7 @@ export default {
:value="link.name"
type="text"
class="form-control"
+ name="asset-link-name"
:state="isNameValid(link)"
@change="updateName(link, $event)"
@keydown.ctrl.enter="updateName(link, $event.target.value)"
@@ -202,6 +204,7 @@ export default {
ref="typeSelect"
:value="link.linkType || $options.defaultTypeOptionValue"
class="form-control pr-4"
+ name="asset-type"
:options="$options.typeOptions"
@change="updateAssetLinkType({ linkIdToUpdate: link.id, newType: $event })"
/>
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index 660fd7ac950..21360a5c6cb 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -1,20 +1,29 @@
<script>
-import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { GlFormGroup, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { mapState, mapActions, mapGetters } from 'vuex';
import { __ } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue';
+import { REF_TYPE_TAGS } from '~/ref/constants';
import FormFieldContainer from './form_field_container.vue';
export default {
name: 'TagFieldNew',
- components: { GlFormGroup, GlFormInput, RefSelector, FormFieldContainer },
+ components: {
+ GlFormGroup,
+ RefSelector,
+ FormFieldContainer,
+ GlDropdownItem,
+ GlSprintf,
+ },
data() {
return {
// Keeps track of whether or not the user has interacted with
// the input field. This is used to avoid showing validation
// errors immediately when the page loads.
isInputDirty: false,
+
+ showCreateFrom: true,
};
},
computed: {
@@ -26,6 +35,12 @@ export default {
},
set(tagName) {
this.updateReleaseTagName(tagName);
+
+ // This setter is used by the `v-model` on the `RefSelector`.
+ // When this is called, the selection originated from the
+ // dropdown list of existing tag names, so we know the tag
+ // already exists and don't need to show the "create from" input
+ this.showCreateFrom = false;
},
},
createFromModel: {
@@ -51,12 +66,28 @@ export default {
markInputAsDirty() {
this.isInputDirty = true;
},
+ createTagClicked(newTagName) {
+ this.updateReleaseTagName(newTagName);
+
+ // This method is called when the user selects the "create tag"
+ // option, so the tag does not already exist. Because of this,
+ // we need to show the "create from" input.
+ this.showCreateFrom = true;
+ },
},
translations: {
- noRefSelected: __('No source selected'),
- searchPlaceholder: __('Search branches, tags, and commits'),
- dropdownHeader: __('Select source'),
+ tagName: {
+ noRefSelected: __('No tag selected'),
+ dropdownHeader: __('Tag name'),
+ searchPlaceholder: __('Search or create tag'),
+ },
+ createFrom: {
+ noRefSelected: __('No source selected'),
+ searchPlaceholder: __('Search branches, tags, and commits'),
+ dropdownHeader: __('Select source'),
+ },
},
+ tagNameEnabledRefTypes: [REF_TYPE_TAGS],
};
</script>
<template>
@@ -69,17 +100,34 @@ export default {
:invalid-feedback="__('Tag name is required')"
>
<form-field-container>
- <gl-form-input
+ <ref-selector
:id="tagNameInputId"
v-model="tagName"
+ :project-id="projectId"
+ :translations="$options.translations.tagName"
+ :enabled-ref-types="$options.tagNameEnabledRefTypes"
:state="!showTagNameValidationError"
- type="text"
- class="form-control"
- @blur.once="markInputAsDirty"
- />
+ @hide.once="markInputAsDirty"
+ >
+ <template #footer="{ isLoading, matches, query }">
+ <gl-dropdown-item
+ v-if="!isLoading && matches && matches.tags.totalCount === 0"
+ is-check-item
+ :is-checked="tagName === query"
+ @click="createTagClicked(query)"
+ >
+ <gl-sprintf :message="__('Create tag %{tagName}')">
+ <template #tagName>
+ <b>{{ query }}</b>
+ </template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </template>
+ </ref-selector>
</form-field-container>
</gl-form-group>
<gl-form-group
+ v-if="showCreateFrom"
:label="__('Create from')"
:label-for="createFromSelectorId"
data-testid="create-from-field"
@@ -89,7 +137,7 @@ export default {
:id="createFromSelectorId"
v-model="createFromModel"
:project-id="projectId"
- :translations="$options.translations"
+ :translations="$options.translations.createFrom"
/>
</form-field-container>
<template #description>
diff --git a/app/assets/javascripts/reports/components/grouped_issues_list.vue b/app/assets/javascripts/reports/components/grouped_issues_list.vue
index 4d3c5f48e94..585127f901e 100644
--- a/app/assets/javascripts/reports/components/grouped_issues_list.vue
+++ b/app/assets/javascripts/reports/components/grouped_issues_list.vue
@@ -14,6 +14,12 @@ export default {
required: false,
default: '',
},
+ nestedLevel: {
+ type: Number,
+ required: false,
+ default: 0,
+ validator: (value) => [0, 1, 2].includes(value),
+ },
resolvedIssues: {
type: Array,
required: false,
@@ -58,6 +64,12 @@ export default {
return groupsCount + issuesCount;
},
+ listClasses() {
+ return {
+ 'gl-pl-7': this.nestedLevel === 1,
+ 'gl-pl-9': this.nestedLevel === 2,
+ };
+ },
},
};
</script>
@@ -67,6 +79,7 @@ export default {
:length="listLength"
:remain="$options.maxShownReportItems"
:size="$options.typicalReportItemHeight"
+ :class="listClasses"
class="report-block-container"
wtag="ul"
wclass="report-block-list"
diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js
index a0349506b69..56f46a3938e 100644
--- a/app/assets/javascripts/reports/components/issue_body.js
+++ b/app/assets/javascripts/reports/components/issue_body.js
@@ -1,6 +1,6 @@
import AccessibilityIssueBody from '../accessibility_report/components/accessibility_issue_body.vue';
import CodequalityIssueBody from '../codequality_report/components/codequality_issue_body.vue';
-import TestIssueBody from './test_issue_body.vue';
+import TestIssueBody from '../grouped_test_report/components/test_issue_body.vue';
export const components = {
AccessibilityIssueBody,
diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/reports/components/issues_list.vue
index 16d5b14d3e9..ea3f0d78d8c 100644
--- a/app/assets/javascripts/reports/components/issues_list.vue
+++ b/app/assets/javascripts/reports/components/issues_list.vue
@@ -67,6 +67,12 @@ export default {
required: false,
default: null,
},
+ nestedLevel: {
+ type: Number,
+ required: false,
+ default: 0,
+ validator: (value) => [0, 1, 2].includes(value),
+ },
},
computed: {
issuesWithState() {
@@ -80,6 +86,12 @@ export default {
wclass() {
return `report-block-list ${this.issuesUlElementClass}`;
},
+ listClasses() {
+ return {
+ 'gl-pl-7': this.nestedLevel === 1,
+ 'gl-pl-8': this.nestedLevel === 2,
+ };
+ },
},
};
</script>
@@ -89,6 +101,7 @@ export default {
:remain="$options.maxShownReportItems"
:size="$options.typicalReportItemHeight"
class="report-block-container"
+ :class="listClasses"
wtag="ul"
:wclass="wclass"
>
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 9d0631fbc01..ff58cd20ca1 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -184,13 +184,14 @@ export default {
<slot name="sub-heading"></slot>
</div>
- <slot name="action-buttons"></slot>
+ <slot name="action-buttons" :is-collapsible="isCollapsible"></slot>
<button
v-if="isCollapsible"
type="button"
data-testid="report-section-expand-button"
- class="js-collapse-btn btn float-right btn-sm align-self-center qa-expand-report-button"
+ class="js-collapse-btn btn float-right btn-sm align-self-center"
+ data-qa-selector="expand_report_button"
@click="toggleCollapsed"
>
{{ collapseText }}
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index 3232c0edf96..8eb43bcf1ba 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -2,6 +2,7 @@
import { GlLoadingIcon } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import Popover from '~/vue_shared/components/help_popover.vue';
+import { ICON_WARNING } from '../constants';
/**
* Renders the summary row for each report
@@ -19,6 +20,11 @@ export default {
GlLoadingIcon,
},
props: {
+ nestedSummary: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
summary: {
type: String,
required: false,
@@ -41,24 +47,36 @@ export default {
icon: `status_${this.statusIcon}`,
};
},
+ rowClasses() {
+ if (!this.nestedSummary) {
+ return ['gl-px-5'];
+ }
+ return ['gl-pl-7', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }];
+ },
+ statusIconSize() {
+ if (!this.nestedSummary) {
+ return 24;
+ }
+ return 16;
+ },
},
};
</script>
<template>
- <div class="report-block-list-issue report-block-list-issue-parent align-items-center">
- <div class="report-block-list-icon gl-mr-3">
+ <div
+ class="gl-border-t-solid gl-border-t-gray-100 gl-border-t-1 gl-py-3 gl-display-flex gl-align-items-center"
+ :class="rowClasses"
+ >
+ <div class="gl-mr-3">
<gl-loading-icon
v-if="statusIcon === 'loading'"
css-class="report-block-list-loading-icon"
size="md"
/>
- <ci-icon v-else :status="iconStatus" :size="24" />
+ <ci-icon v-else :status="iconStatus" :size="statusIconSize" data-testid="summary-row-icon" />
</div>
<div class="report-block-list-issue-description">
- <div
- class="report-block-list-issue-description-text"
- data-testid="test-summary-row-description"
- >
+ <div class="report-block-list-issue-description-text" data-testid="summary-row-description">
<slot name="summary">{{ summary }}</slot
><span v-if="popoverOptions" class="text-nowrap"
>&nbsp;<popover v-if="popoverOptions" :options="popoverOptions" class="align-top" />
diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue
deleted file mode 100644
index 7508e1d1f0d..00000000000
--- a/app/assets/javascripts/reports/components/test_issue_body.vue
+++ /dev/null
@@ -1,64 +0,0 @@
-<script>
-import { GlBadge, GlSprintf } from '@gitlab/ui';
-import { mapActions } from 'vuex';
-
-export default {
- name: 'TestIssueBody',
- components: {
- GlBadge,
- GlSprintf,
- },
- props: {
- issue: {
- type: Object,
- required: true,
- },
- // failed || success
- status: {
- type: String,
- required: true,
- },
- isNew: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- showRecentFailures() {
- return this.issue.recent_failures?.count && this.issue.recent_failures?.base_branch;
- },
- },
- methods: {
- ...mapActions(['openModal']),
- },
-};
-</script>
-<template>
- <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 })"
- >
- <gl-badge v-if="isNew" variant="danger" class="gl-mr-2">{{ s__('New') }}</gl-badge>
- <gl-badge v-if="showRecentFailures" variant="warning" class="gl-mr-2">
- <gl-sprintf
- :message="
- n__(
- 'Reports|Failed %{count} time in %{base_branch} in the last 14 days',
- 'Reports|Failed %{count} times in %{base_branch} in the last 14 days',
- issue.recent_failures.count,
- )
- "
- >
- <template #count>{{ issue.recent_failures.count }}</template>
- <template #base_branch>{{ issue.recent_failures.base_branch }}</template>
- </gl-sprintf>
- </gl-badge>
- {{ issue.name }}
- </button>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
index 6243bddf941..b0310fd003e 100644
--- a/app/assets/javascripts/reports/components/modal.vue
+++ b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
@@ -2,7 +2,7 @@
import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
import CodeBlock from '~/vue_shared/components/code_block.vue';
-import { fieldTypes } from '../constants';
+import { fieldTypes } from '../../constants';
export default {
components: {
diff --git a/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue b/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
new file mode 100644
index 00000000000..522245a442d
--- /dev/null
+++ b/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlBadge, GlButton } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import { sprintf, n__ } from '~/locale';
+import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
+import { STATUS_NEUTRAL } from '../../constants';
+
+export default {
+ name: 'TestIssueBody',
+ components: {
+ GlBadge,
+ GlButton,
+ IssueStatusIcon,
+ },
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ recentFailureMessage() {
+ return sprintf(
+ n__(
+ 'Reports|Failed %{count} time in %{base_branch} in the last 14 days',
+ 'Reports|Failed %{count} times in %{base_branch} in the last 14 days',
+ this.issue.recent_failures.count,
+ ),
+ this.issue.recent_failures,
+ );
+ },
+ showRecentFailures() {
+ return this.issue.recent_failures?.count && this.issue.recent_failures?.base_branch;
+ },
+ status() {
+ return this.issue.status || STATUS_NEUTRAL;
+ },
+ },
+ methods: {
+ ...mapActions(['openModal']),
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-mt-2 gl-mb-2">
+ <issue-status-icon :status="status" :status-icon-size="24" class="gl-mr-3" />
+ <gl-badge
+ v-if="showRecentFailures"
+ variant="warning"
+ class="gl-mr-2"
+ data-testid="test-issue-body-recent-failures"
+ >
+ {{ recentFailureMessage }}
+ </gl-badge>
+ <gl-button
+ button-text-classes="gl-white-space-normal! gl-word-break-all gl-text-left"
+ variant="link"
+ data-testid="test-issue-body-description"
+ @click="openModal({ issue })"
+ >
+ {{ issue.name }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
index 033b8798473..b863e55ae94 100644
--- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
@@ -1,21 +1,21 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlIcon } from '@gitlab/ui';
import { once } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import { sprintf, s__ } from '~/locale';
import Tracking from '~/tracking';
-import createStore from '../store';
+import GroupedIssuesList from '../components/grouped_issues_list.vue';
+import { componentNames } from '../components/issue_body';
+import ReportSection from '../components/report_section.vue';
+import SummaryRow from '../components/summary_row.vue';
+import Modal from './components/modal.vue';
+import createStore from './store';
import {
summaryTextBuilder,
reportTextBuilder,
statusIcon,
recentFailuresTextBuilder,
-} from '../store/utils';
-import { componentNames } from './issue_body';
-import IssuesList from './issues_list.vue';
-import Modal from './modal.vue';
-import ReportSection from './report_section.vue';
-import SummaryRow from './summary_row.vue';
+} from './store/utils';
export default {
name: 'GroupedTestReportsApp',
@@ -23,9 +23,10 @@ export default {
components: {
ReportSection,
SummaryRow,
- IssuesList,
+ GroupedIssuesList,
Modal,
GlButton,
+ GlIcon,
},
mixins: [Tracking.mixin()],
props: {
@@ -86,7 +87,7 @@ export default {
}
if (!report.name) {
- return s__('Reports|An error occured while loading report');
+ return s__('Reports|An error occurred while loading report');
}
return reportTextBuilder(name, summary);
@@ -111,10 +112,12 @@ export default {
);
},
unresolvedIssues(report) {
- return report.existing_failures.concat(report.existing_errors);
- },
- newIssues(report) {
- return report.new_failures.concat(report.new_errors);
+ return [
+ ...report.new_failures,
+ ...report.new_errors,
+ ...report.existing_failures,
+ ...report.existing_errors,
+ ];
},
resolvedIssues(report) {
return report.resolved_failures.concat(report.resolved_errors);
@@ -151,24 +154,39 @@ export default {
<template #body>
<div class="mr-widget-grouped-section report-block">
<template v-for="(report, i) in reports">
- <summary-row :key="`summary-row-${i}`" :status-icon="getReportIcon(report)">
+ <summary-row
+ :key="`summary-row-${i}`"
+ :status-icon="getReportIcon(report)"
+ nested-summary
+ >
<template #summary>
<div class="gl-display-inline-flex gl-flex-direction-column">
<div>{{ reportText(report) }}</div>
+ <div v-if="report.suite_errors">
+ <div v-if="report.suite_errors.head">
+ <gl-icon name="warning" class="gl-mx-2 gl-text-orange-500" />
+ {{ s__('Reports|Head report parsing error:') }}
+ {{ report.suite_errors.head }}
+ </div>
+ <div v-if="report.suite_errors.base">
+ <gl-icon name="warning" class="gl-mx-2 gl-text-orange-500" />
+ {{ s__('Reports|Base report parsing error:') }}
+ {{ report.suite_errors.base }}
+ </div>
+ </div>
<div v-if="hasRecentFailures(report.summary)">
{{ recentFailuresText(report.summary) }}
</div>
</div>
</template>
</summary-row>
- <issues-list
+ <grouped-issues-list
v-if="shouldRenderIssuesList(report)"
:key="`issues-list-${i}`"
:unresolved-issues="unresolvedIssues(report)"
- :new-issues="newIssues(report)"
:resolved-issues="resolvedIssues(report)"
:component="$options.componentNames.TestIssueBody"
- class="report-block-group-list"
+ :nested-level="2"
/>
</template>
<modal
diff --git a/app/assets/javascripts/reports/store/actions.js b/app/assets/javascripts/reports/grouped_test_report/store/actions.js
index c9b8ee64930..ebc8c735b03 100644
--- a/app/assets/javascripts/reports/store/actions.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/actions.js
@@ -1,7 +1,7 @@
import Visibility from 'visibilityjs';
-import axios from '../../lib/utils/axios_utils';
-import httpStatusCodes from '../../lib/utils/http_status';
-import Poll from '../../lib/utils/poll';
+import axios from '../../../lib/utils/axios_utils';
+import httpStatusCodes from '../../../lib/utils/http_status';
+import Poll from '../../../lib/utils/poll';
import * as types from './mutation_types';
export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
diff --git a/app/assets/javascripts/reports/store/getters.js b/app/assets/javascripts/reports/grouped_test_report/store/getters.js
index cc8c4cc446c..e7d1675f74a 100644
--- a/app/assets/javascripts/reports/store/getters.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/getters.js
@@ -1,4 +1,4 @@
-import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../constants';
+import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../../constants';
export const summaryStatus = (state) => {
if (state.isLoading) {
diff --git a/app/assets/javascripts/reports/store/index.js b/app/assets/javascripts/reports/grouped_test_report/store/index.js
index a2edfa94a48..a2edfa94a48 100644
--- a/app/assets/javascripts/reports/store/index.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/index.js
diff --git a/app/assets/javascripts/reports/store/mutation_types.js b/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
index 337085f9bf0..337085f9bf0 100644
--- a/app/assets/javascripts/reports/store/mutation_types.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
index 3bb31d71d8f..3bb31d71d8f 100644
--- a/app/assets/javascripts/reports/store/mutations.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/grouped_test_report/store/state.js
index e8a0db2e1a8..dd55c7abab4 100644
--- a/app/assets/javascripts/reports/store/state.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/state.js
@@ -1,5 +1,5 @@
import { s__ } from '~/locale';
-import { fieldTypes } from '../constants';
+import { fieldTypes } from '../../constants';
export default () => ({
endpoint: null,
diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/grouped_test_report/store/utils.js
index d89833032a0..189b87bfa8d 100644
--- a/app/assets/javascripts/reports/store/utils.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/utils.js
@@ -5,7 +5,7 @@ import {
ICON_WARNING,
ICON_SUCCESS,
ICON_NOTFOUND,
-} from '../constants';
+} from '../../constants';
const textBuilder = (results) => {
const { failed, errored, resolved, total } = results;
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
new file mode 100644
index 00000000000..ec7ba469ca0
--- /dev/null
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -0,0 +1,220 @@
+<script>
+import {
+ GlModal,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+ GlButton,
+ GlAlert,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+
+const PRIMARY_OPTIONS_TEXT = __('Upload file');
+const SECONDARY_OPTIONS_TEXT = __('Cancel');
+const MODAL_TITLE = __('Upload New File');
+const COMMIT_LABEL = __('Commit message');
+const TARGET_BRANCH_LABEL = __('Target branch');
+const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes');
+const REMOVE_FILE_TEXT = __('Remove file');
+const NEW_BRANCH_IN_FORK = __(
+ 'A new branch will be created in your fork and a new merge request will be started.',
+);
+const ERROR_MESSAGE = __('Error uploading file. Please try again.');
+
+export default {
+ components: {
+ GlModal,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+ GlButton,
+ UploadDropzone,
+ GlAlert,
+ },
+ i18n: {
+ MODAL_TITLE,
+ COMMIT_LABEL,
+ TARGET_BRANCH_LABEL,
+ TOGGLE_CREATE_MR_LABEL,
+ REMOVE_FILE_TEXT,
+ NEW_BRANCH_IN_FORK,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ commitMessage: {
+ type: String,
+ required: true,
+ },
+ targetBranch: {
+ type: String,
+ required: true,
+ },
+ originalBranch: {
+ type: String,
+ required: true,
+ },
+ canPushCode: {
+ type: Boolean,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ commit: this.commitMessage,
+ target: this.targetBranch,
+ createNewMr: true,
+ file: null,
+ filePreviewURL: null,
+ fileBinary: null,
+ loading: false,
+ };
+ },
+ computed: {
+ primaryOptions() {
+ return {
+ text: PRIMARY_OPTIONS_TEXT,
+ attributes: [
+ {
+ variant: 'success',
+ loading: this.loading,
+ disabled: !this.formCompleted || this.loading,
+ },
+ ],
+ };
+ },
+ cancelOptions() {
+ return {
+ text: SECONDARY_OPTIONS_TEXT,
+ attributes: [
+ {
+ disabled: this.loading,
+ },
+ ],
+ };
+ },
+ formattedFileSize() {
+ return numberToHumanSize(this.file.size);
+ },
+ showCreateNewMrToggle() {
+ return this.canPushCode && this.target !== this.originalBranch;
+ },
+ formCompleted() {
+ return this.file && this.commit && this.target;
+ },
+ },
+ methods: {
+ setFile(file) {
+ this.file = file;
+
+ const fileUurlReader = new FileReader();
+
+ fileUurlReader.readAsDataURL(this.file);
+
+ fileUurlReader.onload = (e) => {
+ this.filePreviewURL = e.target?.result;
+ };
+ },
+ removeFile() {
+ this.file = null;
+ this.filePreviewURL = null;
+ },
+ uploadFile() {
+ this.loading = true;
+
+ const {
+ $route: {
+ params: { path },
+ },
+ } = this;
+ const uploadPath = joinPaths(this.path, path);
+
+ const formData = new FormData();
+ formData.append('branch_name', this.target);
+ formData.append('create_merge_request', this.createNewMr);
+ formData.append('commit_message', this.commit);
+ formData.append('file', this.file);
+
+ return axios
+ .post(uploadPath, formData, {
+ headers: {
+ ...ContentTypeMultipartFormData,
+ },
+ })
+ .then((response) => {
+ trackFileUploadEvent('click_upload_modal_form_submit');
+ visitUrl(response.data.filePath);
+ })
+ .catch(() => {
+ this.loading = false;
+ createFlash(ERROR_MESSAGE);
+ });
+ },
+ },
+};
+</script>
+<template>
+ <gl-form>
+ <gl-modal
+ :modal-id="modalId"
+ :title="$options.i18n.MODAL_TITLE"
+ :action-primary="primaryOptions"
+ :action-cancel="cancelOptions"
+ @primary.prevent="uploadFile"
+ >
+ <upload-dropzone class="gl-h-200! gl-mb-4" single-file-selection @change="setFile">
+ <div
+ v-if="file"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ >
+ <img v-if="filePreviewURL" :src="filePreviewURL" class="gl-h-11" />
+ <div>{{ formattedFileSize }}</div>
+ <div>{{ file.name }}</div>
+ <gl-button
+ category="tertiary"
+ variant="confirm"
+ :disabled="loading"
+ @click="removeFile"
+ >{{ $options.i18n.REMOVE_FILE_TEXT }}</gl-button
+ >
+ </div>
+ </upload-dropzone>
+ <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message">
+ <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" />
+ </gl-form-group>
+ <gl-form-group
+ v-if="canPushCode"
+ :label="$options.i18n.TARGET_BRANCH_LABEL"
+ label-for="branch_name"
+ >
+ <gl-form-input v-model="target" :disabled="loading" name="branch_name" />
+ </gl-form-group>
+ <gl-toggle
+ v-if="showCreateNewMrToggle"
+ v-model="createNewMr"
+ :disabled="loading"
+ :label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
+ />
+ <gl-alert v-if="!canPushCode" variant="info" :dismissible="false" class="gl-mt-3">
+ {{ $options.i18n.NEW_BRANCH_IN_FORK }}
+ </gl-alert>
+ </gl-modal>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 747b85f5c1c..e6969b7c8b2 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -1,3 +1,4 @@
+import { GlButton } from '@gitlab/ui';
import Vue from 'vue';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import { parseBoolean } from '../lib/utils/common_utils';
@@ -7,7 +8,6 @@ import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
import LastCommit from './components/last_commit.vue';
-import TreeActionLink from './components/tree_action_link.vue';
import apolloProvider from './graphql';
import createRouter from './router';
import { updateFormAction } from './utils/dom';
@@ -101,14 +101,17 @@ export default function setupVueRepositoryList() {
el: treeHistoryLinkEl,
router,
render(h) {
- return h(TreeActionLink, {
- props: {
- path: `${historyLink}/${
- this.$route.params.path ? escapeFileUrl(this.$route.params.path) : ''
- }`,
- text: __('History'),
+ return h(
+ GlButton,
+ {
+ attrs: {
+ href: `${historyLink}/${
+ this.$route.params.path ? escapeFileUrl(this.$route.params.path) : ''
+ }`,
+ },
},
- });
+ [__('History')],
+ );
},
});
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 52ff4e7b100..6cdd89ad431 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -26,6 +26,8 @@ Sidebar.prototype.removeListeners = function () {
// eslint-disable-next-line @gitlab/no-global-event-off
this.sidebar.off('hidden.gl.dropdown');
// eslint-disable-next-line @gitlab/no-global-event-off
+ this.sidebar.off('hiddenGlDropdown');
+ // eslint-disable-next-line @gitlab/no-global-event-off
$('.dropdown').off('loading.gl.dropdown');
// eslint-disable-next-line @gitlab/no-global-event-off
$('.dropdown').off('loaded.gl.dropdown');
@@ -37,6 +39,7 @@ Sidebar.prototype.addEventListeners = function () {
this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
this.sidebar.on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
+ this.sidebar.on('hiddenGlDropdown', this, this.onSidebarDropdownHidden);
$document.on('click', '.js-sidebar-toggle', this.sidebarToggleClicked);
return $(document)
diff --git a/app/assets/javascripts/security_configuration/components/configuration_table.vue b/app/assets/javascripts/security_configuration/components/configuration_table.vue
index 9475cc1781f..4a3f988296c 100644
--- a/app/assets/javascripts/security_configuration/components/configuration_table.vue
+++ b/app/assets/javascripts/security_configuration/components/configuration_table.vue
@@ -1,16 +1,18 @@
<script>
-import { GlLink, GlSprintf, GlTable, GlAlert } from '@gitlab/ui';
+import { GlLink, GlTable, GlAlert } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_DAST,
+ REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_DEPENDENCY_SCANNING,
REPORT_TYPE_CONTAINER_SCANNING,
REPORT_TYPE_COVERAGE_FUZZING,
+ REPORT_TYPE_API_FUZZING,
REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
-import { features } from './features_constants';
import ManageSast from './manage_sast.vue';
+import { scanners } from './scanners_constants';
import Upgrade from './upgrade.vue';
const borderClasses = 'gl-border-b-1! gl-border-b-solid! gl-border-gray-100!';
@@ -19,14 +21,14 @@ const thClass = `gl-text-gray-900 gl-bg-transparent! ${borderClasses}`;
export default {
components: {
GlLink,
- GlSprintf,
GlTable,
GlAlert,
},
- data: () => ({
- features,
- errorMessage: '',
- }),
+ data() {
+ return {
+ errorMessage: '',
+ };
+ },
methods: {
getFeatureDocumentationLinkLabel(item) {
return sprintf(s__('SecurityConfiguration|Feature documentation for %{featureName}'), {
@@ -40,9 +42,11 @@ export default {
const COMPONENTS = {
[REPORT_TYPE_SAST]: ManageSast,
[REPORT_TYPE_DAST]: Upgrade,
+ [REPORT_TYPE_DAST_PROFILES]: Upgrade,
[REPORT_TYPE_DEPENDENCY_SCANNING]: Upgrade,
[REPORT_TYPE_CONTAINER_SCANNING]: Upgrade,
[REPORT_TYPE_COVERAGE_FUZZING]: Upgrade,
+ [REPORT_TYPE_API_FUZZING]: Upgrade,
[REPORT_TYPE_LICENSE_COMPLIANCE]: Upgrade,
};
@@ -62,7 +66,7 @@ export default {
thClass,
},
],
- items: features,
+ items: scanners,
},
};
</script>
@@ -81,7 +85,8 @@ export default {
{{ item.description }}
<gl-link
target="_blank"
- :href="item.link"
+ data-testid="help-link"
+ :href="item.helpPath"
:aria-label="getFeatureDocumentationLinkLabel(item)"
>
{{ s__('SecurityConfiguration|More information') }}
diff --git a/app/assets/javascripts/security_configuration/components/manage_sast.vue b/app/assets/javascripts/security_configuration/components/manage_sast.vue
index 5169096d563..a2528edd914 100644
--- a/app/assets/javascripts/security_configuration/components/manage_sast.vue
+++ b/app/assets/javascripts/security_configuration/components/manage_sast.vue
@@ -14,9 +14,11 @@ export default {
default: '',
},
},
- data: () => ({
- isLoading: false,
- }),
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
methods: {
async mutate() {
this.isLoading = true;
diff --git a/app/assets/javascripts/security_configuration/components/features_constants.js b/app/assets/javascripts/security_configuration/components/scanners_constants.js
index d846a2761d9..9846df0b4bf 100644
--- a/app/assets/javascripts/security_configuration/components/features_constants.js
+++ b/app/assets/javascripts/security_configuration/components/scanners_constants.js
@@ -1,61 +1,73 @@
import { helpPagePath } from '~/helpers/help_page_helper';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_DAST,
+ REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_SECRET_DETECTION,
REPORT_TYPE_DEPENDENCY_SCANNING,
REPORT_TYPE_CONTAINER_SCANNING,
REPORT_TYPE_COVERAGE_FUZZING,
+ REPORT_TYPE_API_FUZZING,
REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
/**
* Translations & helpPagePaths for Static Security Configuration Page
*/
-export const SAST_NAME = s__('Static Application Security Testing (SAST)');
-export const SAST_DESCRIPTION = s__('Analyze your source code for known vulnerabilities.');
+export const SAST_NAME = __('Static Application Security Testing (SAST)');
+export const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.');
export const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index');
-export const DAST_NAME = s__('Dynamic Application Security Testing (DAST)');
-export const DAST_DESCRIPTION = s__('Analyze a review version of your web application.');
+export const DAST_NAME = __('Dynamic Application Security Testing (DAST)');
+export const DAST_DESCRIPTION = __('Analyze a review version of your web application.');
export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index');
-export const SECRET_DETECTION_NAME = s__('Secret Detection');
-export const SECRET_DETECTION_DESCRIPTION = s__(
+export const DAST_PROFILES_NAME = __('DAST Scans');
+export const DAST_PROFILES_DESCRIPTION = __(
+ 'Saved scan settings and target site settings which are reusable.',
+);
+export const DAST_PROFILES_HELP_PATH = helpPagePath('user/application_security/dast/index');
+
+export const SECRET_DETECTION_NAME = __('Secret Detection');
+export const SECRET_DETECTION_DESCRIPTION = __(
'Analyze your source code and git history for secrets.',
);
export const SECRET_DETECTION_HELP_PATH = helpPagePath(
'user/application_security/secret_detection/index',
);
-export const DEPENDENCY_SCANNING_NAME = s__('Dependency Scanning');
-export const DEPENDENCY_SCANNING_DESCRIPTION = s__(
+export const DEPENDENCY_SCANNING_NAME = __('Dependency Scanning');
+export const DEPENDENCY_SCANNING_DESCRIPTION = __(
'Analyze your dependencies for known vulnerabilities.',
);
export const DEPENDENCY_SCANNING_HELP_PATH = helpPagePath(
'user/application_security/dependency_scanning/index',
);
-export const CONTAINER_SCANNING_NAME = s__('Container Scanning');
-export const CONTAINER_SCANNING_DESCRIPTION = s__(
+export const CONTAINER_SCANNING_NAME = __('Container Scanning');
+export const CONTAINER_SCANNING_DESCRIPTION = __(
'Check your Docker images for known vulnerabilities.',
);
export const CONTAINER_SCANNING_HELP_PATH = helpPagePath(
'user/application_security/container_scanning/index',
);
-export const COVERAGE_FUZZING_NAME = s__('Coverage Fuzzing');
-export const COVERAGE_FUZZING_DESCRIPTION = s__(
+export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing');
+export const COVERAGE_FUZZING_DESCRIPTION = __(
'Find bugs in your code with coverage-guided fuzzing.',
);
export const COVERAGE_FUZZING_HELP_PATH = helpPagePath(
'user/application_security/coverage_fuzzing/index',
);
-export const LICENSE_COMPLIANCE_NAME = s__('License Compliance');
-export const LICENSE_COMPLIANCE_DESCRIPTION = s__(
+export const API_FUZZING_NAME = __('API Fuzzing');
+export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.');
+export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index');
+
+export const LICENSE_COMPLIANCE_NAME = __('License Compliance');
+export const LICENSE_COMPLIANCE_DESCRIPTION = __(
'Search your project dependencies for their licenses and apply policies.',
);
export const LICENSE_COMPLIANCE_HELP_PATH = helpPagePath(
@@ -66,7 +78,7 @@ export const UPGRADE_CTA = s__(
'SecurityConfiguration|Available with %{linkStart}upgrade or free trial%{linkEnd}',
);
-export const features = [
+export const scanners = [
{
name: SAST_NAME,
description: SAST_DESCRIPTION,
@@ -80,10 +92,10 @@ export const features = [
type: REPORT_TYPE_DAST,
},
{
- name: SECRET_DETECTION_NAME,
- description: SECRET_DETECTION_DESCRIPTION,
- helpPath: SECRET_DETECTION_HELP_PATH,
- type: REPORT_TYPE_SECRET_DETECTION,
+ name: DAST_PROFILES_NAME,
+ description: DAST_PROFILES_DESCRIPTION,
+ helpPath: DAST_PROFILES_HELP_PATH,
+ type: REPORT_TYPE_DAST_PROFILES,
},
{
name: DEPENDENCY_SCANNING_NAME,
@@ -98,12 +110,24 @@ export const features = [
type: REPORT_TYPE_CONTAINER_SCANNING,
},
{
+ name: SECRET_DETECTION_NAME,
+ description: SECRET_DETECTION_DESCRIPTION,
+ helpPath: SECRET_DETECTION_HELP_PATH,
+ type: REPORT_TYPE_SECRET_DETECTION,
+ },
+ {
name: COVERAGE_FUZZING_NAME,
description: COVERAGE_FUZZING_DESCRIPTION,
helpPath: COVERAGE_FUZZING_HELP_PATH,
type: REPORT_TYPE_COVERAGE_FUZZING,
},
{
+ name: API_FUZZING_NAME,
+ description: API_FUZZING_DESCRIPTION,
+ helpPath: API_FUZZING_HELP_PATH,
+ type: REPORT_TYPE_API_FUZZING,
+ },
+ {
name: LICENSE_COMPLIANCE_NAME,
description: LICENSE_COMPLIANCE_DESCRIPTION,
helpPath: LICENSE_COMPLIANCE_HELP_PATH,
diff --git a/app/assets/javascripts/security_configuration/components/upgrade.vue b/app/assets/javascripts/security_configuration/components/upgrade.vue
index 166ee4ff194..518eb57731d 100644
--- a/app/assets/javascripts/security_configuration/components/upgrade.vue
+++ b/app/assets/javascripts/security_configuration/components/upgrade.vue
@@ -1,12 +1,18 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
-import { UPGRADE_CTA } from './features_constants';
+import { UPGRADE_CTA } from './scanners_constants';
export default {
components: {
GlLink,
GlSprintf,
},
+ inject: {
+ upgradePath: {
+ from: 'upgradePath',
+ default: '#',
+ },
+ },
i18n: {
UPGRADE_CTA,
},
@@ -17,7 +23,7 @@ export default {
<span>
<gl-sprintf :message="$options.i18n.UPGRADE_CTA">
<template #link="{ content }">
- <gl-link target="_blank" href="https://about.gitlab.com/pricing/">
+ <gl-link target="_blank" :href="upgradePath">
{{ content }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index c98fa46b32b..1134a1ffb44 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -14,13 +14,14 @@ export const initStaticSecurityConfiguration = (el) => {
defaultClient: createDefaultClient(),
});
- const { projectPath } = el.dataset;
+ const { projectPath, upgradePath } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
projectPath,
+ upgradePath,
},
render(createElement) {
return createElement(SecurityConfigurationApp);
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
index 9b7264e92ce..c608c71714b 100644
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -132,11 +132,11 @@ export default {
<div class="settings-content">
<form name="self-monitoring-form">
<p ref="selfMonitoringFormText" v-html="selfMonitoringFormText"></p>
- <gl-form-group :label="$options.formLabels.createProject" label-for="self-monitor-toggle">
+ <gl-form-group>
<gl-toggle
v-model="selfMonitorEnabled"
:is-loading="loading"
- name="self-monitor-toggle"
+ :label="$options.formLabels.createProject"
/>
</gl-form-group>
</form>
diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js
index 4277ffec545..bc3b2f16a6a 100644
--- a/app/assets/javascripts/sentry/sentry_config.js
+++ b/app/assets/javascripts/sentry/sentry_config.js
@@ -1,6 +1,6 @@
+import * as Sentry from '@sentry/browser';
import $ from 'jquery';
import { __ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
const IGNORE_ERRORS = [
// Random plugins/extensions
diff --git a/app/assets/javascripts/sentry/wrapper.js b/app/assets/javascripts/sentry/wrapper.js
deleted file mode 100644
index 24039e6141c..00000000000
--- a/app/assets/javascripts/sentry/wrapper.js
+++ /dev/null
@@ -1,26 +0,0 @@
-// Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179
-// export * from '@sentry/browser';
-
-export function init(...args) {
- return args;
-}
-
-export function setUser(...args) {
- return args;
-}
-
-export function captureException(...args) {
- return args;
-}
-
-export function captureMessage(...args) {
- return args;
-}
-
-export function withScope(fn) {
- fn({
- setTag(...args) {
- return args;
- },
- });
-}
diff --git a/app/assets/javascripts/shared/popover.js b/app/assets/javascripts/shared/popover.js
deleted file mode 100644
index 435ee8fb968..00000000000
--- a/app/assets/javascripts/shared/popover.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import $ from 'jquery';
-import { debounce } from 'lodash';
-
-export function togglePopover(show) {
- const isAlreadyShown = this.hasClass('js-popover-show');
- if ((show && isAlreadyShown) || (!show && !isAlreadyShown)) {
- return false;
- }
- this.popover(show ? 'show' : 'hide');
- this.toggleClass('disable-animation js-popover-show', show);
-
- return true;
-}
-
-export function mouseleave() {
- if (!$('.popover:hover').length > 0) {
- const $popover = $(this);
- togglePopover.call($popover, false);
- }
-}
-
-export function mouseenter() {
- const $popover = $(this);
-
- const showedPopover = togglePopover.call($popover, true);
- if (showedPopover) {
- $('.popover').on('mouseleave', mouseleave.bind($popover));
- }
-}
-
-export function debouncedMouseleave(debounceTimeout = 300) {
- return debounce(mouseleave, debounceTimeout);
-}
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index e2dc37a0ac2..b53b7039018 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -31,13 +31,18 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-flex-direction-column">
- <div v-if="emptyUsers" data-testid="none">
+ <div class="gl-display-flex gl-flex-direction-column issuable-assignees">
+ <div
+ v-if="emptyUsers"
+ class="gl-display-flex gl-align-items-center gl-text-gray-500"
+ data-testid="none"
+ >
<span> {{ __('None') }} -</span>
<gl-button
data-testid="assign-yourself"
category="tertiary"
variant="link"
+ class="gl-ml-2"
@click="$emit('assign-self')"
>
<span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index 8f3f77cb5f0..cc2201ad359 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -15,13 +15,12 @@ import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
-import { assigneesQueries } from '~/sidebar/constants';
+import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
export const assigneesWidget = Vue.observable({
updateAssignees: null,
});
-
export default {
i18n: {
unassigned: __('Unassigned'),
@@ -88,10 +87,10 @@ export default {
return this.queryVariables;
},
update(data) {
- return data.issuable || data.project?.issuable;
+ return data.workspace?.issuable;
},
result({ data }) {
- const issuable = data.issuable || data.project?.issuable;
+ const issuable = data.workspace?.issuable;
if (issuable) {
this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes));
}
@@ -104,13 +103,24 @@ export default {
query: searchUsers,
variables() {
return {
+ fullPath: this.fullPath,
search: this.search,
};
},
update(data) {
- return data.users?.nodes || [];
+ const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || [];
+ const mergedSearchResults = this.participants.reduce((acc, current) => {
+ if (
+ !acc.some((user) => current.username === user.username) &&
+ (current.name.includes(this.search) || current.username.includes(this.search))
+ ) {
+ acc.push(current);
+ }
+ return acc;
+ }, searchResults);
+ return mergedSearchResults;
},
- debounce: 250,
+ debounce: ASSIGNEES_DEBOUNCE_DELAY,
skip() {
return this.isSearchEmpty;
},
@@ -185,7 +195,7 @@ export default {
return this.selected.some(isCurrentUser) || this.participants.some(isCurrentUser);
},
noUsersFound() {
- return !this.isSearchEmpty && this.unselectedFiltered.length === 0;
+ return !this.isSearchEmpty && this.searchUsers.length === 0;
},
showCurrentUser() {
return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching);
@@ -218,7 +228,7 @@ export default {
},
})
.then(({ data }) => {
- this.$emit('assignees-updated', data);
+ this.$emit('assignees-updated', data.issuableSetAssignees.issuable.assignees.nodes);
return data;
})
.catch(() => {
@@ -281,6 +291,9 @@ export default {
collapseWidget() {
this.$refs.toggle.collapse();
},
+ showDivider(list) {
+ return list.length > 0 && this.isSearchEmpty;
+ },
},
};
</script>
@@ -306,6 +319,7 @@ export default {
<issuable-assignees
:users="assignees"
:issuable-type="issuableType"
+ class="gl-mt-2"
@assign-self="assignSelf"
/>
</template>
@@ -334,12 +348,14 @@ export default {
data-testid="unassign"
@click="selectAssignee()"
>
- <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'">{{
- $options.i18n.unassigned
- }}</span></gl-dropdown-item
+ <span
+ :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'"
+ class="gl-font-weight-bold"
+ >{{ $options.i18n.unassigned }}</span
+ ></gl-dropdown-item
>
- <gl-dropdown-divider data-testid="unassign-divider" />
</template>
+ <gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
<gl-dropdown-item
v-for="item in selectedFiltered"
:key="item.id"
@@ -358,10 +374,10 @@ export default {
/>
</gl-avatar-link>
</gl-dropdown-item>
- <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
<template v-if="showCurrentUser">
+ <gl-dropdown-divider />
<gl-dropdown-item
- data-testid="unselected-participant"
+ data-testid="current-user"
@click.stop="selectAssignee(currentUser)"
>
<gl-avatar-link>
@@ -370,12 +386,12 @@ export default {
:label="currentUser.name"
:sub-label="currentUser.username"
:src="currentUser.avatarUrl"
- class="gl-align-items-center"
+ class="gl-align-items-center gl-pl-6!"
/>
</gl-avatar-link>
</gl-dropdown-item>
- <gl-dropdown-divider />
</template>
+ <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
@@ -392,7 +408,7 @@ export default {
/>
</gl-avatar-link>
</gl-dropdown-item>
- <gl-dropdown-item v-if="noUsersFound && !isSearching">
+ <gl-dropdown-item v-if="noUsersFound && !isSearching" data-testid="empty-results">
{{ __('No matching results') }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index 36775648809..d0da4a9c75a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -83,7 +83,7 @@ export default {
<assignee-avatar-link :user="user" :issuable-type="issuableType" />
</div>
</div>
- <div v-if="renderShowMoreSection" class="user-list-more">
+ <div v-if="renderShowMoreSection" class="user-list-more gl-hover-text-blue-800">
<button
type="button"
class="btn-link"
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
deleted file mode 100644
index 57b3705e803..00000000000
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ /dev/null
@@ -1,113 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { mapState } from 'vuex';
-import { __, sprintf } from '~/locale';
-import eventHub from '~/sidebar/event_hub';
-import EditForm from './edit_form.vue';
-
-export default {
- components: {
- EditForm,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- fullPath: {
- required: true,
- type: String,
- },
- isEditable: {
- required: true,
- type: Boolean,
- },
- issuableType: {
- required: false,
- type: String,
- default: 'issue',
- },
- },
- data() {
- return {
- edit: false,
- };
- },
- computed: {
- ...mapState({
- confidential: ({ noteableData, confidential }) => {
- if (noteableData) {
- return noteableData.confidential;
- }
- return Boolean(confidential);
- },
- }),
- confidentialityIcon() {
- return this.confidential ? 'eye-slash' : 'eye';
- },
- tooltipLabel() {
- return this.confidential ? __('Confidential') : __('Not confidential');
- },
- confidentialText() {
- return sprintf(__('This %{issuableType} is confidential'), {
- issuableType: this.issuableType,
- });
- },
- },
- created() {
- eventHub.$on('closeConfidentialityForm', this.toggleForm);
- },
- beforeDestroy() {
- eventHub.$off('closeConfidentialityForm', this.toggleForm);
- },
- methods: {
- toggleForm() {
- this.edit = !this.edit;
- },
- },
-};
-</script>
-
-<template>
- <div class="block issuable-sidebar-item confidentiality">
- <div
- ref="collapseIcon"
- v-gl-tooltip.viewport.left
- :title="tooltipLabel"
- class="sidebar-collapsed-icon"
- @click="toggleForm"
- >
- <gl-icon :name="confidentialityIcon" />
- </div>
- <div class="title hide-collapsed">
- {{ __('Confidentiality') }}
- <a
- v-if="isEditable"
- ref="editLink"
- class="float-right confidential-edit"
- href="#"
- data-track-event="click_edit_button"
- data-track-label="right_sidebar"
- data-track-property="confidentiality"
- @click.prevent="toggleForm"
- >{{ __('Edit') }}</a
- >
- </div>
- <div class="value sidebar-item-value hide-collapsed">
- <edit-form
- v-if="edit"
- :confidential="confidential"
- :full-path="fullPath"
- :issuable-type="issuableType"
- />
- <div v-if="!confidential" class="no-value sidebar-item-value" data-testid="not-confidential">
- <gl-icon :size="16" name="eye" class="sidebar-item-icon inline" />
- {{ __('Not confidential') }}
- </div>
- <div v-else class="value sidebar-item-value hide-collapsed">
- <gl-icon :size="16" name="eye-slash" class="sidebar-item-icon inline is-active" />
- {{ confidentialText }}
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
deleted file mode 100644
index 057224d5918..00000000000
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
+++ /dev/null
@@ -1,64 +0,0 @@
-<script>
-import { GlSprintf } from '@gitlab/ui';
-import { __ } from '../../../locale';
-import editFormButtons from './edit_form_buttons.vue';
-
-export default {
- components: {
- editFormButtons,
- GlSprintf,
- },
- props: {
- confidential: {
- required: true,
- type: Boolean,
- },
- fullPath: {
- required: true,
- type: String,
- },
- issuableType: {
- required: true,
- type: String,
- },
- },
- computed: {
- confidentialityOnWarning() {
- return __(
- 'You are going to turn on the confidentiality. This means that only team members with %{strongStart}at least Reporter access%{strongEnd} are able to see and leave comments on the %{issuableType}.',
- );
- },
- confidentialityOffWarning() {
- return __(
- 'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}.',
- );
- },
- },
-};
-</script>
-
-<template>
- <div class="dropdown show">
- <div class="dropdown-menu sidebar-item-warning-message">
- <div>
- <p v-if="!confidential">
- <gl-sprintf :message="confidentialityOnWarning">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- <template #issuableType>{{ issuableType }}</template>
- </gl-sprintf>
- </p>
- <p v-else>
- <gl-sprintf :message="confidentialityOffWarning">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- <template #issuableType>{{ issuableType }}</template>
- </gl-sprintf>
- </p>
- <edit-form-buttons :full-path="fullPath" :confidential="confidential" />
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
deleted file mode 100644
index 154a228c978..00000000000
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import $ from 'jquery';
-import { mapActions } from 'vuex';
-import { deprecatedCreateFlash as Flash } from '~/flash';
-import { __ } from '~/locale';
-import eventHub from '../../event_hub';
-
-export default {
- components: {
- GlButton,
- },
- props: {
- fullPath: {
- required: true,
- type: String,
- },
- confidential: {
- required: true,
- type: Boolean,
- },
- },
- data() {
- return {
- isLoading: false,
- };
- },
- computed: {
- toggleButtonText() {
- if (this.isLoading) {
- return __('Applying');
- }
-
- return this.confidential ? __('Turn Off') : __('Turn On');
- },
- },
- methods: {
- ...mapActions(['updateConfidentialityOnIssuable']),
- closeForm() {
- eventHub.$emit('closeConfidentialityForm');
- $(this.$el).trigger('hidden.gl.dropdown');
- },
- submitForm() {
- this.isLoading = true;
- const confidential = !this.confidential;
-
- this.updateConfidentialityOnIssuable({ confidential, fullPath: this.fullPath })
- .then(() => {
- eventHub.$emit('updateIssuableConfidentiality', confidential);
- })
- .catch((err) => {
- Flash(
- err || __('Something went wrong trying to change the confidentiality of this issue'),
- );
- })
- .finally(() => {
- this.closeForm();
- this.isLoading = false;
- });
- },
- },
-};
-</script>
-
-<template>
- <div class="sidebar-item-warning-message-actions">
- <gl-button class="gl-mr-3" @click="closeForm">
- {{ __('Cancel') }}
- </gl-button>
- <gl-button
- category="secondary"
- variant="warning"
- :disabled="isLoading"
- :loading="isLoading"
- data-testid="confidential-toggle"
- @click.prevent="submitForm"
- >
- {{ toggleButtonText }}
- </gl-button>
- </div>
-</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
new file mode 100644
index 00000000000..37a44eb8f01
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ confidential: {
+ type: Boolean,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ confidentialText() {
+ return this.confidential
+ ? sprintf(__('This %{issuableType} is confidential'), {
+ issuableType: this.issuableType,
+ })
+ : __('Not confidential');
+ },
+ confidentialIcon() {
+ return this.confidential ? 'eye-slash' : 'eye';
+ },
+ tooltipLabel() {
+ return this.confidential ? __('Confidential') : __('Not confidential');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ v-gl-tooltip.viewport.left
+ :title="tooltipLabel"
+ class="sidebar-collapsed-icon"
+ data-testid="sidebar-collapsed-icon"
+ @click="$emit('expandSidebar')"
+ >
+ <gl-icon
+ :size="16"
+ :name="confidentialIcon"
+ class="sidebar-item-icon inline"
+ :class="{ 'is-active': confidential }"
+ />
+ </div>
+ <gl-icon
+ :size="16"
+ :name="confidentialIcon"
+ class="sidebar-item-icon inline hide-collapsed"
+ :class="{ 'is-active': confidential }"
+ />
+ <span class="hide-collapsed" data-testid="confidential-text">{{ confidentialText }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
new file mode 100644
index 00000000000..a21ac73f131
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -0,0 +1,136 @@
+<script>
+import { GlSprintf, GlButton } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { IssuableType } from '~/issue_show/constants';
+import { __, sprintf } from '~/locale';
+import { confidentialityQueries } from '~/sidebar/constants';
+
+export default {
+ i18n: {
+ confidentialityOnWarning: __(
+ 'You are going to turn on confidentiality. Only team members with %{strongStart}at least Reporter access%{strongEnd} will be able to see and leave comments on the %{issuableType}.',
+ ),
+ confidentialityOffWarning: __(
+ 'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}.',
+ ),
+ },
+ components: {
+ GlSprintf,
+ GlButton,
+ },
+ inject: ['fullPath', 'iid'],
+ props: {
+ confidential: {
+ required: true,
+ type: Boolean,
+ },
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ computed: {
+ toggleButtonText() {
+ if (this.loading) {
+ return __('Applying');
+ }
+ return this.confidential ? __('Turn off') : __('Turn on');
+ },
+ warningMessage() {
+ return this.confidential
+ ? this.$options.i18n.confidentialityOffWarning
+ : this.$options.i18n.confidentialityOnWarning;
+ },
+ workspacePath() {
+ return this.issuableType === IssuableType.Issue
+ ? {
+ projectPath: this.fullPath,
+ }
+ : {
+ groupPath: this.fullPath,
+ };
+ },
+ },
+ methods: {
+ submitForm() {
+ this.loading = true;
+ this.$apollo
+ .mutate({
+ mutation: confidentialityQueries[this.issuableType].mutation,
+ variables: {
+ input: {
+ ...this.workspacePath,
+ iid: this.iid,
+ confidential: !this.confidential,
+ },
+ },
+ })
+ .then(
+ ({
+ data: {
+ issuableSetConfidential: { errors },
+ },
+ }) => {
+ if (errors.length) {
+ createFlash({
+ message: errors[0],
+ });
+ } else {
+ this.$emit('closeForm');
+ }
+ },
+ )
+ .catch(() => {
+ createFlash({
+ message: sprintf(
+ __('Something went wrong while setting %{issuableType} confidentiality.'),
+ {
+ issuableType: this.issuableType,
+ },
+ ),
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown show">
+ <div class="dropdown-menu sidebar-item-warning-message">
+ <div>
+ <p data-testid="warning-message">
+ <gl-sprintf :message="warningMessage">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #issuableType>{{ issuableType }}</template>
+ </gl-sprintf>
+ </p>
+ <div class="sidebar-item-warning-message-actions">
+ <gl-button class="gl-mr-3" data-testid="confidential-cancel" @click="$emit('closeForm')">
+ {{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ category="secondary"
+ variant="warning"
+ :disabled="loading"
+ :loading="loading"
+ data-testid="confidential-toggle"
+ @click.prevent="submitForm"
+ >
+ {{ toggleButtonText }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
new file mode 100644
index 00000000000..ec5f07f9785
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -0,0 +1,142 @@
+<script>
+import produce from 'immer';
+import Vue from 'vue';
+import createFlash from '~/flash';
+import { __, sprintf } from '~/locale';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { confidentialityQueries } from '~/sidebar/constants';
+import SidebarConfidentialityContent from './sidebar_confidentiality_content.vue';
+import SidebarConfidentialityForm from './sidebar_confidentiality_form.vue';
+
+export const confidentialWidget = Vue.observable({
+ setConfidentiality: null,
+});
+
+const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', {
+ bubbles: true,
+});
+
+export default {
+ tracking: {
+ event: 'click_edit_button',
+ label: 'right_sidebar',
+ property: 'confidentiality',
+ },
+ components: {
+ SidebarEditableItem,
+ SidebarConfidentialityContent,
+ SidebarConfidentialityForm,
+ },
+ inject: ['fullPath', 'iid'],
+ props: {
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ confidential: false,
+ };
+ },
+ apollo: {
+ confidential: {
+ query() {
+ return confidentialityQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: String(this.iid),
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.confidential || false;
+ },
+ result({ data }) {
+ this.$emit('confidentialityUpdated', data.workspace?.issuable?.confidential);
+ },
+ error() {
+ createFlash({
+ message: sprintf(
+ __('Something went wrong while setting %{issuableType} confidentiality.'),
+ {
+ issuableType: this.issuableType,
+ },
+ ),
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.confidential.loading;
+ },
+ },
+ mounted() {
+ confidentialWidget.setConfidentiality = this.setConfidentiality;
+ },
+ destroyed() {
+ confidentialWidget.setConfidentiality = null;
+ },
+ methods: {
+ closeForm() {
+ this.$refs.editable.collapse();
+ this.$el.dispatchEvent(hideDropdownEvent);
+ this.$emit('closeForm');
+ },
+ // synchronizing the quick action with the sidebar widget
+ // this is a temporary solution until we have confidentiality real-time updates
+ setConfidentiality() {
+ const { defaultClient: client } = this.$apollo.provider.clients;
+ const sourceData = client.readQuery({
+ query: confidentialityQueries[this.issuableType].query,
+ variables: { fullPath: this.fullPath, iid: this.iid },
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ draftData.workspace.issuable.confidential = !this.confidential;
+ });
+
+ client.writeQuery({
+ query: confidentialityQueries[this.issuableType].query,
+ variables: { fullPath: this.fullPath, iid: this.iid },
+ data,
+ });
+ },
+ expandSidebar() {
+ this.$refs.editable.expand();
+ this.$emit('expandSidebar');
+ },
+ },
+};
+</script>
+
+<template>
+ <sidebar-editable-item
+ ref="editable"
+ :title="__('Confidentiality')"
+ :tracking="$options.tracking"
+ :loading="isLoading"
+ class="block confidentiality"
+ >
+ <template #collapsed>
+ <div>
+ <sidebar-confidentiality-content
+ v-if="!isLoading"
+ :confidential="confidential"
+ :issuable-type="issuableType"
+ @expandSidebar="expandSidebar"
+ />
+ </div>
+ </template>
+ <template #default>
+ <sidebar-confidentiality-content :confidential="confidential" :issuable-type="issuableType" />
+ <sidebar-confidentiality-form
+ :confidential="confidential"
+ :issuable-type="issuableType"
+ @closeForm="closeForm"
+ />
+ </template>
+ </sidebar-editable-item>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue
new file mode 100644
index 00000000000..567c921b74e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue
@@ -0,0 +1,84 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { referenceQueries } from '~/sidebar/constants';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ i18n: {
+ copyReference: __('Copy reference'),
+ text: __('Reference'),
+ },
+ components: {
+ ClipboardButton,
+ GlLoadingIcon,
+ },
+ inject: ['fullPath', 'iid'],
+ props: {
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ reference: '',
+ };
+ },
+ apollo: {
+ reference: {
+ query() {
+ return referenceQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.reference || '';
+ },
+ error(error) {
+ this.$emit('fetch-error', {
+ message: __('An error occurred while fetching reference'),
+ error,
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.reference.loading;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="sub-block">
+ <clipboard-button
+ v-if="!isLoading"
+ :title="$options.i18n.copyReference"
+ :text="reference"
+ category="tertiary"
+ css-class="sidebar-collapsed-icon dont-change-state"
+ tooltip-placement="left"
+ />
+ <div class="gl-display-flex gl-align-items-center gl-justify-between gl-mb-2 hide-collapsed">
+ <span class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap">
+ {{ $options.i18n.text }}: {{ reference }}
+ <gl-loading-icon v-if="isLoading" inline :label="$options.i18n.text" />
+ </span>
+ <clipboard-button
+ v-if="!isLoading"
+ :title="$options.i18n.copyReference"
+ :text="reference"
+ size="small"
+ category="tertiary"
+ css-class="gl-mr-1"
+ tooltip-placement="left"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index cbd68f2513a..dd1d54d67f2 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
@@ -50,6 +51,9 @@ export default {
},
},
methods: {
+ approvedByTooltipTitle(user) {
+ return sprintf(s__('MergeRequest|Approved by @%{username}'), user);
+ },
toggleShowLess() {
this.showLess = !this.showLess;
},
@@ -57,6 +61,7 @@ export default {
this.loadingStates[userId] = LOADING_STATE;
this.$emit('request-review', { userId, callback: this.requestReviewComplete });
},
+
requestReviewComplete(userId, success) {
if (success) {
this.loadingStates[userId] = SUCCESS_STATE;
@@ -86,10 +91,19 @@ export default {
<div class="gl-ml-3">@{{ user.username }}</div>
</reviewer-avatar-link>
<gl-icon
+ v-if="user.approved"
+ v-gl-tooltip.left
+ :size="16"
+ :title="approvedByTooltipTitle(user)"
+ name="status-success"
+ class="float-right gl-my-2 gl-ml-2 gl-text-green-500"
+ data-testid="re-approved"
+ />
+ <gl-icon
v-if="loadingStates[user.id] === $options.SUCCESS_STATE"
:size="24"
name="check"
- class="float-right gl-text-green-500"
+ class="float-right gl-py-2 gl-mr-2 gl-text-green-500"
data-testid="re-request-success"
/>
<gl-button
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index 9da839cd133..4ab4606ac1c 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -3,7 +3,12 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: { GlButton, GlLoadingIcon },
- inject: ['canUpdate'],
+ inject: {
+ canUpdate: {},
+ isClassicSidebar: {
+ default: false,
+ },
+ },
props: {
title: {
type: String,
@@ -15,6 +20,15 @@ export default {
required: false,
default: false,
},
+ tracking: {
+ type: Object,
+ required: false,
+ default: () => ({
+ event: null,
+ label: null,
+ property: null,
+ }),
+ },
},
data() {
return {
@@ -71,24 +85,33 @@ export default {
<template>
<div>
- <div class="gl-display-flex gl-align-items-center gl-mb-3" @click.self="collapse">
- <span data-testid="title">{{ title }}</span>
- <gl-loading-icon v-if="loading" inline class="gl-ml-2" />
+ <div class="gl-display-flex gl-align-items-center" @click.self="collapse">
+ <span class="hide-collapsed" data-testid="title">{{ title }}</span>
+ <gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" />
+ <gl-loading-icon
+ v-if="loading && isClassicSidebar"
+ inline
+ class="gl-mx-auto gl-my-0 hide-expanded"
+ />
<gl-button
v-if="canUpdate"
variant="link"
- class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto js-sidebar-dropdown-toggle"
+ class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed"
data-testid="edit-button"
+ :data-track-event="tracking.event"
+ :data-track-label="tracking.label"
+ :data-track-property="tracking.property"
+ data-qa-selector="edit_link"
@keyup.esc="toggle"
@click="toggle"
>
{{ __('Edit') }}
</gl-button>
</div>
- <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
+ <div v-show="!edit" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
- <div v-show="edit" data-testid="expanded-content">
+ <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
<slot :edit="edit"></slot>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index 9b06c20a6f3..c0424dc2873 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -122,6 +122,8 @@ export default {
:value="subscribed"
class="hide-collapsed"
data-testid="subscription-toggle"
+ :label="__('Notifications')"
+ label-position="hidden"
@change="toggleSubscription"
/>
</div>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
index e0f60b9af08..d1a5685fdd3 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
@@ -1,10 +1,14 @@
<script>
/* eslint-disable vue/no-v-html */
+import { GlButton } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '../../../locale';
export default {
name: 'TimeTrackingHelpState',
+ components: {
+ GlButton,
+ },
computed: {
href() {
return joinPaths(gon.relative_url_root || '', '/help/user/project/time_tracking.md');
@@ -40,7 +44,7 @@ export default {
<p>{{ __('Quick actions can be used in the issues description and comment boxes.') }}</p>
<p v-html="estimateText"></p>
<p v-html="spendText"></p>
- <a :href="href" class="btn btn-default learn-more-button"> {{ __('Learn more') }} </a>
+ <gl-button :href="href">{{ __('Learn more') }}</gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 274aa237aea..a0e636488f4 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,9 +1,17 @@
import { IssuableType } from '~/issue_show/constants';
+import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
+import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
+import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
+import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
+import updateEpicMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
+import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
+export const ASSIGNEES_DEBOUNCE_DELAY = 250;
+
export const assigneesQueries = {
[IssuableType.Issue]: {
query: getIssueParticipants,
@@ -14,3 +22,23 @@ export const assigneesQueries = {
mutation: updateMergeRequestParticipantsMutation,
},
};
+
+export const confidentialityQueries = {
+ [IssuableType.Issue]: {
+ query: issueConfidentialQuery,
+ mutation: updateIssueConfidentialMutation,
+ },
+ [IssuableType.Epic]: {
+ query: epicConfidentialQuery,
+ mutation: updateEpicMutation,
+ },
+};
+
+export const referenceQueries = {
+ [IssuableType.Issue]: {
+ query: issueReferenceQuery,
+ },
+ [IssuableType.MergeRequest]: {
+ query: mergeRequestReferenceQuery,
+ },
+};
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
new file mode 100644
index 00000000000..aa139540a51
--- /dev/null
+++ b/app/assets/javascripts/sidebar/graphql.js
@@ -0,0 +1,8 @@
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+export const defaultClient = createDefaultClient();
+
+export const apolloProvider = new VueApollo({
+ defaultClient,
+});
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 662edbc4f8d..312c0c89f29 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createFlash from '~/flash';
-import createDefaultClient from '~/lib/graphql';
+import { IssuableType } from '~/issue_show/constants';
import {
isInIssuePage,
isInDesignPage,
@@ -10,9 +10,11 @@ import {
parseBoolean,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
+import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
+import { apolloProvider } from '~/sidebar/graphql';
import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
-import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
import SidebarLabels from './components/labels/sidebar_labels.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
@@ -54,9 +56,6 @@ function getSidebarAssigneeAvailabilityData() {
function mountAssigneesComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
if (!el) return;
@@ -78,7 +77,9 @@ function mountAssigneesComponent(mediator) {
field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'),
issuableType:
- isInIssuePage() || isInIncidentPage() || isInDesignPage() ? 'issue' : 'merge_request',
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
assigneeAvailabilityStatus,
},
}),
@@ -87,9 +88,6 @@ function mountAssigneesComponent(mediator) {
function mountReviewersComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-reviewers');
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
if (!el) return;
@@ -121,10 +119,6 @@ export function mountSidebarLabels() {
return false;
}
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
-
return new Vue({
el,
apolloProvider,
@@ -139,39 +133,71 @@ export function mountSidebarLabels() {
});
}
-function mountConfidentialComponent(mediator) {
+function mountConfidentialComponent() {
const el = document.getElementById('js-confidential-entry-point');
+ if (!el) {
+ return;
+ }
const { fullPath, iid } = getSidebarOptions();
-
- if (!el) return;
-
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
- import(/* webpackChunkName: 'notesStore' */ '~/notes/stores')
- .then(
- ({ store }) =>
- new Vue({
- el,
- store,
- components: {
- ConfidentialIssueSidebar,
- },
- render: (createElement) =>
- createElement('confidential-issue-sidebar', {
- props: {
- iid: String(iid),
- fullPath,
- isEditable: initialData.is_editable,
- service: mediator.service,
- },
- }),
- }),
- )
- .catch(() => {
- createFlash({ message: __('Failed to load sidebar confidential toggle') });
- });
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarConfidentialityWidget,
+ },
+ provide: {
+ iid: String(iid),
+ fullPath,
+ canUpdate: initialData.is_editable,
+ },
+
+ render: (createElement) =>
+ createElement('sidebar-confidentiality-widget', {
+ props: {
+ issuableType:
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
+ },
+ }),
+ });
+}
+
+function mountReferenceComponent() {
+ const el = document.getElementById('js-reference-entry-point');
+ if (!el) {
+ return;
+ }
+
+ const { fullPath, iid } = getSidebarOptions();
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarReferenceWidget,
+ },
+ provide: {
+ iid: String(iid),
+ fullPath,
+ },
+
+ render: (createElement) =>
+ createElement('sidebar-reference-widget', {
+ props: {
+ issuableType:
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
+ },
+ }),
+ });
}
function mountLockComponent() {
@@ -280,9 +306,6 @@ function mountSeverityComponent() {
if (!severityContainerEl) {
return false;
}
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
const { fullPath, iid, severity } = getSidebarOptions();
@@ -322,6 +345,7 @@ export function mountSidebar(mediator) {
mountAssigneesComponent(mediator);
mountReviewersComponent(mediator);
mountConfidentialComponent(mediator);
+ mountReferenceComponent(mediator);
mountLockComponent();
mountParticipantsComponent(mediator);
mountSubscriptionsComponent(mediator);
diff --git a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
new file mode 100644
index 00000000000..7a1fdb40e93
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
@@ -0,0 +1,10 @@
+query epicConfidential($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ __typename
+ issuable: epic(iid: $iid) {
+ __typename
+ id
+ confidential
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
new file mode 100644
index 00000000000..92cabf46af7
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
@@ -0,0 +1,10 @@
+query issueConfidential($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: issue(iid: $iid) {
+ __typename
+ id
+ confidential
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
new file mode 100644
index 00000000000..db4f58a4f69
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
@@ -0,0 +1,10 @@
+query issueReference($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: issue(iid: $iid) {
+ __typename
+ id
+ reference(full: true)
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
new file mode 100644
index 00000000000..7979a1ccb3e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
@@ -0,0 +1,10 @@
+query mergeRequestReference($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: mergeRequest(iid: $iid) {
+ __typename
+ id
+ reference(full: true)
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql
new file mode 100644
index 00000000000..02498b18832
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql
@@ -0,0 +1,7 @@
+query mergeRequestSidebarDetails($fullPath: ID!, $iid: String!) {
+ project(fullPath: $fullPath) {
+ mergeRequest(iid: $iid) {
+ iid # currently unused.
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql
new file mode 100644
index 00000000000..69927ddd205
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql
@@ -0,0 +1,9 @@
+mutation updateEpic($input: UpdateEpicInput!) {
+ issuableSetConfidential: updateEpic(input: $input) {
+ issuable: epic {
+ id
+ confidential
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_confidential.mutation.graphql
index 5caf5f6b555..8f716c882d6 100644
--- a/app/assets/javascripts/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_issue_confidential.mutation.graphql
@@ -1,6 +1,7 @@
mutation updateIssueConfidential($input: IssueSetConfidentialInput!) {
- issueSetConfidential(input: $input) {
- issue {
+ issuableSetConfidential: issueSetConfidential(input: $input) {
+ issuable: issue {
+ id
confidential
}
errors
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index f31e4a3e0dd..88501f2c305 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -1,8 +1,14 @@
-import sidebarDetailsQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql';
+import sidebarDetailsIssueQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql';
+import sidebarDetailsMRQuery from '../queries/sidebarDetailsMR.query.graphql';
+
+const queries = {
+ merge_request: sidebarDetailsMRQuery,
+ issue: sidebarDetailsIssueQuery,
+};
export const gqClient = createGqClient(
{},
@@ -20,6 +26,7 @@ export default class SidebarService {
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
this.fullPath = endpointMap.fullPath;
this.iid = endpointMap.iid;
+ this.issuableType = endpointMap.issuableType;
SidebarService.singleton = this;
}
@@ -31,7 +38,7 @@ export default class SidebarService {
return Promise.all([
axios.get(this.endpoint),
gqClient.query({
- query: sidebarDetailsQuery,
+ query: this.sidebarDetailsQuery(),
variables: {
fullPath: this.fullPath,
iid: this.iid.toString(),
@@ -40,6 +47,10 @@ export default class SidebarService {
]);
}
+ sidebarDetailsQuery() {
+ return queries[this.issuableType];
+ }
+
update(key, data) {
return axios.put(this.endpoint, { [key]: data });
}
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index bd382ed0fdb..3595354da80 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -22,6 +22,7 @@ export default class SidebarMediator {
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
fullPath: options.fullPath,
iid: options.iid,
+ issuableType: options.issuableType,
});
SidebarMediator.singleton = this;
}
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 687289b6675..2c4928fc338 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -73,7 +73,7 @@ export default class SingleFileDiff {
this.collapsedContent.hide();
this.loadingContent.show();
- axios
+ return axios
.get(this.diffForPath)
.then(({ data }) => {
this.loadingContent.hide();
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index 9f43ac36df7..bee9d7b8c2a 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -221,7 +221,10 @@ export default {
this.captchaResponse = captchaResponse;
if (this.captchaResponse) {
- // If the user solved the captcha resubmit the form.
+ // If the user solved the captcha, resubmit the form.
+ // NOTE: we do not need to clear out the captchaResponse and spamLogId
+ // data values after submit, because this component always does a full page reload.
+ // Otherwise, we would need to.
this.handleFormSubmit();
} else {
// If the user didn't solve the captcha (e.g. they just closed the modal),
diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
index 18a7d4ad218..e6aa3be0371 100644
--- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
@@ -55,7 +55,12 @@ export default {
>
<div class="d-flex align-items-center">
<gl-icon :size="16" :name="option.icon" />
- <span class="font-weight-bold ml-1 js-visibility-option">{{ option.label }}</span>
+ <span
+ class="font-weight-bold ml-1 js-visibility-option"
+ data-qa-selector="visibility_content"
+ :data-qa-visibility="option.label"
+ >{{ option.label }}</span
+ >
</div>
<template #help>{{
isProjectSnippet && option.description_project
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
index 4cabd943e22..5fb20b00705 100644
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ b/app/assets/javascripts/static_site_editor/constants.js
@@ -12,7 +12,7 @@ export const SUBMIT_CHANGES_MERGE_REQUEST_ERROR = s__(
'StaticSiteEditor|Could not create merge request.',
);
export const LOAD_CONTENT_ERROR = __(
- 'An error ocurred while loading your content. Please try again.',
+ 'An error occurred while loading your content. Please try again.',
);
export const DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE = s__(
diff --git a/app/assets/javascripts/tooltips/components/tooltips.vue b/app/assets/javascripts/tooltips/components/tooltips.vue
index 90bdf06bc4c..1ad18508294 100644
--- a/app/assets/javascripts/tooltips/components/tooltips.vue
+++ b/app/assets/javascripts/tooltips/components/tooltips.vue
@@ -82,9 +82,10 @@ export default {
},
triggerEvent(target, event) {
const tooltip = this.findTooltipByTarget(target);
+ const tooltipRef = this.$refs[tooltip?.id];
- if (tooltip) {
- this.$refs[tooltip.id][0].$emit(event);
+ if (tooltipRef) {
+ tooltipRef[0].$emit(event);
}
},
tooltipExists(element) {
@@ -113,6 +114,7 @@ export default {
:boundary="tooltip.boundary"
:disabled="tooltip.disabled"
:show="tooltip.show"
+ @hidden="$emit('hidden', tooltip)"
>
<span v-if="tooltip.html" v-safe-html:[$options.safeHtmlConfig]="tooltip.title"></span>
<span v-else>{{ tooltip.title }}</span>
diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js
index a9978c03a6e..f60c0759c72 100644
--- a/app/assets/javascripts/tooltips/index.js
+++ b/app/assets/javascripts/tooltips/index.js
@@ -92,6 +92,7 @@ export const hide = createTooltipApiInvoker((element) =>
export const show = createTooltipApiInvoker((element) =>
tooltipsApp().triggerEvent(element, 'open'),
);
+export const once = (event, cb) => tooltipsApp().$once(event, cb);
export const destroy = () => {
tooltipsApp().$destroy();
app = null;
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index 5d82d56f4ba..01de034417e 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -1,4 +1,16 @@
import { omitBy, isUndefined } from 'lodash';
+import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
+import { getExperimentData } from '~/experimentation/utils';
+
+const standardContext = { ...window.gl?.snowplowStandardContext };
+
+export const STANDARD_CONTEXT = {
+ schema: standardContext.schema,
+ data: {
+ ...(standardContext.data || {}),
+ source: 'gitlab-javascript',
+ },
+};
const DEFAULT_SNOWPLOW_OPTIONS = {
namespace: 'gl',
@@ -20,11 +32,17 @@ const createEventPayload = (el, { suffix = '' } = {}) => {
let value = el.dataset.trackValue || el.value || undefined;
if (el.type === 'checkbox' && !el.checked) value = false;
+ let context = el.dataset.trackContext;
+ if (el.dataset.trackExperiment) {
+ const data = getExperimentData(el.dataset.trackExperiment);
+ if (data) context = { schema: TRACKING_CONTEXT_SCHEMA, data };
+ }
+
const data = {
label: el.dataset.trackLabel,
property: el.dataset.trackProperty,
value,
- context: el.dataset.trackContext,
+ context,
};
return {
@@ -51,25 +69,51 @@ const eventHandlers = (category, func) => {
return handlers;
};
+const dispatchEvent = (category = document.body.dataset.page, action = 'generic', data = {}) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (!category) throw new Error('Tracking: no category provided for tracking.');
+
+ const { label, property, value } = data;
+ const contexts = [STANDARD_CONTEXT];
+
+ if (data.context) {
+ contexts.push(data.context);
+ }
+
+ return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
+};
+
export default class Tracking {
+ static queuedEvents = [];
+ static initialized = false;
+
static trackable() {
return !['1', 'yes'].includes(
window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack,
);
}
+ static flushPendingEvents() {
+ this.initialized = true;
+
+ while (this.queuedEvents.length) {
+ dispatchEvent(...this.queuedEvents.shift());
+ }
+ }
+
static enabled() {
return typeof window.snowplow === 'function' && this.trackable();
}
- static event(category = document.body.dataset.page, action = 'generic', data = {}) {
+ static event(...eventData) {
if (!this.enabled()) return false;
- // eslint-disable-next-line @gitlab/require-i18n-strings
- if (!category) throw new Error('Tracking: no category provided for tracking.');
- const { label, property, value, context } = data;
- const contexts = context ? [context] : undefined;
- return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
+ if (!this.initialized) {
+ this.queuedEvents.push(eventData);
+ return false;
+ }
+
+ return dispatchEvent(...eventData);
}
static bindDocument(category = document.body.dataset.page, parent = document) {
@@ -128,13 +172,15 @@ export function initUserTracking() {
window.snowplow('newTracker', opts.namespace, opts.hostname, opts);
document.dispatchEvent(new Event('SnowplowInitialized'));
+ Tracking.flushPendingEvents();
}
export function initDefaultTrackers() {
if (!Tracking.enabled()) return;
window.snowplow('enableActivityTracking', 30, 30);
- window.snowplow('trackPageView'); // must be after enableActivityTracking
+ // must be after enableActivityTracking
+ window.snowplow('trackPageView', null, [STANDARD_CONTEXT]);
if (window.snowplowOptions.formTracking) window.snowplow('enableFormTracking');
if (window.snowplowOptions.linkClickTracking) window.snowplow('enableLinkClickTracking');
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index c18f4fb46cc..21368edb6af 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -36,6 +36,7 @@ const populateUserInfo = (user) => {
if (userData) {
Object.assign(user, {
avatarUrl: userData.avatar_url,
+ bot: userData.bot,
username: userData.username,
name: userData.name,
location: userData.location,
@@ -59,11 +60,33 @@ const populateUserInfo = (user) => {
};
const initializedPopovers = new Map();
+let domObservedForChanges = false;
-export default (elements = document.querySelectorAll('.js-user-link')) => {
+const addPopoversToModifiedTree = new MutationObserver(() => {
+ const userLinks = document?.querySelectorAll('.js-user-link, .gfm-project_member');
+
+ if (userLinks) {
+ addPopovers(userLinks); /* eslint-disable-line no-use-before-define */
+ }
+});
+
+function observeBody() {
+ if (!domObservedForChanges) {
+ addPopoversToModifiedTree.observe(document.body, {
+ subtree: true,
+ childList: true,
+ });
+
+ domObservedForChanges = true;
+ }
+}
+
+export default function addPopovers(elements = document.querySelectorAll('.js-user-link')) {
const userLinks = Array.from(elements);
const UserPopoverComponent = Vue.extend(UserPopover);
+ observeBody();
+
return userLinks
.filter(({ dataset }) => dataset.user || dataset.userId)
.map((el) => {
@@ -105,4 +128,4 @@ export default (elements = document.querySelectorAll('.js-user-link')) => {
return renderedPopover;
});
-};
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
new file mode 100644
index 00000000000..d23c7f016fb
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
@@ -0,0 +1,92 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import MrCollapsibleExtension from '../mr_collapsible_extension.vue';
+
+export default {
+ components: {
+ Deployment: () => import('./deployment.vue'),
+ GlSprintf,
+ MrCollapsibleExtension,
+ },
+ props: {
+ deployments: {
+ type: Array,
+ required: true,
+ },
+ deploymentClass: {
+ type: String,
+ required: true,
+ },
+ hasDeploymentMetrics: {
+ type: Boolean,
+ required: true,
+ },
+ visualReviewAppMeta: {
+ type: Object,
+ required: false,
+ default: () => ({
+ sourceProjectId: '',
+ sourceProjectPath: '',
+ mergeRequestId: '',
+ appUrl: '',
+ }),
+ },
+ showVisualReviewAppLink: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ showCollapsedDeployments() {
+ return this.deployments.length > 3;
+ },
+ multipleDeploymentsTitle() {
+ return n__(
+ 'Deployments|%{deployments} environment impacted.',
+ 'Deployments|%{deployments} environments impacted.',
+ this.deployments.length,
+ );
+ },
+ },
+};
+</script>
+<template>
+ <mr-collapsible-extension
+ v-if="showCollapsedDeployments"
+ :title="__('View all environments.')"
+ data-testid="mr-collapsed-deployments"
+ >
+ <template #header>
+ <div class="gl-mr-3 gl-line-height-normal">
+ <gl-sprintf :message="multipleDeploymentsTitle">
+ <template #deployments>
+ <span class="gl-font-weight-bold gl-mr-2">{{ deployments.length }}</span>
+ </template>
+ </gl-sprintf>
+ </div>
+ </template>
+ <deployment
+ v-for="deployment in deployments"
+ :key="deployment.id"
+ :class="deploymentClass"
+ class="gl-bg-gray-50"
+ :deployment="deployment"
+ :show-metrics="hasDeploymentMetrics"
+ :show-visual-review-app="showVisualReviewAppLink"
+ :visual-review-app-meta="visualReviewAppMeta"
+ />
+ </mr-collapsible-extension>
+ <div v-else class="mr-widget-extension">
+ <deployment
+ v-for="deployment in deployments"
+ :key="deployment.id"
+ :class="deploymentClass"
+ :deployment="deployment"
+ :show-metrics="hasDeploymentMetrics"
+ :show-visual-review-app="showVisualReviewAppLink"
+ :visual-review-app-meta="visualReviewAppMeta"
+ />
+ </div>
+</template>
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 b6b5b56e5aa..a619ae9c351 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
@@ -50,9 +50,9 @@ export default {
<div class="mr-widget-extension d-flex align-items-center pl-3">
<div v-if="hasError" class="ci-widget media">
<div class="media-body">
- <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state">{{
- title
- }}</span>
+ <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state">
+ {{ title }}
+ </span>
</div>
</div>
@@ -67,16 +67,27 @@ export default {
<gl-loading-icon v-if="isLoading" />
<gl-icon v-else :name="arrowIconName" class="js-icon" />
</button>
+ <template v-if="isCollapsed">
+ <slot name="header"></slot>
+ <gl-button
+ variant="link"
+ data-testid="mr-collapsible-title"
+ :disabled="isLoading"
+ :class="{ 'border-0': isLoading }"
+ @click="toggleCollapsed"
+ >
+ {{ title }}
+ </gl-button>
+ </template>
<gl-button
+ v-else
variant="link"
- class="js-title"
+ data-testid="mr-collapsible-title"
:disabled="isLoading"
:class="{ 'border-0': isLoading }"
@click="toggleCollapsed"
+ >{{ __('Collapse') }}</gl-button
>
- <template v-if="isCollapsed">{{ title }}</template>
- <template v-else>{{ __('Collapse') }}</template>
- </gl-button>
</template>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index 71ff0fe6fbd..f1230e2fdeb 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
@@ -175,7 +175,7 @@ export default {
:href="mr.emailPatchesPath"
class="js-download-email-patches"
download
- data-qa-selector="download_email_patches"
+ data-qa-selector="download_email_patches_menu_item"
>
{{ s__('mrWidget|Email patches') }}
</gl-dropdown-item>
@@ -183,7 +183,7 @@ export default {
:href="mr.plainDiffPath"
class="js-download-plain-diff"
download
- data-qa-selector="download_plain_diff"
+ data-qa-selector="download_plain_diff_menu_item"
>
{{ s__('mrWidget|Plain diff') }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
index 7c50df5f104..7532eabee8a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
@@ -108,9 +108,7 @@ export default {
{{ $options.i18n.steps.step1.help }}
</p>
<div class="gl-display-flex">
- <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{
- mergeInfo1
- }}</pre>
+ <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo1 }}</pre>
<clipboard-button
:text="mergeInfo1"
:title="$options.i18n.copyCommands"
@@ -131,9 +129,7 @@ export default {
{{ $options.i18n.steps.step3.help }}
</p>
<div class="gl-display-flex">
- <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{
- mergeInfo2
- }}</pre>
+ <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo2 }}</pre>
<clipboard-button
:text="mergeInfo2"
:title="$options.i18n.copyCommands"
@@ -147,9 +143,7 @@ export default {
{{ $options.i18n.steps.step4.help }}
</p>
<div class="gl-display-flex">
- <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{
- mergeInfo3
- }}</pre>
+ <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo3 }}</pre>
<clipboard-button
:text="mergeInfo3"
:title="$options.i18n.copyCommands"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
deleted file mode 100644
index 3cd003461b3..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<script>
-import { GlButton, GlModalDirective } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
-
-export default {
- name: 'MRWidgetMergeHelp',
- components: {
- GlButton,
- },
- directives: {
- GlModalDirective,
- },
- props: {
- missingBranch: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- missingBranchInfo() {
- return sprintf(
- s__(
- 'mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the',
- ),
- { branch: this.missingBranch },
- );
- },
- },
-};
-</script>
-<template>
- <section class="gl-py-3 gl-pr-3 gl-pl-5 gl-ml-7 mr-widget-help gl-font-style-italic">
- <template v-if="missingBranch">
- {{ missingBranchInfo }}
- </template>
- <template v-else>
- {{ s__('mrWidget|You can merge this merge request manually using the') }}
- </template>
-
- <gl-button
- v-gl-modal-directive="'modal-merge-info'"
- variant="link"
- class="gl-mt-n2 js-open-modal-help"
- >
- {{ s__('mrWidget|command line') }}
- </gl-button>
- </section>
-</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 d022579ef54..3419abd4738 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
@@ -11,10 +11,11 @@ import {
} from '@gitlab/ui';
import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline';
import { s__, n__ } from '~/locale';
+import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
-import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import { MT_MERGE_STRATEGY } from '../constants';
export default {
name: 'MRWidgetPipeline',
@@ -26,7 +27,7 @@ export default {
GlSprintf,
GlTooltip,
PipelineArtifacts,
- PipelineStage,
+ PipelineMiniGraph,
TooltipOnTruncate,
LinkedPipelinesMiniList: () =>
import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
@@ -80,6 +81,11 @@ export default {
type: String,
required: true,
},
+ mergeStrategy: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
hasPipeline() {
@@ -94,9 +100,7 @@ export default {
: {};
},
hasStages() {
- return (
- this.pipeline.details && this.pipeline.details.stages && this.pipeline.details.stages.length
- );
+ return this.pipeline?.details?.stages?.length > 0;
},
hasCommitInfo() {
return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0;
@@ -130,6 +134,9 @@ export default {
this.buildsWithCoverage.length,
);
},
+ isMergeTrain() {
+ return this.mergeStrategy === MT_MERGE_STRATEGY;
+ },
},
errorText: s__(
'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.',
@@ -242,19 +249,13 @@ export default {
<span class="mr-widget-pipeline-graph">
<span class="stage-cell">
<linked-pipelines-mini-list v-if="triggeredBy.length" :triggered-by="triggeredBy" />
- <template v-if="hasStages">
- <div
- v-for="(stage, i) in pipeline.details.stages"
- :key="i"
- :class="{
- 'has-downstream': hasDownstream(i),
- }"
- class="stage-container dropdown mr-widget-pipeline-stages"
- data-testid="widget-mini-pipeline-graph"
- >
- <pipeline-stage :stage="stage" />
- </div>
- </template>
+ <pipeline-mini-graph
+ v-if="hasStages"
+ class="gl-display-inline-block"
+ stages-class="mr-widget-pipeline-stages"
+ :stages="pipeline.details.stages"
+ :is-merge-train="isMergeTrain"
+ />
</span>
<linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" />
<pipeline-artifacts
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 2bf86c1863a..c24ae92db4f 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
@@ -1,8 +1,11 @@
<script>
import { isNumber } from 'lodash';
import { sanitize } from '~/lib/dompurify';
+import { n__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import MergeRequestStore from '../stores/mr_widget_store';
import ArtifactsApp from './artifacts_list_app.vue';
+import DeploymentList from './deployment/deployment_list.vue';
import MrWidgetContainer from './mr_widget_container.vue';
import MrWidgetPipeline from './mr_widget_pipeline.vue';
@@ -18,7 +21,7 @@ export default {
name: 'MrWidgetPipelineContainer',
components: {
ArtifactsApp,
- Deployment: () => import('./deployment/deployment.vue'),
+ DeploymentList,
MrWidgetContainer,
MrWidgetPipeline,
MergeTrainPositionIndicator: () =>
@@ -64,11 +67,32 @@ export default {
return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline;
},
showVisualReviewAppLink() {
- return this.mr.visualReviewAppAvailable && this.glFeatures.anonymousVisualReviewFeedback;
+ return Boolean(
+ this.mr.visualReviewAppAvailable && this.glFeatures.anonymousVisualReviewFeedback,
+ );
},
showMergeTrainPositionIndicator() {
return isNumber(this.mr.mergeTrainIndex);
},
+ showCollapsedDeployments() {
+ return this.deployments.length > 3;
+ },
+ multipleDeploymentsTitle() {
+ return n__(
+ 'Deployments|%{deployments} environment impacted.',
+ 'Deployments|%{deployments} environments impacted.',
+ this.deployments.length,
+ );
+ },
+ preferredAutoMergeStrategy() {
+ if (this.glFeatures.mergeRequestWidgetGraphql) {
+ return MergeRequestStore.getPreferredAutoMergeStrategy(
+ this.mr.availableAutoMergeStrategies,
+ );
+ }
+
+ return this.mr.preferredAutoMergeStrategy;
+ },
},
};
</script>
@@ -85,22 +109,20 @@ export default {
:source-branch-link="branchLink"
:mr-troubleshooting-docs-path="mr.mrTroubleshootingDocsPath"
:ci-troubleshooting-docs-path="mr.ciTroubleshootingDocsPath"
+ :merge-strategy="preferredAutoMergeStrategy"
/>
<template #footer>
<div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts">
<artifacts-app :endpoint="mr.exposedArtifactsPath" />
</div>
- <div v-if="deployments.length" class="mr-widget-extension">
- <deployment
- v-for="deployment in deployments"
- :key="deployment.id"
- :class="deploymentClass"
- :deployment="deployment"
- :show-metrics="hasDeploymentMetrics"
- :show-visual-review-app="showVisualReviewAppLink"
- :visual-review-app-meta="visualReviewAppMeta"
- />
- </div>
+ <deployment-list
+ v-if="deployments.length"
+ :deployments="deployments"
+ :deployment-class="deploymentClass"
+ :has-deployment-metrics="hasDeploymentMetrics"
+ :visual-review-app-meta="visualReviewAppMeta"
+ :show-visual-review-app-link="showVisualReviewAppLink"
+ />
<merge-train-position-indicator
v-if="showMergeTrainPositionIndicator"
class="mr-widget-extension"
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 428641a1109..84a21a25552 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
@@ -154,7 +154,7 @@ export default {
<status-icon status="success" />
<div class="media-body">
<h4 class="gl-display-flex">
- <span class="gl-mr-3">
+ <span class="gl-mr-3" data-qa-selector="merge_request_status_content">
<span class="js-status-text-before-author" data-testid="beforeStatusText">{{
statusTextBeforeAuthor
}}</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 2335e2984e4..23f415c3116 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -1,9 +1,6 @@
<script>
-import { GlButton, GlModalDirective, GlSkeletonLoader } from '@gitlab/ui';
-import $ from 'jquery';
-import { escape } from 'lodash';
-import { s__, sprintf } from '~/locale';
-import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover';
+import { GlButton, GlModalDirective, GlSkeletonLoader, GlPopover, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import userPermissionsQuery from '../../queries/permissions.query.graphql';
@@ -16,6 +13,8 @@ export default {
GlSkeletonLoader,
StatusIcon,
GlButton,
+ GlPopover,
+ GlLink,
},
directives: {
GlModalDirective,
@@ -106,48 +105,11 @@ export default {
return this.showResolveButton && this.sourceBranchProtected;
},
},
- watch: {
- showPopover: {
- handler(newVal) {
- if (newVal) {
- this.$nextTick(this.initPopover);
- }
- },
- immediate: true,
- },
- },
- methods: {
- initPopover() {
- const $el = $(this.$refs.popover);
-
- $el
- .popover({
- html: true,
- trigger: 'focus',
- container: 'body',
- placement: 'top',
- template:
- '<div class="popover" role="tooltip"><div class="arrow"></div><p class="popover-header"></p><div class="popover-body"></div></div>',
- title: s__(
- 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.',
- ),
- content: sprintf(
- s__('mrWidget|%{link_start}Learn more about resolving conflicts%{link_end}'),
- {
- link_start: `<a href="${escape(
- this.mr.conflictsDocsPath,
- )}" target="_blank" rel="noopener noreferrer">`,
- link_end: '</a>',
- },
- false,
- ),
- })
- .on('mouseenter', mouseenter)
- .on('mouseleave', debouncedMouseleave(300))
- .on('show.bs.popover', () => {
- window.addEventListener('scroll', togglePopover.bind($el, false), { once: true });
- });
- },
+ i18n: {
+ title: s__(
+ 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.',
+ ),
+ linkText: s__('mrWidget|Learn more about resolving conflicts'),
},
};
</script>
@@ -162,7 +124,7 @@ export default {
<rect x="250" y="7" width="84" height="16" rx="4" />
</gl-skeleton-loader>
</div>
- <div v-else class="media-body space-children">
+ <div v-else class="media-body space-children gl-display-flex gl-align-items-center">
<span v-if="shouldBeRebased" class="bold">
{{
s__(`mrWidget|Fast-forward merge is not possible.
@@ -181,17 +143,35 @@ export default {
</span>
<span v-if="showResolveButton" ref="popover">
<gl-button
- :href="!sourceBranchProtected && mr.conflictResolutionPath"
+ :href="mr.conflictResolutionPath"
:disabled="sourceBranchProtected"
- class="js-resolve-conflicts-button"
+ data-testid="resolve-conflicts-button"
>
{{ s__('mrWidget|Resolve conflicts') }}
</gl-button>
+ <gl-popover
+ v-if="showPopover"
+ :target="() => $refs.popover"
+ placement="top"
+ triggers="hover focus"
+ >
+ <template #title>
+ <div class="gl-font-weight-normal gl-font-base">
+ {{ $options.i18n.title }}
+ </div>
+ </template>
+
+ <div class="gl-text-center">
+ <gl-link :href="mr.conflictsDocsPath" target="_blank" rel="noopener noreferrer">
+ {{ $options.i18n.linkText }}
+ </gl-link>
+ </div>
+ </gl-popover>
</span>
<gl-button
v-if="canMerge"
v-gl-modal-directive="'modal-merge-info'"
- class="js-merge-locally-button"
+ data-testid="merge-locally-button"
>
{{ s__('mrWidget|Merge locally') }}
</gl-button>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index d15794c71b1..33ca582583b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -176,16 +176,22 @@ export default {
<gl-button
:loading="isMakingRequest"
variant="success"
- class="qa-mr-rebase-button"
+ data-qa-selector="mr_rebase_button"
@click="rebase"
>
{{ __('Rebase') }}
</gl-button>
- <span v-if="!rebasingError" class="gl-font-weight-bold" data-testid="rebase-message">{{
- __(
- 'Fast-forward merge is not possible. Rebase the source branch onto the target branch.',
- )
- }}</span>
+ <span
+ v-if="!rebasingError"
+ class="gl-font-weight-bold"
+ data-testid="rebase-message"
+ data-qa-selector="no_fast_forward_message_content"
+ >{{
+ __(
+ 'Fast-forward merge is not possible. Rebase the source branch onto the target branch.',
+ )
+ }}</span
+ >
<span v-else class="gl-font-weight-bold danger" data-testid="rebase-message">{{
rebasingError
}}</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
index f0259a975db..01e0b91bd4a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
@@ -1,12 +1,15 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
import emptyStateSVG from 'icons/_mr_widget_empty_state.svg';
+import { helpPagePath } from '~/helpers/help_page_helper';
export default {
name: 'MRWidgetNothingToMerge',
components: {
GlButton,
+ GlSprintf,
+ GlLink,
},
props: {
mr: {
@@ -17,6 +20,7 @@ export default {
data() {
return { emptyStateSVG };
},
+ ciHelpPage: helpPagePath('/ci/quick_start/index.html'),
};
</script>
@@ -30,25 +34,20 @@ export default {
</div>
<div class="text col-md-7 order-md-first col-12">
<p class="highlight">
- {{
- s__(
- 'mrWidgetNothingToMerge|Merge requests are a place to propose changes you have made to a project and discuss those changes with others.',
- )
- }}
+ {{ s__('mrWidgetNothingToMerge|This merge request contains no changes.') }}
</p>
<p>
- {{
- s__(
- 'mrWidgetNothingToMerge|Interested parties can even contribute by pushing commits if they want to.',
- )
- }}
- </p>
- <p>
- {{
- s__(
- "mrWidgetNothingToMerge|Currently there are no changes in this merge request's source branch. Please push new commits or use a different branch.",
- )
- }}
+ <gl-sprintf
+ :message="
+ s__(
+ 'mrWidgetNothingToMerge|Use merge requests to propose changes to your project and discuss them with your team. To make changes, push a commit or edit this merge request to use a different branch. With %{linkStart}CI/CD%{linkEnd}, automatically test your changes before merging.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="$options.ciHelpPage" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</p>
<div>
<gl-button
@@ -56,6 +55,7 @@ export default {
:href="mr.newBlobPath"
category="secondary"
variant="success"
+ data-testid="createFileButton"
>
{{ __('Create file') }}
</gl-button>
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 690b6e9c462..62c5cd90035 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
@@ -5,6 +5,7 @@ import {
GlButtonGroup,
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
GlSprintf,
GlLink,
GlTooltipDirective,
@@ -81,6 +82,7 @@ export default {
GlButtonGroup,
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
GlSkeletonLoader,
MergeTrainHelperText: () =>
import('ee_component/vue_merge_request_widget/components/merge_train_helper_text.vue'),
@@ -453,12 +455,13 @@ export default {
<div class="mr-widget-body media" :class="{ 'gl-pb-3': shouldRenderMergeTrainHelperText }">
<status-icon :status="iconClass" />
<div class="media-body">
- <div class="mr-widget-body-controls media space-children">
- <gl-button-group>
+ <div class="mr-widget-body-controls gl-display-flex gl-align-items-center">
+ <gl-button-group class="gl-align-self-start">
<gl-button
size="medium"
category="primary"
- class="qa-merge-button accept-merge-request"
+ class="accept-merge-request"
+ data-testid="merge-button"
:variant="mergeButtonVariant"
:disabled="isMergeButtonDisabled"
:loading="isMakingRequest"
@@ -481,7 +484,7 @@ export default {
<gl-dropdown-item
icon-name="warning"
button-class="accept-merge-request js-merge-immediately-button"
- data-qa-selector="merge_immediately_option"
+ data-qa-selector="merge_immediately_menu_item"
@click="handleMergeImmediatelyButtonClick"
>
{{ __('Merge immediately') }}
@@ -493,47 +496,48 @@ export default {
/>
</gl-dropdown>
</gl-button-group>
- <div class="media-body-wrap space-children">
- <template v-if="shouldShowMergeControls">
- <label v-if="canRemoveSourceBranch">
- <input
- id="remove-source-branch-input"
- v-model="removeSourceBranch"
- :disabled="isRemoveSourceBranchButtonDisabled"
- class="js-remove-source-branch-checkbox"
- type="checkbox"
- />
- {{ __('Delete source branch') }}
- </label>
-
- <!-- Placeholder for EE extension of this component -->
- <squash-before-merge
- v-if="shouldShowSquashBeforeMerge"
- v-model="squashBeforeMerge"
- :help-path="mr.squashBeforeMergeHelpPath"
- :is-disabled="isSquashReadOnly"
- />
- </template>
- <template v-else>
- <div class="bold js-resolve-mr-widget-items-message">
- <div
- v-if="hasPipelineMustSucceedConflict"
- class="gl-display-flex gl-align-items-center"
- data-testid="pipeline-succeed-conflict"
+ <div
+ v-if="shouldShowMergeControls"
+ class="gl-display-flex gl-align-items-center gl-flex-wrap"
+ >
+ <gl-form-checkbox
+ v-if="canRemoveSourceBranch"
+ id="remove-source-branch-input"
+ v-model="removeSourceBranch"
+ :disabled="isRemoveSourceBranchButtonDisabled"
+ class="js-remove-source-branch-checkbox gl-mx-3 gl-display-flex gl-align-items-center"
+ >
+ {{ __('Delete source branch') }}
+ </gl-form-checkbox>
+
+ <!-- Placeholder for EE extension of this component -->
+ <squash-before-merge
+ v-if="shouldShowSquashBeforeMerge"
+ v-model="squashBeforeMerge"
+ :help-path="mr.squashBeforeMergeHelpPath"
+ :is-disabled="isSquashReadOnly"
+ class="gl-mx-3"
+ />
+ </div>
+ <template v-else>
+ <div class="bold js-resolve-mr-widget-items-message gl-ml-3">
+ <div
+ v-if="hasPipelineMustSucceedConflict"
+ class="gl-display-flex gl-align-items-center"
+ data-testid="pipeline-succeed-conflict"
+ >
+ <gl-sprintf :message="pipelineMustSucceedConflictText" />
+ <gl-link
+ :href="mr.pipelineMustSucceedDocsPath"
+ target="_blank"
+ class="gl-display-flex gl-ml-2"
>
- <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" />
+ <gl-icon name="question" />
+ </gl-link>
</div>
- </template>
- </div>
+ <gl-sprintf v-else :message="mergeDisabledText" />
+ </div>
+ </template>
</div>
<div v-if="isSHAMismatch" class="d-flex align-items-center mt-2 js-sha-mismatch">
<gl-icon name="warning-solid" class="text-warning mr-1" />
@@ -559,7 +563,11 @@ export default {
:merge-train-when-pipeline-succeeds-docs-path="mr.mergeTrainWhenPipelineSucceedsDocsPath"
/>
<template v-if="shouldShowMergeControls">
- <div v-if="!shouldShowMergeEdit" class="mr-fast-forward-message">
+ <div
+ v-if="!shouldShowMergeEdit"
+ class="mr-fast-forward-message"
+ data-qa-selector="fast_forward_message_content"
+ >
{{ __('Fast-forward merge without a merge commit') }}
</div>
<commits-header
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
index 8acca0d6481..89edf588213 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -13,7 +13,7 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
- <span class="bold">
+ <span class="bold" data-qa-selector="head_mismatch_content">
{{
s__(`mrWidget|The source branch HEAD has recently changed.
Please reload the page and review the changes before merging`)
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index 12fdfe601a4..6388b817e46 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
@@ -44,7 +44,7 @@ export default {
:checked="value"
:disabled="isDisabled"
name="squash"
- class="qa-squash-checkbox js-squash-checkbox gl-mb-0 gl-mr-2"
+ class="qa-squash-checkbox js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center"
:title="tooltipTitle"
@change="(checked) => $emit('input', checked)"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index af305815381..f0c624c5d8d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -70,7 +70,7 @@ export default {
data: {
mergeRequestSetWip: {
errors,
- mergeRequest: { workInProgress, title },
+ mergeRequest: { mergeableDiscussionsState, workInProgress, title },
},
},
},
@@ -87,9 +87,8 @@ export default {
});
const data = produce(sourceData, (draftState) => {
- // eslint-disable-next-line no-param-reassign
+ draftState.project.mergeRequest.mergeableDiscussionsState = mergeableDiscussionsState;
draftState.project.mergeRequest.workInProgress = workInProgress;
- // eslint-disable-next-line no-param-reassign
draftState.project.mergeRequest.title = title;
});
@@ -107,6 +106,7 @@ export default {
errors: [],
mergeRequest: {
__typename: 'MergeRequest',
+ mergeableDiscussionsState: true,
title: this.mr.title,
workInProgress: false,
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
index 96e8bb45e34..7b77d7475bc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
@@ -7,9 +7,4 @@ export default {
return [];
},
},
- methods: {
- hasDownstream() {
- return false;
- },
- },
};
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 2a53eb8b241..89b095fbfc1 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
@@ -14,11 +14,10 @@ import { deprecatedCreateFlash as createFlash } from '../flash';
import { setFaviconOverlay } from '../lib/utils/favicon';
import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue';
-import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
+import GroupedTestReportsApp from '../reports/grouped_test_report/grouped_test_reports_app.vue';
import Loading from './components/loading.vue';
import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue';
import WidgetHeader from './components/mr_widget_header.vue';
-import WidgetMergeHelp from './components/mr_widget_merge_help.vue';
import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue';
import WidgetRelatedLinks from './components/mr_widget_related_links.vue';
import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue';
@@ -59,7 +58,6 @@ export default {
// ExtensionsContainer,
'mr-widget-header': WidgetHeader,
'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
- 'mr-widget-merge-help': WidgetMergeHelp,
MrWidgetPipelineContainer,
'mr-widget-related-links': WidgetRelatedLinks,
MrWidgetAlertMessage,
@@ -140,9 +138,6 @@ export default {
componentName() {
return stateMaps.stateToComponentMap[this.mr.state];
},
- shouldRenderMergeHelp() {
- return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1;
- },
hasPipelineMustSucceedConflict() {
return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds;
},
@@ -530,9 +525,6 @@ export default {
<source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
</div>
</div>
- <div v-if="shouldRenderMergeHelp" class="mr-widget-footer">
- <mr-widget-merge-help />
- </div>
</div>
<mr-widget-pipeline-container
v-if="shouldRenderMergedPipeline"
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql
index 37abe5ddf3c..cfaa198d516 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql
@@ -1,6 +1,7 @@
mutation toggleWIPStatus($projectPath: ID!, $iid: String!, $wip: Boolean!) {
mergeRequestSetWip(input: { projectPath: $projectPath, iid: $iid, wip: $wip }) {
mergeRequest {
+ mergeableDiscussionsState
title
workInProgress
}
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 a0f14f558d2..7ccbd771379 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
@@ -177,7 +177,7 @@ export default class MergeRequestStore {
this.ciStatus = `${this.ciStatus}-with-warnings`;
}
- this.commitsCount = mergeRequest.commitCount || 10;
+ this.commitsCount = mergeRequest.commitCount;
this.branchMissing = !mergeRequest.sourceBranchExists || !mergeRequest.targetBranchExists;
this.hasConflicts = mergeRequest.conflicts;
this.hasMergeableDiscussionsState = mergeRequest.mergeableDiscussionsState === false;
@@ -241,6 +241,7 @@ export default class MergeRequestStore {
this.reviewingDocsPath = data.reviewing_and_managing_merge_requests_docs_path;
this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
+ this.licenseComplianceDocsPath = data.license_compliance_docs_path;
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
this.approvalsHelpPath = data.approvals_help_path;
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
index 0af5d028a2a..f7b49a85b83 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
@@ -11,12 +11,12 @@ import {
GlButton,
GlSafeHtmlDirective,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import { fetchPolicies } from '~/lib/graphql';
import { toggleContainerClasses } from '~/lib/utils/dom_utils';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
import Tracking from '~/tracking';
import initUserPopovers from '~/user_popovers';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
@@ -222,7 +222,9 @@ export default {
});
},
incidentPath(issueId) {
- return joinPaths(this.projectIssuesPath, issueId);
+ return this.isThreatMonitoringPage
+ ? joinPaths(this.projectIssuesPath, issueId)
+ : joinPaths(this.projectIssuesPath, 'incident', issueId);
},
trackPageViews() {
const { category, action } = this.trackAlertsDetailsViewsOptions;
@@ -268,10 +270,10 @@ export default {
</span>
</div>
<gl-button
- v-if="alert.issueIid"
+ v-if="alert.issue"
class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-incident-button"
data-testid="viewIncidentBtn"
- :href="incidentPath(alert.issueIid)"
+ :href="incidentPath(alert.issue.iid)"
category="primary"
variant="success"
>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
index dd4faa03c00..9d5006564ef 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
@@ -1,7 +1,7 @@
<script>
+import * as Sentry from '@sentry/browser';
import Vue from 'vue';
import Vuex from 'vuex';
-import * as Sentry from '~/sentry/wrapper';
Vue.use(Vuex);
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
index 39ac6c7feca..271f0b4e4bb 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
@@ -116,7 +116,6 @@ export default {
});
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.project.alertManagementAlerts.nodes[0].todos.nodes = [];
});
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
index 3ea43d7a843..50f2e63702b 100644
--- a/app/assets/javascripts/vue_shared/alert_details/index.js
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -20,7 +20,6 @@ export default (selector) => {
toggleSidebarStatus: (_, __, { cache }) => {
const sourceData = cache.readQuery({ query: sidebarStatusQuery });
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.sidebarStatus = !draftData.sidebarStatus;
});
cache.writeQuery({ query: sidebarStatusQuery, data });
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index ce67d33d4a1..82b3545117f 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -2,7 +2,9 @@
/* eslint-disable vue/no-v-html */
import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { groupBy } from 'lodash';
+import EmojiPicker from '~/emoji/components/picker.vue';
import { __, sprintf } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { glEmojiTag } from '../../emoji';
// Internal constant, specific to this component, used when no `currentUserId` is given
@@ -12,10 +14,12 @@ export default {
components: {
GlButton,
GlIcon,
+ EmojiPicker,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
awards: {
type: Array,
@@ -166,7 +170,25 @@ export default {
<span class="js-counter">{{ awardList.list.length }}</span>
</gl-button>
<div v-if="canAwardEmoji" class="award-menu-holder">
+ <emoji-picker
+ v-if="glFeatures.improvedEmojiPicker"
+ toggle-class="add-reaction-button gl-relative!"
+ @click="handleAward"
+ >
+ <template #button-content>
+ <span class="reaction-control-icon reaction-control-icon-neutral">
+ <gl-icon name="slight-smile" />
+ </span>
+ <span class="reaction-control-icon reaction-control-icon-positive">
+ <gl-icon name="smiley" />
+ </span>
+ <span class="reaction-control-icon reaction-control-icon-super-positive">
+ <gl-icon name="smile" />
+ </span>
+ </template>
+ </emoji-picker>
<gl-button
+ v-else
v-gl-tooltip.viewport
:class="addButtonClass"
class="add-reaction-button js-add-award"
diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
index 14e99977a85..4b53f55b856 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -82,7 +82,13 @@ export default {
data-qa-selector="changed_file_icon_content"
:data-qa-title="tooltipTitle"
>
- <gl-icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" />
+ <gl-icon
+ v-if="showIcon"
+ :name="changedIcon"
+ :size="size"
+ :class="changedIconClass"
+ use-deprecated-sizes
+ />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index 07bd6019b80..dbf459cb289 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -64,6 +64,12 @@ export default {
</script>
<template>
<span :class="cssClass">
- <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" />
+ <gl-icon
+ :name="icon"
+ :size="size"
+ :class="cssClasses"
+ :aria-label="status.icon"
+ use-deprecated-sizes
+ />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index bf1361f1a6a..fe329b18f30 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -46,6 +46,11 @@ export default {
required: false,
default: false,
},
+ tooltipBoundary: {
+ type: String,
+ required: false,
+ default: null,
+ },
cssClass: {
type: String,
required: false,
@@ -75,8 +80,11 @@ export default {
<template>
<gl-button
- v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
- v-gl-tooltip.hover.blur
+ v-gl-tooltip.hover.blur.viewport="{
+ placement: tooltipPlacement,
+ container: tooltipContainer,
+ boundary: tooltipBoundary,
+ }"
:class="cssClass"
:title="title"
:data-clipboard-text="clipboardText"
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
index d6f99e9a049..b3edd05b0ee 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
@@ -37,9 +37,11 @@ export default {
required: true,
},
},
- data: () => ({
- state: STATE_IDLING,
- }),
+ data() {
+ return {
+ state: STATE_IDLING,
+ };
+ },
computed: {
shortSha() {
return truncateSha(this.diffFile.content_sha);
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
index 8ac8a3beb7d..4244cab902a 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -86,7 +86,7 @@ export default {
<template>
<span>
<gl-loading-icon v-if="loading" :inline="true" />
- <gl-icon v-else-if="isSymlink" name="symlink" :size="size" />
+ <gl-icon v-else-if="isSymlink" name="symlink" :size="size" use-deprecated-sizes />
<svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]">
<use v-bind="{ 'xlink:href': spriteHref }" />
</svg>
@@ -95,6 +95,7 @@ export default {
:name="folderIconName"
:size="size"
class="folder-icon"
+ use-deprecated-sizes
data-qa-selector="folder_icon_content"
/>
</span>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 80ca62a0e9b..b4cac13168a 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -102,7 +102,7 @@ export default {
data-qa-selector="pipeline_header"
data-testid="ci-header-content"
>
- <section class="header-main-content">
+ <section class="header-main-content gl-mr-3">
<ci-icon-badge :status="status" />
<strong data-testid="ci-header-item-text"> {{ itemName }} #{{ itemId }} </strong>
@@ -142,12 +142,16 @@ export default {
</template>
</section>
- <section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex">
+ <section
+ v-if="$slots.default"
+ data-testid="ci-header-action-buttons"
+ class="gl-display-flex gl-mr-3"
+ >
<slot></slot>
</section>
<gl-button
v-if="hasSidebarButton"
- class="d-sm-none js-sidebar-build-toggle gl-ml-auto"
+ class="gl-md-display-none gl-ml-auto gl-align-self-start js-sidebar-build-toggle"
icon="chevron-double-lg-left"
:aria-label="__('Toggle sidebar')"
@click="onClickSidebarButton"
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 4c6fa71398d..7c28e74e256 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
@@ -62,9 +62,6 @@ export default {
canBeBatched() {
return Boolean(this.glFeatures.batchSuggestions);
},
- canAddCustomCommitMessage() {
- return this.glFeatures.suggestionsCustomCommit;
- },
isApplying() {
return this.isApplyingSingle || this.isApplyingBatch;
},
@@ -89,11 +86,7 @@ export default {
if (!this.canApply) return;
this.isApplyingSingle = true;
- this.$emit(
- 'apply',
- this.applySuggestionCallback,
- gon.features?.suggestionsCustomCommit ? message : undefined,
- );
+ this.$emit('apply', this.applySuggestionCallback, message);
},
applySuggestionCallback() {
this.isApplyingSingle = false;
@@ -114,7 +107,7 @@ export default {
<template>
<div class="md-suggestion-header border-bottom-0 mt-2">
- <div class="qa-suggestion-diff-header js-suggestion-diff-header font-weight-bold">
+ <div class="js-suggestion-diff-header font-weight-bold">
{{ __('Suggested change') }}
<a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn">
<gl-icon name="question-o" css-classes="link-highlight" />
@@ -158,23 +151,12 @@ export default {
{{ __('Add suggestion to batch') }}
</gl-button>
<apply-suggestion
- v-if="canAddCustomCommitMessage"
+ v-if="isLoggedIn"
:disabled="isDisableButton"
:default-commit-message="defaultCommitMessage"
class="gl-ml-3"
@apply="applySuggestion"
/>
- <span v-else v-gl-tooltip.viewport="tooltipMessage" tabindex="0">
- <gl-button
- v-if="isLoggedIn"
- class="btn-inverted js-apply-btn btn-grouped"
- :disabled="isDisableButton"
- variant="success"
- @click="applySuggestion"
- >
- {{ __('Apply suggestion') }}
- </gl-button>
- </span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
new file mode 100644
index 00000000000..35f9ac14681
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
@@ -0,0 +1,3 @@
+<template>
+ <div class="timeline-icon"><slot></slot></div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue b/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue
index bc7f8a2b17a..1a85a641dd1 100644
--- a/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue
@@ -56,27 +56,29 @@ export default {
</script>
<template>
- <div v-if="!multiline" class="gl-mb-3">
+ <div>
<label v-if="label" :for="generateFormId('instruction-input')">{{ label }}</label>
- <div class="input-group gl-mb-3">
- <input
- :id="generateFormId('instruction-input')"
- :value="instruction"
- type="text"
- class="form-control gl-font-monospace"
- data-testid="instruction-input"
- readonly
- @copy="trackCopy"
- />
- <span class="input-group-append" data-testid="instruction-button" @click="trackCopy">
- <clipboard-button :text="instruction" :title="copyText" class="input-group-text" />
- </span>
+ <div v-if="!multiline" class="gl-mb-3">
+ <div class="input-group gl-mb-3">
+ <input
+ :id="generateFormId('instruction-input')"
+ :value="instruction"
+ type="text"
+ class="form-control gl-font-monospace"
+ data-testid="instruction-input"
+ readonly
+ @copy="trackCopy"
+ />
+ <span class="input-group-append" data-testid="instruction-button" @click="trackCopy">
+ <clipboard-button :text="instruction" :title="copyText" class="input-group-text" />
+ </span>
+ </div>
</div>
- </div>
- <div v-else>
- <pre class="gl-font-monospace" data-testid="multiline-instruction" @copy="trackCopy">{{
- instruction
- }}</pre>
+ <div v-else>
+ <pre class="gl-font-monospace" data-testid="multiline-instruction" @copy="trackCopy">{{
+ instruction
+ }}</pre>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index 9db5d6953d7..4ade75e705e 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -54,7 +54,7 @@ export default {
class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1"
:class="optionalClasses"
>
- <div class="gl-display-flex gl-align-items-center gl-py-3">
+ <div class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5">
<div
v-if="$slots['left-action']"
class="gl-w-7 gl-display-none gl-sm-display-flex gl-justify-content-start gl-pl-2"
diff --git a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
new file mode 100644
index 00000000000..36b1a9c49f4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+
+export default {
+ name: 'PersistedDropdownSelection',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ LocalStorageSync,
+ },
+ props: {
+ options: {
+ type: Array,
+ required: true,
+ },
+ storageKey: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selected: null,
+ };
+ },
+ computed: {
+ dropdownText() {
+ const selected = this.parsedOptions.find((o) => o.selected);
+ return selected?.label || this.options[0].label;
+ },
+ parsedOptions() {
+ return this.options.map((o) => ({ ...o, selected: o.value === this.selected }));
+ },
+ },
+ methods: {
+ setSelected(value) {
+ this.selected = value;
+ this.$emit('change', value);
+ },
+ },
+};
+</script>
+
+<template>
+ <local-storage-sync :storage-key="storageKey" :value="selected" @input="setSelected">
+ <gl-dropdown :text="dropdownText" lazy>
+ <gl-dropdown-item
+ v-for="option in parsedOptions"
+ :key="option.value"
+ :is-checked="option.selected"
+ :is-check-item="true"
+ @click="setSelected(option.value)"
+ >
+ {{ option.label }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </local-storage-sync>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue
index 6574a5ddfde..bb1a8fae7b0 100644
--- a/app/assets/javascripts/vue_shared/components/select2_select.vue
+++ b/app/assets/javascripts/vue_shared/components/select2_select.vue
@@ -20,6 +20,12 @@ export default {
},
},
+ watch: {
+ value() {
+ $(this.$refs.dropdownInput).val(this.value).trigger('change');
+ },
+ },
+
mounted() {
loadCSSFile(gon.select2_css_path)
.then(() => {
diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
index 31094b985a2..92ae4575c52 100644
--- a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
+++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
@@ -5,6 +5,11 @@ import { __ } from '~/locale';
export default {
components: { GlButton },
props: {
+ slideAnimated: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
defaultExpanded: {
type: Boolean,
default: false,
@@ -28,7 +33,7 @@ export default {
</script>
<template>
- <section class="settings no-animate" :class="{ expanded }">
+ <section class="settings" :class="{ 'no-animate': !slideAnimated, expanded }">
<div class="settings-header">
<h4><slot name="title"></slot></h4>
<gl-button @click="sectionExpanded = !sectionExpanded">
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
index f173c8db540..46ccb9470e5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
@@ -21,11 +21,14 @@ export default {
'allowLabelRemove',
'allowScopedLabels',
'labelsFilterBasePath',
+ 'labelsFilterParam',
]),
},
methods: {
labelFilterUrl(label) {
- return `${this.labelsFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`;
+ return `${this.labelsFilterBasePath}?${this.labelsFilterParam}[]=${encodeURIComponent(
+ label.title,
+ )}`;
},
scopedLabel(label) {
return this.allowScopedLabels && isScopedLabel(label);
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 93fdae19a8d..426ae430ce7 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
@@ -81,6 +81,11 @@ export default {
required: false,
default: '',
},
+ labelsFilterParam: {
+ type: String,
+ required: false,
+ default: 'label_name',
+ },
dropdownButtonText: {
type: String,
required: false,
@@ -156,6 +161,7 @@ export default {
labelsFetchPath: this.labelsFetchPath,
labelsManagePath: this.labelsManagePath,
labelsFilterBasePath: this.labelsFilterBasePath,
+ labelsFilterParam: this.labelsFilterParam,
labelsListTitle: this.labelsListTitle,
labelsCreateTitle: this.labelsCreateTitle,
footerCreateLabelTitle: this.footerCreateLabelTitle,
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
index 132abcab82b..ef5f052527b 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
@@ -1,10 +1,11 @@
<script>
-import { GlDropdown, GlDropdownForm } from '@gitlab/ui';
+import { GlDropdown, GlDropdownForm, GlDropdownDivider } from '@gitlab/ui';
export default {
components: {
GlDropdownForm,
GlDropdown,
+ GlDropdownDivider,
},
props: {
headerText: {
@@ -20,8 +21,12 @@ export default {
</script>
<template>
- <gl-dropdown class="show" :text="text" :header-text="headerText" @toggle="$emit('toggle')">
- <slot name="search"></slot>
+ <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')">
+ <template #header>
+ <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
+ <gl-dropdown-divider />
+ <slot name="search"></slot>
+ </template>
<gl-dropdown-form>
<slot name="items"></slot>
</gl-dropdown-form>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
index 62c0b05426b..459ea27e9cd 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
@@ -1,8 +1,10 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
- project(fullPath: $fullPath) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
issuable: issue(iid: $iid) {
+ __typename
id
participants {
nodes {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
index a75ce85a1dc..43bd9f17e9a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
@@ -1,7 +1,7 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) {
- project(fullPath: $fullPath) {
+ workspace: project(fullPath: $fullPath) {
issuable: mergeRequest(iid: $iid) {
id
participants {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
index 2eb9bb4b07b..8ee8de2cb5c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
@@ -1,10 +1,10 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
- issueSetAssignees(
+ issuableSetAssignees: issueSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
- issue {
+ issuable: issue {
id
assignees {
nodes {
diff --git a/app/assets/javascripts/vue_shared/components/tabs/tab.vue b/app/assets/javascripts/vue_shared/components/tabs/tab.vue
deleted file mode 100644
index d24c27cfcc3..00000000000
--- a/app/assets/javascripts/vue_shared/components/tabs/tab.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<script>
-export default {
- props: {
- title: {
- type: String,
- required: false,
- default: '',
- },
- active: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- // props can't be updated, so we map it to data where we can
- localActive: this.active,
- };
- },
- watch: {
- active() {
- this.localActive = this.active;
- },
- },
- created() {
- this.isTab = true;
- },
- updated() {
- if (this.$parent) {
- this.$parent.$forceUpdate();
- }
- },
-};
-</script>
-
-<template>
- <div
- :class="{
- active: localActive,
- }"
- class="tab-pane"
- role="tabpanel"
- >
- <slot></slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/tabs/tabs.js b/app/assets/javascripts/vue_shared/components/tabs/tabs.js
deleted file mode 100644
index 233df96a520..00000000000
--- a/app/assets/javascripts/vue_shared/components/tabs/tabs.js
+++ /dev/null
@@ -1,76 +0,0 @@
-export default {
- props: {
- stopPropagation: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- currentIndex: 0,
- tabs: [],
- };
- },
- mounted() {
- this.updateTabs();
- },
- methods: {
- updateTabs() {
- this.tabs = this.$children.filter((child) => child.isTab);
- this.currentIndex = this.tabs.findIndex((tab) => tab.localActive);
- },
- setTab(e, index) {
- if (this.stopPropagation) {
- e.stopPropagation();
- e.preventDefault();
- }
-
- this.tabs[this.currentIndex].localActive = false;
- this.tabs[index].localActive = true;
-
- this.currentIndex = index;
- },
- },
- render(h) {
- const navItems = this.tabs.map((tab, i) =>
- h(
- 'li',
- {
- key: i,
- },
- [
- h(
- 'a',
- {
- class: tab.localActive ? 'active' : null,
- attrs: {
- href: '#',
- },
- on: {
- click: (e) => this.setTab(e, i),
- },
- },
- tab.$slots.title || tab.title,
- ),
- ],
- ),
- );
- const nav = h(
- 'ul',
- {
- class: 'nav-links tab-links',
- },
- [navItems],
- );
- const content = h(
- 'div',
- {
- class: ['tab-content'],
- },
- [this.$slots.default],
- );
-
- return h('div', {}, [[nav], content]);
- },
-};
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
index 8aa6e29adf1..c5fdb5fc242 100644
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
@@ -1,11 +1,11 @@
<script>
+import { GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { isFunction } from 'lodash';
import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
-import tooltip from '../directives/tooltip';
export default {
directives: {
- tooltip,
+ GlTooltip,
},
props: {
title: {
@@ -59,9 +59,8 @@ export default {
<template>
<span
v-if="showTooltip"
- v-tooltip
+ v-gl-tooltip="{ placement }"
:title="title"
- :data-placement="placement"
class="js-show-tooltip gl-min-w-0"
>
<slot></slot>
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
index 5a08e992084..afb1ea702fa 100644
--- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -36,6 +36,11 @@ export default {
required: false,
default: () => [VALID_IMAGE_FILE_MIMETYPE.mimetype],
},
+ singleFileSelection: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -79,7 +84,7 @@ export default {
return;
}
- this.$emit('change', files);
+ this.$emit('change', this.singleFileSelection ? files[0] : files);
},
ondragenter(e) {
this.dragCounter += 1;
@@ -92,7 +97,7 @@ export default {
this.$refs.fileUpload.click();
},
onFileInputChange(e) {
- this.$emit('change', e.target.files);
+ this.$emit('change', this.singleFileSelection ? e.target.files[0] : e.target.files);
},
},
};
@@ -119,9 +124,15 @@ export default {
data-testid="dropzone-area"
>
<gl-icon name="upload" :size="iconStyles.size" :class="iconStyles.class" />
- <p class="gl-mb-0">
+ <p class="gl-mb-0" data-testid="upload-text">
<slot name="upload-text" :openFileUpload="openFileUpload">
- <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} files to attach')">
+ <gl-sprintf
+ :message="
+ singleFileSelection
+ ? __('Drop or %{linkStart}upload%{linkEnd} file to attach')
+ : __('Drop or %{linkStart}upload%{linkEnd} files to attach')
+ "
+ >
<template #link="{ content }">
<gl-link @click.stop="openFileUpload">
{{ content }}
@@ -139,7 +150,7 @@ export default {
name="upload_file"
:accept="validFileMimetypes"
class="hide"
- multiple
+ :multiple="!singleFileSelection"
@change="onFileInputChange"
/>
</slot>
diff --git a/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue b/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue
new file mode 100644
index 00000000000..e5558c038b3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue
@@ -0,0 +1,22 @@
+<script>
+/**
+ * This component applies particular styling to GlBadge that isn't
+ * available in the current GlBadge variants.
+ * Where possible, prefer one of the supported GlBadge variants.
+ * Discussion issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1247
+ */
+import { GlBadge } from '@gitlab/ui';
+
+export default {
+ name: 'UserAccessRoleBadge',
+ components: {
+ GlBadge,
+ },
+};
+</script>
+
+<template>
+ <gl-badge class="gl-bg-transparent! gl-inset-border-1-gray-100!">
+ <slot></slot>
+ </gl-badge>
+</template>
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 37bde089de8..dbd8efec948 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
@@ -12,11 +12,6 @@ import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
const MAX_SKELETON_LINES = 4;
-const SECURITY_BOT_USER_DATA = {
- username: 'GitLab-Security-Bot',
- name: 'GitLab Security Bot',
-};
-
export default {
name: 'UserPopover',
maxSkeletonLines: MAX_SKELETON_LINES,
@@ -56,15 +51,6 @@ export default {
userIsLoading() {
return !this.user?.loaded;
},
- isSecurityBot() {
- const { username, name, websiteUrl = '' } = this.user;
- return (
- gon.features?.securityAutoFix &&
- username === SECURITY_BOT_USER_DATA.username &&
- name === SECURITY_BOT_USER_DATA.name &&
- websiteUrl.length
- );
- },
availabilityStatus() {
return this.user?.status?.availability || '';
},
@@ -114,7 +100,7 @@ export default {
<div v-if="statusHtml" class="js-user-status gl-mt-3">
<span v-html="statusHtml"></span>
</div>
- <div v-if="isSecurityBot" class="gl-text-blue-500">
+ <div v-if="user.bot" class="gl-text-blue-500">
<gl-icon name="question" />
<gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
{{ sprintf(__('Learn more about %{username}'), { username: user.name }) }}
diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js
deleted file mode 100644
index 0eb505bfce8..00000000000
--- a/app/assets/javascripts/vue_shared/directives/tooltip.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import $ from 'jquery';
-import '~/commons/bootstrap';
-import { parseBoolean } from '~/lib/utils/common_utils';
-
-export default {
- bind(el) {
- const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
- const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0;
-
- $(el).tooltip({
- trigger: 'hover',
- delay,
- // By default, sanitize is run even if there is no `html` or `template` present
- // so let's optimize to only run this when necessary.
- // https://github.com/twbs/bootstrap/blob/c5966de27395a407f9a3d20d0eb2ff8e8fb7b564/js/src/tooltip.js#L716
- sanitize: parseBoolean(el.dataset.html) || Boolean(el.dataset.template),
- });
- },
-
- componentUpdated(el) {
- $(el).tooltip('_fixTitle');
-
- // update visible tooltips
- const tooltipInstance = $(el).data('bs.tooltip');
- const tip = tooltipInstance.getTipElement();
- tooltipInstance.setElementContent(
- $(tip.querySelectorAll('.tooltip-inner')),
- tooltipInstance.getTitle(),
- );
- },
-
- unbind(el) {
- $(el).tooltip('dispose');
- },
-};
diff --git a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
index e1734809bce..c12ffaac40a 100644
--- a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
+++ b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
@@ -1,7 +1,12 @@
export default (Vue) => {
Vue.mixin({
provide: {
- glFeatures: { ...((window.gon && window.gon.features) || {}) },
+ glFeatures:
+ {
+ ...window.gon?.features,
+ // TODO: extract into glLicensedFeatures https://gitlab.com/gitlab-org/gitlab/-/issues/322460
+ ...window.gon?.licensed_features,
+ } || {},
},
});
};
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
index 52ded0e0cc1..4a6edae0c06 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -175,7 +175,7 @@ const mixins = {
return __('Merged');
}
- return this.isOpen ? __('Opened') : __('Closed');
+ return this.isOpen ? __('Created') : __('Closed');
},
stateTimeInWords() {
if (this.isMerged) {
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue
index 3c606283c7d..26bc9b5d60e 100644
--- a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue
@@ -34,7 +34,7 @@ export default {
<span v-if="discoverProjectSecurityPath">
<gl-button
ref="discoverProjectSecurity"
- icon="information-o"
+ icon="question-o"
category="tertiary"
:aria-label="$options.i18n.upgradeToManageVulnerabilities"
/>
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index aac5a5c1def..1cdcf87097f 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -18,11 +18,12 @@ export const REPORT_FILE_TYPES = {
*/
export const REPORT_TYPE_SAST = 'sast';
export const REPORT_TYPE_DAST = 'dast';
+export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles';
export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection';
export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning';
export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning';
export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing';
-export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_compliance';
+export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning';
export const REPORT_TYPE_API_FUZZING = 'api_fuzzing';
/**
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 20ff78d32d3..4a15e0eb458 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -14,7 +14,6 @@
@import './pages/issues';
@import './pages/labels';
@import './pages/login';
-@import './pages/members';
@import './pages/merge_requests';
@import './pages/monitor';
@import './pages/note_form';
@@ -35,5 +34,4 @@
@import './pages/sherlock';
@import './pages/storage_quota';
@import './pages/tree';
-@import './pages/trials';
@import './pages/users';
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index deeef86c386..a5cfc8d12b0 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -170,12 +170,6 @@ table {
display: none;
}
-h3.popover-header {
- // Default bootstrap popovers use <h3>
- // which we default to having a top margin
- margin-top: 0;
-}
-
// Add to .label so that old system notes that are saved to the db
// will still receive the correct styling
.badge:not(.gl-badge),
@@ -198,7 +192,15 @@ h3.popover-header {
}
.divider {
- @extend .dropdown-divider;
+ // copied rules from node_modules/bootstrap/scss/_dropdown.scss:116
+ // this might be safe to just remove instead
+ // most places that use divider add overrides to undo these things
+ // there is also a probably-unintentional use in deprecated_dropdown_divider.scss
+ // so we would end up with .gl-dropdown .dropdown-divider
+ height: 0;
+ margin: 4px 0;
+ overflow: hidden;
+ border-top: 1px solid $border-color;
}
.info-well {
@@ -231,12 +233,9 @@ h3.popover-header {
}
.card {
- &.card-without-border {
- @extend .border-0;
- }
-
+ &.card-without-border,
&.bg-light {
- @extend .border-0;
+ border: 0 !important;
}
}
diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss
index 3c8abe43070..c8f69bfdbaf 100644
--- a/app/assets/stylesheets/components/avatar.scss
+++ b/app/assets/stylesheets/components/avatar.scss
@@ -70,7 +70,8 @@ $avatar-sizes: (
$identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $identicon-blue, $identicon-teal,
$identicon-orange, $identicon-gray;
-%avatar-circle {
+.avatar,
+.avatar-container {
float: left;
margin-right: $gl-padding;
border-radius: $avatar-radius;
@@ -84,7 +85,6 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i
}
.avatar {
- @extend %avatar-circle;
transition-property: none;
width: 40px;
@@ -151,7 +151,6 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i
}
.avatar-container {
- @extend %avatar-circle;
overflow: hidden;
display: flex;
diff --git a/app/assets/stylesheets/components/milestone_combobox.scss b/app/assets/stylesheets/components/milestone_combobox.scss
index f73ec4d5998..5d1709c22ec 100644
--- a/app/assets/stylesheets/components/milestone_combobox.scss
+++ b/app/assets/stylesheets/components/milestone_combobox.scss
@@ -1,8 +1,14 @@
-.selected-item {
- /* stylelint-disable-next-line function-url-quotes */
- background: url(asset_path('checkmark.png')) no-repeat 0 2px;
-}
+.milestone-combobox {
+ .selected-item {
+ /* stylelint-disable-next-line function-url-quotes */
+ background: url(asset_path('checkmark.png')) no-repeat 0 2px;
+ }
+
+ .dropdown-item-space {
+ padding: 8px 12px;
+ }
-.dropdown-item-space {
- padding: 8px 12px;
+ .dropdown-menu.show {
+ overflow: hidden;
+ }
}
diff --git a/app/assets/stylesheets/components/ref_selector.scss b/app/assets/stylesheets/components/ref_selector.scss
index 970a7b967ee..ded911c2492 100644
--- a/app/assets/stylesheets/components/ref_selector.scss
+++ b/app/assets/stylesheets/components/ref_selector.scss
@@ -1,17 +1,13 @@
.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.
+ overflow: hidden;
width: 20rem;
- max-height: $dropdown-max-height-lg;
+
+ &,
+ .gl-new-dropdown-inner {
+ max-height: $dropdown-max-height-lg;
+ }
}
}
diff --git a/app/assets/stylesheets/disable_animations.scss b/app/assets/stylesheets/disable_animations.scss
index 799c6e80ec9..1e63cdcfa39 100644
--- a/app/assets/stylesheets/disable_animations.scss
+++ b/app/assets/stylesheets/disable_animations.scss
@@ -12,9 +12,3 @@
animation: none !important;
/* stylelint-enable property-no-vendor-prefix */
}
-
-// Disable sticky changes bar for tests
-.diff-files-changed {
- position: relative !important;
- top: 0 !important;
-}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 7931f4deea0..1fe94a796f5 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -42,7 +42,6 @@
@import 'framework/notes';
@import 'framework/tabs';
@import 'framework/timeline';
-@import 'framework/tooltips';
@import 'framework/toggle';
@import 'framework/typography';
@import 'framework/zen';
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index a7623b65539..662f7f52d61 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -274,7 +274,9 @@
// `position:absolute`
&::after {
content: '\a0';
+ display: block !important;
width: 1em;
+ color: transparent;
}
.reaction-control-icon {
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index b51fec925cb..d1fa1187703 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -196,10 +196,6 @@
@include btn-orange;
}
- &.btn-close {
- @include btn-outline($white, $orange-500, $orange-500, $orange-50, $orange-600, $orange-600, $orange-100, $orange-700, $orange-700);
- }
-
&.btn-danger {
@include btn-red;
}
@@ -216,6 +212,7 @@
color: $gray-700;
}
+ // deprecated class
&.btn-text-field {
width: 100%;
text-align: left;
@@ -436,32 +433,27 @@
}
// All disabled buttons, regardless of color, type, etc
-%disabled {
- 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;
- }
-
- i {
- color: $gl-text-color-disabled;
- }
-}
-
.btn.disabled,
.btn[disabled],
fieldset[disabled] .btn,
.dropdown-toggle[disabled],
[disabled].dropdown-menu-toggle {
- @extend %disabled;
-
+ &,
&:hover {
- @extend %disabled;
+ 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;
+ }
+
+ i {
+ color: $gl-text-color-disabled;
+ }
}
&.btn-link {
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index e30caeb1dfb..bd15022eadf 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -1035,7 +1035,6 @@ table.code {
auto;
// Retina cursor
- // scss-lint:disable DuplicateProperty
cursor: image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x,
image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index f56d8f2c2a9..bdde6c7b313 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -34,7 +34,14 @@
overflow-y: auto;
&.dropdown-extended-height {
- max-height: 400px;
+ $extended-max-height: 400px;
+
+ max-height: $extended-max-height;
+
+ // See comment below for explanation
+ .gl-new-dropdown-inner {
+ max-height: $extended-max-height - 2px;
+ }
}
@include media-breakpoint-down(xs) {
@@ -46,6 +53,15 @@
overflow-y: initial;
max-height: initial;
}
+
+ // `GlDropdown` specifies the `max-height` of `.gl-new-dropdown-inner`
+ // as `$dropdown-max-height`, but the `max-height` rule above forces
+ // the parent `.dropdown-menu` to be _slightly_ too small because of
+ // the 1px borders. The workaround below overrides the @gitlab/ui style
+ // to avoid a double scroll bar.
+ .gl-new-dropdown-inner {
+ max-height: $dropdown-max-height - 2px;
+ }
}
.dropdown-toggle,
@@ -1093,3 +1109,11 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
width: $gl-dropdown-width-wide;
}
}
+
+// This class won't be needed once we can add a prop for this in the GitLab UI component
+// https://gitlab.com/gitlab-org/gitlab-ui/-/issues/966
+.gl-new-dropdown.gl-dropdown-menu-full-width {
+ .dropdown-menu {
+ width: 100%;
+ }
+}
diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/editor-lite.scss
index c3b287a6c3d..eda3e33a6aa 100644
--- a/app/assets/stylesheets/framework/editor-lite.scss
+++ b/app/assets/stylesheets/framework/editor-lite.scss
@@ -3,6 +3,11 @@
@include gl-display-flex;
@include gl-justify-content-center;
@include gl-align-items-center;
+ @include gl-z-index-0;
+
+ > * {
+ filter: blur(5px);
+ }
&::before {
content: '';
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index 13c5541da92..c5c660c1014 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -16,3 +16,26 @@ gl-emoji {
vertical-align: baseline;
}
}
+
+.emoji-picker-category-header {
+ @include gl-sticky;
+ background-color: $white-transparent;
+}
+
+.emoji-picker-emoji {
+ height: 30px;
+ // Create a width that fits 9 emojis per row
+ width: 100 / 9 * 1%;
+}
+
+.emoji-picker .gl-new-dropdown .dropdown-menu {
+ width: 350px;
+}
+
+.emoji-picker-category-tab {
+ border-bottom-color: transparent;
+}
+
+.emoji-picker .gl-new-dropdown-inner > :last-child {
+ padding-bottom: 0;
+}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 07d59847829..a4af45a467c 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -265,6 +265,7 @@
flex: 1;
position: relative;
min-width: 0;
+ height: 2rem;
background-color: $input-bg;
}
@@ -453,7 +454,7 @@
.search-token-target-branch {
.value {
font-family: $monospace-font;
- font-size: 13px;
+ font-size: $gl-font-size-monospace;
}
}
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index 6e47fef02d5..c366bf80093 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -35,7 +35,8 @@ label {
margin: 0;
}
- &.form-check-label {
+ &.form-check-label,
+ &.custom-control-label {
font-weight: $gl-font-weight-normal;
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index ffcc20b624b..fdb56a128c7 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -4,7 +4,6 @@
margin-bottom: 0;
min-height: $header-height;
border: 0;
- border-bottom: 1px solid $border-color;
position: fixed;
top: 0;
left: 0;
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 28577e2801e..73a2170fc68 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -45,8 +45,7 @@
a {
font-family: $monospace-font;
- display: flex;
- justify-content: flex-end;
+ display: block;
font-size: $code-font-size !important;
white-space: nowrap;
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index cac3695e1a1..4f9896dd58a 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -8,9 +8,8 @@ html {
body {
// Improves readability for dyslexic users; supported only in Chrome/Safari so far
- // scss-lint:disable PropertySpelling
text-decoration-skip: ink;
- // scss-lint:enable PropertySpelling
+
&.navless {
background-color: $white !important;
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index b0334da6943..df2ba718c72 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -192,11 +192,6 @@ ul.content-list {
display: flex;
align-items: center;
white-space: nowrap;
-
- // Override style that allows the flex-row text to wrap.
- &.allow-wrap {
- white-space: normal;
- }
}
.row-main-content {
@@ -283,12 +278,6 @@ ul.indent-list {
to { transform: rotate(360deg); }
}
-.namespace-title {
- .tooltip-inner {
- max-width: 350px;
- }
-}
-
.horizontal-list {
padding-left: 0;
list-style: none;
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index e29e204b14f..1e2fc1445e8 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -433,7 +433,6 @@
@mixin middle-dot-divider {
&::after {
// Duplicate `content` property used as a fallback
- // scss-lint:disable DuplicateProperty
content: '\00B7'; // middle dot fallback if browser does not support alternative content
content: '\00B7' / ''; // tell screen readers to ignore the content https://www.w3.org/TR/css-content-3/#accessibility
padding: 0 0.375rem;
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 2dbeacb0f8c..ec433434573 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -130,22 +130,6 @@ body.modal-open {
.issues-import-modal,
.issuable-export-modal {
- .modal-header {
- justify-content: flex-start;
-
- .import-export-svg-container {
- flex-grow: 1;
- height: 56px;
- padding: $gl-btn-padding $gl-btn-padding 0;
- text-align: right;
-
- .illustration {
- height: inherit;
- width: initial;
- }
- }
- }
-
.modal-body {
padding: 0;
diff --git a/app/assets/stylesheets/framework/tooltips.scss b/app/assets/stylesheets/framework/tooltips.scss
deleted file mode 100644
index edc2fb532c8..00000000000
--- a/app/assets/stylesheets/framework/tooltips.scss
+++ /dev/null
@@ -1,6 +0,0 @@
-.tooltip-inner {
- font-size: $gl-font-size-small;
- border-radius: $border-radius-default;
- line-height: $gl-line-height;
- font-weight: $gl-font-weight-normal;
-}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 496e2aba421..5624a6ea8a3 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -78,7 +78,9 @@
code {
font-family: $monospace-font;
white-space: pre-wrap;
- word-wrap: normal;
+ // Safari
+ word-wrap: break-word;
+ overflow-wrap: break-word;
word-break: keep-all;
}
@@ -344,6 +346,9 @@
// Multi-line code blocks should scroll horizontally
code {
white-space: pre;
+ // Safari
+ word-wrap: normal;
+ overflow-wrap: normal;
}
&.plain-readme {
@@ -430,8 +435,7 @@
}
}
- a[href*='/uploads/'],
- a[href*='storage.googleapis.com/google-code-attachments/'] {
+ a.with-attachment-icon {
&::before {
margin-right: 4px;
@@ -441,6 +445,11 @@
-webkit-font-smoothing: antialiased;
content: '📎';
}
+ }
+
+ a[href*='/uploads/'],
+ a[href*='storage.googleapis.com/google-code-attachments/'] {
+ @extend .with-attachment-icon;
&.no-attachment-icon {
&::before {
@@ -604,7 +613,7 @@ pre {
display: block;
padding: $gl-padding-8 $input-horizontal-padding;
margin: 0 0 $gl-padding-8;
- font-size: 13px;
+ font-size: $gl-font-size-monospace;
word-break: break-all;
word-wrap: break-word;
color: $gl-text-color;
@@ -646,7 +655,7 @@ code {
*/
textarea.js-gfm-input {
font-family: $monospace-font;
- font-size: 13px;
+ font-size: $gl-font-size-monospace;
}
.strikethrough {
@@ -699,13 +708,11 @@ textarea {
opacity: 1; // FF defaults to 0.54
}
- // scss-lint:disable PseudoElement
// support Edge vendor prefix
&::-ms-input-placeholder {
color: $gl-text-color-tertiary;
}
- // scss-lint:disable PseudoElement
// support IE vendor prefix
&:-ms-input-placeholder {
color: $gl-text-color-tertiary;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 4bf9236407f..1aa4177c902 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -443,7 +443,7 @@ $gl-avatar-size: 40px;
$border-radius-default: 4px;
$border-radius-small: 2px;
$border-radius-large: 8px;
-$default-icon-size: 18px;
+$default-icon-size: 16px;
$layout-link-gray: #7e7c7c;
$btn-side-margin: 10px;
$btn-sm-side-margin: 7px;
@@ -830,8 +830,8 @@ $ci-variable-remove-button-width: calc(1em + #{2 * $gl-padding});
/*
GitLab Plans
*/
-$gl-gold-plan: #d4af37;
-$gl-silver-plan: #91a1ab;
+$gl-ultimate-plan: #d4af37;
+$gl-premium-plan: #91a1ab;
$gl-bronze-plan: #cd7f32;
/*
diff --git a/app/assets/stylesheets/highlight/conflict_colors.scss b/app/assets/stylesheets/highlight/conflict_colors.scss
index 98ca3775b72..910dc6f4ad6 100644
--- a/app/assets/stylesheets/highlight/conflict_colors.scss
+++ b/app/assets/stylesheets/highlight/conflict_colors.scss
@@ -1,5 +1,4 @@
// Disabled to use the color map for creating color schemes
-// scss-lint:disable ColorVariable
$conflict-colors: (
white-header-head-neutral : #e1fad7,
white-line-head-neutral : #effdec,
@@ -116,4 +115,3 @@ $conflict-colors: (
none_line_not_chosen : $gray-light
);
-// scss-lint:enable ColorVariable
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 64387fbce09..0dc01213606 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -38,7 +38,7 @@ $dark-cp: #969896;
$dark-c1: #969896;
$dark-cs: #969896;
$dark-gd: #c66;
-$dark-gh: #c5c8c6;
+$dark-gh: #8abeb7;
$dark-gi: #b5bd68;
$dark-gp: #969896;
$dark-gu: #8abeb7;
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 119908ffba8..95c3e8e9103 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -87,6 +87,7 @@ $monokai-il: #ae81ff;
$monokai-gu: #75715e;
$monokai-gd: #f92672;
$monokai-gi: #a6e22e;
+$monokai-gh: #75715e;
.code.monokai {
// Line numbers
@@ -281,4 +282,5 @@ $monokai-gi: #a6e22e;
.gu { color: $monokai-gu; } /* Generic.Subheading & Diff Unified/Comment? */
.gd { color: $monokai-gd; } /* Generic.Deleted & Diff Deleted */
.gi { color: $monokai-gi; } /* Generic.Inserted & Diff Inserted */
+ .gh { color: $monokai-gh; } /* Generic.Heading */
}
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 777332881f3..128fe0cc046 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -24,7 +24,7 @@ $white-gd-bg: #fdd;
$white-gd-x: $black;
$white-gd-x-bg: #faa;
$white-gr: #a00;
-$white-gh: #999;
+$white-gh: #800080;
$white-gi: $black;
$white-gi-bg: #dfd;
$white-gi-x: $black;
@@ -280,7 +280,9 @@ span.highlight_word {
.ge { font-style: italic; }
.gr { color: $white-gr; }
-.gh { color: $white-gh; }
+
+.gh { color: $white-gh;
+ font-weight: $gl-font-weight-bold; }
.gi {
color: $white-gi;
diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
index b148cc8f0e7..0d40159f6de 100644
--- a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
+++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
@@ -339,3 +339,19 @@
display: inline;
}
}
+
+.gl-select2-html5-required-fix {
+ .select2-container {
+ + .select2 {
+ @include gl-opacity-0;
+ @include gl-border-0;
+ @include gl-bg-none;
+ @include gl-bg-transparent;
+ display: block !important;
+ width: 1px;
+ height: 1px;
+ z-index: -1;
+ margin: -3px auto 0;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/mailer_client_specific.scss b/app/assets/stylesheets/mailer_client_specific.scss
index 41bedecf90f..0547aa7752f 100644
--- a/app/assets/stylesheets/mailer_client_specific.scss
+++ b/app/assets/stylesheets/mailer_client_specific.scss
@@ -3,7 +3,6 @@
// These are client-specific rules, ignore some linting rules
//
// stylelint-disable property-no-vendor-prefix, property-no-unknown, length-zero-no-unit
-// scss-lint:disable PropertySpelling, ZeroUnit
body,
table,
diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
index d8bb021e1f5..75c2428c1d4 100644
--- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss
+++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
@@ -27,7 +27,7 @@ $highlighted-gd-bg: #fdd;
$highlighted-gd-x: #000;
$highlighted-gd-x-bg: #faa;
$highlighted-gr: #a00;
-$highlighted-gh: #999;
+$highlighted-gh: #800080;
$highlighted-gi: #000;
$highlighted-gi-bg: #dfd;
$highlighted-gi-x: #000;
@@ -185,7 +185,9 @@ span.highlight_word {
.ge { font-style: italic; }
.gr { color: $highlighted-gr; }
-.gh { color: $highlighted-gh; }
+
+.gh { color: $highlighted-gh;
+ font-weight: $gl-font-weight-bold; }
.gi {
color: $highlighted-gi;
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index 620b37914d8..fdf9e157e1e 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -30,12 +30,21 @@
color: var(--gray-500, $gray-500);
}
+[data-page$='epic_boards:index'],
+[data-page$='epic_boards:show'],
.issue-boards-page {
.content-wrapper {
padding-bottom: 0;
}
}
+[data-page$='epic_boards:index'],
+[data-page$='epic_boards:show'] {
+ .filter-form {
+ display: none;
+ }
+}
+
.boards-app {
@include media-breakpoint-up(sm) {
transition: width $sidebar-transition-duration;
@@ -515,7 +524,7 @@
}
}
-.board-issue-path.js-show-tooltip {
+.board-item-path.js-show-tooltip {
cursor: help;
}
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index 3962c546b51..34f88f9405a 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -140,23 +140,6 @@
width: 289px;
}
- .block {
- width: 100%;
- word-break: break-word;
-
- &:last-child {
- border-bottom: 1px solid $border-gray-normal;
- }
-
- &.coverage {
- padding: 0 16px 11px;
- }
- }
-
- .block-last {
- padding: 16px 0;
- }
-
.trigger-variables-btn-container {
justify-content: space-between;
align-items: center;
@@ -198,18 +181,6 @@
margin-left: 2px;
}
- .retry-link {
- display: block;
-
- .btn-inverted-secondary {
- color: $blue-500;
-
- &:hover {
- color: $white;
- }
- }
- }
-
.stage-item {
cursor: pointer;
diff --git a/app/assets/stylesheets/page_bundles/cycle_analytics.scss b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
index 4a48333cd27..2742c95c6e1 100644
--- a/app/assets/stylesheets/page_bundles/cycle_analytics.scss
+++ b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
@@ -1,6 +1,5 @@
@import 'mixins_and_variables_and_functions';
-#cycle-analytics,
.cycle-analytics {
margin: 24px auto 0;
position: relative;
@@ -316,35 +315,4 @@
}
}
}
-
- .empty-stage,
- .no-access-stage {
- text-align: center;
- width: 75%;
- margin: 0 auto;
- padding-top: 130px;
- color: var(--gray-500, $gray-500);
-
- h4 {
- color: var(--gl-text-color, $gl-text-color);
- }
- }
-
- .empty-stage {
- .icon-no-data {
- height: 36px;
- width: 78px;
- display: inline-block;
- margin-bottom: 20px;
- }
- }
-
- .no-access-stage {
- .icon-lock {
- height: 36px;
- width: 78px;
- display: inline-block;
- margin-bottom: 20px;
- }
- }
}
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 7c4d51ab677..009019a45d9 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -1043,8 +1043,7 @@ $ide-commit-header-height: 48px;
.input-icon {
right: auto;
left: 10px;
- top: 50%;
- transform: translateY(-50%);
+ top: 1rem;
}
}
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss
index 315a0ae6202..26d55dfe2d0 100644
--- a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss
@@ -2,14 +2,14 @@
// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes
// -------
.ide.theme-solarized-light {
- --ide-border-color: #dfd7bf;
+ --ide-border-color: #ded7c1;
--ide-border-color-alt: #dfd7bf;
--ide-highlight-accent: #5c4e21;
--ide-text-color: #616161;
--ide-text-color-secondary: #526f76;
- --ide-background: #efe8d3;
- --ide-background-hover: #ded6be;
- --ide-highlight-background: #fef6e1;
+ --ide-background: #ddd6c1;
+ --ide-background-hover: #d3cbb7;
+ --ide-highlight-background: #eee8d5;
--ide-link-color: #955800;
--ide-footer-background: #efe8d3;
--ide-empty-state-background: #fef6e1;
diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss
index 453b810196b..525481638f3 100644
--- a/app/assets/stylesheets/page_bundles/import.scss
+++ b/app/assets/stylesheets/page_bundles/import.scss
@@ -1,5 +1,12 @@
@import 'mixins_and_variables_and_functions';
+// Fixing double scrollbar issue
+// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1156 and
+// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54837
+.import-entities-namespace-dropdown.show.dropdown .dropdown-menu {
+ max-height: initial;
+}
+
.import-jobs-to-col {
width: 39%;
}
diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss
index 25401a161da..eb2dd6e578e 100644
--- a/app/assets/stylesheets/page_bundles/jira_connect.scss
+++ b/app/assets/stylesheets/page_bundles/jira_connect.scss
@@ -20,36 +20,23 @@
@import '@gitlab/ui/src/components/base/tooltip/tooltip';
$atlaskit-border-color: #dfe1e6;
+$header-height: 40px;
-.ac-content {
- margin: 20px;
-
- .subscription-form {
- margin-bottom: 20px;
-
- .field-group-input {
- display: flex;
- padding-top: $gl-padding-4;
+.subscription-form {
+ .field-group-input {
+ display: flex;
+ padding-top: $gl-padding-4;
- .ak-button {
- align-items: center;
- height: auto;
- margin-left: $btn-margin-5;
- }
+ .ak-button {
+ align-items: center;
+ height: auto;
+ margin-left: $btn-margin-5;
}
}
}
-$header-height: 40px;
-
.jira-connect-header {
- border-bottom: 1px solid $gray-100;
- display: flex;
- align-items: center;
- justify-content: center;
min-height: $header-height;
- padding-left: 16px;
- padding-right: 16px;
position: fixed;
top: 0;
left: 0;
@@ -57,7 +44,6 @@ $header-height: 40px;
}
.jira-connect-user {
- font-size: $gl-font-size;
position: fixed;
top: 10px;
right: 20px;
@@ -65,20 +51,17 @@ $header-height: 40px;
.jira-connect-app {
margin-top: $header-height;
+ height: calc(100% - #{$header-height});
+ max-width: 1000px;
+}
+
+.jira-connect-app-body {
max-width: 600px;
- min-height: 95vh;
- padding-top: 48px;
- padding-left: 16px;
- padding-right: 16px;
margin-left: auto;
margin-right: auto;
- text-align: center;
-}
-
-.gl-mt-5 {
- margin-top: 16px;
}
+// for external_link buttons
svg {
fill: currentColor;
@@ -115,12 +98,3 @@ svg {
}
}
}
-
-.empty-subscriptions {
- color: $gray-900;
-}
-
-.browser-limitations-notice {
- font-size: $gl-font-size;
- margin-top: 32px;
-}
diff --git a/app/assets/stylesheets/page_bundles/learn_gitlab.scss b/app/assets/stylesheets/page_bundles/learn_gitlab.scss
new file mode 100644
index 00000000000..189aefb330b
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/learn_gitlab.scss
@@ -0,0 +1,3 @@
+.learn-gitlab-info-card-content {
+ height: 200px;
+}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/page_bundles/members.scss
index 0ccde57746a..7b4c74b8253 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/page_bundles/members.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
.project-members-title {
padding-bottom: 10px;
border-bottom: 1px solid $border-color;
diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
index a6668f00147..5eaf91c3017 100644
--- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss
+++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
@@ -182,10 +182,3 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
transform: translateX(-50%);
}
}
-
-.gl-token {
- .gl-avatar-labeled-label {
- @include gl-text-white;
- @include gl-font-weight-normal;
- }
-}
diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss
index ae36f7e3ac1..6ef7f912ea9 100644
--- a/app/assets/stylesheets/page_bundles/pipelines.scss
+++ b/app/assets/stylesheets/page_bundles/pipelines.scss
@@ -67,8 +67,7 @@
// Mini Pipelines
.stage-cell {
- .mini-pipeline-graph-dropdown-toggle,
- .mini-pipeline-graph-gl-dropdown-toggle {
+ .mini-pipeline-graph-dropdown-toggle {
svg {
height: $ci-action-icon-size;
width: $ci-action-icon-size;
@@ -138,14 +137,16 @@
}
}
-// Dropdown button in mini pipeline graph
+// Commit mini pipeline (HAML)
button.mini-pipeline-graph-dropdown-toggle,
-// As the `mini-pipeline-item` mixin specificity is lower
-// than the toggle of dropdown with 'variant="link"' we add
-// classes ".gl-button.btn-link" to make it more specific.
-// Once FF ci_mini_pipeline_gl_dropdown is removed, the `mini-pipeline-item`
-// itself could increase its specificity to simplify this selector
-button.gl-button.btn-link.mini-pipeline-graph-gl-dropdown-toggle {
+// GlDropdown mini pipeline (Vue)
+// As the `mini-pipeline-item` mixin specificity is lower
+// than the toggle of dropdown with 'variant="link"' we add
+// classes ".gl-button.btn-link" to make it more specific
+// and avoid having the size overriden
+//
+// See https://gitlab.com/gitlab-org/gitlab/-/issues/320737
+button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle {
@include mini-pipeline-item();
}
diff --git a/app/assets/stylesheets/page_bundles/reports.scss b/app/assets/stylesheets/page_bundles/reports.scss
index 18ca5f9a3a9..ce91988cb8a 100644
--- a/app/assets/stylesheets/page_bundles/reports.scss
+++ b/app/assets/stylesheets/page_bundles/reports.scss
@@ -5,11 +5,6 @@
max-height: 170px;
overflow: auto;
}
-
- .report-block-list-issue-parent {
- padding: $gl-padding-top $gl-padding;
- border-top: 1px solid var(--border-color, $border-color);
- }
}
.report-block-container {
diff --git a/app/assets/stylesheets/page_bundles/signup.scss b/app/assets/stylesheets/page_bundles/signup.scss
index a207c10b04f..d6c3a3ff5da 100644
--- a/app/assets/stylesheets/page_bundles/signup.scss
+++ b/app/assets/stylesheets/page_bundles/signup.scss
@@ -32,11 +32,6 @@
@include media-breakpoint-down(md) {
width: 100%;
}
-
- img {
- width: $default-icon-size;
- height: $default-icon-size;
- }
}
.decline-page {
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index f7b8a4c5b84..98074f8af29 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -105,6 +105,7 @@
border: 0;
&.table-row-header {
+ // stylelint-disable-next-line
background-color: none;
border: 0;
font-weight: bold;
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 87307fd682e..ee1385d36df 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -519,10 +519,6 @@ table.pipeline-project-metrics tr td {
margin-left: 25px;
}
- .item-visibility {
- margin-right: 0;
- }
-
.last-updated {
position: relative;
min-width: 250px;
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 019d827798c..2d04354a99d 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -106,17 +106,6 @@
width: 100%;
}
}
-
- .omniauth-btn {
- width: 100%;
- padding: $gl-padding-8;
-
- img {
- width: $default-icon-size;
- height: $default-icon-size;
- margin-right: $gl-padding;
- }
- }
}
.new-session-tabs {
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index a62df1258af..23e368a2e73 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -4,6 +4,7 @@
*/
$mr-widget-min-height: 69px;
+$tabs-holder-z-index: 250;
.space-children {
@include clearfix;
@@ -225,10 +226,6 @@ $mr-widget-min-height: 69px;
}
}
- .mini-pipeline-graph-dropdown-toggle {
- vertical-align: top;
- }
-
.normal {
flex: 1;
flex-basis: auto;
@@ -675,7 +672,7 @@ $mr-widget-min-height: 69px;
.mr-version-controls {
position: relative;
- z-index: 203;
+ z-index: $tabs-holder-z-index + 10;
background: $white;
color: $gl-text-color;
margin-top: -1px;
@@ -745,7 +742,7 @@ $mr-widget-min-height: 69px;
.merge-request-tabs-holder,
.epic-tabs-holder {
top: $header-height;
- z-index: 250;
+ z-index: $tabs-holder-z-index;
background-color: $body-bg;
border-bottom: 1px solid $border-color;
@@ -981,15 +978,15 @@ $mr-widget-min-height: 69px;
line-height: initial;
}
- .mini-pipeline-graph-dropdown-toggle,
- .stage-cell .mini-pipeline-graph-dropdown-toggle svg,
- // As the `mini-pipeline-item` mixin specificity is lower
- // than the toggle of dropdown with 'variant="link"' we add
- // classes ".gl-button.btn-link" to make it more specific.
- // Once FF ci_mini_pipeline_gl_dropdown is removed, the `mini-pipeline-item`
- // itself could increase its specificity to simplify this selector
- button.gl-button.btn-link.mini-pipeline-graph-gl-dropdown-toggle,
- .stage-cell button.gl-button.btn-link.mini-pipeline-graph-gl-dropdown-toggle svg {
+ // GlDropdown mini pipeline (Vue)
+ // As the `mini-pipeline-item` mixin specificity is lower
+ // than the toggle of dropdown with 'variant="link"' we add
+ // classes ".gl-button.btn-link" to make it more specific
+ // and avoid having the size overriden
+ //
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/320737
+ button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle,
+ .stage-cell button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle svg {
height: $ci-action-icon-size-lg;
width: $ci-action-icon-size-lg;
}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index ffbfa47f9bd..cb5050fc578 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -302,8 +302,21 @@ table {
width: 100%;
}
}
+}
+
+.discussion-reply-holder {
+ .reply-placeholder-text-field {
+ font-family: $monospace-font;
+ font-size: $gl-font-size-monospace;
+ border-radius: $gl-border-radius-base;
+ width: 100%;
+ resize: none;
+ padding: $gl-padding-8 $gl-padding-12;
+ line-height: 1;
+ border: 1px solid $border-color;
+ background-color: $white;
+ overflow: hidden;
- .btn-text-field {
@include media-breakpoint-down(xs) {
margin-bottom: $gl-padding-8;
}
@@ -381,32 +394,6 @@ table {
}
.comment-type-dropdown {
- .btn-success {
- width: auto;
- }
-
- .dropdown-toggle {
- float: right;
-
- i {
- color: $white;
- padding-right: 2px;
- margin-top: 2px;
- }
-
- &[disabled] {
- i {
- color: $gl-text-color-disabled;
- }
- }
- }
-
- .dropdown-menu {
- top: initial;
- bottom: 100%;
- width: 298px;
- }
-
@include media-breakpoint-down(xs) {
display: flex;
width: 100%;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 8251cdb9bbb..e51cc0b4479 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -136,33 +136,9 @@
.project-repo-buttons {
.btn {
- &:last-child {
- margin-left: 0;
- }
-
svg {
- fill: $layout-link-gray;
+ fill: $gray-500;
}
-
- .notifications-icon {
- top: 1px;
- margin-right: 0;
- }
- }
-
- .icon {
- top: 0;
- }
-
- .count-badge,
- .btn-xs {
- height: 24px;
- }
-
- .home-panel-action-button,
- .project-action-button {
- margin: $gl-padding $gl-padding-8 0 0;
- vertical-align: top;
}
.notification-dropdown .dropdown-menu {
@@ -175,34 +151,6 @@
}
}
- .count-buttons {
- display: inline-block;
- vertical-align: top;
- margin-top: $gl-padding;
-
- .count-badge-count,
- .count-badge-button {
- border: 1px solid $border-color;
- line-height: 1;
- }
-
- .count,
- .count-badge-button {
- color: $gl-text-color;
- }
-
- .count-badge-count {
- padding: 0 12px;
- background: $gray-light;
- border-radius: 0 $border-radius-base $border-radius-base 0;
- }
-
- .count-badge-button {
- border-right: 0;
- border-radius: $border-radius-base 0 0 $border-radius-base;
- }
- }
-
.project-clone-holder {
display: inline-block;
margin: $gl-padding 0 0;
@@ -313,7 +261,6 @@
}
.fork-thumbnail {
- height: 200px;
width: calc((100% / 2) - #{$gl-padding * 2});
@include media-breakpoint-up(md) {
@@ -887,10 +834,6 @@ pre.light-well {
}
.git-clone-holder {
- .btn-clipboard {
- border: 1px solid $border-color;
- }
-
.clone-options {
display: table-cell;
@@ -1008,6 +951,18 @@ pre.light-well {
}
}
+.compare-revision-cards {
+ @media (min-width: $breakpoint-lg) {
+ .gl-card {
+ width: calc(50% - 15px);
+ }
+
+ .compare-ellipsis {
+ width: 30px;
+ }
+ }
+}
+
.clearable-input {
position: relative;
diff --git a/app/assets/stylesheets/pages/trials.scss b/app/assets/stylesheets/pages/trials.scss
deleted file mode 100644
index 55f323b7df7..00000000000
--- a/app/assets/stylesheets/pages/trials.scss
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
-* A CSS cross-browser fix for Select2 failire to display HTML5 required warnings
-* MR link https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22716
-*/
-.gl-select2-html5-required-fix div.select2-container+select.select2 {
- @include gl-opacity-0;
- @include gl-border-0;
- @include gl-bg-none;
- @include gl-bg-transparent;
- display: block !important;
- width: 1px;
- height: 1px;
- z-index: -1;
- margin: -3px auto 0;
-}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 849749ee7c7..9f7a8860e4d 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -1031,6 +1031,7 @@ body.gl-dark .logo-text svg {
}
body.gl-dark .navbar-gitlab {
background-color: #2e2e2e;
+ box-shadow: 0 1px 0 0 var(--gray-100);
}
body.gl-dark .navbar-gitlab .navbar-sub-nav li.active > a,
body.gl-dark .navbar-gitlab .navbar-sub-nav li.active > button,
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 4b88b94f3a6..6b78abdb5e0 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -2142,11 +2142,6 @@ table.code {
width: 100%;
padding: 8px;
}
-.login-page .omniauth-container .omniauth-btn img {
- width: 1.125rem;
- height: 1.125rem;
- margin-right: 16px;
-}
.login-page .new-session-tabs {
display: flex;
box-shadow: 0 0 0 1px #dbdbdb;
diff --git a/app/assets/stylesheets/test_environment.scss b/app/assets/stylesheets/test_environment.scss
new file mode 100644
index 00000000000..e9ba41f9bb7
--- /dev/null
+++ b/app/assets/stylesheets/test_environment.scss
@@ -0,0 +1,11 @@
+// Disable sticky changes bar for tests
+.diff-files-changed {
+ position: relative !important;
+ top: 0 !important;
+}
+
+// Un-hide inputs for @gitlab/ui custom checkboxes and radios so Capybara can target them
+.custom-control-input {
+ z-index: 500;
+ opacity: 1;
+}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index 417377b514e..5b3e2ab4cd0 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -92,6 +92,7 @@
.notification-dot {
will-change: border-color, background-color;
+ // stylelint-disable-next-line
border-color: $nav-svg-color + 33;
}
@@ -204,6 +205,10 @@
}
}
+ .emoji-picker-category-active {
+ border-bottom-color: $active-tab-border;
+ }
+
.branch-header-title {
color: $border-and-box-shadow;
}
diff --git a/app/assets/stylesheets/themes/theme_light.scss b/app/assets/stylesheets/themes/theme_light.scss
index 58003db4236..228bff94f5d 100644
--- a/app/assets/stylesheets/themes/theme_light.scss
+++ b/app/assets/stylesheets/themes/theme_light.scss
@@ -84,11 +84,12 @@ body {
&.gl-dark {
.logo-text svg {
- fill: $gl-text-color;
+ fill: var(--gl-text-color);
}
.navbar-gitlab {
- background-color: $gray-50;
+ background-color: var(--gray-50);
+ box-shadow: 0 1px 0 0 var(--gray-100);
.navbar-sub-nav,
.navbar-nav {
@@ -97,8 +98,8 @@ body {
> a:focus,
> button:hover,
> button:focus {
- color: $gl-text-color;
- background-color: $gray-200;
+ color: var(--gl-text-color);
+ background-color: var(--gray-200);
}
}
@@ -106,21 +107,21 @@ body {
li.dropdown.show {
> a,
> button {
- color: $gl-text-color;
- background-color: $gray-200;
+ color: var(--gl-text-color);
+ background-color: var(--gray-200);
}
}
}
.search {
form {
- background-color: $gray-100;
- box-shadow: inset 0 0 0 1px $border-color;
+ background-color: var(--gray-100);
+ box-shadow: inset 0 0 0 1px var(--border-color);
&:active,
&:hover {
- background-color: $gray-100;
- box-shadow: inset 0 0 0 1px $blue-200;
+ background-color: var(--gray-100);
+ box-shadow: inset 0 0 0 1px var(--blue-200);
}
}
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index d424dcbf8f2..024162eba3e 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -117,6 +117,7 @@
flex-basis: 25%;
}
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1168
.gl-md-ml-3 {
@media (min-width: $breakpoint-md) {
margin-left: $gl-spacing-scale-3;
@@ -157,3 +158,17 @@
margin-bottom: $gl-spacing-scale-4 !important;
}
}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1168
+.gl-sm-pr-3 {
+ @media (min-width: $breakpoint-sm) {
+ padding-right: $gl-spacing-scale-3;
+ }
+}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1168
+.gl-sm-w-half {
+ @media (min-width: $breakpoint-sm) {
+ width: 50%;
+ }
+}
diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss
index f31dbbeafe8..b92331facee 100644
--- a/app/assets/stylesheets/vendors/atwho.scss
+++ b/app/assets/stylesheets/vendors/atwho.scss
@@ -1,6 +1,7 @@
.atwho-view {
overflow-y: auto;
overflow-x: hidden;
+ max-width: calc(100% - 6px);
.name,
small.aliases,
@@ -80,10 +81,6 @@
}
@include media-breakpoint-down(xs) {
- .atwho-view-ul {
- width: 350px;
- }
-
.atwho-view ul li {
overflow: hidden;
text-overflow: ellipsis;
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 7f7d38a09c5..7c6a444ce7a 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -237,7 +237,6 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
[
*::ApplicationSettingsHelper.visible_attributes,
*::ApplicationSettingsHelper.external_authorization_service_attributes,
- *ApplicationSetting.repository_storages_weighted_attributes,
*ApplicationSetting.kroki_formats_attributes.keys.map { |key| "kroki_formats_#{key}".to_sym },
:lets_encrypt_notification_email,
:lets_encrypt_terms_of_service_accepted,
@@ -248,8 +247,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:default_branch_name,
disabled_oauth_sign_in_sources: [],
import_sources: [],
- repository_storages: [],
- restricted_visibility_levels: []
+ restricted_visibility_levels: [],
+ repository_storages_weighted: {}
]
end
diff --git a/app/controllers/admin/instance_statistics_controller.rb b/app/controllers/admin/instance_statistics_controller.rb
deleted file mode 100644
index 30891fcfe7c..00000000000
--- a/app/controllers/admin/instance_statistics_controller.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-class Admin::InstanceStatisticsController < Admin::ApplicationController
- include Analytics::UniqueVisitsHelper
-
- before_action :check_feature_flag
-
- track_unique_visits :index, target_id: 'i_analytics_instance_statistics'
-
- feature_category :devops_reports
-
- def index
- end
-
- def check_feature_flag
- render_404 unless Feature.enabled?(:instance_statistics, default_enabled: true)
- end
-end
diff --git a/app/controllers/admin/usage_trends_controller.rb b/app/controllers/admin/usage_trends_controller.rb
new file mode 100644
index 00000000000..7073f71a1a8
--- /dev/null
+++ b/app/controllers/admin/usage_trends_controller.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class Admin::UsageTrendsController < Admin::ApplicationController
+ include Analytics::UniqueVisitsHelper
+
+ track_unique_visits :index, target_id: 'i_analytics_instance_statistics'
+
+ feature_category :devops_reports
+
+ def index
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 5f14d95ffed..379da90827a 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -29,7 +29,6 @@ class ApplicationController < ActionController::Base
before_action :validate_user_service_ticket!
before_action :check_password_expiration, if: :html_request?
before_action :ldap_security_check
- around_action :sentry_context
before_action :default_headers
before_action :default_cache_headers
before_action :add_gon_variables, if: :html_request?
@@ -171,7 +170,12 @@ class ApplicationController < ActionController::Base
end
def log_exception(exception)
- Gitlab::ErrorTracking.track_exception(exception)
+ # At this point, the controller already exits set_current_context around
+ # block. To maintain the context while handling error exception, we need to
+ # set the context again
+ set_current_context do
+ Gitlab::ErrorTracking.track_exception(exception)
+ end
backtrace_cleaner = request.env["action_dispatch.backtrace_cleaner"]
application_trace = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).application_trace
@@ -528,10 +532,6 @@ class ApplicationController < ActionController::Base
.execute
end
- def sentry_context(&block)
- Gitlab::ErrorTracking.with_context(current_user, &block)
- end
-
def allow_gitaly_ref_name_caching
::Gitlab::GitalyClient.allow_ref_name_caching do
yield
diff --git a/app/controllers/concerns/boards_responses.rb b/app/controllers/concerns/boards_responses.rb
index 6e6686f225c..7307b7b4f8f 100644
--- a/app/controllers/concerns/boards_responses.rb
+++ b/app/controllers/concerns/boards_responses.rb
@@ -35,7 +35,7 @@ module BoardsResponses
end
def authorize_read_list
- authorize_action_for!(board, :read_list)
+ authorize_action_for!(board, :read_issue_board_list)
end
def authorize_read_issue
@@ -54,7 +54,7 @@ module BoardsResponses
end
def authorize_admin_list
- authorize_action_for!(board, :admin_list)
+ authorize_action_for!(board, :admin_issue_board_list)
end
def authorize_action_for!(resource, ability)
diff --git a/app/controllers/concerns/check_rate_limit.rb b/app/controllers/concerns/check_rate_limit.rb
new file mode 100644
index 00000000000..c4de3315e22
--- /dev/null
+++ b/app/controllers/concerns/check_rate_limit.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+# == CheckRateLimit
+#
+# Controller concern that checks if the rate limit for a given action is throttled by calling the
+# Gitlab::ApplicationRateLimiter class. If the action is throttled for the current user, the request
+# will be logged and an error message will be rendered with a Too Many Requests response status.
+module CheckRateLimit
+ def check_rate_limit(key)
+ return unless rate_limiter.throttled?(key, scope: current_user, users_allowlist: rate_limit_users_allowlist)
+
+ rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user)
+ render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests
+ end
+
+ def rate_limiter
+ ::Gitlab::ApplicationRateLimiter
+ end
+
+ def rate_limit_users_allowlist
+ Gitlab::CurrentSettings.current_application_settings.notes_create_limit_allowlist
+ end
+end
diff --git a/app/controllers/concerns/comment_and_close_flag.rb b/app/controllers/concerns/comment_and_close_flag.rb
deleted file mode 100644
index e2f3272abbc..00000000000
--- a/app/controllers/concerns/comment_and_close_flag.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module CommentAndCloseFlag
- extend ActiveSupport::Concern
-
- included do
- before_action do
- push_frontend_feature_flag(:remove_comment_close_reopen, @group)
- end
- end
-end
diff --git a/app/controllers/concerns/multiple_boards_actions.rb b/app/controllers/concerns/multiple_boards_actions.rb
index 5206f5759d8..85bb73463db 100644
--- a/app/controllers/concerns/multiple_boards_actions.rb
+++ b/app/controllers/concerns/multiple_boards_actions.rb
@@ -80,7 +80,7 @@ module MultipleBoardsActions
end
def authorize_admin_board!
- return render_404 unless can?(current_user, :admin_board, parent)
+ return render_404 unless can?(current_user, :admin_issue_board, parent)
end
def serializer
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 036d95622ef..2d8168af2e3 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -3,6 +3,7 @@
module NotesActions
include RendersNotes
include Gitlab::Utils::StrongMemoize
+ include CheckRateLimit
extend ActiveSupport::Concern
# last_fetched_at is an integer number of microseconds, which is the same
@@ -15,6 +16,7 @@ module NotesActions
before_action :require_noteable!, only: [:index, :create]
before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :note_project, only: [:create]
+ before_action -> { check_rate_limit(:notes_create) }, only: [:create]
end
def index
@@ -31,9 +33,9 @@ module NotesActions
# We know there's more data, so tell the frontend to poll again after 1ms
set_polling_interval_header(interval: 1) if meta[:more]
- # We might still want to investigate further adjusting ETag caching with paginated notes, but
- # let's avoid ETag caching for now until we confirm the viability of paginated notes.
- ::Gitlab::EtagCaching::Middleware.skip!(response)
+ # Only present an ETag for the empty response to ensure pagination works
+ # as expected
+ ::Gitlab::EtagCaching::Middleware.skip!(response) if notes.present?
render json: meta.merge(notes: notes)
end
diff --git a/app/controllers/concerns/security_and_compliance_permissions.rb b/app/controllers/concerns/security_and_compliance_permissions.rb
new file mode 100644
index 00000000000..104f3638bb7
--- /dev/null
+++ b/app/controllers/concerns/security_and_compliance_permissions.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module SecurityAndCompliancePermissions
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :ensure_security_and_compliance_enabled!
+ end
+
+ private
+
+ def ensure_security_and_compliance_enabled!
+ render_404 unless can?(current_user, :access_security_and_compliance, project)
+ end
+end
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
index b285faee9bc..9e861d2859d 100644
--- a/app/controllers/concerns/spammable_actions.rb
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -2,6 +2,7 @@
module SpammableActions
extend ActiveSupport::Concern
+ include Spam::Concerns::HasSpamActionResponseFields
included do
before_action :authorize_submit_spammable!, only: :mark_as_spam
@@ -25,14 +26,20 @@ module SpammableActions
respond_to do |format|
format.html do
+ # NOTE: format.html is still used by issue create, and uses the legacy HAML
+ # `_recaptcha_form.html.haml` rendered via the `projects/issues/verify` template.
render :verify
end
format.json do
- locals = { spammable: spammable, script: false, has_submit: false }
- recaptcha_html = render_to_string(partial: 'shared/recaptcha_form', formats: :html, locals: locals)
+ # format.json is used by all new Vue-based CAPTCHA implementations, which
+ # handle all of the CAPTCHA form rendering on the client via the Pajamas-based
+ # app/assets/javascripts/captcha/captcha_modal.vue
- render json: { recaptcha_html: recaptcha_html }
+ # NOTE: "409 - Conflict" seems to be the most appropriate HTTP status code for a response
+ # which requires a CAPTCHA to be solved in order for the request to be resubmitted.
+ # See https://stackoverflow.com/q/26547466/25192
+ render json: spam_action_response_fields(spammable), status: :conflict
end
end
else
@@ -58,7 +65,7 @@ module SpammableActions
# After this newer GraphQL/JS API process is fully supported by the backend, we can remove the
# check for the 'g-recaptcha-response' field and other HTML/HAML form-specific support.
- captcha_response = params['g-recaptcha-response']
+ captcha_response = params['g-recaptcha-response'] || params[:captcha_response]
{
request: request,
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index 4014e4f0024..60ff0a12d0c 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -112,10 +112,11 @@ module WikiActions
wiki_page_path(wiki, page)
)
else
+ @error = response.message
render 'shared/wikis/edit'
end
rescue WikiPage::PageChangedError, WikiPage::PageRenameError => e
- @error = e
+ @error = e.message
render 'shared/wikis/edit'
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb
index 01dc0b1ab00..5a885349467 100644
--- a/app/controllers/dashboard/snippets_controller.rb
+++ b/app/controllers/dashboard/snippets_controller.rb
@@ -19,6 +19,7 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController
.page(params[:page])
.inc_author
.inc_projects_namespace_route
+ .inc_statistics
return if redirect_out_of_range(@snippets)
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 0ae326b5d94..782c8c293fd 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -52,7 +52,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
def bulk_restore
- TodoService.new.restore_todos(current_user.todos.for_ids(params[:ids]), current_user)
+ TodoService.new.restore_todos(current_user.todos.id_in(params[:ids]), current_user)
render json: todos_counts
end
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index 1fd3cfd11f9..f6671f7250f 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -97,7 +97,7 @@ class Explore::ProjectsController < Explore::ApplicationController
end
def default_sort_order
- sort_value_name
+ sort_value_latest_activity
end
def sorting_field
diff --git a/app/controllers/explore/snippets_controller.rb b/app/controllers/explore/snippets_controller.rb
index 91ab18f2f55..617cc2e7f3d 100644
--- a/app/controllers/explore/snippets_controller.rb
+++ b/app/controllers/explore/snippets_controller.rb
@@ -11,6 +11,7 @@ class Explore::SnippetsController < Explore::ApplicationController
.page(params[:page])
.without_count
.inc_author
+ .inc_statistics
@noteable_meta_data = noteable_meta_data(@snippets, 'Snippet')
end
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 152f07b4c16..53064041ab8 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -4,6 +4,8 @@ class GraphqlController < ApplicationController
# Unauthenticated users have access to the API for public data
skip_before_action :authenticate_user!
+ WHITELIST_HEADER = 'HTTP_X_GITLAB_QUERY_WHITELIST_ISSUE'
+
# If a user is using their session to access GraphQL, we need to have session
# storage, since the admin-mode check is session wide.
# We can't enable this for anonymous users because that would cause users using
@@ -21,6 +23,7 @@ class GraphqlController < ApplicationController
before_action(only: [:execute]) { authenticate_sessionless_user!(:api) }
before_action :set_user_last_activity
before_action :track_vs_code_usage
+ before_action :whitelist_query!
# Since we deactivate authentication from the main ApplicationController and
# defer it to :authorize_access_api!, we need to override the bypass session
@@ -59,6 +62,14 @@ class GraphqlController < ApplicationController
private
+ # Tests may mark some queries as exempt from query limits
+ def whitelist_query!
+ whitelist_issue = request.headers[WHITELIST_HEADER]
+ return unless whitelist_issue
+
+ Gitlab::QueryLimiting.whitelist(whitelist_issue)
+ end
+
def set_user_last_activity
return unless current_user
@@ -66,7 +77,8 @@ class GraphqlController < ApplicationController
end
def track_vs_code_usage
- Gitlab::UsageDataCounters::VSCodeExtensionActivityUniqueCounter.track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
+ Gitlab::UsageDataCounters::VSCodeExtensionActivityUniqueCounter
+ .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
end
def execute_multiplex
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index fa109021b7d..3354c0a5c9f 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -9,6 +9,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false)
+ push_frontend_feature_flag(:boards_filtered_search, group)
end
feature_category :boards
@@ -21,13 +22,13 @@ class Groups::BoardsController < Groups::ApplicationController
def boards_finder
strong_memoize :boards_finder do
- Boards::ListService.new(parent, current_user)
+ Boards::BoardsFinder.new(parent, current_user)
end
end
def board_finder
strong_memoize :board_finder do
- Boards::ListService.new(parent, current_user, board_id: params[:id])
+ Boards::BoardsFinder.new(parent, current_user, board_id: params[:id])
end
end
@@ -44,6 +45,6 @@ class Groups::BoardsController < Groups::ApplicationController
end
def authorize_read_board!
- access_denied! unless can?(current_user, :read_board, group)
+ access_denied! unless can?(current_user, :read_issue_board, group)
end
end
diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
index 0f640397320..e2c104f88a4 100644
--- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb
+++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
@@ -16,7 +16,18 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro
result = DependencyProxy::FindOrCreateManifestService.new(group, image, tag, token).execute
if result[:status] == :success
- send_upload(result[:manifest].file)
+ response.headers['Docker-Content-Digest'] = result[:manifest].digest
+ response.headers['Content-Length'] = result[:manifest].size
+ response.headers['Docker-Distribution-Api-Version'] = DependencyProxy::DISTRIBUTION_API_VERSION
+ response.headers['Etag'] = "\"#{result[:manifest].digest}\""
+ content_type = result[:manifest].content_type
+
+ send_upload(
+ result[:manifest].file,
+ proxy: true,
+ redirect_params: { query: { 'response-content-type' => content_type } },
+ send_params: { type: content_type }
+ )
else
render status: result[:http_status], json: result[:message]
end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 9d7aebe4505..5de207857bb 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -30,7 +30,6 @@ class GroupsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuables_list, @group)
- push_frontend_feature_flag(:vue_notification_dropdown, @group, default_enabled: :yaml)
end
before_action do
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index e995562f0c4..06906001ef0 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -2,6 +2,7 @@
class HelpController < ApplicationController
skip_before_action :authenticate_user!, unless: :public_visibility_restricted?
+ skip_before_action :check_two_factor_requirement
feature_category :not_owned
layout 'help'
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index 8c0414ad5da..ea67c76a8bc 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -5,10 +5,12 @@ class IdeController < ApplicationController
include ClientsidePreviewCSP
include StaticObjectExternalStorageCSP
+ include Gitlab::Utils::StrongMemoize
before_action do
push_frontend_feature_flag(:build_service_proxy)
push_frontend_feature_flag(:schema_linting)
+ define_index_vars
end
feature_category :web_ide
@@ -16,4 +18,26 @@ class IdeController < ApplicationController
def index
Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count
end
+
+ private
+
+ def define_index_vars
+ return unless project
+
+ @branch = params[:branch]
+ @path = params[:path]
+ @merge_request = params[:merge_request_id]
+
+ unless can?(current_user, :push_code, project)
+ @forked_project = ForkProjectsFinder.new(project, current_user: current_user).execute.first
+ end
+ end
+
+ def project
+ strong_memoize(:project) do
+ next unless params[:project_id].present?
+
+ Project.find_by_full_path(params[:project_id])
+ end
+ end
end
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index ef32ba4d119..48635c933af 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -37,8 +37,13 @@ class Import::BulkImportsController < ApplicationController
end
def create
- result = BulkImportService.new(current_user, create_params, credentials).execute
- render json: result.to_json(only: [:id])
+ response = BulkImportService.new(current_user, create_params, credentials).execute
+
+ if response.success?
+ render json: response.payload.to_json(only: [:id])
+ else
+ render json: { error: response.message }, status: response.http_status
+ end
end
def realtime_changes
@@ -128,7 +133,7 @@ class Import::BulkImportsController < ApplicationController
rescue Gitlab::UrlBlocker::BlockedUrlError => e
clear_session_data
- redirect_to new_group_path, alert: _('Specified URL cannot be used: "%{reason}"') % { reason: e.message }
+ redirect_to new_group_path(anchor: 'import-group-pane'), alert: _('Specified URL cannot be used: "%{reason}"') % { reason: e.message }
end
def allow_local_requests?
@@ -151,7 +156,7 @@ class Import::BulkImportsController < ApplicationController
}, status: :unprocessable_entity
end
format.html do
- redirect_to new_group_path
+ redirect_to new_group_path(anchor: 'import-group-pane')
end
end
end
diff --git a/app/controllers/import/gitlab_groups_controller.rb b/app/controllers/import/gitlab_groups_controller.rb
index f68b76a7b36..503b10f766b 100644
--- a/app/controllers/import/gitlab_groups_controller.rb
+++ b/app/controllers/import/gitlab_groups_controller.rb
@@ -10,7 +10,7 @@ class Import::GitlabGroupsController < ApplicationController
def create
unless file_is_valid?(group_params[:file])
- return redirect_back_or_default(options: { alert: s_('GroupImport|Unable to process group import file') })
+ return redirect_to new_group_path(anchor: 'import-group-pane'), alert: s_('GroupImport|Unable to process group import file')
end
group_data = group_params.except(:file).merge(
@@ -30,9 +30,8 @@ class Import::GitlabGroupsController < ApplicationController
redirect_to group_path(group), alert: _("Group import could not be scheduled")
end
else
- redirect_back_or_default(
- options: { alert: s_("GroupImport|Group could not be imported: %{errors}") % { errors: group.errors.full_messages.to_sentence } }
- )
+ redirect_to new_group_path(anchor: 'import-group-pane'),
+ alert: s_("GroupImport|Group could not be imported: %{errors}") % { errors: group.errors.full_messages.to_sentence }
end
end
diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb
index 161280a05fc..3ff12f29f10 100644
--- a/app/controllers/jira_connect/subscriptions_controller.rb
+++ b/app/controllers/jira_connect/subscriptions_controller.rb
@@ -19,9 +19,6 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
before_action :allow_rendering_in_iframe, only: :index
before_action :verify_qsh_claim!, only: :index
before_action :authenticate_user!, only: :create
- before_action do
- push_frontend_feature_flag(:new_jira_connect_ui, type: :development, default_enabled: :yaml)
- end
def index
@subscriptions = current_jira_installation.subscriptions.preload_namespace_route
diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb
deleted file mode 100644
index 7d8c035c852..00000000000
--- a/app/controllers/notification_settings_controller.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-# frozen_string_literal: true
-
-class NotificationSettingsController < ApplicationController
- before_action :authenticate_user!
-
- feature_category :users
-
- def create
- return render_404 unless can_read?(resource)
-
- @notification_setting = current_user.notification_settings_for(resource)
- @saved = @notification_setting.update(notification_setting_params_for(resource))
-
- render_response
- end
-
- def update
- @notification_setting = current_user.notification_settings.find(params[:id])
- @saved = @notification_setting.update(notification_setting_params_for(@notification_setting.source))
-
- render_response
- end
-
- private
-
- def resource
- @resource ||=
- if params[:project_id].present?
- Project.find(params[:project_id])
- elsif params[:namespace_id].present?
- Group.find(params[:namespace_id])
- end
- end
-
- def can_read?(resource)
- ability_name = resource.class.name.downcase
- ability_name = "read_#{ability_name}".to_sym
-
- can?(current_user, ability_name, resource)
- end
-
- def render_response
- btn_class = nil
-
- if params[:hide_label].present?
- btn_class = 'btn-xs' if params[:project_id].present?
- response_template = 'shared/notifications/_new_button'
- else
- response_template = 'shared/notifications/_button'
- end
-
- render json: {
- html: view_to_html_string(response_template, notification_setting: @notification_setting, btn_class: btn_class),
- saved: @saved
- }
- end
-
- def notification_setting_params_for(source)
- params.require(:notification_setting).permit(NotificationSetting.allowed_fields(source))
- end
-end
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index a45205c5da7..251967a7dff 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -3,6 +3,10 @@
class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
feature_category :authentication_and_authorization
+ before_action do
+ push_frontend_feature_flag(:personal_access_tokens_scoped_to_projects, current_user)
+ end
+
def index
set_index_vars
@personal_access_token = finder.build
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index add5046e213..45bab5f6cd1 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -49,7 +49,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:tab_width,
:sourcegraph_enabled,
:gitpod_enabled,
- :render_whitespace_in_code
+ :render_whitespace_in_code,
+ :markdown_surround_selection
]
end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 3bb00978aac..0080ae1a5be 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -31,9 +31,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action :editor_variables, except: [:show, :preview, :diff]
before_action :validate_diff_params, only: :diff
before_action :set_last_commit_sha, only: [:edit, :update]
- before_action only: :new do
- record_experiment_user(:ci_syntax_templates, namespace_id: @project.namespace_id) if params[:file_name] == @project.ci_config_path_or_default
- end
+ before_action :record_experiment, only: :new
track_redis_hll_event :create, :update, name: 'g_edit_by_sfe'
@@ -263,4 +261,10 @@ class Projects::BlobController < Projects::ApplicationController
def visitor_id
current_user&.id
end
+
+ def record_experiment
+ return unless params[:file_name] == @project.ci_config_path_or_default && @project.namespace.recent?
+
+ record_experiment_user(:ci_syntax_templates_b, namespace_id: @project.namespace_id)
+ end
end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index d2e5d319f96..418f2ee1592 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -21,13 +21,13 @@ class Projects::BoardsController < Projects::ApplicationController
def boards_finder
strong_memoize :boards_finder do
- Boards::ListService.new(parent, current_user)
+ Boards::BoardsFinder.new(parent, current_user)
end
end
def board_finder
strong_memoize :board_finder do
- Boards::ListService.new(parent, current_user, board_id: params[:id])
+ Boards::BoardsFinder.new(parent, current_user, board_id: params[:id])
end
end
@@ -45,6 +45,6 @@ class Projects::BoardsController < Projects::ApplicationController
end
def authorize_read_board!
- access_denied! unless can?(current_user, :read_board, project)
+ access_denied! unless can?(current_user, :read_issue_board, project)
end
end
diff --git a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
index aabcb74cefa..fee216da492 100644
--- a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
+++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
@@ -1,11 +1,6 @@
# frozen_string_literal: true
class Projects::Ci::DailyBuildGroupReportResultsController < Projects::ApplicationController
- include Gitlab::Utils::StrongMemoize
-
- MAX_ITEMS = 1000
- REPORT_WINDOW = 90.days
-
before_action :authorize_read_build_report_results!
before_action :validate_param_type!
@@ -40,53 +35,23 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati
end
def report_results
- if ::Gitlab::Ci::Features.use_coverage_data_new_finder?(project)
- ::Ci::Testing::DailyBuildGroupReportResultsFinder.new(
- params: new_finder_params,
- current_user: current_user
- ).execute
- else
- Ci::DailyBuildGroupReportResultsFinder.new(**finder_params).execute
- end
+ ::Ci::DailyBuildGroupReportResultsFinder.new(
+ params: finder_params,
+ current_user: current_user
+ ).execute
end
- def new_finder_params
+ def finder_params
{
project: project,
coverage: true,
- start_date: start_date,
- end_date: end_date,
+ start_date: params[:start_date],
+ end_date: params[:end_date],
ref_path: params[:ref_path],
sort: true
}
end
- def finder_params
- {
- current_user: current_user,
- project: project,
- ref_path: params.require(:ref_path),
- start_date: start_date,
- end_date: end_date,
- limit: MAX_ITEMS
- }
- end
-
- def start_date
- strong_memoize(:start_date) do
- start_date = Date.parse(params.require(:start_date))
-
- # The start_date cannot be older than `end_date - 90 days`
- [start_date, end_date - REPORT_WINDOW].max
- end
- end
-
- def end_date
- strong_memoize(:end_date) do
- Date.parse(params.require(:end_date))
- end
- end
-
def allowed_param_types
Ci::DailyBuildGroupReportResult::PARAM_TYPES
end
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index 3552915b561..4d491b33aa0 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -5,12 +5,13 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: :yaml)
push_frontend_feature_flag(:ci_config_merged_tab, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:pipeline_status_for_pipeline_editor, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:pipeline_editor_empty_state_action, @project, default_enabled: :yaml)
end
feature_category :pipeline_authoring
def show
- render_404 unless ::Gitlab::Ci::Features.ci_pipeline_editor_page_enabled?(@project)
end
private
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index ffdd9fca95b..8154128da29 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -16,10 +16,16 @@ class Projects::CommitController < Projects::ApplicationController
before_action :authorize_read_pipeline!, only: [:pipelines]
before_action :commit
before_action :define_commit_vars, only: [:show, :diff_for_path, :diff_files, :pipelines, :merge_requests]
+ before_action :define_commit_box_vars, only: [:show, :pipelines]
before_action :define_note_vars, only: [:show, :diff_for_path, :diff_files]
before_action :authorize_edit_tree!, only: [:revert, :cherry_pick]
- before_action only: [:pipelines] do
- push_frontend_feature_flag(:ci_mini_pipeline_gl_dropdown, @project, type: :development, default_enabled: :yaml)
+
+ before_action only: [:show, :pipelines] do
+ push_frontend_feature_flag(:ci_commit_pipeline_mini_graph_vue, @project, default_enabled: :yaml)
+ end
+
+ before_action do
+ push_frontend_feature_flag(:pick_into_project)
end
BRANCH_SEARCH_LIMIT = 1000
@@ -53,6 +59,8 @@ class Projects::CommitController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def pipelines
+ set_pipeline_feature_flag
+
@pipelines = @commit.pipelines.order(id: :desc)
@pipelines = @pipelines.where(ref: params[:ref]).page(params[:page]).per(30) if params[:ref]
@@ -127,6 +135,10 @@ class Projects::CommitController < Projects::ApplicationController
private
+ def set_pipeline_feature_flag
+ push_frontend_feature_flag(:new_pipelines_table, @project, default_enabled: :yaml)
+ end
+
def create_new_branch?
params[:create_merge_request].present? || !can?(current_user, :push_code, @project)
end
@@ -199,6 +211,15 @@ class Projects::CommitController < Projects::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
+ def define_commit_box_vars
+ @last_pipeline = @commit.last_pipeline
+
+ return unless ::Gitlab::Ci::Features.ci_commit_pipeline_mini_graph_vue_enabled?(@project)
+ return unless @commit.last_pipeline
+
+ @last_pipeline_stages = StageSerializer.new(project: @project, current_user: @current_user).represent(@last_pipeline.stages)
+ end
+
def assign_change_commit_vars
@start_branch = params[:start_branch]
@commit_params = { commit: @commit }
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 6be0b465402..81f80d37662 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -6,6 +6,7 @@ class Projects::CompareController < Projects::ApplicationController
include DiffForPath
include DiffHelper
include RendersCommits
+ include CompareHelper
# Authorize
before_action :require_non_empty_project
@@ -19,6 +20,10 @@ class Projects::CompareController < Projects::ApplicationController
# Validation
before_action :validate_refs!
+ before_action do
+ push_frontend_feature_flag(:compare_repo_dropdown, source_project, default_enabled: :yaml)
+ end
+
feature_category :source_code_management
def index
@@ -37,16 +42,18 @@ class Projects::CompareController < Projects::ApplicationController
end
def create
- if params[:from].blank? || params[:to].blank?
+ from_to_vars = {
+ from: params[:from].presence,
+ to: params[:to].presence,
+ from_project_id: params[:from_project_id].presence
+ }
+
+ if from_to_vars[:from].blank? || from_to_vars[:to].blank?
flash[:alert] = "You must select a Source and a Target revision"
- from_to_vars = {
- from: params[:from].presence,
- to: params[:to].presence
- }
- redirect_to project_compare_index_path(@project, from_to_vars)
+
+ redirect_to project_compare_index_path(source_project, from_to_vars)
else
- redirect_to project_compare_path(@project,
- params[:from], params[:to])
+ redirect_to project_compare_path(source_project, from_to_vars)
end
end
@@ -73,13 +80,34 @@ class Projects::CompareController < Projects::ApplicationController
return if valid.all?
flash[:alert] = "Invalid branch name"
- redirect_to project_compare_index_path(@project)
+ redirect_to project_compare_index_path(source_project)
+ end
+
+ # target == start_ref == from
+ def target_project
+ strong_memoize(:target_project) do
+ next source_project unless params.key?(:from_project_id)
+ next source_project unless Feature.enabled?(:compare_repo_dropdown, source_project, default_enabled: :yaml)
+ next source_project if params[:from_project_id].to_i == source_project.id
+
+ target_project = target_projects(source_project).find_by_id(params[:from_project_id])
+
+ # Just ignore the field if it points at a non-existent or hidden project
+ next source_project unless target_project && can?(current_user, :download_code, target_project)
+
+ target_project
+ end
+ end
+
+ # source == head_ref == to
+ def source_project
+ project
end
def compare
return @compare if defined?(@compare)
- @compare = CompareService.new(@project, head_ref).execute(@project, start_ref)
+ @compare = CompareService.new(source_project, head_ref).execute(target_project, start_ref)
end
def start_ref
@@ -102,9 +130,9 @@ class Projects::CompareController < Projects::ApplicationController
def define_environment
if compare
- environment_params = @repository.branch_exists?(head_ref) ? { ref: head_ref } : { commit: compare.commit }
+ environment_params = source_project.repository.branch_exists?(head_ref) ? { ref: head_ref } : { commit: compare.commit }
environment_params[:find_latest] = true
- @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
+ @environment = EnvironmentsFinder.new(source_project, current_user, environment_params).execute.last
end
end
@@ -114,8 +142,8 @@ class Projects::CompareController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def merge_request
- @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened
- .find_by(source_project: @project, source_branch: head_ref, target_branch: start_ref)
+ @merge_request ||= MergeRequestsFinder.new(current_user, project_id: target_project.id).execute.opened
+ .find_by(source_project: source_project, source_branch: head_ref, target_branch: start_ref)
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 5576d5766c7..33f046f414f 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -16,6 +16,10 @@ class Projects::ForksController < Projects::ApplicationController
feature_category :source_code_management
+ before_action do
+ push_frontend_feature_flag(:fork_project_form)
+ end
+
def index
@total_forks_count = project.forks.size
@public_forks_count = project.forks.public_only.size
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index 2b030793c58..ad39b317b31 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -62,7 +62,7 @@ class Projects::GraphsController < Projects::ApplicationController
return unless can?(current_user, :read_build_report_results, project)
date_today = Date.current
- report_window = Projects::Ci::DailyBuildGroupReportResultsController::REPORT_WINDOW
+ report_window = ::Ci::DailyBuildGroupReportResultsFinder::REPORT_WINDOW
@daily_coverage_options = {
base_params: {
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 2816977277a..c454ae6eaf4 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -9,7 +9,6 @@ class Projects::IssuesController < Projects::ApplicationController
include IssuesCalendar
include SpammableActions
include RecordUserLastActivity
- include CommentAndCloseFlag
ISSUES_EXCEPT_ACTIONS = %i[index calendar new create bulk_update import_csv export_csv service_desk].freeze
SET_ISSUEABLES_INDEX_ONLY_ACTIONS = %i[index calendar service_desk].freeze
@@ -45,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:usage_data_design_action, project, default_enabled: true)
+ push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
end
before_action only: :show do
@@ -54,7 +54,6 @@ class Projects::IssuesController < Projects::ApplicationController
push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled)
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
- record_experiment_user(:invite_members_version_a)
record_experiment_user(:invite_members_version_b)
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index c9e9a34ad88..2c6d5f62b4e 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -11,7 +11,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
include RecordUserLastActivity
include SourcegraphDecorator
include DiffHelper
- include CommentAndCloseFlag
skip_before_action :merge_request, only: [:index, :bulk_update, :export_csv]
before_action :apply_diff_view_cookie!, only: [:show]
@@ -33,7 +32,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:file_identifier_hash)
push_frontend_feature_flag(:batch_suggestions, @project, default_enabled: true)
push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true)
- push_frontend_feature_flag(:merge_request_widget_graphql, @project)
+ push_frontend_feature_flag(:merge_request_widget_graphql, @project, default_enabled: :yaml)
push_frontend_feature_flag(:drag_comment_selection, @project, default_enabled: true)
push_frontend_feature_flag(:unified_diff_components, @project, default_enabled: true)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project, default_enabled: :yaml)
@@ -41,12 +40,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true)
push_frontend_feature_flag(:diffs_gradual_load, @project, default_enabled: true)
push_frontend_feature_flag(:codequality_backend_comparison, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:suggestions_custom_commit, @project, default_enabled: true)
push_frontend_feature_flag(:local_file_reviews, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_notes, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:ci_mini_pipeline_gl_dropdown, @project, type: :development, default_enabled: :yaml)
+ push_frontend_feature_flag(:new_pipelines_table, @project, default_enabled: :yaml)
- record_experiment_user(:invite_members_version_a)
record_experiment_user(:invite_members_version_b)
end
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index 71a93701dc4..77fd7688caf 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -10,7 +10,6 @@ class Projects::NotesController < Projects::ApplicationController
before_action :authorize_read_note!
before_action :authorize_create_note!, only: [:create]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
- before_action :create_rate_limit, only: [:create]
feature_category :issue_tracking
@@ -91,20 +90,4 @@ class Projects::NotesController < Projects::ApplicationController
def whitelist_query_limiting
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42383')
end
-
- def create_rate_limit
- key = :notes_create
- return unless rate_limiter.throttled?(key, scope: [current_user], users_allowlist: rate_limit_users_allowlist)
-
- rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user)
- render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests
- end
-
- def rate_limiter
- ::Gitlab::ApplicationRateLimiter
- end
-
- def rate_limit_users_allowlist
- Gitlab::CurrentSettings.current_application_settings.notes_create_limit_allowlist
- end
end
diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb
index 1702783b10f..25ec7ab1335 100644
--- a/app/controllers/projects/pipelines/tests_controller.rb
+++ b/app/controllers/projects/pipelines/tests_controller.rb
@@ -32,7 +32,7 @@ module Projects
# rubocop: disable CodeReuse/ActiveRecord
def builds
- @builds ||= pipeline.latest_builds.for_ids(build_ids).presence || render_404
+ @builds ||= pipeline.latest_builds.id_in(build_ids).presence || render_404
end
def build_ids
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 59b14bbb91d..9b5f5871c41 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -13,12 +13,11 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_create_pipeline!, only: [:new, :create, :config_variables]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
- push_frontend_feature_flag(:pipelines_security_report_summary, project)
- push_frontend_feature_flag(:new_pipeline_form, project, default_enabled: true)
+ push_frontend_feature_flag(:new_pipeline_form, project, default_enabled: :yaml)
push_frontend_feature_flag(:graphql_pipeline_details, project, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:graphql_pipeline_details_users, current_user, type: :development, default_enabled: :yaml)
- push_frontend_feature_flag(:ci_mini_pipeline_gl_dropdown, project, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:jira_for_vulnerabilities, project, type: :development, default_enabled: :yaml)
+ push_frontend_feature_flag(:new_pipelines_table, project, default_enabled: :yaml)
end
before_action :ensure_pipeline, only: [:show]
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index a7c7839dc9f..5972b29a298 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -8,10 +8,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
- before_action do
- push_frontend_feature_flag(:vue_project_members_list, @project, default_enabled: :yaml)
- end
-
feature_category :authentication_and_authorization
def index
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index d4f7d0bc521..84b155c8002 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -21,6 +21,7 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
def protected_ref_params(*attrs)
attrs = ([:name,
+ :allow_force_push,
merge_access_levels_attributes: access_level_attributes,
push_access_levels_attributes: access_level_attributes] + attrs).uniq
diff --git a/app/controllers/projects/security/configuration_controller.rb b/app/controllers/projects/security/configuration_controller.rb
index 9366ca7b0ed..bc4e58e54a9 100644
--- a/app/controllers/projects/security/configuration_controller.rb
+++ b/app/controllers/projects/security/configuration_controller.rb
@@ -3,19 +3,13 @@
module Projects
module Security
class ConfigurationController < Projects::ApplicationController
+ include SecurityAndCompliancePermissions
+
feature_category :static_application_security_testing
def show
- return render_404 unless feature_enabled?
-
render_403 unless can?(current_user, :read_security_configuration, project)
end
-
- private
-
- def feature_enabled?
- ::Feature.enabled?(:secure_security_and_compliance_configuration_page_on_ce, @project, default_enabled: :yaml)
- end
end
end
end
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index b5c73f29784..554eb01defe 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -12,8 +12,6 @@ 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(:jira_issues_integration, @project, type: :licensed, default_enabled: true)
- push_frontend_feature_flag(:jira_vulnerabilities_integration, @project, type: :licensed, default_enabled: true)
push_frontend_feature_flag(:jira_for_vulnerabilities, @project, type: :development, default_enabled: :yaml)
end
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index f8155b77e60..c407b15e29f 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -6,10 +6,6 @@ module Projects
before_action :authorize_admin_operations!
before_action :authorize_read_prometheus_alerts!, only: [:reset_alerting_token]
- before_action do
- push_frontend_feature_flag(:multiple_http_integrations_custom_mapping, @project)
- end
-
respond_to :json, only: [:reset_alerting_token, :reset_pagerduty_token]
helper_method :error_tracking_setting
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index 779e149bb9c..ff28c3be298 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -22,6 +22,7 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
.execute
.page(params[:page])
.inc_author
+ .inc_statistics
return if redirect_out_of_range(@snippets)
diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb
index ab05c9694fd..b4b8fb97049 100644
--- a/app/controllers/projects/templates_controller.rb
+++ b/app/controllers/projects/templates_controller.rb
@@ -25,7 +25,7 @@ class Projects::TemplatesController < Projects::ApplicationController
def names
respond_to do |format|
- format.json { render json: TemplateFinder.all_template_names_array(project, params[:template_type].to_s.pluralize) }
+ format.json { render json: TemplateFinder.all_template_names_hash_or_array(project, params[:template_type].to_s) }
end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index ebffb62cff3..bc48ebd1c74 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -31,10 +31,6 @@ class ProjectsController < Projects::ApplicationController
# Project Export Rate Limit
before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export]
- before_action do
- push_frontend_feature_flag(:vue_notification_dropdown, @project, default_enabled: :yaml)
- end
-
before_action only: [:edit] do
push_frontend_feature_flag(:allow_editing_commit_messages, @project)
end
@@ -74,7 +70,11 @@ class ProjectsController < Projects::ApplicationController
@project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute
if @project.saved?
- experiment(:new_project_readme, actor: current_user).track(:created, property: active_new_project_tab)
+ experiment(:new_project_readme, actor: current_user).track(
+ :created,
+ property: active_new_project_tab,
+ value: project_params[:initialize_with_readme].to_i
+ )
redirect_to(
project_path(@project, custom_import_params),
notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name }
@@ -90,21 +90,13 @@ class ProjectsController < Projects::ApplicationController
# Refresh the repo in case anything changed
@repository = @project.repository
- respond_to do |format|
- if result[:status] == :success
- flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name }
-
- format.html do
- redirect_to(edit_project_path(@project, anchor: 'js-general-project-settings'))
- end
- else
- flash[:alert] = result[:message]
- @project.reset
-
- format.html { render_edit }
- end
-
- format.js
+ if result[:status] == :success
+ flash[:notice] = _("Project '%{project_name}' was successfully updated.") % { project_name: @project.name }
+ redirect_to(edit_project_path(@project, anchor: 'js-general-project-settings'))
+ else
+ flash[:alert] = result[:message]
+ @project.reset
+ render 'edit'
end
end
@@ -329,6 +321,11 @@ class ProjectsController < Projects::ApplicationController
if can?(current_user, :download_code, @project)
return render 'projects/no_repo' unless @project.repository_exists?
+ if @project.can_current_user_push_to_default_branch?
+ property = @project.empty_repo? ? 'empty' : 'nonempty'
+ experiment(:empty_repo_upload, project: @project).track(:view_project_show, property: property)
+ end
+
if @project.empty_repo?
record_experiment_user(:invite_members_empty_project_version_a)
@@ -393,6 +390,7 @@ class ProjectsController < Projects::ApplicationController
metrics_dashboard_access_level
analytics_access_level
operations_access_level
+ security_and_compliance_access_level
]
end
@@ -523,7 +521,7 @@ class ProjectsController < Projects::ApplicationController
def export_rate_limit
prefixed_action = "project_#{params[:action]}".to_sym
- project_scope = params[:action] == :download_export ? @project : nil
+ project_scope = params[:action] == 'download_export' ? @project : nil
if rate_limiter.throttled?(prefixed_action, scope: [current_user, project_scope].compact)
rate_limiter.log_request(request, "#{prefixed_action}_request_limit".to_sym, current_user)
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
index 9ad700404ff..d68ba80ab5d 100644
--- a/app/controllers/repositories/git_http_controller.rb
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -78,11 +78,11 @@ module Repositories
def update_fetch_statistics
return unless project
return if Gitlab::Database.read_only?
- return if Feature.enabled?(:disable_git_http_fetch_writes)
-
return unless repo_type.project?
- OnboardingProgressService.new(project.namespace).execute(action: :git_read)
+ OnboardingProgressService.async(project.namespace_id).execute(action: :git_pull)
+
+ return if Feature.enabled?(:disable_git_http_fetch_writes)
if Feature.enabled?(:project_statistics_sync, project, default_enabled: true)
Projects::FetchStatisticsIncrementService.new(project).execute
diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb
index 191134472c2..dab2f3bd67a 100644
--- a/app/controllers/root_controller.rb
+++ b/app/controllers/root_controller.rb
@@ -46,6 +46,8 @@ class RootController < Dashboard::ProjectsController
redirect_to(activity_dashboard_path)
when 'starred_project_activity'
redirect_to(activity_dashboard_path(filter: 'starred'))
+ when 'followed_user_activity'
+ redirect_to(activity_dashboard_path(filter: 'followed'))
when 'groups'
redirect_to(dashboard_groups_path)
when 'todos'
@@ -69,7 +71,7 @@ class RootController < Dashboard::ProjectsController
end
def customize_homepage
- @customize_homepage = experiment_enabled?(:customize_homepage)
+ @customize_homepage = Feature.enabled?(:customize_homepage, default_enabled: :yaml)
end
end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 820b00a902e..45c1c35a655 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -49,6 +49,12 @@ class SearchController < ApplicationController
scope = search_service.scope
count = search_service.search_results.formatted_count(scope)
+ # Users switching tabs will keep fetching the same tab counts so it's a
+ # good idea to cache in their browser just for a short time. They can still
+ # clear cache if they are seeing an incorrect count but inaccurate count is
+ # not such a bad thing.
+ expires_in 1.minute
+
render json: { count: count }
end
@@ -125,7 +131,7 @@ class SearchController < ApplicationController
payload[:metadata] ||= {}
payload[:metadata]['meta.search.group_id'] = params[:group_id]
payload[:metadata]['meta.search.project_id'] = params[:project_id]
- payload[:metadata]['meta.search.scope'] = params[:scope]
+ payload[:metadata]['meta.search.scope'] = params[:scope] || @scope
payload[:metadata]['meta.search.filters.confidential'] = params[:confidential]
payload[:metadata]['meta.search.filters.state'] = params[:state]
payload[:metadata]['meta.search.force_search_results'] = params[:force_search_results]
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 913b1e3bb6e..1c6168dbc2c 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -24,6 +24,7 @@ class SnippetsController < Snippets::ApplicationController
.execute
.page(params[:page])
.inc_author
+ .inc_statistics
return if redirect_out_of_range(@snippets)
diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb
index cfec9d6d905..df3e2425e9f 100644
--- a/app/controllers/user_callouts_controller.rb
+++ b/app/controllers/user_callouts_controller.rb
@@ -4,10 +4,11 @@ class UserCalloutsController < ApplicationController
feature_category :navigation
def create
- callout = ensure_callout
+ callout = Users::DismissUserCalloutService.new(
+ container: nil, current_user: current_user, params: { feature_name: feature_name }
+ ).execute
if callout.persisted?
- callout.update(dismissed_at: Time.current)
respond_to do |format|
format.json { head :ok }
end
@@ -20,12 +21,6 @@ class UserCalloutsController < ApplicationController
private
- # rubocop: disable CodeReuse/ActiveRecord
- def ensure_callout
- current_user.callouts.find_or_create_by(feature_name: UserCallout.feature_names[feature_name])
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def feature_name
params.require(:feature_name)
end
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
index 317514d088b..ed0d146af8c 100644
--- a/app/experiments/application_experiment.rb
+++ b/app/experiments/application_experiment.rb
@@ -3,19 +3,28 @@
class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/NamespacedClass
def enabled?
return false if Feature::Definition.get(feature_flag_name).nil? # there has to be a feature flag yaml file
- return false unless Gitlab.dev_env_or_com? # we're in an environment that allows experiments
+ return false unless Gitlab.dev_env_or_com? # we have to be in an environment that allows experiments
+ # the feature flag has to be rolled out
Feature.get(feature_flag_name).state != :off # rubocop:disable Gitlab/AvoidFeatureGet
end
- def publish(_result)
+ def publish(_result = nil)
+ return unless should_track? # don't track events for excluded contexts
+
track(:assignment) # track that we've assigned a variant for this context
- Gon.global.push({ experiment: { name => signature } }, true) # push to client
+
+ begin
+ Gon.push({ experiment: { name => signature } }, true) # push the experiment data to the client
+ rescue NoMethodError
+ # means we're not in the request cycle, and can't add to Gon. Log a warning maybe?
+ end
end
def track(action, **event_args)
- return unless should_track? # no events for opted out actors or excluded subjects
+ return unless should_track? # don't track events for excluded contexts
+ # track the event, and mix in the experiment signature data
Gitlab::Tracking.event(name, action.to_s, **event_args.merge(
context: (event_args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
'iglu:com.gitlab/gitlab_experiment/jsonschema/0-3-0', signature
@@ -23,16 +32,8 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
))
end
- def rollout_strategy
- # no-op override in inherited class as desired
- end
-
- def variants
- # override as desired in inherited class with all variants + control
- # %i[variant1 variant2 control]
- #
- # this will make sure we supply variants as these go together - rollout_strategy of :round_robin must have variants
- raise NotImplementedError, "Inheriting class must supply variants as an array if :round_robin strategy is used" if rollout_strategy == :round_robin
+ def exclude!
+ @excluded = true
end
private
@@ -41,80 +42,7 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
name.tr('/', '_')
end
- def resolve_variant_name
- case rollout_strategy
- when :round_robin
- round_robin_rollout
- else
- percentage_rollout
- end
- end
-
- def round_robin_rollout
- Strategy::RoundRobin.new(feature_flag_name, variants).execute
- end
-
- def percentage_rollout
- return variant_names.first if Feature.enabled?(feature_flag_name, self, type: :experiment, default_enabled: :yaml)
-
- nil # Returning nil vs. :control is important for not caching and rollouts.
- end
-
- # Cache is an implementation on top of Gitlab::Redis::SharedState that also
- # adheres to the ActiveSupport::Cache::Store interface and uses the redis
- # hash data type.
- #
- # Since Gitlab::Experiment can use any type of caching layer, utilizing the
- # long lived shared state interface here gives us an efficient way to store
- # context keys and the variant they've been assigned -- while also giving us
- # a simple way to clean up an experiments data upon resolution.
- #
- # The data structure:
- # key: experiment.name
- # fields: context key => variant name
- #
- # The keys are expected to be `experiment_name:context_key`, which is the
- # default cache key strategy. So running `cache.fetch("foo:bar", "value")`
- # would create/update a hash with the key of "foo", with a field named
- # "bar" that has "value" assigned to it.
- class Cache < ActiveSupport::Cache::Store # rubocop:disable Gitlab/NamespacedClass
- # Clears the entire cache for a given experiment. Be careful with this
- # since it would reset all resolved variants for the entire experiment.
- def clear(key:)
- key = hkey(key)[0] # extract only the first part of the key
- pool do |redis|
- case redis.type(key)
- when 'hash', 'none' then redis.del(key)
- else raise ArgumentError, 'invalid call to clear a non-hash cache key'
- end
- end
- end
-
- private
-
- def pool
- raise ArgumentError, 'missing block' unless block_given?
-
- Gitlab::Redis::SharedState.with { |redis| yield redis }
- end
-
- def hkey(key)
- key.to_s.split(':') # this assumes the default strategy in gitlab-experiment
- end
-
- def read_entry(key, **options)
- value = pool { |redis| redis.hget(*hkey(key)) }
- value.nil? ? nil : ActiveSupport::Cache::Entry.new(value)
- end
-
- def write_entry(key, entry, **options)
- return false if entry.value.blank? # don't cache any empty values
-
- pool { |redis| redis.hset(*hkey(key), entry.value) }
- end
-
- def delete_entry(key, **options)
- pool { |redis| redis.hdel(*hkey(key)) }
- end
+ def experiment_group?
+ Feature.enabled?(feature_flag_name, self, type: :experiment, default_enabled: :yaml)
end
end
diff --git a/app/experiments/members/invite_email_experiment.rb b/app/experiments/members/invite_email_experiment.rb
index 4a03ebb7726..a0f4dee2866 100644
--- a/app/experiments/members/invite_email_experiment.rb
+++ b/app/experiments/members/invite_email_experiment.rb
@@ -7,12 +7,8 @@ module Members
INVITE_TYPE = 'initial_email'
- def rollout_strategy
- :round_robin
- end
-
- def variants
- %i[avatar permission_info control]
+ def resolve_variant_name
+ Strategy::RoundRobin.new(feature_flag_name, %i[avatar permission_info control]).execute
end
end
end
diff --git a/app/finders/admin/plans_finder.rb b/app/finders/admin/plans_finder.rb
new file mode 100644
index 00000000000..5ca4b61b25e
--- /dev/null
+++ b/app/finders/admin/plans_finder.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Admin
+ class PlansFinder
+ attr_reader :params
+
+ def initialize(params = {})
+ @params = params
+ end
+
+ def execute
+ plans = Plan.all
+ by_name(plans)
+ end
+
+ private
+
+ def by_name(plans)
+ return plans unless params[:name]
+
+ Plan.find_by(name: params[:name]) # rubocop: disable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/services/boards/list_service.rb b/app/finders/boards/boards_finder.rb
index 80ceb91f56d..5b8c313a178 100644
--- a/app/services/boards/list_service.rb
+++ b/app/finders/boards/boards_finder.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Boards
- class ListService < Boards::BaseService
+ class BoardsFinder < Boards::BaseService
def execute
find_boards
end
diff --git a/app/finders/ci/daily_build_group_report_results_finder.rb b/app/finders/ci/daily_build_group_report_results_finder.rb
index ef97ccb4c0f..9e736c70dda 100644
--- a/app/finders/ci/daily_build_group_report_results_finder.rb
+++ b/app/finders/ci/daily_build_group_report_results_finder.rb
@@ -1,56 +1,99 @@
# frozen_string_literal: true
+# DailyBuildGroupReportResultsFinder
+#
+# Used to filter DailyBuildGroupReportResults by set of params
+#
+# Arguments:
+# current_user
+# params:
+# project: integer
+# group: integer
+# coverage: boolean
+# ref_path: string
+# start_date: string
+# end_date: string
+# sort: boolean
+# limit: integer
+
module Ci
class DailyBuildGroupReportResultsFinder
include Gitlab::Allowable
- def initialize(current_user:, project:, ref_path: nil, start_date:, end_date:, limit: nil)
+ MAX_ITEMS = 1_000
+ REPORT_WINDOW = 90.days
+ DATE_FORMAT_ALLOWED = '%Y-%m-%d'
+
+ attr_reader :params, :current_user
+
+ def initialize(params: {}, current_user: nil)
+ @params = params
@current_user = current_user
- @project = project
- @ref_path = ref_path
- @start_date = start_date
- @end_date = end_date
- @limit = limit
end
def execute
- return none unless query_allowed?
+ return Ci::DailyBuildGroupReportResult.none unless query_allowed?
- query
+ collection = Ci::DailyBuildGroupReportResult.by_projects(params[:project])
+ collection = filter_report_results(collection)
+ collection
end
- protected
+ private
- attr_reader :current_user, :project, :ref_path, :start_date, :end_date, :limit
+ def query_allowed?
+ can?(current_user, :read_build_report_results, params[:project])
+ end
- def query
- Ci::DailyBuildGroupReportResult.recent_results(
- query_params,
- limit: limit
- )
+ def filter_report_results(collection)
+ collection = by_coverage(collection)
+ collection = by_ref_path(collection)
+ collection = by_dates(collection)
+
+ collection = sort(collection)
+ collection = limit_by(collection)
+ collection
end
- def query_allowed?
- can?(current_user, :read_build_report_results, project)
+ def by_coverage(items)
+ params[:coverage].present? ? items.with_coverage : items
+ end
+
+ def by_ref_path(items)
+ params[:ref_path].present? ? items.by_ref_path(params[:ref_path]) : items.with_default_branch
end
- def query_params
- params = {
- project_id: project,
- date: start_date..end_date
- }
+ def by_dates(items)
+ params[:start_date].present? && params[:end_date].present? ? items.by_dates(start_date, end_date) : items
+ end
- if ref_path.present?
- params[:ref_path] = ref_path
- else
- params[:default_branch] = true
- end
+ def sort(items)
+ params[:sort].present? ? items.ordered_by_date_and_group_name : items
+ end
- params
+ # rubocop: disable CodeReuse/ActiveRecord
+ def limit_by(items)
+ items.limit(limit)
end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def limit
+ return MAX_ITEMS unless params[:limit].present?
- def none
- Ci::DailyBuildGroupReportResult.none
+ [params[:limit].to_i, MAX_ITEMS].min
+ end
+
+ def start_date
+ start_date = Date.strptime(params[:start_date], DATE_FORMAT_ALLOWED) rescue REPORT_WINDOW.ago.to_date
+
+ # The start_date cannot be older than `end_date - 90 days`
+ [start_date, end_date - REPORT_WINDOW].max
+ end
+
+ def end_date
+ Date.strptime(params[:end_date], DATE_FORMAT_ALLOWED) rescue Date.current
end
end
end
+
+Ci::DailyBuildGroupReportResultsFinder.prepend_if_ee('::EE::Ci::DailyBuildGroupReportResultsFinder')
diff --git a/app/finders/ci/testing/daily_build_group_report_results_finder.rb b/app/finders/ci/testing/daily_build_group_report_results_finder.rb
deleted file mode 100644
index 70d9e55dc47..00000000000
--- a/app/finders/ci/testing/daily_build_group_report_results_finder.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-# frozen_string_literal: true
-
-# DailyBuildGroupReportResultsFinder
-#
-# Used to filter DailyBuildGroupReportResults by set of params
-#
-# Arguments:
-# current_user
-# params:
-# project: integer
-# group: integer
-# coverage: boolean
-# ref_path: string
-# start_date: date
-# end_date: date
-# sort: boolean
-# limit: integer
-
-module Ci
- module Testing
- class DailyBuildGroupReportResultsFinder
- include Gitlab::Allowable
-
- MAX_ITEMS = 1_000
-
- attr_reader :params, :current_user
-
- def initialize(params: {}, current_user: nil)
- @params = params
- @current_user = current_user
- end
-
- def execute
- return Ci::DailyBuildGroupReportResult.none unless query_allowed?
-
- collection = Ci::DailyBuildGroupReportResult.by_projects(params[:project])
- collection = filter_report_results(collection)
- collection
- end
-
- private
-
- def query_allowed?
- can?(current_user, :read_build_report_results, params[:project])
- end
-
- def filter_report_results(collection)
- collection = by_coverage(collection)
- collection = by_ref_path(collection)
- collection = by_dates(collection)
-
- collection = sort(collection)
- collection = limit_by(collection)
- collection
- end
-
- def by_coverage(items)
- params[:coverage].present? ? items.with_coverage : items
- end
-
- def by_ref_path(items)
- params[:ref_path].present? ? items.by_ref_path(params[:ref_path]) : items.with_default_branch
- end
-
- def by_dates(items)
- params[:start_date].present? && params[:end_date].present? ? items.by_dates(params[:start_date], params[:end_date]) : items
- end
-
- def sort(items)
- params[:sort].present? ? items.ordered_by_date_and_group_name : items
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def limit_by(items)
- items.limit(limit)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def limit
- return MAX_ITEMS unless params[:limit].present?
-
- [params[:limit].to_i, MAX_ITEMS].min
- end
- end
- end
-end
-
-Ci::Testing::DailyBuildGroupReportResultsFinder.prepend_if_ee('::EE::Ci::Testing::DailyBuildGroupReportResultsFinder')
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index fc03d5cd90c..2409dc9d77d 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -47,6 +47,7 @@ class IssuableFinder
NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze
attr_accessor :current_user, :params
+ attr_reader :original_params
attr_writer :parent
delegate(*%i[assignee milestones], to: :params)
@@ -87,7 +88,7 @@ class IssuableFinder
end
def valid_params
- @valid_params ||= scalar_params + [array_params.merge(not: {})]
+ @valid_params ||= scalar_params + [array_params.merge(or: {}, not: {})]
end
end
@@ -101,6 +102,7 @@ class IssuableFinder
def initialize(current_user, params = {})
@current_user = current_user
+ @original_params = params
@params = params_class.new(params, current_user, klass)
end
@@ -142,7 +144,7 @@ class IssuableFinder
end
def should_filter_negated_args?
- return false unless Feature.enabled?(:not_issuable_queries, params.group || params.project, default_enabled: true)
+ return false unless not_filters_enabled?
# API endpoints send in `nil` values so we test if there are any non-nil
not_params.present? && not_params.values.any?
@@ -150,7 +152,6 @@ class IssuableFinder
# Negates all params found in `negatable_params`
def filter_negated_items(items)
- items = by_negated_author(items)
items = by_negated_assignee(items)
items = by_negated_label(items)
items = by_negated_milestone(items)
@@ -372,31 +373,14 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def by_author(items)
- if params.author
- items.where(author_id: params.author.id)
- elsif params.no_author?
- items.where(author_id: nil)
- elsif params.author_id? || params.author_username? # author not found
- items.none
- else
- items
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # rubocop: disable CodeReuse/ActiveRecord
- def by_negated_author(items)
- if not_params.author
- items.where.not(author_id: not_params.author.id)
- elsif not_params.author_id? || not_params.author_username? # author not found
- items.none
- else
- items
- end
+ Issuables::AuthorFilter.new(
+ items,
+ params: original_params,
+ or_filters_enabled: or_filters_enabled?,
+ not_filters_enabled: not_filters_enabled?
+ ).filter
end
- # rubocop: enable CodeReuse/ActiveRecord
def by_assignee(items)
if params.filter_by_no_assignee?
@@ -514,4 +498,20 @@ class IssuableFinder
def by_non_archived(items)
params[:non_archived].present? ? items.non_archived : items
end
+
+ def or_filters_enabled?
+ strong_memoize(:or_filters_enabled) do
+ Feature.enabled?(:or_issuable_queries, feature_flag_scope, default_enabled: :yaml)
+ end
+ end
+
+ def not_filters_enabled?
+ strong_memoize(:not_filters_enabled) do
+ Feature.enabled?(:not_issuable_queries, feature_flag_scope, default_enabled: :yaml)
+ end
+ end
+
+ def feature_flag_scope
+ params.group || params.project
+ end
end
diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb
index 803b30e86ac..a62210ceac5 100644
--- a/app/finders/issuable_finder/params.rb
+++ b/app/finders/issuable_finder/params.rb
@@ -27,19 +27,6 @@ class IssuableFinder
params.present?
end
- def author_id?
- params[:author_id].present? && params[:author_id] != NONE
- end
-
- def author_username?
- params[:author_username].present? && params[:author_username] != NONE
- end
-
- def no_author?
- # author_id takes precedence over author_username
- params[:author_id] == NONE || params[:author_username] == NONE
- end
-
def filter_by_no_assignee?
params[:assignee_id].to_s.downcase == FILTER_NONE
end
@@ -170,20 +157,6 @@ class IssuableFinder
end
# rubocop: disable CodeReuse/ActiveRecord
- def author
- strong_memoize(:author) do
- if author_id?
- User.find_by(id: params[:author_id])
- elsif author_username?
- User.find_by_username(params[:author_username])
- else
- nil
- end
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # rubocop: disable CodeReuse/ActiveRecord
def assignees
strong_memoize(:assignees) do
if assignee_id?
diff --git a/app/finders/issuables/author_filter.rb b/app/finders/issuables/author_filter.rb
new file mode 100644
index 00000000000..ce68dbafb95
--- /dev/null
+++ b/app/finders/issuables/author_filter.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Issuables
+ class AuthorFilter < BaseFilter
+ def filter
+ filtered = by_author(issuables)
+ filtered = by_author_union(filtered)
+ by_negated_author(filtered)
+ end
+
+ private
+
+ def by_author(issuables)
+ if params[:author_id].present?
+ issuables.authored(params[:author_id])
+ elsif params[:author_username].present?
+ issuables.authored(User.by_username(params[:author_username]))
+ else
+ issuables
+ end
+ end
+
+ def by_author_union(issuables)
+ return issuables unless or_filters_enabled? && or_params&.fetch(:author_username).present?
+
+ issuables.authored(User.by_username(or_params[:author_username]))
+ end
+
+ def by_negated_author(issuables)
+ return issuables unless not_filters_enabled? && not_params.present?
+
+ if not_params[:author_id].present?
+ issuables.not_authored(not_params[:author_id])
+ elsif not_params[:author_username].present?
+ issuables.not_authored(User.by_username(not_params[:author_username]))
+ else
+ issuables
+ end
+ end
+ end
+end
diff --git a/app/finders/issuables/base_filter.rb b/app/finders/issuables/base_filter.rb
new file mode 100644
index 00000000000..641ae2568cc
--- /dev/null
+++ b/app/finders/issuables/base_filter.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Issuables
+ class BaseFilter
+ attr_reader :issuables, :params
+
+ def initialize(issuables, params:, or_filters_enabled: false, not_filters_enabled: false)
+ @issuables = issuables
+ @params = params
+ @or_filters_enabled = or_filters_enabled
+ @not_filters_enabled = not_filters_enabled
+ end
+
+ def filter
+ raise NotImplementedError
+ end
+
+ private
+
+ def or_params
+ params[:or]
+ end
+
+ def not_params
+ params[:not]
+ end
+
+ def or_filters_enabled?
+ @or_filters_enabled
+ end
+
+ def not_filters_enabled?
+ @not_filters_enabled
+ end
+ end
+end
diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb
index 85a73e0c6ff..dc9b28ab0a0 100644
--- a/app/finders/merge_request_target_project_finder.rb
+++ b/app/finders/merge_request_target_project_finder.rb
@@ -5,29 +5,30 @@ class MergeRequestTargetProjectFinder
attr_reader :current_user, :source_project
- def initialize(current_user: nil, source_project:)
+ def initialize(current_user: nil, source_project:, project_feature: :merge_requests)
@current_user = current_user
@source_project = source_project
+ @project_feature = project_feature
end
- # rubocop: disable CodeReuse/ActiveRecord
def execute(include_routes: false)
if source_project.fork_network
include_routes ? projects.inc_routes : projects
else
- Project.where(id: source_project)
+ Project.id_in(source_project.id)
end
end
- # rubocop: enable CodeReuse/ActiveRecord
private
+ attr_reader :project_feature
+
def projects
source_project
.fork_network
.projects
.public_or_visible_to_user(current_user)
.non_archived
- .with_feature_available_for_user(:merge_requests, current_user)
+ .with_feature_available_for_user(project_feature, current_user)
end
end
diff --git a/app/finders/merge_requests/oldest_per_commit_finder.rb b/app/finders/merge_requests/oldest_per_commit_finder.rb
index f50db43d7d2..5360f301036 100644
--- a/app/finders/merge_requests/oldest_per_commit_finder.rb
+++ b/app/finders/merge_requests/oldest_per_commit_finder.rb
@@ -15,19 +15,45 @@ module MergeRequests
# Returns a Hash that maps a commit ID to the oldest merge request that
# introduced that commit.
def execute(commits)
+ mapping = {}
+ shas = commits.map(&:id)
+
+ # To include merge requests by the commit SHA, we don't need to go through
+ # any diff rows.
+ #
+ # We can't squeeze all this into a single query, as the diff based data
+ # relies on a GROUP BY. On the other hand, retrieving MRs by their merge
+ # SHAs separately is much easier, and plenty fast.
+ @project
+ .merge_requests
+ .preload_target_project
+ .by_merge_commit_sha(shas)
+ .each do |mr|
+ # Merge SHAs can't be in the merge request itself. It _is_ possible a
+ # newer merge request includes the merge commit, but in that case we
+ # still want the oldest merge request.
+ mapping[mr.merge_commit_sha] = mr
+ end
+
+ remaining = shas - mapping.keys
+
+ return mapping if remaining.empty?
+
id_rows = MergeRequestDiffCommit
- .oldest_merge_request_id_per_commit(@project.id, commits.map(&:id))
+ .oldest_merge_request_id_per_commit(@project.id, remaining)
mrs = MergeRequest
.preload_target_project
.id_in(id_rows.map { |r| r[:merge_request_id] })
.index_by(&:id)
- id_rows.each_with_object({}) do |row, hash|
+ id_rows.each do |row|
if (mr = mrs[row[:merge_request_id]])
- hash[row[:sha]] = mr
+ mapping[row[:sha]] = mr
end
end
+
+ mapping
end
end
end
diff --git a/app/finders/namespaces/projects_finder.rb b/app/finders/namespaces/projects_finder.rb
new file mode 100644
index 00000000000..a6d98015e9d
--- /dev/null
+++ b/app/finders/namespaces/projects_finder.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+# Namespaces::ProjectsFinder
+#
+# Used to filter Projects by set of params
+#
+# Arguments:
+# current_user
+# namespace
+# params:
+# sort: string
+# search: string
+# include_subgroups: boolean
+# ids: int[]
+#
+module Namespaces
+ class ProjectsFinder
+ def initialize(namespace: nil, current_user: nil, params: {})
+ @namespace = namespace
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ return Project.none if namespace.nil?
+
+ collection = if params[:include_subgroups].present?
+ namespace.all_projects.with_route
+ else
+ namespace.projects.with_route
+ end
+
+ filter_projects(collection)
+ end
+
+ private
+
+ attr_reader :namespace, :params, :current_user
+
+ def filter_projects(collection)
+ collection = by_ids(collection)
+ collection = by_similarity(collection)
+ collection
+ end
+
+ def by_ids(items)
+ return items unless params[:ids].present?
+
+ items.id_in(params[:ids])
+ end
+
+ def by_similarity(items)
+ return items unless params[:search].present?
+
+ if params[:sort] == :similarity
+ items = items.sorted_by_similarity_desc(params[:search], include_in_select: true)
+ end
+
+ items.merge(Project.search(params[:search]))
+ end
+ end
+end
+
+Namespaces::ProjectsFinder.prepend_if_ee('::EE::Namespaces::ProjectsFinder')
diff --git a/app/finders/packages/maven/package_finder.rb b/app/finders/packages/maven/package_finder.rb
index 7e753705cbd..ba3d4631f55 100644
--- a/app/finders/packages/maven/package_finder.rb
+++ b/app/finders/packages/maven/package_finder.rb
@@ -59,9 +59,8 @@ module Packages
# 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)
+ group.all_projects
+ .public_or_visible_to_user(current_user)
end
end
end
diff --git a/app/finders/packages/npm/package_finder.rb b/app/finders/packages/npm/package_finder.rb
index 2854226e178..3b79785d0e1 100644
--- a/app/finders/packages/npm/package_finder.rb
+++ b/app/finders/packages/npm/package_finder.rb
@@ -2,29 +2,40 @@
module Packages
module Npm
class PackageFinder
- attr_reader :project, :package_name
-
delegate :find_by_version, to: :execute
+ delegate :last, to: :execute
- def initialize(project, package_name)
- @project = project
+ def initialize(package_name, project: nil, namespace: nil)
@package_name = package_name
+ @project = project
+ @namespace = namespace
end
def execute
- return Packages::Package.none unless project
-
- packages
+ base.npm
+ .with_name(@package_name)
+ .last_of_each_version
+ .preload_files
end
private
- def packages
- project.packages
- .npm
- .with_name(package_name)
- .last_of_each_version
- .preload_files
+ def base
+ if @project
+ packages_for_project
+ elsif @namespace
+ packages_for_namespace
+ else
+ ::Packages::Package.none
+ end
+ end
+
+ def packages_for_project
+ @project.packages
+ end
+
+ def packages_for_namespace
+ ::Packages::Package.for_projects(@namespace.all_projects)
end
end
end
diff --git a/app/finders/projects/groups_finder.rb b/app/finders/projects/groups_finder.rb
new file mode 100644
index 00000000000..d0c42ad5611
--- /dev/null
+++ b/app/finders/projects/groups_finder.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+# Used to filter ancestor and shared project's Groups by a set of params
+#
+# Arguments:
+# project
+# current_user - which user is requesting groups
+# params:
+# with_shared: boolean (optional)
+# shared_min_access_level: integer (optional)
+# skip_groups: array of integers (optional)
+#
+module Projects
+ class GroupsFinder < UnionFinder
+ def initialize(project:, current_user: nil, params: {})
+ @project = project
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ return Group.none unless authorized?
+
+ items = all_groups.map do |item|
+ item = exclude_group_ids(item)
+ item
+ end
+
+ find_union(items, Group).with_route.order_id_desc
+ end
+
+ private
+
+ attr_reader :project, :current_user, :params
+
+ def authorized?
+ Ability.allowed?(current_user, :read_project, project)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def all_groups
+ groups = []
+ groups << project.group.self_and_ancestors if project.group
+
+ if params[:with_shared]
+ shared_groups = project.invited_groups
+
+ if params[:shared_min_access_level]
+ shared_groups = shared_groups.where(
+ 'project_group_links.group_access >= ?', params[:shared_min_access_level]
+ )
+ end
+
+ groups << shared_groups
+ end
+
+ groups << Group.none if groups.compact.empty?
+ groups
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def exclude_group_ids(groups)
+ return groups unless params[:skip_groups]
+
+ groups.id_not_in(params[:skip_groups])
+ end
+ end
+end
diff --git a/app/finders/repositories/commits_with_trailer_finder.rb b/app/finders/repositories/changelog_commits_finder.rb
index 4bd643c345b..b80b8e94e59 100644
--- a/app/finders/repositories/commits_with_trailer_finder.rb
+++ b/app/finders/repositories/changelog_commits_finder.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
module Repositories
- # Finder for obtaining commits between two refs, with a Git trailer set.
- class CommitsWithTrailerFinder
+ # Finder for getting the commits to include in a changelog.
+ class ChangelogCommitsFinder
# The maximum number of commits to retrieve per page.
#
# This value is arbitrarily chosen. Lowering it means more Gitaly calls, but
@@ -20,6 +20,9 @@ module Repositories
# 5-10 Gitaly calls, while keeping memory usage at a reasonable amount.
COMMITS_PER_PAGE = 1024
+ # The regex to use for extracting the SHA of a reverted commit.
+ REVERT_REGEX = /^This reverts commit (?<sha>[0-9a-f]{40})/i.freeze
+
# The `project` argument specifies the project for which to obtain the
# commits.
#
@@ -44,7 +47,7 @@ module Repositories
#
# Example:
#
- # CommitsWithTrailerFinder.new(...).each_page('Signed-off-by') do |commits|
+ # ChangelogCommitsFinder.new(...).each_page('Changelog') do |commits|
# commits.each do |commit|
# ...
# end
@@ -53,12 +56,22 @@ module Repositories
return to_enum(__method__, trailer) unless block_given?
offset = 0
+ reverted = Set.new
response = fetch_commits
while response.any?
commits = []
response.each do |commit|
+ # If the commit is reverted in the same range (by a newer commit), we
+ # won't include it. This works here because commits are processed in
+ # reverse order (= newer first).
+ next if reverted.include?(commit.id)
+
+ if (sha = revert_commit_sha(commit))
+ reverted << sha
+ end
+
commits.push(commit) if commit.trailers.key?(trailer)
end
@@ -78,5 +91,11 @@ module Repositories
.repository
.commits(range, limit: @per_page, offset: offset, trailers: true)
end
+
+ def revert_commit_sha(commit)
+ matches = commit.description&.match(REVERT_REGEX)
+
+ matches[:sha] if matches
+ end
end
end
diff --git a/app/finders/repositories/previous_tag_finder.rb b/app/finders/repositories/previous_tag_finder.rb
index 150a6332c29..b5e786c30e9 100644
--- a/app/finders/repositories/previous_tag_finder.rb
+++ b/app/finders/repositories/previous_tag_finder.rb
@@ -16,12 +16,13 @@ module Repositories
# This finder expects that all tags to consider meet the following
# requirements:
#
- # * They start with the letter "v"
- # * They use semantic versioning for the tag format
+ # * They start with the letter "v" followed by a version, or immediately start
+ # with a version
+ # * They use semantic versioning for the version format
#
# Tags not meeting these requirements are ignored.
class PreviousTagFinder
- TAG_REGEX = /\Av(?<version>#{Gitlab::Regex.unbounded_semver_regex})\z/.freeze
+ TAG_REGEX = /\Av?(?<version>#{Gitlab::Regex.unbounded_semver_regex})\z/.freeze
def initialize(project)
@project = project
@@ -36,6 +37,11 @@ module Repositories
next unless matches
+ # When using this class for generating changelog data for a range of
+ # commits, we want to compare against the tag of the last _stable_
+ # release; not some random RC that came after that.
+ next if matches[:prerelease]
+
version = matches[:version]
tags[version] = tag
versions << version
diff --git a/app/finders/security/license_compliance_jobs_finder.rb b/app/finders/security/license_compliance_jobs_finder.rb
index 100f94b2cc7..0ec428ae408 100644
--- a/app/finders/security/license_compliance_jobs_finder.rb
+++ b/app/finders/security/license_compliance_jobs_finder.rb
@@ -12,7 +12,7 @@
module Security
class LicenseComplianceJobsFinder < JobsFinder
def self.allowed_job_types
- [:license_management, :license_scanning]
+ [:license_scanning]
end
end
end
diff --git a/app/finders/template_finder.rb b/app/finders/template_finder.rb
index 36f8d144908..739beee236c 100644
--- a/app/finders/template_finder.rb
+++ b/app/finders/template_finder.rb
@@ -22,16 +22,26 @@ class TemplateFinder
end
end
+ # This is temporary and will be removed once we introduce group level inherited templates and
+ # remove the inherited_issuable_templates FF
+ def all_template_names_hash_or_array(project, issuable_type)
+ if project.inherited_issuable_templates_enabled?
+ all_template_names(project, issuable_type.pluralize)
+ else
+ all_template_names_array(project, issuable_type.pluralize)
+ end
+ end
+
def all_template_names(project, type)
return {} if !VENDORED_TEMPLATES.key?(type.to_s) && type.to_s != 'licenses'
build(type, project).template_names
end
- # This is issues and merge requests description templates only.
- # This will be removed once we introduce group level inherited templates
+ # This is for issues and merge requests description templates only.
+ # This will be removed once we introduce group level inherited templates and remove the inherited_issuable_templates FF
def all_template_names_array(project, type)
- all_template_names(project, type).values.flatten.uniq
+ all_template_names(project, type).values.flatten.select { |tmpl| tmpl[:project_id] == project.id }.compact.uniq
end
end
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
index 42042406f3f..5ac905e0dd4 100644
--- a/app/finders/users_finder.rb
+++ b/app/finders/users_finder.rb
@@ -14,6 +14,7 @@
# active: boolean
# blocked: boolean
# external: boolean
+# non_external: boolean
# without_projects: boolean
# sort: string
# id: integer
@@ -40,6 +41,7 @@ class UsersFinder
users = by_active(users)
users = by_external_identity(users)
users = by_external(users)
+ users = by_non_external(users)
users = by_2fa(users)
users = by_created_at(users)
users = by_without_projects(users)
@@ -97,13 +99,18 @@ class UsersFinder
# rubocop: disable CodeReuse/ActiveRecord
def by_external(users)
- return users = users.where.not(external: true) unless current_user&.admin?
return users unless params[:external]
users.external
end
# rubocop: enable CodeReuse/ActiveRecord
+ def by_non_external(users)
+ return users unless params[:non_external]
+
+ users.non_external
+ end
+
def by_2fa(users)
case params[:two_factor]
when 'enabled'
diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb
index d66a2333d11..7ab5dc36e4a 100644
--- a/app/graphql/gitlab_schema.rb
+++ b/app/graphql/gitlab_schema.rb
@@ -13,8 +13,6 @@ class GitlabSchema < GraphQL::Schema
use GraphQL::Pagination::Connections
use BatchLoader::GraphQL
use Gitlab::Graphql::Authorize
- use Gitlab::Graphql::Present
- use Gitlab::Graphql::CallsGitaly
use Gitlab::Graphql::Pagination::Connections
use Gitlab::Graphql::GenericTracing
use Gitlab::Graphql::Timeout, max_seconds: Gitlab.config.gitlab.graphql_timeout
diff --git a/app/graphql/mutations/boards/create.rb b/app/graphql/mutations/boards/create.rb
index 92bce557446..003c4f7761b 100644
--- a/app/graphql/mutations/boards/create.rb
+++ b/app/graphql/mutations/boards/create.rb
@@ -14,7 +14,7 @@ module Mutations
null: true,
description: 'The board after mutation.'
- authorize :admin_board
+ authorize :admin_issue_board
def resolve(args)
board_parent = authorized_resource_parent_find!(args)
@@ -22,7 +22,7 @@ module Mutations
response = ::Boards::CreateService.new(board_parent, current_user, args).execute
{
- board: response.payload,
+ board: response.success? ? response.payload : nil,
errors: response.errors
}
end
diff --git a/app/graphql/mutations/boards/destroy.rb b/app/graphql/mutations/boards/destroy.rb
index 8ec13b885d5..4a0068edee2 100644
--- a/app/graphql/mutations/boards/destroy.rb
+++ b/app/graphql/mutations/boards/destroy.rb
@@ -14,7 +14,7 @@ module Mutations
required: true,
description: 'The global ID of the board to destroy.'
- authorize :admin_board
+ authorize :admin_issue_board
def resolve(id:)
board = authorized_find!(id: id)
diff --git a/app/graphql/mutations/boards/issues/issue_move_list.rb b/app/graphql/mutations/boards/issues/issue_move_list.rb
index 91dfd9fc3e9..096ac89db1c 100644
--- a/app/graphql/mutations/boards/issues/issue_move_list.rb
+++ b/app/graphql/mutations/boards/issues/issue_move_list.rb
@@ -5,35 +5,38 @@ module Mutations
module Issues
class IssueMoveList < Mutations::Issues::Base
graphql_name 'IssueMoveList'
+ BoardGID = ::Types::GlobalIDType[::Board]
+ ListID = ::GraphQL::ID_TYPE
+ IssueID = ::GraphQL::ID_TYPE
- argument :board_id, GraphQL::ID_TYPE,
- required: true,
- loads: Types::BoardType,
- description: 'Global ID of the board that the issue is in.'
+ argument :board_id, BoardGID,
+ required: true,
+ loads: Types::BoardType,
+ description: 'Global ID of the board that the issue is in.'
argument :project_path, GraphQL::ID_TYPE,
- required: true,
- description: 'Project the issue to mutate is in.'
+ required: true,
+ description: 'Project the issue to mutate is in.'
argument :iid, GraphQL::STRING_TYPE,
- required: true,
- description: 'IID of the issue to mutate.'
+ required: true,
+ description: 'IID of the issue to mutate.'
- argument :from_list_id, GraphQL::ID_TYPE,
- required: false,
- description: 'ID of the board list that the issue will be moved from.'
+ argument :from_list_id, ListID,
+ required: false,
+ description: 'ID of the board list that the issue will be moved from.'
- argument :to_list_id, GraphQL::ID_TYPE,
- required: false,
- description: 'ID of the board list that the issue will be moved to.'
+ argument :to_list_id, ListID,
+ required: false,
+ description: 'ID of the board list that the issue will be moved to.'
- argument :move_before_id, GraphQL::ID_TYPE,
- required: false,
- description: 'ID of issue that should be placed before the current issue.'
+ argument :move_before_id, IssueID,
+ required: false,
+ description: 'ID of issue that should be placed before the current issue.'
- argument :move_after_id, GraphQL::ID_TYPE,
- required: false,
- description: 'ID of issue that should be placed after the current issue.'
+ argument :move_after_id, IssueID,
+ required: false,
+ description: 'ID of issue that should be placed after the current issue.'
def ready?(**args)
if move_arguments(args).blank?
@@ -83,7 +86,7 @@ module Mutations
end
def authorize_board!(board)
- return if Ability.allowed?(current_user, :read_board, board.resource_parent)
+ return if Ability.allowed?(current_user, :read_issue_board, board.resource_parent)
raise_resource_not_available_error!
end
diff --git a/app/graphql/mutations/boards/lists/create.rb b/app/graphql/mutations/boards/lists/create.rb
index f3aae9ac9c8..673fa95fc56 100644
--- a/app/graphql/mutations/boards/lists/create.rb
+++ b/app/graphql/mutations/boards/lists/create.rb
@@ -15,7 +15,7 @@ module Mutations
null: true,
description: 'Issue list in the issue board.'
- authorize :admin_list
+ authorize :admin_issue_board_list
private
diff --git a/app/graphql/mutations/boards/lists/destroy.rb b/app/graphql/mutations/boards/lists/destroy.rb
index 61ffae7c047..a50b5f73455 100644
--- a/app/graphql/mutations/boards/lists/destroy.rb
+++ b/app/graphql/mutations/boards/lists/destroy.rb
@@ -33,7 +33,7 @@ module Mutations
def can_admin_list?(list)
return false unless list.present?
- Ability.allowed?(current_user, :admin_list, list.board)
+ Ability.allowed?(current_user, :admin_issue_board_list, list.board)
end
end
end
diff --git a/app/graphql/mutations/boards/lists/update.rb b/app/graphql/mutations/boards/lists/update.rb
index d30d1d89bb2..504082ec22c 100644
--- a/app/graphql/mutations/boards/lists/update.rb
+++ b/app/graphql/mutations/boards/lists/update.rb
@@ -17,7 +17,7 @@ module Mutations
argument :collapsed, GraphQL::BOOLEAN_TYPE,
required: false,
- description: 'Indicates if list is collapsed for this user.'
+ description: 'Indicates if the list is collapsed for this user.'
field :list,
Types::BoardListType,
@@ -44,7 +44,7 @@ module Mutations
def can_read_list?(list)
return false unless list.present?
- Ability.allowed?(current_user, :read_list, list.board)
+ Ability.allowed?(current_user, :read_issue_board_list, list.board)
end
end
end
diff --git a/app/graphql/mutations/boards/update.rb b/app/graphql/mutations/boards/update.rb
index b4f8179829e..628b3a3fadb 100644
--- a/app/graphql/mutations/boards/update.rb
+++ b/app/graphql/mutations/boards/update.rb
@@ -17,7 +17,7 @@ module Mutations
null: true,
description: 'The board after mutation.'
- authorize :admin_board
+ authorize :admin_issue_board
def resolve(id:, **args)
board = authorized_find!(id: id)
diff --git a/app/graphql/mutations/branches/create.rb b/app/graphql/mutations/branches/create.rb
index 6354976f1ea..a94d3966258 100644
--- a/app/graphql/mutations/branches/create.rb
+++ b/app/graphql/mutations/branches/create.rb
@@ -30,8 +30,6 @@ module Mutations
def resolve(project_path:, name:, ref:)
project = authorized_find!(project_path)
- context.scoped_set!(:branch_project, project)
-
result = ::Branches::CreateService.new(project, current_user)
.execute(name, ref)
diff --git a/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb b/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb
index 2d4983f0d6e..ba644eff36c 100644
--- a/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb
+++ b/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb
@@ -5,6 +5,7 @@ module Mutations
# and optionally support the workflow to allow clients to display and solve CAPTCHAs.
module CanMutateSpammable
extend ActiveSupport::Concern
+ include Spam::Concerns::HasSpamActionResponseFields
# NOTE: The arguments and fields are intentionally named with 'captcha' instead of 'recaptcha',
# so that they can be applied to future alternative CAPTCHA implementations other than
@@ -59,25 +60,5 @@ module Mutations
request: context[:request]
}
end
-
- # with_spam_action_fields(spammable) { {other_fields: true} } -> hash
- #
- # Takes a Spammable and a block as arguments.
- #
- # The block passed should be a hash, which the spam action fields will be merged into.
- def with_spam_action_fields(spammable)
- spam_action_fields = {
- spam: spammable.spam?,
- # NOTE: These fields are intentionally named with 'captcha' instead of 'recaptcha', so
- # that they can be applied to future alternative CAPTCHA implementations other than
- # reCAPTCHA (such as FriendlyCaptcha) without having to change the response field name
- # in the API.
- needs_captcha_response: spammable.render_recaptcha?,
- spam_log_id: spammable.spam_log&.id,
- captcha_site_key: Gitlab::CurrentSettings.recaptcha_site_key
- }
-
- yield.merge(spam_action_fields)
- end
end
end
diff --git a/app/graphql/mutations/custom_emoji/create.rb b/app/graphql/mutations/custom_emoji/create.rb
index 9ec96be0f26..5cf54f8f877 100644
--- a/app/graphql/mutations/custom_emoji/create.rb
+++ b/app/graphql/mutations/custom_emoji/create.rb
@@ -31,6 +31,7 @@ module Mutations
group = authorized_find!(group_path: group_path)
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37911#note_444682238
args[:external] = true
+ args[:creator] = current_user
custom_emoji = group.custom_emoji.create(args)
diff --git a/app/graphql/mutations/merge_requests/accept.rb b/app/graphql/mutations/merge_requests/accept.rb
new file mode 100644
index 00000000000..540be7098ac
--- /dev/null
+++ b/app/graphql/mutations/merge_requests/accept.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module Mutations
+ module MergeRequests
+ class Accept < Base
+ NOT_MERGEABLE = 'This branch cannot be merged'
+ HOOKS_VALIDATION_ERROR = 'Pre-merge hooks failed'
+ SHA_MISMATCH = 'The merge-head is not at the anticipated SHA'
+ MERGE_FAILED = 'The merge failed'
+ ALREADY_SCHEDULED = 'The merge request is already scheduled to be merged'
+
+ graphql_name 'MergeRequestAccept'
+ authorize :accept_merge_request
+ description <<~DESC
+ Accepts a merge request.
+ When accepted, the source branch will be merged into the target branch, either
+ immediately if possible, or using one of the automatic merge strategies.
+ DESC
+
+ argument :strategy,
+ ::Types::MergeStrategyEnum,
+ required: false,
+ as: :auto_merge_strategy,
+ description: 'How to merge this merge request.'
+
+ argument :commit_message, ::GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Custom merge commit message.'
+ argument :squash_commit_message, ::GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Custom squash commit message (if squash is true).'
+ argument :sha, ::GraphQL::STRING_TYPE,
+ required: true,
+ description: 'The HEAD SHA at the time when this merge was requested.'
+
+ argument :should_remove_source_branch, ::GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: 'Should the source branch be removed.'
+ argument :squash, ::GraphQL::BOOLEAN_TYPE,
+ required: false,
+ default_value: false,
+ description: 'Squash commits on the source branch before merge.'
+
+ def resolve(project_path:, iid:, **args)
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42317')
+ merge_request = authorized_find!(project_path: project_path, iid: iid)
+ project = merge_request.target_project
+ merge_params = args.compact.with_indifferent_access
+ merge_service = ::MergeRequests::MergeService.new(project, current_user, merge_params)
+
+ if error = validate(merge_request, merge_service, merge_params)
+ return { merge_request: merge_request, errors: [error] }
+ end
+
+ merge_request.update(merge_error: nil, squash: merge_params[:squash])
+
+ result = if merge_params.key?(:auto_merge_strategy)
+ service = AutoMergeService.new(project, current_user, merge_params)
+ service.execute(merge_request, merge_params[:auto_merge_strategy])
+ else
+ merge_service.execute(merge_request)
+ end
+
+ {
+ merge_request: merge_request,
+ errors: result == :failed ? [MERGE_FAILED] : []
+ }
+ rescue ::MergeRequests::MergeBaseService::MergeError => e
+ {
+ merge_request: merge_request,
+ errors: [e.message]
+ }
+ end
+
+ def validate(merge_request, merge_service, merge_params)
+ if merge_request.auto_merge_enabled?
+ ALREADY_SCHEDULED
+ elsif !merge_request.mergeable?(skip_ci_check: merge_params.key?(:auto_merge_strategy))
+ NOT_MERGEABLE
+ elsif !merge_service.hooks_validation_pass?(merge_request)
+ HOOKS_VALIDATION_ERROR
+ elsif merge_params[:sha] != merge_request.diff_head_sha
+ SHA_MISMATCH
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/merge_requests/set_wip.rb b/app/graphql/mutations/merge_requests/set_wip.rb
index 0b5c20de377..beb042ce93f 100644
--- a/app/graphql/mutations/merge_requests/set_wip.rb
+++ b/app/graphql/mutations/merge_requests/set_wip.rb
@@ -9,7 +9,7 @@ module Mutations
GraphQL::BOOLEAN_TYPE,
required: true,
description: <<~DESC
- Whether or not to set the merge request as a WIP.
+ Whether or not to set the merge request as a draft.
DESC
def resolve(project_path:, iid:, wip: nil)
diff --git a/app/graphql/mutations/notes/create/diff_note.rb b/app/graphql/mutations/notes/create/diff_note.rb
index 9b5f3092006..019e7cb8623 100644
--- a/app/graphql/mutations/notes/create/diff_note.rb
+++ b/app/graphql/mutations/notes/create/diff_note.rb
@@ -11,6 +11,22 @@ module Mutations
required: true,
description: copy_field_description(Types::Notes::NoteType, :position)
+ def ready?(**args)
+ # As both arguments are optional, validate here that one of the
+ # arguments are present.
+ #
+ # This may be able to be done using InputUnions in the future
+ # if this RFC is merged:
+ # https://github.com/graphql/graphql-spec/blob/master/rfcs/InputUnion.md
+
+ if args[:position].to_hash.values_at(:old_line, :new_line).compact.blank?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'position oldLine or newLine arguments are required'
+ end
+
+ super(**args)
+ end
+
private
def create_note_params(noteable, args)
diff --git a/app/graphql/mutations/notes/update/base.rb b/app/graphql/mutations/notes/update/base.rb
index 4edb7429b97..571001981a4 100644
--- a/app/graphql/mutations/notes/update/base.rb
+++ b/app/graphql/mutations/notes/update/base.rb
@@ -6,12 +6,18 @@ module Mutations
# This is a Base class for the Note update mutations and is not
# mounted as a GraphQL mutation itself.
class Base < Mutations::Notes::Base
+ QUICK_ACTION_ONLY_WARNING = <<~NB
+ If the body of the Note contains only quick actions,
+ the Note will be destroyed during the update, and no Note will be
+ returned.
+ NB
+
authorize :admin_note
argument :id,
- ::Types::GlobalIDType[::Note],
- required: true,
- description: 'The global ID of the note to update.'
+ ::Types::GlobalIDType[::Note],
+ required: true,
+ description: 'The global ID of the note to update.'
def resolve(args)
note = authorized_find!(id: args[:id])
diff --git a/app/graphql/mutations/notes/update/image_diff_note.rb b/app/graphql/mutations/notes/update/image_diff_note.rb
index f4533cd9edb..6160ee03f4e 100644
--- a/app/graphql/mutations/notes/update/image_diff_note.rb
+++ b/app/graphql/mutations/notes/update/image_diff_note.rb
@@ -5,16 +5,20 @@ module Mutations
module Update
class ImageDiffNote < Mutations::Notes::Update::Base
graphql_name 'UpdateImageDiffNote'
+ description <<~DESC
+ Updates a DiffNote on an image (a `Note` where the `position.positionType` is `"image"`).
+ #{QUICK_ACTION_ONLY_WARNING}
+ DESC
argument :body,
- GraphQL::STRING_TYPE,
- required: false,
- description: copy_field_description(Types::Notes::NoteType, :body)
+ GraphQL::STRING_TYPE,
+ required: false,
+ description: copy_field_description(Types::Notes::NoteType, :body)
argument :position,
- Types::Notes::UpdateDiffImagePositionInputType,
- required: false,
- description: copy_field_description(Types::Notes::NoteType, :position)
+ Types::Notes::UpdateDiffImagePositionInputType,
+ required: false,
+ description: copy_field_description(Types::Notes::NoteType, :position)
def ready?(**args)
# As both arguments are optional, validate here that one of the
@@ -34,10 +38,9 @@ module Mutations
private
def pre_update_checks!(note, _args)
- unless note.is_a?(DiffNote) && note.position.on_image?
- raise Gitlab::Graphql::Errors::ResourceNotAvailable,
- 'Resource is not an ImageDiffNote'
- end
+ return if note.is_a?(DiffNote) && note.position.on_image?
+
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Resource is not an ImageDiffNote'
end
def note_params(note, args)
diff --git a/app/graphql/mutations/notes/update/note.rb b/app/graphql/mutations/notes/update/note.rb
index 73b9b9bc49a..11d8c6e2cb9 100644
--- a/app/graphql/mutations/notes/update/note.rb
+++ b/app/graphql/mutations/notes/update/note.rb
@@ -5,16 +5,17 @@ module Mutations
module Update
class Note < Mutations::Notes::Update::Base
graphql_name 'UpdateNote'
+ description "Updates a Note.\n#{QUICK_ACTION_ONLY_WARNING}"
argument :body,
- GraphQL::STRING_TYPE,
- required: false,
- description: copy_field_description(Types::Notes::NoteType, :body)
+ GraphQL::STRING_TYPE,
+ required: false,
+ 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.'
+ GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: 'The confidentiality flag of a note. Default is false.'
private
diff --git a/app/graphql/mutations/release_asset_links/create.rb b/app/graphql/mutations/release_asset_links/create.rb
new file mode 100644
index 00000000000..02704efb47c
--- /dev/null
+++ b/app/graphql/mutations/release_asset_links/create.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ReleaseAssetLinks
+ class Create < BaseMutation
+ include FindsProject
+
+ graphql_name 'ReleaseAssetLinkCreate'
+
+ authorize :create_release
+
+ include Types::ReleaseAssetLinkSharedInputArguments
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'Full path of the project the asset link is associated with.'
+
+ argument :tag_name, GraphQL::STRING_TYPE,
+ required: true, as: :tag,
+ description: "Name of the associated release's tag."
+
+ field :link,
+ Types::ReleaseAssetLinkType,
+ null: true,
+ description: 'The asset link after mutation.'
+
+ def resolve(project_path:, tag:, **link_attrs)
+ project = authorized_find!(project_path)
+ release = project.releases.find_by_tag(tag)
+
+ if release.nil?
+ message = _('Release with tag "%{tag}" was not found') % { tag: tag }
+ return { link: nil, errors: [message] }
+ end
+
+ new_link = release.links.create(link_attrs)
+
+ unless new_link.persisted?
+ return { link: nil, errors: new_link.errors.full_messages }
+ end
+
+ { link: new_link, errors: [] }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/release_asset_links/update.rb b/app/graphql/mutations/release_asset_links/update.rb
new file mode 100644
index 00000000000..1d9460bde78
--- /dev/null
+++ b/app/graphql/mutations/release_asset_links/update.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ReleaseAssetLinks
+ class Update < BaseMutation
+ graphql_name 'ReleaseAssetLinkUpdate'
+
+ authorize :update_release
+
+ ReleaseAssetLinkID = ::Types::GlobalIDType[::Releases::Link]
+
+ argument :id, ReleaseAssetLinkID,
+ required: true,
+ description: 'ID of the release asset link to update.'
+
+ argument :name, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Name of the asset link.'
+
+ argument :url, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'URL of the asset link.'
+
+ argument :direct_asset_path, GraphQL::STRING_TYPE,
+ required: false, as: :filepath,
+ description: 'Relative path for a direct asset link.'
+
+ argument :link_type, Types::ReleaseAssetLinkTypeEnum,
+ required: false,
+ description: 'The type of the asset link.'
+
+ field :link,
+ Types::ReleaseAssetLinkType,
+ null: true,
+ description: 'The asset link after mutation.'
+
+ def ready?(**args)
+ if args.key?(:link_type) && args[:link_type].nil?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'if the linkType argument is provided, it cannot be null'
+ end
+
+ super
+ end
+
+ def resolve(id:, **link_attrs)
+ link = authorized_find!(id)
+
+ unless link.update(link_attrs)
+ return { link: nil, errors: link.errors.full_messages }
+ end
+
+ { link: link, errors: [] }
+ end
+
+ def find_object(id)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ReleaseAssetLinkID.coerce_isolated_input(id)
+
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index 73eac9f0f3b..7f2dd448b8b 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -56,7 +56,7 @@ module Mutations
end
snippet = service_response.payload[:snippet]
- with_spam_action_fields(snippet) do
+ with_spam_action_response_fields(snippet) do
{
snippet: service_response.success? ? snippet : nil,
errors: errors_on_object(snippet)
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index af8e6f384b7..9f9f8bca848 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -45,7 +45,7 @@ module Mutations
end
snippet = service_response.payload[:snippet]
- with_spam_action_fields(snippet) do
+ with_spam_action_response_fields(snippet) do
{
snippet: service_response.success? ? snippet : snippet.reset,
errors: errors_on_object(snippet)
diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb
index dc02ffadada..41ccbd77aa6 100644
--- a/app/graphql/mutations/todos/restore_many.rb
+++ b/app/graphql/mutations/todos/restore_many.rb
@@ -60,7 +60,7 @@ module Mutations
def authorized_find_all_pending_by_current_user(ids)
return Todo.none if ids.blank? || current_user.nil?
- Todo.for_ids(ids).for_user(current_user).done
+ Todo.id_in(ids).for_user(current_user).done
end
def restore(todos)
diff --git a/app/graphql/mutations/user_callouts/create.rb b/app/graphql/mutations/user_callouts/create.rb
new file mode 100644
index 00000000000..0d3dcacda41
--- /dev/null
+++ b/app/graphql/mutations/user_callouts/create.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Mutations
+ module UserCallouts
+ class Create < ::Mutations::BaseMutation
+ graphql_name 'UserCalloutCreate'
+
+ argument :feature_name,
+ GraphQL::STRING_TYPE,
+ required: true,
+ description: "The feature name you want to dismiss the callout for."
+
+ field :user_callout, Types::UserCalloutType,
+ null: false,
+ description: 'The user callout dismissed.'
+
+ def resolve(feature_name:)
+ callout = Users::DismissUserCalloutService.new(
+ container: nil, current_user: current_user, params: { feature_name: feature_name }
+ ).execute
+ errors = errors_on_object(callout)
+
+ {
+ user_callout: callout,
+ errors: errors
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver.rb b/app/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver.rb
index 9df07df24d8..a5f81149d4e 100644
--- a/app/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver.rb
+++ b/app/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver.rb
@@ -3,13 +3,13 @@
module Resolvers
module Admin
module Analytics
- module InstanceStatistics
+ module UsageTrends
class MeasurementsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
- type Types::Admin::Analytics::InstanceStatistics::MeasurementType, null: true
+ type Types::Admin::Analytics::UsageTrends::MeasurementType, null: true
- argument :identifier, Types::Admin::Analytics::InstanceStatistics::MeasurementIdentifierEnum,
+ argument :identifier, Types::Admin::Analytics::UsageTrends::MeasurementIdentifierEnum,
required: true,
description: 'The type of measurement/statistics to retrieve.'
@@ -24,7 +24,7 @@ module Resolvers
def resolve(identifier:, recorded_before: nil, recorded_after: nil)
authorize!
- ::Analytics::InstanceStatistics::Measurement
+ ::Analytics::UsageTrends::Measurement
.recorded_after(recorded_after)
.recorded_before(recorded_before)
.with_identifier(identifier)
diff --git a/app/graphql/resolvers/alert_management/alert_resolver.rb b/app/graphql/resolvers/alert_management/alert_resolver.rb
index d60cabde62b..008641ed88a 100644
--- a/app/graphql/resolvers/alert_management/alert_resolver.rb
+++ b/app/graphql/resolvers/alert_management/alert_resolver.rb
@@ -43,7 +43,8 @@ module Resolvers
def preloads
{
assignees: [:assignees],
- notes: [:ordered_notes, { ordered_notes: [:system_note_metadata, :project, :noteable] }]
+ notes: [:ordered_notes, { ordered_notes: [:system_note_metadata, :project, :noteable] }],
+ issue: [:issue]
}
end
end
diff --git a/app/graphql/resolvers/alert_management/http_integrations_resolver.rb b/app/graphql/resolvers/alert_management/http_integrations_resolver.rb
new file mode 100644
index 00000000000..94a72bca7c7
--- /dev/null
+++ b/app/graphql/resolvers/alert_management/http_integrations_resolver.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module AlertManagement
+ class HttpIntegrationsResolver < BaseResolver
+ alias_method :project, :synchronized_object
+
+ type Types::AlertManagement::HttpIntegrationType.connection_type, null: true
+
+ def resolve(**args)
+ http_integrations
+ end
+
+ private
+
+ def http_integrations
+ return [] unless Ability.allowed?(current_user, :admin_operations, project)
+
+ ::AlertManagement::HttpIntegrationsFinder.new(project, {}).execute
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 5db618254cb..67bba079512 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -12,8 +12,17 @@ module Resolvers
@requires_argument = true
end
+ def self.calls_gitaly!
+ @calls_gitaly = true
+ end
+
def self.field_options
- super.merge(requires_argument: @requires_argument)
+ extra_options = {
+ requires_argument: @requires_argument,
+ calls_gitaly: @calls_gitaly
+ }.compact
+
+ super.merge(extra_options)
end
def self.singular_type
diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb
index a97ac3220d5..e66f7b97b40 100644
--- a/app/graphql/resolvers/board_lists_resolver.rb
+++ b/app/graphql/resolvers/board_lists_resolver.rb
@@ -9,7 +9,7 @@ module Resolvers
type Types::BoardListType, null: true
extras [:lookahead]
- authorize :read_list
+ authorize :read_issue_board_list
argument :id, Types::GlobalIDType[List],
required: false,
diff --git a/app/graphql/resolvers/board_resolver.rb b/app/graphql/resolvers/board_resolver.rb
index 2c2922c3fbf..637d690e4cd 100644
--- a/app/graphql/resolvers/board_resolver.rb
+++ b/app/graphql/resolvers/board_resolver.rb
@@ -13,7 +13,7 @@ module Resolvers
def resolve(id: nil)
return unless parent
- ::Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute.first
+ ::Boards::BoardsFinder.new(parent, context[:current_user], board_id: extract_board_id(id)).execute.first
rescue ActiveRecord::RecordNotFound
nil
end
diff --git a/app/graphql/resolvers/boards_resolver.rb b/app/graphql/resolvers/boards_resolver.rb
index be2f22175dc..679f2b4cceb 100644
--- a/app/graphql/resolvers/boards_resolver.rb
+++ b/app/graphql/resolvers/boards_resolver.rb
@@ -16,7 +16,7 @@ module Resolvers
return Board.none unless parent
- ::Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute
+ ::Boards::BoardsFinder.new(parent, context[:current_user], board_id: extract_board_id(id)).execute
rescue ActiveRecord::RecordNotFound
Board.none
end
diff --git a/app/graphql/resolvers/branch_commit_resolver.rb b/app/graphql/resolvers/branch_commit_resolver.rb
index 11c49e17bc5..ba235251950 100644
--- a/app/graphql/resolvers/branch_commit_resolver.rb
+++ b/app/graphql/resolvers/branch_commit_resolver.rb
@@ -7,11 +7,16 @@ module Resolvers
alias_method :branch, :object
def resolve(**args)
- return unless branch
+ commit = branch&.dereferenced_target
+ return unless commit
- commit = branch.dereferenced_target
+ lazy_project = BatchLoader::GraphQL.for(commit.repository.gl_project_path).batch do |paths, loader|
+ paths.each { |path| loader.call(path, Project.find_by_full_path(path)) }
+ end
- ::Commit.new(commit, context[:branch_project]) if commit
+ ::Gitlab::Graphql::Lazy.with_value(lazy_project) do |project|
+ ::Commit.new(commit, project) if project
+ end
end
end
end
diff --git a/app/graphql/resolvers/ci/config_resolver.rb b/app/graphql/resolvers/ci/config_resolver.rb
index 852bb47e215..f8670649e48 100644
--- a/app/graphql/resolvers/ci/config_resolver.rb
+++ b/app/graphql/resolvers/ci/config_resolver.rb
@@ -16,7 +16,7 @@ module Resolvers
argument :content, GraphQL::STRING_TYPE,
required: true,
- description: "Contents of '.gitlab-ci.yml'."
+ description: "Contents of `.gitlab-ci.yml`."
argument :dry_run, GraphQL::BOOLEAN_TYPE,
required: false,
diff --git a/app/graphql/resolvers/concerns/resolves_snippets.rb b/app/graphql/resolvers/concerns/resolves_snippets.rb
index 0bc38188b9a..445f3567b1d 100644
--- a/app/graphql/resolvers/concerns/resolves_snippets.rb
+++ b/app/graphql/resolvers/concerns/resolves_snippets.rb
@@ -8,7 +8,7 @@ module ResolvesSnippets
argument :ids, [::Types::GlobalIDType[::Snippet]],
required: false,
- description: 'Array of global snippet ids, e.g., "gid://gitlab/ProjectSnippet/1".'
+ description: 'Array of global snippet IDs. For example, `gid://gitlab/ProjectSnippet/1`.'
argument :visibility, Types::Snippets::VisibilityScopesEnum,
required: false,
diff --git a/app/graphql/resolvers/full_path_resolver.rb b/app/graphql/resolvers/full_path_resolver.rb
index d01cdf749a1..b5e90da78b2 100644
--- a/app/graphql/resolvers/full_path_resolver.rb
+++ b/app/graphql/resolvers/full_path_resolver.rb
@@ -7,7 +7,7 @@ module Resolvers
prepended do
argument :full_path, GraphQL::ID_TYPE,
required: true,
- description: 'The full path of the project, group or namespace, e.g., "gitlab-org/gitlab-foss".'
+ description: 'The full path of the project, group or namespace, e.g., `gitlab-org/gitlab-foss`.'
end
def model_by_full_path(model, full_path)
diff --git a/app/graphql/resolvers/group_packages_resolver.rb b/app/graphql/resolvers/group_packages_resolver.rb
new file mode 100644
index 00000000000..d441cd80249
--- /dev/null
+++ b/app/graphql/resolvers/group_packages_resolver.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class GroupPackagesResolver < BaseResolver
+ type Types::Packages::PackageType.connection_type, null: true
+
+ def ready?(**args)
+ context[self.class] ||= { executions: 0 }
+ context[self.class][:executions] += 1
+ raise GraphQL::ExecutionError, "Packages can be requested only for one group at a time" if context[self.class][:executions] > 1
+
+ super
+ end
+
+ def resolve(**args)
+ return unless packages_available?
+
+ ::Packages::GroupPackagesFinder.new(current_user, object).execute
+ end
+
+ private
+
+ def packages_available?
+ ::Gitlab.config.packages.enabled
+ end
+ end
+end
diff --git a/app/graphql/resolvers/last_commit_resolver.rb b/app/graphql/resolvers/last_commit_resolver.rb
index dd89c322617..00c43bdfee6 100644
--- a/app/graphql/resolvers/last_commit_resolver.rb
+++ b/app/graphql/resolvers/last_commit_resolver.rb
@@ -4,6 +4,8 @@ module Resolvers
class LastCommitResolver < BaseResolver
type Types::CommitType, null: true
+ calls_gitaly!
+
alias_method :tree, :object
def resolve(**args)
diff --git a/app/graphql/resolvers/metrics/dashboard_resolver.rb b/app/graphql/resolvers/metrics/dashboard_resolver.rb
index f569cb0b2c3..a82a4a95254 100644
--- a/app/graphql/resolvers/metrics/dashboard_resolver.rb
+++ b/app/graphql/resolvers/metrics/dashboard_resolver.rb
@@ -3,19 +3,30 @@
module Resolvers
module Metrics
class DashboardResolver < Resolvers::BaseResolver
+ type Types::Metrics::DashboardType, null: true
+ calls_gitaly!
+
argument :path, GraphQL::STRING_TYPE,
required: true,
- description: "Path to a file which defines metrics dashboard eg: 'config/prometheus/common_metrics.yml'."
-
- type Types::Metrics::DashboardType, null: true
+ description: "Path to a file which defines metrics dashboard " \
+ "eg: 'config/prometheus/common_metrics.yml'."
alias_method :environment, :object
def resolve(**args)
return unless environment
- ::PerformanceMonitoring::PrometheusDashboard
- .find_for(project: environment.project, user: context[:current_user], path: args[:path], options: { environment: environment })
+ ::PerformanceMonitoring::PrometheusDashboard.find_for(**args, **service_params)
+ end
+
+ private
+
+ def service_params
+ {
+ project: environment.project,
+ user: current_user,
+ options: { environment: environment }
+ }
end
end
end
diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb
index da44b9b5623..7320c3ce141 100644
--- a/app/graphql/resolvers/namespace_projects_resolver.rb
+++ b/app/graphql/resolvers/namespace_projects_resolver.rb
@@ -17,23 +17,23 @@ module Resolvers
default_value: nil,
description: 'Sort projects by this criteria.'
+ argument :ids, [GraphQL::ID_TYPE],
+ required: false,
+ default_value: nil,
+ description: 'Filter projects by IDs.'
+
type Types::ProjectType, null: true
- def resolve(include_subgroups:, sort:, search:)
+ def resolve(args)
# The namespace could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` or the `full_path` of the namespace
# to query for projects, so make sure it's loaded and not `nil` before continuing.
- return Project.none if namespace.nil?
-
- query = include_subgroups ? namespace.all_projects.with_route : namespace.projects.with_route
- return query unless search.present?
-
- if sort == :similarity
- query.sorted_by_similarity_desc(search, include_in_select: true).merge(Project.search(search))
- else
- query.merge(Project.search(search))
- end
+ ::Namespaces::ProjectsFinder.new(
+ namespace: namespace,
+ current_user: current_user,
+ params: finder_params(args)
+ ).execute
end
def self.resolver_complexity(args, child_complexity:)
@@ -48,6 +48,19 @@ module Resolvers
object.respond_to?(:sync) ? object.sync : object
end
end
+
+ def finder_params(args)
+ {
+ include_subgroups: args.dig(:include_subgroups),
+ sort: args.dig(:sort),
+ search: args.dig(:search),
+ ids: parse_gids(args.dig(:ids))
+ }
+ end
+
+ def parse_gids(gids)
+ gids&.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::Project).model_id }
+ end
end
end
diff --git a/app/graphql/resolvers/project_merge_requests_resolver.rb b/app/graphql/resolvers/project_merge_requests_resolver.rb
index 9628a6dfd7a..66c020a0c14 100644
--- a/app/graphql/resolvers/project_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/project_merge_requests_resolver.rb
@@ -10,7 +10,7 @@ module Resolvers
def resolve(**args)
scope = super
- if only_count_is_selected_with_merged_at_filter?(args) && Feature.enabled?(:optimized_merge_request_count_with_merged_at_filter, default_enabled: :yaml)
+ if only_count_is_selected_with_merged_at_filter?(args)
MergeRequest::MetricsFinder
.new(current_user, args.merge(target_project: project))
.execute
diff --git a/app/graphql/resolvers/packages_resolver.rb b/app/graphql/resolvers/project_packages_resolver.rb
index 3eeed48ff7e..288e14b41d0 100644
--- a/app/graphql/resolvers/packages_resolver.rb
+++ b/app/graphql/resolvers/project_packages_resolver.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Resolvers
- class PackagesResolver < BaseResolver
+ class ProjectPackagesResolver < BaseResolver
type Types::Packages::PackageType.connection_type, null: true
def resolve(**args)
diff --git a/app/graphql/resolvers/project_pipeline_resolver.rb b/app/graphql/resolvers/project_pipeline_resolver.rb
index b604a408928..8fca6b829c0 100644
--- a/app/graphql/resolvers/project_pipeline_resolver.rb
+++ b/app/graphql/resolvers/project_pipeline_resolver.rb
@@ -7,14 +7,34 @@ module Resolvers
alias_method :project, :object
argument :iid, GraphQL::ID_TYPE,
- required: true,
- description: 'IID of the Pipeline, e.g., "1".'
+ required: false,
+ description: 'IID of the Pipeline. For example, "1".'
- def resolve(iid:)
- BatchLoader::GraphQL.for(iid).batch(key: project) do |iids, loader, args|
- finder = ::Ci::PipelinesFinder.new(project, context[:current_user], iids: iids)
+ argument :sha, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'SHA of the Pipeline. For example, "dyd0f15ay83993f5ab66k927w28673882x99100b".'
- finder.execute.each { |pipeline| loader.call(pipeline.iid.to_s, pipeline) }
+ def ready?(iid: nil, sha: nil)
+ unless iid.present? ^ sha.present?
+ raise Gitlab::Graphql::Errors::ArgumentError, 'Provide one of an IID or SHA'
+ end
+
+ super
+ end
+
+ def resolve(iid: nil, sha: nil)
+ if iid
+ BatchLoader::GraphQL.for(iid).batch(key: project) do |iids, loader, args|
+ finder = ::Ci::PipelinesFinder.new(project, current_user, iids: iids)
+
+ finder.execute.each { |pipeline| loader.call(pipeline.iid.to_s, pipeline) }
+ end
+ else
+ BatchLoader::GraphQL.for(sha).batch(key: project) do |shas, loader, args|
+ finder = ::Ci::PipelinesFinder.new(project, current_user, shas: shas)
+
+ finder.execute.each { |pipeline| loader.call(pipeline.sha.to_s, pipeline) }
+ end
end
end
end
diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb
index 672214df7d5..569b82149d3 100644
--- a/app/graphql/resolvers/snippets/blobs_resolver.rb
+++ b/app/graphql/resolvers/snippets/blobs_resolver.rb
@@ -8,6 +8,7 @@ module Resolvers
type Types::Snippets::BlobType.connection_type, null: true
authorize :read_snippet
+ calls_gitaly!
alias_method :snippet, :object
diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb
index 7a70c35897d..c07d9187d4d 100644
--- a/app/graphql/resolvers/tree_resolver.rb
+++ b/app/graphql/resolvers/tree_resolver.rb
@@ -4,6 +4,8 @@ module Resolvers
class TreeResolver < BaseResolver
type Types::Tree::TreeType, null: true
+ calls_gitaly!
+
argument :path, GraphQL::STRING_TYPE,
required: false,
default_value: '',
diff --git a/app/graphql/types/access_level_enum.rb b/app/graphql/types/access_level_enum.rb
index 6754d3d28ce..b7eb35ddfc9 100644
--- a/app/graphql/types/access_level_enum.rb
+++ b/app/graphql/types/access_level_enum.rb
@@ -5,11 +5,12 @@ module Types
graphql_name 'AccessLevelEnum'
description 'Access level to a resource'
- value 'NO_ACCESS', value: Gitlab::Access::NO_ACCESS
- value 'GUEST', value: Gitlab::Access::GUEST
- value 'REPORTER', value: Gitlab::Access::REPORTER
- value 'DEVELOPER', value: Gitlab::Access::DEVELOPER
- value 'MAINTAINER', value: Gitlab::Access::MAINTAINER
- value 'OWNER', value: Gitlab::Access::OWNER
+ value 'NO_ACCESS', value: Gitlab::Access::NO_ACCESS, description: 'No access'
+ value 'MINIMAL_ACCESS', value: Gitlab::Access::MINIMAL_ACCESS, description: 'Minimal access'
+ value 'GUEST', value: Gitlab::Access::GUEST, description: 'Guest access'
+ value 'REPORTER', value: Gitlab::Access::REPORTER, description: 'Reporter access'
+ value 'DEVELOPER', value: Gitlab::Access::DEVELOPER, description: 'Developer access'
+ value 'MAINTAINER', value: Gitlab::Access::MAINTAINER, description: 'Maintainer access'
+ value 'OWNER', value: Gitlab::Access::OWNER, description: 'Owner access'
end
end
diff --git a/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb b/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb
deleted file mode 100644
index c6ca5963588..00000000000
--- a/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-module Types
- module Admin
- module Analytics
- module InstanceStatistics
- class MeasurementIdentifierEnum < BaseEnum
- graphql_name 'MeasurementIdentifier'
- description 'Possible identifier types for a measurement'
-
- value 'PROJECTS', 'Project count', value: 'projects'
- value 'USERS', 'User count', value: 'users'
- value 'ISSUES', 'Issue count', value: 'issues'
- value 'MERGE_REQUESTS', 'Merge request count', value: 'merge_requests'
- value 'GROUPS', 'Group count', value: 'groups'
- value 'PIPELINES', 'Pipeline count', value: 'pipelines'
- value 'PIPELINES_SUCCEEDED', 'Pipeline count with success status', value: 'pipelines_succeeded'
- value 'PIPELINES_FAILED', 'Pipeline count with failed status', value: 'pipelines_failed'
- value 'PIPELINES_CANCELED', 'Pipeline count with canceled status', value: 'pipelines_canceled'
- value 'PIPELINES_SKIPPED', 'Pipeline count with skipped status', value: 'pipelines_skipped'
- end
- end
- end
- end
-end
diff --git a/app/graphql/types/admin/analytics/usage_trends/measurement_identifier_enum.rb b/app/graphql/types/admin/analytics/usage_trends/measurement_identifier_enum.rb
new file mode 100644
index 00000000000..33bec5278fe
--- /dev/null
+++ b/app/graphql/types/admin/analytics/usage_trends/measurement_identifier_enum.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ module Admin
+ module Analytics
+ module UsageTrends
+ class MeasurementIdentifierEnum < BaseEnum
+ graphql_name 'MeasurementIdentifier'
+ description 'Possible identifier types for a measurement'
+
+ value 'PROJECTS', 'Project count.', value: 'projects'
+ value 'USERS', 'User count.', value: 'users'
+ value 'ISSUES', 'Issue count.', value: 'issues'
+ value 'MERGE_REQUESTS', 'Merge request count.', value: 'merge_requests'
+ value 'GROUPS', 'Group count.', value: 'groups'
+ value 'PIPELINES', 'Pipeline count.', value: 'pipelines'
+ value 'PIPELINES_SUCCEEDED', 'Pipeline count with success status.', value: 'pipelines_succeeded'
+ value 'PIPELINES_FAILED', 'Pipeline count with failed status.', value: 'pipelines_failed'
+ value 'PIPELINES_CANCELED', 'Pipeline count with canceled status.', value: 'pipelines_canceled'
+ value 'PIPELINES_SKIPPED', 'Pipeline count with skipped status.', value: 'pipelines_skipped'
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/admin/analytics/instance_statistics/measurement_type.rb b/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb
index 17a5af8d36b..c8ca702706f 100644
--- a/app/graphql/types/admin/analytics/instance_statistics/measurement_type.rb
+++ b/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb
@@ -3,13 +3,13 @@
module Types
module Admin
module Analytics
- module InstanceStatistics
+ module UsageTrends
class MeasurementType < BaseObject
include Gitlab::Graphql::Authorize::AuthorizeResource
- graphql_name 'InstanceStatisticsMeasurement'
+ graphql_name 'UsageTrendsMeasurement'
description 'Represents a recorded measurement (object count) for the Admins'
- authorize :read_instance_statistics_measurements
+ authorize :read_usage_trends_measurement
field :recorded_at, Types::TimeType, null: true,
description: 'The time the measurement was recorded.'
@@ -17,7 +17,7 @@ module Types
field :count, GraphQL::INT_TYPE, null: false,
description: 'Object count.'
- field :identifier, Types::Admin::Analytics::InstanceStatistics::MeasurementIdentifierEnum, null: false,
+ field :identifier, Types::Admin::Analytics::UsageTrends::MeasurementIdentifierEnum, null: false,
description: 'The type of objects being measured.'
end
end
diff --git a/app/graphql/types/alert_management/alert_sort_enum.rb b/app/graphql/types/alert_management/alert_sort_enum.rb
index 51e7bef0a7f..11a5cb95722 100644
--- a/app/graphql/types/alert_management/alert_sort_enum.rb
+++ b/app/graphql/types/alert_management/alert_sort_enum.rb
@@ -6,20 +6,20 @@ module Types
graphql_name 'AlertManagementAlertSort'
description 'Values for sorting alerts'
- value 'STARTED_AT_ASC', 'Start time by ascending order', value: :started_at_asc
- value 'STARTED_AT_DESC', 'Start time by descending order', value: :started_at_desc
- value 'ENDED_AT_ASC', 'End time by ascending order', value: :ended_at_asc
- value 'ENDED_AT_DESC', 'End time by descending order', value: :ended_at_desc
- value 'CREATED_TIME_ASC', 'Created time by ascending order', value: :created_at_asc
- value 'CREATED_TIME_DESC', 'Created time by descending order', value: :created_at_desc
- value 'UPDATED_TIME_ASC', 'Created time by ascending order', value: :updated_at_asc
- 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 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
+ value 'STARTED_AT_ASC', 'Start time by ascending order.', value: :started_at_asc
+ value 'STARTED_AT_DESC', 'Start time by descending order.', value: :started_at_desc
+ value 'ENDED_AT_ASC', 'End time by ascending order.', value: :ended_at_asc
+ value 'ENDED_AT_DESC', 'End time by descending order.', value: :ended_at_desc
+ value 'CREATED_TIME_ASC', 'Created time by ascending order.', value: :created_at_asc
+ value 'CREATED_TIME_DESC', 'Created time by descending order.', value: :created_at_desc
+ value 'UPDATED_TIME_ASC', 'Created time by ascending order.', value: :updated_at_asc
+ 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 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 6b7e7030c1f..5a2a5c68c8d 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -20,8 +20,14 @@ module Types
field :issue_iid,
GraphQL::ID_TYPE,
null: true,
+ deprecated: { reason: 'Use issue field', milestone: '13.10' },
description: 'Internal ID of the GitLab issue attached to the alert.'
+ field :issue,
+ Types::IssueType,
+ null: true,
+ description: 'Issue attached to the alert.'
+
field :title,
GraphQL::STRING_TYPE,
null: true,
diff --git a/app/graphql/types/alert_management/domain_filter_enum.rb b/app/graphql/types/alert_management/domain_filter_enum.rb
index a798cfb9ee9..3ee01e4c391 100644
--- a/app/graphql/types/alert_management/domain_filter_enum.rb
+++ b/app/graphql/types/alert_management/domain_filter_enum.rb
@@ -6,8 +6,8 @@ module Types
graphql_name 'AlertManagementDomainFilter'
description 'Filters the alerts based on given domain'
- value 'operations', description: 'Alerts for operations domain'
- value 'threat_monitoring', description: 'Alerts for threat monitoring domain'
+ value 'operations', description: 'Alerts for operations domain.'
+ value 'threat_monitoring', description: 'Alerts for threat monitoring domain.'
end
end
end
diff --git a/app/graphql/types/alert_management/http_integration_type.rb b/app/graphql/types/alert_management/http_integration_type.rb
index 88782050b94..0d5bb50a77c 100644
--- a/app/graphql/types/alert_management/http_integration_type.rb
+++ b/app/graphql/types/alert_management/http_integration_type.rb
@@ -20,3 +20,5 @@ module Types
end
end
end
+
+Types::AlertManagement::HttpIntegrationType.prepend_ee_mod
diff --git a/app/graphql/types/alert_management/integration_type_enum.rb b/app/graphql/types/alert_management/integration_type_enum.rb
index 2f9be549e58..94f38e03409 100644
--- a/app/graphql/types/alert_management/integration_type_enum.rb
+++ b/app/graphql/types/alert_management/integration_type_enum.rb
@@ -6,8 +6,8 @@ module Types
graphql_name 'AlertManagementIntegrationType'
description 'Values of types of integrations'
- value 'PROMETHEUS', 'Prometheus integration', value: :prometheus
- value 'HTTP', 'Integration with any monitoring tool', value: :http
+ value 'PROMETHEUS', 'Prometheus integration.', value: :prometheus
+ value 'HTTP', 'Integration with any monitoring tool.', value: :http
end
end
end
diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb
index 11774d0b59d..4ad9e8c0e40 100644
--- a/app/graphql/types/base_argument.rb
+++ b/app/graphql/types/base_argument.rb
@@ -6,7 +6,6 @@ module Types
def initialize(*args, **kwargs, &block)
kwargs = gitlab_deprecation(kwargs)
- kwargs.delete(:deprecation_reason)
super(*args, **kwargs, &block)
end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index f145b9d45c3..78ab6890923 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -9,48 +9,29 @@ module Types
DEFAULT_COMPLEXITY = 1
- def initialize(*args, **kwargs, &block)
+ def initialize(**kwargs, &block)
@calls_gitaly = !!kwargs.delete(:calls_gitaly)
- @constant_complexity = !!kwargs[:complexity]
+ @constant_complexity = kwargs[:complexity].is_a?(Integer) && kwargs[:complexity] > 0
@requires_argument = !!kwargs.delete(:requires_argument)
kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity])
@feature_flag = kwargs[:feature_flag]
kwargs = check_feature_flag(kwargs)
kwargs = gitlab_deprecation(kwargs)
- super(*args, **kwargs, &block)
+ super(**kwargs, &block)
+
+ # We want to avoid the overhead of this in prod
+ extension ::Gitlab::Graphql::CallsGitaly::FieldExtension if Gitlab.dev_or_test_env?
+
+ extension ::Gitlab::Graphql::Present::FieldExtension
end
- def requires_argument?
- @requires_argument || arguments.values.any? { |argument| argument.type.non_null? }
+ def may_call_gitaly?
+ @constant_complexity || @calls_gitaly
end
- # Based on https://github.com/rmosolgo/graphql-ruby/blob/v1.11.4/lib/graphql/schema/field.rb#L538-L563
- # Modified to fix https://github.com/rmosolgo/graphql-ruby/issues/3113
- def resolve_field(obj, args, ctx)
- ctx.schema.after_lazy(obj) do |after_obj|
- query_ctx = ctx.query.context
- inner_obj = after_obj&.object
-
- ctx.schema.after_lazy(to_ruby_args(after_obj, args, ctx)) do |ruby_args|
- if authorized?(inner_obj, ruby_args, query_ctx)
- if @resolve_proc
- # We pass `after_obj` here instead of `inner_obj` because extensions expect a GraphQL::Schema::Object
- with_extensions(after_obj, ruby_args, query_ctx) do |extended_obj, extended_args|
- # Since `extended_obj` is now a GraphQL::Schema::Object, we need to get the inner object and pass that to `@resolve_proc`
- extended_obj = extended_obj.object if extended_obj.is_a?(GraphQL::Schema::Object)
-
- @resolve_proc.call(extended_obj, args, ctx)
- end
- else
- public_send_field(after_obj, ruby_args, query_ctx)
- end
- else
- err = GraphQL::UnauthorizedFieldError.new(object: inner_obj, type: obj.class, context: ctx, field: self)
- query_ctx.schema.unauthorized_field(err)
- end
- end
- end
+ def requires_argument?
+ @requires_argument || arguments.values.any? { |argument| argument.type.non_null? }
end
def base_complexity
@@ -82,8 +63,10 @@ module Types
end
def check_feature_flag(args)
- args[:description] = feature_documentation_message(args[:feature_flag], args[:description]) if args[:feature_flag].present?
- args.delete(:feature_flag)
+ ff = args.delete(:feature_flag)
+ return args unless ff.present?
+
+ args[:description] = feature_documentation_message(ff, args[:description])
args
end
@@ -106,7 +89,9 @@ module Types
# items which can be loaded.
proc do |ctx, args, child_complexity|
# Resolvers may add extra complexity depending on used arguments
- complexity = child_complexity + self.resolver&.try(:resolver_complexity, args, child_complexity: child_complexity).to_i
+ complexity = child_complexity + resolver&.try(
+ :resolver_complexity, args, child_complexity: child_complexity
+ ).to_i
complexity += 1 if calls_gitaly?
complexity += complexity * connection_complexity_multiplier(ctx, args)
@@ -121,7 +106,7 @@ module Types
page_size = field_defn.connection_max_page_size || ctx.schema.default_max_page_size
limit_value = [args[:first], args[:last], page_size].compact.min
- multiplier = self.resolver&.try(:complexity_multiplier, args).to_f
+ multiplier = resolver&.try(:complexity_multiplier, args).to_f
limit_value * multiplier
end
end
diff --git a/app/graphql/types/blob_viewers/type_enum.rb b/app/graphql/types/blob_viewers/type_enum.rb
index 35e659197e5..3cb5c76d881 100644
--- a/app/graphql/types/blob_viewers/type_enum.rb
+++ b/app/graphql/types/blob_viewers/type_enum.rb
@@ -6,9 +6,9 @@ module Types
graphql_name 'BlobViewersType'
description 'Types of blob viewers'
- value 'rich', value: :rich
- value 'simple', value: :simple
- value 'auxiliary', value: :auxiliary
+ value 'rich', value: :rich, description: 'Rich blob viewers type.'
+ value 'simple', value: :simple, description: 'Simple blob viewers type.'
+ value 'auxiliary', value: :auxiliary, description: 'Auxiliary blob viewers type.'
end
end
end
diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb
index 46b49c5d8a4..f215aa255de 100644
--- a/app/graphql/types/board_list_type.rb
+++ b/app/graphql/types/board_list_type.rb
@@ -8,6 +8,8 @@ module Types
graphql_name 'BoardList'
description 'Represents a list for an issue board'
+ alias_method :list, :object
+
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID (global ID) of the list.'
field :title, GraphQL::STRING_TYPE, null: false,
@@ -19,7 +21,7 @@ module Types
field :label, Types::LabelType, null: true,
description: 'Label of the list.'
field :collapsed, GraphQL::BOOLEAN_TYPE, null: true,
- description: 'Indicates if list is collapsed for this user.'
+ description: 'Indicates if the list is collapsed for this user.'
field :issues_count, GraphQL::INT_TYPE, null: true,
description: 'Count of issues in the list.'
@@ -37,12 +39,10 @@ module Types
def metadata
strong_memoize(:metadata) do
- list = self.object
- user = context[:current_user]
params = (context[:issue_filters] || {}).merge(board_id: list.board_id, id: list.id)
::Boards::Issues::ListService
- .new(list.board.resource_parent, user, params)
+ .new(list.board.resource_parent, current_user, params)
.metadata
end
end
diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb
index f8bd31d5fa0..f33f3f5e537 100644
--- a/app/graphql/types/board_type.rb
+++ b/app/graphql/types/board_type.rb
@@ -3,9 +3,9 @@
module Types
class BoardType < BaseObject
graphql_name 'Board'
- description 'Represents a project or group board'
+ description 'Represents a project or group issue board'
accepts ::Board
- authorize :read_board
+ authorize :read_issue_board
present_using BoardPresenter
diff --git a/app/graphql/types/boards/board_issue_input_base_type.rb b/app/graphql/types/boards/board_issue_input_base_type.rb
index dab1414760b..b762cef6e58 100644
--- a/app/graphql/types/boards/board_issue_input_base_type.rb
+++ b/app/graphql/types/boards/board_issue_input_base_type.rb
@@ -2,7 +2,6 @@
module Types
module Boards
- # rubocop: disable Graphql/AuthorizeTypes
class BoardIssueInputBaseType < BaseInputObject
argument :label_name, GraphQL::STRING_TYPE.to_list_type,
required: false,
@@ -28,7 +27,6 @@ module Types
required: false,
description: 'Filter by reaction emoji.'
end
- # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/boards/board_issue_input_type.rb b/app/graphql/types/boards/board_issue_input_type.rb
index 62a402ee724..9cc0f484a16 100644
--- a/app/graphql/types/boards/board_issue_input_type.rb
+++ b/app/graphql/types/boards/board_issue_input_type.rb
@@ -2,7 +2,6 @@
module Types
module Boards
- # rubocop: disable Graphql/AuthorizeTypes
class NegatedBoardIssueInputType < BoardIssueInputBaseType
end
@@ -17,7 +16,6 @@ module Types
required: false,
description: 'Search query for issue title or description.'
end
- # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/ci/config/status_enum.rb b/app/graphql/types/ci/config/status_enum.rb
index 92b04c61679..1ba207531b8 100644
--- a/app/graphql/types/ci/config/status_enum.rb
+++ b/app/graphql/types/ci/config/status_enum.rb
@@ -7,8 +7,8 @@ module Types
graphql_name 'CiConfigStatus'
description 'Values for YAML processor result'
- value 'VALID', 'The configuration file is valid', value: :valid
- value 'INVALID', 'The configuration file is not valid', value: :invalid
+ value 'VALID', 'The configuration file is valid.', value: :valid
+ value 'INVALID', 'The configuration file is not valid.', value: :invalid
end
end
end
diff --git a/app/graphql/types/ci/job_artifact_file_type_enum.rb b/app/graphql/types/ci/job_artifact_file_type_enum.rb
index 4b484dec590..5099b0d4850 100644
--- a/app/graphql/types/ci/job_artifact_file_type_enum.rb
+++ b/app/graphql/types/ci/job_artifact_file_type_enum.rb
@@ -6,7 +6,8 @@ module Types
graphql_name 'JobArtifactFileType'
::Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.keys.each do |file_type|
- value file_type.to_s.upcase, value: file_type.to_s
+ description = file_type == :codequality ? "CODE QUALITY" : file_type.to_s.titleize.upcase # This is needed as doc lint will not allow codequality as one word
+ value file_type.to_s.upcase, value: file_type.to_s, description: "#{description} job artifact file type."
end
end
end
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index ba463cdd9c1..c86337eea89 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -18,6 +18,10 @@ module Types
description: 'Schedule for the build.'
field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true,
description: 'Artifacts generated by the job.'
+ field :finished_at, Types::TimeType, null: true,
+ description: 'When a job has finished running.'
+ field :duration, GraphQL::INT_TYPE, null: true,
+ description: 'Duration of the job in seconds.'
def pipeline
Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Pipeline, object.pipeline_id).find
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index 2c386c9b564..49be200a788 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -95,6 +95,9 @@ module Types
field :path, GraphQL::STRING_TYPE, null: true,
description: "Relative path to the pipeline's page."
+ field :commit_path, GraphQL::STRING_TYPE, null: true,
+ description: 'Path to the commit that triggered the pipeline.'
+
field :project, Types::ProjectType, null: true,
description: 'Project the pipeline belongs to.'
@@ -109,6 +112,10 @@ module Types
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find
end
+ def commit_path
+ ::Gitlab::Routing.url_helpers.project_commit_path(object.project, object.sha)
+ end
+
def path
::Gitlab::Routing.url_helpers.project_pipeline_path(object.project, object)
end
diff --git a/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb b/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb
index 9835a7ef208..ccb72283cf6 100644
--- a/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb
+++ b/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb
@@ -3,7 +3,6 @@
module Types
module CiConfiguration
module Sast
- # rubocop: disable Graphql/AuthorizeTypes
class AnalyzersEntityInputType < BaseInputObject
graphql_name 'SastCiConfigurationAnalyzersEntityInput'
description 'Represents the analyzers entity in SAST CI configuration'
diff --git a/app/graphql/types/ci_configuration/sast/entity_input_type.rb b/app/graphql/types/ci_configuration/sast/entity_input_type.rb
index 39b3efb3db8..9fce120889b 100644
--- a/app/graphql/types/ci_configuration/sast/entity_input_type.rb
+++ b/app/graphql/types/ci_configuration/sast/entity_input_type.rb
@@ -3,7 +3,6 @@
module Types
module CiConfiguration
module Sast
- # rubocop: disable Graphql/AuthorizeTypes
class EntityInputType < BaseInputObject
graphql_name 'SastCiConfigurationEntityInput'
description 'Represents an entity in SAST CI configuration'
diff --git a/app/graphql/types/ci_configuration/sast/input_type.rb b/app/graphql/types/ci_configuration/sast/input_type.rb
index 615436683f6..a86bdfca4da 100644
--- a/app/graphql/types/ci_configuration/sast/input_type.rb
+++ b/app/graphql/types/ci_configuration/sast/input_type.rb
@@ -3,7 +3,7 @@
module Types
module CiConfiguration
module Sast
- class InputType < BaseInputObject # rubocop:disable Graphql/AuthorizeTypes
+ class InputType < BaseInputObject
graphql_name 'SastCiConfigurationInput'
description 'Represents a CI configuration of SAST'
diff --git a/app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb b/app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb
index 3a208f9d3e4..76d2a314c13 100644
--- a/app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb
+++ b/app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb
@@ -7,9 +7,9 @@ module Types
graphql_name 'SastUiComponentSize'
description 'Size of UI component in SAST configuration page'
- value 'SMALL'
- value 'MEDIUM'
- value 'LARGE'
+ value 'SMALL', description: "The size of UI component in SAST configuration page is small."
+ value 'MEDIUM', description: "The size of UI component in SAST configuration page is medium."
+ value 'LARGE', description: "The size of UI component in SAST configuration page is large."
end
end
end
diff --git a/app/graphql/types/commit_action_mode_enum.rb b/app/graphql/types/commit_action_mode_enum.rb
index 77658a85b51..ea3d49c9822 100644
--- a/app/graphql/types/commit_action_mode_enum.rb
+++ b/app/graphql/types/commit_action_mode_enum.rb
@@ -5,10 +5,10 @@ module Types
graphql_name 'CommitActionMode'
description 'Mode of a commit action'
- value 'CREATE', description: 'Create command', value: :create
- value 'DELETE', description: 'Delete command', value: :delete
- value 'MOVE', description: 'Move command', value: :move
- value 'UPDATE', description: 'Update command', value: :update
- value 'CHMOD', description: 'Chmod command', value: :chmod
+ value 'CREATE', description: 'Create command.', value: :create
+ value 'DELETE', description: 'Delete command.', value: :delete
+ value 'MOVE', description: 'Move command.', value: :move
+ value 'UPDATE', description: 'Update command.', value: :update
+ value 'CHMOD', description: 'Chmod command.', value: :chmod
end
end
diff --git a/app/graphql/types/commit_action_type.rb b/app/graphql/types/commit_action_type.rb
index e14e7157752..cc1f45478e4 100644
--- a/app/graphql/types/commit_action_type.rb
+++ b/app/graphql/types/commit_action_type.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
module Types
- # rubocop: disable Graphql/AuthorizeTypes
class CommitActionType < BaseInputObject
argument :action, type: Types::CommitActionModeEnum, required: true,
description: 'The action to perform, create, delete, move, update, chmod.'
@@ -18,5 +17,4 @@ module Types
argument :encoding, type: Types::CommitEncodingEnum, required: false,
description: 'Encoding of the file. Default is text.'
end
- # rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/commit_encoding_enum.rb b/app/graphql/types/commit_encoding_enum.rb
index 0ea89b82db7..c51c1e864fb 100644
--- a/app/graphql/types/commit_encoding_enum.rb
+++ b/app/graphql/types/commit_encoding_enum.rb
@@ -4,7 +4,7 @@ module Types
class CommitEncodingEnum < BaseEnum
graphql_name 'CommitEncoding'
- value 'TEXT', description: 'Text encoding', value: :text
- value 'BASE64', description: 'Base64 encoding', value: :base64
+ value 'TEXT', description: 'Text encoding.', value: :text
+ value 'BASE64', description: 'Base64 encoding.', value: :base64
end
end
diff --git a/app/graphql/types/concerns/gitlab_style_deprecations.rb b/app/graphql/types/concerns/gitlab_style_deprecations.rb
index 9f087f3812d..ad195354930 100644
--- a/app/graphql/types/concerns/gitlab_style_deprecations.rb
+++ b/app/graphql/types/concerns/gitlab_style_deprecations.rb
@@ -10,7 +10,7 @@ module GitlabStyleDeprecations
def gitlab_deprecation(kwargs)
if kwargs[:deprecation_reason].present?
raise ArgumentError, 'Use `deprecated` property instead of `deprecation_reason`. ' \
- 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-fields-and-enum-values'
+ 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-fields-arguments-and-enum-values'
end
deprecation = kwargs.delete(:deprecated)
diff --git a/app/graphql/types/container_repository_sort_enum.rb b/app/graphql/types/container_repository_sort_enum.rb
index 96210c0546b..4f2e15fd32f 100644
--- a/app/graphql/types/container_repository_sort_enum.rb
+++ b/app/graphql/types/container_repository_sort_enum.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'ContainerRepositorySort'
description 'Values for sorting container repositories'
- value 'NAME_ASC', 'Name by ascending order', value: :name_asc
- value 'NAME_DESC', 'Name by descending order', value: :name_desc
+ value 'NAME_ASC', 'Name by ascending order.', value: :name_asc
+ value 'NAME_DESC', 'Name by descending order.', value: :name_desc
end
end
diff --git a/app/graphql/types/current_user_todos.rb b/app/graphql/types/current_user_todos.rb
index 79a430af1d7..2551db875b0 100644
--- a/app/graphql/types/current_user_todos.rb
+++ b/app/graphql/types/current_user_todos.rb
@@ -16,9 +16,10 @@ module Types
end
def current_user_todos(state: nil)
- state ||= %i(done pending) # TodosFinder treats a `nil` state param as `pending`
+ state ||= %i[done pending] # TodosFinder treats a `nil` state param as `pending`
+ klass = unpresented.class
- TodosFinder.new(current_user, state: state, type: object.class.name, target_id: object.id).execute
+ TodosFinder.new(current_user, state: state, type: klass.name, target_id: object.id).execute
end
end
end
diff --git a/app/graphql/types/design_management/design_version_event_enum.rb b/app/graphql/types/design_management/design_version_event_enum.rb
index ea4bc1ffbfa..354b34c9f9f 100644
--- a/app/graphql/types/design_management/design_version_event_enum.rb
+++ b/app/graphql/types/design_management/design_version_event_enum.rb
@@ -8,7 +8,7 @@ module Types
NONE = 'NONE'
- value NONE, 'No change'
+ value NONE, 'No change.'
::DesignManagement::Action.events.keys.each do |event_name|
value event_name.upcase, value: event_name, description: "A #{event_name} event"
diff --git a/app/graphql/types/diff_paths_input_type.rb b/app/graphql/types/diff_paths_input_type.rb
index 864cec1ab07..d148b7656eb 100644
--- a/app/graphql/types/diff_paths_input_type.rb
+++ b/app/graphql/types/diff_paths_input_type.rb
@@ -1,12 +1,10 @@
# frozen_string_literal: true
module Types
- # rubocop: disable Graphql/AuthorizeTypes
class DiffPathsInputType < BaseInputObject
argument :old_path, GraphQL::STRING_TYPE, required: false,
description: 'The path of the file on the start sha.'
argument :new_path, GraphQL::STRING_TYPE, required: false,
description: 'The path of the file on the head sha.'
end
- # rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/error_tracking/sentry_error_status_enum.rb b/app/graphql/types/error_tracking/sentry_error_status_enum.rb
index df68eef4f3c..10579bcbfb1 100644
--- a/app/graphql/types/error_tracking/sentry_error_status_enum.rb
+++ b/app/graphql/types/error_tracking/sentry_error_status_enum.rb
@@ -6,10 +6,10 @@ module Types
graphql_name 'SentryErrorStatus'
description 'State of a Sentry error'
- value 'RESOLVED', value: 'resolved', description: 'Error has been resolved'
- value 'RESOLVED_IN_NEXT_RELEASE', value: 'resolvedInNextRelease', description: 'Error has been ignored until next release'
- value 'UNRESOLVED', value: 'unresolved', description: 'Error is unresolved'
- value 'IGNORED', value: 'ignored', description: 'Error has been ignored'
+ value 'RESOLVED', value: 'resolved', description: 'Error has been resolved.'
+ value 'RESOLVED_IN_NEXT_RELEASE', value: 'resolvedInNextRelease', description: 'Error has been ignored until next release.'
+ value 'UNRESOLVED', value: 'unresolved', description: 'Error is unresolved.'
+ value 'IGNORED', value: 'ignored', description: 'Error has been ignored.'
end
end
end
diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb
index ed28c3ffd7e..750bd1bfe8d 100644
--- a/app/graphql/types/global_id_type.rb
+++ b/app/graphql/types/global_id_type.rb
@@ -19,7 +19,14 @@ end
module Types
class GlobalIDType < BaseScalar
graphql_name 'GlobalID'
- description 'A global identifier'
+ description <<~DESC
+ A global identifier.
+
+ A global identifier represents an object uniquely across the application.
+ An example of such an identifier is "gid://gitlab/User/1".
+
+ Global identifiers are encoded as strings.
+ DESC
# @param value [GID]
# @return [String]
@@ -46,35 +53,40 @@ module Types
@id_types[model_class] ||= Class.new(self) do
graphql_name "#{model_class.name.gsub(/::/, '')}ID"
- description "Identifier of #{model_class.name}."
+ description <<~MD
+ A `#{graphql_name}` is a global ID. It is encoded as a string.
+
+ An example `#{graphql_name}` is: `"#{::Gitlab::GlobalId.build(model_name: model_class.name, id: 1)}"`.
+ MD
- self.define_singleton_method(:to_s) do
+ define_singleton_method(:to_s) do
graphql_name
end
- self.define_singleton_method(:inspect) do
+ define_singleton_method(:inspect) do
graphql_name
end
- self.define_singleton_method(:coerce_result) do |gid, ctx|
+ 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
+ next global_id.to_s if suitable?(global_id)
+
+ raise GraphQL::CoercionError, "Expected a #{model_class.name} ID, got #{global_id}"
end
- self.define_singleton_method(:suitable?) do |gid|
- gid&.model_class&.ancestors&.include?(model_class)
+ define_singleton_method(:suitable?) do |gid|
+ next false if gid.nil?
+
+ gid.model_name.safe_constantize.present? &&
+ gid.model_class.ancestors.include?(model_class)
end
- self.define_singleton_method(:coerce_input) do |string, ctx|
+ 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)
+ next gid if suitable?(gid)
- gid
+ raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of #{model_class.name}"
end
end
end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 0aaeb8d20df..7a84e76657b 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -95,6 +95,10 @@ module Types
field :container_repositories_count, GraphQL::INT_TYPE, null: false,
description: 'Number of container repositories in the group.'
+ field :packages,
+ description: 'Packages of the group.',
+ resolver: Resolvers::GroupPackagesResolver
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
LabelsFinder
diff --git a/app/graphql/types/issuable_sort_enum.rb b/app/graphql/types/issuable_sort_enum.rb
index a6d52124d99..f6e17d8988e 100644
--- a/app/graphql/types/issuable_sort_enum.rb
+++ b/app/graphql/types/issuable_sort_enum.rb
@@ -5,11 +5,11 @@ module Types
graphql_name 'IssuableSort'
description 'Values for sorting issuables'
- value 'PRIORITY_ASC', 'Priority by ascending order', value: :priority_asc
- value 'PRIORITY_DESC', 'Priority by descending order', value: :priority_desc
- value 'LABEL_PRIORITY_ASC', 'Label priority by ascending order', value: :label_priority_asc
- value 'LABEL_PRIORITY_DESC', 'Label priority by descending order', value: :label_priority_desc
- value 'MILESTONE_DUE_ASC', 'Milestone due date by ascending order', value: :milestone_due_asc
- value 'MILESTONE_DUE_DESC', 'Milestone due date by descending order', value: :milestone_due_desc
+ value 'PRIORITY_ASC', 'Priority by ascending order.', value: :priority_asc
+ value 'PRIORITY_DESC', 'Priority by descending order.', value: :priority_desc
+ value 'LABEL_PRIORITY_ASC', 'Label priority by ascending order.', value: :label_priority_asc
+ value 'LABEL_PRIORITY_DESC', 'Label priority by descending order.', value: :label_priority_desc
+ value 'MILESTONE_DUE_ASC', 'Milestone due date by ascending order.', value: :milestone_due_asc
+ value 'MILESTONE_DUE_DESC', 'Milestone due date by descending order.', value: :milestone_due_desc
end
end
diff --git a/app/graphql/types/issuable_state_enum.rb b/app/graphql/types/issuable_state_enum.rb
index 543b7f8e5b2..5a1b11b3bdc 100644
--- a/app/graphql/types/issuable_state_enum.rb
+++ b/app/graphql/types/issuable_state_enum.rb
@@ -5,9 +5,9 @@ module Types
graphql_name 'IssuableState'
description 'State of a GitLab issue or merge request'
- value 'opened'
- value 'closed'
- value 'locked'
- value 'all'
+ value 'opened', description: 'In open state.'
+ value 'closed', description: 'In closed state.'
+ value 'locked', description: 'Discussion has been locked.'
+ value 'all', description: 'All available.'
end
end
diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb
index 08762264b1b..bf900fe3525 100644
--- a/app/graphql/types/issue_sort_enum.rb
+++ b/app/graphql/types/issue_sort_enum.rb
@@ -5,11 +5,11 @@ module Types
graphql_name 'IssueSort'
description 'Values for sorting issues'
- value 'DUE_DATE_ASC', 'Due date by ascending order', value: :due_date_asc
- value 'DUE_DATE_DESC', 'Due date by descending order', value: :due_date_desc
- value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: :relative_position_asc
- 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 'DUE_DATE_ASC', 'Due date by ascending order.', value: :due_date_asc
+ value 'DUE_DATE_DESC', 'Due date by descending order.', value: :due_date_desc
+ value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order.', value: :relative_position_asc
+ 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
end
end
diff --git a/app/graphql/types/issue_state_event_enum.rb b/app/graphql/types/issue_state_event_enum.rb
index 6a9d840831d..165b9c28367 100644
--- a/app/graphql/types/issue_state_event_enum.rb
+++ b/app/graphql/types/issue_state_event_enum.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'IssueStateEvent'
description 'Values for issue state events'
- value 'REOPEN', 'Reopens the issue', value: 'reopen'
- value 'CLOSE', 'Closes the issue', value: 'close'
+ value 'REOPEN', 'Reopens the issue.', value: 'reopen'
+ value 'CLOSE', 'Closes the issue.', value: 'close'
end
end
diff --git a/app/graphql/types/jira_users_mapping_input_type.rb b/app/graphql/types/jira_users_mapping_input_type.rb
index 0c26ea87df2..61e3240ecf3 100644
--- a/app/graphql/types/jira_users_mapping_input_type.rb
+++ b/app/graphql/types/jira_users_mapping_input_type.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
module Types
- # rubocop: disable Graphql/AuthorizeTypes
class JiraUsersMappingInputType < BaseInputObject
graphql_name 'JiraUsersMappingInputType'
@@ -14,5 +13,4 @@ module Types
required: false,
description: 'Id of the GitLab user.'
end
- # rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb
index 94fd15e075c..4e8718a80da 100644
--- a/app/graphql/types/label_type.rb
+++ b/app/graphql/types/label_type.rb
@@ -19,5 +19,9 @@ module Types
description: 'Background color of the label.'
field :text_color, GraphQL::STRING_TYPE, null: false,
description: 'Text color of the label.'
+ field :created_at, Types::TimeType, null: false,
+ description: 'When this label was created.'
+ field :updated_at, Types::TimeType, null: false,
+ description: 'When this label was last updated.'
end
end
diff --git a/app/graphql/types/merge_request_sort_enum.rb b/app/graphql/types/merge_request_sort_enum.rb
index c64ae367a76..92a71998d91 100644
--- a/app/graphql/types/merge_request_sort_enum.rb
+++ b/app/graphql/types/merge_request_sort_enum.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'MergeRequestSort'
description 'Values for sorting merge requests'
- value 'MERGED_AT_ASC', 'Merge time by ascending order', value: :merged_at_asc
- value 'MERGED_AT_DESC', 'Merge time by descending order', value: :merged_at_desc
+ value 'MERGED_AT_ASC', 'Merge time by ascending order.', value: :merged_at_asc
+ value 'MERGED_AT_DESC', 'Merge time by descending order.', value: :merged_at_desc
end
end
diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb
index c14b9f80a53..a2d7bd0306c 100644
--- a/app/graphql/types/merge_request_state_enum.rb
+++ b/app/graphql/types/merge_request_state_enum.rb
@@ -5,6 +5,6 @@ module Types
graphql_name 'MergeRequestState'
description 'State of a GitLab merge request'
- value 'merged', description: "Merge Request has been merged"
+ value 'merged', description: "Merge Request has been merged."
end
end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 10f324e901a..449286915f2 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -54,7 +54,7 @@ module Types
field :target_branch, GraphQL::STRING_TYPE, null: false,
description: 'Target branch of the merge request.'
field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false,
- description: 'Indicates if the merge request is a work in progress (WIP).'
+ description: 'Indicates if the merge request is a draft.'
field :merge_when_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true,
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,
@@ -108,6 +108,10 @@ module Types
null: false, calls_gitaly: true,
method: :target_branch_exists?,
description: 'Indicates if the target branch of the merge request exists.'
+ field :diverged_from_target_branch, GraphQL::BOOLEAN_TYPE,
+ null: false, calls_gitaly: true,
+ method: :diverged_from_target_branch?,
+ description: 'Indicates if the source branch is behind the target branch.'
field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.'
field :web_url, GraphQL::STRING_TYPE, null: true,
diff --git a/app/graphql/types/merge_strategy_enum.rb b/app/graphql/types/merge_strategy_enum.rb
new file mode 100644
index 00000000000..2120dc576eb
--- /dev/null
+++ b/app/graphql/types/merge_strategy_enum.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Types
+ class MergeStrategyEnum < BaseEnum
+ AutoMergeService.all_strategies_ordered_by_preference.each do |strat|
+ value strat.upcase, value: strat, description: "Use the #{strat} merge strategy."
+ end
+ end
+end
diff --git a/app/graphql/types/milestone_state_enum.rb b/app/graphql/types/milestone_state_enum.rb
index e3b60395c9b..f3797f8f9e5 100644
--- a/app/graphql/types/milestone_state_enum.rb
+++ b/app/graphql/types/milestone_state_enum.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'MilestoneStateEnum'
description 'Current state of milestone'
- value 'active', description: 'Milestone is currently active'
- value 'closed', description: 'Milestone is closed'
+ value 'active', description: 'Milestone is currently active.'
+ value 'closed', description: 'Milestone is closed.'
end
end
diff --git a/app/graphql/types/mutation_operation_mode_enum.rb b/app/graphql/types/mutation_operation_mode_enum.rb
index 37e83e7a2e1..75c1d7cd4a6 100644
--- a/app/graphql/types/mutation_operation_mode_enum.rb
+++ b/app/graphql/types/mutation_operation_mode_enum.rb
@@ -7,8 +7,8 @@ module Types
# Suggested param name for the enum: `operation_mode`
- value 'REPLACE', 'Performs a replace operation'
- value 'APPEND', 'Performs an append operation'
- value 'REMOVE', 'Performs a removal operation'
+ value 'REPLACE', 'Performs a replace operation.'
+ value 'APPEND', 'Performs an append operation.'
+ value 'REMOVE', 'Performs a removal operation.'
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 166f5617da2..76ffddf416f 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -24,6 +24,7 @@ module Types
mount_mutation Mutations::AwardEmojis::Toggle
mount_mutation Mutations::Boards::Create
mount_mutation Mutations::Boards::Destroy
+ mount_mutation Mutations::Boards::Update
mount_mutation Mutations::Boards::Issues::IssueMoveList
mount_mutation Mutations::Boards::Lists::Create
mount_mutation Mutations::Boards::Lists::Update
@@ -43,6 +44,7 @@ module Types
mount_mutation Mutations::Issues::Update
mount_mutation Mutations::Issues::Move
mount_mutation Mutations::Labels::Create
+ mount_mutation Mutations::MergeRequests::Accept
mount_mutation Mutations::MergeRequests::Create
mount_mutation Mutations::MergeRequests::Update
mount_mutation Mutations::MergeRequests::SetLabels
@@ -57,19 +59,15 @@ module Types
mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true
mount_mutation Mutations::Notes::Create::DiffNote, calls_gitaly: true
mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true
- mount_mutation Mutations::Notes::Update::Note,
- description: 'Updates a Note. If the body of the Note contains only quick actions, ' \
- 'the Note will be destroyed during the update, and no Note will be ' \
- 'returned'
- mount_mutation Mutations::Notes::Update::ImageDiffNote,
- description: 'Updates a DiffNote on an image (a `Note` where the `position.positionType` is `"image"`). ' \
- 'If the body of the Note contains only quick actions, the Note will be ' \
- 'destroyed during the update, and no Note will be returned'
+ mount_mutation Mutations::Notes::Update::Note
+ mount_mutation Mutations::Notes::Update::ImageDiffNote
mount_mutation Mutations::Notes::RepositionImageDiffNote
mount_mutation Mutations::Notes::Destroy
mount_mutation Mutations::Releases::Create
mount_mutation Mutations::Releases::Update
mount_mutation Mutations::Releases::Delete
+ mount_mutation Mutations::ReleaseAssetLinks::Create
+ mount_mutation Mutations::ReleaseAssetLinks::Update
mount_mutation Mutations::Terraform::State::Delete
mount_mutation Mutations::Terraform::State::Lock
mount_mutation Mutations::Terraform::State::Unlock
@@ -95,6 +93,7 @@ module Types
mount_mutation Mutations::Ci::Pipeline::Retry
mount_mutation Mutations::Ci::CiCdSettingsUpdate
mount_mutation Mutations::Namespace::PackageSettings::Update
+ mount_mutation Mutations::UserCallouts::Create
end
end
diff --git a/app/graphql/types/notes/diff_image_position_input_type.rb b/app/graphql/types/notes/diff_image_position_input_type.rb
index 23b53b20815..dd5c8f20cc3 100644
--- a/app/graphql/types/notes/diff_image_position_input_type.rb
+++ b/app/graphql/types/notes/diff_image_position_input_type.rb
@@ -2,7 +2,6 @@
module Types
module Notes
- # rubocop: disable Graphql/AuthorizeTypes
class DiffImagePositionInputType < DiffPositionBaseInputType
graphql_name 'DiffImagePositionInput'
@@ -15,6 +14,5 @@ module Types
argument :height, GraphQL::INT_TYPE, required: true,
description: copy_field_description(Types::Notes::DiffPositionType, :height)
end
- # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/notes/diff_position_base_input_type.rb b/app/graphql/types/notes/diff_position_base_input_type.rb
index a9b4e1a8948..c8f9f9028cc 100644
--- a/app/graphql/types/notes/diff_position_base_input_type.rb
+++ b/app/graphql/types/notes/diff_position_base_input_type.rb
@@ -2,7 +2,6 @@
module Types
module Notes
- # rubocop: disable Graphql/AuthorizeTypes
class DiffPositionBaseInputType < BaseInputObject
argument :head_sha, GraphQL::STRING_TYPE, required: true,
description: copy_field_description(Types::DiffRefsType, :head_sha)
@@ -17,6 +16,5 @@ module Types
description: 'The paths of the file that was changed. ' \
'Both of the properties of this input are optional, but at least one of them is required'
end
- # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/notes/diff_position_input_type.rb b/app/graphql/types/notes/diff_position_input_type.rb
index 02c91e173cb..7ec5fd9e086 100644
--- a/app/graphql/types/notes/diff_position_input_type.rb
+++ b/app/graphql/types/notes/diff_position_input_type.rb
@@ -2,15 +2,13 @@
module Types
module Notes
- # rubocop: disable Graphql/AuthorizeTypes
class DiffPositionInputType < DiffPositionBaseInputType
graphql_name 'DiffPositionInput'
argument :old_line, GraphQL::INT_TYPE, required: false,
description: copy_field_description(Types::Notes::DiffPositionType, :old_line)
- argument :new_line, GraphQL::INT_TYPE, required: true,
+ argument :new_line, GraphQL::INT_TYPE, required: false,
description: copy_field_description(Types::Notes::DiffPositionType, :new_line)
end
- # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/notes/position_type_enum.rb b/app/graphql/types/notes/position_type_enum.rb
index abdb2cfc804..9939f6511ce 100644
--- a/app/graphql/types/notes/position_type_enum.rb
+++ b/app/graphql/types/notes/position_type_enum.rb
@@ -6,8 +6,8 @@ module Types
graphql_name 'DiffPositionType'
description 'Type of file the position refers to'
- value 'text'
- value 'image'
+ value 'text', description: "A text file"
+ value 'image', description: "An image"
end
end
end
diff --git a/app/graphql/types/notes/update_diff_image_position_input_type.rb b/app/graphql/types/notes/update_diff_image_position_input_type.rb
index 1b915b65ae9..ab27f6b9ad3 100644
--- a/app/graphql/types/notes/update_diff_image_position_input_type.rb
+++ b/app/graphql/types/notes/update_diff_image_position_input_type.rb
@@ -3,8 +3,6 @@
module Types
module Notes
# InputType used for updateImageDiffNote mutation.
- #
- # rubocop: disable Graphql/AuthorizeTypes
class UpdateDiffImagePositionInputType < BaseInputObject
graphql_name 'UpdateDiffImagePositionInput'
@@ -32,6 +30,5 @@ module Types
end
end
end
- # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 7205c615271..9a3f2e311e6 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -181,7 +181,7 @@ module Types
field :packages,
description: 'Packages of the project.',
- resolver: Resolvers::PackagesResolver
+ resolver: Resolvers::ProjectPackagesResolver
field :pipelines,
null: true,
@@ -273,6 +273,12 @@ module Types
description: 'Integrations which can receive alerts for the project.',
resolver: Resolvers::AlertManagement::IntegrationsResolver
+ field :alert_management_http_integrations,
+ Types::AlertManagement::HttpIntegrationType.connection_type,
+ null: true,
+ description: 'HTTP Integrations which can receive alerts for the project.',
+ resolver: Resolvers::AlertManagement::HttpIntegrationsResolver
+
field :releases,
Types::ReleaseType.connection_type,
null: true,
diff --git a/app/graphql/types/projects/namespace_project_sort_enum.rb b/app/graphql/types/projects/namespace_project_sort_enum.rb
index ede29748beb..bd7058196dd 100644
--- a/app/graphql/types/projects/namespace_project_sort_enum.rb
+++ b/app/graphql/types/projects/namespace_project_sort_enum.rb
@@ -6,8 +6,8 @@ module Types
graphql_name 'NamespaceProjectSort'
description 'Values for sorting projects'
- value 'SIMILARITY', 'Most similar to the search query', value: :similarity
- value 'STORAGE', 'Sort by storage size', value: :storage
+ value 'SIMILARITY', 'Most similar to the search query.', value: :similarity
+ value 'STORAGE', 'Sort by storage size.', value: :storage
end
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 1d1ab4f2e17..74818bfcd42 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -78,14 +78,20 @@ module Types
field :issue, Types::IssueType,
null: true,
- description: 'Find an issue.' do
+ description: 'Find an Issue.' do
argument :id, ::Types::GlobalIDType[::Issue], required: true, description: 'The global ID of the Issue.'
end
- field :instance_statistics_measurements, Types::Admin::Analytics::InstanceStatistics::MeasurementType.connection_type,
+ field :instance_statistics_measurements, Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type,
null: true,
description: 'Get statistics on the instance.',
- resolver: Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsResolver
+ deprecated: { reason: 'This field was renamed. Use the `usageTrendsMeasurements` field instead', milestone: '13.10' },
+ resolver: Resolvers::Admin::Analytics::UsageTrends::MeasurementsResolver
+
+ field :usage_trends_measurements, Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type,
+ null: true,
+ description: 'Get statistics on the instance.',
+ resolver: Resolvers::Admin::Analytics::UsageTrends::MeasurementsResolver
field :ci_application_settings, Types::Ci::ApplicationSettingType,
null: true,
diff --git a/app/graphql/types/range_input_type.rb b/app/graphql/types/range_input_type.rb
index b75c3669fbf..e31b8ecd811 100644
--- a/app/graphql/types/range_input_type.rb
+++ b/app/graphql/types/range_input_type.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
module Types
- # rubocop: disable Graphql/AuthorizeTypes
class RangeInputType < BaseInputObject
def self.[](type, closed = true)
@subtypes ||= {}
@@ -25,5 +24,4 @@ module Types
to_h
end
end
- # rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/release_asset_link_input_type.rb b/app/graphql/types/release_asset_link_input_type.rb
index 242d19b683f..17d05a73797 100644
--- a/app/graphql/types/release_asset_link_input_type.rb
+++ b/app/graphql/types/release_asset_link_input_type.rb
@@ -1,25 +1,10 @@
# frozen_string_literal: true
module Types
- # rubocop: disable Graphql/AuthorizeTypes
class ReleaseAssetLinkInputType < BaseInputObject
graphql_name 'ReleaseAssetLinkInput'
description 'Fields that are available when modifying a release asset link'
- argument :name, GraphQL::STRING_TYPE,
- required: true,
- description: 'Name of the asset link.'
-
- argument :url, GraphQL::STRING_TYPE,
- required: true,
- description: 'URL of the asset link.'
-
- argument :direct_asset_path, GraphQL::STRING_TYPE,
- required: false, as: :filepath,
- description: 'Relative path for a direct asset link.'
-
- argument :link_type, Types::ReleaseAssetLinkTypeEnum,
- required: false, default_value: 'other',
- description: 'The type of the asset link.'
+ include Types::ReleaseAssetLinkSharedInputArguments
end
end
diff --git a/app/graphql/types/release_asset_link_shared_input_arguments.rb b/app/graphql/types/release_asset_link_shared_input_arguments.rb
new file mode 100644
index 00000000000..4aa247e47cc
--- /dev/null
+++ b/app/graphql/types/release_asset_link_shared_input_arguments.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ module ReleaseAssetLinkSharedInputArguments
+ extend ActiveSupport::Concern
+
+ included do
+ argument :name, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Name of the asset link.'
+
+ argument :url, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'URL of the asset link.'
+
+ argument :direct_asset_path, GraphQL::STRING_TYPE,
+ required: false, as: :filepath,
+ description: 'Relative path for a direct asset link.'
+
+ argument :link_type, Types::ReleaseAssetLinkTypeEnum,
+ required: false, default_value: 'other',
+ description: 'The type of the asset link.'
+ end
+ end
+end
diff --git a/app/graphql/types/release_assets_input_type.rb b/app/graphql/types/release_assets_input_type.rb
index f50be89d43f..2c8d3de3495 100644
--- a/app/graphql/types/release_assets_input_type.rb
+++ b/app/graphql/types/release_assets_input_type.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
module Types
- # rubocop: disable Graphql/AuthorizeTypes
class ReleaseAssetsInputType < BaseInputObject
graphql_name 'ReleaseAssetsInput'
description 'Fields that are available when modifying release assets'
diff --git a/app/graphql/types/release_sort_enum.rb b/app/graphql/types/release_sort_enum.rb
index 2f9af1bced9..f51b73d89f1 100644
--- a/app/graphql/types/release_sort_enum.rb
+++ b/app/graphql/types/release_sort_enum.rb
@@ -9,10 +9,10 @@ module Types
# Borrowed from Types::SortEnum
# These values/descriptions should stay in-sync as much as possible.
- value 'CREATED_DESC', 'Created at descending order', value: :created_desc
- value 'CREATED_ASC', 'Created at ascending order', value: :created_asc
+ value 'CREATED_DESC', 'Created at descending order.', value: :created_desc
+ value 'CREATED_ASC', 'Created at ascending order.', value: :created_asc
- value 'RELEASED_AT_DESC', 'Released at by descending order', value: :released_at_desc
- value 'RELEASED_AT_ASC', 'Released at by ascending order', value: :released_at_asc
+ value 'RELEASED_AT_DESC', 'Released at by descending order.', value: :released_at_desc
+ value 'RELEASED_AT_ASC', 'Released at by ascending order.', value: :released_at_asc
end
end
diff --git a/app/graphql/types/snippets/blob_action_input_type.rb b/app/graphql/types/snippets/blob_action_input_type.rb
index c4289375f6b..13eade3dcc4 100644
--- a/app/graphql/types/snippets/blob_action_input_type.rb
+++ b/app/graphql/types/snippets/blob_action_input_type.rb
@@ -2,7 +2,7 @@
module Types
module Snippets
- class BlobActionInputType < BaseInputObject # rubocop:disable Graphql/AuthorizeTypes
+ class BlobActionInputType < BaseInputObject
graphql_name 'SnippetBlobActionInputType'
description 'Represents an action to perform over a snippet file'
diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb
index fb0c1d9409b..fb9ee380705 100644
--- a/app/graphql/types/snippets/blob_type.rb
+++ b/app/graphql/types/snippets/blob_type.rb
@@ -14,7 +14,6 @@ module Types
field :plain_data, GraphQL::STRING_TYPE,
description: 'Blob plain highlighted data.',
- calls_gitaly: true,
null: true
field :raw_path, GraphQL::STRING_TYPE,
diff --git a/app/graphql/types/sort_enum.rb b/app/graphql/types/sort_enum.rb
index c3a76330fe9..ff994039b6d 100644
--- a/app/graphql/types/sort_enum.rb
+++ b/app/graphql/types/sort_enum.rb
@@ -7,14 +7,14 @@ module Types
# Deprecated, as we prefer uppercase enums
# https://gitlab.com/groups/gitlab-org/-/epics/1838
- value 'updated_desc', 'Updated at descending order', value: :updated_desc, deprecated: { reason: 'Use UPDATED_DESC', milestone: '13.5' }
- value 'updated_asc', 'Updated at ascending order', value: :updated_asc, deprecated: { reason: 'Use UPDATED_ASC', milestone: '13.5' }
- value 'created_desc', 'Created at descending order', value: :created_desc, deprecated: { reason: 'Use CREATED_DESC', milestone: '13.5' }
- value 'created_asc', 'Created at ascending order', value: :created_asc, deprecated: { reason: 'Use CREATED_ASC', milestone: '13.5' }
+ value 'updated_desc', 'Updated at descending order.', value: :updated_desc, deprecated: { reason: 'Use UPDATED_DESC', milestone: '13.5' }
+ value 'updated_asc', 'Updated at ascending order.', value: :updated_asc, deprecated: { reason: 'Use UPDATED_ASC', milestone: '13.5' }
+ value 'created_desc', 'Created at descending order.', value: :created_desc, deprecated: { reason: 'Use CREATED_DESC', milestone: '13.5' }
+ value 'created_asc', 'Created at ascending order.', value: :created_asc, deprecated: { reason: 'Use CREATED_ASC', milestone: '13.5' }
- value 'UPDATED_DESC', 'Updated at descending order', value: :updated_desc
- value 'UPDATED_ASC', 'Updated at ascending order', value: :updated_asc
- value 'CREATED_DESC', 'Created at descending order', value: :created_desc
- value 'CREATED_ASC', 'Created at ascending order', value: :created_asc
+ value 'UPDATED_DESC', 'Updated at descending order.', value: :updated_desc
+ value 'UPDATED_ASC', 'Updated at ascending order.', value: :updated_asc
+ value 'CREATED_DESC', 'Created at descending order.', value: :created_desc
+ value 'CREATED_ASC', 'Created at ascending order.', value: :created_asc
end
end
diff --git a/app/graphql/types/time_type.rb b/app/graphql/types/time_type.rb
index c31e4873df0..2db14953308 100644
--- a/app/graphql/types/time_type.rb
+++ b/app/graphql/types/time_type.rb
@@ -3,7 +3,13 @@
module Types
class TimeType < BaseScalar
graphql_name 'Time'
- description 'Time represented in ISO 8601'
+ description <<~DESC
+ Time represented in ISO 8601.
+
+ For example: "2021-03-09T14:58:50+00:00".
+
+ See `https://www.iso.org/iso-8601-date-and-time-format.html`.
+ DESC
def self.coerce_input(value, ctx)
Time.parse(value)
diff --git a/app/graphql/types/timeframe_input_type.rb b/app/graphql/types/timeframe_input_type.rb
index 79c1bc5cf01..02a0ec7df7a 100644
--- a/app/graphql/types/timeframe_input_type.rb
+++ b/app/graphql/types/timeframe_input_type.rb
@@ -1,10 +1,8 @@
# frozen_string_literal: true
module Types
- # rubocop: disable Graphql/AuthorizeTypes
class TimeframeInputType < RangeInputType[::Types::DateType]
graphql_name 'Timeframe'
description 'A time-frame defined as a closed inclusive range of two dates'
end
- # rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/todo_action_enum.rb b/app/graphql/types/todo_action_enum.rb
index 0e538838474..ef43b6eb464 100644
--- a/app/graphql/types/todo_action_enum.rb
+++ b/app/graphql/types/todo_action_enum.rb
@@ -2,12 +2,14 @@
module Types
class TodoActionEnum < BaseEnum
- value 'assigned', value: 1
- value 'mentioned', value: 2
- value 'build_failed', value: 3
- value 'marked', value: 4
- value 'approval_required', value: 5
- value 'unmergeable', value: 6
- value 'directly_addressed', value: 7
+ value 'assigned', value: 1, description: 'User was assigned.'
+ value 'mentioned', value: 2, description: 'User was mentioned.'
+ value 'build_failed', value: 3, description: 'Build triggered by the user failed.'
+ value 'marked', value: 4, description: 'User added a TODO.'
+ value 'approval_required', value: 5, description: 'User was set as an approver.'
+ value 'unmergeable', value: 6, description: 'Merge request authored by the user could not be merged.'
+ value 'directly_addressed', value: 7, description: 'User was directly addressed.'
+ value 'merge_train_removed', value: 8, description: 'Merge request authored by the user was removed from the merge train.'
+ value 'review_requested', value: 9, description: 'Review was requested from the user.'
end
end
diff --git a/app/graphql/types/todo_state_enum.rb b/app/graphql/types/todo_state_enum.rb
index 29a28b5208d..604e2a62f70 100644
--- a/app/graphql/types/todo_state_enum.rb
+++ b/app/graphql/types/todo_state_enum.rb
@@ -2,7 +2,7 @@
module Types
class TodoStateEnum < BaseEnum
- value 'pending'
- value 'done'
+ value 'pending', description: "The state of the todo is pending."
+ value 'done', description: "The state of the todo is done."
end
end
diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb
index b797722fef8..ebf65e99936 100644
--- a/app/graphql/types/todo_target_enum.rb
+++ b/app/graphql/types/todo_target_enum.rb
@@ -2,11 +2,11 @@
module Types
class TodoTargetEnum < BaseEnum
- value 'COMMIT', value: 'Commit', description: 'A Commit'
- 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'
+ value 'COMMIT', value: 'Commit', description: 'A Commit.'
+ 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 3823bd94083..d192c8d3c57 100644
--- a/app/graphql/types/tree/blob_type.rb
+++ b/app/graphql/types/tree/blob_type.rb
@@ -15,6 +15,7 @@ module Types
field :web_path, GraphQL::STRING_TYPE, null: true,
description: 'Web path of the blob.'
field :lfs_oid, GraphQL::STRING_TYPE, null: true,
+ calls_gitaly: true,
description: 'LFS ID of the blob.'
field :mode, GraphQL::STRING_TYPE, null: true,
description: 'Blob mode in numeric format.'
diff --git a/app/graphql/types/user_callout_feature_name_enum.rb b/app/graphql/types/user_callout_feature_name_enum.rb
new file mode 100644
index 00000000000..410ca5e1c95
--- /dev/null
+++ b/app/graphql/types/user_callout_feature_name_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class UserCalloutFeatureNameEnum < BaseEnum
+ graphql_name 'UserCalloutFeatureNameEnum'
+ description 'Name of the feature that the callout is for.'
+
+ ::UserCallout.feature_names.keys.each do |feature_name|
+ value feature_name.upcase, value: feature_name, description: "Callout feature name for #{feature_name}."
+ end
+ end
+end
diff --git a/app/graphql/types/user_callout_type.rb b/app/graphql/types/user_callout_type.rb
new file mode 100644
index 00000000000..12f4fdea878
--- /dev/null
+++ b/app/graphql/types/user_callout_type.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class UserCalloutType < BaseObject # rubocop:disable Graphql/AuthorizeTypes
+ graphql_name 'UserCallout'
+
+ field :feature_name, UserCalloutFeatureNameEnum, null: false,
+ description: 'Name of the feature that the callout is for.'
+ field :dismissed_at, Types::TimeType, null: true,
+ description: 'Date when the callout was dismissed.'
+ end
+end
diff --git a/app/graphql/types/user_state_enum.rb b/app/graphql/types/user_state_enum.rb
index d34936b4c48..5adec17672e 100644
--- a/app/graphql/types/user_state_enum.rb
+++ b/app/graphql/types/user_state_enum.rb
@@ -5,8 +5,8 @@ module Types
graphql_name 'UserState'
description 'Possible states of a user'
- value 'active', 'The user is active and is able to use the system', value: 'active'
- value 'blocked', 'The user has been blocked and is prevented from using the system', value: 'blocked'
- value 'deactivated', 'The user is no longer active and is unable to use the system', value: 'deactivated'
+ value 'active', 'The user is active and is able to use the system.', value: 'active'
+ value 'blocked', 'The user has been blocked and is prevented from using the system.', value: 'blocked'
+ value 'deactivated', 'The user is no longer active and is unable to use the system.', value: 'deactivated'
end
end
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index 0cefc84633d..2cc7d379240 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -67,5 +67,9 @@ module Types
null: true,
description: 'Snippets authored by the user.',
resolver: Resolvers::Users::SnippetsResolver
+ field :callouts,
+ Types::UserCalloutType.connection_type,
+ null: true,
+ description: 'User callouts that belong to the user.'
end
end
diff --git a/app/graphql/types/visibility_levels_enum.rb b/app/graphql/types/visibility_levels_enum.rb
index d5ace24455e..07718679bef 100644
--- a/app/graphql/types/visibility_levels_enum.rb
+++ b/app/graphql/types/visibility_levels_enum.rb
@@ -3,7 +3,7 @@
module Types
class VisibilityLevelsEnum < BaseEnum
Gitlab::VisibilityLevel.string_options.each do |name, int_value|
- value name.downcase, value: int_value
+ value name.downcase, value: int_value, description: "#{name.titleize} visibility level."
end
end
end
diff --git a/app/helpers/analytics/navbar_helper.rb b/app/helpers/analytics/navbar_helper.rb
index bc0b5e7c74f..33a5028cdf1 100644
--- a/app/helpers/analytics/navbar_helper.rb
+++ b/app/helpers/analytics/navbar_helper.rb
@@ -58,7 +58,7 @@ module Analytics
return unless project.feature_available?(:builds, current_user) || !project.empty_repo?
navbar_sub_item(
- title: _('CI / CD'),
+ title: _('CI/CD'),
path: 'pipelines#charts',
link: charts_project_pipelines_path(project)
)
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 30ae535b06f..085fbfd08da 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -37,13 +37,8 @@ module ApplicationSettingsHelper
end
def storage_weights
- ApplicationSetting.repository_storages_weighted_attributes.map do |attribute|
- storage = attribute.to_s.delete_prefix('repository_storages_weighted_')
- {
- name: attribute,
- label: storage,
- value: @application_setting.repository_storages_weighted[storage] || 0
- }
+ Gitlab.config.repositories.storages.keys.each_with_object(OpenStruct.new) do |storage, weights|
+ weights[storage.to_sym] = @application_setting.repository_storages_weighted[storage] || 0
end
end
@@ -107,15 +102,20 @@ module ApplicationSettingsHelper
def oauth_providers_checkboxes
button_based_providers.map do |source|
disabled = @application_setting.disabled_oauth_sign_in_sources.include?(source.to_s)
- css_class = ['btn']
- css_class << 'active' unless disabled
- checkbox_name = 'application_setting[enabled_oauth_sign_in_sources][]'
name = Gitlab::Auth::OAuth::Provider.label_for(source)
-
- label_tag(checkbox_name, class: css_class.join(' ')) do
- check_box_tag(checkbox_name, source, !disabled,
- autocomplete: 'off',
- id: name.tr(' ', '_')) + name
+ checkbox_name = 'application_setting[enabled_oauth_sign_in_sources][]'
+ checkbox_id = "application_setting_enabled_oauth_sign_in_sources_#{name.parameterize(separator: '_')}"
+
+ content_tag :div, class: 'form-check' do
+ check_box_tag(
+ checkbox_name,
+ source,
+ !disabled,
+ autocomplete: 'off',
+ id: checkbox_id,
+ class: 'form-check-input'
+ ) +
+ label_tag(checkbox_id, name, class: 'form-check-label')
end
end
end
@@ -252,6 +252,7 @@ module ApplicationSettingsHelper
:housekeeping_incremental_repack_period,
:html_emails_enabled,
:import_sources,
+ :in_product_marketing_emails_enabled,
:invisible_captcha_enabled,
:max_artifacts_size,
:max_attachment_size,
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 24c1d224c89..cacf9c7ad0b 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -1,8 +1,22 @@
# frozen_string_literal: true
module AuthHelper
- PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq salesforce atlassian_oauth2 openid_connect).freeze
+ PROVIDERS_WITH_ICONS = %w(
+ atlassian_oauth2
+ authentiq
+ azure_activedirectory_v2
+ azure_oauth2
+ bitbucket
+ facebook
+ github
+ gitlab
+ google_oauth2
+ openid_connect
+ salesforce
+ twitter
+ ).freeze
LDAP_PROVIDER = /\Aldap/.freeze
+ TRIAL_REGISTRATION_PROVIDERS = %w(google_oauth2 github).freeze
def ldap_enabled?
Gitlab::Auth::Ldap::Config.enabled?
@@ -113,8 +127,8 @@ module AuthHelper
end
end
- def experiment_enabled_button_based_providers
- enabled_button_based_providers & %w(google_oauth2 github).freeze
+ def trial_enabled_button_based_providers
+ enabled_button_based_providers & TRIAL_REGISTRATION_PROVIDERS
end
def button_based_providers_enabled?
@@ -125,11 +139,11 @@ module AuthHelper
label = label_for_provider(provider)
if provider_has_custom_icon?(provider)
- image_tag(icon_for_provider(provider), alt: label, title: "Sign in with #{label}")
+ image_tag(icon_for_provider(provider), alt: label, title: "Sign in with #{label}", class: "gl-button-icon")
elsif provider_has_builtin_icon?(provider)
file_name = "#{provider.to_s.split('_').first}_#{size}.png"
- image_tag("auth_buttons/#{file_name}", alt: label, title: "Sign in with #{label}")
+ image_tag("auth_buttons/#{file_name}", alt: label, title: "Sign in with #{label}", class: "gl-button-icon")
else
label
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 5457f96d506..8d22bda279f 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -22,11 +22,14 @@ module AvatarsHelper
end
def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true)
- user = User.find_by_any_email(email)
- if user
- avatar_icon_for_user(user, size, scale, only_path: only_path)
+ return gravatar_icon(email, size, scale) if email.nil?
+
+ if Feature.enabled?(:avatar_cache_for_email, @current_user, type: :development)
+ Gitlab::AvatarCache.by_email(email, size, scale, only_path) do
+ avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path: only_path)
+ end
else
- gravatar_icon(email, size, scale)
+ avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path: only_path)
end
end
@@ -101,19 +104,23 @@ module AvatarsHelper
private
- def user_avatar_url_for(only_path: true, **options)
- return options[:url] if options[:url]
-
- email = options[:user_email]
- user = options.key?(:user) ? options[:user] : User.find_by_any_email(email)
+ def avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path:)
+ user = User.find_by_any_email(email)
if user
- avatar_icon_for_user(user, options[:size], only_path: only_path)
+ avatar_icon_for_user(user, size, scale, only_path: only_path)
else
- gravatar_icon(email, options[:size])
+ gravatar_icon(email, size, scale)
end
end
+ def user_avatar_url_for(only_path: true, **options)
+ return options[:url] if options[:url]
+ return avatar_icon_for_user(options[:user], options[:size], only_path: only_path) if options[:user]
+
+ avatar_icon_for_email(options[:user_email], options[:size], only_path: only_path)
+ end
+
def source_icon(source, options = {})
avatar_url = source.try(:avatar_url)
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 28a947a6ca1..41bbd0fddd5 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -235,7 +235,9 @@ module BlobHelper
def copy_blob_source_button(blob)
return unless blob.rendered_as_text?(ignore_errors: false)
- clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}'] > pre", class: "btn gl-button btn-default btn-icon js-copy-blob-source-btn", title: _("Copy file contents"))
+ content_tag(:span, class: 'btn-group has-tooltip js-copy-blob-source-btn-tooltip') do
+ clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}'] > pre", class: "btn gl-button btn-default btn-icon js-copy-blob-source-btn", hide_tooltip: true)
+ end
end
def open_raw_blob_button(blob)
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index 73e7476b55d..b8c2255ab7e 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -18,7 +18,7 @@ module BoardsHelper
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
recent_boards_endpoint: recent_boards_path,
parent: current_board_parent.model_name.param_key,
- group_id: @group&.id,
+ group_id: group_id,
labels_filter_base_path: build_issue_link_base,
labels_fetch_path: labels_fetch_path,
labels_manage_path: labels_manage_path,
@@ -26,6 +26,12 @@ module BoardsHelper
}
end
+ def group_id
+ return @group.id if board.group_board?
+
+ @project&.group&.id
+ end
+
def full_path
if board.group_board?
@group.full_path
diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb
index 3f48b2687b9..a71b0f4a83a 100644
--- a/app/helpers/ci/pipeline_editor_helper.rb
+++ b/app/helpers/ci/pipeline_editor_helper.rb
@@ -5,8 +5,7 @@ module Ci
include ChecksCollaboration
def can_view_pipeline_editor?(project)
- can_collaborate_with_project?(project) &&
- Gitlab::Ci::Features.ci_pipeline_editor_page_enabled?(project)
+ can_collaborate_with_project?(project)
end
end
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index f5c75d62097..93776503dd6 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -105,7 +105,7 @@ module CommitsHelper
tooltip = _("Browse Directory")
end
- link_to url, class: "btn gl-button btn-default has-tooltip", title: tooltip, data: { container: "body" } do
+ link_to url, class: "btn gl-button btn-default btn-icon has-tooltip", title: tooltip, data: { container: "body" } do
sprite_icon('folder-open')
end
end
@@ -127,13 +127,23 @@ module CommitsHelper
end
def conditionally_paginate_diff_files(diffs, paginate:, per: Projects::CommitController::COMMIT_DIFFS_PER_PAGE)
- if paginate && Feature.enabled?(:paginate_commit_view, @project, type: :development)
+ if paginate
Kaminari.paginate_array(diffs.diff_files.to_a).page(params[:page]).per(per)
else
diffs.diff_files
end
end
+ def cherry_pick_projects_data(project)
+ target_projects(project).map do |project|
+ {
+ id: project.id.to_s,
+ name: project.full_path,
+ refsUrl: refs_project_path(project)
+ }
+ end
+ end
+
protected
# Private: Returns a link to a person. If the person has a matching user and
diff --git a/app/helpers/compare_helper.rb b/app/helpers/compare_helper.rb
index 9ece8b0bc5b..b07baf59114 100644
--- a/app/helpers/compare_helper.rb
+++ b/app/helpers/compare_helper.rb
@@ -1,22 +1,47 @@
# frozen_string_literal: true
module CompareHelper
- def create_mr_button?(from = params[:from], to = params[:to], project = @project)
+ def create_mr_button?(from: params[:from], to: params[:to], source_project: @project, target_project: @target_project)
from.present? &&
to.present? &&
from != to &&
- can?(current_user, :create_merge_request_from, project) &&
- project.repository.branch_exists?(from) &&
- project.repository.branch_exists?(to)
+ can?(current_user, :create_merge_request_from, source_project) &&
+ can?(current_user, :create_merge_request_in, target_project) &&
+ target_project.repository.branch_exists?(from) &&
+ source_project.repository.branch_exists?(to)
end
- def create_mr_path(from = params[:from], to = params[:to], project = @project)
+ def create_mr_path(from: params[:from], to: params[:to], source_project: @project, target_project: @target_project)
project_new_merge_request_path(
- project,
+ target_project,
merge_request: {
+ source_project_id: source_project.id,
source_branch: to,
+ target_project_id: target_project.id,
target_branch: from
}
)
end
+
+ def target_projects(source_project)
+ MergeRequestTargetProjectFinder
+ .new(current_user: current_user, source_project: source_project, project_feature: :repository)
+ .execute(include_routes: true)
+ end
+
+ def project_compare_selector_data(project, merge_request, params)
+ {
+ project_compare_index_path: project_compare_index_path(project),
+ refs_project_path: refs_project_path(project),
+ params_from: params[:from],
+ params_to: params[:to],
+ project_merge_request_path: merge_request.present? ? project_merge_request_path(project, merge_request) : '',
+ create_mr_path: create_mr_button? ? create_mr_path : ''
+ }.tap do |data|
+ if Feature.enabled?(:compare_repo_dropdown, project, default_enabled: :yaml)
+ data[:project_to] = { id: project.id, name: project.full_path }.to_json
+ data[:projects_from] = target_projects(project).map { |project| { id: project.id, name: project.full_path } }.to_json
+ end
+ end
+ end
end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 233a8260036..10c7b4032cf 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -4,8 +4,8 @@ module DiffHelper
def mark_inline_diffs(old_line, new_line)
old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_line, new_line).inline_diffs
- marked_old_line = Gitlab::Diff::InlineDiffMarker.new(old_line).mark(old_diffs, mode: :deletion)
- marked_new_line = Gitlab::Diff::InlineDiffMarker.new(new_line).mark(new_diffs, mode: :addition)
+ marked_old_line = Gitlab::Diff::InlineDiffMarker.new(old_line).mark(old_diffs)
+ marked_new_line = Gitlab::Diff::InlineDiffMarker.new(new_line).mark(new_diffs)
[marked_old_line, marked_new_line]
end
@@ -232,7 +232,7 @@ module DiffHelper
# Always use HTML to handle case where JSON diff rendered this button
params_copy.delete(:format)
- link_to url_for(params_copy), id: "#{name}-diff-btn", class: (selected ? 'btn gl-button active' : 'btn gl-button'), data: { view_type: name } do
+ link_to url_for(params_copy), id: "#{name}-diff-btn", class: (selected ? 'btn gl-button btn-default selected' : 'btn gl-button btn-default'), data: { view_type: name } do
title
end
end
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 7ae00a70671..6dcdc018a20 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -347,6 +347,11 @@ module GitlabRoutingHelper
Gitlab::UrlBuilder.wiki_page_url(wiki, page, only_path: true, **options)
end
+ # GraphQL ETag routes
+ def graphql_etag_pipeline_path(pipeline)
+ [api_graphql_path, "pipelines/id/#{pipeline.id}"].join(':')
+ end
+
private
def snippet_query_params(snippet, *args)
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index eeeffb7b3ae..62f0c68b0c8 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -63,7 +63,7 @@ module GroupsHelper
end
def group_open_issues_count(group)
- if Feature.enabled?(:cached_sidebar_open_issues_count, group)
+ if Feature.enabled?(:cached_sidebar_open_issues_count, group, default_enabled: :yaml)
cached_open_group_issues_count(group)
else
number_with_delimiter(group_issues_count(state: 'opened'))
@@ -99,6 +99,12 @@ module GroupsHelper
.count
end
+ def group_dependency_proxy_url(group)
+ # The namespace path can include uppercase letters, which
+ # Docker doesn't allow. The proxy expects it to be downcased.
+ "#{group_url(group).downcase}#{DependencyProxy::URL_SUFFIX}"
+ end
+
def group_icon_url(group, options = {})
if group.is_a?(String)
group = Group.find_by_full_path(group)
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 93f5ca7258d..5f0d513ba35 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -3,18 +3,31 @@
module IdeHelper
def ide_data
{
- "empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'),
- "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
- "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
- "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.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
+ 'empty-state-svg-path' => image_path('illustrations/multi_file_editor_empty.svg'),
+ 'no-changes-state-svg-path' => image_path('illustrations/multi-editor_no_changes_empty.svg'),
+ 'committed-state-svg-path' => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
+ '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.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,
+ 'branch-name' => @branch,
+ 'file-path' => @path,
+ 'merge-request' => @merge_request,
+ 'forked-project' => convert_to_project_entity_json(@forked_project),
+ 'project' => convert_to_project_entity_json(@project)
}
end
+
+ private
+
+ def convert_to_project_entity_json(project)
+ return unless project
+
+ API::Entities::Project.represent(project).to_json
+ end
end
::IdeHelper.prepend_if_ee('::EE::IdeHelper')
diff --git a/app/helpers/in_product_marketing_helper.rb b/app/helpers/in_product_marketing_helper.rb
index a0e533d3fb8..061404e989d 100644
--- a/app/helpers/in_product_marketing_helper.rb
+++ b/app/helpers/in_product_marketing_helper.rb
@@ -47,7 +47,7 @@ module InProductMarketingHelper
s_('InProductMarketing|Are your runners ready?')
],
trial: [
- s_('InProductMarketing|Start a free trial of GitLab Gold – no CC required'),
+ s_('InProductMarketing|Start a free trial of GitLab Ultimate – no CC required'),
s_('InProductMarketing|Improve app security with a 30-day trial'),
s_('InProductMarketing|Start with a GitLab Gold free trial')
],
@@ -343,7 +343,7 @@ module InProductMarketingHelper
end
end
- def inline_image_link(folder, image, **options)
+ def inline_image_link(folder, image, options)
attachments.inline[image] = File.read(Rails.root.join("app/assets/images", folder, image))
image_tag attachments[image].url, **options
end
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
index 889365e39de..62d83ebe79e 100644
--- a/app/helpers/invite_members_helper.rb
+++ b/app/helpers/invite_members_helper.rb
@@ -13,7 +13,7 @@ module InviteMembersHelper
def directly_invite_members?
strong_memoize(:directly_invite_members) do
- experiment_enabled?(:invite_members_version_a) && can_import_members?
+ can_import_members?
end
end
@@ -23,6 +23,14 @@ module InviteMembersHelper
end
end
+ def show_invite_members_track_event
+ if directly_invite_members?
+ 'show_invite_members'
+ elsif indirectly_invite_members?
+ 'show_invite_members_version_b'
+ end
+ end
+
def invite_group_members?(group)
experiment_enabled?(:invite_members_empty_group_version_a) && Ability.allowed?(current_user, :admin_group_member, group)
end
diff --git a/app/helpers/issuables_description_templates_helper.rb b/app/helpers/issuables_description_templates_helper.rb
index 110b3954900..5f69098de56 100644
--- a/app/helpers/issuables_description_templates_helper.rb
+++ b/app/helpers/issuables_description_templates_helper.rb
@@ -5,7 +5,8 @@ module IssuablesDescriptionTemplatesHelper
include GitlabRoutingHelper
def template_dropdown_tag(issuable, &block)
- title = selected_template(issuable) || "Choose a template"
+ selected_template = selected_template(issuable)
+ title = selected_template || "Choose a template"
options = {
toggle_class: 'js-issuable-selector',
title: title,
@@ -15,7 +16,7 @@ module IssuablesDescriptionTemplatesHelper
data: {
data: issuable_templates(ref_project, issuable.to_ability_name),
field_name: 'issuable_template',
- selected: selected_template(issuable),
+ selected: selected_template,
project_id: ref_project.id
}
}
@@ -28,15 +29,21 @@ module IssuablesDescriptionTemplatesHelper
def issuable_templates(project, issuable_type)
@template_types ||= {}
@template_types[project.id] ||= {}
- @template_types[project.id][issuable_type] ||= TemplateFinder.all_template_names_array(project, issuable_type.pluralize)
+ @template_types[project.id][issuable_type] ||= TemplateFinder.all_template_names_hash_or_array(project, issuable_type)
end
def issuable_templates_names(issuable)
- issuable_templates(ref_project, issuable.to_ability_name).map { |template| template[:name] }
+ all_templates = issuable_templates(ref_project, issuable.to_ability_name)
+
+ if ref_project.inherited_issuable_templates_enabled?
+ all_templates.values.flatten.map { |tpl| tpl[:name] if tpl[:project_id] == ref_project.id }.compact.uniq
+ else
+ all_templates.map { |template| template[:name] }
+ end
end
def selected_template(issuable)
- params[:issuable_template] if issuable_templates(ref_project, issuable.to_ability_name).any? { |template| template[:name] == params[:issuable_template] }
+ params[:issuable_template] if issuable_templates_names(issuable).any? { |tmpl_name| tmpl_name == params[:issuable_template] }
end
def template_names_path(parent, issuable)
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 41e9f61cf9f..639a54fa9ec 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -151,7 +151,7 @@ module IssuablesHelper
def issuable_meta(issuable, project)
output = []
- output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
+ output << "Created #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
if issuable.is_a?(Issue) && issuable.service_desk_reply_to
output << "#{html_escape(issuable.service_desk_reply_to)} via "
@@ -388,7 +388,8 @@ module IssuablesHelper
iid: issuable[:iid],
severity: issuable[:severity],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours,
- createNoteEmail: issuable[:create_note_email]
+ createNoteEmail: issuable[:create_note_email],
+ issuableType: issuable[:type]
}
end
diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb
index 080883fd594..76a7f785df6 100644
--- a/app/helpers/jira_connect_helper.rb
+++ b/app/helpers/jira_connect_helper.rb
@@ -1,13 +1,7 @@
# frozen_string_literal: true
module JiraConnectHelper
- def new_jira_connect_ui?
- Feature.enabled?(:new_jira_connect_ui, type: :development, default_enabled: :yaml)
- end
-
def jira_connect_app_data(subscriptions)
- return {} unless new_jira_connect_ui?
-
skip_groups = subscriptions.map(&:namespace_id)
{
diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb
index e72a9c83fc9..f50c1e52bed 100644
--- a/app/helpers/learn_gitlab_helper.rb
+++ b/app/helpers/learn_gitlab_helper.rb
@@ -15,7 +15,8 @@ module LearnGitlabHelper
[
action,
url: url,
- completed: attributes[OnboardingProgress.column_name(action)].present?
+ completed: attributes[OnboardingProgress.column_name(action)].present?,
+ svg: image_path("learn_gitlab/#{action}.svg")
]
end.to_h
end
@@ -23,13 +24,13 @@ module LearnGitlabHelper
private
ACTION_ISSUE_IDS = {
- git_write: 2,
- pipeline_created: 4,
- merge_request_created: 6,
- user_added: 7,
- trial_started: 13,
- required_mr_approvals_enabled: 15,
- code_owners_enabled: 16
+ git_write: 6,
+ pipeline_created: 7,
+ merge_request_created: 9,
+ user_added: 8,
+ trial_started: 2,
+ required_mr_approvals_enabled: 11,
+ code_owners_enabled: 10
}.freeze
ACTION_DOC_URLS = {
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index ff1305f8cc5..7a798c83b7e 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -174,6 +174,18 @@ module MergeRequestsHelper
end
end
+ def reviewers_label(merge_request, include_value: true)
+ reviewers = merge_request.reviewers
+ reviewer_label = 'Reviewer'.pluralize(reviewers.count)
+
+ if include_value
+ sanitized_list = sanitize_name(reviewers.map(&:name).to_sentence)
+ "#{reviewer_label}: #{sanitized_list}"
+ else
+ reviewer_label
+ end
+ end
+
private
def review_requested_merge_requests_count
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 729585be84a..9db28b54fe9 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -72,52 +72,6 @@ module NotificationsHelper
end
end
- def notification_list_item(level, setting)
- title = notification_title(level)
-
- data = {
- notification_level: level,
- notification_title: title
- }
-
- content_tag(:li, role: "menuitem") do
- link_to '#', class: "update-notification #{('is-active' if setting.level == level)}", data: data do
- link_output = content_tag(:strong, title, class: 'dropdown-menu-inner-title')
- link_output << content_tag(:span, notification_description(level), class: 'dropdown-menu-inner-content')
- end
- end
- end
-
- # Identifier to trigger individually dropdowns and custom settings modals in the same view
- def notifications_menu_identifier(type, notification_setting)
- "#{type}-#{notification_setting.user_id}-#{notification_setting.source_id}-#{notification_setting.source_type}"
- end
-
- # Create hidden field to send notification setting source to controller
- def hidden_setting_source_input(notification_setting)
- return unless notification_setting.source_type
-
- hidden_field_tag "#{notification_setting.source_type.downcase}_id", notification_setting.source_id
- end
-
- def notification_event_name(event)
- # All values from NotificationSetting.email_events
- case event
- when :success_pipeline
- s_('NotificationEvent|Successful pipeline')
- else
- event_name = "NotificationEvent|#{event.to_s.humanize}"
- s_(event_name)
- end
- end
-
- def notification_setting_icon(notification_setting = nil)
- sprite_icon(
- !notification_setting.present? || notification_setting.disabled? ? "notifications-off" : "notifications",
- css_class: "icon notifications-icon js-notifications-icon"
- )
- end
-
def show_unsubscribe_title?(noteable)
can?(current_user, "read_#{noteable.to_ability_name}".to_sym, noteable)
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index c2a77abb9c9..12bc509466e 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -29,6 +29,7 @@ module PreferencesHelper
stars: _("Starred Projects"),
project_activity: _("Your Projects' Activity"),
starred_project_activity: _("Starred Projects' Activity"),
+ followed_user_activity: _("Followed Users' Activity"),
groups: _("Your Groups"),
todos: _("Your To-Do List"),
issues: _("Assigned Issues"),
diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb
index 99c1b742da4..662afbcfd25 100644
--- a/app/helpers/projects/project_members_helper.rb
+++ b/app/helpers/projects/project_members_helper.rb
@@ -40,7 +40,7 @@ module Projects::ProjectMembersHelper
members: project_members_data_json(project, members),
member_path: project_project_member_path(project, ':id'),
source_id: project.id,
- can_manage_members: can_manage_project_members?(project)
+ can_manage_members: can_manage_project_members?(project).to_s
}
end
@@ -49,7 +49,7 @@ module Projects::ProjectMembersHelper
members: project_group_links_data_json(group_links),
member_path: project_group_link_path(project, ':id'),
source_id: project.id,
- can_manage_members: can_manage_project_members?(project)
+ can_manage_members: can_manage_project_members?(project).to_s
}
end
end
diff --git a/app/helpers/projects/security/configuration_helper.rb b/app/helpers/projects/security/configuration_helper.rb
new file mode 100644
index 00000000000..265d46cbc41
--- /dev/null
+++ b/app/helpers/projects/security/configuration_helper.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Projects
+ module Security
+ module ConfigurationHelper
+ def security_upgrade_path
+ 'https://about.gitlab.com/pricing/'
+ end
+ end
+ end
+end
+
+::Projects::Security::ConfigurationHelper.prepend_if_ee('::EE::Projects::Security::ConfigurationHelper')
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index f5cd89d96b4..6c17039a5d9 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -379,8 +379,8 @@ module ProjectsHelper
private
def can_read_security_configuration?(project, current_user)
- ::Feature.enabled?(:secure_security_and_compliance_configuration_page_on_ce, @subject, default_enabled: :yaml) &&
- can?(current_user, :read_security_configuration, project)
+ can?(current_user, :access_security_and_compliance, project) &&
+ can?(current_user, :read_security_configuration, project)
end
def get_project_security_nav_tabs(project, current_user)
@@ -646,7 +646,8 @@ module ProjectsHelper
metricsDashboardAccessLevel: feature.metrics_dashboard_access_level,
operationsAccessLevel: feature.operations_access_level,
showDefaultAwardEmojis: project.show_default_award_emojis?,
- allowEditingCommitMessages: project.allow_editing_commit_messages?
+ allowEditingCommitMessages: project.allow_editing_commit_messages?,
+ securityAndComplianceAccessLevel: project.security_and_compliance_access_level
}
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index a7acc0cd7db..86012352c8b 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -360,33 +360,31 @@ module SearchHelper
end
end
+ def search_md_sanitize(source)
+ search_sanitize(markdown(search_truncate(source)))
+ end
+
+ def simple_search_highlight_and_truncate(text, phrase, options = {})
+ highlight(search_sanitize(search_truncate(text)), phrase.split, options)
+ end
+
# Sanitize a HTML field for search display. Most tags are stripped out and the
# maximum length is set to 200 characters.
- def search_md_sanitize(source)
- source = Truncato.truncate(
+ def search_truncate(source)
+ Truncato.truncate(
source,
count_tags: false,
count_tail: false,
+ filtered_tags: %w(img),
max_length: 200
)
+ end
- html = markdown(source)
-
+ def search_sanitize(html)
# Truncato's filtered_tags and filtered_attributes are not quite the same
sanitize(html, tags: %w(a p ol ul li pre code))
end
- def simple_search_highlight_and_truncate(text, phrase, options = {})
- text = Truncato.truncate(
- text,
- count_tags: false,
- count_tail: false,
- max_length: options.delete(:length) { 200 }
- )
-
- highlight(text, phrase.split, options)
- end
-
# _search_highlight is used in EE override
def highlight_and_truncate_issuable(issuable, search_term, _search_highlight)
return unless issuable.description.present?
@@ -406,7 +404,7 @@ module SearchHelper
# Closed is considered "danger" for MR so we need to handle separately
if issuable.is_a?(::MergeRequest)
if issuable.merged?
- :primary
+ :info
elsif issuable.closed?
:danger
else
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 1be7e240c1a..36f4fced147 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -68,4 +68,18 @@ module SnippetsHelper
title: 'Download',
rel: 'noopener noreferrer')
end
+
+ def snippet_file_count(snippet)
+ file_count = snippet.statistics&.file_count
+
+ return unless file_count&.nonzero?
+
+ tooltip = n_('%d file', '%d files', file_count) % file_count
+
+ tag.span(class: 'file_count', title: tooltip, data: { toggle: 'tooltip', container: 'body' }) do
+ concat(sprite_icon('documents', css_class: 'gl-vertical-align-middle'))
+ concat(' ')
+ concat(file_count)
+ end
+ end
end
diff --git a/app/helpers/stat_anchors_helper.rb b/app/helpers/stat_anchors_helper.rb
index 1e8e6371284..d9429f28be7 100644
--- a/app/helpers/stat_anchors_helper.rb
+++ b/app/helpers/stat_anchors_helper.rb
@@ -5,13 +5,14 @@ module StatAnchorsHelper
{}.tap do |attrs|
attrs[:class] = %w(nav-link gl-display-flex gl-align-items-center) << extra_classes(anchor)
attrs[:itemprop] = anchor.itemprop if anchor.itemprop
+ attrs[:data] = anchor.data if anchor.data
end
end
private
def button_attribute(anchor)
- "btn-#{anchor.class_modifier || 'dashed'}"
+ anchor.class_modifier || 'btn-dashed'
end
def extra_classes(anchor)
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index f55a6c3c9e5..6a242d000ae 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -31,7 +31,7 @@ module UserCalloutsHelper
render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name
end
- def render_dashboard_gold_trial(user)
+ def render_dashboard_ultimate_trial(user)
end
def render_account_recovery_regular_check
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 696f29164fd..b49bd33a30b 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -35,9 +35,7 @@ module VisibilityLevelHelper
end
def visibility_level_label(level)
- # The visibility level can be:
- # 'VisibilityLevel|Private', 'VisibilityLevel|Internal', 'VisibilityLevel|Public'
- s_(Project.visibility_levels.key(level))
+ Project.visibility_levels.key(level)
end
def restricted_visibility_levels(show_all = false)
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 786081ca815..3f82cf893a0 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -50,24 +50,6 @@ module WikiHelper
end
end
- def wiki_page_errors(error)
- return unless error
-
- content_tag(:div, class: 'alert alert-danger') do
- case error
- when WikiPage::PageChangedError
- page_link = link_to s_("WikiPageConflictMessage|the page"), wiki_page_path(@wiki, @page), target: "_blank"
- concat(
- (s_("WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{page_link} and make sure your changes will not unintentionally remove theirs.") % { page_link: page_link }).html_safe
- )
- when WikiPage::PageRenameError
- s_("WikiEdit|There is already a page with the same title in that path.")
- else
- error.message
- end
- end
- end
-
def wiki_attachment_upload_url
case @wiki.container
when Project
diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb
index f74b53d68a1..28dd1b00292 100644
--- a/app/helpers/workhorse_helper.rb
+++ b/app/helpers/workhorse_helper.rb
@@ -7,7 +7,7 @@ module WorkhorseHelper
def send_git_blob(repository, blob, inline: true)
headers.store(*Gitlab::Workhorse.send_git_blob(repository, blob))
- headers['Content-Disposition'] = inline ? 'inline' : 'attachment'
+ headers['Content-Disposition'] = inline ? 'inline' : content_disposition_attachment(repository.project, blob.name)
# If enabled, this will override the values set above
workhorse_set_content_type!
@@ -48,4 +48,12 @@ module WorkhorseHelper
def workhorse_set_content_type!
headers[Gitlab::Workhorse::DETECT_HEADER] = "true"
end
+
+ def content_disposition_attachment(project, filename)
+ if Feature.enabled?(:attachment_with_filename, project, default_enabled: :yaml)
+ ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: filename)
+ else
+ 'attachment'
+ end
+ end
end
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index 494d9875ce4..e538b5e4718 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -23,6 +23,14 @@ module Emails
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
+ def change_in_merge_request_draft_status_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
+ setup_merge_request_mail(merge_request_id, recipient_id)
+
+ @updated_by_user = User.find(updated_by_user_id)
+
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_ids, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb
index 0b830f4ee5e..fb1f70723fd 100644
--- a/app/mailers/emails/pipelines.rb
+++ b/app/mailers/emails/pipelines.rb
@@ -3,7 +3,7 @@
module Emails
module Pipelines
def pipeline_success_email(pipeline, recipients)
- pipeline_mail(pipeline, recipients, 'Succesful')
+ pipeline_mail(pipeline, recipients, 'Successful')
end
def pipeline_failed_email(pipeline, recipients)
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index e3c72a343e7..f13ba9caee0 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -50,15 +50,16 @@ module Emails
end
# rubocop: enable CodeReuse/ActiveRecord
- def access_token_about_to_expire_email(user)
+ def access_token_about_to_expire_email(user, token_names)
return unless user
@user = user
+ @token_names = token_names
@target_url = profile_personal_access_tokens_url
@days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email, subject: subject(_("Your Personal Access Tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
+ mail(to: @user.notification_email, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
end
end
diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb
index 4dceff5b7ba..66eb2c646a9 100644
--- a/app/mailers/emails/service_desk.rb
+++ b/app/mailers/emails/service_desk.rb
@@ -17,18 +17,18 @@ module Emails
send_from_user_email: false,
sender_name: @project.service_desk_setting&.outgoing_name
)
- options = service_desk_options(email_sender, 'thank_you')
+ options = service_desk_options(email_sender, 'thank_you', @issue.external_author)
.merge(subject: "Re: #{subject_base}")
mail_new_thread(@issue, options)
end
- def service_desk_new_note_email(issue_id, note_id)
+ def service_desk_new_note_email(issue_id, note_id, recipient)
@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')
+ options = service_desk_options(email_sender, 'new_note', recipient)
.merge(subject: subject_base)
mail_answer_thread(@issue, options)
@@ -44,10 +44,10 @@ module Emails
@sent_notification = SentNotification.record(@issue, @support_bot.id, reply_key)
end
- def service_desk_options(email_sender, email_type)
+ def service_desk_options(email_sender, email_type, recipient)
{
from: email_sender,
- to: @issue.external_author
+ to: recipient
}.tap do |options|
next unless template_body = template_content(email_type)
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index c70ac1428cd..5fda60a7408 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -60,6 +60,10 @@ class NotifyPreview < ActionMailer::Preview
end
end
+ def new_mention_in_merge_request_email
+ Notify.new_mention_in_merge_request_email(user.id, issue.id, user.id).message
+ end
+
def closed_issue_email
Notify.closed_issue_email(user.id, issue.id, user.id).message
end
@@ -84,12 +88,20 @@ class NotifyPreview < ActionMailer::Preview
Notify.issues_csv_email(user, project, '1997,Ford,E350', { truncated: false, rows_expected: 3, rows_written: 3 }).message
end
+ def new_merge_request_email
+ Notify.new_merge_request_email(user.id, merge_request.id).message
+ end
+
def closed_merge_request_email
Notify.closed_merge_request_email(user.id, issue.id, user.id).message
end
def merge_request_status_email
- Notify.merge_request_status_email(user.id, merge_request.id, 'closed', user.id).message
+ Notify.merge_request_status_email(user.id, merge_request.id, 'reopened', user.id).message
+ end
+
+ def merge_request_unmergeable_email
+ Notify.merge_request_unmergeable_email(user.id, merge_request.id, 'conflict').message
end
def merged_merge_request_email
@@ -169,7 +181,7 @@ class NotifyPreview < ActionMailer::Preview
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
+ Notify.service_desk_new_note_email(issue.id, note.id, 'someone@gitlab.com').message
end
end
diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb
index 2d9a2d7031c..e98c770c364 100644
--- a/app/models/alert_management/http_integration.rb
+++ b/app/models/alert_management/http_integration.rb
@@ -27,6 +27,7 @@ module AlertManagement
before_validation :prevent_token_assignment
before_validation :prevent_endpoint_identifier_assignment
before_validation :ensure_token
+ before_validation :ensure_payload_example_not_nil
scope :for_endpoint_identifier, -> (endpoint_identifier) { where(endpoint_identifier: endpoint_identifier) }
scope :active, -> { where(active: true) }
@@ -74,5 +75,9 @@ module AlertManagement
self.endpoint_identifier = endpoint_identifier_was
end
end
+
+ def ensure_payload_example_not_nil
+ self.payload_example ||= {}
+ end
end
end
diff --git a/app/models/analytics/instance_statistics.rb b/app/models/analytics/instance_statistics.rb
deleted file mode 100644
index df7b26e4fa6..00000000000
--- a/app/models/analytics/instance_statistics.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-module Analytics
- module InstanceStatistics
- def self.table_name_prefix
- 'analytics_instance_statistics_'
- end
- end
-end
diff --git a/app/models/analytics/instance_statistics/measurement.rb b/app/models/analytics/usage_trends/measurement.rb
index c8b76e005ef..ad0272699c2 100644
--- a/app/models/analytics/instance_statistics/measurement.rb
+++ b/app/models/analytics/usage_trends/measurement.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
module Analytics
- module InstanceStatistics
+ module UsageTrends
class Measurement < ApplicationRecord
- EXPERIMENTAL_IDENTIFIERS = %i[pipelines_succeeded pipelines_failed pipelines_canceled pipelines_skipped].freeze
+ self.table_name = 'analytics_instance_statistics_measurements'
enum identifier: {
projects: 1,
@@ -58,4 +58,4 @@ module Analytics
end
end
-Analytics::InstanceStatistics::Measurement.prepend_if_ee('EE::Analytics::InstanceStatistics::Measurement')
+Analytics::UsageTrends::Measurement.prepend_if_ee('EE::Analytics::UsageTrends::Measurement')
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 4959401eb27..44eb2fefb3f 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -25,10 +25,6 @@ class ApplicationSetting < ApplicationRecord
alias_attribute :instance_group_id, :instance_administrators_group_id
alias_attribute :instance_administrators_group, :instance_group
- def self.repository_storages_weighted_attributes
- @repository_storages_weighted_atributes ||= Gitlab.config.repositories.storages.keys.map { |k| "repository_storages_weighted_#{k}".to_sym }.freeze
- end
-
def self.kroki_formats_attributes
{
blockdiag: {
@@ -44,7 +40,6 @@ class ApplicationSetting < ApplicationRecord
end
store_accessor :kroki_formats, *ApplicationSetting.kroki_formats_attributes.keys, prefix: true
- store_accessor :repository_storages_weighted, *Gitlab.config.repositories.storages.keys, prefix: true
# Include here so it can override methods from
# `add_authentication_token_field`
@@ -503,6 +498,7 @@ class ApplicationSetting < ApplicationRecord
inclusion: { in: [true, false], message: _('must be a boolean value') }
before_validation :ensure_uuid!
+ before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed?
before_save :ensure_runners_registration_token
before_save :ensure_health_check_access_token
@@ -583,12 +579,6 @@ class ApplicationSetting < ApplicationRecord
recaptcha_enabled || login_recaptcha_protection_enabled
end
- repository_storages_weighted_attributes.each do |attribute|
- define_method :"#{attribute}=" do |value|
- super(value.to_i)
- end
- end
-
kroki_formats_attributes.keys.each do |key|
define_method :"kroki_formats_#{key}=" do |value|
super(::Gitlab::Utils.to_boolean(value))
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 08c16930b13..c067199b52c 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -123,7 +123,7 @@ module ApplicationSettingImplementation
raw_blob_request_limit: 300,
recaptcha_enabled: false,
repository_checks_enabled: true,
- repository_storages_weighted: { default: 100 },
+ repository_storages_weighted: { 'default' => 100 },
repository_storages: ['default'],
require_admin_approval_after_user_signup: true,
require_two_factor_authentication: false,
@@ -298,10 +298,6 @@ module ApplicationSettingImplementation
Array(read_attribute(:repository_storages))
end
- def repository_storages_weighted
- read_attribute(:repository_storages_weighted)
- end
-
def commit_email_hostname
super.presence || self.class.default_commit_email_hostname
end
@@ -333,9 +329,10 @@ module ApplicationSettingImplementation
def normalized_repository_storage_weights
strong_memoize(:normalized_repository_storage_weights) do
- weights_total = repository_storages_weighted.values.reduce(:+)
+ repository_storages_weights = repository_storages_weighted.slice(*Gitlab.config.repositories.storages.keys)
+ weights_total = repository_storages_weights.values.reduce(:+)
- repository_storages_weighted.transform_values do |w|
+ repository_storages_weights.transform_values do |w|
next w if weights_total == 0
w.to_f / weights_total
@@ -473,16 +470,20 @@ module ApplicationSettingImplementation
invalid.empty?
end
+ def coerce_repository_storages_weighted
+ repository_storages_weighted.transform_values!(&:to_i)
+ end
+
def check_repository_storages_weighted
invalid = repository_storages_weighted.keys - Gitlab.config.repositories.storages.keys
- errors.add(:repository_storages_weighted, "can't include: %{invalid_storages}" % { invalid_storages: invalid.join(", ") }) unless
+ errors.add(:repository_storages_weighted, _("can't include: %{invalid_storages}") % { invalid_storages: invalid.join(", ") }) unless
invalid.empty?
repository_storages_weighted.each do |key, val|
next unless val.present?
- errors.add(:"repository_storages_weighted_#{key}", "value must be an integer") unless val.is_a?(Integer)
- errors.add(:"repository_storages_weighted_#{key}", "value must be between 0 and 100") unless val.between?(0, 100)
+ errors.add(:repository_storages_weighted, _("value for '%{storage}' must be an integer") % { storage: key }) unless val.is_a?(Integer)
+ errors.add(:repository_storages_weighted, _("value for '%{storage}' must be between 0 and 100") % { storage: key }) unless val.between?(0, 100)
end
end
diff --git a/app/models/board.rb b/app/models/board.rb
index 85fad762ebe..418ea67fc6a 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -7,6 +7,7 @@ class Board < ApplicationRecord
has_many :lists, -> { ordered }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :destroyable_lists, -> { destroyable.ordered }, class_name: "List"
+ validates :name, presence: true
validates :project, presence: true, if: :project_needed?
validates :group, presence: true, unless: :project
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index 16224fde502..9127dab56a6 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -37,8 +37,9 @@ class BulkImports::Entity < ApplicationRecord
validates :project, absence: true, if: :group
validates :group, absence: true, if: :project
- validates :source_type, :source_full_path, :destination_name,
- :destination_namespace, presence: true
+ validates :source_type, :source_full_path, :destination_name, presence: true
+ validates :destination_namespace, exclusion: [nil], if: :group
+ validates :destination_namespace, presence: true, if: :project
validate :validate_parent_is_a_group, if: :parent
validate :validate_imported_entity_type
@@ -117,8 +118,8 @@ class BulkImports::Entity < ApplicationRecord
if source.self_and_descendants.any? { |namespace| namespace.full_path == destination_namespace }
errors.add(
- :destination_namespace,
- s_('BulkImport|destination group cannot be part of the source group tree')
+ :base,
+ s_('BulkImport|Import failed: Destination cannot be a subgroup of the source group. Change the destination and try again.')
)
end
end
diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb
index 02e0904e1af..182c0bbaa8a 100644
--- a/app/models/bulk_imports/tracker.rb
+++ b/app/models/bulk_imports/tracker.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-# This model is responsible for keeping track of the requests/pagination
-# happening during a Group Migration (BulkImport).
class BulkImports::Tracker < ApplicationRecord
self.table_name = 'bulk_import_trackers'
@@ -15,4 +13,32 @@ class BulkImports::Tracker < ApplicationRecord
uniqueness: { scope: :bulk_import_entity_id }
validates :next_page, presence: { if: :has_next_page? }
+
+ validates :stage, presence: true
+
+ state_machine :status, initial: :created do
+ state :created, value: 0
+ state :started, value: 1
+ state :finished, value: 2
+ state :failed, value: -1
+ state :skipped, value: -2
+
+ event :start do
+ transition created: :started
+ end
+
+ event :finish do
+ transition started: :finished
+ transition failed: :failed
+ transition skipped: :skipped
+ end
+
+ event :skip do
+ transition any => :skipped
+ end
+
+ event :fail_op do
+ transition any => :failed
+ end
+ end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index db151126caf..824e35a6480 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -486,6 +486,10 @@ module Ci
self.options.fetch(:environment, {}).fetch(:action, 'start') if self.options
end
+ def environment_deployment_tier
+ self.options.dig(:environment, :deployment_tier) if self.options
+ end
+
def outdated_deployment?
success? && !deployment.try(:last?)
end
@@ -510,7 +514,6 @@ module Ci
.concat(scoped_variables)
.concat(job_variables)
.concat(persisted_environment_variables)
- .to_runner_variables
end
end
@@ -523,6 +526,7 @@ module Ci
.append(key: 'CI_JOB_ID', value: id.to_s)
.append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self))
.append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false, masked: true)
+ .append(key: 'CI_JOB_STARTED_AT', value: started_at&.iso8601)
.append(key: 'CI_BUILD_ID', value: id.to_s)
.append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false, masked: true)
.append(key: 'CI_REGISTRY_USER', value: ::Gitlab::Auth::CI_JOB_USER)
@@ -564,7 +568,10 @@ module Ci
end
def features
- { trace_sections: true }
+ {
+ trace_sections: true,
+ failure_reasons: self.class.failure_reasons.keys
+ }
end
def merge_request
@@ -691,7 +698,7 @@ module Ci
end
def any_runners_online?
- project.any_runners? { |runner| runner.active? && runner.online? && runner.can_pick?(self) }
+ project.any_active_runners? { |runner| runner.match_build_if_online?(self) }
end
def stuck?
@@ -810,14 +817,15 @@ module Ci
end
def cache
- cache = options[:cache]
+ cache = Array.wrap(options[:cache])
- if cache && project.jobs_cache_index
- cache = cache.merge(
- key: "#{cache[:key]}-#{project.jobs_cache_index}")
+ if project.jobs_cache_index
+ cache = cache.map do |single_cache|
+ single_cache.merge(key: "#{single_cache[:key]}-#{project.jobs_cache_index}")
+ end
end
- [cache]
+ cache
end
def credentials
@@ -983,7 +991,7 @@ module Ci
# TODO: Have `debug_mode?` check against data on sent back from runner
# to capture all the ways that variables can be set.
# See (https://gitlab.com/gitlab-org/gitlab/-/issues/290955)
- variables.any? { |variable| variable[:key] == 'CI_DEBUG_TRACE' && variable[:value].casecmp('true') == 0 }
+ variables['CI_DEBUG_TRACE']&.value&.casecmp('true') == 0
end
def drop_with_exit_code!(failure_reason, exit_code)
diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb
index fac615f97b9..7bc70f9f1e1 100644
--- a/app/models/ci/build_need.rb
+++ b/app/models/ci/build_need.rb
@@ -10,6 +10,7 @@ module Ci
validates :build, presence: true
validates :name, presence: true, length: { maximum: 128 }
+ validates :optional, inclusion: { in: [true, false] }
scope :scoped_build, -> { where('ci_builds.id=ci_build_needs.build_id') }
scope :artifacts, -> { where(artifacts: true) }
diff --git a/app/models/ci/build_report_result.rb b/app/models/ci/build_report_result.rb
index 530233ad5c0..eb6a0700006 100644
--- a/app/models/ci/build_report_result.rb
+++ b/app/models/ci/build_report_result.rb
@@ -38,6 +38,10 @@ module Ci
tests.dig("skipped").to_i
end
+ def suite_error
+ tests.dig("suite_error")
+ end
+
def tests_total
[tests_success, tests_failed, tests_errored, tests_skipped].sum
end
diff --git a/app/models/ci/daily_build_group_report_result.rb b/app/models/ci/daily_build_group_report_result.rb
index 23c96e63724..5dcf575abd7 100644
--- a/app/models/ci/daily_build_group_report_result.rb
+++ b/app/models/ci/daily_build_group_report_result.rb
@@ -4,7 +4,6 @@ module Ci
class DailyBuildGroupReportResult < ApplicationRecord
extend Gitlab::Ci::Model
- REPORT_WINDOW = 90.days
PARAM_TYPES = %w[coverage].freeze
belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id
@@ -13,13 +12,11 @@ module Ci
validates :data, json_schema: { filename: "daily_build_group_report_result_data" }
- scope :with_included_projects, -> { includes(:project) }
scope :by_ref_path, -> (ref_path) { where(ref_path: ref_path) }
scope :by_projects, -> (ids) { where(project_id: ids) }
scope :by_group, -> (group_id) { where(group_id: group_id) }
scope :with_coverage, -> { where("(data->'coverage') IS NOT NULL") }
scope :with_default_branch, -> { where(default_branch: true) }
- scope :by_date, -> (start_date) { where(date: report_window(start_date)..Date.current) }
scope :by_dates, -> (start_date, end_date) { where(date: start_date..end_date) }
scope :ordered_by_date_and_group_name, -> { order(date: :desc, group_name: :asc) }
@@ -29,17 +26,6 @@ module Ci
def upsert_reports(data)
upsert_all(data, unique_by: :index_daily_build_group_report_results_unique_columns) if data.any?
end
-
- def recent_results(attrs, limit: nil)
- where(attrs).order(date: :desc, group_name: :asc).limit(limit)
- end
-
- def report_window(start_date)
- default_date = REPORT_WINDOW.ago.to_date
- date = Date.parse(start_date) rescue default_date
-
- [date, default_date].max
- end
end
end
end
diff --git a/app/models/ci/deleted_object.rb b/app/models/ci/deleted_object.rb
index e74946eda16..2942a153e05 100644
--- a/app/models/ci/deleted_object.rb
+++ b/app/models/ci/deleted_object.rb
@@ -17,9 +17,9 @@ module Ci
.lock('FOR UPDATE SKIP LOCKED')
end
- def self.bulk_import(artifacts)
+ def self.bulk_import(artifacts, pick_up_at = nil)
attributes = artifacts.each.with_object([]) do |artifact, accumulator|
- record = artifact.to_deleted_object_attrs
+ record = artifact.to_deleted_object_attrs(pick_up_at)
accumulator << record if record[:store_dir] && record[:file]
end
diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb
index c7c0ec61e62..4ba09fd8152 100644
--- a/app/models/ci/group.rb
+++ b/app/models/ci/group.rb
@@ -39,7 +39,7 @@ module Ci
def status_struct
strong_memoize(:status_struct) do
Gitlab::Ci::Status::Composite
- .new(@jobs)
+ .new(@jobs, project: project)
end
end
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index 1e1dd68ee6c..2928ce801ad 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -6,16 +6,18 @@ module Ci
include Ci::HasVariable
include Presentable
include Ci::Maskable
+ prepend HasEnvironmentScope
belongs_to :group, class_name: "::Group"
alias_attribute :secret_value, :value
validates :key, uniqueness: {
- scope: :group_id,
+ scope: [:group_id, :environment_scope],
message: "(%{value}) has already been taken"
}
scope :unprotected, -> { where(protected: false) }
+ scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) }
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index f927111758a..d5e88f2be5b 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -136,11 +136,7 @@ module Ci
scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) }
scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) }
- scope :with_job, -> do
- if Feature.enabled?(:non_public_artifacts, type: :development)
- joins(:job).includes(:job)
- end
- end
+ scope :with_job, -> { joins(:job).includes(:job) }
scope :with_file_types, -> (file_types) do
types = self.file_types.select { |file_type| file_types.include?(file_type) }.values
@@ -311,12 +307,12 @@ module Ci
max_size&.megabytes.to_i
end
- def to_deleted_object_attrs
+ def to_deleted_object_attrs(pick_up_at = nil)
{
file_store: file_store,
store_dir: file.store_dir.to_s,
file: file_identifier,
- pick_up_at: expire_at || Time.current
+ pick_up_at: pick_up_at || expire_at || Time.current
}
end
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
index df4368eccd5..ffd3d3fcd88 100644
--- a/app/models/ci/legacy_stage.rb
+++ b/app/models/ci/legacy_stage.rb
@@ -32,7 +32,7 @@ module Ci
end
def status
- @status ||= statuses.latest.composite_status
+ @status ||= statuses.latest.composite_status(project: project)
end
def detailed_status(current_user)
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 3be107ea2e1..b63ec0c8a97 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -228,7 +228,7 @@ module Ci
pipeline.run_after_commit do
PipelineHooksWorker.perform_async(pipeline.id)
- ExpirePipelineCacheWorker.perform_async(pipeline.id) if pipeline.cacheable?
+ ExpirePipelineCacheWorker.perform_async(pipeline.id)
end
end
@@ -573,7 +573,7 @@ module Ci
end
def cancel_running(retries: nil)
- retry_optimistic_lock(cancelable_statuses, retries) do |cancelable|
+ retry_optimistic_lock(cancelable_statuses, retries, name: 'ci_pipeline_cancel_running') do |cancelable|
cancelable.find_each do |job|
yield(job) if block_given?
job.cancel
@@ -693,14 +693,6 @@ module Ci
.exists?
end
- # TODO: this logic is duplicate with Pipeline::Chain::Config::Content
- # we should persist this is `ci_pipelines.config_path`
- def config_path
- return unless repository_source? || unknown_source?
-
- project.ci_config_path_or_default
- end
-
def has_yaml_errors?
yaml_errors.present?
end
@@ -744,7 +736,7 @@ module Ci
end
def set_status(new_status)
- retry_optimistic_lock(self) do
+ retry_optimistic_lock(self, name: 'ci_pipeline_set_status') do
case new_status
when 'created' then nil
when 'waiting_for_resource' then request_resource
@@ -785,8 +777,7 @@ module Ci
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'CI_PIPELINE_IID', value: iid.to_s)
variables.append(key: 'CI_PIPELINE_SOURCE', value: source.to_s)
-
- variables.append(key: 'CI_CONFIG_PATH', value: config_path)
+ variables.append(key: 'CI_PIPELINE_CREATED_AT', value: created_at&.iso8601)
variables.concat(predefined_commit_variables)
@@ -938,6 +929,12 @@ module Ci
.first
end
+ def self_with_ancestors_and_descendants(same_project: false)
+ ::Gitlab::Ci::PipelineObjectHierarchy
+ .new(self.class.unscoped.where(id: id), options: { same_project: same_project })
+ .all_objects
+ end
+
def bridge_triggered?
source_bridge.present?
end
@@ -1117,7 +1114,7 @@ module Ci
detached_merge_request_pipeline? && !merge_request_ref?
end
- def merge_request_pipeline?
+ def merged_result_pipeline?
merge_request? && target_sha.present?
end
@@ -1157,7 +1154,7 @@ module Ci
return unless merge_request?
strong_memoize(:merge_request_event_type) do
- if merge_request_pipeline?
+ if merged_result_pipeline?
:merged_result
elsif detached_merge_request_pipeline?
:detached
@@ -1169,10 +1166,6 @@ module Ci
@persistent_ref ||= PersistentRef.new(pipeline: self)
end
- def cacheable?
- !dangling?
- end
-
def dangling?
Enums::Ci::Pipeline.dangling_sources.key?(source.to_sym)
end
@@ -1247,7 +1240,7 @@ module Ci
def merge_request_diff_sha
return unless merge_request?
- if merge_request_pipeline?
+ if merged_result_pipeline?
source_sha
else
sha
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index fae65ed0632..0ad1ed2fce8 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -120,10 +120,6 @@ module Ci
raise NotImplementedError
end
- def scoped_variables_hash
- raise NotImplementedError
- end
-
override :all_met_to_become_pending?
def all_met_to_become_pending?
super && !with_resource_group?
diff --git a/app/models/ci/ref.rb b/app/models/ci/ref.rb
index 713a0bf9c45..3d71a5f2c96 100644
--- a/app/models/ci/ref.rb
+++ b/app/models/ci/ref.rb
@@ -62,7 +62,7 @@ module Ci
end
def update_status_by!(pipeline)
- retry_lock(self) do
+ retry_lock(self, name: 'ci_ref_update_status_by') do
next unless last_finished_pipeline_id == pipeline.id
case pipeline.status
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 44a00e36bcc..d1a20bc93c3 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -9,6 +9,7 @@ module Ci
include FromUnion
include TokenAuthenticatable
include IgnorableColumns
+ include FeatureGate
add_authentication_token_field :token, encrypted: -> { Feature.enabled?(:ci_runners_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
@@ -251,10 +252,21 @@ module Ci
runner_projects.any?
end
+ # TODO: remove this method in favor of `matches_build?` once feature flag is removed
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/323317
def can_pick?(build)
- return false if self.ref_protected? && !build.protected?
+ if Feature.enabled?(:ci_runners_short_circuit_assignable_for, self, default_enabled: :yaml)
+ matches_build?(build)
+ else
+ # Run `matches_build?` checks before, since they are cheaper than
+ # `assignable_for?`.
+ #
+ matches_build?(build) && assignable_for?(build.project_id)
+ end
+ end
- assignable_for?(build.project_id) && accepting_tags?(build)
+ def match_build_if_online?(build)
+ active? && online? && can_pick?(build)
end
def only_for?(project)
@@ -265,6 +277,16 @@ module Ci
token[0...8] if token
end
+ def tag_list
+ return super unless Feature.enabled?(:ci_preload_runner_tags, default_enabled: :yaml)
+
+ if tags.loaded?
+ tags.map(&:name)
+ else
+ super
+ end
+ end
+
def has_tags?
tag_list.any?
end
@@ -304,8 +326,10 @@ module Ci
end
def pick_build!(build)
- if can_pick?(build)
- tick_runner_queue
+ if Feature.enabled?(:ci_reduce_queries_when_ticking_runner_queue, self, default_enabled: :yaml)
+ tick_runner_queue if matches_build?(build)
+ else
+ tick_runner_queue if can_pick?(build)
end
end
@@ -341,6 +365,8 @@ module Ci
end
end
+ # TODO: remove this method once feature flag ci_runners_short_circuit_assignable_for
+ # is removed. https://gitlab.com/gitlab-org/gitlab/-/issues/323317
def assignable_for?(project_id)
self.class.owned_or_instance_wide(project_id).where(id: self.id).any?
end
@@ -369,6 +395,12 @@ module Ci
end
end
+ def matches_build?(build)
+ return false if self.ref_protected? && !build.protected?
+
+ accepting_tags?(build)
+ end
+
def accepting_tags?(build)
(run_untagged? || build.has_tags?) && (build.tag_list - tag_list).empty?
end
diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb
index 6903e8a21a1..e6c1899c89d 100644
--- a/app/models/ci/runner_namespace.rb
+++ b/app/models/ci/runner_namespace.rb
@@ -4,10 +4,17 @@ module Ci
class RunnerNamespace < ApplicationRecord
extend Gitlab::Ci::Model
- belongs_to :runner, inverse_of: :runner_namespaces, validate: true
+ belongs_to :runner, inverse_of: :runner_namespaces
belongs_to :namespace, inverse_of: :runner_namespaces, class_name: '::Namespace'
belongs_to :group, class_name: '::Group', foreign_key: :namespace_id
validates :runner_id, uniqueness: { scope: :namespace_id }
+ validate :group_runner_type
+
+ private
+
+ def group_runner_type
+ errors.add(:runner, 'is not a group runner') unless runner&.group_type?
+ end
end
end
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index ae80692d598..03a97355574 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -84,7 +84,7 @@ module Ci
end
def set_status(new_status)
- retry_optimistic_lock(self) do
+ retry_optimistic_lock(self, name: 'ci_stage_set_status') do
case new_status
when 'created' then nil
when 'waiting_for_resource' then request_resource
@@ -138,7 +138,7 @@ module Ci
end
def latest_stage_status
- statuses.latest.composite_status || 'skipped'
+ statuses.latest.composite_status(project: project) || 'skipped'
end
end
end
diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb
index 13358b95a47..84505befc5c 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -18,7 +18,6 @@ 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/agent_token.rb b/app/models/clusters/agent_token.rb
index b260822f784..9d79887b574 100644
--- a/app/models/clusters/agent_token.rb
+++ b/app/models/clusters/agent_token.rb
@@ -7,9 +7,12 @@ module Clusters
self.table_name = 'cluster_agent_tokens'
- belongs_to :agent, class_name: 'Clusters::Agent'
+ belongs_to :agent, class_name: 'Clusters::Agent', optional: false
belongs_to :created_by_user, class_name: 'User', optional: true
before_save :ensure_token
+
+ validates :description, length: { maximum: 1024 }
+ validates :name, presence: true, length: { maximum: 255 }, on: :create
end
end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index f87eccecf9f..8a49d476ba7 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.25.0'
+ VERSION = '0.26.0'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/instance.rb b/app/models/clusters/instance.rb
index 94fadace01c..2a09ba11564 100644
--- a/app/models/clusters/instance.rb
+++ b/app/models/clusters/instance.rb
@@ -6,10 +6,6 @@ module Clusters
Clusters::Cluster.instance_type
end
- def feature_available?(feature)
- ::Feature.enabled?(feature, type: :licensed, default_enabled: true)
- end
-
def flipper_id
self.class.to_s
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index ea2f425c5f6..524429bf12a 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -52,7 +52,6 @@ class CommitStatus < ApplicationRecord
scope :before_stage, -> (index) { where('stage_idx < ?', index) }
scope :for_stage, -> (index) { where(stage_idx: index) }
scope :after_stage, -> (index) { where('stage_idx > ?', index) }
- scope :for_ids, -> (ids) { where(id: ids) }
scope :for_ref, -> (ref) { where(ref: ref) }
scope :by_name, -> (name) { where(name: name) }
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
@@ -85,6 +84,8 @@ class CommitStatus < ApplicationRecord
# extend this `Hash` with new values.
enum_with_nil failure_reason: Enums::Ci::CommitStatus.failure_reasons
+ default_value_for :retried, false
+
##
# We still create some CommitStatuses outside of CreatePipelineService.
#
@@ -291,6 +292,14 @@ class CommitStatus < ApplicationRecord
failed? && !unrecoverable_failure?
end
+ def update_older_statuses_retried!
+ self.class
+ .latest
+ .where(name: name)
+ .where.not(id: id)
+ .update_all(retried: true, processed: true)
+ end
+
private
def unrecoverable_failure?
diff --git a/app/models/commit_with_pipeline.rb b/app/models/commit_with_pipeline.rb
deleted file mode 100644
index 7f952fb77a0..00000000000
--- a/app/models/commit_with_pipeline.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-class Ci::CommitWithPipeline < SimpleDelegator
- include Presentable
-
- def initialize(commit)
- @latest_pipelines = {}
- super(commit)
- end
-
- def pipelines
- project.ci_pipelines.where(sha: sha)
- end
-
- def last_pipeline
- strong_memoize(:last_pipeline) do
- pipelines.last
- end
- end
-
- def latest_pipeline(ref = nil)
- @latest_pipelines.fetch(ref) do |ref|
- @latest_pipelines[ref] = latest_pipeline_for_project(ref, project)
- end
- end
-
- def latest_pipeline_for_project(ref, pipeline_project)
- pipeline_project.ci_pipelines.latest_pipeline_per_commit(id, ref)[id]
- end
-
- def set_latest_pipeline_for_ref(ref, pipeline)
- @latest_pipelines[ref] = pipeline
- end
-
- def status(ref = nil)
- latest_pipeline(ref)&.status
- end
-end
diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb
index 080ff07ec0c..f1c39dda49d 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stage.rb
@@ -49,14 +49,6 @@ module Analytics
end
end
- def start_event_identifier
- backward_compatible_identifier(:start_event_identifier) || super
- end
-
- def end_event_identifier
- backward_compatible_identifier(:end_event_identifier) || super
- end
-
def start_event_label_based?
start_event_identifier && start_event.label_based?
end
@@ -136,17 +128,6 @@ module Analytics
.id_in(label_id)
.exists?
end
-
- # Temporary, will be removed in 13.10
- def backward_compatible_identifier(attribute_name)
- removed_identifier = 6 # References IssueFirstMentionedInCommit removed on https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51975
- replacement_identifier = :issue_first_mentioned_in_commit
-
- # ActiveRecord returns nil if the column value is not part of the Enum definition
- if self[attribute_name].nil? && read_attribute_before_type_cast(attribute_name) == removed_identifier
- replacement_identifier
- end
- end
end
end
end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index d342b526677..c106c08c04a 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -20,6 +20,7 @@ module Avatarable
mount_uploader :avatar, AvatarUploader
after_initialize :add_avatar_to_batch
+ after_commit :clear_avatar_caches
end
module ShadowMethods
@@ -127,4 +128,11 @@ module Avatarable
def avatar_mounter
strong_memoize(:avatar_mounter) { _mounter(:avatar) }
end
+
+ def clear_avatar_caches
+ return unless respond_to?(:verified_emails) && verified_emails.any? && avatar_changed?
+ return unless Feature.enabled?(:avatar_cache_for_email, self, type: :development)
+
+ Gitlab::AvatarCache.delete_by_email(*verified_emails)
+ end
end
diff --git a/app/models/concerns/boards/listable.rb b/app/models/concerns/boards/listable.rb
index b7c0a8b3489..d6863e87261 100644
--- a/app/models/concerns/boards/listable.rb
+++ b/app/models/concerns/boards/listable.rb
@@ -13,6 +13,14 @@ module Boards
scope :ordered, -> { order(:list_type, :position) }
scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) }
scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) }
+
+ class << self
+ def preload_preferences_for_user(lists, user)
+ return unless user
+
+ lists.each { |list| list.preferences_for(user) }
+ end
+ end
end
class_methods do
@@ -33,6 +41,18 @@ module Boards
self.class.movable_types.include?(list_type&.to_sym)
end
+ def collapsed?(user)
+ preferences = preferences_for(user)
+
+ preferences.collapsed?
+ end
+
+ def update_preferences_for(user, preferences = {})
+ return unless user
+
+ preferences_for(user).update(preferences)
+ end
+
def title
if label?
label.name
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index c8b55e7b39f..bdba2d3e251 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -20,7 +20,7 @@ module Ci
variables.concat(user_variables)
variables.concat(dependency_variables) if dependencies
variables.concat(secret_instance_variables)
- variables.concat(secret_group_variables)
+ variables.concat(secret_group_variables(environment: environment))
variables.concat(secret_project_variables(environment: environment))
variables.concat(trigger_request.user_variables) if trigger_request
variables.concat(pipeline.variables)
@@ -29,14 +29,6 @@ module Ci
end
##
- # Regular Ruby hash of scoped variables, without duplicates that are
- # possible to be present in an array of hashes returned from `variables`.
- #
- def scoped_variables_hash
- scoped_variables.to_hash
- end
-
- ##
# Variables that do not depend on the environment name.
#
def simple_variables
@@ -93,13 +85,13 @@ module Ci
project.ci_instance_variables_for(ref: git_ref)
end
- def secret_group_variables
+ def secret_group_variables(environment: expanded_environment_name)
return [] unless project.group
- project.group.ci_variables_for(git_ref, project)
+ project.group.ci_variables_for(git_ref, project, environment: environment)
end
- def secret_project_variables(environment: persisted_environment)
+ def secret_project_variables(environment: expanded_environment_name)
project.ci_variables_for(ref: git_ref, environment: environment)
end
diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb
index 1cc2e8a51e3..0412f7a072b 100644
--- a/app/models/concerns/ci/has_status.rb
+++ b/app/models/concerns/ci/has_status.rb
@@ -20,9 +20,11 @@ module Ci
UnknownStatusError = Class.new(StandardError)
class_methods do
- def composite_status
+ # The parameter `project` is only used for the feature flag check, and will be removed with
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/321972
+ def composite_status(project: nil)
Gitlab::Ci::Status::Composite
- .new(all, with_allow_failure: columns_hash.key?('allow_failure'))
+ .new(all, with_allow_failure: columns_hash.key?('allow_failure'), project: project)
.status
end
diff --git a/app/models/concerns/ci/has_variable.rb b/app/models/concerns/ci/has_variable.rb
index 9bf2b409080..7309469c77e 100644
--- a/app/models/concerns/ci/has_variable.rb
+++ b/app/models/concerns/ci/has_variable.rb
@@ -16,6 +16,7 @@ module Ci
format: { with: /\A[a-zA-Z0-9_]+\z/,
message: "can contain only letters, digits and '_'." }
+ scope :by_key, -> (key) { where(key: key) }
scope :order_key_asc, -> { reorder(key: :asc) }
attr_encrypted :value,
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 83ff5b16efe..e1be0665452 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -86,6 +86,7 @@ module Issuable
before_validation :truncate_description_on_import!
scope :authored, ->(user) { where(author_id: user) }
+ scope :not_authored, ->(user) { where.not(author_id: user) }
scope :recent, -> { reorder(id: :desc) }
scope :of_projects, ->(ids) { where(project_id: ids) }
scope :opened, -> { with_state(:opened) }
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index 07bec07e556..7c774d8bad7 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -34,6 +34,10 @@ module ProjectFeaturesCompatibility
write_feature_attribute_boolean(:snippets_access_level, value)
end
+ def security_and_compliance_enabled=(value)
+ write_feature_attribute_boolean(:security_and_compliance_access_level, value)
+ end
+
def repository_access_level=(value)
write_feature_attribute_string(:repository_access_level, value)
end
@@ -78,6 +82,14 @@ module ProjectFeaturesCompatibility
write_feature_attribute_string(:operations_access_level, value)
end
+ def security_and_compliance_access_level=(value)
+ write_feature_attribute_string(:security_and_compliance_access_level, value)
+ end
+
+ def container_registry_access_level=(value)
+ write_feature_attribute_string(:container_registry_access_level, value)
+ end
+
private
def write_feature_attribute_boolean(field, value)
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index f4c914c6a3a..aea48a5ec20 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -6,6 +6,7 @@ class CustomEmoji < ApplicationRecord
belongs_to :namespace, inverse_of: :custom_emoji
belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
+ belongs_to :creator, class_name: "User", inverse_of: :created_custom_emoji
# For now only external emoji are supported. See https://gitlab.com/gitlab-org/gitlab/-/issues/230467
validates :external, inclusion: { in: [true] }
@@ -15,6 +16,7 @@ class CustomEmoji < ApplicationRecord
validate :valid_emoji_name
validates :group, presence: true
+ validates :creator, presence: true
validates :name,
uniqueness: { scope: [:namespace_id, :name] },
presence: true,
diff --git a/app/models/dependency_proxy.rb b/app/models/dependency_proxy.rb
index 9cbaf7e9884..0ed17921aaa 100644
--- a/app/models/dependency_proxy.rb
+++ b/app/models/dependency_proxy.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
module DependencyProxy
URL_SUFFIX = '/dependency_proxy/containers'
+ DISTRIBUTION_API_VERSION = 'registry/2.0'
def self.table_name_prefix
'dependency_proxy_'
diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb
index f3c7f34e0d7..d613d5708f0 100644
--- a/app/models/dependency_proxy/manifest.rb
+++ b/app/models/dependency_proxy/manifest.rb
@@ -12,5 +12,10 @@ class DependencyProxy::Manifest < ApplicationRecord
mount_file_store_uploader DependencyProxy::FileUploader
- scope :find_or_initialize_by_file_name, ->(file_name) { find_or_initialize_by(file_name: file_name) }
+ def self.find_or_initialize_by_file_name_or_digest(file_name:, digest:)
+ result = find_by(file_name: file_name) || find_by(digest: digest)
+ return result if result
+
+ new(file_name: file_name, digest: digest)
+ end
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 4f7f688a040..d89909a71a2 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -39,6 +39,7 @@ class Environment < ApplicationRecord
before_validation :generate_slug, if: ->(env) { env.slug.blank? }
before_save :set_environment_type
+ before_save :ensure_environment_tier
after_save :clear_reactive_cache!
validates :name,
@@ -87,6 +88,7 @@ class Environment < ApplicationRecord
end
scope :for_project, -> (project) { where(project_id: project) }
+ scope :for_tier, -> (tier) { where(tier: tier).where('tier IS NOT NULL') }
scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) }
scope :unfoldered, -> { where(environment_type: nil) }
scope :with_rank, -> do
@@ -94,6 +96,30 @@ class Environment < ApplicationRecord
end
scope :for_id, -> (id) { where(id: id) }
+ scope :stopped_review_apps, -> (before, limit) do
+ stopped
+ .in_review_folder
+ .where("created_at < ?", before)
+ .order("created_at ASC")
+ .limit(limit)
+ end
+
+ scope :scheduled_for_deletion, -> do
+ where.not(auto_delete_at: nil)
+ end
+
+ scope :not_scheduled_for_deletion, -> do
+ where(auto_delete_at: nil)
+ end
+
+ enum tier: {
+ production: 0,
+ staging: 1,
+ testing: 2,
+ development: 3,
+ other: 4
+ }
+
state_machine :state, initial: :available do
event :start do
transition stopped: :available
@@ -137,6 +163,10 @@ class Environment < ApplicationRecord
self.state_machine.states.map(&:name)
end
+ def self.schedule_to_delete(at_time = 1.week.from_now)
+ update_all(auto_delete_at: at_time)
+ end
+
class << self
##
# This method returns stop actions (jobs) for multiple environments within one
@@ -242,7 +272,7 @@ class Environment < ApplicationRecord
def cancel_deployment_jobs!
jobs = active_deployments.with_deployable
jobs.each do |deployment|
- Gitlab::OptimisticLocking.retry_lock(deployment.deployable) do |deployable|
+ Gitlab::OptimisticLocking.retry_lock(deployment.deployable, name: 'environment_cancel_deployment_jobs') do |deployable|
deployable.cancel! if deployable&.cancelable?
end
rescue => e
@@ -429,6 +459,22 @@ class Environment < ApplicationRecord
def generate_slug
self.slug = Gitlab::Slug::Environment.new(name).generate
end
+
+ def ensure_environment_tier
+ self.tier ||= guess_tier
+ end
+
+ # Guessing the tier of the environment if it's not explicitly specified by users.
+ # See https://en.wikipedia.org/wiki/Deployment_environment for industry standard deployment environments
+ def guess_tier
+ case name
+ when %r{dev|review|trunk}i then self.class.tiers[:development]
+ when %r{test|qc}i then self.class.tiers[:testing]
+ when %r{st(a|)g|mod(e|)l|pre|demo}i then self.class.tiers[:staging]
+ when %r{pr(o|)d|live}i then self.class.tiers[:production]
+ else self.class.tiers[:other]
+ end
+ end
end
Environment.prepend_if_ee('EE::Environment')
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index fa32c8a5450..9a9fbc6a801 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -77,7 +77,7 @@ module ErrorTracking
def sentry_client
strong_memoize(:sentry_client) do
- Sentry::Client.new(api_url, token)
+ ErrorTracking::SentryClient.new(api_url, token)
end
end
@@ -168,13 +168,13 @@ module ErrorTracking
def handle_exceptions
yield
- rescue Sentry::Client::Error => e
+ rescue ErrorTracking::SentryClient::Error => e
{ error: e.message, error_type: SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE }
- rescue Sentry::Client::MissingKeysError => e
+ rescue ErrorTracking::SentryClient::MissingKeysError => e
{ error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS }
- rescue Sentry::Client::ResponseInvalidSizeError => e
+ rescue ErrorTracking::SentryClient::ResponseInvalidSizeError => e
{ error: e.message, error_type: SENTRY_API_ERROR_INVALID_SIZE }
- rescue Sentry::Client::BadRequestError => e
+ rescue ErrorTracking::SentryClient::BadRequestError => e
{ error: e.message, error_type: SENTRY_API_ERROR_TYPE_BAD_REQUEST }
rescue StandardError => e
Gitlab::ErrorTracking.track_exception(e)
diff --git a/app/models/experiment.rb b/app/models/experiment.rb
index 354b1e0b6b9..ac8b6516d02 100644
--- a/app/models/experiment.rb
+++ b/app/models/experiment.rb
@@ -14,22 +14,30 @@ class Experiment < ApplicationRecord
find_or_create_by!(name: name).record_group_and_variant!(group, variant)
end
- def self.record_conversion_event(name, user)
- find_or_create_by!(name: name).record_conversion_event_for_user(user)
+ def self.record_conversion_event(name, user, context = {})
+ find_or_create_by!(name: name).record_conversion_event_for_user(user, context)
end
# Create or update the recorded experiment_user row for the user in this experiment.
def record_user_and_group(user, group_type, context = {})
experiment_user = experiment_users.find_or_initialize_by(user: user)
- merged_context = experiment_user.context.deep_merge(context.deep_stringify_keys)
- experiment_user.update!(group_type: group_type, context: merged_context)
+ experiment_user.update!(group_type: group_type, context: merged_context(experiment_user, context))
end
- def record_conversion_event_for_user(user)
- experiment_users.find_by(user: user, converted_at: nil)&.touch(:converted_at)
+ def record_conversion_event_for_user(user, context = {})
+ experiment_user = experiment_users.find_by(user: user)
+ return unless experiment_user
+
+ experiment_user.update!(converted_at: Time.current, context: merged_context(experiment_user, context))
end
def record_group_and_variant!(group, variant)
experiment_subjects.find_or_initialize_by(group: group).update!(variant: variant)
end
+
+ private
+
+ def merged_context(experiment_user, new_context)
+ experiment_user.context.deep_merge(new_context.deep_stringify_keys)
+ end
end
diff --git a/app/models/group.rb b/app/models/group.rb
index 1eaa4499eb5..9f8a9996f31 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -33,7 +33,6 @@ class Group < Namespace
has_many :members_and_requesters, as: :source, class_name: 'GroupMember'
has_many :milestones
- has_many :iterations
has_many :services
has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink'
has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink'
@@ -341,6 +340,13 @@ class Group < Namespace
has_owner?(user) && members_with_parents.owners.size == 1
end
+ def last_blocked_owner?(user)
+ return false if members_with_parents.owners.any?
+
+ blocked_owners = members.blocked.where(access_level: Gitlab::Access::OWNER)
+ blocked_owners.size == 1 && blocked_owners.exists?(user_id: user)
+ end
+
def ldap_synced?
false
end
@@ -364,15 +370,30 @@ class Group < Namespace
# rubocop: enable CodeReuse/ServiceClass
# rubocop: disable CodeReuse/ServiceClass
- def refresh_members_authorized_projects(blocking: true, priority: UserProjectAccessChangedService::HIGH_PRIORITY)
+ def refresh_members_authorized_projects(
+ blocking: true,
+ priority: UserProjectAccessChangedService::HIGH_PRIORITY,
+ direct_members_only: false
+ )
+
+ user_ids = if direct_members_only
+ users_ids_of_direct_members
+ else
+ user_ids_for_project_authorizations
+ end
+
UserProjectAccessChangedService
- .new(user_ids_for_project_authorizations)
+ .new(user_ids)
.execute(blocking: blocking, priority: priority)
end
# rubocop: enable CodeReuse/ServiceClass
+ def users_ids_of_direct_members
+ direct_members.pluck(:user_id)
+ end
+
def user_ids_for_project_authorizations
- members_with_parents.pluck(:user_id)
+ members_with_parents.pluck(Arel.sql('DISTINCT members.user_id'))
end
def self_and_ancestors_ids
@@ -381,6 +402,12 @@ class Group < Namespace
end
end
+ def direct_members
+ GroupMember.active_without_invites_and_requests
+ .non_minimal_access
+ .where(source_id: id)
+ end
+
def members_with_parents
# Avoids an unnecessary SELECT when the group has no parents
source_ids =
@@ -485,7 +512,7 @@ class Group < Namespace
# @param only_concrete_membership [Bool] whether require admin concrete membership status
def max_member_access_for_user(user, only_concrete_membership: false)
return GroupMember::NO_ACCESS unless user
- return GroupMember::OWNER if user.admin? && !only_concrete_membership
+ return GroupMember::OWNER if user.can_admin_all_resources? && !only_concrete_membership
max_member_access = members_with_parents.where(user_id: user)
.reorder(access_level: :desc)
@@ -505,15 +532,11 @@ class Group < Namespace
}
end
- def ci_variables_for(ref, project)
- cache_key = "ci_variables_for:group:#{self&.id}:project:#{project&.id}:ref:#{ref}"
+ def ci_variables_for(ref, project, environment: nil)
+ cache_key = "ci_variables_for:group:#{self&.id}:project:#{project&.id}:ref:#{ref}:environment:#{environment}"
::Gitlab::SafeRequestStore.fetch(cache_key) do
- list_of_ids = [self] + ancestors
- variables = Ci::GroupVariable.where(group: list_of_ids)
- variables = variables.unprotected unless project.protected_for?(ref)
- variables = variables.group_by(&:group_id)
- list_of_ids.reverse.flat_map { |group| variables[group.id] }.compact
+ uncached_ci_variables_for(ref, project, environment: environment)
end
end
@@ -755,6 +778,23 @@ class Group < Namespace
def enable_shared_runners!
update!(shared_runners_enabled: true)
end
+
+ def uncached_ci_variables_for(ref, project, environment: nil)
+ list_of_ids = [self] + ancestors
+ variables = Ci::GroupVariable.where(group: list_of_ids)
+ variables = variables.unprotected unless project.protected_for?(ref)
+
+ if Feature.enabled?(:scoped_group_variables, self, default_enabled: :yaml)
+ variables = if environment
+ variables.on_environment(environment)
+ else
+ variables.where(environment_scope: '*')
+ end
+ end
+
+ variables = variables.group_by(&:group_id)
+ list_of_ids.reverse.flat_map { |group| variables[group.id] }.compact
+ end
end
Group.prepend_if_ee('EE::Group')
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
index 03f1797f4f4..e2230a2d644 100644
--- a/app/models/hooks/web_hook_log.rb
+++ b/app/models/hooks/web_hook_log.rb
@@ -6,6 +6,8 @@ class WebHookLog < ApplicationRecord
include DeleteWithLimit
include CreatedAtFilterable
+ self.primary_key = :id
+
belongs_to :web_hook
serialize :request_headers, Hash # rubocop:disable Cop/ActiveRecordSerialize
diff --git a/app/models/hooks/web_hook_log_partitioned.rb b/app/models/hooks/web_hook_log_partitioned.rb
new file mode 100644
index 00000000000..b4b150afb6a
--- /dev/null
+++ b/app/models/hooks/web_hook_log_partitioned.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+# This model is not yet intended to be used.
+# It is in a transitioning phase while we are partitioning
+# the web_hook_logs table on the database-side.
+# Please refer to https://gitlab.com/groups/gitlab-org/-/epics/5558
+# for details.
+# rubocop:disable Gitlab/NamespacedClass: This is a temporary class with no relevant namespace
+# WebHook, WebHookLog and all hooks are defined outside of a namespace
+class WebHookLogPartitioned < ApplicationRecord
+ include PartitionedTable
+
+ self.table_name = 'web_hook_logs_part_0c5294f417'
+ self.primary_key = :id
+
+ partitioned_by :created_at, strategy: :monthly
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 79d0229a281..2f2d24cbe93 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -438,6 +438,14 @@ class Issue < ApplicationRecord
issue_type_supports?(:assignee)
end
+ def email_participants_emails
+ issue_email_participants.pluck(:email)
+ end
+
+ def email_participants_emails_downcase
+ issue_email_participants.pluck(IssueEmailParticipant.arel_table[:email].lower)
+ end
+
private
def ensure_metrics
diff --git a/app/models/issue_email_participant.rb b/app/models/issue_email_participant.rb
index 8eb9b6a8152..76a96151350 100644
--- a/app/models/issue_email_participant.rb
+++ b/app/models/issue_email_participant.rb
@@ -3,7 +3,7 @@
class IssueEmailParticipant < ApplicationRecord
belongs_to :issue
- validates :email, presence: true, uniqueness: { scope: [:issue_id] }
+ validates :email, uniqueness: { scope: [:issue_id], case_sensitive: false }
validates :issue, presence: true
validate :validate_email_format
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
index 7a35bb1cd1f..7483d04aab8 100644
--- a/app/models/iteration.rb
+++ b/app/models/iteration.rb
@@ -1,140 +1,16 @@
# frozen_string_literal: true
+# Placeholder class for model that is implemented in EE
class Iteration < ApplicationRecord
self.table_name = 'sprints'
- attr_accessor :skip_future_date_validation
- attr_accessor :skip_project_validation
-
- STATE_ENUM_MAP = {
- upcoming: 1,
- started: 2,
- closed: 3
- }.with_indifferent_access.freeze
-
- include AtomicInternalId
-
- belongs_to :project
- belongs_to :group
-
- has_internal_id :iid, scope: :project
- has_internal_id :iid, scope: :group
-
- validates :start_date, presence: true
- validates :due_date, presence: true
-
- validate :dates_do_not_overlap, if: :start_or_due_dates_changed?
- validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation
- validate :no_project, unless: :skip_project_validation
-
- scope :upcoming, -> { with_state(:upcoming) }
- scope :started, -> { with_state(:started) }
- scope :closed, -> { with_state(:closed) }
-
- scope :within_timeframe, -> (start_date, end_date) do
- where('start_date IS NOT NULL OR due_date IS NOT NULL')
- .where('start_date IS NULL OR start_date <= ?', end_date)
- .where('due_date IS NULL OR due_date >= ?', start_date)
+ def self.reference_prefix
+ '*iteration:'
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
- end
-
- event :close do
- transition [:upcoming, :started] => :closed
- end
-
- state :upcoming, value: Iteration::STATE_ENUM_MAP[:upcoming]
- state :started, value: Iteration::STATE_ENUM_MAP[:started]
- state :closed, value: Iteration::STATE_ENUM_MAP[:closed]
- end
-
- # Alias to state machine .with_state_enum method
- # This needs to be defined after the state machine block to avoid errors
- class << self
- alias_method :with_state, :with_state_enum
- alias_method :with_states, :with_state_enums
-
- def filter_by_state(iterations, state)
- case state
- when 'closed' then iterations.closed
- when 'started' then iterations.started
- when 'upcoming' then iterations.upcoming
- when 'opened' then iterations.started.or(iterations.upcoming)
- when 'all' then iterations
- else raise ArgumentError, "Unknown state filter: #{state}"
- end
- end
-
- def reference_prefix
- '*iteration:'
- end
-
- def reference_pattern
- nil
- end
- end
-
- def state
- STATE_ENUM_MAP.key(state_enum)
- end
-
- def state=(value)
- self.state_enum = STATE_ENUM_MAP[value]
- end
-
- def resource_parent
- group || project
- end
-
- private
-
- def parent_group
- group || project.group
- end
-
- def start_or_due_dates_changed?
- start_date_changed? || due_date_changed?
- end
-
- # ensure dates do not overlap with other Iterations in the same group/project tree
- def dates_do_not_overlap
- iterations = if parent_group.present? && resource_parent.is_a?(Project)
- Iteration.where(group: parent_group.self_and_ancestors).or(project.iterations)
- elsif parent_group.present?
- Iteration.where(group: parent_group.self_and_ancestors)
- else
- project.iterations
- end
-
- return unless 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
-
- # ensure dates are in the future
- def future_date
- if start_date_changed?
- errors.add(:start_date, s_("Iteration|cannot be in the past")) if start_date < Date.current
- errors.add(:start_date, s_("Iteration|cannot be more than 500 years in the future")) if start_date > 500.years.from_now
- end
-
- if due_date_changed?
- errors.add(:due_date, s_("Iteration|cannot be in the past")) if due_date < Date.current
- errors.add(:due_date, s_("Iteration|cannot be more than 500 years in the future")) if due_date > 500.years.from_now
- end
- end
-
- def no_project
- return unless project_id.present?
-
- errors.add(:project_id, s_("is not allowed. We do not currently support project-level iterations"))
+ def self.reference_pattern
+ nil
end
end
-Iteration.prepend_if_ee('EE::Iteration')
+Iteration.prepend_if_ee('::EE::Iteration')
diff --git a/app/models/label.rb b/app/models/label.rb
index 7a31b095cfc..26faaa90df3 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -131,6 +131,10 @@ class Label < ApplicationRecord
nil
end
+ def self.ids_on_board(board_id)
+ on_board(board_id).pluck(:label_id)
+ end
+
# Searches for labels with a matching title or description.
#
# This method uses ILIKE on PostgreSQL.
diff --git a/app/models/list.rb b/app/models/list.rb
index 49834af3dfb..e1954ed72c4 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -14,17 +14,10 @@ class List < ApplicationRecord
validates :label_id, uniqueness: { scope: :board_id }, if: :label?
scope :preload_associated_models, -> { preload(:board, label: :priorities) }
+ scope :without_types, ->(list_types) { where.not(list_type: list_types) }
alias_method :preferences, :list_user_preferences
- class << self
- def preload_preferences_for_user(lists, user)
- return unless user
-
- lists.each { |list| list.preferences_for(user) }
- end
- end
-
def preferences_for(user)
return preferences.build unless user
@@ -38,18 +31,6 @@ class List < ApplicationRecord
end
end
- def update_preferences_for(user, preferences = {})
- return unless user
-
- preferences_for(user).update(preferences)
- end
-
- def collapsed?(user)
- preferences = preferences_for(user)
-
- preferences.collapsed?
- end
-
def as_json(options = {})
super(options).tap do |json|
json[:collapsed] = false
diff --git a/app/models/member.rb b/app/models/member.rb
index 62fe757683f..38574d67cb6 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -75,7 +75,20 @@ class Member < ApplicationRecord
left_join_users
.where(user_ok)
- .where(requested_at: nil)
+ .non_request
+ .non_minimal_access
+ .reorder(nil)
+ end
+
+ scope :blocked, -> do
+ is_external_invite = arel_table[:user_id].eq(nil).and(arel_table[:invite_token].not_eq(nil))
+ user_is_blocked = User.arel_table[:state].eq(:blocked)
+
+ user_ok = Arel::Nodes::Grouping.new(is_external_invite).or(user_is_blocked)
+
+ left_join_users
+ .where(user_ok)
+ .non_request
.non_minimal_access
.reorder(nil)
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 1374e8a814a..ac50e5c5107 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -36,6 +36,10 @@ class MergeRequest < ApplicationRecord
SORTING_PREFERENCE_FIELD = :merge_requests_sort
+ ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON = {
+ 'Ci::CompareCodequalityReportsService' => ->(project) { ::Gitlab::Ci::Features.display_codequality_backend_comparison?(project) }
+ }.freeze
+
belongs_to :target_project, class_name: "Project"
belongs_to :source_project, class_name: "Project"
belongs_to :merge_user, class_name: "User"
@@ -187,9 +191,13 @@ class MergeRequest < ApplicationRecord
end
state_machine :merge_status, initial: :unchecked do
+ event :mark_as_preparing do
+ transition unchecked: :preparing
+ end
+
event :mark_as_unchecked do
- transition [:can_be_merged, :checking, :unchecked] => :unchecked
- transition [:cannot_be_merged, :cannot_be_merged_rechecking, :cannot_be_merged_recheck] => :cannot_be_merged_recheck
+ transition [:preparing, :can_be_merged, :checking] => :unchecked
+ transition [:cannot_be_merged, :cannot_be_merged_rechecking] => :cannot_be_merged_recheck
end
event :mark_as_checking do
@@ -205,6 +213,7 @@ class MergeRequest < ApplicationRecord
transition [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking] => :cannot_be_merged
end
+ state :preparing
state :unchecked
state :cannot_be_merged_recheck
state :checking
@@ -233,7 +242,7 @@ class MergeRequest < ApplicationRecord
# Returns current merge_status except it returns `cannot_be_merged_rechecking` as `checking`
# to avoid exposing unnecessary internal state
def public_merge_status
- cannot_be_merged_rechecking? ? 'checking' : merge_status
+ cannot_be_merged_rechecking? || preparing? ? 'checking' : merge_status
end
validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?]
@@ -301,10 +310,28 @@ class MergeRequest < ApplicationRecord
end
scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) }
scope :order_merged_at, ->(direction) do
- query = join_metrics.order(Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', direction))
-
- # Add `merge_request_metrics.merged_at` to the `SELECT` in order to make the keyset pagination work.
- query.select(*query.arel.projections, MergeRequest::Metrics.arel_table[:merged_at].as('"merge_request_metrics.merged_at"'))
+ reverse_direction = { 'ASC' => 'DESC', 'DESC' => 'ASC' }
+ reversed_direction = reverse_direction[direction] || raise("Unknown sort direction was given: #{direction}")
+
+ order = Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'merge_request_metrics_merged_at',
+ column_expression: MergeRequest::Metrics.arel_table[:merged_at],
+ order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', direction),
+ reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', reversed_direction),
+ order_direction: direction,
+ nullable: :nulls_last,
+ distinct: false,
+ add_to_projections: true
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'merge_request_metrics_id',
+ order_expression: MergeRequest::Metrics.arel_table[:id].desc,
+ add_to_projections: true
+ )
+ ])
+
+ order.apply_cursor_conditions(join_metrics).order(order)
end
scope :order_merged_at_asc, -> { order_merged_at('ASC') }
scope :order_merged_at_desc, -> { order_merged_at('DESC') }
@@ -317,6 +344,8 @@ class MergeRequest < ApplicationRecord
scope :preload_author, -> { preload(:author) }
scope :preload_approved_by_users, -> { preload(:approved_by_users) }
scope :preload_metrics, -> (relation) { preload(metrics: relation) }
+ scope :preload_project_and_latest_diff, -> { preload(:source_project, :latest_merge_request_diff) }
+ scope :preload_latest_diff_commit, -> { preload(latest_merge_request_diff: :merge_request_diff_commits) }
scope :with_web_entity_associations, -> { preload(:author, :target_project) }
scope :with_auto_merge_enabled, -> do
@@ -374,8 +403,7 @@ class MergeRequest < ApplicationRecord
alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds
alias_method :issuing_parent, :target_project
- delegate :active?, :builds_with_coverage, to: :head_pipeline, prefix: true, allow_nil: true
- delegate :success?, :active?, to: :actual_head_pipeline, prefix: true, allow_nil: true
+ delegate :builds_with_coverage, to: :head_pipeline, prefix: true, allow_nil: true
RebaseLockTimeout = Class.new(StandardError)
@@ -401,8 +429,8 @@ class MergeRequest < ApplicationRecord
def self.sort_by_attribute(method, excluded_labels: [])
case method.to_s
- when 'merged_at', 'merged_at_asc' then order_merged_at_asc.with_order_id_desc
- when 'merged_at_desc' then order_merged_at_desc.with_order_id_desc
+ when 'merged_at', 'merged_at_asc' then order_merged_at_asc
+ when 'merged_at_desc' then order_merged_at_desc
else
super
end
@@ -435,6 +463,18 @@ class MergeRequest < ApplicationRecord
target_project.latest_pipeline(target_branch, sha)
end
+ def head_pipeline_active?
+ !!head_pipeline&.active?
+ end
+
+ def actual_head_pipeline_active?
+ !!actual_head_pipeline&.active?
+ end
+
+ def actual_head_pipeline_success?
+ !!actual_head_pipeline&.success?
+ end
+
# Pattern used to extract `!123` merge request references from text
#
# This pattern supports cross-project references.
@@ -1026,6 +1066,7 @@ class MergeRequest < ApplicationRecord
def work_in_progress?
self.class.work_in_progress?(title)
end
+ alias_method :draft?, :work_in_progress?
def wipless_title
self.class.wipless_title(self.title)
@@ -1264,7 +1305,14 @@ class MergeRequest < ApplicationRecord
# Returns the oldest multi-line commit message, or the MR title if none found
def default_squash_commit_message
strong_memoize(:default_squash_commit_message) do
- recent_commits.without_merge_commits.reverse_each.find(&:description?)&.safe_message || title
+ first_multiline_commit&.safe_message || title
+ end
+ end
+
+ # Returns the oldest multi-line commit
+ def first_multiline_commit
+ strong_memoize(:first_multiline_commit) do
+ recent_commits.without_merge_commits.reverse_each.find(&:description?)
end
end
@@ -1550,7 +1598,7 @@ class MergeRequest < ApplicationRecord
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)
+ .latest?(comparison_base_pipeline(service_class.name), actual_head_pipeline, data)
raise InvalidateReactiveCache
end
@@ -1586,7 +1634,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, report_type: report_type).execute(base_pipeline, actual_head_pipeline)
+ service_class.new(project, current_user, id: id, report_type: report_type).execute(comparison_base_pipeline(identifier), actual_head_pipeline)
end
def all_commits
@@ -1710,6 +1758,14 @@ class MergeRequest < ApplicationRecord
end
end
+ def use_merge_base_pipeline_for_comparison?(service_class)
+ ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON[service_class]&.call(project)
+ end
+
+ def comparison_base_pipeline(service_class)
+ (use_merge_base_pipeline_for_comparison?(service_class) && merge_base_pipeline) || base_pipeline
+ end
+
def base_pipeline
@base_pipeline ||= project.ci_pipelines
.order(id: :desc)
@@ -1830,6 +1886,12 @@ class MergeRequest < ApplicationRecord
}
end
+ def includes_ci_config?
+ return false unless diff_stats
+
+ diff_stats.map(&:path).include?(project.ci_config_path_or_default)
+ end
+
private
def missing_report_error(report_type)
@@ -1837,11 +1899,7 @@ class MergeRequest < ApplicationRecord
end
def with_rebase_lock
- if Feature.enabled?(:merge_request_rebase_nowait_lock, default_enabled: true)
- with_retried_nowait_lock { yield }
- else
- with_lock(true) { yield }
- end
+ with_retried_nowait_lock { yield }
end
# If the merge request is idle in transaction or has a SELECT FOR
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 3342fb1fce9..3f7ccdb977e 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -63,12 +63,11 @@ class Namespace < ApplicationRecord
validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
+ validate :validate_parent_type, if: -> { Feature.enabled?(:validate_namespace_parent_type) }
validate :nesting_level_allowed
validate :changing_shared_runners_enabled_is_allowed
validate :changing_allow_descendants_override_disabled_shared_runners_is_allowed
- validates_associated :runners
-
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :avatar_url, to: :owner, allow_nil: true
@@ -84,6 +83,8 @@ class Namespace < ApplicationRecord
before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir
+ before_save :ensure_delayed_project_removal_assigned_to_namespace_settings, if: :delayed_project_removal_changed?
+
scope :for_user, -> { where('type IS NULL') }
scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) }
scope :include_route, -> { includes(:route) }
@@ -164,6 +165,10 @@ class Namespace < ApplicationRecord
name = host.delete_suffix(gitlab_host)
Namespace.where(parent_id: nil).by_path(name)
end
+
+ def top_most
+ where(parent_id: nil)
+ end
end
def package_settings
@@ -255,11 +260,12 @@ class Namespace < ApplicationRecord
# Includes projects from this namespace and projects from all subgroups
# that belongs to this namespace
def all_projects
- if Feature.enabled?(:recursive_approach_for_all_projects)
- namespace = user? ? self : self_and_descendants
- Project.where(namespace: namespace)
+ return Project.where(namespace: self) if user?
+
+ if Feature.enabled?(:recursive_namespace_lookup_as_inner_join, self)
+ Project.joins("INNER JOIN (#{self_and_descendants.select(:id).to_sql}) namespaces ON namespaces.id=projects.namespace_id")
else
- Project.inside_path(full_path)
+ Project.where(namespace: self_and_descendants)
end
end
@@ -400,8 +406,19 @@ class Namespace < ApplicationRecord
!has_parent?
end
+ def recent?
+ created_at >= 90.days.ago
+ end
+
private
+ def ensure_delayed_project_removal_assigned_to_namespace_settings
+ return if Feature.disabled?(:migrate_delayed_project_removal, default_enabled: true)
+
+ self.namespace_settings || build_namespace_settings
+ namespace_settings.delayed_project_removal = delayed_project_removal
+ end
+
def all_projects_with_pages
if all_projects.pages_metadata_not_migrated.exists?
Gitlab::BackgroundMigration::MigratePagesMetadata.new.perform_on_relation(
@@ -437,6 +454,16 @@ class Namespace < ApplicationRecord
end
end
+ def validate_parent_type
+ return unless has_parent?
+
+ if user?
+ errors.add(:parent_id, 'a user namespace cannot have a parent')
+ elsif group?
+ errors.add(:parent_id, 'a group cannot have a user namespace as its parent') if parent.user?
+ end
+ end
+
def sync_share_with_group_lock_with_parent
if parent&.share_with_group_lock?
self.share_with_group_lock = true
diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb
index 90aeee7a4f1..0c91ae760b2 100644
--- a/app/models/namespace/root_storage_statistics.rb
+++ b/app/models/namespace/root_storage_statistics.rb
@@ -57,8 +57,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord
end
def attributes_from_personal_snippets
- # Return if the type of namespace does not belong to a user
- return {} unless namespace.type.nil?
+ return {} unless namespace.user?
from_personal_snippets.take.slice(SNIPPETS_SIZE_STAT_NAME)
end
@@ -70,3 +69,5 @@ class Namespace::RootStorageStatistics < ApplicationRecord
.select("COALESCE(SUM(s.repository_size), 0) AS #{SNIPPETS_SIZE_STAT_NAME}")
end
end
+
+Namespace::RootStorageStatistics.prepend_if_ee('EE::Namespace::RootStorageStatistics')
diff --git a/app/models/namespaces/traversal/recursive.rb b/app/models/namespaces/traversal/recursive.rb
index c46cc521735..d74b7883830 100644
--- a/app/models/namespaces/traversal/recursive.rb
+++ b/app/models/namespaces/traversal/recursive.rb
@@ -15,8 +15,7 @@ module Namespaces
# Returns all ancestors, self, and descendants of the current namespace.
def self_and_hierarchy
- Gitlab::ObjectHierarchy
- .new(self.class.where(id: id))
+ object_hierarchy(self.class.where(id: id))
.all_objects
end
@@ -24,38 +23,38 @@ module Namespaces
def ancestors
return self.class.none unless parent_id
- Gitlab::ObjectHierarchy
- .new(self.class.where(id: parent_id))
+ object_hierarchy(self.class.where(id: parent_id))
.base_and_ancestors
end
# returns all ancestors upto but excluding the given namespace
# when no namespace is given, all ancestors upto the top are returned
def ancestors_upto(top = nil, hierarchy_order: nil)
- Gitlab::ObjectHierarchy.new(self.class.where(id: id))
+ object_hierarchy(self.class.where(id: id))
.ancestors(upto: top, hierarchy_order: hierarchy_order)
end
def self_and_ancestors(hierarchy_order: nil)
return self.class.where(id: id) unless parent_id
- Gitlab::ObjectHierarchy
- .new(self.class.where(id: id))
+ object_hierarchy(self.class.where(id: id))
.base_and_ancestors(hierarchy_order: hierarchy_order)
end
# Returns all the descendants of the current namespace.
def descendants
- Gitlab::ObjectHierarchy
- .new(self.class.where(parent_id: id))
+ object_hierarchy(self.class.where(parent_id: id))
.base_and_descendants
end
def self_and_descendants
- Gitlab::ObjectHierarchy
- .new(self.class.where(id: id))
+ object_hierarchy(self.class.where(id: id))
.base_and_descendants
end
+
+ def object_hierarchy(ancestors_base)
+ Gitlab::ObjectHierarchy.new(ancestors_base, options: { use_distinct: Feature.enabled?(:use_distinct_in_object_hierarchy, self) })
+ end
end
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index fdc972d9726..fb540d692d1 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -30,7 +30,6 @@ class Note < ApplicationRecord
# Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes.
# See https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/10392/diffs#note_28719102
- alias_attribute :last_edited_at, :updated_at
alias_attribute :last_edited_by, :updated_by
# Attribute containing rendered and redacted Markdown as generated by
@@ -319,6 +318,7 @@ class Note < ApplicationRecord
def noteable_assignee_or_author?(user)
return false unless user
+ return false unless noteable.respond_to?(:author_id)
return noteable.assignee_or_author?(user) if [MergeRequest, Issue].include?(noteable.class)
noteable.author_id == user.id
@@ -348,7 +348,13 @@ class Note < ApplicationRecord
!system?
end
- # Since we're using `updated_at` as `last_edited_at`, it could be touched by transforming / resolving a note.
+ # We used `last_edited_at` as an alias of `updated_at` before.
+ # This makes it compatible with the previous way without data migration.
+ def last_edited_at
+ super || updated_at
+ end
+
+ # Since we used `updated_at` as `last_edited_at`, it could be touched by transforming / resolving a note.
# This makes sure it is only marked as edited when the note body is updated.
def edited?
return false if updated_by.blank?
@@ -546,7 +552,7 @@ class Note < ApplicationRecord
end
def skip_notification?
- review.present? || author.blocked? || author.ghost?
+ review.present? || !author.can_trigger_notifications?
end
private
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index 82e39e4f207..72813b17501 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -49,7 +49,8 @@ class NotificationSetting < ApplicationRecord
:failed_pipeline,
:fixed_pipeline,
:success_pipeline,
- :moved_project
+ :moved_project,
+ :merge_when_pipeline_succeeds
].freeze
def self.email_events(source = nil)
diff --git a/app/models/onboarding_progress.rb b/app/models/onboarding_progress.rb
index 8a444f8934e..be76c3dbf9d 100644
--- a/app/models/onboarding_progress.rb
+++ b/app/models/onboarding_progress.rb
@@ -17,6 +17,7 @@ class OnboardingProgress < ApplicationRecord
:code_owners_enabled,
:scoped_label_created,
:security_scan_enabled,
+ :issue_created,
:issue_auto_closed,
:repository_imported,
:repository_mirrored
@@ -66,6 +67,13 @@ class OnboardingProgress < ApplicationRecord
where(namespace: namespace).where.not(action_column => nil).exists?
end
+ def not_completed?(namespace_id, action)
+ return unless ACTIONS.include?(action)
+
+ action_column = column_name(action)
+ where(namespace_id: namespace_id).where(action_column => nil).exists?
+ end
+
def column_name(action)
:"#{action}_at"
end
diff --git a/app/models/packages/maven/metadatum.rb b/app/models/packages/maven/metadatum.rb
index b7f27fb9e06..7aed274216b 100644
--- a/app/models/packages/maven/metadatum.rb
+++ b/app/models/packages/maven/metadatum.rb
@@ -18,6 +18,13 @@ class Packages::Maven::Metadatum < ApplicationRecord
validate :maven_package_type
+ scope :for_package_ids, -> (package_ids) { where(package_id: package_ids) }
+ scope :order_created, -> { reorder('created_at ASC') }
+
+ def self.pluck_app_name
+ pluck(:app_name)
+ end
+
private
def maven_package_type
diff --git a/app/models/packages/nuget.rb b/app/models/packages/nuget.rb
index 42c167e9b7f..f152eedb8fc 100644
--- a/app/models/packages/nuget.rb
+++ b/app/models/packages/nuget.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module Packages
module Nuget
+ TEMPORARY_PACKAGE_NAME = 'NuGet.Temporary.Package'
+
def self.table_name_prefix
'packages_nuget_'
end
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index 391540634be..993d1123c86 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -40,11 +40,11 @@ class Packages::Package < ApplicationRecord
validate :unique_debian_package_name, if: :debian_package?
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 :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic?
+ validates :name, format: { with: Gitlab::Regex.npm_package_name_regex }, if: :npm?
validates :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget?
validates :name, format: { with: Gitlab::Regex.debian_package_name_regex }, if: :debian_package?
validates :name, inclusion: { in: %w[incoming] }, if: :debian_incoming?
@@ -91,6 +91,12 @@ class Packages::Package < ApplicationRecord
joins(:conan_metadatum).where(packages_conan_metadata: { package_username: package_username })
end
+ scope :with_debian_codename, -> (codename) do
+ debian
+ .joins(:debian_distribution)
+ .where(Packages::Debian::ProjectDistribution.table_name => { codename: codename })
+ end
+ scope :preload_debian_file_metadata, -> { preload(package_files: :debian_file_metadatum) }
scope :with_composer_target, -> (target) do
includes(:composer_metadatum)
.joins(:composer_metadatum)
@@ -98,12 +104,12 @@ class Packages::Package < ApplicationRecord
end
scope :preload_composer, -> { preload(:composer_metadatum) }
- scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
+ scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::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)
+ where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME)
)
end
scope :preload_files, -> { preload(:package_files) }
@@ -126,6 +132,8 @@ class Packages::Package < ApplicationRecord
scope :order_project_path_desc, -> { joins(:project).reorder('projects.path DESC, id DESC') }
scope :order_by_package_file, -> { joins(:package_files).order('packages_package_files.created_at ASC') }
+ after_commit :update_composer_cache, on: :destroy, if: -> { composer? }
+
def self.for_projects(projects)
return none unless projects.any?
@@ -218,8 +226,20 @@ class Packages::Package < ApplicationRecord
end
end
+ def sync_maven_metadata(user)
+ return unless maven? && version? && user
+
+ ::Packages::Maven::Metadata::SyncWorker.perform_async(user.id, project.id, name)
+ end
+
private
+ def update_composer_cache
+ return unless composer?
+
+ ::Packages::Composer::CacheUpdateWorker.perform_async(project_id, name, composer_metadatum.version_cache_sha) # rubocop:disable CodeReuse/Worker
+ end
+
def composer_tag_version?
composer? && !Gitlab::Regex.composer_dev_version_regex.match(version.to_s)
end
@@ -247,14 +267,6 @@ class Packages::Package < ApplicationRecord
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
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index 9059f61b5de..23a7144e2bb 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -30,6 +30,10 @@ class Packages::PackageFile < ApplicationRecord
scope :preload_conan_file_metadata, -> { preload(:conan_file_metadatum) }
scope :preload_debian_file_metadata, -> { preload(:debian_file_metadatum) }
+ scope :for_rubygem_with_file_name, ->(project, file_name) do
+ joins(:package).merge(project.packages.rubygems).with_file_name(file_name)
+ end
+
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] })
diff --git a/app/models/packages/rubygems.rb b/app/models/packages/rubygems.rb
new file mode 100644
index 00000000000..1aa6b16f47e
--- /dev/null
+++ b/app/models/packages/rubygems.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+module Packages
+ module Rubygems
+ TEMPORARY_PACKAGE_NAME = 'Gem.Temporary.Package'
+
+ def self.table_name_prefix
+ 'packages_rubygems_'
+ end
+ end
+end
diff --git a/app/models/packages/rubygems/metadatum.rb b/app/models/packages/rubygems/metadatum.rb
index 42db1f3defc..d4e5feb7c98 100644
--- a/app/models/packages/rubygems/metadatum.rb
+++ b/app/models/packages/rubygems/metadatum.rb
@@ -3,7 +3,6 @@
module Packages
module Rubygems
class Metadatum < ApplicationRecord
- self.table_name = 'packages_rubygems_metadata'
self.primary_key = :package_id
belongs_to :package, -> { where(package_type: :rubygems) }, inverse_of: :rubygems_metadatum
diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb
index c6781f8f6e3..33771580be2 100644
--- a/app/models/pages/lookup_path.rb
+++ b/app/models/pages/lookup_path.rb
@@ -43,8 +43,6 @@ module Pages
def deployment
strong_memoize(:deployment) do
- next unless Feature.enabled?(:pages_serve_from_deployments, project, default_enabled: true)
-
project.pages_metadatum.pages_deployment
end
end
@@ -52,9 +50,9 @@ module Pages
def zip_source
return unless deployment&.file
- return if deployment.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project)
+ return if deployment.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project, default_enabled: true)
- return if deployment.migrated? && !Feature.enabled?(:pages_serve_from_migrated_zip, project)
+ return if deployment.migrated? && !Feature.enabled?(:pages_serve_from_migrated_zip, project, default_enabled: true)
global_id = ::Gitlab::GlobalId.build(deployment, id: deployment.id).to_s
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 3b07551fe05..ad2f4525171 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -4,6 +4,7 @@ class PersonalAccessToken < ApplicationRecord
include Expirable
include TokenAuthenticatable
include Sortable
+ include EachBatch
extend ::Gitlab::Utils::Override
add_authentication_token_field :token, digest: true
@@ -97,6 +98,10 @@ class PersonalAccessToken < ApplicationRecord
end
def set_default_scopes
+ # When only loading a select set of attributes, for example using `EachBatch`,
+ # the `scopes` attribute is not present, so we can't initialize it.
+ return unless has_attribute?(:scopes)
+
self.scopes = Gitlab::Auth::DEFAULT_SCOPES if self.scopes.empty?
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 2b9b7dcf733..274dae8fd65 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -95,6 +95,9 @@ class Project < ApplicationRecord
before_save :ensure_runners_token
+ # https://api.rubyonrails.org/v6.0.3.4/classes/ActiveRecord/AttributeMethods/Dirty.html#method-i-will_save_change_to_attribute-3F
+ before_update :set_container_registry_access_level, if: :will_save_change_to_container_registry_enabled?
+
after_save :update_project_statistics, if: :saved_change_to_namespace_id?
after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? }
@@ -345,7 +348,7 @@ class Project < ApplicationRecord
has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult'
- has_many :repository_storage_moves, class_name: 'ProjectRepositoryStorageMove', inverse_of: :container
+ has_many :repository_storage_moves, class_name: 'Projects::RepositoryStorageMove', inverse_of: :container
has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project
has_many :reviews, inverse_of: :project
@@ -392,7 +395,9 @@ class Project < ApplicationRecord
:merge_requests_access_level, :forking_access_level, :issues_access_level,
:wiki_access_level, :snippets_access_level, :builds_access_level,
:repository_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level,
- :operations_enabled?, :operations_access_level, to: :project_feature, allow_nil: true
+ :operations_enabled?, :operations_access_level, :security_and_compliance_access_level,
+ :container_registry_access_level,
+ to: :project_feature, allow_nil: true
delegate :show_default_award_emojis, :show_default_award_emojis=,
:show_default_award_emojis?,
to: :project_setting, allow_nil: true
@@ -491,10 +496,22 @@ class Project < ApplicationRecord
{ column: arel_table["description"], multiplier: 0.2 }
])
- query = reorder(order_expression.desc, arel_table['id'].desc)
+ order = Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'similarity',
+ column_expression: order_expression,
+ order_expression: order_expression.desc,
+ order_direction: :desc,
+ distinct: false,
+ add_to_projections: true
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: Project.arel_table[:id].desc
+ )
+ ])
- query = query.select(*query.arel.projections, order_expression.as('similarity')) if include_in_select
- query
+ order.apply_cursor_conditions(reorder(order))
end
scope :with_packages, -> { joins(:packages) }
@@ -1696,8 +1713,8 @@ class Project < ApplicationRecord
end
end
- def any_runners?(&block)
- active_runners.any?(&block)
+ def any_active_runners?(&block)
+ active_runners_with_tags.any?(&block)
end
def valid_runners_token?(token)
@@ -1986,6 +2003,7 @@ class Project < ApplicationRecord
.append(key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: repository_languages.map(&:name).join(',').downcase)
.append(key: 'CI_DEFAULT_BRANCH', value: default_branch)
.append(key: 'CI_PROJECT_CONFIG_PATH', value: ci_config_path_or_default)
+ .append(key: 'CI_CONFIG_PATH', value: ci_config_path_or_default)
end
def predefined_ci_server_variables
@@ -2023,10 +2041,12 @@ class Project < ApplicationRecord
Gitlab::Ci::Variables::Collection.new.tap do |variables|
break variables unless Gitlab.config.dependency_proxy.enabled
- variables.append(key: 'CI_DEPENDENCY_PROXY_SERVER', value: "#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}")
+ variables.append(key: 'CI_DEPENDENCY_PROXY_SERVER', value: Gitlab.host_with_port)
variables.append(
key: 'CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX',
- value: "#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/#{namespace.root_ancestor.path}#{DependencyProxy::URL_SUFFIX}"
+ # The namespace path can include uppercase letters, which
+ # Docker doesn't allow. The proxy expects it to be downcased.
+ value: "#{Gitlab.host_with_port}/#{namespace.root_ancestor.path.downcase}#{DependencyProxy::URL_SUFFIX}"
)
end
end
@@ -2532,8 +2552,26 @@ class Project < ApplicationRecord
Projects::GitGarbageCollectWorker
end
+ def inherited_issuable_templates_enabled?
+ Feature.enabled?(:inherited_issuable_templates, self, default_enabled: :yaml)
+ end
+
private
+ def set_container_registry_access_level
+ # changes_to_save = { 'container_registry_enabled' => [value_before_update, value_after_update] }
+ value = changes_to_save['container_registry_enabled'][1]
+
+ access_level =
+ if value
+ ProjectFeature::ENABLED
+ else
+ ProjectFeature::DISABLED
+ end
+
+ project_feature.update!(container_registry_access_level: access_level)
+ end
+
def find_service(services, name)
services.find { |service| service.to_param == name }
end
@@ -2694,6 +2732,12 @@ class Project < ApplicationRecord
def cache_has_external_issue_tracker
update_column(:has_external_issue_tracker, services.external_issue_trackers.any?) if Gitlab::Database.read_write?
end
+
+ def active_runners_with_tags
+ strong_memoize(:active_runners_with_tags) do
+ active_runners.with_tags
+ end
+ end
end
Project.prepend_if_ee('EE::Project')
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 7b204cfb1c0..a598bf3f60c 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -3,7 +3,23 @@
class ProjectFeature < ApplicationRecord
include Featurable
- FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard analytics operations).freeze
+ FEATURES = %i[
+ issues
+ forking
+ merge_requests
+ wiki
+ snippets
+ builds
+ repository
+ pages
+ metrics_dashboard
+ analytics
+ operations
+ security_and_compliance
+ container_registry
+ ].freeze
+
+ EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance]).freeze
set_available_features(FEATURES)
@@ -27,6 +43,8 @@ class ProjectFeature < ApplicationRecord
end
end
+ before_create :set_container_registry_access_level
+
# Default scopes force us to unscope here since a service may need to check
# permissions for a project in pending_delete
# http://stackoverflow.com/questions/1540645/how-to-disable-default-scope-for-a-belongs-to
@@ -37,16 +55,17 @@ class ProjectFeature < ApplicationRecord
validate :repository_children_level
validate :allowed_access_levels
- default_value_for :builds_access_level, value: ENABLED, allows_nil: false
- default_value_for :issues_access_level, value: ENABLED, allows_nil: false
- default_value_for :forking_access_level, value: ENABLED, allows_nil: false
- default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false
- default_value_for :snippets_access_level, value: ENABLED, allows_nil: false
- default_value_for :wiki_access_level, value: ENABLED, allows_nil: false
- default_value_for :repository_access_level, value: ENABLED, allows_nil: false
- default_value_for :analytics_access_level, value: ENABLED, allows_nil: false
+ default_value_for :builds_access_level, value: ENABLED, allows_nil: false
+ default_value_for :issues_access_level, value: ENABLED, allows_nil: false
+ default_value_for :forking_access_level, value: ENABLED, allows_nil: false
+ default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false
+ default_value_for :snippets_access_level, value: ENABLED, allows_nil: false
+ default_value_for :wiki_access_level, value: ENABLED, allows_nil: false
+ default_value_for :repository_access_level, value: ENABLED, allows_nil: false
+ default_value_for :analytics_access_level, value: ENABLED, allows_nil: false
default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false
- default_value_for :operations_access_level, value: ENABLED, allows_nil: false
+ default_value_for :operations_access_level, value: ENABLED, allows_nil: false
+ default_value_for :security_and_compliance_access_level, value: PRIVATE, allows_nil: false
default_value_for(:pages_access_level, allows_nil: false) do |feature|
if ::Gitlab::Pages.access_control_is_forced?
@@ -70,6 +89,15 @@ class ProjectFeature < ApplicationRecord
private
+ def set_container_registry_access_level
+ self.container_registry_access_level =
+ if project&.container_registry_enabled
+ ENABLED
+ else
+ DISABLED
+ end
+ end
+
# Validates builds and merge requests access level
# which cannot be higher than repository access level
def repository_children_level
diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb
index 1e3782a1fb5..e54489ddb88 100644
--- a/app/models/project_repository_storage_move.rb
+++ b/app/models/project_repository_storage_move.rb
@@ -1,34 +1,13 @@
# frozen_string_literal: true
-# ProjectRepositoryStorageMove are details of repository storage moves for a
-# project. For example, moving a project to another gitaly node to help
-# balance storage capacity.
-class ProjectRepositoryStorageMove < ApplicationRecord
- extend ::Gitlab::Utils::Override
- include RepositoryStorageMovable
-
- belongs_to :container, class_name: 'Project', inverse_of: :repository_storage_moves, foreign_key: :project_id
- alias_attribute :project, :container
- scope :with_projects, -> { includes(container: :route) }
-
- override :update_repository_storage
- def update_repository_storage(new_storage)
- container.update_column(:repository_storage, new_storage)
- end
-
- override :schedule_repository_storage_update_worker
- def schedule_repository_storage_update_worker
- ProjectUpdateRepositoryStorageWorker.perform_async(
- project_id,
- destination_storage_name,
- id
- )
- end
-
- private
-
- override :error_key
- def error_key
- :project
- end
+# This is a compatibility class to avoid calling a non-existent
+# class from sidekiq during deployment.
+#
+# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853.
+# we cannot remove this class entirely because there can be jobs
+# referencing it.
+#
+# We can get rid of this class in 14.0
+# https://gitlab.com/gitlab-org/gitlab/-/issues/322393
+class ProjectRepositoryStorageMove < Projects::RepositoryStorageMove
end
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index 1d50d5cf19e..cf7cad09676 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -97,9 +97,12 @@ class ChatNotificationService < Service
opts[:channel] = channels if channels.present?
opts[:username] = username if username
- return false unless notify(message, opts)
+ if notify(message, opts)
+ log_usage(event_type, user_id_from_hook_data(data))
+ return true
+ end
- true
+ false
end
def event_channel_names
@@ -120,6 +123,10 @@ class ChatNotificationService < Service
private
+ def log_usage(_, _)
+ # Implement in child class
+ end
+
def labels_to_be_notified_list
return [] if labels_to_be_notified.nil?
@@ -136,6 +143,10 @@ class ChatNotificationService < Service
(labels_to_be_notified_list & label_titles).any?
end
+ def user_id_from_hook_data(data)
+ data.dig(:user, :id) || data[:user_id]
+ end
+
# every notifier must implement this independently
def notify(message, opts)
raise NotImplementedError
diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb
index 941b7f64263..37bbb9b8752 100644
--- a/app/models/project_services/discord_service.rb
+++ b/app/models/project_services/discord_service.rb
@@ -59,6 +59,9 @@ class DiscordService < ChatNotificationService
embed.description = (message.pretext + "\n" + Array.wrap(message.attachments).join("\n")).gsub(ATTACHMENT_REGEX, " \\k<entry> - \\k<name>\n")
end
end
+ rescue RestClient::Exception => error
+ log_error(error.message)
+ false
end
def custom_data(data)
diff --git a/app/models/project_services/mattermost_service.rb b/app/models/project_services/mattermost_service.rb
index c1055db78e5..9cff979fcf2 100644
--- a/app/models/project_services/mattermost_service.rb
+++ b/app/models/project_services/mattermost_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class MattermostService < ChatNotificationService
- include ::SlackService::Notifier
+ include SlackMattermost::Notifier
def title
'Mattermost notifications'
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index ab043227832..b8869547a37 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -46,7 +46,7 @@ class PrometheusService < MonitoringService
end
def description
- s_('PrometheusService|Time-series monitoring service')
+ s_('PrometheusService|Monitor application health with Prometheus metrics and dashboards')
end
def self.to_param
@@ -59,20 +59,23 @@ class PrometheusService < MonitoringService
type: 'checkbox',
name: 'manual_configuration',
title: s_('PrometheusService|Active'),
+ help: s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.'),
required: true
},
{
type: 'text',
name: 'api_url',
title: 'API URL',
- placeholder: s_('PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/'),
+ placeholder: s_('PrometheusService|https://prometheus.example.com/'),
+ help: s_('PrometheusService|The Prometheus API base URL.'),
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)'),
+ placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'),
+ help: s_('PrometheusService|PrometheusService|The ID of the IAP-secured resource.'),
autocomplete: 'off',
required: false
},
@@ -80,7 +83,8 @@ class PrometheusService < MonitoringService
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": ... }'),
+ placeholder: s_('PrometheusService|{ "type": "service_account", "project_id": ... }'),
+ help: s_('PrometheusService|The contents of the credentials.json file of your service account.'),
required: false
}
]
diff --git a/app/models/project_services/slack_mattermost/notifier.rb b/app/models/project_services/slack_mattermost/notifier.rb
new file mode 100644
index 00000000000..1a78cea5933
--- /dev/null
+++ b/app/models/project_services/slack_mattermost/notifier.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module SlackMattermost
+ module Notifier
+ private
+
+ def notify(message, opts)
+ # See https://gitlab.com/gitlab-org/slack-notifier/#custom-http-client
+ notifier = Slack::Messenger.new(webhook, opts.merge(http_client: HTTPClient))
+ notifier.ping(
+ message.pretext,
+ attachments: message.attachments,
+ fallback: message.fallback
+ )
+ end
+
+ class HTTPClient
+ def self.post(uri, params = {})
+ params.delete(:http_options) # these are internal to the client and we do not want them
+ Gitlab::HTTP.post(uri, body: params)
+ end
+ end
+ end
+end
diff --git a/app/models/project_services/slack_service.rb b/app/models/project_services/slack_service.rb
index 79245e84238..f42b3de39d5 100644
--- a/app/models/project_services/slack_service.rb
+++ b/app/models/project_services/slack_service.rb
@@ -1,6 +1,14 @@
# frozen_string_literal: true
class SlackService < ChatNotificationService
+ include SlackMattermost::Notifier
+ extend ::Gitlab::Utils::Override
+
+ SUPPORTED_EVENTS_FOR_USAGE_LOG = %w[
+ push issue confidential_issue merge_request note confidential_note
+ tag_push wiki_page deployment
+ ].freeze
+
prop_accessor EVENT_CHANNEL['alert']
def title
@@ -36,26 +44,14 @@ class SlackService < ChatNotificationService
super
end
- module Notifier
- private
+ override :log_usage
+ def log_usage(event, user_id)
+ return unless user_id
- def notify(message, opts)
- # See https://gitlab.com/gitlab-org/slack-notifier/#custom-http-client
- notifier = Slack::Messenger.new(webhook, opts.merge(http_client: HTTPClient))
- notifier.ping(
- message.pretext,
- attachments: message.attachments,
- fallback: message.fallback
- )
- end
+ return unless SUPPORTED_EVENTS_FOR_USAGE_LOG.include?(event)
- class HTTPClient
- def self.post(uri, params = {})
- params.delete(:http_options) # these are internal to the client and we do not want them
- Gitlab::HTTP.post(uri, body: params)
- end
- end
- end
+ key = "i_ecosystem_slack_service_#{event}_notification"
- include Notifier
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user_id)
+ end
end
diff --git a/app/models/project_services/unify_circuit_service.rb b/app/models/project_services/unify_circuit_service.rb
index 1e12179e62a..1a0eebe7d64 100644
--- a/app/models/project_services/unify_circuit_service.rb
+++ b/app/models/project_services/unify_circuit_service.rb
@@ -47,7 +47,7 @@ class UnifyCircuitService < ChatNotificationService
def notify(message, opts)
response = Gitlab::HTTP.post(webhook, body: {
subject: message.project_name,
- text: message.pretext,
+ text: message.summary,
markdown: true
}.to_json)
diff --git a/app/models/project_services/webex_teams_service.rb b/app/models/project_services/webex_teams_service.rb
index 1d791b19486..4e8281f4e81 100644
--- a/app/models/project_services/webex_teams_service.rb
+++ b/app/models/project_services/webex_teams_service.rb
@@ -46,7 +46,7 @@ class WebexTeamsService < ChatNotificationService
def notify(message, opts)
header = { 'Content-Type' => 'application/json' }
- response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.pretext }.to_json)
+ response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json)
response if response.success?
end
diff --git a/app/models/projects/repository_storage_move.rb b/app/models/projects/repository_storage_move.rb
new file mode 100644
index 00000000000..f4411e0b4fd
--- /dev/null
+++ b/app/models/projects/repository_storage_move.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+# Projects::RepositoryStorageMove are details of repository storage moves for a
+# project. For example, moving a project to another gitaly node to help
+# balance storage capacity.
+module Projects
+ class RepositoryStorageMove < ApplicationRecord
+ extend ::Gitlab::Utils::Override
+ include RepositoryStorageMovable
+
+ self.table_name = 'project_repository_storage_moves'
+
+ belongs_to :container, class_name: 'Project', inverse_of: :repository_storage_moves, foreign_key: :project_id
+ alias_attribute :project, :container
+ scope :with_projects, -> { includes(container: :route) }
+
+ override :update_repository_storage
+ def update_repository_storage(new_storage)
+ container.update_column(:repository_storage, new_storage)
+ end
+
+ override :schedule_repository_storage_update_worker
+ def schedule_repository_storage_update_worker
+ Projects::UpdateRepositoryStorageWorker.perform_async(
+ project_id,
+ destination_storage_name,
+ id
+ )
+ end
+
+ private
+
+ override :error_key
+ def error_key
+ :project
+ end
+ end
+end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index ad418a47476..cbbdd091feb 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -7,6 +7,9 @@ class ProtectedBranch < ApplicationRecord
scope :requiring_code_owner_approval,
-> { where(code_owner_approval_required: true) }
+ scope :allowing_force_push,
+ -> { where(allow_force_push: true) }
+
protected_ref_access_levels :merge, :push
def self.protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil)
@@ -26,6 +29,12 @@ class ProtectedBranch < ApplicationRecord
self.matching(ref_name, protected_refs: protected_refs(project)).present?
end
+ def self.allow_force_push?(project, ref_name)
+ return false unless ::Feature.enabled?(:allow_force_push_to_protected_branches, project)
+
+ project.protected_branches.allowing_force_push.matching(ref_name).any?
+ end
+
def self.any_protected?(project, ref_names)
protected_refs(project).any? do |protected_ref|
ref_names.any? do |ref_name|
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index ab8782ed87f..5fdd4551982 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -45,7 +45,7 @@ class Snippet < ApplicationRecord
has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :user_mentions, class_name: "SnippetUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_one :snippet_repository, inverse_of: :snippet
- has_many :repository_storage_moves, class_name: 'SnippetRepositoryStorageMove', inverse_of: :container
+ has_many :repository_storage_moves, class_name: 'Snippets::RepositoryStorageMove', inverse_of: :container
# 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
@@ -82,6 +82,7 @@ class Snippet < ApplicationRecord
scope :fresh, -> { order("created_at DESC") }
scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> { includes(author: :status) }
+ scope :inc_statistics, -> { includes(:statistics) }
scope :with_statistics, -> { joins(:statistics) }
scope :inc_projects_namespace_route, -> { includes(project: [:route, :namespace]) }
@@ -216,8 +217,10 @@ class Snippet < ApplicationRecord
def blobs
return [] unless repository_exists?
- branch = default_branch
- list_files(branch).map { |file| Blob.lazy(repository, branch, file) }
+ files = list_files(default_branch)
+ items = files.map { |file| [default_branch, file] }
+
+ repository.blobs_at(items).compact
end
def hook_attrs
diff --git a/app/models/snippet_repository_storage_move.rb b/app/models/snippet_repository_storage_move.rb
index bb157c08995..8234905a7e1 100644
--- a/app/models/snippet_repository_storage_move.rb
+++ b/app/models/snippet_repository_storage_move.rb
@@ -1,28 +1,13 @@
# frozen_string_literal: true
-# SnippetRepositoryStorageMove are details of repository storage moves for a
-# snippet. For example, moving a snippet to another gitaly node to help
-# balance storage capacity.
-class SnippetRepositoryStorageMove < ApplicationRecord
- extend ::Gitlab::Utils::Override
- include RepositoryStorageMovable
-
- belongs_to :container, class_name: 'Snippet', inverse_of: :repository_storage_moves, foreign_key: :snippet_id
- alias_attribute :snippet, :container
-
- override :schedule_repository_storage_update_worker
- def schedule_repository_storage_update_worker
- SnippetUpdateRepositoryStorageWorker.perform_async(
- snippet_id,
- destination_storage_name,
- id
- )
- end
-
- private
-
- override :error_key
- def error_key
- :snippet
- end
+# This is a compatibility class to avoid calling a non-existent
+# class from sidekiq during deployment.
+#
+# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853.
+# we cannot remove this class entirely because there can be jobs
+# referencing it.
+#
+# We can get rid of this class in 14.0
+# https://gitlab.com/gitlab-org/gitlab/-/issues/322393
+class SnippetRepositoryStorageMove < Snippets::RepositoryStorageMove
end
diff --git a/app/models/snippets/repository_storage_move.rb b/app/models/snippets/repository_storage_move.rb
new file mode 100644
index 00000000000..3d6e1b0ccea
--- /dev/null
+++ b/app/models/snippets/repository_storage_move.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+# Snippets::RepositoryStorageMove are details of repository storage moves for a
+# snippet. For example, moving a snippet to another gitaly node to help
+# balance storage capacity.
+module Snippets
+ class RepositoryStorageMove < ApplicationRecord
+ extend ::Gitlab::Utils::Override
+ include RepositoryStorageMovable
+
+ self.table_name = 'snippet_repository_storage_moves'
+
+ belongs_to :container, class_name: 'Snippet', inverse_of: :repository_storage_moves, foreign_key: :snippet_id
+ alias_attribute :snippet, :container
+
+ override :schedule_repository_storage_update_worker
+ def schedule_repository_storage_update_worker
+ Snippets::UpdateRepositoryStorageWorker.perform_async(
+ snippet_id,
+ destination_storage_name,
+ id
+ )
+ end
+
+ private
+
+ override :error_key
+ def error_key
+ :snippet
+ end
+ end
+end
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 12dc9ce0fe6..176d5e56fc0 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -55,7 +55,6 @@ class Todo < ApplicationRecord
validates :project, presence: true, unless: :group_id
validates :group, presence: true, unless: :project_id
- scope :for_ids, -> (ids) { where(id: ids) }
scope :pending, -> { with_state(:pending) }
scope :done, -> { with_state(:done) }
scope :for_action, -> (action) { where(action: action) }
diff --git a/app/models/user.rb b/app/models/user.rb
index 1f8b680c7e5..11046bdabe4 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -179,6 +179,7 @@ class User < ApplicationRecord
has_many :merge_request_reviewers, inverse_of: :reviewer
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request
+ has_many :created_custom_emoji, class_name: 'CustomEmoji', inverse_of: :creator
has_many :bulk_imports
@@ -271,7 +272,7 @@ class User < ApplicationRecord
enum layout: { fixed: 0, fluid: 1 }
# User's Dashboard preference
- enum dashboard: { projects: 0, stars: 1, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8 }
+ enum dashboard: { projects: 0, stars: 1, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8, followed_user_activity: 9 }
# User's Project preference
enum project_view: { readme: 0, activity: 1, files: 2 }
@@ -293,6 +294,7 @@ class User < ApplicationRecord
:setup_for_company, :setup_for_company=,
:render_whitespace_in_code, :render_whitespace_in_code=,
:experience_level, :experience_level=,
+ :markdown_surround_selection, :markdown_surround_selection=,
to: :user_preference
delegate :path, to: :namespace, allow_nil: true, prefix: true
@@ -359,6 +361,7 @@ class User < ApplicationRecord
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :blocked_pending_approval, -> { with_states(:blocked_pending_approval) }
scope :external, -> { where(external: true) }
+ scope :non_external, -> { where(external: false) }
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :active, -> { with_state(:active).non_internal }
scope :active_without_ghosts, -> { with_state(:active).without_ghosts }
@@ -937,11 +940,7 @@ class User < ApplicationRecord
# Returns the groups a user has access to, either through a membership or a project authorization
def authorized_groups
Group.unscoped do
- if Feature.enabled?(:shared_group_membership_auth, self)
- authorized_groups_with_shared_membership
- else
- authorized_groups_without_shared_membership
- end
+ authorized_groups_with_shared_membership
end
end
@@ -1705,6 +1704,10 @@ class User < ApplicationRecord
can?(:read_all_resources)
end
+ def can_admin_all_resources?
+ can?(:admin_all_resources)
+ end
+
def update_two_factor_requirement
periods = expanded_groups_requiring_two_factor_authentication.pluck(:two_factor_grace_period)
@@ -1855,6 +1858,14 @@ class User < ApplicationRecord
created_at > Devise.confirm_within.ago
end
+ def find_or_initialize_callout(feature_name)
+ callouts.find_or_initialize_by(feature_name: ::UserCallout.feature_names[feature_name])
+ end
+
+ def can_trigger_notifications?
+ confirmed? && !blocked? && !ghost?
+ end
+
protected
# override, from Devise::Validatable
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index d93fe611538..bb5a9dceaeb 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -7,7 +7,7 @@ class UserCallout < ApplicationRecord
gke_cluster_integration: 1,
gcp_signup_offer: 2,
cluster_security_warning: 3,
- gold_trial: 4, # EE-only
+ ultimate_trial: 4, # EE-only
geo_enable_hashed_storage: 5, # EE-only
geo_migrate_hashed_storage: 6, # EE-only
canary_deployment: 7, # EE-only
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 49b93ffaf66..0bf8c8f901d 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -27,6 +27,7 @@ class UserPreference < ApplicationRecord
default_value_for :time_display_relative, value: true, allows_nil: false
default_value_for :time_format_in_24h, value: false, allows_nil: false
default_value_for :render_whitespace_in_code, value: false, allows_nil: false
+ default_value_for :markdown_surround_selection, value: true, allows_nil: false
class << self
def notes_filters
diff --git a/app/models/users_statistics.rb b/app/models/users_statistics.rb
index 37a430015e5..d724b06a996 100644
--- a/app/models/users_statistics.rb
+++ b/app/models/users_statistics.rb
@@ -70,3 +70,5 @@ class UsersStatistics < ApplicationRecord
end
end
end
+
+UsersStatistics.prepend_if_ee('EE::UsersStatistics')
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index 45747c0b03c..df31c54bd0f 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -159,8 +159,17 @@ class Wiki
find_page(SIDEBAR, version)
end
- def find_file(name, version = nil)
- wiki.file(name, version)
+ def find_file(name, version = 'HEAD', load_content: true)
+ if Feature.enabled?(:gitaly_find_file, user, default_enabled: :yaml)
+ data_limit = load_content ? -1 : 0
+ blobs = repository.blobs_at([[version, name]], blob_size_limit: data_limit)
+
+ return if blobs.empty?
+
+ Gitlab::Git::WikiFile.from_blob(blobs.first)
+ else
+ wiki.file(name, version)
+ end
end
def create_page(title, content, format = :markdown, message = nil)
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 989128987d5..3b9a7ded83e 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -205,14 +205,15 @@ class WikiPage
last_commit_sha = attrs.delete(:last_commit_sha)
if last_commit_sha && last_commit_sha != self.last_commit_sha
- raise PageChangedError
+ raise PageChangedError, s_(
+ 'WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{wikiLinkStart}the page%{wikiLinkEnd} and make sure your changes will not unintentionally remove theirs.')
end
update_attributes(attrs)
if title.present? && title_changed? && wiki.find_page(title).present?
attributes[:title] = page.title
- raise PageRenameError
+ raise PageRenameError, s_('WikiEdit|There is already a page with the same title in that path.')
end
save do
diff --git a/app/models/zoom_meeting.rb b/app/models/zoom_meeting.rb
index c8b510c4779..f684f9e6fe0 100644
--- a/app/models/zoom_meeting.rb
+++ b/app/models/zoom_meeting.rb
@@ -10,7 +10,7 @@ class ZoomMeeting < ApplicationRecord
validates :project, presence: true, unless: :importing?
validates :issue, presence: true, unless: :importing?
- validates :url, presence: true, length: { maximum: 255 }, zoom_url: true
+ validates :url, presence: true, length: { maximum: 255 }, 'gitlab/utils/zoom_url': true
validates :issue, same_project_association: true, unless: :importing?
enum issue_status: {
diff --git a/app/policies/analytics/instance_statistics/measurement_policy.rb b/app/policies/analytics/usage_trends/measurement_policy.rb
index 3d6a5a08ff6..da3c0927ea5 100644
--- a/app/policies/analytics/instance_statistics/measurement_policy.rb
+++ b/app/policies/analytics/usage_trends/measurement_policy.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Analytics
- module InstanceStatistics
+ module UsageTrends
class MeasurementPolicy < BasePolicy
delegate { :global }
end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 51694ec7c50..e32a889c906 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -55,14 +55,17 @@ class BasePolicy < DeclarativePolicy::Base
prevent :read_cross_project
end
- # Policy extended in EE to also enable auditors
- rule { admin }.enable :read_all_resources
+ rule { admin }.policy do
+ # Only for actual administrator accounts, behaviour affected by admin mode application setting
+ enable :admin_all_resources
+ # Policy extended in EE to also enable auditors
+ enable :read_all_resources
+ enable :change_repository_storage
+ end
rule { default }.enable :read_cross_project
condition(:is_gitlab_com) { ::Gitlab.dev_env_or_com? }
-
- rule { admin }.enable :change_repository_storage
end
BasePolicy.prepend_if_ee('EE::BasePolicy')
diff --git a/app/policies/concerns/readonly_abilities.rb b/app/policies/concerns/readonly_abilities.rb
index a267e963541..0303d4cff14 100644
--- a/app/policies/concerns/readonly_abilities.rb
+++ b/app/policies/concerns/readonly_abilities.rb
@@ -17,7 +17,7 @@ module ReadonlyAbilities
READONLY_FEATURES = %i[
issue
- list
+ issue_board_list
merge_request
label
milestone
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 9c79a797a6a..5ee34ebbb2f 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -100,7 +100,7 @@ class GlobalPolicy < BasePolicy
enable :update_custom_attribute
enable :approve_user
enable :reject_user
- enable :read_instance_statistics_measurements
+ enable :read_usage_trends_measurement
end
# We can't use `read_statistics` because the user may have different permissions for different projects
diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb
index 09cac96e3a5..1dd650c8a90 100644
--- a/app/policies/group_member_policy.rb
+++ b/app/policies/group_member_policy.rb
@@ -4,7 +4,7 @@ class GroupMemberPolicy < BasePolicy
delegate :group
with_scope :subject
- condition(:last_owner) { @subject.group.last_owner?(@subject.user) }
+ condition(:last_owner) { @subject.group.last_owner?(@subject.user) || @subject.group.last_blocked_owner?(@subject.user) }
desc "Membership is users' own"
with_score 0
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 7dd88fcc1ff..53286cf1fdf 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -97,9 +97,9 @@ class GroupPolicy < BasePolicy
rule { can?(:read_group) }.policy do
enable :read_milestone
- enable :read_list
+ enable :read_issue_board_list
enable :read_label
- enable :read_board
+ enable :read_issue_board
enable :read_group_member
enable :read_custom_emoji
end
@@ -122,9 +122,9 @@ class GroupPolicy < BasePolicy
rule { reporter }.policy do
enable :reporter_access
enable :read_container_image
- enable :admin_board
+ enable :admin_issue_board
enable :admin_label
- enable :admin_list
+ enable :admin_issue_board_list
enable :admin_issue
enable :read_metrics_dashboard_annotation
enable :read_prometheus
diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb
index d5ba42d750c..e3fb54172f8 100644
--- a/app/policies/merge_request_policy.rb
+++ b/app/policies/merge_request_policy.rb
@@ -9,7 +9,10 @@ class MergeRequestPolicy < IssuablePolicy
# Although :read_merge_request is computed in the policy context,
# 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?(:read_merge_request) }.policy do
+ prevent :create_note
+ prevent :accept_merge_request
+ end
rule { can?(:update_merge_request) }.policy do
enable :approve_merge_request
@@ -18,6 +21,12 @@ class MergeRequestPolicy < IssuablePolicy
rule { ~anonymous & can?(:read_merge_request) }.policy do
enable :create_todo
end
+
+ condition(:can_merge) { @subject.can_be_merged_by?(@user) }
+
+ rule { can_merge }.policy do
+ enable :accept_merge_request
+ end
end
MergeRequestPolicy.prepend_if_ee('EE::MergeRequestPolicy')
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index 2bf6b6c3161..38f0f165376 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -57,6 +57,10 @@ class NotePolicy < BasePolicy
enable :resolve_note
end
+ rule { can_read_confidential }.policy do
+ enable :mark_note_as_confidential
+ end
+
rule { confidential & ~can_read_confidential }.policy do
prevent :read_note
prevent :admin_note
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index aaf985d6c63..de80f2f72b8 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -156,6 +156,7 @@ class ProjectPolicy < BasePolicy
metrics_dashboard
analytics
operations
+ security_and_compliance
]
features.each do |f|
@@ -203,8 +204,8 @@ class ProjectPolicy < BasePolicy
rule { can?(:guest_access) }.policy do
enable :read_project
enable :create_merge_request_in
- enable :read_board
- enable :read_list
+ enable :read_issue_board
+ enable :read_issue_board_list
enable :read_wiki
enable :read_issue
enable :read_label
@@ -230,7 +231,7 @@ class ProjectPolicy < BasePolicy
rule { guest & can?(:read_container_image) }.enable :build_read_container_image
rule { can?(:reporter_access) }.policy do
- enable :admin_board
+ enable :admin_issue_board
enable :download_code
enable :read_statistics
enable :download_wiki_code
@@ -239,7 +240,7 @@ class ProjectPolicy < BasePolicy
enable :reopen_issue
enable :admin_issue
enable :admin_label
- enable :admin_list
+ enable :admin_issue_board_list
enable :admin_issue_link
enable :read_commit_status
enable :read_build
@@ -318,7 +319,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:developer_access) }.policy do
enable :create_package
- enable :admin_board
+ enable :admin_issue_board
enable :admin_merge_request
enable :admin_milestone
enable :update_merge_request
@@ -368,7 +369,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:maintainer_access) }.policy do
enable :destroy_package
- enable :admin_board
+ enable :admin_issue_board
enable :push_to_delete_protected_branch
enable :update_snippet
enable :admin_snippet
@@ -428,8 +429,8 @@ class ProjectPolicy < BasePolicy
rule { issues_disabled }.policy do
prevent(*create_read_update_admin_destroy(:issue))
- prevent(*create_read_update_admin_destroy(:board))
- prevent(*create_read_update_admin_destroy(:list))
+ prevent(*create_read_update_admin_destroy(:issue_board))
+ prevent(*create_read_update_admin_destroy(:issue_board_list))
end
rule { merge_requests_disabled | repository_disabled }.policy do
@@ -506,8 +507,8 @@ class ProjectPolicy < BasePolicy
rule { can?(:public_access) }.policy do
enable :read_package
enable :read_project
- enable :read_board
- enable :read_list
+ enable :read_issue_board
+ enable :read_issue_board_list
enable :read_wiki
enable :read_label
enable :read_milestone
@@ -640,6 +641,10 @@ class ProjectPolicy < BasePolicy
enable :set_pipeline_variables
end
+ rule { ~security_and_compliance_disabled & can?(:developer_access) }.policy do
+ enable :access_security_and_compliance
+ end
+
private
def user_is_user?
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index dbb77143e2e..769b793ee75 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -32,6 +32,10 @@ module Ci
end.to_i
end
+ def runner_variables
+ variables.to_runner_variables
+ end
+
def refspecs
specs = []
specs << refspec_for_persistent_ref if persistent_ref_exist?
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 0c68c33cbbe..4e955469ddf 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -61,7 +61,7 @@ module Ci
link_to_merge_request: link_to_merge_request,
link_to_merge_request_source_branch: link_to_merge_request_source_branch
}
- elsif pipeline.merge_request_pipeline?
+ elsif pipeline.merged_result_pipeline?
_("for %{link_to_merge_request} with %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}")
.html_safe % {
link_to_merge_request: link_to_merge_request,
diff --git a/app/presenters/dev_ops_report/metric_presenter.rb b/app/presenters/dev_ops_report/metric_presenter.rb
index e7363293435..b31dfd25a87 100644
--- a/app/presenters/dev_ops_report/metric_presenter.rb
+++ b/app/presenters/dev_ops_report/metric_presenter.rb
@@ -2,6 +2,8 @@
module DevOpsReport
class MetricPresenter < Gitlab::View::Presenter::Simple
+ delegate :created_at, to: :subject
+
def cards
[
Card.new(
diff --git a/app/presenters/packages/composer/packages_presenter.rb b/app/presenters/packages/composer/packages_presenter.rb
index ed0e9d3b731..8e3f482126d 100644
--- a/app/presenters/packages/composer/packages_presenter.rb
+++ b/app/presenters/packages/composer/packages_presenter.rb
@@ -11,8 +11,19 @@ module Packages
end
def root
- path = api_v4_group___packages_composer_package_name_path({ id: @group.id, package_name: '%package%$%hash%', format: '.json' }, true)
- { 'packages' => [], 'provider-includes' => { 'p/%hash%.json' => { 'sha256' => provider_sha } }, 'providers-url' => path }
+ v1_path = expose_path(api_v4_group___packages_composer_package_name_path({ id: @group.id, package_name: '%package%$%hash%', format: '.json' }, true))
+ v2_path = expose_path(api_v4_group___packages_composer_p2_package_name_path({ id: @group.id, package_name: '%package%', format: '.json' }, true))
+
+ {
+ 'packages' => [],
+ 'provider-includes' => {
+ 'p/%hash%.json' => {
+ 'sha256' => provider_sha
+ }
+ },
+ 'providers-url' => v1_path,
+ 'metadata-url' => v2_path
+ }
end
def provider
diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb
index dbfcfcb67f3..9960fb4bf12 100644
--- a/app/presenters/packages/detail/package_presenter.rb
+++ b/app/presenters/packages/detail/package_presenter.rb
@@ -42,7 +42,11 @@ module Packages
created_at: package_file.created_at,
download_path: package_file.download_path,
file_name: package_file.file_name,
- size: package_file.size
+ size: package_file.size,
+ file_md5: package_file.file_md5,
+ file_sha1: package_file.file_sha1,
+ file_sha256: package_file.file_sha256
+
}
file_view[:pipelines] = build_pipeline_infos(package_file.pipelines) if package_file.pipelines.present?
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index e13ef7a3811..71cbe28de25 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -10,10 +10,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
include BlobHelper
include ChecksCollaboration
include Gitlab::Utils::StrongMemoize
+ include Gitlab::Experiment::Dsl
presents :project
- AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon, :itemprop)
+ AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon, :itemprop, :data)
MAX_TOPICS_TO_SHOW = 3
def statistic_icon(icon_name = 'plus-square-o')
@@ -33,13 +34,15 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
def statistics_buttons(show_auto_devops_callout:)
[
+ upload_anchor_data,
readme_anchor_data,
license_anchor_data,
changelog_anchor_data,
contribution_guide_anchor_data,
autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout),
kubernetes_cluster_anchor_data,
- gitlab_ci_anchor_data
+ gitlab_ci_anchor_data,
+ integrations_anchor_data
].compact.reject(&:is_link).sort_by.with_index { |item, idx| [item.class_modifier ? 0 : 1, idx] }
end
@@ -49,12 +52,14 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
def empty_repo_statistics_buttons
[
+ upload_anchor_data,
new_file_anchor_data,
readme_anchor_data,
license_anchor_data,
changelog_anchor_data,
contribution_guide_anchor_data,
- gitlab_ci_anchor_data
+ gitlab_ci_anchor_data,
+ integrations_anchor_data
].compact.reject { |item| item.is_link }
end
@@ -154,6 +159,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def can_current_user_push_to_branch?(branch)
+ return false unless current_user
+
user_access(project).can_push_to_branch?(branch)
end
@@ -232,19 +239,50 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
empty_repo? ? nil : project_tags_path(project))
end
+ def upload_anchor_data
+ strong_memoize(:upload_anchor_data) do
+ next unless can_current_user_push_to_default_branch?
+
+ experiment(:empty_repo_upload, project: project) do |e|
+ e.use {}
+ e.try do
+ AnchorData.new(false,
+ statistic_icon('upload') + _('Upload file'),
+ '#modal-upload-blob',
+ 'js-upload-file-experiment-trigger',
+ nil,
+ nil,
+ {
+ 'target_branch' => default_branch_or_master,
+ 'original_branch' => default_branch_or_master,
+ 'can_push_code' => 'true',
+ 'path' => project_create_blob_path(project, default_branch_or_master),
+ 'project_path' => project.path
+ }
+ )
+ end
+ e.run
+ end
+ end
+ end
+
+ def empty_repo_upload_experiment?
+ upload_anchor_data.present?
+ end
+
def new_file_anchor_data
- if current_user && can_current_user_push_to_default_branch?
+ if can_current_user_push_to_default_branch?
new_file_path = empty_repo? ? ide_edit_path(project, default_branch_or_master) : project_new_blob_path(project, default_branch_or_master)
AnchorData.new(false,
statistic_icon + _('New file'),
new_file_path,
- 'dashed')
+ 'btn-dashed')
end
end
def readme_anchor_data
- if current_user && can_current_user_push_to_default_branch? && readme_path.nil?
+ if can_current_user_push_to_default_branch? && readme_path.nil?
AnchorData.new(false,
statistic_icon + _('Add README'),
empty_repo? ? add_readme_ide_path : add_readme_path)
@@ -252,13 +290,13 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
AnchorData.new(false,
statistic_icon('doc-text') + _('README'),
default_view != 'readme' ? readme_path : '#readme',
- 'default',
+ 'btn-default',
'doc-text')
end
end
def changelog_anchor_data
- if current_user && can_current_user_push_to_default_branch? && repository.changelog.blank?
+ if can_current_user_push_to_default_branch? && repository.changelog.blank?
AnchorData.new(false,
statistic_icon + _('Add CHANGELOG'),
empty_repo? ? add_changelog_ide_path : add_changelog_path)
@@ -266,7 +304,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
AnchorData.new(false,
statistic_icon('doc-text') + _('CHANGELOG'),
changelog_path,
- 'default')
+ 'btn-default')
end
end
@@ -277,11 +315,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
AnchorData.new(false,
icon + content_tag(:span, license_short_name, class: 'project-stat-value'),
license_path,
- 'default',
+ 'btn-default',
nil,
'license')
else
- if current_user && can_current_user_push_to_default_branch?
+ if can_current_user_push_to_default_branch?
AnchorData.new(false,
content_tag(:span, statistic_icon + _('Add LICENSE'), class: 'add-license-link d-flex'),
empty_repo? ? add_license_ide_path : add_license_path)
@@ -294,7 +332,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def contribution_guide_anchor_data
- if current_user && can_current_user_push_to_default_branch? && repository.contribution_guide.blank?
+ if can_current_user_push_to_default_branch? && repository.contribution_guide.blank?
AnchorData.new(false,
statistic_icon + _('Add CONTRIBUTING'),
empty_repo? ? add_contribution_guide_ide_path : add_contribution_guide_path)
@@ -302,7 +340,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
AnchorData.new(false,
statistic_icon('doc-text') + _('CONTRIBUTING'),
contribution_guide_path,
- 'default')
+ 'btn-default')
end
end
@@ -312,7 +350,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
AnchorData.new(false,
statistic_icon('settings') + _('Auto DevOps enabled'),
project_settings_ci_cd_path(project, anchor: 'autodevops-settings'),
- 'default')
+ 'btn-default')
else
AnchorData.new(false,
statistic_icon + _('Enable Auto DevOps'),
@@ -337,7 +375,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
AnchorData.new(false,
_('Kubernetes'),
cluster_link,
- 'default')
+ 'btn-default')
end
end
end
@@ -351,7 +389,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
AnchorData.new(false,
statistic_icon('doc-text') + _('CI/CD configuration'),
ci_configuration_path,
- 'default')
+ 'btn-default')
end
end
@@ -389,6 +427,25 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
private
+ def integrations_anchor_data
+ experiment(:repo_integrations_link, project: project) do |e|
+ e.exclude! unless can?(current_user, :admin_project, project)
+
+ e.use {} # nil control
+ e.try do
+ label = statistic_icon('settings') + _('Configure Integrations')
+ AnchorData.new(false, label, project_settings_integrations_path(project), nil, nil, nil, {
+ 'track-event': 'click',
+ 'track-experiment': e.name
+ })
+ end
+
+ e.run # call run so the return value will be the AnchorData (or nil)
+
+ e.track(:view, value: project.id) # track an event for the view, with project id
+ end
+ end
+
def cicd_missing?
current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled?
end
diff --git a/app/serializers/base_discussion_entity.rb b/app/serializers/base_discussion_entity.rb
index 8d4c3906847..7d3b9651b8b 100644
--- a/app/serializers/base_discussion_entity.rb
+++ b/app/serializers/base_discussion_entity.rb
@@ -40,7 +40,7 @@ class BaseDiscussionEntity < Grape::Entity
end
expose :resolve_with_issue_path do |discussion|
- new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id)
+ new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) if discussion&.project&.issues_enabled?
end
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index ea72b2b89e7..01a8a4ebea9 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -99,7 +99,7 @@ class BuildDetailsEntity < JobEntity
end
expose :available do |build|
- project.any_runners?
+ project.any_active_runners?
end
expose :settings_path, if: -> (*) { can_admin_build? } do |build|
diff --git a/app/serializers/ci/pipeline_entity.rb b/app/serializers/ci/pipeline_entity.rb
index 86f93929a5d..743643a978f 100644
--- a/app/serializers/ci/pipeline_entity.rb
+++ b/app/serializers/ci/pipeline_entity.rb
@@ -31,7 +31,7 @@ class Ci::PipelineEntity < Grape::Entity
expose :can_cancel?, as: :cancelable
expose :failure_reason?, as: :failure_reason
expose :detached_merge_request_pipeline?, as: :detached_merge_request_pipeline
- expose :merge_request_pipeline?, as: :merge_request_pipeline
+ expose :merged_result_pipeline?, as: :merge_request_pipeline
end
expose :details do
@@ -64,8 +64,8 @@ class Ci::PipelineEntity < Grape::Entity
expose :commit, using: CommitEntity
expose :merge_request_event_type, if: -> (pipeline, _) { pipeline.merge_request? }
- expose :source_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? }
- expose :target_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? }
+ expose :source_sha, if: -> (pipeline, _) { pipeline.merged_result_pipeline? }
+ expose :target_sha, if: -> (pipeline, _) { pipeline.merged_result_pipeline? }
expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
expose :failure_reason, if: -> (pipeline, _) { pipeline.failure_reason? }
diff --git a/app/serializers/current_board_entity.rb b/app/serializers/current_board_entity.rb
index f9d6691dc84..08f31bc698f 100644
--- a/app/serializers/current_board_entity.rb
+++ b/app/serializers/current_board_entity.rb
@@ -3,6 +3,8 @@
class CurrentBoardEntity < Grape::Entity
expose :id
expose :name
+ expose :hide_backlog_list
+ expose :hide_closed_list
end
CurrentBoardEntity.prepend_if_ee('EE::CurrentBoardEntity')
diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb
index edb7e10bac5..a36c4da3e83 100644
--- a/app/serializers/merge_request_user_entity.rb
+++ b/app/serializers/merge_request_user_entity.rb
@@ -4,6 +4,10 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic
include UserStatusTooltip
include RequestAwareEntity
+ def self.satisfies(*methods)
+ ->(_, options) { methods.all? { |m| options[:merge_request].try(m) } }
+ end
+
expose :can_merge do |reviewer, options|
options[:merge_request]&.can_be_merged_by?(reviewer)
end
@@ -12,11 +16,17 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic
request.current_user&.can?(:update_merge_request, options[:merge_request])
end
- expose :reviewed, if: -> (_, options) { options[:merge_request] && options[:merge_request].allows_reviewers? } do |reviewer, options|
+ expose :reviewed, if: satisfies(:present?, :allows_reviewers?) do |reviewer, options|
reviewer = options[:merge_request].find_reviewer(reviewer)
reviewer&.reviewed?
end
+
+ expose :approved, if: satisfies(:present?) do |user, options|
+ # This approach is preferred over MergeRequest#approved_by? since this
+ # makes one query per merge request, whereas #approved_by? makes one per user
+ options[:merge_request].approvals.any? { |app| app.user_id == user.id }
+ end
end
MergeRequestUserEntity.prepend_if_ee('EE::MergeRequestUserEntity')
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 560dd2ea08b..3ed7d9d8914 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -157,7 +157,7 @@ class MergeRequestWidgetEntity < Grape::Entity
end
def use_merge_base_with_merged_results?
- object.actual_head_pipeline&.merge_request_event_type == :merged_result
+ object.actual_head_pipeline&.merged_result_pipeline?
end
def head_pipeline_downloadable_path_for_report_type(file_type)
diff --git a/app/serializers/merge_requests/pipeline_entity.rb b/app/serializers/merge_requests/pipeline_entity.rb
index 8b684d4641b..c7caad0e62b 100644
--- a/app/serializers/merge_requests/pipeline_entity.rb
+++ b/app/serializers/merge_requests/pipeline_entity.rb
@@ -11,7 +11,7 @@ class MergeRequests::PipelineEntity < Grape::Entity
end
expose :flags do
- expose :merge_request_pipeline?, as: :merge_request_pipeline
+ expose :merged_result_pipeline?, as: :merge_request_pipeline
end
expose :commit, using: CommitEntity
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index ab2c6dfeace..85887e64a8b 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -42,15 +42,14 @@ class PipelineSerializer < BaseSerializer
[
:cancelable_statuses,
:latest_statuses_ordered_by_stage,
- :latest_builds_report_results,
- :manual_actions,
:retryable_builds,
- :scheduled_actions,
:stages,
:latest_statuses,
:trigger_requests,
:user,
{
+ manual_actions: :metadata,
+ scheduled_actions: :metadata,
downloadable_artifacts: {
project: [:route, { namespace: :route }],
job: []
diff --git a/app/serializers/test_suite_comparer_entity.rb b/app/serializers/test_suite_comparer_entity.rb
index aab805f9598..cfa728c01be 100644
--- a/app/serializers/test_suite_comparer_entity.rb
+++ b/app/serializers/test_suite_comparer_entity.rb
@@ -34,4 +34,16 @@ class TestSuiteComparerEntity < Grape::Entity
expose :resolved_errors, using: TestCaseEntity do |suite|
suite.limited_tests.resolved_errors
end
+
+ expose :suite_errors do |suite|
+ head_suite_error = suite.head_suite.suite_error
+ base_suite_error = suite.base_suite.suite_error
+
+ next unless head_suite_error.present? || base_suite_error.present?
+
+ {
+ head: head_suite_error,
+ base: base_suite_error
+ }
+ end
end
diff --git a/app/serializers/test_suite_summary_entity.rb b/app/serializers/test_suite_summary_entity.rb
index 6718b31a7f5..228c6e499fe 100644
--- a/app/serializers/test_suite_summary_entity.rb
+++ b/app/serializers/test_suite_summary_entity.rb
@@ -4,4 +4,6 @@ class TestSuiteSummaryEntity < TestSuiteEntity
expose :build_ids do |summary|
summary.build_ids
end
+
+ expose :suite_error
end
diff --git a/app/services/alert_management/create_alert_issue_service.rb b/app/services/alert_management/create_alert_issue_service.rb
index 58c7402c6c1..a81c2380dad 100644
--- a/app/services/alert_management/create_alert_issue_service.rb
+++ b/app/services/alert_management/create_alert_issue_service.rb
@@ -4,6 +4,9 @@ module AlertManagement
class CreateAlertIssueService
include Gitlab::Utils::StrongMemoize
+ DEFAULT_ALERT_TITLE = ::Gitlab::AlertManagement::Payload::Generic::DEFAULT_TITLE
+ DEFAULT_INCIDENT_TITLE = 'New: Incident'
+
# @param alert [AlertManagement::Alert]
# @param user [User]
def initialize(alert, user)
@@ -21,6 +24,8 @@ module AlertManagement
issue = result.payload[:issue]
return error(object_errors(alert), issue) unless associate_alert_with_issue(issue)
+ update_title_for(issue)
+
SystemNoteService.new_alert_issue(alert, issue, user)
result
@@ -50,6 +55,12 @@ module AlertManagement
alert.update(issue_id: issue.id)
end
+ def update_title_for(issue)
+ return unless issue.title == DEFAULT_ALERT_TITLE
+
+ issue.update!(title: "#{DEFAULT_INCIDENT_TITLE} #{issue.iid}")
+ end
+
def error(message, issue = nil)
ServiceResponse.error(payload: { issue: issue }, message: message)
end
diff --git a/app/services/authorized_project_update/periodic_recalculate_service.rb b/app/services/authorized_project_update/periodic_recalculate_service.rb
index 91c0f50e5e0..662d10c648b 100644
--- a/app/services/authorized_project_update/periodic_recalculate_service.rb
+++ b/app/services/authorized_project_update/periodic_recalculate_service.rb
@@ -2,8 +2,8 @@
module AuthorizedProjectUpdate
class PeriodicRecalculateService
- BATCH_SIZE = 480
- DELAY_INTERVAL = 30.seconds.to_i
+ BATCH_SIZE = 450
+ DELAY_INTERVAL = 50.seconds.to_i
def execute
# Using this approach (instead of eg. User.each_batch) keeps the arguments
diff --git a/app/services/boards/base_item_move_service.rb b/app/services/boards/base_item_move_service.rb
new file mode 100644
index 00000000000..bf3e29df54b
--- /dev/null
+++ b/app/services/boards/base_item_move_service.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module Boards
+ class BaseItemMoveService < Boards::BaseService
+ def execute(issuable)
+ issuable_modification_params = issuable_params(issuable)
+ return false if issuable_modification_params.empty?
+
+ move_single_issuable(issuable, issuable_modification_params)
+ end
+
+ private
+
+ def issuable_params(issuable)
+ attrs = {}
+
+ if move_between_lists?
+ attrs.merge!(
+ add_label_ids: add_label_ids,
+ remove_label_ids: remove_label_ids,
+ state_event: issuable_state
+ )
+ end
+
+ attrs
+ end
+
+ def move_single_issuable(issuable, issuable_modification_params)
+ ability_name = :"admin_#{issuable.to_ability_name}"
+ return unless can?(current_user, ability_name, issuable)
+
+ update(issuable, issuable_modification_params)
+ end
+
+ def move_between_lists?
+ moving_from_list.present? && moving_to_list.present? &&
+ moving_from_list != moving_to_list
+ end
+
+ def moving_from_list
+ return unless params[:from_list_id].present?
+
+ @moving_from_list ||= board.lists.id_in(params[:from_list_id]).first
+ end
+
+ def moving_to_list
+ return unless params[:to_list_id].present?
+
+ @moving_to_list ||= board.lists.id_in(params[:to_list_id]).first
+ end
+
+ def issuable_state
+ return 'reopen' if moving_from_list.closed?
+ return 'close' if moving_to_list.closed?
+ end
+
+ def add_label_ids
+ [moving_to_list.label_id].compact
+ end
+
+ def remove_label_ids
+ label_ids =
+ if moving_to_list.movable?
+ moving_from_list.label_id
+ else
+ ::Label.ids_on_board(board.id)
+ end
+
+ Array(label_ids).compact
+ end
+ end
+end
diff --git a/app/services/boards/base_items_list_service.rb b/app/services/boards/base_items_list_service.rb
index 851120ef597..5aebf216460 100644
--- a/app/services/boards/base_items_list_service.rb
+++ b/app/services/boards/base_items_list_service.rb
@@ -11,8 +11,24 @@ module Boards
ordered_items
end
+ # rubocop: disable CodeReuse/ActiveRecord
+ def metadata
+ issuables = item_model.arel_table
+ keys = metadata_fields.keys
+ # TODO: eliminate need for SQL literal fragment
+ columns = Arel.sql(metadata_fields.values_at(*keys).join(', '))
+ results = item_model.where(id: items.select(issuables[:id])).pluck(columns)
+
+ Hash[keys.zip(results.flatten)]
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
private
+ def metadata_fields
+ { size: 'COUNT(*)' }
+ end
+
def ordered_items
raise NotImplementedError
end
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 27d59e052c7..c6855f29af0 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -9,18 +9,6 @@ module Boards
IssuesFinder.valid_params
end
- # rubocop: disable CodeReuse/ActiveRecord
- def metadata
- issues = Issue.arel_table
- keys = metadata_fields.keys
- # TODO: eliminate need for SQL literal fragment
- columns = Arel.sql(metadata_fields.values_at(*keys).join(', '))
- results = Issue.where(id: items.select(issues[:id])).pluck(columns)
-
- Hash[keys.zip(results.flatten)]
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
private
def ordered_items
@@ -35,10 +23,6 @@ module Boards
@board ||= parent.boards.find(params[:board_id])
end
- def metadata_fields
- { size: 'COUNT(*)' }
- end
-
def filter_params
set_scope
set_non_archived
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
index 56a7e228b10..99374fa01ae 100644
--- a/app/services/boards/issues/move_service.rb
+++ b/app/services/boards/issues/move_service.rb
@@ -2,13 +2,8 @@
module Boards
module Issues
- class MoveService < Boards::BaseService
- def execute(issue)
- issue_modification_params = issue_params(issue)
- return false if issue_modification_params.empty?
-
- move_single_issue(issue, issue_modification_params)
- end
+ class MoveService < Boards::BaseItemMoveService
+ extend ::Gitlab::Utils::Override
def execute_multiple(issues)
return execute_multiple_empty_result if issues.empty?
@@ -16,7 +11,7 @@ module Boards
handled_issues = []
last_inserted_issue_id = nil
count = issues.each.inject(0) do |moved_count, issue|
- issue_modification_params = issue_params(issue)
+ issue_modification_params = issuable_params(issue)
next moved_count if issue_modification_params.empty?
if last_inserted_issue_id
@@ -24,7 +19,7 @@ module Boards
end
last_inserted_issue_id = issue.id
- handled_issue = move_single_issue(issue, issue_modification_params)
+ handled_issue = move_single_issuable(issue, issue_modification_params)
handled_issues << present_issue_entity(handled_issue) if handled_issue
handled_issue && handled_issue.valid? ? moved_count + 1 : moved_count
end
@@ -54,51 +49,17 @@ module Boards
move_between_ids({ move_after_id: nil, move_before_id: id })
end
- def move_single_issue(issue, issue_modification_params)
- return unless can?(current_user, :update_issue, issue)
-
- update(issue, issue_modification_params)
- end
-
def board
@board ||= parent.boards.find(params[:board_id])
end
- def move_between_lists?
- moving_from_list.present? && moving_to_list.present? &&
- moving_from_list != moving_to_list
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def moving_from_list
- return unless params[:from_list_id].present?
-
- @moving_from_list ||= board.lists.find_by(id: params[:from_list_id])
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # rubocop: disable CodeReuse/ActiveRecord
- def moving_to_list
- return unless params[:to_list_id].present?
-
- @moving_to_list ||= board.lists.find_by(id: params[:to_list_id])
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def update(issue, issue_modification_params)
::Issues::UpdateService.new(issue.project, current_user, issue_modification_params).execute(issue)
end
- def issue_params(issue)
- attrs = {}
-
- if move_between_lists?
- attrs.merge!(
- add_label_ids: add_label_ids,
- remove_label_ids: remove_label_ids,
- state_event: issue_state
- )
- end
+ override :issuable_params
+ def issuable_params(issuable)
+ attrs = super
move_between_ids = move_between_ids(params)
if move_between_ids
@@ -109,28 +70,6 @@ module Boards
attrs
end
- def issue_state
- return 'reopen' if moving_from_list.closed?
- return 'close' if moving_to_list.closed?
- end
-
- def add_label_ids
- [moving_to_list.label_id].compact
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def remove_label_ids
- label_ids =
- if moving_to_list.movable?
- moving_from_list.label_id
- else
- ::Label.on_board(board.id).pluck(:label_id)
- end
-
- Array(label_ids).compact
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def move_between_ids(move_params)
ids = [move_params[:move_after_id], move_params[:move_before_id]]
.map(&:to_i)
diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb
index e4c789c4597..3c296cde51e 100644
--- a/app/services/boards/lists/list_service.rb
+++ b/app/services/boards/lists/list_service.rb
@@ -9,7 +9,26 @@ module Boards
end
lists = board.lists.preload_associated_models
- params[:list_id].present? ? lists.where(id: params[:list_id]) : lists # rubocop: disable CodeReuse/ActiveRecord
+
+ return lists.id_in(params[:list_id]) if params[:list_id].present?
+
+ list_types = unavailable_list_types_for(board)
+ lists.without_types(list_types)
+ end
+
+ private
+
+ def unavailable_list_types_for(board)
+ hidden_lists_for(board)
+ end
+
+ def hidden_lists_for(board)
+ hidden = []
+
+ hidden << ::List.list_types[:backlog] if board.hide_backlog_list
+ hidden << ::List.list_types[:closed] if board.hide_closed_list
+
+ hidden
end
end
end
diff --git a/app/services/boards/lists/update_service.rb b/app/services/boards/lists/update_service.rb
index 4a463372c82..e2d9c371ca2 100644
--- a/app/services/boards/lists/update_service.rb
+++ b/app/services/boards/lists/update_service.rb
@@ -47,11 +47,11 @@ module Boards
end
def can_read?(list)
- Ability.allowed?(current_user, :read_list, parent)
+ Ability.allowed?(current_user, :read_issue_board_list, parent)
end
def can_admin?(list)
- Ability.allowed?(current_user, :admin_list, parent)
+ Ability.allowed?(current_user, :admin_issue_board_list, parent)
end
end
end
diff --git a/app/services/boards/update_service.rb b/app/services/boards/update_service.rb
index 0340836fd78..48c6e44d55e 100644
--- a/app/services/boards/update_service.rb
+++ b/app/services/boards/update_service.rb
@@ -2,9 +2,20 @@
module Boards
class UpdateService < Boards::BaseService
+ PERMITTED_PARAMS = %i(name hide_backlog_list hide_closed_list).freeze
+
def execute(board)
+ filter_params
board.update(params)
end
+
+ def filter_params
+ params.slice!(*permitted_params)
+ end
+
+ def permitted_params
+ PERMITTED_PARAMS
+ end
end
end
diff --git a/app/services/bulk_import_service.rb b/app/services/bulk_import_service.rb
index 29439a79afe..4e13e967dbd 100644
--- a/app/services/bulk_import_service.rb
+++ b/app/services/bulk_import_service.rb
@@ -39,7 +39,12 @@ class BulkImportService
BulkImportWorker.perform_async(bulk_import.id)
- bulk_import
+ ServiceResponse.success(payload: bulk_import)
+ rescue ActiveRecord::RecordInvalid => e
+ ServiceResponse.error(
+ message: e.message,
+ http_status: :unprocessable_entity
+ )
end
private
diff --git a/app/services/ci/build_report_result_service.rb b/app/services/ci/build_report_result_service.rb
index f138aa91236..8bdb51320f9 100644
--- a/app/services/ci/build_report_result_service.rb
+++ b/app/services/ci/build_report_result_service.rb
@@ -33,7 +33,8 @@ module Ci
failed: test_suite.failed_count,
errored: test_suite.error_count,
skipped: test_suite.skipped_count,
- success: test_suite.success_count
+ success: test_suite.success_count,
+ suite_error: test_suite.suite_error
}
}
end
diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb
index 629d85b041f..93f0338fcba 100644
--- a/app/services/ci/create_downstream_pipeline_service.rb
+++ b/app/services/ci/create_downstream_pipeline_service.rb
@@ -43,7 +43,7 @@ module Ci
private
def update_bridge_status!(bridge, pipeline)
- Gitlab::OptimisticLocking.retry_lock(bridge) do |subject|
+ Gitlab::OptimisticLocking.retry_lock(bridge, name: 'create_downstream_pipeline_update_bridge_status') do |subject|
if pipeline.created_successfully?
# If bridge uses `strategy:depend` we leave it running
# and update the status when the downstream pipeline completes.
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index dc42411dfa1..0fd47e625fd 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -122,7 +122,9 @@ module Ci
end
def record_conversion_event
- Experiments::RecordConversionEventWorker.perform_async(:ci_syntax_templates, current_user.id)
+ return unless project.namespace.recent?
+
+ Experiments::RecordConversionEventWorker.perform_async(:ci_syntax_templates_b, current_user.id)
end
def create_namespace_onboarding_action
diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb
index 7d8a3c17abe..d91cfb3cc82 100644
--- a/app/services/ci/destroy_expired_job_artifacts_service.rb
+++ b/app/services/ci/destroy_expired_job_artifacts_service.rb
@@ -4,7 +4,6 @@ module Ci
class DestroyExpiredJobArtifactsService
include ::Gitlab::ExclusiveLeaseHelpers
include ::Gitlab::LoopHelpers
- include ::Gitlab::Utils::StrongMemoize
BATCH_SIZE = 100
LOOP_TIMEOUT = 5.minutes
@@ -34,50 +33,20 @@ module Ci
def destroy_job_artifacts_with_slow_iteration(start_at)
Ci::JobArtifact.expired_before(start_at).each_batch(of: BATCH_SIZE, column: :expire_at, order: :desc) do |relation, index|
- artifacts = relation.unlocked.with_destroy_preloads.to_a
+ # For performance reasons, join with ci_pipelines after the batch is queried.
+ # See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47496
+ artifacts = relation.unlocked
+
+ service_response = destroy_batch_async(artifacts)
+ @removed_artifacts_count += service_response[:destroyed_artifacts_count]
- parallel_destroy_batch(artifacts) if artifacts.any?
break if loop_timeout?(start_at)
break if index >= LOOP_LIMIT
end
end
- def parallel_destroy_batch(job_artifacts)
- Ci::DeletedObject.transaction do
- Ci::DeletedObject.bulk_import(job_artifacts)
- Ci::JobArtifact.id_in(job_artifacts.map(&:id)).delete_all
- destroy_related_records_for(job_artifacts)
- end
-
- # This is executed outside of the transaction because it depends on Redis
- update_project_statistics_for(job_artifacts)
- increment_monitoring_statistics(job_artifacts.size)
- end
-
- # This method is implemented in EE and it must do only database work
- def destroy_related_records_for(job_artifacts); end
-
- def update_project_statistics_for(job_artifacts)
- artifacts_by_project = job_artifacts.group_by(&:project)
- artifacts_by_project.each do |project, artifacts|
- delta = -artifacts.sum { |artifact| artifact.size.to_i }
- ProjectStatistics.increment_statistic(
- project, Ci::JobArtifact.project_statistics_name, delta)
- end
- end
-
- def increment_monitoring_statistics(size)
- destroyed_artifacts_counter.increment({}, size)
- @removed_artifacts_count += size
- end
-
- def destroyed_artifacts_counter
- strong_memoize(:destroyed_artifacts_counter) do
- name = :destroyed_job_artifacts_count_total
- comment = 'Counter of destroyed expired job artifacts'
-
- ::Gitlab::Metrics.counter(name, comment)
- end
+ def destroy_batch_async(artifacts)
+ Ci::JobArtifactsDestroyBatchService.new(artifacts).execute
end
def loop_timeout?(start_at)
@@ -85,5 +54,3 @@ module Ci
end
end
end
-
-Ci::DestroyExpiredJobArtifactsService.prepend_if_ee('EE::Ci::DestroyExpiredJobArtifactsService')
diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb
index 8343e0f8cd0..2ae60907dab 100644
--- a/app/services/ci/expire_pipeline_cache_service.rb
+++ b/app/services/ci/expire_pipeline_cache_service.rb
@@ -2,6 +2,11 @@
module Ci
class ExpirePipelineCacheService
+ class UrlHelpers
+ include ::Gitlab::Routing
+ include ::GitlabRoutingHelper
+ end
+
def execute(pipeline, delete: false)
store = Gitlab::EtagCaching::Store.new
@@ -17,27 +22,27 @@ module Ci
private
def project_pipelines_path(project)
- Gitlab::Routing.url_helpers.project_pipelines_path(project, format: :json)
+ url_helpers.project_pipelines_path(project, format: :json)
end
def project_pipeline_path(project, pipeline)
- Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json)
+ url_helpers.project_pipeline_path(project, pipeline, format: :json)
end
def commit_pipelines_path(project, commit)
- Gitlab::Routing.url_helpers.pipelines_project_commit_path(project, commit.id, format: :json)
+ url_helpers.pipelines_project_commit_path(project, commit.id, format: :json)
end
def new_merge_request_pipelines_path(project)
- Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json)
+ url_helpers.project_new_merge_request_path(project, format: :json)
end
def pipelines_project_merge_request_path(merge_request)
- Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json)
+ url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json)
end
def merge_request_widget_path(merge_request)
- Gitlab::Routing.url_helpers.cached_widget_project_json_merge_request_path(merge_request.project, merge_request, format: :json)
+ url_helpers.cached_widget_project_json_merge_request_path(merge_request.project, merge_request, format: :json)
end
def each_pipelines_merge_request_path(pipeline)
@@ -47,6 +52,10 @@ module Ci
end
end
+ def graphql_pipeline_path(pipeline)
+ url_helpers.graphql_etag_pipeline_path(pipeline)
+ end
+
# Updates ETag caches of a pipeline.
#
# This logic resides in a separate method so that EE can more easily extend
@@ -58,14 +67,20 @@ module Ci
project = pipeline.project
store.touch(project_pipelines_path(project))
- store.touch(project_pipeline_path(project, pipeline))
store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil?
store.touch(new_merge_request_pipelines_path(project))
each_pipelines_merge_request_path(pipeline) do |path|
store.touch(path)
end
+
+ pipeline.self_with_ancestors_and_descendants.each do |relative_pipeline|
+ store.touch(project_pipeline_path(relative_pipeline.project, relative_pipeline))
+ store.touch(graphql_pipeline_path(relative_pipeline))
+ end
+ end
+
+ def url_helpers
+ @url_helpers ||= UrlHelpers.new
end
end
end
-
-Ci::ExpirePipelineCacheService.prepend_if_ee('EE::Ci::ExpirePipelineCacheService')
diff --git a/app/services/ci/job_artifacts_destroy_batch_service.rb b/app/services/ci/job_artifacts_destroy_batch_service.rb
new file mode 100644
index 00000000000..f8ece27fe86
--- /dev/null
+++ b/app/services/ci/job_artifacts_destroy_batch_service.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module Ci
+ class JobArtifactsDestroyBatchService
+ include BaseServiceUtility
+ include ::Gitlab::Utils::StrongMemoize
+
+ # Danger: Private - Should only be called in Ci Services that pass a batch of job artifacts
+ # Not for use outsie of the ci namespace
+
+ # Adds the passed batch of job artifacts to the `ci_deleted_objects` table
+ # for asyncronous destruction of the objects in Object Storage via the `Ci::DeleteObjectsService`
+ # and then deletes the batch of related `ci_job_artifacts` records.
+ # Params:
+ # +job_artifacts+:: A relation of job artifacts to destroy (fewer than MAX_JOB_ARTIFACT_BATCH_SIZE)
+ # +pick_up_at+:: When to pick up for deletion of files
+ # Returns:
+ # +Hash+:: A hash with status and destroyed_artifacts_count keys
+ def initialize(job_artifacts, pick_up_at: nil)
+ @job_artifacts = job_artifacts.with_destroy_preloads.to_a
+ @pick_up_at = pick_up_at
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute
+ return success(destroyed_artifacts_count: artifacts_count) if @job_artifacts.empty?
+
+ Ci::DeletedObject.transaction do
+ Ci::DeletedObject.bulk_import(@job_artifacts, @pick_up_at)
+ Ci::JobArtifact.id_in(@job_artifacts.map(&:id)).delete_all
+ destroy_related_records(@job_artifacts)
+ end
+
+ # This is executed outside of the transaction because it depends on Redis
+ update_project_statistics
+ increment_monitoring_statistics(artifacts_count)
+
+ success(destroyed_artifacts_count: artifacts_count)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ # This method is implemented in EE and it must do only database work
+ def destroy_related_records(artifacts); end
+
+ def update_project_statistics
+ artifacts_by_project = @job_artifacts.group_by(&:project)
+ artifacts_by_project.each do |project, artifacts|
+ delta = -artifacts.sum { |artifact| artifact.size.to_i }
+ ProjectStatistics.increment_statistic(
+ project, Ci::JobArtifact.project_statistics_name, delta)
+ end
+ end
+
+ def increment_monitoring_statistics(size)
+ metrics.increment_destroyed_artifacts(size)
+ end
+
+ def metrics
+ @metrics ||= ::Gitlab::Ci::Artifacts::Metrics.new
+ end
+
+ def artifacts_count
+ strong_memoize(:artifacts_count) do
+ @job_artifacts.count
+ end
+ end
+ end
+end
+
+Ci::JobArtifactsDestroyBatchService.prepend_if_ee('EE::Ci::JobArtifactsDestroyBatchService')
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
index a23d5d8941a..236d660d829 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -53,7 +53,7 @@ module Ci
end
def update_processables!(ids)
- created_processables = pipeline.processables.for_ids(ids)
+ created_processables = pipeline.processables.id_in(ids)
.with_project_preload
.created
.latest
@@ -80,7 +80,7 @@ module Ci
return unless Ci::HasStatus::COMPLETED_STATUSES.include?(status)
# transition status if possible
- Gitlab::OptimisticLocking.retry_lock(processable) do |subject|
+ Gitlab::OptimisticLocking.retry_lock(processable, name: 'atomic_processing_update_processable') do |subject|
Ci::ProcessBuildService.new(project, subject.user)
.execute(subject, status)
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 aeabbb99468..35818e2cf3d 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
@@ -78,7 +78,7 @@ module Ci
def status_for_array(statuses, dag:)
result = Gitlab::Ci::Status::Composite
- .new(statuses, dag: dag)
+ .new(statuses, dag: dag, project: pipeline.project)
.status
result || 'success'
end
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index 678b386fbbf..970652b4da3 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -30,6 +30,8 @@ module Ci
# this updates only when there are data that needs to be updated, there are two groups with no retried flag
# rubocop: disable CodeReuse/ActiveRecord
def update_retried
+ return if Feature.enabled?(:ci_remove_update_retried_from_process_pipeline, pipeline.project, default_enabled: :yaml)
+
# find the latest builds for each name
latest_statuses = pipeline.latest_statuses
.group(:name)
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 59691fe4ef3..ed9e44d60f1 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -4,21 +4,85 @@ module Ci
# This class responsible for assigning
# proper pending build to runner on runner API request
class RegisterJobService
- attr_reader :runner
+ attr_reader :runner, :metrics
- JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30, 60, 300, 900, 1800, 3600].freeze
- JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET = 5.freeze
- METRICS_SHARD_TAG_PREFIX = 'metrics_shard::'
- DEFAULT_METRICS_SHARD = 'default'
+ TEMPORARY_LOCK_TIMEOUT = 3.seconds
Result = Struct.new(:build, :build_json, :valid?)
+ MAX_QUEUE_DEPTH = 50
+
def initialize(runner)
@runner = runner
+ @metrics = ::Gitlab::Ci::Queue::Metrics.new(runner)
end
- # rubocop: disable CodeReuse/ActiveRecord
def execute(params = {})
+ @metrics.increment_queue_operation(:queue_attempt)
+
+ @metrics.observe_queue_time do
+ process_queue(params)
+ end
+ end
+
+ private
+
+ def process_queue(params)
+ valid = true
+ depth = 0
+
+ each_build(params) do |build|
+ depth += 1
+ @metrics.increment_queue_operation(:queue_iteration)
+
+ if depth > max_queue_depth
+ @metrics.increment_queue_operation(:queue_depth_limit)
+
+ valid = false
+
+ break
+ end
+
+ # We read builds from replicas
+ # It is likely that some other concurrent connection is processing
+ # a given build at a given moment. To avoid an expensive compute
+ # we perform an exclusive lease on Redis to acquire a build temporarily
+ unless acquire_temporary_lock(build.id)
+ @metrics.increment_queue_operation(:build_temporary_locked)
+
+ # We failed to acquire lock
+ # - our queue is not complete as some resources are locked temporarily
+ # - we need to re-process it again to ensure that all builds are handled
+ valid = false
+
+ next
+ end
+
+ result = process_build(build, params)
+ next unless result
+
+ if result.valid?
+ @metrics.register_success(result.build)
+ @metrics.observe_queue_depth(:found, depth)
+
+ return result # rubocop:disable Cop/AvoidReturnFromBlocks
+ else
+ # The usage of valid: is described in
+ # handling of ActiveRecord::StaleObjectError
+ valid = false
+ end
+ end
+
+ @metrics.increment_queue_operation(:queue_conflict) unless valid
+ @metrics.observe_queue_depth(:conflict, depth) unless valid
+ @metrics.observe_queue_depth(:not_found, depth) if valid
+ @metrics.register_failure
+
+ Result.new(nil, nil, valid)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def each_build(params, &blk)
builds =
if runner.instance_type?
builds_for_shared_runner
@@ -28,8 +92,6 @@ module Ci
builds_for_project_runner
end
- valid = true
-
# pick builds that does not have other tags than runner's one
builds = builds.matches_tag_ids(runner.tags.ids)
@@ -43,37 +105,42 @@ module Ci
builds = builds.queued_before(params[:job_age].seconds.ago)
end
- builds.each do |build|
- result = process_build(build, params)
- next unless result
+ if Feature.enabled?(:ci_register_job_service_one_by_one, runner)
+ build_ids = builds.pluck(:id)
- if result.valid?
- register_success(result.build)
+ @metrics.observe_queue_size(-> { build_ids.size })
- return result
- else
- # The usage of valid: is described in
- # handling of ActiveRecord::StaleObjectError
- valid = false
+ build_ids.each do |build_id|
+ yield Ci::Build.find(build_id)
end
- end
+ else
+ @metrics.observe_queue_size(-> { builds.to_a.size })
- register_failure
- Result.new(nil, nil, valid)
+ builds.each(&blk)
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
- private
-
def process_build(build, params)
- return unless runner.can_pick?(build)
+ unless build.pending?
+ @metrics.increment_queue_operation(:build_not_pending)
+ return
+ end
+
+ if runner.can_pick?(build)
+ @metrics.increment_queue_operation(:build_can_pick)
+ else
+ @metrics.increment_queue_operation(:build_not_pick)
+
+ return
+ end
# 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)
present_build!(build)
end
- rescue StateMachines::InvalidTransition, ActiveRecord::StaleObjectError
+ rescue ActiveRecord::StaleObjectError
# We are looping to find another build that is not conflicting
# It also indicates that this build can be picked and passed to runner.
# If we don't do it, basically a bunch of runners would be competing for a build
@@ -83,8 +150,16 @@ 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.
+ @metrics.increment_queue_operation(:build_conflict_lock)
+
+ Result.new(nil, nil, false)
+ rescue StateMachines::InvalidTransition
+ @metrics.increment_queue_operation(:build_conflict_transition)
+
Result.new(nil, nil, false)
rescue => ex
+ @metrics.increment_queue_operation(:build_conflict_exception)
+
# If an error (e.g. GRPC::DeadlineExceeded) occurred constructing
# the result, consider this as a failure to be retried.
scheduler_failure!(build)
@@ -94,6 +169,16 @@ module Ci
nil
end
+ def max_queue_depth
+ @max_queue_depth ||= begin
+ if Feature.enabled?(:gitlab_ci_builds_queue_limit, runner, default_enabled: false)
+ MAX_QUEUE_DEPTH
+ else
+ ::Gitlab::Database::MAX_INT_VALUE
+ end
+ end
+ end
+
# Force variables evaluation to occur now
def present_build!(build)
# We need to use the presenter here because Gitaly calls in the presenter
@@ -110,16 +195,30 @@ module Ci
failure_reason, _ = pre_assign_runner_checks.find { |_, check| check.call(build, params) }
if failure_reason
+ @metrics.increment_queue_operation(:runner_pre_assign_checks_failed)
+
build.drop!(failure_reason)
else
+ @metrics.increment_queue_operation(:runner_pre_assign_checks_success)
+
build.run!
end
!failure_reason
end
+ def acquire_temporary_lock(build_id)
+ return true unless Feature.enabled?(:ci_register_job_temporary_lock, runner)
+
+ key = "build/register/#{build_id}"
+
+ Gitlab::ExclusiveLease
+ .new(key, timeout: TEMPORARY_LOCK_TIMEOUT.to_i)
+ .try_obtain
+ end
+
def scheduler_failure!(build)
- Gitlab::OptimisticLocking.retry_lock(build, 3) do |subject|
+ Gitlab::OptimisticLocking.retry_lock(build, 3, name: 'register_job_scheduler_failure') do |subject|
subject.drop!(:scheduler_failure)
end
rescue => ex
@@ -189,48 +288,6 @@ module Ci
builds
end
- def register_failure
- failed_attempt_counter.increment
- attempt_counter.increment
- end
-
- def register_success(job)
- labels = { shared_runner: runner.instance_type?,
- jobs_running_for_project: jobs_running_for_project(job),
- shard: DEFAULT_METRICS_SHARD }
-
- if runner.instance_type?
- shard = runner.tag_list.sort.find { |name| name.starts_with?(METRICS_SHARD_TAG_PREFIX) }
- labels[:shard] = shard.gsub(METRICS_SHARD_TAG_PREFIX, '') if shard
- end
-
- job_queue_duration_seconds.observe(labels, Time.current - job.queued_at) unless job.queued_at.nil?
- attempt_counter.increment
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def jobs_running_for_project(job)
- return '+Inf' unless runner.instance_type?
-
- # excluding currently started job
- running_jobs_count = job.project.builds.running.where(runner: Ci::Runner.instance_type)
- .limit(JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET + 1).count - 1
- running_jobs_count < JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET ? running_jobs_count : "#{JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET}+"
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def failed_attempt_counter
- @failed_attempt_counter ||= Gitlab::Metrics.counter(:job_register_attempts_failed_total, "Counts the times a runner tries to register a job")
- end
-
- def attempt_counter
- @attempt_counter ||= Gitlab::Metrics.counter(:job_register_attempts_total, "Counts the times a runner tries to register a job")
- end
-
- def job_queue_duration_seconds
- @job_queue_duration_seconds ||= Gitlab::Metrics.histogram(:job_queue_duration_seconds, 'Request handling execution time', {}, JOB_QUEUE_DURATION_SECONDS_BUCKETS)
- end
-
def pre_assign_runner_checks
{
missing_dependency_failure: -> (build, _) { !build.has_valid_build_dependencies? },
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index e5e79f70616..b2c5249a0c7 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -19,7 +19,7 @@ module Ci
mark_subsequent_stages_as_processable(build)
build.pipeline.reset_ancestor_bridges!
- Gitlab::OptimisticLocking.retry_lock(new_build, &:enqueue)
+ Gitlab::OptimisticLocking.retry_lock(new_build, name: 'retry_build', &:enqueue)
MergeRequests::AddTodoWhenBuildFailsService
.new(project, current_user)
@@ -68,7 +68,7 @@ module Ci
def mark_subsequent_stages_as_processable(build)
build.pipeline.processables.skipped.after_stage(build.stage_idx).find_each do |skipped|
- retry_optimistic_lock(skipped) { |build| build.process(current_user) }
+ retry_optimistic_lock(skipped, name: 'ci_retry_build_mark_subsequent_stages') { |build| build.process(current_user) }
end
end
end
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index dea4bf73a4c..90ee7b9b3ba 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -23,7 +23,7 @@ module Ci
end
pipeline.builds.latest.skipped.find_each do |skipped|
- retry_optimistic_lock(skipped) { |build| build.process(current_user) }
+ retry_optimistic_lock(skipped, name: 'ci_retry_pipeline') { |build| build.process(current_user) }
end
pipeline.reset_ancestor_bridges!
diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb
index 241eba733ea..cf629b879b3 100644
--- a/app/services/ci/update_build_queue_service.rb
+++ b/app/services/ci/update_build_queue_service.rb
@@ -2,16 +2,21 @@
module Ci
class UpdateBuildQueueService
- def execute(build)
- tick_for(build, build.project.all_runners)
+ def execute(build, metrics = ::Gitlab::Ci::Queue::Metrics)
+ tick_for(build, build.project.all_runners, metrics)
end
private
- def tick_for(build, runners)
+ def tick_for(build, runners, metrics)
runners = runners.with_recent_runner_queue
+ runners = runners.with_tags if Feature.enabled?(:ci_preload_runner_tags, default_enabled: :yaml)
+
+ metrics.observe_active_runners(-> { runners.to_a.size })
runners.each do |runner|
+ metrics.increment_runner_tick(runner)
+
runner.pick_build!(build)
end
end
diff --git a/app/services/clusters/kubernetes.rb b/app/services/clusters/kubernetes.rb
index 819ac4c8464..ef549b56946 100644
--- a/app/services/clusters/kubernetes.rb
+++ b/app/services/clusters/kubernetes.rb
@@ -14,5 +14,7 @@ module Clusters
GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME = 'gitlab-crossplane-database-rolebinding'
KNATIVE_SERVING_NAMESPACE = 'knative-serving'
ISTIO_SYSTEM_NAMESPACE = 'istio-system'
+ GITLAB_CILIUM_ROLE_NAME = 'gitlab-cilium-role'
+ GITLAB_CILIUM_ROLE_BINDING_NAME = 'gitlab-cilium-rolebinding'
end
end
diff --git a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
index eabc428d0d2..ecad33fc7c0 100644
--- a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
+++ b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
@@ -53,6 +53,8 @@ module Clusters
create_or_update_knative_serving_role_binding
create_or_update_crossplane_database_role
create_or_update_crossplane_database_role_binding
+ create_or_update_cilium_role
+ create_or_update_cilium_role_binding
end
private
@@ -97,6 +99,14 @@ module Clusters
kubeclient.update_role_binding(crossplane_database_role_binding_resource)
end
+ def create_or_update_cilium_role
+ kubeclient.update_role(cilium_role_resource)
+ end
+
+ def create_or_update_cilium_role_binding
+ kubeclient.update_role_binding(cilium_role_binding_resource)
+ end
+
def service_account_resource
Gitlab::Kubernetes::ServiceAccount.new(
service_account_name,
@@ -175,6 +185,28 @@ module Clusters
service_account_name: service_account_name
).generate
end
+
+ def cilium_role_resource
+ Gitlab::Kubernetes::Role.new(
+ name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME,
+ namespace: service_account_namespace,
+ rules: [{
+ apiGroups: %w(cilium.io),
+ resources: %w(ciliumnetworkpolicies),
+ verbs: %w(get list create update patch)
+ }]
+ ).generate
+ end
+
+ def cilium_role_binding_resource
+ Gitlab::Kubernetes::RoleBinding.new(
+ name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME,
+ role_name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME,
+ role_kind: :Role,
+ namespace: service_account_namespace,
+ service_account_name: service_account_name
+ ).generate
+ end
end
end
end
diff --git a/app/services/concerns/alert_management/alert_processing.rb b/app/services/concerns/alert_management/alert_processing.rb
index 9b15c5d7b4b..7b6f681fe3e 100644
--- a/app/services/concerns/alert_management/alert_processing.rb
+++ b/app/services/concerns/alert_management/alert_processing.rb
@@ -41,14 +41,21 @@ module AlertManagement
end
def process_resolved_alert
+ SystemNoteService.log_resolving_alert(alert, alert_source)
+
return unless auto_close_incident?
- return close_issue(alert.issue) if alert.resolve(incoming_payload.ends_at)
- logger.warn(
- message: 'Unable to update AlertManagement::Alert status to resolved',
- project_id: project.id,
- alert_id: alert.id
- )
+ if alert.resolve(incoming_payload.ends_at)
+ SystemNoteService.change_alert_status(alert, User.alert_bot)
+
+ close_issue(alert.issue)
+ else
+ logger.warn(
+ message: 'Unable to update AlertManagement::Alert status to resolved',
+ project_id: project.id,
+ alert_id: alert.id
+ )
+ end
end
def process_firing_alert
diff --git a/app/services/dependency_proxy/find_or_create_manifest_service.rb b/app/services/dependency_proxy/find_or_create_manifest_service.rb
index 6b46f5e4c59..ee608d715aa 100644
--- a/app/services/dependency_proxy/find_or_create_manifest_service.rb
+++ b/app/services/dependency_proxy/find_or_create_manifest_service.rb
@@ -13,7 +13,7 @@ module DependencyProxy
def execute
@manifest = @group.dependency_proxy_manifests
- .find_or_initialize_by_file_name(@file_name)
+ .find_or_initialize_by_file_name_or_digest(file_name: @file_name, digest: @tag)
head_result = DependencyProxy::HeadManifestService.new(@image, @tag, @token).execute
@@ -30,6 +30,7 @@ module DependencyProxy
def pull_new_manifest
DependencyProxy::PullManifestService.new(@image, @tag, @token).execute_with_manifest do |new_manifest|
@manifest.update!(
+ content_type: new_manifest[:content_type],
digest: new_manifest[:digest],
file: new_manifest[:file],
size: new_manifest[:file].size
@@ -38,7 +39,9 @@ module DependencyProxy
end
def cached_manifest_matches?(head_result)
- @manifest && @manifest.digest == head_result[:digest]
+ return false if head_result[:status] == :error
+
+ @manifest && @manifest.digest == head_result[:digest] && @manifest.content_type == head_result[:content_type]
end
def respond
diff --git a/app/services/dependency_proxy/head_manifest_service.rb b/app/services/dependency_proxy/head_manifest_service.rb
index 87d9c417c98..ecc3eb77399 100644
--- a/app/services/dependency_proxy/head_manifest_service.rb
+++ b/app/services/dependency_proxy/head_manifest_service.rb
@@ -2,6 +2,8 @@
module DependencyProxy
class HeadManifestService < DependencyProxy::BaseService
+ ACCEPT_HEADERS = ::ContainerRegistry::Client::ACCEPTED_TYPES.join(',')
+
def initialize(image, tag, token)
@image = image
@tag = tag
@@ -9,10 +11,10 @@ module DependencyProxy
end
def execute
- response = Gitlab::HTTP.head(manifest_url, headers: auth_headers)
+ response = Gitlab::HTTP.head(manifest_url, headers: auth_headers.merge(Accept: ACCEPT_HEADERS))
if response.success?
- success(digest: response.headers['docker-content-digest'])
+ success(digest: response.headers['docker-content-digest'], content_type: response.headers['content-type'])
else
error(response.body, response.code)
end
diff --git a/app/services/dependency_proxy/pull_manifest_service.rb b/app/services/dependency_proxy/pull_manifest_service.rb
index 5c804489fd1..737414c396e 100644
--- a/app/services/dependency_proxy/pull_manifest_service.rb
+++ b/app/services/dependency_proxy/pull_manifest_service.rb
@@ -11,7 +11,7 @@ module DependencyProxy
def execute_with_manifest
raise ArgumentError, 'Block must be provided' unless block_given?
- response = Gitlab::HTTP.get(manifest_url, headers: auth_headers)
+ response = Gitlab::HTTP.get(manifest_url, headers: auth_headers.merge(Accept: ::ContainerRegistry::Client::ACCEPTED_TYPES.join(',')))
if response.success?
file = Tempfile.new
@@ -20,7 +20,7 @@ module DependencyProxy
file.write(response)
file.flush
- yield(success(file: file, digest: response.headers['docker-content-digest']))
+ yield(success(file: file, digest: response.headers['docker-content-digest'], content_type: response.headers['content-type']))
ensure
file.close
file.unlink
diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb
index e765d2484ea..9283a5c1279 100644
--- a/app/services/deployments/older_deployments_drop_service.rb
+++ b/app/services/deployments/older_deployments_drop_service.rb
@@ -12,7 +12,7 @@ module Deployments
return unless @deployment&.running?
older_deployments.find_each do |older_deployment|
- Gitlab::OptimisticLocking.retry_lock(older_deployment.deployable) do |deployable|
+ Gitlab::OptimisticLocking.retry_lock(older_deployment.deployable, name: 'older_deployments_drop') do |deployable|
deployable.drop(:forward_deployment_failure)
end
rescue => e
diff --git a/app/services/deployments/update_environment_service.rb b/app/services/deployments/update_environment_service.rb
index e9c2f41f626..98fedb9f699 100644
--- a/app/services/deployments/update_environment_service.rb
+++ b/app/services/deployments/update_environment_service.rb
@@ -25,11 +25,10 @@ module Deployments
def update_environment(deployment)
ActiveRecord::Base.transaction do
- if (url = expanded_environment_url)
- environment.external_url = url
- end
-
+ # Renew attributes at update
+ renew_external_url
renew_auto_stop_in
+ renew_deployment_tier
environment.fire_state_event(action)
if environment.save && !environment.stopped?
@@ -56,11 +55,25 @@ module Deployments
environment_options[:action] || 'start'
end
+ def renew_external_url
+ if (url = expanded_environment_url)
+ environment.external_url = url
+ end
+ end
+
def renew_auto_stop_in
return unless deployable
environment.auto_stop_in = deployable.environment_auto_stop_in
end
+
+ def renew_deployment_tier
+ return unless deployable
+
+ if (tier = deployable.environment_deployment_tier)
+ environment.tier = tier
+ end
+ end
end
end
diff --git a/app/services/environments/schedule_to_delete_review_apps_service.rb b/app/services/environments/schedule_to_delete_review_apps_service.rb
new file mode 100644
index 00000000000..b3b86689748
--- /dev/null
+++ b/app/services/environments/schedule_to_delete_review_apps_service.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+module Environments
+ class ScheduleToDeleteReviewAppsService < ::BaseService
+ include ::Gitlab::ExclusiveLeaseHelpers
+
+ EXCLUSIVE_LOCK_KEY_BASE = 'environments:delete_review_apps:lock'
+ LOCK_TIMEOUT = 2.minutes
+
+ def execute
+ if validation_error = validate
+ return validation_error
+ end
+
+ mark_deletable_environments
+ end
+
+ private
+
+ def key
+ "#{EXCLUSIVE_LOCK_KEY_BASE}:#{project.id}"
+ end
+
+ def dry_run?
+ return true if params[:dry_run].nil?
+
+ params[:dry_run]
+ end
+
+ def validate
+ return if can?(current_user, :destroy_environment, project)
+
+ Result.new(error_message: "You do not have permission to destroy environments in this project", status: :unauthorized)
+ end
+
+ def mark_deletable_environments
+ in_lock(key, ttl: LOCK_TIMEOUT, retries: 1) do
+ unsafe_mark_deletable_environments
+ end
+
+ rescue FailedToObtainLockError
+ Result.new(error_message: "Another process is already processing a delete request. Please retry later.", status: :conflict)
+ end
+
+ def unsafe_mark_deletable_environments
+ result = Result.new
+ environments = project.environments
+ .not_scheduled_for_deletion
+ .stopped_review_apps(params[:before], params[:limit])
+
+ # Check if the actor has write permission to a potentially-protected environment.
+ deletable, failed = *environments.partition { |env| current_user.can?(:destroy_environment, env) }
+
+ if deletable.any? && failed.empty?
+ mark_for_deletion(deletable) unless dry_run?
+ result.set_status(:ok)
+ result.set_scheduled_entries(deletable)
+ else
+ result.set_status(
+ :bad_request,
+ error_message: "Failed to authorize deletions for some or all of the environments. Ask someone with more permissions to delete the environments."
+ )
+
+ result.set_unprocessable_entries(failed)
+ end
+
+ result
+ end
+
+ def mark_for_deletion(deletable_environments)
+ Environment.for_id(deletable_environments).schedule_to_delete
+ end
+
+ class Result
+ attr_accessor :scheduled_entries, :unprocessable_entries, :error_message, :status
+
+ def initialize(scheduled_entries: [], unprocessable_entries: [], error_message: nil, status: nil)
+ self.scheduled_entries = scheduled_entries
+ self.unprocessable_entries = unprocessable_entries
+ self.error_message = error_message
+ self.status = status
+ end
+
+ def success?
+ status == :ok
+ end
+
+ def set_status(status, error_message: nil)
+ self.status = status
+ self.error_message = error_message
+ end
+
+ def set_scheduled_entries(entries)
+ self.scheduled_entries = entries
+ end
+
+ def set_unprocessable_entries(entries)
+ self.unprocessable_entries = entries
+ end
+ end
+ end
+end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 06a3b31c665..3ead2323588 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -33,7 +33,7 @@ module Groups
Group.transaction do
if @group.save
@group.add_owner(current_user)
- @group.create_namespace_settings
+ @group.create_namespace_settings unless @group.namespace_settings
Service.create_from_active_default_integrations(@group, :group_id)
OnboardingProgress.onboard(@group)
end
diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb
index c7107e2fa56..a27330d1104 100644
--- a/app/services/groups/destroy_service.rb
+++ b/app/services/groups/destroy_service.rb
@@ -31,11 +31,11 @@ module Groups
# If any other groups are shared with the group that is being destroyed,
# we should specifically trigger update of all project authorizations
- # for users that are the members of this group.
+ # for users that are the direct members of this group.
# If not, the project authorization records of these users to projects within the shared groups
# will never be removed, causing inconsistencies with access permissions.
if any_other_groups_are_shared_with_this_group?
- user_ids_for_project_authorizations_refresh = group.user_ids_for_project_authorizations
+ user_ids_for_project_authorizations_refresh = group.users_ids_of_direct_members
end
group.destroy
diff --git a/app/services/groups/group_links/create_service.rb b/app/services/groups/group_links/create_service.rb
index 589ac7ccde7..57c746c3841 100644
--- a/app/services/groups/group_links/create_service.rb
+++ b/app/services/groups/group_links/create_service.rb
@@ -18,7 +18,7 @@ module Groups
)
if link.save
- group.refresh_members_authorized_projects
+ group.refresh_members_authorized_projects(direct_members_only: true)
success(link: link)
else
error(link.errors.full_messages.to_sentence, 409)
diff --git a/app/services/groups/group_links/destroy_service.rb b/app/services/groups/group_links/destroy_service.rb
index b0d496ae78c..05504a80f46 100644
--- a/app/services/groups/group_links/destroy_service.rb
+++ b/app/services/groups/group_links/destroy_service.rb
@@ -16,7 +16,7 @@ module Groups
groups_to_refresh = links.map(&:shared_with_group)
groups_to_refresh.uniq.each do |group|
- group.refresh_members_authorized_projects
+ group.refresh_members_authorized_projects(direct_members_only: true)
end
else
Gitlab::AppLogger.info(
diff --git a/app/services/groups/group_links/update_service.rb b/app/services/groups/group_links/update_service.rb
index 71b52cb616c..3703d535482 100644
--- a/app/services/groups/group_links/update_service.rb
+++ b/app/services/groups/group_links/update_service.rb
@@ -13,7 +13,7 @@ module Groups
group_link.update!(group_link_params)
if requires_authorization_refresh?(group_link_params)
- group_link.shared_with_group.refresh_members_authorized_projects
+ group_link.shared_with_group.refresh_members_authorized_projects(direct_members_only: true)
end
end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index 847c5eb4397..3ee5a185f42 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -2,6 +2,9 @@
module Import
class GithubService < Import::BaseService
+ include ActiveSupport::NumberHelper
+ include Gitlab::Utils::StrongMemoize
+
attr_accessor :client
attr_reader :params, :current_user
@@ -14,6 +17,10 @@ module Import
return error(_('This namespace has already been taken! Please choose another one.'), :unprocessable_entity)
end
+ if oversized?
+ return error(oversize_error_message, :unprocessable_entity)
+ end
+
project = create_project(access_params, provider)
if project.persisted?
@@ -32,7 +39,8 @@ module Import
target_namespace,
current_user,
type: provider,
- **access_params).execute(extra_project_attrs)
+ **access_params
+ ).execute(extra_project_attrs)
end
def repo
@@ -55,6 +63,30 @@ module Import
{}
end
+ def oversized?
+ repository_size_limit > 0 && repo.size > repository_size_limit
+ end
+
+ def oversize_error_message
+ _('"%{repository_name}" size (%{repository_size}) is larger than the limit of %{limit}.') % {
+ repository_name: repo.name,
+ repository_size: number_to_human_size(repo.size),
+ limit: number_to_human_size(repository_size_limit)
+ }
+ end
+
+ def repository_size_limit
+ strong_memoize :repository_size_limit do
+ namespace_limit = target_namespace.repository_size_limit.to_i
+
+ if namespace_limit > 0
+ namespace_limit
+ else
+ Gitlab::CurrentSettings.repository_size_limit.to_i
+ end
+ end
+ end
+
def authorized?
can?(current_user, :create_projects, target_namespace)
end
diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb
index b2f9c083b5b..3c2bc527b12 100644
--- a/app/services/issuable/clone/base_service.rb
+++ b/app/services/issuable/clone/base_service.rb
@@ -3,12 +3,13 @@
module Issuable
module Clone
class BaseService < IssuableBaseService
- attr_reader :original_entity, :new_entity
+ attr_reader :original_entity, :new_entity, :target_project
alias_method :old_project, :project
- def execute(original_entity, new_project = nil)
+ def execute(original_entity, target_project = nil)
@original_entity = original_entity
+ @target_project = target_project
# Using transaction because of a high resources footprint
# on rewriting notes (unfolding references)
@@ -77,6 +78,12 @@ module Issuable
new_entity.project.group
end
end
+
+ def relative_position
+ return if original_entity.project.root_ancestor.id != target_project.root_ancestor.id
+
+ original_entity.relative_position
+ end
end
end
end
diff --git a/app/services/issuable/process_assignees.rb b/app/services/issuable/process_assignees.rb
new file mode 100644
index 00000000000..c9c6b0bed85
--- /dev/null
+++ b/app/services/issuable/process_assignees.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+# This follows the rules specified in the specs.
+# See spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb
+
+module Issuable
+ class ProcessAssignees
+ def initialize(assignee_ids:, add_assignee_ids:, remove_assignee_ids:, existing_assignee_ids: nil, extra_assignee_ids: nil)
+ @assignee_ids = assignee_ids
+ @add_assignee_ids = add_assignee_ids
+ @remove_assignee_ids = remove_assignee_ids
+ @existing_assignee_ids = existing_assignee_ids || []
+ @extra_assignee_ids = extra_assignee_ids || []
+ end
+
+ def execute
+ if assignee_ids.blank?
+ updated_new_assignees = new_assignee_ids
+ updated_new_assignees |= add_assignee_ids if add_assignee_ids
+ updated_new_assignees -= remove_assignee_ids if remove_assignee_ids
+ else
+ updated_new_assignees = assignee_ids
+ end
+
+ updated_new_assignees.uniq
+ end
+
+ private
+
+ attr_accessor :assignee_ids, :add_assignee_ids, :remove_assignee_ids, :existing_assignee_ids, :extra_assignee_ids
+
+ def new_assignee_ids
+ existing_assignee_ids | extra_assignee_ids
+ end
+ end
+end
diff --git a/app/services/issue_rebalancing_service.rb b/app/services/issue_rebalancing_service.rb
index 849afc4edb8..db5c5ddfb84 100644
--- a/app/services/issue_rebalancing_service.rb
+++ b/app/services/issue_rebalancing_service.rb
@@ -2,6 +2,7 @@
class IssueRebalancingService
MAX_ISSUE_COUNT = 10_000
+ BATCH_SIZE = 100
TooManyIssues = Class.new(StandardError)
def initialize(issue)
@@ -21,13 +22,13 @@ class IssueRebalancingService
Issue.transaction do
assign_positions(start, indexed_ids)
.sort_by(&:first)
- .each_slice(100) do |pairs_with_position|
+ .each_slice(BATCH_SIZE) do |pairs_with_position|
update_positions(pairs_with_position, 'rebalance issue positions in batches ordered by id')
end
end
else
Issue.transaction do
- indexed_ids.each_slice(100) do |pairs|
+ indexed_ids.each_slice(BATCH_SIZE) do |pairs|
pairs_with_position = assign_positions(start, pairs)
update_positions(pairs_with_position, 'rebalance issue positions')
end
diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb
index 4c9c34f1247..b64e4687a87 100644
--- a/app/services/issues/clone_service.rb
+++ b/app/services/issues/clone_service.rb
@@ -47,6 +47,7 @@ module Issues
new_params = {
id: nil,
iid: nil,
+ relative_position: relative_position,
project: target_project,
author: current_user,
assignee_ids: original_entity.assignee_ids
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index d2285a375a1..3fdc66ed84e 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -28,6 +28,7 @@ module Issues
issue.run_after_commit do
NewIssueWorker.perform_async(issue.id, user.id)
IssuePlacementWorker.perform_async(nil, issue.project_id)
+ Namespaces::OnboardingIssueCreatedWorker.perform_async(issue.namespace.id)
end
end
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 90ccbd8ed21..c1afb8f456d 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -48,13 +48,14 @@ module Issues
def create_new_entity
new_params = {
- id: nil,
- iid: nil,
- project: target_project,
- author: original_entity.author,
- assignee_ids: original_entity.assignee_ids,
- moved_issue: true
- }
+ id: nil,
+ iid: nil,
+ relative_position: relative_position,
+ project: target_project,
+ author: original_entity.author,
+ assignee_ids: original_entity.assignee_ids,
+ moved_issue: true
+ }
new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
diff --git a/app/services/jira_import/users_importer.rb b/app/services/jira_import/users_importer.rb
index 438a74343a5..3de165c1014 100644
--- a/app/services/jira_import/users_importer.rb
+++ b/app/services/jira_import/users_importer.rb
@@ -13,7 +13,7 @@ module JiraImport
ServiceResponse.success(payload: mapped_users)
rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => error
- Gitlab::ErrorTracking.track_exception(error, project_id: project.id)
+ Gitlab::ErrorTracking.log_exception(error, project_id: project.id)
ServiceResponse.error(message: "There was an error when communicating to Jira")
rescue Projects::ImportService::Error => error
ServiceResponse.error(message: error.message)
diff --git a/app/services/members/invite_service.rb b/app/services/members/invite_service.rb
index 60ebbaface2..169500d08f0 100644
--- a/app/services/members/invite_service.rb
+++ b/app/services/members/invite_service.rb
@@ -2,112 +2,97 @@
module Members
class InviteService < Members::BaseService
- DEFAULT_LIMIT = 100
+ BlankEmailsError = Class.new(StandardError)
+ TooManyEmailsError = Class.new(StandardError)
- attr_reader :errors
+ def initialize(*args)
+ super
- def initialize(current_user, params)
- @current_user, @params = current_user, params.dup
@errors = {}
+ @emails = params[:email]&.split(',')&.uniq&.flatten
end
def execute(source)
- return error(s_('Email cannot be blank')) if params[:email].blank?
+ validate_emails!
- emails = params[:email].split(',').uniq.flatten
- return error(s_("Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if
- user_limit && emails.size > user_limit
-
- emails.each do |email|
- next if existing_member?(source, email)
- next if existing_invite?(source, email)
- next if existing_request?(source, email)
-
- if existing_user?(email)
- add_existing_user_as_member(current_user, source, params, email)
- next
- end
-
- invite_new_member_and_user(current_user, source, params, email)
- end
-
- return success unless errors.any?
-
- error(errors)
+ @source = source
+ emails.each(&method(:process_email))
+ result
+ rescue BlankEmailsError, TooManyEmailsError => e
+ error(e.message)
end
private
- def invite_new_member_and_user(current_user, source, params, email)
- new_member = (source.class.name + 'Member').constantize.create(source_id: source.id,
- user_id: nil,
- access_level: params[:access_level],
- invite_email: email,
- created_by_id: current_user.id,
- expires_at: params[:expires_at])
-
- unless new_member.valid? && new_member.persisted?
- errors[params[:email]] = new_member.errors.full_messages.to_sentence
- end
- end
+ attr_reader :source, :errors, :emails
- def add_existing_user_as_member(current_user, source, params, email)
- new_member = create_member(current_user, existing_user(email), source, params.merge({ invite_email: email }))
+ def validate_emails!
+ raise BlankEmailsError, s_('AddMember|Email cannot be blank') if emails.blank?
- unless new_member.valid? && new_member.persisted?
- errors[email] = new_member.errors.full_messages.to_sentence
+ if user_limit && emails.size > user_limit
+ raise TooManyEmailsError, s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }
end
end
- def create_member(current_user, user, source, params)
- source.add_user(user, params[:access_level], current_user: current_user, expires_at: params[:expires_at])
+ def user_limit
+ limit = params.fetch(:limit, Members::CreateService::DEFAULT_LIMIT)
+
+ limit < 0 ? nil : limit
end
- def user_limit
- limit = params.fetch(:limit, DEFAULT_LIMIT)
+ def process_email(email)
+ return if existing_member?(email)
+ return if existing_invite?(email)
+ return if existing_request?(email)
- limit && limit < 0 ? nil : limit
+ add_member(email)
end
- def existing_member?(source, email)
+ def existing_member?(email)
existing_member = source.members.with_user_by_email(email).exists?
if existing_member
- errors[email] = "Already a member of #{source.name}"
+ errors[email] = s_("AddMember|Already a member of %{source_name}") % { source_name: source.name }
return true
end
false
end
- def existing_invite?(source, email)
+ def existing_invite?(email)
existing_invite = source.members.search_invite_email(email).exists?
if existing_invite
- errors[email] = "Member already invited to #{source.name}"
+ errors[email] = s_("AddMember|Member already invited to %{source_name}") % { source_name: source.name }
return true
end
false
end
- def existing_request?(source, email)
+ def existing_request?(email)
existing_request = source.requesters.with_user_by_email(email).exists?
if existing_request
- errors[email] = "Member cannot be invited because they already requested to join #{source.name}"
+ errors[email] = s_("AddMember|Member cannot be invited because they already requested to join %{source_name}") % { source_name: source.name }
return true
end
false
end
- def existing_user(email)
- User.find_by_email(email)
+ def add_member(email)
+ new_member = source.add_user(email, params[:access_level], current_user: current_user, expires_at: params[:expires_at])
+
+ errors[email] = new_member.errors.full_messages.to_sentence if new_member.invalid?
end
- def existing_user?(email)
- existing_user(email).present?
+ def result
+ if errors.any?
+ error(errors)
+ else
+ success
+ end
end
end
end
diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb
index 03fcb5a4c1b..b22afe8a20d 100644
--- a/app/services/merge_requests/after_create_service.rb
+++ b/app/services/merge_requests/after_create_service.rb
@@ -3,8 +3,18 @@
module MergeRequests
class AfterCreateService < MergeRequests::BaseService
def execute(merge_request)
+ prepare_merge_request(merge_request)
+ merge_request.mark_as_unchecked if merge_request.preparing?
+ end
+
+ private
+
+ def prepare_merge_request(merge_request)
event_service.open_mr(merge_request, current_user)
+
merge_request_activity_counter.track_create_mr_action(user: current_user)
+ merge_request_activity_counter.track_mr_including_ci_config(user: current_user, merge_request: merge_request)
+
notification_service.new_merge_request(merge_request, current_user)
create_pipeline_for(merge_request, current_user)
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 6bd31e26748..317cd11a69d 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -181,7 +181,7 @@ module MergeRequests
}
if exception
- Gitlab::ErrorTracking.with_context(current_user) do
+ Gitlab::ApplicationContext.with_context(user: current_user) do
Gitlab::ErrorTracking.track_exception(exception, data)
end
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index 12c901aa1a1..e4d3c91d13e 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -58,6 +58,7 @@ module MergeRequests
:compare_commits,
:wip_title,
:description,
+ :first_multiline_commit,
:errors,
to: :merge_request
@@ -196,7 +197,8 @@ module MergeRequests
# interpreted as the user wants to close that issue on this project.
#
# For example:
- # - Issue 112 exists, title: Emoji don't show up in commit title
+ # - Issue 112 exists
+ # - title: Emoji don't show up in commit title
# - Source branch is: 112-fix-mep-mep
#
# Will lead to:
@@ -205,7 +207,7 @@ module MergeRequests
# more than one commit in the MR
#
def assign_title_and_description
- assign_title_and_description_from_single_commit
+ assign_title_and_description_from_commits
merge_request.title ||= title_from_issue if target_project.issues_enabled? || target_project.external_issue_tracker
merge_request.title ||= source_branch.titleize.humanize
merge_request.title = wip_title if compare_commits.empty?
@@ -240,12 +242,16 @@ module MergeRequests
end
end
- def assign_title_and_description_from_single_commit
+ def assign_title_and_description_from_commits
commits = compare_commits
- return unless commits&.count == 1
+ if commits&.count == 1
+ commit = commits.first
+ else
+ commit = first_multiline_commit
+ return unless commit
+ end
- commit = commits.first
merge_request.title ||= commit.title
merge_request.description ||= commit.description.try(:strip)
end
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index fc4405ef704..27f474b0fe7 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -107,8 +107,7 @@ module MergeRequests
log_info("Post merge finished on JID #{merge_jid} with state #{state}")
if delete_source_branch?
- ::Branches::DeleteService.new(@merge_request.source_project, branch_deletion_user)
- .execute(merge_request.source_branch)
+ MergeRequests::DeleteSourceBranchWorker.perform_async(@merge_request.id, @merge_request.source_branch_sha, branch_deletion_user.id)
end
end
diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index aafba9bfcef..4d7d632ee14 100644
--- a/app/services/merge_requests/post_merge_service.rb
+++ b/app/services/merge_requests/post_merge_service.rb
@@ -20,7 +20,6 @@ module MergeRequests
merge_request_activity_counter.track_merge_mr_action(user: current_user)
notification_service.merge_mr(merge_request, current_user)
execute_hooks(merge_request, 'merge')
- retarget_chain_merge_requests(merge_request)
invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers)
merge_request.update_project_counter_caches
delete_non_latest_diffs(merge_request)
@@ -31,34 +30,6 @@ module MergeRequests
private
- def retarget_chain_merge_requests(merge_request)
- return unless Feature.enabled?(:retarget_merge_requests, merge_request.target_project)
-
- # we can only retarget MRs that are targeting the same project
- # and have a remove source branch set
- return unless merge_request.for_same_project? && merge_request.remove_source_branch?
-
- # find another merge requests that
- # - as a target have a current source project and branch
- other_merge_requests = merge_request.source_project
- .merge_requests
- .opened
- .by_target_branch(merge_request.source_branch)
- .preload_source_project
- .at_most(MAX_RETARGET_MERGE_REQUESTS)
-
- other_merge_requests.find_each do |other_merge_request|
- # Update only MRs on projects that we have access to
- next unless can?(current_user, :update_merge_request, other_merge_request.source_project)
-
- ::MergeRequests::UpdateService
- .new(other_merge_request.source_project, current_user,
- target_branch: merge_request.target_branch,
- target_branch_was_deleted: true)
- .execute(other_merge_request)
- end
- end
-
def close_issues(merge_request)
return unless merge_request.target_branch == project.default_branch
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index ed977a5a872..0fb16597aff 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -44,6 +44,7 @@ module MergeRequests
notify_about_push(mr)
mark_mr_as_draft_from_commits(mr)
execute_mr_web_hooks(mr)
+ merge_request_activity_counter.track_mr_including_ci_config(user: mr.author, merge_request: mr)
end
true
@@ -74,7 +75,8 @@ module MergeRequests
def post_merge_manually_merged
commit_ids = @commits.map(&:id)
merge_requests = @project.merge_requests.opened
- .preload(:latest_merge_request_diff)
+ .preload_project_and_latest_diff
+ .preload_latest_diff_commit
.where(target_branch: @push.branch_name).to_a
.select(&:diff_head_commit)
.select do |merge_request|
@@ -116,11 +118,14 @@ module MergeRequests
# Note: we should update merge requests from forks too
def reload_merge_requests
merge_requests = @project.merge_requests.opened
- .by_source_or_target_branch(@push.branch_name).to_a
+ .by_source_or_target_branch(@push.branch_name)
+ .preload_project_and_latest_diff
- merge_requests += merge_requests_for_forks.to_a
+ merge_requests_from_forks = merge_requests_for_forks
+ .preload_project_and_latest_diff
- filter_merge_requests(merge_requests).each do |merge_request|
+ merge_requests_array = merge_requests.to_a + merge_requests_from_forks.to_a
+ filter_merge_requests(merge_requests_array).each do |merge_request|
if branch_and_project_match?(merge_request) || @push.force_push?
merge_request.reload_diff(current_user)
# Clear existing merge error if the push were directed at the
diff --git a/app/services/merge_requests/retarget_chain_service.rb b/app/services/merge_requests/retarget_chain_service.rb
new file mode 100644
index 00000000000..f24d67243c9
--- /dev/null
+++ b/app/services/merge_requests/retarget_chain_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class RetargetChainService < MergeRequests::BaseService
+ MAX_RETARGET_MERGE_REQUESTS = 4
+
+ def execute(merge_request)
+ return unless Feature.enabled?(:retarget_merge_requests, merge_request.target_project, default_enabled: :yaml)
+
+ # we can only retarget MRs that are targeting the same project
+ return unless merge_request.for_same_project? && merge_request.merged?
+
+ # find another merge requests that
+ # - as a target have a current source project and branch
+ other_merge_requests = merge_request.source_project
+ .merge_requests
+ .opened
+ .by_target_branch(merge_request.source_branch)
+ .preload_source_project
+ .at_most(MAX_RETARGET_MERGE_REQUESTS)
+
+ other_merge_requests.find_each do |other_merge_request|
+ # Update only MRs on projects that we have access to
+ next unless can?(current_user, :update_merge_request, other_merge_request.source_project)
+
+ ::MergeRequests::UpdateService
+ .new(other_merge_request.source_project, current_user,
+ target_branch: merge_request.target_branch,
+ target_branch_was_deleted: true)
+ .execute(other_merge_request)
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 1707daff734..f5e14797f7e 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -31,54 +31,37 @@ module MergeRequests
old_mentioned_users = old_associations.fetch(:mentioned_users, [])
old_assignees = old_associations.fetch(:assignees, [])
old_reviewers = old_associations.fetch(:reviewers, [])
+ old_timelogs = old_associations.fetch(:timelogs, [])
+ changed_fields = merge_request.previous_changes.keys
- if has_changes?(merge_request, old_labels: old_labels, old_assignees: old_assignees, old_reviewers: old_reviewers)
- todo_service.resolve_todos_for_target(merge_request, current_user)
- end
+ resolve_todos(merge_request, old_labels, old_assignees, old_reviewers)
if merge_request.previous_changes.include?('title') ||
merge_request.previous_changes.include?('description')
todo_service.update_merge_request(merge_request, current_user, old_mentioned_users)
end
- if merge_request.previous_changes.include?('target_branch')
- create_branch_change_note(merge_request,
- 'target',
- target_branch_was_deleted ? 'delete' : 'update',
- merge_request.previous_changes['target_branch'].first,
- merge_request.target_branch)
-
- abort_auto_merge(merge_request, 'target branch was changed')
- end
-
+ handle_target_branch_change(merge_request)
handle_assignees_change(merge_request, old_assignees) if merge_request.assignees != old_assignees
-
handle_reviewers_change(merge_request, old_reviewers) if merge_request.reviewers != old_reviewers
-
- if merge_request.previous_changes.include?('target_branch') ||
- merge_request.previous_changes.include?('source_branch')
- merge_request.mark_as_unchecked
- end
-
handle_milestone_change(merge_request)
+ handle_draft_status_change(merge_request, changed_fields)
- added_labels = merge_request.labels - old_labels
- if added_labels.present?
- notification_service.async.relabeled_merge_request(
- merge_request,
- added_labels,
- current_user
- )
- end
+ track_title_and_desc_edits(changed_fields)
+ track_discussion_lock_toggle(merge_request, changed_fields)
+ track_time_estimate_and_spend_edits(merge_request, old_timelogs, changed_fields)
+ track_labels_change(merge_request, old_labels)
- added_mentions = merge_request.mentioned_users(current_user) - old_mentioned_users
+ notify_if_labels_added(merge_request, old_labels)
+ notify_if_mentions_added(merge_request, old_mentioned_users)
- if added_mentions.present?
- notification_service.async.new_mentions_in_merge_request(
- merge_request,
- added_mentions,
- current_user
- )
+ # Since #mark_as_unchecked triggers an update action through the MR's
+ # state machine, we want to push this as far down in the process so we
+ # avoid resetting #ActiveModel::Dirty
+ #
+ if merge_request.previous_changes.include?('target_branch') ||
+ merge_request.previous_changes.include?('source_branch')
+ merge_request.mark_as_unchecked
end
end
@@ -95,56 +78,128 @@ module MergeRequests
MergeRequests::CloseService
end
- def before_update(issuable, skip_spam_check: false)
- return unless issuable.changed?
-
- @issuable_changes = issuable.changes
- end
-
def after_update(issuable)
issuable.cache_merge_request_closes_issues!(current_user)
+ end
- return unless @issuable_changes
+ private
- %w(title description).each do |action|
- next unless @issuable_changes.key?(action)
+ attr_reader :target_branch_was_deleted
+
+ def track_title_and_desc_edits(changed_fields)
+ tracked_fields = %w(title description)
+
+ return unless changed_fields.any? { |field| tracked_fields.include?(field) }
+
+ tracked_fields.each do |action|
+ next unless changed_fields.include?(action)
- # Track edits to title or description
- #
merge_request_activity_counter
.public_send("track_#{action}_edit_action".to_sym, user: current_user) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
- # Track changes to Draft/WIP status
- #
- if action == "title"
- old_title, new_title = @issuable_changes["title"]
- old_title_wip = MergeRequest.work_in_progress?(old_title)
- new_title_wip = MergeRequest.work_in_progress?(new_title)
-
- if !old_title_wip && new_title_wip
- # Marked as Draft/WIP
- #
- merge_request_activity_counter
- .track_marked_as_draft_action(user: current_user)
- elsif old_title_wip && !new_title_wip
- # Unmarked as Draft/WIP
- #
- merge_request_activity_counter
- .track_unmarked_as_draft_action(user: current_user)
- end
- end
+ def track_discussion_lock_toggle(merge_request, changed_fields)
+ return unless changed_fields.include?('discussion_locked')
+
+ if merge_request.discussion_locked
+ merge_request_activity_counter.track_discussion_locked_action(user: current_user)
+ else
+ merge_request_activity_counter.track_discussion_unlocked_action(user: current_user)
end
end
- private
+ def track_time_estimate_and_spend_edits(merge_request, old_timelogs, changed_fields)
+ merge_request_activity_counter.track_time_estimate_changed_action(user: current_user) if changed_fields.include?('time_estimate')
+ merge_request_activity_counter.track_time_spent_changed_action(user: current_user) if old_timelogs != merge_request.timelogs
+ end
- attr_reader :target_branch_was_deleted
+ def track_labels_change(merge_request, old_labels)
+ return if Set.new(merge_request.labels) == Set.new(old_labels)
+
+ merge_request_activity_counter.track_labels_changed_action(user: current_user)
+ end
+
+ def notify_if_labels_added(merge_request, old_labels)
+ added_labels = merge_request.labels - old_labels
+
+ return unless added_labels.present?
+
+ notification_service.async.relabeled_merge_request(
+ merge_request,
+ added_labels,
+ current_user
+ )
+ end
+
+ def notify_if_mentions_added(merge_request, old_mentioned_users)
+ added_mentions = merge_request.mentioned_users(current_user) - old_mentioned_users
+
+ return unless added_mentions.present?
+
+ notification_service.async.new_mentions_in_merge_request(
+ merge_request,
+ added_mentions,
+ current_user
+ )
+ end
+
+ def resolve_todos(merge_request, old_labels, old_assignees, old_reviewers)
+ return unless has_changes?(merge_request, old_labels: old_labels, old_assignees: old_assignees, old_reviewers: old_reviewers)
+
+ todo_service.resolve_todos_for_target(merge_request, current_user)
+ end
+
+ def handle_target_branch_change(merge_request)
+ return unless merge_request.previous_changes.include?('target_branch')
+
+ create_branch_change_note(
+ merge_request,
+ 'target',
+ target_branch_was_deleted ? 'delete' : 'update',
+ merge_request.previous_changes['target_branch'].first,
+ merge_request.target_branch
+ )
+
+ abort_auto_merge(merge_request, 'target branch was changed')
+ end
+
+ def handle_draft_status_change(merge_request, changed_fields)
+ return unless changed_fields.include?("title")
+
+ old_title, new_title = merge_request.previous_changes["title"]
+ old_title_wip = MergeRequest.work_in_progress?(old_title)
+ new_title_wip = MergeRequest.work_in_progress?(new_title)
+
+ if !old_title_wip && new_title_wip
+ # Marked as Draft/WIP
+ #
+ merge_request_activity_counter
+ .track_marked_as_draft_action(user: current_user)
+ elsif old_title_wip && !new_title_wip
+ # Unmarked as Draft/WIP
+ #
+ notify_draft_status_changed(merge_request)
+
+ merge_request_activity_counter
+ .track_unmarked_as_draft_action(user: current_user)
+ end
+ end
+
+ def notify_draft_status_changed(merge_request)
+ notification_service.async.change_in_merge_request_draft_status(
+ merge_request,
+ current_user
+ )
+ end
def handle_milestone_change(merge_request)
return if skip_milestone_email
return unless merge_request.previous_changes.include?('milestone_id')
+ merge_request_activity_counter.track_milestone_changed_action(user: current_user)
+
if merge_request.milestone.nil?
notification_service.async.removed_milestone_merge_request(merge_request, current_user)
else
@@ -159,6 +214,7 @@ module MergeRequests
new_assignees = merge_request.assignees - old_assignees
merge_request_activity_counter.track_users_assigned_to_mr(users: new_assignees)
+ merge_request_activity_counter.track_assignees_changed_action(user: current_user)
end
def handle_reviewers_change(merge_request, old_reviewers)
@@ -170,6 +226,7 @@ module MergeRequests
new_reviewers = merge_request.reviewers - old_reviewers
merge_request_activity_counter.track_users_review_requested(users: new_reviewers)
+ merge_request_activity_counter.track_reviewers_changed_action(user: current_user)
end
def create_branch_change_note(issuable, branch_type, event_type, old_branch, new_branch)
diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb
index 45b4619ddbe..f009f5d8538 100644
--- a/app/services/namespaces/in_product_marketing_emails_service.rb
+++ b/app/services/namespaces/in_product_marketing_emails_service.rb
@@ -63,7 +63,10 @@ module Namespaces
.completed_actions_with_latest_in_range(completed_actions, range)
.incomplete_actions(incomplete_action)
- Group.joins(:onboarding_progress).merge(onboarding_progress_scope)
+ # Filtering out sub-groups is a temporary fix to prevent calling
+ # `.root_ancestor` on groups that are not root groups.
+ # See https://gitlab.com/groups/gitlab-org/-/epics/5594 for more information.
+ Group.where(parent_id: nil).joins(:onboarding_progress).merge(onboarding_progress_scope)
end
def users_for_group(group)
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index cf21818a886..8c250526efc 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -3,32 +3,36 @@
module Notes
class BuildService < ::BaseService
def execute
- should_resolve = false
in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id)
+ discussion = nil
if in_reply_to_discussion_id.present?
discussion = find_discussion(in_reply_to_discussion_id)
- unless discussion && can?(current_user, :create_note, discussion.noteable)
- note = Note.new
- note.errors.add(:base, _('Discussion to reply to cannot be found'))
- return note
- end
+ return discussion_not_found unless discussion && can?(current_user, :create_note, discussion.noteable)
discussion = discussion.convert_to_discussion! if discussion.can_convert_to_discussion?
params.merge!(discussion.reply_attributes)
- should_resolve = discussion.resolved?
end
+ new_note(params, discussion)
+ end
+
+ private
+
+ def new_note(params, discussion)
note = Note.new(params)
note.project = project
note.author = current_user
- if should_resolve
- note.resolve_without_save(current_user)
- end
+ parent_confidential = discussion&.confidential?
+ can_set_confidential = can?(current_user, :mark_note_as_confidential, note)
+ return discussion_not_found if parent_confidential && !can_set_confidential
+
+ note.confidential = (parent_confidential.nil? && can_set_confidential ? params.delete(:confidential) : parent_confidential)
+ note.resolve_without_save(current_user) if discussion&.resolved?
note
end
@@ -39,5 +43,11 @@ module Notes
Note.find_discussion(discussion_id)
end
end
+
+ def discussion_not_found
+ note = Note.new
+ note.errors.add(:base, _('Discussion to reply to cannot be found'))
+ note
+ end
end
end
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index 857ffbb6965..76f9b6369b3 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -7,12 +7,7 @@ module Notes
old_mentioned_users = note.mentioned_users(current_user).to_a
- note.assign_attributes(params.merge(updated_by: current_user))
-
- note.with_transaction_returning_status do
- update_confidentiality(note)
- note.save
- end
+ note.assign_attributes(params)
track_note_edit_usage_for_issues(note) if note.for_issue?
track_note_edit_usage_for_merge_requests(note) if note.for_merge_request?
@@ -28,6 +23,15 @@ module Notes
note.note = content
end
+ if note.note_changed?
+ note.assign_attributes(last_edited_at: Time.current, updated_by: current_user)
+ end
+
+ note.with_transaction_returning_status do
+ update_confidentiality(note)
+ note.save
+ end
+
unless only_commands || note.for_personal_snippet?
note.create_new_cross_references!(current_user)
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 50247532f69..fc2eb1dc4e4 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -66,10 +66,10 @@ class NotificationService
# Notify the owner of the personal access token, when it is about to expire
# And mark the token with about_to_expire_delivered
- def access_token_about_to_expire(user)
+ def access_token_about_to_expire(user, token_names)
return unless user.can?(:receive_notifications)
- mailer.access_token_about_to_expire_email(user).deliver_later
+ mailer.access_token_about_to_expire_email(user, token_names).deliver_later
end
# Notify the user when at least one of their personal access tokens has expired today
@@ -95,7 +95,7 @@ class NotificationService
# * users with custom level checked with "new issue"
#
def new_issue(issue, current_user)
- new_resource_email(issue, :new_issue_email)
+ new_resource_email(issue, current_user, :new_issue_email)
end
# When issue text is updated, we should send an email to:
@@ -176,7 +176,7 @@ class NotificationService
#
# In EE, approvers of the merge request are also included
def new_merge_request(merge_request, current_user)
- new_resource_email(merge_request, :new_merge_request_email)
+ new_resource_email(merge_request, current_user, :new_merge_request_email)
end
def push_to_merge_request(merge_request, current_user, new_commits: [], existing_commits: [])
@@ -189,6 +189,20 @@ class NotificationService
end
end
+ def change_in_merge_request_draft_status(merge_request, current_user)
+ recipients = NotificationRecipients::BuildService.build_recipients(merge_request, current_user, action: "draft_status_change")
+
+ recipients.each do |recipient|
+ mailer.send(
+ :change_in_merge_request_draft_status_email,
+ recipient.user.id,
+ merge_request.id,
+ current_user.id,
+ recipient.reason
+ ).deliver_later
+ end
+ end
+
# When a merge request is found to be unmergeable, we should send an email to:
#
# * mr author
@@ -355,22 +369,28 @@ class NotificationService
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
+ recipients = issue.email_participants_emails
+
+ return unless recipients.any?
- return unless issue.external_author.present?
- return unless issue.project.service_desk_enabled?
- return if note.author == support_bot
- return unless issue.subscribed?(support_bot, issue.project)
+ support_bot = User.support_bot
+ recipients.delete(issue.external_author) if note.author == support_bot
- mailer.service_desk_new_note_email(issue.id, note.id).deliver_later
+ recipients.each do |recipient|
+ mailer.service_desk_new_note_email(issue.id, note.id, recipient).deliver_later
+ end
end
# Notify users when a new release is created
def send_new_release_notifications(release)
+ unless release.author&.can_trigger_notifications?
+ warn_skipping_notifications(release.author, release)
+ return false
+ end
+
recipients = NotificationRecipients::BuildService.build_new_release_recipients(release)
recipients.each do |recipient|
@@ -665,7 +685,12 @@ class NotificationService
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 = ::NotificationRecipients::BuildService.build_recipients(
+ merge_request,
+ current_user,
+ action: 'merge_when_pipeline_succeeds',
+ custom_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
@@ -678,7 +703,12 @@ class NotificationService
protected
- def new_resource_email(target, method)
+ def new_resource_email(target, current_user, method)
+ unless current_user&.can_trigger_notifications?
+ warn_skipping_notifications(current_user, target)
+ return false
+ end
+
recipients = NotificationRecipients::BuildService.build_recipients(target, target.author, action: "new")
recipients.each do |recipient|
@@ -687,6 +717,11 @@ class NotificationService
end
def new_mentions_in_resource_email(target, new_mentioned_users, current_user, method)
+ unless current_user&.can_trigger_notifications?
+ warn_skipping_notifications(current_user, target)
+ return false
+ end
+
recipients = NotificationRecipients::BuildService.build_recipients(target, current_user, action: "new")
recipients = recipients.select {|r| new_mentioned_users.include?(r.user) }
@@ -820,6 +855,10 @@ class NotificationService
source.respond_to?(:group) && source.group
end
+
+ def warn_skipping_notifications(user, object)
+ Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class, object_id: object.id)
+ end
end
NotificationService.prepend_if_ee('EE::NotificationService')
diff --git a/app/services/onboarding_progress_service.rb b/app/services/onboarding_progress_service.rb
index 241bd8a01ca..6d44c0a61ea 100644
--- a/app/services/onboarding_progress_service.rb
+++ b/app/services/onboarding_progress_service.rb
@@ -1,6 +1,24 @@
# frozen_string_literal: true
class OnboardingProgressService
+ class Async
+ attr_reader :namespace_id
+
+ def initialize(namespace_id)
+ @namespace_id = namespace_id
+ end
+
+ def execute(action:)
+ return unless OnboardingProgress.not_completed?(namespace_id, action)
+
+ Namespaces::OnboardingProgressWorker.perform_async(namespace_id, action)
+ end
+ end
+
+ def self.async(namespace_id)
+ Async.new(namespace_id)
+ end
+
def initialize(namespace)
@namespace = namespace&.root_ancestor
end
diff --git a/app/services/packages/composer/create_package_service.rb b/app/services/packages/composer/create_package_service.rb
index 0f5429f667e..c84d40c3753 100644
--- a/app/services/packages/composer/create_package_service.rb
+++ b/app/services/packages/composer/create_package_service.rb
@@ -17,6 +17,8 @@ module Packages
})
end
+ ::Packages::Composer::CacheUpdateWorker.perform_async(created_package.project_id, created_package.name, nil)
+
created_package
end
diff --git a/app/services/packages/conan/search_service.rb b/app/services/packages/conan/search_service.rb
index 4513616bad2..143fd8a627b 100644
--- a/app/services/packages/conan/search_service.rb
+++ b/app/services/packages/conan/search_service.rb
@@ -44,7 +44,7 @@ module Packages
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)
+ return unless Ability.allowed?(current_user, :read_package, project)
result = project.packages.with_name(name).with_version(version).order_created.last
[result&.conan_recipe].compact
diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb
index 63248ef07c9..8fed6e2def8 100644
--- a/app/services/packages/create_event_service.rb
+++ b/app/services/packages/create_event_service.rb
@@ -3,14 +3,12 @@
module Packages
class CreateEventService < BaseService
def execute
- if Feature.enabled?(:collect_package_events_redis, default_enabled: true)
- ::Packages::Event.unique_counters_for(event_scope, event_name, originator_type).each do |event_name|
- ::Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event_name, values: current_user.id)
- end
-
- ::Packages::Event.counters_for(event_scope, event_name, originator_type).each do |event_name|
- ::Gitlab::UsageDataCounters::PackageEventCounter.count(event_name)
- end
+ ::Packages::Event.unique_counters_for(event_scope, event_name, originator_type).each do |event_name|
+ ::Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event_name, values: current_user.id)
+ end
+
+ ::Packages::Event.counters_for(event_scope, event_name, originator_type).each do |event_name|
+ ::Gitlab::UsageDataCounters::PackageEventCounter.count(event_name)
end
if Feature.enabled?(:collect_package_events) && Gitlab::Database.read_write?
diff --git a/app/services/packages/create_temporary_package_service.rb b/app/services/packages/create_temporary_package_service.rb
new file mode 100644
index 00000000000..ee609fd787d
--- /dev/null
+++ b/app/services/packages/create_temporary_package_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Packages
+ class CreateTemporaryPackageService < ::Packages::CreatePackageService
+ PACKAGE_VERSION = '0.0.0'
+
+ def execute(package_type, name: 'Temporary.Package')
+ create_package!(package_type,
+ name: name,
+ version: "#{PACKAGE_VERSION}-#{uuid}",
+ status: 'processing'
+ )
+ end
+
+ private
+
+ def uuid
+ SecureRandom.uuid
+ end
+ end
+end
diff --git a/app/services/packages/debian/get_or_create_incoming_service.rb b/app/services/packages/debian/find_or_create_incoming_service.rb
index 09e7877a2b4..2d29ba5f3c3 100644
--- a/app/services/packages/debian/get_or_create_incoming_service.rb
+++ b/app/services/packages/debian/find_or_create_incoming_service.rb
@@ -2,7 +2,7 @@
module Packages
module Debian
- class GetOrCreateIncomingService < ::Packages::CreatePackageService
+ class FindOrCreateIncomingService < ::Packages::CreatePackageService
def execute
find_or_create_package!(:debian, name: 'incoming', version: nil)
end
diff --git a/app/services/packages/debian/find_or_create_package_service.rb b/app/services/packages/debian/find_or_create_package_service.rb
new file mode 100644
index 00000000000..46e06c9f584
--- /dev/null
+++ b/app/services/packages/debian/find_or_create_package_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Packages
+ module Debian
+ class FindOrCreatePackageService < ::Packages::CreatePackageService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute
+ package = project.packages
+ .debian
+ .with_name(params[:name])
+ .with_version(params[:version])
+ .with_debian_codename(params[:distribution_name])
+ .first
+
+ package ||= create_package!(
+ :debian,
+ debian_publication_attributes: { distribution_id: distribution.id }
+ )
+
+ ServiceResponse.success(payload: { package: package })
+ end
+
+ private
+
+ def distribution
+ strong_memoize(:distribution) do
+ Packages::Debian::DistributionsFinder.new(project, codename: params[:distribution_name]).execute.last!
+ end
+ 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
index 4c916d264a7..401e52f7e51 100644
--- a/app/services/packages/maven/find_or_create_package_service.rb
+++ b/app/services/packages/maven/find_or_create_package_service.rb
@@ -2,7 +2,6 @@
module Packages
module Maven
class FindOrCreatePackageService < BaseService
- MAVEN_METADATA_FILE = 'maven-metadata.xml'
SNAPSHOT_TERM = '-SNAPSHOT'
def execute
@@ -33,7 +32,7 @@ module Packages
# - my-company/my-app/maven-metadata.xml
#
# The first upload has to create the proper package (the one with the version set).
- if params[:file_name] == MAVEN_METADATA_FILE && !params[:path]&.ends_with?(SNAPSHOT_TERM)
+ if params[:file_name] == Packages::Maven::Metadata.filename && !params[:path]&.ends_with?(SNAPSHOT_TERM)
package_name, version = params[:path], nil
else
package_name, _, version = params[:path].rpartition('/')
diff --git a/app/services/packages/maven/metadata.rb b/app/services/packages/maven/metadata.rb
new file mode 100644
index 00000000000..437e18e3138
--- /dev/null
+++ b/app/services/packages/maven/metadata.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Packages
+ module Maven
+ module Metadata
+ FILENAME = 'maven-metadata.xml'
+
+ def self.filename
+ FILENAME
+ end
+ end
+ end
+end
diff --git a/app/services/packages/maven/metadata/append_package_file_service.rb b/app/services/packages/maven/metadata/append_package_file_service.rb
new file mode 100644
index 00000000000..e991576ebc6
--- /dev/null
+++ b/app/services/packages/maven/metadata/append_package_file_service.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module Packages
+ module Maven
+ module Metadata
+ class AppendPackageFileService
+ XML_CONTENT_TYPE = 'application/xml'
+ DEFAULT_CONTENT_TYPE = 'application/octet-stream'
+
+ MD5_FILE_NAME = "#{Metadata.filename}.md5"
+ SHA1_FILE_NAME = "#{Metadata.filename}.sha1"
+ SHA256_FILE_NAME = "#{Metadata.filename}.sha256"
+ SHA512_FILE_NAME = "#{Metadata.filename}.sha512"
+
+ def initialize(package:, metadata_content:)
+ @package = package
+ @metadata_content = metadata_content
+ end
+
+ def execute
+ return ServiceResponse.error(message: 'package is not set') unless @package
+ return ServiceResponse.error(message: 'metadata content is not set') unless @metadata_content
+
+ file_md5 = digest_from(@metadata_content, :md5)
+ file_sha1 = digest_from(@metadata_content, :sha1)
+ file_sha256 = digest_from(@metadata_content, :sha256)
+ file_sha512 = digest_from(@metadata_content, :sha512)
+
+ @package.transaction do
+ append_metadata_file(
+ content: @metadata_content,
+ file_name: Metadata.filename,
+ content_type: XML_CONTENT_TYPE,
+ sha1: file_sha1,
+ md5: file_md5,
+ sha256: file_sha256
+ )
+
+ append_metadata_file(content: file_md5, file_name: MD5_FILE_NAME)
+ append_metadata_file(content: file_sha1, file_name: SHA1_FILE_NAME)
+ append_metadata_file(content: file_sha256, file_name: SHA256_FILE_NAME)
+ append_metadata_file(content: file_sha512, file_name: SHA512_FILE_NAME)
+ end
+
+ ServiceResponse.success(message: 'New metadata package file created')
+ end
+
+ private
+
+ def append_metadata_file(content:, file_name:, content_type: DEFAULT_CONTENT_TYPE, sha1: nil, md5: nil, sha256: nil)
+ file_md5 = md5 || digest_from(content, :md5)
+ file_sha1 = sha1 || digest_from(content, :sha1)
+ file_sha256 = sha256 || digest_from(content, :sha256)
+
+ file = CarrierWaveStringFile.new_file(
+ file_content: content,
+ filename: file_name,
+ content_type: content_type
+ )
+
+ ::Packages::CreatePackageFileService.new(
+ @package,
+ file: file,
+ size: file.size,
+ file_name: file_name,
+ file_sha1: file_sha1,
+ file_md5: file_md5,
+ file_sha256: file_sha256
+ ).execute
+ end
+
+ def digest_from(content, type)
+ digest_class = case type
+ when :md5
+ Digest::MD5
+ when :sha1
+ Digest::SHA1
+ when :sha256
+ Digest::SHA256
+ when :sha512
+ Digest::SHA512
+ end
+ digest_class.hexdigest(content)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/maven/metadata/base_create_xml_service.rb b/app/services/packages/maven/metadata/base_create_xml_service.rb
new file mode 100644
index 00000000000..4d5cab4978e
--- /dev/null
+++ b/app/services/packages/maven/metadata/base_create_xml_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Packages
+ module Maven
+ module Metadata
+ class BaseCreateXmlService
+ include Gitlab::Utils::StrongMemoize
+
+ INDENT_SPACE = 2
+
+ def initialize(metadata_content:, package:)
+ @metadata_content = metadata_content
+ @package = package
+ end
+
+ private
+
+ def xml_doc
+ strong_memoize(:xml_doc) do
+ Nokogiri::XML(@metadata_content) do |config|
+ config.default_xml.noblanks
+ end
+ end
+ end
+
+ def xml_node(name, content)
+ xml_doc.create_element(name).tap { |e| e.content = content }
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/maven/metadata/create_plugins_xml_service.rb b/app/services/packages/maven/metadata/create_plugins_xml_service.rb
new file mode 100644
index 00000000000..707a8c577ba
--- /dev/null
+++ b/app/services/packages/maven/metadata/create_plugins_xml_service.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module Packages
+ module Maven
+ module Metadata
+ class CreatePluginsXmlService < BaseCreateXmlService
+ XPATH_PLUGIN_ARTIFACT_ID = '//plugin/artifactId'
+ XPATH_PLUGINS = '//metadata/plugins'
+ EMPTY_PLUGINS_PAYLOAD = {
+ changes_exist: true,
+ empty_plugins: true
+ }.freeze
+
+ def execute
+ return ServiceResponse.error(message: 'package not set') unless @package
+ return ServiceResponse.error(message: 'metadata_content not set') unless @metadata_content
+ return ServiceResponse.error(message: 'metadata_content is invalid') unless plugins_xml_node.present?
+ return ServiceResponse.success(payload: EMPTY_PLUGINS_PAYLOAD) if plugin_artifact_ids_from_database.empty?
+
+ changes_exist = update_plugins_list
+
+ payload = { changes_exist: changes_exist, empty_versions: false }
+ payload[:metadata_content] = xml_doc.to_xml(indent: INDENT_SPACE) if changes_exist
+
+ ServiceResponse.success(payload: payload)
+ end
+
+ private
+
+ def update_plugins_list
+ return false if plugin_artifact_ids_from_xml == plugin_artifact_ids_from_database
+
+ plugins_xml_node.children.remove
+
+ plugin_artifact_ids_from_database.each do |artifact_id|
+ plugins_xml_node.add_child(plugin_node_for(artifact_id))
+ end
+
+ true
+ end
+
+ def plugins_xml_node
+ strong_memoize(:plugins_xml_node) do
+ xml_doc.xpath(XPATH_PLUGINS)
+ .first
+ end
+ end
+
+ def plugin_artifact_ids_from_xml
+ strong_memoize(:plugin_artifact_ids_from_xml) do
+ plugins_xml_node.xpath(XPATH_PLUGIN_ARTIFACT_ID)
+ .map(&:content)
+ end
+ end
+
+ def plugin_artifact_ids_from_database
+ strong_memoize(:plugin_artifact_ids_from_database) do
+ package_names = plugin_artifact_ids_from_xml.map do |artifact_id|
+ "#{@package.name}/#{artifact_id}"
+ end
+
+ packages = @package.project.packages
+ .maven
+ .displayable
+ .with_name(package_names)
+ .has_version
+
+ ::Packages::Maven::Metadatum.for_package_ids(packages.select(:id))
+ .order_created
+ .pluck_app_name
+ .uniq
+ end
+ end
+
+ def plugin_node_for(artifact_id)
+ xml_doc.create_element('plugin').tap do |plugin_node|
+ plugin_node.add_child(xml_node('name', artifact_id))
+ plugin_node.add_child(xml_node('prefix', prefix_from(artifact_id)))
+ plugin_node.add_child(xml_node('artifactId', artifact_id))
+ end
+ end
+
+ # Maven plugin prefix generation from
+ # https://github.com/apache/maven/blob/c3dba0e5ba71ee7cbd62620f669a8c206e71b5e2/maven-plugin-api/src/main/java/org/apache/maven/plugin/descriptor/PluginDescriptor.java#L189
+ def prefix_from(artifact_id)
+ artifact_id.gsub(/-?maven-?/, '')
+ .gsub(/-?plugin-?/, '')
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/maven/metadata/create_versions_xml_service.rb b/app/services/packages/maven/metadata/create_versions_xml_service.rb
new file mode 100644
index 00000000000..13b6efa8650
--- /dev/null
+++ b/app/services/packages/maven/metadata/create_versions_xml_service.rb
@@ -0,0 +1,165 @@
+# frozen_string_literal: true
+
+module Packages
+ module Maven
+ module Metadata
+ class CreateVersionsXmlService < BaseCreateXmlService
+ XPATH_VERSIONING = '//metadata/versioning'
+ XPATH_VERSIONS = '//versions'
+ XPATH_VERSION = '//version'
+ XPATH_LATEST = '//latest'
+ XPATH_RELEASE = '//release'
+ XPATH_LAST_UPDATED = '//lastUpdated'
+
+ EMPTY_VERSIONS_PAYLOAD = {
+ changes_exist: true,
+ empty_versions: true
+ }.freeze
+
+ def execute
+ return ServiceResponse.error(message: 'package not set') unless @package
+ return ServiceResponse.error(message: 'metadata_content not set') unless @metadata_content
+ return ServiceResponse.error(message: 'metadata_content is invalid') unless valid_metadata_content?
+ return ServiceResponse.success(payload: EMPTY_VERSIONS_PAYLOAD) if versions_from_database.empty?
+
+ changes_exist = false
+ changes_exist = true if update_versions_list
+ changes_exist = true if update_latest
+ changes_exist = true if update_release
+ update_last_updated_timestamp if changes_exist
+
+ payload = { changes_exist: changes_exist, empty_versions: false }
+ payload[:metadata_content] = xml_doc.to_xml(indent: INDENT_SPACE) if changes_exist
+
+ ServiceResponse.success(payload: payload)
+ end
+
+ private
+
+ def valid_metadata_content?
+ versioning_xml_node.present? &&
+ versions_xml_node.present? &&
+ last_updated_xml_node.present?
+ end
+
+ def update_versions_list
+ return false if versions_from_xml == versions_from_database
+
+ version_xml_nodes.remove
+
+ versions_from_database.each do |version|
+ versions_xml_node.add_child(xml_node('version', version))
+ end
+ true
+ end
+
+ def update_latest
+ return false if latest_coherent?
+
+ latest_xml_node.content = latest_from_database
+ true
+ end
+
+ def latest_coherent?
+ latest_from_xml.nil? || latest_from_xml == latest_from_database
+ end
+
+ def update_release
+ return false if release_coherent?
+
+ if release_from_database
+ release_xml_node.content = release_from_database
+ else
+ release_xml_node.remove
+ end
+
+ true
+ end
+
+ def release_coherent?
+ release_from_xml == release_from_database
+ end
+
+ def update_last_updated_timestamp
+ last_updated_xml_node.content = Time.zone.now.strftime('%Y%m%d%H%M%S')
+ end
+
+ def versioning_xml_node
+ strong_memoize(:versioning_xml_node) do
+ xml_doc.xpath(XPATH_VERSIONING).first
+ end
+ end
+
+ def versions_xml_node
+ strong_memoize(:versions_xml_node) do
+ versioning_xml_node&.xpath(XPATH_VERSIONS)
+ &.first
+ end
+ end
+
+ def version_xml_nodes
+ versions_xml_node&.xpath(XPATH_VERSION)
+ end
+
+ def latest_xml_node
+ strong_memoize(:latest_xml_node) do
+ versioning_xml_node&.xpath(XPATH_LATEST)
+ &.first
+ end
+ end
+
+ def release_xml_node
+ strong_memoize(:release_xml_node) do
+ versioning_xml_node&.xpath(XPATH_RELEASE)
+ &.first
+ end
+ end
+
+ def last_updated_xml_node
+ strong_memoize(:last_updated_xml_mode) do
+ versioning_xml_node.xpath(XPATH_LAST_UPDATED)
+ .first
+ end
+ end
+
+ def versions_from_xml
+ strong_memoize(:versions_from_xml) do
+ versions_xml_node.xpath(XPATH_VERSION)
+ .map(&:text)
+ end
+ end
+
+ def latest_from_xml
+ latest_xml_node&.text
+ end
+
+ def release_from_xml
+ release_xml_node&.text
+ end
+
+ def versions_from_database
+ strong_memoize(:versions_from_database) do
+ @package.project.packages
+ .maven
+ .displayable
+ .with_name(@package.name)
+ .has_version
+ .order_created
+ .pluck_versions
+ end
+ end
+
+ def latest_from_database
+ versions_from_database.last
+ end
+
+ def release_from_database
+ strong_memoize(:release_from_database) do
+ non_snapshot_versions_from_database = versions_from_database.reject { |v| v.ends_with?('SNAPSHOT') }
+ non_snapshot_versions_from_database.last
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/maven/metadata/sync_service.rb b/app/services/packages/maven/metadata/sync_service.rb
new file mode 100644
index 00000000000..a6534aa706d
--- /dev/null
+++ b/app/services/packages/maven/metadata/sync_service.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+module Packages
+ module Maven
+ module Metadata
+ class SyncService < BaseContainerService
+ include Gitlab::Utils::StrongMemoize
+
+ alias_method :project, :container
+
+ MAX_FILE_SIZE = 10.megabytes.freeze
+
+ def execute
+ return error('Blank package name') unless package_name
+ return error('Not allowed') unless Ability.allowed?(current_user, :destroy_package, project)
+ return error('Non existing versionless package') unless versionless_package_for_versions
+ return error('Non existing metadata file for versions') unless metadata_package_file_for_versions
+
+ if metadata_package_file_for_plugins
+ result = update_plugins_xml
+
+ return result if result.error?
+ end
+
+ update_versions_xml
+ end
+
+ private
+
+ def update_versions_xml
+ update_xml(
+ kind: :versions,
+ package_file: metadata_package_file_for_versions,
+ service_class: CreateVersionsXmlService,
+ payload_empty_field: :empty_versions
+ )
+ end
+
+ def update_plugins_xml
+ update_xml(
+ kind: :plugins,
+ package_file: metadata_package_file_for_plugins,
+ service_class: CreatePluginsXmlService,
+ payload_empty_field: :empty_plugins
+ )
+ end
+
+ def update_xml(kind:, package_file:, service_class:, payload_empty_field:)
+ return error("Metadata file for #{kind} is too big") if package_file.size > MAX_FILE_SIZE
+
+ package_file.file.use_open_file do |file|
+ result = service_class.new(metadata_content: file, package: package_file.package)
+ .execute
+
+ next result unless result.success?
+ next success("No changes for #{kind} xml") unless result.payload[:changes_exist]
+
+ if result.payload[payload_empty_field]
+ package_file.package.destroy!
+ success("Versionless package for #{kind} destroyed")
+ else
+ AppendPackageFileService.new(metadata_content: result.payload[:metadata_content], package: package_file.package)
+ .execute
+ end
+ end
+ end
+
+ def metadata_package_file_for_versions
+ strong_memoize(:metadata_file_for_versions) do
+ metadata_package_file_for(versionless_package_for_versions)
+ end
+ end
+
+ def versionless_package_for_versions
+ strong_memoize(:versionless_package_for_versions) do
+ versionless_package_named(package_name)
+ end
+ end
+
+ def metadata_package_file_for_plugins
+ strong_memoize(:metadata_package_file_for_plugins) do
+ metadata_package_file_for(versionless_package_named(package_name_for_plugins))
+ end
+ end
+
+ def metadata_package_file_for(package)
+ return unless package
+
+ package.package_files
+ .with_file_name(Metadata.filename)
+ .recent
+ .first
+ end
+
+ def versionless_package_named(name)
+ project.packages
+ .maven
+ .displayable
+ .with_name(name)
+ .with_version(nil)
+ .first
+ end
+
+ def package_name
+ params[:package_name]
+ end
+
+ def package_name_for_plugins
+ group = versionless_package_for_versions.maven_metadatum.app_group
+ group.tr('.', '/')
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def success(message)
+ ServiceResponse.success(message: message)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/nuget/create_package_service.rb b/app/services/packages/nuget/create_package_service.rb
deleted file mode 100644
index 3999ccd3347..00000000000
--- a/app/services/packages/nuget/create_package_service.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module Packages
- module Nuget
- class CreatePackageService < ::Packages::CreatePackageService
- TEMPORARY_PACKAGE_NAME = 'NuGet.Temporary.Package'
- PACKAGE_VERSION = '0.0.0'
-
- def execute
- create_package!(:nuget,
- 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/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb
index 0109ee23c49..1bcab00bd92 100644
--- a/app/services/packages/nuget/update_package_from_metadata_service.rb
+++ b/app/services/packages/nuget/update_package_from_metadata_service.rb
@@ -68,7 +68,8 @@ module Packages
def update_linked_package
@package_file.package.update!(
name: package_name,
- version: package_version
+ version: package_version,
+ status: :default
)
::Packages::Nuget::CreateDependencyService.new(@package_file.package, package_dependencies)
diff --git a/app/services/packages/rubygems/dependency_resolver_service.rb b/app/services/packages/rubygems/dependency_resolver_service.rb
new file mode 100644
index 00000000000..c44b26e2b92
--- /dev/null
+++ b/app/services/packages/rubygems/dependency_resolver_service.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Packages
+ module Rubygems
+ class DependencyResolverService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ DEFAULT_PLATFORM = 'ruby'
+
+ def execute
+ return ServiceResponse.error(message: "forbidden", http_status: :forbidden) unless Ability.allowed?(current_user, :read_package, project)
+ return ServiceResponse.error(message: "#{gem_name} not found", http_status: :not_found) if packages.empty?
+
+ payload = packages.map do |package|
+ dependencies = package.dependency_links.map do |link|
+ [link.dependency.name, link.dependency.version_pattern]
+ end
+
+ {
+ name: gem_name,
+ number: package.version,
+ platform: DEFAULT_PLATFORM,
+ dependencies: dependencies
+ }
+ end
+
+ ServiceResponse.success(payload: payload)
+ end
+
+ private
+
+ def packages
+ strong_memoize(:packages) do
+ project.packages.with_name(gem_name)
+ end
+ end
+
+ def gem_name
+ params[:gem_name]
+ end
+ end
+ end
+end
diff --git a/app/services/pages/legacy_storage_lease.rb b/app/services/pages/legacy_storage_lease.rb
index 3f42fc8c63b..1849def0183 100644
--- a/app/services/pages/legacy_storage_lease.rb
+++ b/app/services/pages/legacy_storage_lease.rb
@@ -8,15 +8,6 @@ module Pages
LEASE_TIMEOUT = 1.hour
- # override method from exclusive lease guard to guard it by feature flag
- # TODO: just remove this method after testing this in production
- # https://gitlab.com/gitlab-org/gitlab/-/issues/282464
- def try_obtain_lease
- return yield unless Feature.enabled?(:pages_use_legacy_storage_lease, project, default_enabled: true)
-
- super
- end
-
def lease_key
"pages_legacy_storage:#{project.id}"
end
diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb
index 53bd954eab6..68086f636b7 100644
--- a/app/services/projects/autocomplete_service.rb
+++ b/app/services/projects/autocomplete_service.rb
@@ -16,7 +16,7 @@ module Projects
finder_params[:group_ids] = @project.group.self_and_ancestors.select(:id) if @project.group
- MilestonesFinder.new(finder_params).execute.select([:iid, :title])
+ MilestonesFinder.new(finder_params).execute.select([:iid, :title, :due_date])
end
def merge_requests
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 08f569662a8..e3b1fd5f4c0 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -19,6 +19,8 @@ module Projects
@project = Project.new(params)
+ @project.visibility_level = @project.group.visibility_level unless @project.visibility_level_allowed_by_group?
+
# If a project is newly created it should have shared runners settings
# based on its group having it enabled. This is like the "default value"
@project.shared_runners_enabled = false if !params.key?(:shared_runners_enabled) && @project.group && @project.group.shared_runners_setting != 'enabled'
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index c1501625300..6840c395a76 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -107,12 +107,7 @@ module Projects
end
project.leave_pool_repository
-
- if Gitlab::Ci::Features.project_transactionless_destroy?(project)
- destroy_project_related_records(project)
- else
- Project.transaction { destroy_project_related_records(project) }
- end
+ destroy_project_related_records(project)
end
def destroy_project_related_records(project)
diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb
index 3fcc721fe65..3262839e246 100644
--- a/app/services/projects/group_links/create_service.rb
+++ b/app/services/projects/group_links/create_service.rb
@@ -23,7 +23,7 @@ module Projects
private
def setup_authorizations(group, group_access = nil)
- if Feature.enabled?(:specialized_project_authorization_project_share_worker)
+ if Feature.enabled?(:specialized_project_authorization_project_share_worker, default_enabled: :yaml)
AuthorizedProjectUpdate::ProjectGroupLinkCreateWorker.perform_async(
project.id, group.id, group_access)
diff --git a/app/services/projects/schedule_bulk_repository_shard_moves_service.rb b/app/services/projects/schedule_bulk_repository_shard_moves_service.rb
index 53de9abdb59..98fc2e22967 100644
--- a/app/services/projects/schedule_bulk_repository_shard_moves_service.rb
+++ b/app/services/projects/schedule_bulk_repository_shard_moves_service.rb
@@ -25,7 +25,7 @@ module Projects
override :schedule_bulk_worker_klass
def self.schedule_bulk_worker_klass
- ::ProjectScheduleBulkRepositoryShardMovesWorker
+ ::Projects::ScheduleBulkRepositoryShardMovesWorker
end
end
end
diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb
index 67d388dc8a3..01539d58545 100644
--- a/app/services/projects/update_pages_configuration_service.rb
+++ b/app/services/projects/update_pages_configuration_service.rb
@@ -11,6 +11,8 @@ module Projects
end
def execute
+ return success unless Feature.enabled?(:pages_update_legacy_storage, default_enabled: true)
+
# If the pages were never deployed, we can't write out the config, as the
# directory would not exist.
# https://gitlab.com/gitlab-org/gitlab/-/issues/235139
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 29e92d725e2..2b59fdd539d 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -33,6 +33,7 @@ module Projects
@status = create_status
@status.enqueue!
@status.run!
+ @status.update_older_statuses_retried! if Feature.enabled?(:ci_fix_commit_status_retried, project, default_enabled: :yaml)
raise InvalidStateError, 'missing pages artifacts' unless build.artifacts?
raise InvalidStateError, 'build SHA is outdated for this ref' unless latest?
diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb
index ac4917d6590..bf1a966472b 100644
--- a/app/services/protected_branches/api_service.rb
+++ b/app/services/protected_branches/api_service.rb
@@ -9,10 +9,15 @@ module ProtectedBranches
def protected_branch_params
{
name: params[:name],
+ allow_force_push: allow_force_push?,
push_access_levels_attributes: AccessLevelParams.new(:push, params).access_levels,
merge_access_levels_attributes: AccessLevelParams.new(:merge, params).access_levels
}
end
+
+ def allow_force_push?
+ params[:allow_force_push] || false
+ end
end
end
diff --git a/app/services/repositories/changelog_service.rb b/app/services/repositories/changelog_service.rb
index 96a63865a49..3981e91e7f3 100644
--- a/app/services/repositories/changelog_service.rb
+++ b/app/services/repositories/changelog_service.rb
@@ -39,10 +39,10 @@ module Repositories
project,
user,
version:,
- to:,
+ branch: project.default_branch_or_master,
from: nil,
+ to: branch,
date: DateTime.now,
- branch: project.default_branch_or_master,
trailer: DEFAULT_TRAILER,
file: DEFAULT_FILE,
message: "Add changelog for version #{version}"
@@ -73,7 +73,7 @@ module Repositories
.new(version: @version, date: @date, config: config)
commits =
- CommitsWithTrailerFinder.new(project: @project, from: from, to: @to)
+ ChangelogCommitsFinder.new(project: @project, from: from, to: @to)
commits.each_page(@trailer) do |page|
mrs = mrs_finder.execute(page)
diff --git a/app/services/security/vulnerability_uuid.rb b/app/services/security/vulnerability_uuid.rb
new file mode 100644
index 00000000000..3eab0f3dad6
--- /dev/null
+++ b/app/services/security/vulnerability_uuid.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Security
+ class VulnerabilityUUID
+ def self.generate(report_type:, primary_identifier_fingerprint:, location_fingerprint:, project_id:)
+ Gitlab::UUID.v5("#{report_type}-#{primary_identifier_fingerprint}-#{location_fingerprint}-#{project_id}")
+ end
+ end
+end
diff --git a/app/services/snippets/schedule_bulk_repository_shard_moves_service.rb b/app/services/snippets/schedule_bulk_repository_shard_moves_service.rb
index f7bdd0a99a5..09eac8e6cda 100644
--- a/app/services/snippets/schedule_bulk_repository_shard_moves_service.rb
+++ b/app/services/snippets/schedule_bulk_repository_shard_moves_service.rb
@@ -25,7 +25,7 @@ module Snippets
override :schedule_bulk_worker_klass
def self.schedule_bulk_worker_klass
- ::SnippetScheduleBulkRepositoryShardMovesWorker
+ ::Snippets::ScheduleBulkRepositoryShardMovesWorker
end
end
end
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index ff32bc32d93..185b9e39070 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -9,7 +9,9 @@ module Spam
# after the spammable is created/updated based on the remaining parameters.
#
# Takes a hash of parameters from an incoming request to modify a model (via a controller,
- # service, or GraphQL mutation).
+ # service, or GraphQL mutation). The parameters will either be camelCase (if they are
+ # received directly via controller params) or underscore_case (if they have come from
+ # a GraphQL mutation which has converted them to underscore)
#
# Deletes the parameters which are related to spam and captcha processing, and returns
# them in a SpamParams parameters object. See:
@@ -18,12 +20,12 @@ module Spam
# NOTE: The 'captcha_response' field can be expanded to multiple fields when we move to future
# alternative captcha implementations such as FriendlyCaptcha. See
# https://gitlab.com/gitlab-org/gitlab/-/issues/273480
- captcha_response = params.delete(:captcha_response)
+ captcha_response = params.delete(:captcha_response) || params.delete(:captchaResponse)
SpamParams.new(
api: params.delete(:api),
captcha_response: captcha_response,
- spam_log_id: params.delete(:spam_log_id)
+ spam_log_id: params.delete(:spam_log_id) || params.delete(:spamLogId)
)
end
diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb
index 5273dedb56f..d854b95cb93 100644
--- a/app/services/system_hooks_service.rb
+++ b/app/services/system_hooks_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class SystemHooksService
- BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember, Group].freeze
+ BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember, Group, ProjectMember].freeze
def execute_hooks_for(model, event)
data = build_event_data(model, event)
@@ -56,22 +56,13 @@ class SystemHooksService
when :failed_login
data[:state] = model.state
end
- when ProjectMember
- data.merge!(project_member_data(model))
end
data
end
def build_event_name(model, event)
- case model
- when ProjectMember
- return "user_add_to_team" if event == :create
- return "user_remove_from_team" if event == :destroy
- return "user_update_for_team" if event == :update
- else
- "#{model.class.name.downcase}_#{event}"
- end
+ "#{model.class.name.downcase}_#{event}"
end
def project_data(model)
@@ -88,23 +79,6 @@ class SystemHooksService
}
end
- def project_member_data(model)
- project = model.project || Project.unscoped.find(model.source_id)
-
- {
- project_name: project.name,
- project_path: project.path,
- project_path_with_namespace: project.full_path,
- project_id: project.id,
- user_username: model.user.username,
- user_name: model.user.name,
- user_email: model.user.email,
- user_id: model.user.id,
- access_level: model.human_access,
- project_visibility: Project.visibility_levels.key(project.visibility_level_value).downcase
- }
- end
-
def user_data(model)
{
name: model.name,
@@ -124,6 +98,8 @@ class SystemHooksService
Gitlab::HookData::GroupMemberBuilder
when Group
Gitlab::HookData::GroupBuilder
+ when ProjectMember
+ Gitlab::HookData::ProjectMemberBuilder
end
builder_class.new(model).build(event)
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 7d654ca7f5b..082ed93eca2 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -175,7 +175,7 @@ module SystemNoteService
# Example Note text is based on event_type:
#
# update: "changed target branch from `Old` to `New`"
- # delete: "changed automatically target branch to `New` because `Old` was deleted"
+ # delete: "deleted the `Old` branch. This merge request now targets the `New` branch"
#
# Returns the created Note object
def change_branch(noteable, project, author, branch_type, event_type, old_branch, new_branch)
@@ -241,6 +241,10 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_canonical_issue_of_duplicate(duplicate_issue)
end
+ def add_email_participants(noteable, project, author, body)
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).add_email_participants(body)
+ end
+
def discussion_lock(issuable, author)
::SystemNotes::IssuablesService.new(noteable: issuable, project: issuable.project, author: author).discussion_lock
end
@@ -323,6 +327,10 @@ module SystemNoteService
::SystemNotes::IncidentService.new(noteable: incident, project: incident.project, author: author).change_incident_severity
end
+ def log_resolving_alert(alert, monitoring_tool)
+ ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project).log_resolving_alert(monitoring_tool)
+ end
+
private
def merge_requests_service(noteable, project, author)
diff --git a/app/services/system_notes/alert_management_service.rb b/app/services/system_notes/alert_management_service.rb
index 376f2c1cfbf..27ddf2e36f1 100644
--- a/app/services/system_notes/alert_management_service.rb
+++ b/app/services/system_notes/alert_management_service.rb
@@ -62,5 +62,20 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'status'))
end
+
+ # Called when an alert is resolved due to received resolving alert payload
+ #
+ # alert - AlertManagement::Alert object.
+ #
+ # Example Note text:
+ #
+ # "changed the status to Resolved by closing issue #17"
+ #
+ # Returns the created Note object
+ def log_resolving_alert(monitoring_tool)
+ body = "logged a resolving alert from **#{monitoring_tool}**"
+
+ create_note(NoteSummary.new(noteable, project, User.alert_bot, body, action: 'new_alert_added'))
+ end
end
end
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index b344b240a07..60dd56e772a 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -125,8 +125,8 @@ module SystemNotes
old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_title, new_title).inline_diffs
- marked_old_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(old_title).mark(old_diffs, mode: :deletion)
- marked_new_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(new_title).mark(new_diffs, mode: :addition)
+ marked_old_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(old_title).mark(old_diffs)
+ marked_new_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(new_title).mark(new_diffs)
body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**"
@@ -354,6 +354,10 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
end
+ def add_email_participants(body)
+ create_note(NoteSummary.new(noteable, project, author, body))
+ end
+
def discussion_lock
action = noteable.discussion_locked? ? 'locked' : 'unlocked'
body = "#{action} this #{noteable.class.to_s.titleize.downcase}"
diff --git a/app/services/system_notes/merge_requests_service.rb b/app/services/system_notes/merge_requests_service.rb
index 99e03e67bf1..546a23c95c2 100644
--- a/app/services/system_notes/merge_requests_service.rb
+++ b/app/services/system_notes/merge_requests_service.rb
@@ -90,14 +90,14 @@ module SystemNotes
# Example Note text is based on event_type:
#
# update: "changed target branch from `Old` to `New`"
- # delete: "changed automatically target branch to `New` because `Old` was deleted"
+ # delete: "deleted the `Old` branch. This merge request now targets the `New` branch"
#
# Returns the created Note object
def change_branch(branch_type, event_type, old_branch, new_branch)
body =
case event_type.to_s
when 'delete'
- "changed automatically #{branch_type} branch to `#{new_branch}` because `#{old_branch}` was deleted"
+ "deleted the `#{old_branch}` branch. This merge request now targets the `#{new_branch}` branch"
when 'update'
"changed #{branch_type} branch from `#{old_branch}` to `#{new_branch}`"
else
diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb
index 9500a821071..db47bc024ba 100644
--- a/app/services/terraform/remote_state_handler.rb
+++ b/app/services/terraform/remote_state_handler.rb
@@ -60,7 +60,7 @@ module Terraform
private
def retrieve_with_lock(find_only: false)
- create_or_find!(find_only: find_only).tap { |state| retry_optimistic_lock(state) { |state| yield state } }
+ create_or_find!(find_only: find_only).tap { |state| retry_optimistic_lock(state, name: 'terraform_remote_state_handler_retrieve') { |state| yield state } }
end
def create_or_find!(find_only:)
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index e3f02bf85f0..b3b172f9df2 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -83,7 +83,8 @@ module Users
:location,
:public_email,
:user_type,
- :note
+ :note,
+ :view_diffs_file_by_file
]
end
diff --git a/app/services/users/dismiss_user_callout_service.rb b/app/services/users/dismiss_user_callout_service.rb
new file mode 100644
index 00000000000..f05c44186bb
--- /dev/null
+++ b/app/services/users/dismiss_user_callout_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Users
+ class DismissUserCalloutService < BaseContainerService
+ def execute
+ current_user.find_or_initialize_callout(params[:feature_name]).tap do |callout|
+ callout.update(dismissed_at: Time.current) if callout.valid?
+ end
+ end
+ end
+end
diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb
index 24e3fb73370..070713929e4 100644
--- a/app/services/users/refresh_authorized_projects_service.rb
+++ b/app/services/users/refresh_authorized_projects_service.rb
@@ -92,7 +92,7 @@ module Users
# remove - The IDs of the authorization rows to remove.
# add - Rows to insert in the form `[user id, project id, access level]`
def update_authorizations(remove = [], add = [])
- log_refresh_details(remove.length, add.length)
+ log_refresh_details(remove, add)
User.transaction do
user.remove_project_authorizations(remove) unless remove.empty?
@@ -104,11 +104,16 @@ module Users
user.reset
end
- def log_refresh_details(rows_deleted, rows_added)
+ def log_refresh_details(remove, add)
Gitlab::AppJsonLogger.info(event: 'authorized_projects_refresh',
+ user_id: user.id,
'authorized_projects_refresh.source': source,
- 'authorized_projects_refresh.rows_deleted': rows_deleted,
- 'authorized_projects_refresh.rows_added': rows_added)
+ 'authorized_projects_refresh.rows_deleted_count': remove.length,
+ 'authorized_projects_refresh.rows_added_count': add.length,
+ # most often there's only a few entries in remove and add, but limit it to the first 5
+ # entries to avoid flooding the logs
+ 'authorized_projects_refresh.rows_deleted_slice': remove.first(5),
+ 'authorized_projects_refresh.rows_added_slice': add.first(5))
end
def fresh_access_levels_per_project
diff --git a/app/uploaders/dependency_proxy/file_uploader.rb b/app/uploaders/dependency_proxy/file_uploader.rb
index b67a22bae4d..c46539bafaa 100644
--- a/app/uploaders/dependency_proxy/file_uploader.rb
+++ b/app/uploaders/dependency_proxy/file_uploader.rb
@@ -3,6 +3,7 @@
class DependencyProxy::FileUploader < GitlabUploader
include ObjectStorage::Concern
+ before :cache, :set_content_type
storage_options Gitlab.config.dependency_proxy
alias_method :upload, :model
@@ -17,6 +18,17 @@ class DependencyProxy::FileUploader < GitlabUploader
private
+ # Docker manifests return a custom content type
+ # GCP will only use the content-type that is stored with the file
+ # and will not allow it to be overwritten when downloaded
+ # so we must store the custom content type in object storage.
+ # This does not apply to DependencyProxy::Blob uploads.
+ def set_content_type(file)
+ return unless model.class == DependencyProxy::Manifest
+
+ file.content_type = model.content_type
+ end
+
def dynamic_segment
Gitlab::HashedPath.new('dependency_proxy', model.group_id, 'files', model.id, root_hash: model.group_id)
end
diff --git a/app/validators/gitlab/utils/zoom_url_validator.rb b/app/validators/gitlab/utils/zoom_url_validator.rb
new file mode 100644
index 00000000000..57e30dcefa6
--- /dev/null
+++ b/app/validators/gitlab/utils/zoom_url_validator.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# Gitlab::Utils::ZoomUrlValidator
+#
+# Custom validator for zoom urls
+#
+module Gitlab
+ module Utils
+ class ZoomUrlValidator < ActiveModel::EachValidator
+ ALLOWED_SCHEMES = %w(https).freeze
+
+ def validate_each(record, attribute, value)
+ links_count = Gitlab::ZoomLinkExtractor.new(value).links.size
+ valid = Gitlab::UrlSanitizer.valid?(value, allowed_schemes: ALLOWED_SCHEMES)
+
+ return if links_count == 1 && valid
+
+ record.errors.add(:url, 'must contain one valid Zoom URL')
+ end
+ end
+ end
+end
diff --git a/app/validators/json_schemas/security_scan_info.json b/app/validators/json_schemas/security_scan_info.json
new file mode 100644
index 00000000000..c8932c1870d
--- /dev/null
+++ b/app/validators/json_schemas/security_scan_info.json
@@ -0,0 +1,28 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "title": "Security::Scan#info schema",
+ "description": "The schema validates the content of the Security::Scan#info attribute",
+ "additionalProperties": false,
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "type",
+ "message"
+ ]
+ }
+ }
+ }
+}
diff --git a/app/validators/json_schemas/vulnerability_finding_details.json b/app/validators/json_schemas/vulnerability_finding_details.json
index f2940866f4b..2ba1fc9e9db 100644
--- a/app/validators/json_schemas/vulnerability_finding_details.json
+++ b/app/validators/json_schemas/vulnerability_finding_details.json
@@ -6,60 +6,67 @@
"^.*$": {
"allOf": [
{ "$ref": "#/definitions/named_field" },
- { "$ref": "#/definitions/type_list" }
+ { "$ref": "#/definitions/detail_type" }
]
}
},
"definitions": {
- "type_list": {
+ "detail_type": {
"oneOf": [
{ "$ref": "#/definitions/named_list" },
{ "$ref": "#/definitions/list" },
{ "$ref": "#/definitions/table" },
-
{ "$ref": "#/definitions/text" },
{ "$ref": "#/definitions/url" },
{ "$ref": "#/definitions/code" },
- { "$ref": "#/definitions/int" },
-
+ { "$ref": "#/definitions/value" },
+ { "$ref": "#/definitions/diff" },
+ { "$ref": "#/definitions/markdown" },
{ "$ref": "#/definitions/commit" },
{ "$ref": "#/definitions/file_location" },
{ "$ref": "#/definitions/module_location" }
]
},
- "lang_text": {
- "type": "object",
- "required": [ "value", "lang" ],
- "properties": {
- "lang": { "type": "string" },
- "value": { "type": "string" }
- }
- },
- "lang_text_list": {
- "type": "array",
- "items": { "$ref": "#/definitions/lang_text" }
+ "text_value": {
+ "type": "string"
},
"named_field": {
"type": "object",
- "required": [ "name" ],
+ "required": [
+ "name"
+ ],
"properties": {
- "name": { "$ref": "#/definitions/lang_text_list" },
- "description": { "$ref": "#/definitions/lang_text_list" }
+ "name": {
+ "$ref": "#/definitions/text_value",
+ "minLength": 1
+ },
+ "description": {
+ "$ref": "#/definitions/text_value"
+ }
}
},
"named_list": {
"type": "object",
"description": "An object with named and typed fields",
- "required": [ "type", "items" ],
+ "required": [
+ "type",
+ "items"
+ ],
"properties": {
- "type": { "const": "named-list" },
+ "type": {
+ "const": "named-list"
+ },
"items": {
"type": "object",
"patternProperties": {
"^.*$": {
"allOf": [
- { "$ref": "#/definitions/named_field" },
- { "$ref": "#/definitions/type_list" }
+ {
+ "$ref": "#/definitions/named_field"
+ },
+ {
+ "$ref": "#/definitions/detail_type"
+ }
]
}
}
@@ -69,38 +76,45 @@
"list": {
"type": "object",
"description": "A list of typed fields",
- "required": [ "type", "items" ],
+ "required": [
+ "type",
+ "items"
+ ],
"properties": {
- "type": { "const": "list" },
+ "type": {
+ "const": "list"
+ },
"items": {
"type": "array",
- "items": { "$ref": "#/definitions/type_list" }
+ "items": {
+ "$ref": "#/definitions/detail_type"
+ }
}
}
},
"table": {
"type": "object",
"description": "A table of typed fields",
- "required": [],
+ "required": [
+ "type",
+ "rows"
+ ],
"properties": {
- "type": { "const": "table" },
- "items": {
- "type": "object",
- "properties": {
- "header": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/type_list"
- }
- },
- "rows": {
- "type": "array",
- "items": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/type_list"
- }
- }
+ "type": {
+ "const": "table"
+ },
+ "header": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/detail_type"
+ }
+ },
+ "rows": {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/detail_type"
}
}
}
@@ -109,73 +123,171 @@
"text": {
"type": "object",
"description": "Raw text",
- "required": [ "type", "value" ],
+ "required": [
+ "type",
+ "value"
+ ],
"properties": {
- "type": { "const": "text" },
- "value": { "$ref": "#/definitions/lang_text_list" }
+ "type": {
+ "const": "text"
+ },
+ "value": {
+ "$ref": "#/definitions/text_value"
+ }
}
},
"url": {
"type": "object",
"description": "A single URL",
- "required": [ "type", "href" ],
+ "required": [
+ "type",
+ "href"
+ ],
"properties": {
- "type": { "const": "url" },
- "text": { "$ref": "#/definitions/lang_text_list" },
- "href": { "type": "string" }
+ "type": {
+ "const": "url"
+ },
+ "text": {
+ "$ref": "#/definitions/text_value"
+ },
+ "href": {
+ "type": "string",
+ "minLength": 1,
+ "examples": ["http://mysite.com"]
+ }
}
},
"code": {
"type": "object",
"description": "A codeblock",
- "required": [ "type", "value" ],
+ "required": [
+ "type",
+ "value"
+ ],
"properties": {
- "type": { "const": "code" },
- "value": { "type": "string" },
- "lang": { "type": "string" }
+ "type": {
+ "const": "code"
+ },
+ "value": {
+ "type": "string"
+ },
+ "lang": {
+ "type": "string",
+ "description": "A programming language"
+ }
}
},
- "int": {
+ "value": {
"type": "object",
- "description": "An integer",
- "required": [ "type", "value" ],
+ "description": "A field that can store a range of types of value",
+ "required": ["type", "value"],
"properties": {
- "type": { "const": "int" },
- "value": { "type": "integer" },
- "format": {
- "type": "string",
- "enum": [ "default", "hex" ]
+ "type": { "const": "value" },
+ "value": {
+ "type": ["number", "string", "boolean"]
+ }
+ }
+ },
+ "diff": {
+ "type": "object",
+ "description": "A diff",
+ "required": [
+ "type",
+ "before",
+ "after"
+ ],
+ "properties": {
+ "type": {
+ "const": "diff"
+ },
+ "before": {
+ "type": "string"
+ },
+ "after": {
+ "type": "string"
+ }
+ }
+ },
+ "markdown": {
+ "type": "object",
+ "description": "GitLab flavoured markdown, see https://docs.gitlab.com/ee/user/markdown.html",
+ "required": [
+ "type",
+ "value"
+ ],
+ "properties": {
+ "type": {
+ "const": "markdown"
+ },
+ "value": {
+ "$ref": "#/definitions/text_value",
+ "examples": ["Here is markdown `inline code` #1 [test](gitlab.com)\n\n![GitLab Logo](https://about.gitlab.com/images/press/logo/preview/gitlab-logo-white-preview.png)"]
}
}
},
"commit": {
"type": "object",
- "description": "A specific commit within the project",
- "required": [ "type", "value" ],
+ "description": "A commit/tag/branch within the GitLab project",
+ "required": [
+ "type",
+ "value"
+ ],
"properties": {
- "type": { "const": "commit" },
- "value": { "type": "string", "description": "The commit SHA" }
+ "type": {
+ "const": "commit"
+ },
+ "value": {
+ "type": "string",
+ "description": "The commit SHA",
+ "minLength": 1
+ }
}
},
"file_location": {
"type": "object",
"description": "A location within a file in the project",
- "required": [ "type", "file_name", "line_start" ],
+ "required": [
+ "type",
+ "file_name",
+ "line_start"
+ ],
"properties": {
- "type": { "const": "file-location" },
- "file_name": { "type": "string" },
- "line_start": { "type": "integer" },
- "line_end": { "type": "integer" }
+ "type": {
+ "const": "file-location"
+ },
+ "file_name": {
+ "type": "string",
+ "minLength": 1
+ },
+ "line_start": {
+ "type": "integer"
+ },
+ "line_end": {
+ "type": "integer"
+ }
}
},
"module_location": {
"type": "object",
"description": "A location within a binary module of the form module+relative_offset",
- "required": [ "type", "module_name", "offset" ],
+ "required": [
+ "type",
+ "module_name",
+ "offset"
+ ],
"properties": {
- "type": { "const": "module-location" },
- "module_name": { "type": "string" },
- "offset": { "type": "integer" }
+ "type": {
+ "const": "module-location"
+ },
+ "module_name": {
+ "type": "string",
+ "minLength": 1,
+ "examples": ["compiled_binary"]
+ },
+ "offset": {
+ "type": "integer",
+ "examples": [100]
+ }
}
}
}
diff --git a/app/validators/zoom_url_validator.rb b/app/validators/zoom_url_validator.rb
deleted file mode 100644
index e0f8e4e34a2..00000000000
--- a/app/validators/zoom_url_validator.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-# ZoomUrlValidator
-#
-# Custom validator for zoom urls
-#
-class ZoomUrlValidator < ActiveModel::EachValidator
- ALLOWED_SCHEMES = %w(https).freeze
-
- def validate_each(record, attribute, value)
- links_count = Gitlab::ZoomLinkExtractor.new(value).links.size
- valid = Gitlab::UrlSanitizer.valid?(value, allowed_schemes: ALLOWED_SCHEMES)
-
- return if links_count == 1 && valid
-
- record.errors.add(:url, 'must contain one valid Zoom URL')
- end
-end
diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml
index 09b16c54700..78fa16c13a5 100644
--- a/app/views/abuse_reports/new.html.haml
+++ b/app/views/abuse_reports/new.html.haml
@@ -25,4 +25,4 @@
= _("Explain the problem. If appropriate, provide a link to the relevant issue or comment.")
.form-actions
- = f.submit _("Send report"), class: "gl-button btn btn-success"
+ = f.submit _("Send report"), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml
index e6f12f4785a..1aaea1999e5 100644
--- a/app/views/admin/appearances/_form.html.haml
+++ b/app/views/admin/appearances/_form.html.haml
@@ -101,7 +101,7 @@
= parsed_with_gfm
.gl-mt-3.gl-mb-3
- = f.submit 'Update appearance settings', class: 'btn gl-button btn-success'
+ = f.submit 'Update appearance settings', class: 'btn gl-button btn-confirm'
- if @appearance.persisted? || @appearance.updated_at
.mt-4
- if @appearance.persisted?
diff --git a/app/views/admin/appearances/preview_sign_in.html.haml b/app/views/admin/appearances/preview_sign_in.html.haml
index 6e5bb45c3cc..f972b3b5cbf 100644
--- a/app/views/admin/appearances/preview_sign_in.html.haml
+++ b/app/views/admin/appearances/preview_sign_in.html.haml
@@ -8,5 +8,5 @@
= label_tag :password
= password_field_tag :password, nil, class: "form-control gl-form-input bottom", title: 'This field is required.'
.form-group
- = button_tag "Sign in", class: "btn gl-button btn-success"
+ = button_tag "Sign in", class: "btn gl-button btn-confirm"
diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml
index ea9bdbed9ae..f050c0816b1 100644
--- a/app/views/admin/application_settings/_abuse.html.haml
+++ b/app/views/admin/application_settings/_abuse.html.haml
@@ -8,4 +8,4 @@
.form-text.text-muted
Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area.
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
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 009e0732911..2e5cf156a65 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -65,4 +65,4 @@
= render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f
= render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f
- = f.submit _('Save changes'), class: 'gl-button btn btn-success qa-save-changes-button'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index f11770b397e..0af244d54f3 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -68,4 +68,4 @@
= _("The default CI configuration path for new projects.").html_safe
= link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-cicd-configuration-path'), target: '_blank'
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml
index 494558a6c2d..c5ae5c579ad 100644
--- a/app/views/admin/application_settings/_diff_limits.html.haml
+++ b/app/views/admin/application_settings/_diff_limits.html.haml
@@ -12,4 +12,4 @@
= link_to sprite_icon('question-o'),
help_page_path('user/admin_area/diff_limits',
anchor: 'maximum-diff-patch-size')
- = f.submit _('Save changes'), class: 'gl-button btn btn-success'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml
index 8897d0eb14b..1ddf927ed13 100644
--- a/app/views/admin/application_settings/_eks.html.haml
+++ b/app/views/admin/application_settings/_eks.html.haml
@@ -3,8 +3,8 @@
.settings-header
%h4
= _('Amazon EKS')
- %button.btn.gl-button.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
%p
= _('Amazon EKS integration allows you to provision EKS clusters from GitLab.')
@@ -33,4 +33,4 @@
.form-text.text-muted
= _('AWS Secret Access Key. Only required if not using role instance credentials')
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml
index 89946c63bb0..b22aaabe41a 100644
--- a/app/views/admin/application_settings/_email.html.haml
+++ b/app/views/admin/application_settings/_email.html.haml
@@ -25,4 +25,12 @@
= render_if_exists 'admin/application_settings/email_additional_text_setting', form: f
- = f.submit _('Save changes'), class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
+ .form-group
+ .form-check
+ = f.check_box :in_product_marketing_emails_enabled, class: 'form-check-input'
+ = f.label :in_product_marketing_emails_enabled, class: 'form-check-label' do
+ = _('Enable in-product marketing emails')
+ .form-text.text-muted
+ = _('By default, GitLab sends emails to help guide users through the onboarding process.')
+
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
index 07256c9f4fe..97e09476e78 100644
--- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml
+++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
@@ -47,4 +47,4 @@
.form-group
= f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold'
= f.text_field :external_authorization_service_default_label, class: 'form-control gl-form-input'
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml
index 56ec35d9329..72e7cb0b437 100644
--- a/app/views/admin/application_settings/_gitaly.html.haml
+++ b/app/views/admin/application_settings/_gitaly.html.haml
@@ -24,4 +24,4 @@
.form-text.text-muted
Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout.
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml
index cca81136bb9..48b0c6be0a8 100644
--- a/app/views/admin/application_settings/_gitpod.html.haml
+++ b/app/views/admin/application_settings/_gitpod.html.haml
@@ -7,7 +7,7 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- %integration-help-text{ "id" => "js-gitpod-settings-help-text", "message" => gitpod_enable_description, "message-url" => "https://gitpod.io/" }
+ #js-gitpod-settings-help-text{ data: {"message" => gitpod_enable_description, "message-url" => "https://gitpod.io/" } }
= link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information')
@@ -25,4 +25,4 @@
= f.text_field :gitpod_url, class: 'form-control gl-form-input', placeholder: s_('Gitpod|e.g. https://gitpod.example.com')
.form-text.text-muted
= s_('Gitpod|Add the URL to your Gitpod instance configured to read your GitLab projects.')
- = f.submit s_('Save changes'), class: 'gl-button btn btn-success'
+ = f.submit s_('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_grafana.html.haml b/app/views/admin/application_settings/_grafana.html.haml
index 368b4db4549..fd9e7ee50c4 100644
--- a/app/views/admin/application_settings/_grafana.html.haml
+++ b/app/views/admin/application_settings/_grafana.html.haml
@@ -14,4 +14,4 @@
= f.label :grafana_url, _('Grafana URL'), class: 'label-bold'
= f.text_field :grafana_url, class: 'form-control gl-form-input', placeholder: '/-/grafana'
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml
index 858df44bd98..e7816f5a1c0 100644
--- a/app/views/admin/application_settings/_help_page.html.haml
+++ b/app/views/admin/application_settings/_help_page.html.haml
@@ -23,4 +23,4 @@
= f.label :help_page_documentation_base_url, _('Documentation pages URL'), class: 'label-bold'
= f.text_field :help_page_documentation_base_url, class: 'form-control gl-form-input', placeholder: 'https://docs.gitlab.com'
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_import_export_limits.html.haml b/app/views/admin/application_settings/_import_export_limits.html.haml
index e1a58c888a5..820c11279d5 100644
--- a/app/views/admin/application_settings/_import_export_limits.html.haml
+++ b/app/views/admin/application_settings/_import_export_limits.html.haml
@@ -31,4 +31,4 @@
= 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 gl-form-input'
- = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm", 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
index e7718f94b90..b5c178641df 100644
--- a/app/views/admin/application_settings/_initial_branch_name.html.haml
+++ b/app/views/admin/application_settings/_initial_branch_name.html.haml
@@ -10,4 +10,4 @@
%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'
+ = f.submit _('Save changes'), class: 'gl-button btn-confirm'
diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml
index a603eaec913..18d71a90e34 100644
--- a/app/views/admin/application_settings/_ip_limits.html.haml
+++ b/app/views/admin/application_settings/_ip_limits.html.haml
@@ -57,4 +57,4 @@
= _('A plain-text response to show to clients that hit the rate limit.')
= f.text_area :rate_limiting_response_text, placeholder: ::Gitlab::Throttle::DEFAULT_RATE_LIMITING_RESPONSE_TEXT, class: 'form-control gl-form-input', rows: 5
- = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_issue_limits.html.haml b/app/views/admin/application_settings/_issue_limits.html.haml
index e16561b4489..0e1ba8c9c88 100644
--- a/app/views/admin/application_settings/_issue_limits.html.haml
+++ b/app/views/admin/application_settings/_issue_limits.html.haml
@@ -6,4 +6,4 @@
= f.label :issues_create_limit, 'Max requests per minute per user', class: 'label-bold'
= f.number_field :issues_create_limit, class: 'form-control gl-form-input'
- = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml
index cd57d4cca65..fc51942ed1f 100644
--- a/app/views/admin/application_settings/_kroki.html.haml
+++ b/app/views/admin/application_settings/_kroki.html.haml
@@ -30,4 +30,4 @@
= f.check_box format[:name], class: 'form-check-input'
= f.label format[:name], format[:label], class: 'form-check-label'
- = f.submit _('Save changes'), class: "btn gl-button btn-success"
+ = f.submit _('Save changes'), class: "btn gl-button btn-confirm"
diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml
index 5ad7080b22b..fdb91937ec3 100644
--- a/app/views/admin/application_settings/_localization.html.haml
+++ b/app/views/admin/application_settings/_localization.html.haml
@@ -15,4 +15,4 @@
= f.label :time_tracking_limit_to_hours, class: 'form-check-label' do
= _('Limit display of time tracking units to hours.')
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_note_limits.html.haml b/app/views/admin/application_settings/_note_limits.html.haml
index 9578da90170..d50b3395d8f 100644
--- a/app/views/admin/application_settings/_note_limits.html.haml
+++ b/app/views/admin/application_settings/_note_limits.html.haml
@@ -9,4 +9,4 @@
= f.label :notes_create_limit_allowlist, _('List of users to be excluded from the limit'), class: 'label-bold'
= f.text_area :notes_create_limit_allowlist_raw, placeholder: 'username1, username2', class: 'form-control gl-form-input', rows: 5
- = f.submit _('Save changes'), class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml
index 694cc9deab6..d8d105293a1 100644
--- a/app/views/admin/application_settings/_outbound.html.haml
+++ b/app/views/admin/application_settings/_outbound.html.haml
@@ -27,4 +27,4 @@
%span.form-text.text-muted
= _('Resolves IP addresses once and uses them to submit requests')
- = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml
index 86df1aa6e02..0ca8493c596 100644
--- a/app/views/admin/application_settings/_package_registry.html.haml
+++ b/app/views/admin/application_settings/_package_registry.html.haml
@@ -36,7 +36,7 @@
= f.label :maven_max_file_size, _('Maximum Maven package file size in bytes'), class: 'label-bold'
= f.number_field :maven_max_file_size, class: 'form-control gl-form-input'
.form-group
- = f.label :npm_max_file_size, _('Maximum NPM package file size in bytes'), class: 'label-bold'
+ = f.label :npm_max_file_size, _('Maximum npm package file size in bytes'), class: 'label-bold'
= f.number_field :npm_max_file_size, class: 'form-control gl-form-input'
.form-group
= f.label :nuget_max_file_size, _('Maximum NuGet package file size in bytes'), class: 'label-bold'
@@ -47,4 +47,4 @@
.form-group
= f.label :generic_packages_max_file_size, _('Generic package file size in bytes'), class: 'label-bold'
= f.number_field :generic_packages_max_file_size, class: 'form-control gl-form-input'
- = f.submit _('Save %{name} size limits').html_safe % { name: plan.name.capitalize }, class: 'btn gl-button btn-success'
+ = f.submit _('Save %{name} size limits').html_safe % { name: plan.name.capitalize }, class: 'btn gl-button btn-confirm'
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
index 503aae861d0..8f52e8b8461 100644
--- a/app/views/admin/application_settings/_pages.html.haml
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -41,4 +41,4 @@
- terms_of_service_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: lets_encrypt_terms_of_service_admin_application_settings_path }
= _("I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end} (PDF)").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe }
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml
index 3efe163de7b..5ee68e8fd16 100644
--- a/app/views/admin/application_settings/_performance.html.haml
+++ b/app/views/admin/application_settings/_performance.html.haml
@@ -31,4 +31,4 @@
.form-text.text-muted
= _('Number of changes (branches or tags) in a single push to determine whether individual push events or bulk push event will be created. Bulk push event will be created if it surpasses that value.')
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml
index 2db22552596..8f2bdd109cb 100644
--- a/app/views/admin/application_settings/_performance_bar.html.haml
+++ b/app/views/admin/application_settings/_performance_bar.html.haml
@@ -4,11 +4,11 @@
%fieldset
.form-group
.form-check
- = f.check_box :performance_bar_enabled, class: 'form-check-input'
- = f.label :performance_bar_enabled, class: 'form-check-label qa-enable-performance-bar-checkbox' do
+ = f.check_box :performance_bar_enabled, class: 'form-check-input', data: { qa_selector: 'enable_performance_bar_checkbox'}
+ = f.label :performance_bar_enabled, class: 'form-check-label' do
Enable access to the Performance Bar
.form-group
= f.label :performance_bar_allowed_group_path, 'Allowed group', class: 'label-bold'
= f.text_field :performance_bar_allowed_group_path, class: 'form-control gl-form-input', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path
- = f.submit 'Save changes', class: 'gl-button btn btn-success qa-save-changes-button'
+ = f.submit 'Save changes', class: 'gl-button btn btn-confirm qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml
index 93fcc90f044..e6e9bbf3ee0 100644
--- a/app/views/admin/application_settings/_plantuml.html.haml
+++ b/app/views/admin/application_settings/_plantuml.html.haml
@@ -24,4 +24,4 @@
= link_to "PlantUML", "http://plantuml.com"
diagrams in Asciidoc documents using an external PlantUML service.
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml
index c394bc65046..468c1786d6f 100644
--- a/app/views/admin/application_settings/_prometheus.html.haml
+++ b/app/views/admin/application_settings/_prometheus.html.haml
@@ -30,4 +30,4 @@
A method call is only tracked when it takes longer to complete than
the given amount of milliseconds.
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml
index 57bba4f970a..faa675f211d 100644
--- a/app/views/admin/application_settings/_protected_paths.html.haml
+++ b/app/views/admin/application_settings/_protected_paths.html.haml
@@ -28,4 +28,4 @@
= _('All paths are relative to the GitLab URL. Do not include %{relative_url_link_start}relative URL%{relative_url_link_end}.').html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe }
= f.text_area :protected_paths_raw, placeholder: '/users/sign_in,/users/password', class: 'form-control gl-form-input', rows: 10
- = f.submit 'Save changes', class: 'gl-button btn btn-success'
+ = f.submit 'Save changes', class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml
index 2b54a15d615..bee120d2f78 100644
--- a/app/views/admin/application_settings/_realtime.html.haml
+++ b/app/views/admin/application_settings/_realtime.html.haml
@@ -14,4 +14,4 @@
installations. Set to 0 to completely disable polling.
= link_to sprite_icon('question-o'), help_page_path('administration/polling')
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml
index 5fb5effaa55..fc03a6dd10c 100644
--- a/app/views/admin/application_settings/_registry.html.haml
+++ b/app/views/admin/application_settings/_registry.html.haml
@@ -31,4 +31,4 @@
.form-text.text-muted
= _("The maximum number of tags that a single worker accepts for cleanup. If the number of tags goes above this limit, the list of tags to delete is truncated to this number. To remove this limit, set it to 0.")
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index 24e74dd0f1b..ee0281b6e33 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -55,4 +55,4 @@
.form-text.text-muted
Number of Git pushes after which 'git gc' is run.
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
index 125fa48bbc3..a0076a2f75d 100644
--- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml
+++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
@@ -14,4 +14,4 @@
= render_if_exists 'admin/application_settings/mirror_settings', form: f
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml
index 42fe2b24bb2..f8ec04003fa 100644
--- a/app/views/admin/application_settings/_repository_static_objects.html.haml
+++ b/app/views/admin/application_settings/_repository_static_objects.html.haml
@@ -15,4 +15,4 @@
%span.form-text.text-muted#static_objects_external_storage_auth_token_help_block
= _('A secure token that identifies an external storage request.')
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
index 0862d1bf0b6..ab1b2bab573 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -18,8 +18,9 @@
= _('Enter weights for storages for new repositories.')
= link_to sprite_icon('question-o'), help_page_path('administration/repository_storage_paths')
.form-check
- - storage_weights.each do |attribute|
- = f.text_field attribute[:name], class: 'form-text-input', value: attribute[:value]
- = f.label attribute[:label], attribute[:label], class: 'label-bold form-check-label'
- %br
- = f.submit _('Save changes'), class: "gl-button btn btn-success qa-save-changes-button"
+ = f.fields_for :repository_storages_weighted, storage_weights do |storage_form|
+ - Gitlab.config.repositories.storages.keys.each do |storage|
+ = storage_form.text_field storage, class: 'form-text-input'
+ = storage_form.label storage, storage, class: 'label-bold form-check-label'
+ %br
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 23a7856e483..54bd5cf4072 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -20,12 +20,11 @@
or LDAP password
must be used to authenticate.
- if omniauth_enabled? && button_based_providers.any?
- .form-group
- = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'label-bold'
+ %fieldset.form-group
+ %legend.gl-font-base.gl-mb-3.gl-border-none.gl-font-weight-bold= _('Enabled OAuth sign-in sources')
= hidden_field_tag 'application_setting[enabled_oauth_sign_in_sources][]'
- .btn-group{ data: { toggle: 'buttons' } }
- - oauth_providers_checkboxes.each do |source|
- = source
+ - oauth_providers_checkboxes.each do |source|
+ = source
.form-group
= f.label :two_factor_authentication, 'Two-factor authentication', class: 'label-bold'
.form-check
@@ -57,4 +56,4 @@
= f.label :sign_in_text, _('Sign-in text'), class: 'label-bold'
= f.text_area :sign_in_text, class: 'form-control gl-form-input', rows: 4
.form-text.text-muted Markdown enabled
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml
index 82824f1d436..272eba67b1b 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -77,4 +77,4 @@
= f.label :after_sign_up_text, class: 'label-bold'
= f.text_area :after_sign_up_text, class: 'form-control gl-form-input', rows: 4
.form-text.text-muted Markdown enabled
- = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index 5f5a3a6992c..e6ac2a4db34 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -26,4 +26,4 @@
= f.label :snowplow_cookie_domain, _('Cookie domain'), class: 'label-light'
= f.text_field :snowplow_cookie_domain, class: 'form-control gl-form-input'
- = f.submit _('Save changes'), class: 'gl-button btn btn-success'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml
index e1af269c6fd..af25577f058 100644
--- a/app/views/admin/application_settings/_sourcegraph.html.haml
+++ b/app/views/admin/application_settings/_sourcegraph.html.haml
@@ -5,7 +5,7 @@
.settings-header
%h4
= _('Sourcegraph')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://sourcegraph.com/' }
@@ -35,4 +35,4 @@
= f.text_field :sourcegraph_url, class: 'form-control gl-form-input', placeholder: s_('SourcegraphAdmin|e.g. https://sourcegraph.example.com')
.form-text.text-muted
= s_('SourcegraphAdmin|Configure the URL to a Sourcegraph instance which can read your GitLab projects.')
- = f.submit s_('SourcegraphAdmin|Save changes'), class: 'gl-button btn btn-success'
+ = f.submit s_('SourcegraphAdmin|Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml
index 6085cea4f5d..2086fbc9d32 100644
--- a/app/views/admin/application_settings/_spam.html.haml
+++ b/app/views/admin/application_settings/_spam.html.haml
@@ -79,4 +79,4 @@
= f.label :spam_check_endpoint_url, _('URL of the external Spam Check endpoint'), class: 'label-bold'
= f.text_field :spam_check_endpoint_url, class: 'form-control gl-form-input'
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml
index 8f89cf27291..487ce25a4da 100644
--- a/app/views/admin/application_settings/_terminal.html.haml
+++ b/app/views/admin/application_settings/_terminal.html.haml
@@ -8,4 +8,4 @@
.form-text.text-muted
Maximum time for web terminal websocket connection (in seconds).
0 for unlimited.
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml
index 717b2220336..8cc4169b383 100644
--- a/app/views/admin/application_settings/_terms.html.haml
+++ b/app/views/admin/application_settings/_terms.html.haml
@@ -15,4 +15,4 @@
= f.text_area :terms, class: 'form-control gl-form-input', rows: 8
.form-text.text-muted
= _("Markdown enabled")
- = f.submit _("Save changes"), class: "gl-button btn btn-success"
+ = f.submit _("Save changes"), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml
index 32023b11993..970c9c6b003 100644
--- a/app/views/admin/application_settings/_third_party_offers.html.haml
+++ b/app/views/admin/application_settings/_third_party_offers.html.haml
@@ -17,4 +17,4 @@
= f.check_box :hide_third_party_offers, class: 'form-check-input'
= f.label :hide_third_party_offers, _('Do not display offers from third parties within GitLab'), class: 'form-check-label'
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index fe83d4b807c..00306e1ba06 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -33,8 +33,8 @@
%pre.usage-data.js-syntax-highlight.code.highlight.mt-2.d-none{ class: payload_class, 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('development/usage_ping', anchor: 'disable-usage-ping')
+ - deactivating_usage_ping_path = help_page_path('development/usage_ping/index.md', 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 }
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index 0931ba50aa7..e51a41d5254 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -74,4 +74,4 @@
= f.label :disable_feed_token, class: 'form-check-label' do
= s_('AdminSettings|Disable feed token')
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 794e02787f5..3a14b4fbc7b 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -6,7 +6,7 @@
.settings-header
%h4
= _('Visibility and access controls')
- %button.btn.gl-button.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Set default and restrict visibility levels. Configure import sources and git access protocol.')
@@ -17,7 +17,7 @@
.settings-header
%h4
= _('Account and limit')
- %button.btn.gl-button.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Set projects and maximum size limits, session duration, user options, and check feature availability for namespace plan.')
@@ -28,7 +28,7 @@
.settings-header
%h4
= _('Diff limits')
- %button.btn.gl-button.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Diff content limits')
@@ -39,7 +39,7 @@
.settings-header
%h4
= _('Sign-up restrictions')
- %button.btn.gl-button.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Configure the way a user creates a new account.')
@@ -50,7 +50,7 @@
.settings-header
%h4
= _('Sign-in restrictions')
- %button.btn.gl-button.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Set requirements for a user to sign-in. Enable mandatory two-factor authentication.')
@@ -101,7 +101,7 @@
= s_('IDE|Live Preview')
%span.form-text.text-muted
= s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox Live Preview.')
- = f.submit _('Save changes'), class: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
= render_if_exists 'admin/application_settings/maintenance_mode_settings_form'
= render 'admin/application_settings/gitpod'
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index 14044d6eecb..e7e17502da2 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -40,5 +40,5 @@
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
.form-actions
- = f.submit 'Submit', class: "gl-button btn btn-success wide"
- = link_to "Cancel", admin_applications_path, class: "gl-button btn btn-cancel"
+ = f.submit 'Submit', class: "gl-button btn btn-confirm wide"
+ = link_to "Cancel", admin_applications_path, class: "gl-button btn btn-default btn-cancel"
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index 7ac55157f65..28a7bd1820a 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -4,7 +4,7 @@
%p.light
= _('System OAuth applications don\'t belong to any user and can only be managed by admins')
%hr
-%p= link_to _('New application'), new_admin_application_path, class: 'gl-button btn btn-success'
+%p= link_to _('New application'), new_admin_application_path, class: 'gl-button btn btn-confirm'
.table-responsive
%table.table
%thead
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index 89fccff954d..21908c08690 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -77,6 +77,6 @@
= f.datetime_select :ends_at, {}, class: 'form-control form-control-inline'
.form-actions
- if @broadcast_message.persisted?
- = f.submit "Update broadcast message", class: "btn gl-button btn-success"
+ = f.submit "Update broadcast message", class: "btn gl-button btn-confirm"
- else
- = f.submit "Add broadcast message", class: "btn gl-button btn-success"
+ = f.submit "Add broadcast message", class: "btn gl-button btn-confirm"
diff --git a/app/views/admin/dashboard/_billable_users_text.html.haml b/app/views/admin/dashboard/_billable_users_text.html.haml
deleted file mode 100644
index e9485d23228..00000000000
--- a/app/views/admin/dashboard/_billable_users_text.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= s_('AdminArea|Active users')
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index f6ebc4c465d..f16158d5656 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -60,7 +60,7 @@
.d-flex.align-items-center
= sprite_icon('group', size: 16, css_class: 'gl-text-gray-700')
%h3.gl-m-0.gl-ml-3= approximate_count_with_delimiters(@counts, Group)
- .gl-mt-3.text-uppercase= s_('AdminArea|Projects')
+ .gl-mt-3.text-uppercase= s_('AdminArea|Groups')
= link_to(s_('AdminArea|New group'), new_admin_group_path, class: "btn gl-button btn-default")
.gl-card-footer.gl-bg-transparent
.d-flex.align-items-center
diff --git a/app/views/admin/dashboard/stats.html.haml b/app/views/admin/dashboard/stats.html.haml
index 9a89bf12365..b98d11b734b 100644
--- a/app/views/admin/dashboard/stats.html.haml
+++ b/app/views/admin/dashboard/stats.html.haml
@@ -50,10 +50,11 @@
= s_('AdminArea|Bots')
%td.p-3.text-right
= @users_statistics&.bots.to_i
+ = render_if_exists 'admin/dashboard/billable_users_row'
%tr.bg-gray-light.gl-text-gray-900
%td.p-3
%strong
- = render_if_exists 'admin/dashboard/billable_users_text'
+ = s_('AdminArea|Active users')
%td.p-3.text-right
%strong
= @users_statistics&.active.to_i
diff --git a/app/views/admin/deploy_keys/edit.html.haml b/app/views/admin/deploy_keys/edit.html.haml
index 2a0177ab997..f85b37b3640 100644
--- a/app/views/admin/deploy_keys/edit.html.haml
+++ b/app/views/admin/deploy_keys/edit.html.haml
@@ -6,5 +6,5 @@
= form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f|
= render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
- = f.submit _('Save changes'), class: 'btn gl-button btn-success'
- = link_to _('Cancel'), admin_deploy_keys_path, class: 'btn gl-button btn-cancel'
+ = f.submit _('Save changes'), class: 'btn gl-button btn-confirm'
+ = link_to _('Cancel'), admin_deploy_keys_path, class: 'btn gl-button btn-default btn-cancel'
diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml
index 9b6aa278906..eec8f816f04 100644
--- a/app/views/admin/deploy_keys/index.html.haml
+++ b/app/views/admin/deploy_keys/index.html.haml
@@ -2,7 +2,7 @@
- if @deploy_keys.any?
%h3.page-title.deploy-keys-title
= _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.load.size }
- = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'float-right btn gl-button btn-success btn-md gl-button'
+ = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'float-right btn gl-button btn-confirm btn-md gl-button'
.table-holder.deploy-keys-list
%table.table
%thead
diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml
index 5a3b880a596..dc49db6557b 100644
--- a/app/views/admin/deploy_keys/new.html.haml
+++ b/app/views/admin/deploy_keys/new.html.haml
@@ -6,5 +6,5 @@
= form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f|
= render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
- = f.submit 'Create', class: 'btn gl-button btn-success'
- = link_to 'Cancel', admin_deploy_keys_path, class: 'btn gl-button btn-cancel'
+ = f.submit 'Create', class: 'btn gl-button btn-confirm'
+ = link_to 'Cancel', admin_deploy_keys_path, class: 'btn gl-button btn-default btn-cancel'
diff --git a/app/views/admin/dev_ops_report/_report.html.haml b/app/views/admin/dev_ops_report/_report.html.haml
index 5faadd15ef8..95ef1298d03 100644
--- a/app/views/admin/dev_ops_report/_report.html.haml
+++ b/app/views/admin/dev_ops_report/_report.html.haml
@@ -4,11 +4,13 @@
= render 'callout'
- if !usage_ping_enabled
- #js-devops-empty-state{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_usage_ping_link: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/usage_ping') } }
+ #js-devops-empty-state{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_usage_ping_link: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/usage_ping/index.md') } }
- elsif @metric.blank?
= render 'no_data'
- else
.devops
+ .gl-my-3.gl-text-gray-400{ data: { testid: 'devops-score-note-text' } }
+ = s_('DevopsReport|DevOps score metrics are based on usage over the last 30 days. Last updated: %{timestamp}.').html_safe % { timestamp: @metric.created_at.strftime('%Y-%m-%d %H:%M') }
.devops-header
%h2.devops-header-title{ class: "devops-#{score_level(@metric.average_percentage_score)}-score" }
= number_to_percentage(@metric.average_percentage_score, precision: 1)
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index c2599238bce..15306ab7878 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -30,10 +30,10 @@
.gl-alert-body
= render 'shared/group_tips'
.form-actions
- = f.submit _('Create group'), class: "gl-button btn btn-success"
+ = f.submit _('Create group'), class: "gl-button btn btn-confirm"
= link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-default btn-cancel"
- else
.form-actions
- = f.submit _('Save changes'), class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
= link_to _('Cancel'), admin_group_path(@group), class: "gl-button btn btn-cancel"
diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml
index dc122d74e90..df7af86e089 100644
--- a/app/views/admin/groups/_group.html.haml
+++ b/app/views/admin/groups/_group.html.haml
@@ -33,5 +33,5 @@
= visibility_level_icon(group.visibility_level)
.controls.gl-flex-shrink-0.gl-ml-5
- = link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'gl-button btn'
+ = link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'btn gl-button btn-default'
= link_to _('Delete'), [:admin, group], data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name } }, method: :delete, class: 'gl-button btn btn-danger'
diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml
index bc4d4e489ce..17dccae44b5 100644
--- a/app/views/admin/groups/index.html.haml
+++ b/app/views/admin/groups/index.html.haml
@@ -10,7 +10,7 @@
= search_field_tag :name, project_name, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name', data: { qa_selector: 'group_search_field' }
= sprite_icon('search', css_class: 'search-icon')
= render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash
- = link_to new_admin_group_path, class: "gl-button btn btn-success" do
+ = link_to new_admin_group_path, class: "gl-button btn btn-confirm" do
= _('New group')
%ul.content-list
= render @groups
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index b949d08718a..f8c490dd948 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/members'
- add_to_breadcrumbs _("Groups"), admin_groups_path
- breadcrumb_title @group.name
- page_title @group.name, _("Groups")
@@ -117,7 +118,7 @@
.gl-mt-3
= select_tag :access_level, options_for_select(@group.access_level_roles), class: "project-access-select select2"
%hr
- = button_tag _('Add users to group'), class: "gl-button btn btn-success"
+ = button_tag _('Add users to group'), class: "gl-button btn btn-confirm"
= render 'shared/members/requests', membership_source: @group, group: @group, requesters: @requesters, force_mobile_view: true
.card
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index 74f73b4972a..93038e63a2e 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -9,7 +9,7 @@
= form_for @hook, as: :hook, url: admin_hook_path do |f|
= render partial: 'form', locals: { form: f, hook: @hook }
.form-actions
- %span>= f.submit _('Save changes'), class: 'btn gl-button btn-success gl-mr-3'
+ %span>= f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
= link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn gl-button btn-danger float-right', data: { confirm: _('Are you sure?') }
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index c0bad6a0a63..3e2cd126e1a 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -7,7 +7,7 @@
.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 gl-button btn-success'
+ = f.submit _('Add system hook'), class: 'btn gl-button btn-confirm'
= render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml
index 12c7acd7668..abf380474e4 100644
--- a/app/views/admin/labels/_form.html.haml
+++ b/app/views/admin/labels/_form.html.haml
@@ -27,5 +27,5 @@
= render_suggested_colors
.form-actions
- = f.submit _('Save'), class: 'btn gl-button btn-success js-save-button'
- = link_to _("Cancel"), admin_labels_path, class: 'btn gl-button btn-cancel'
+ = f.submit _('Save'), class: 'btn gl-button btn-confirm js-save-button'
+ = link_to _("Cancel"), admin_labels_path, class: 'btn gl-button btn-default btn-cancel'
diff --git a/app/views/admin/labels/destroy.js.haml b/app/views/admin/labels/destroy.js.haml
index b9b63829f25..5ee53088230 100644
--- a/app/views/admin/labels/destroy.js.haml
+++ b/app/views/admin/labels/destroy.js.haml
@@ -1,2 +1,3 @@
- if @labels.size == 0
- $('.labels').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000)
+ var emptyState = document.querySelector('.labels .nothing-here-block.hidden');
+ if (emptyState) emptyState.classList.remove('hidden');
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index f204e620e9d..6861a802a63 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -1,7 +1,7 @@
- page_title _("Labels")
%div
- = link_to new_admin_label_path, class: "float-right btn gl-button btn-success" do
+ = link_to new_admin_label_path, class: "float-right btn gl-button btn-confirm" do
= _('New label')
%h3.page-title
= _('Labels')
@@ -13,5 +13,6 @@
= render @labels
= paginate @labels, theme: 'gitlab'
- - else
- .nothing-here-block= _('There are no labels yet')
+
+ .nothing-here-block{ class: ('hidden' if @labels.present?) }
+ = _('There are no labels yet')
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index 4131c8b7edd..c2e40413a14 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -4,7 +4,7 @@
- @projects.each_with_index do |project|
%li.project-row{ class: ('no-description' if project.description.blank?) }
.controls
- = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "gl-button btn"
+ = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn gl-button btn-default"
%button.delete-project-button.gl-button.btn.btn-danger{ data: { delete_project_url: admin_project_path(project), project_name: project.name } }
= s_('AdminProjects|Delete')
diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml
index d9ff4404519..50f3c94bcb3 100644
--- a/app/views/admin/projects/index.html.haml
+++ b/app/views/admin/projects/index.html.haml
@@ -30,7 +30,7 @@
= dropdown_content
= dropdown_loading
= render 'shared/projects/dropdown'
- = link_to new_project_path, class: 'gl-button btn btn-success' do
+ = link_to new_project_path, class: 'gl-button btn btn-confirm' do
New Project
= button_tag "Search", class: "gl-button btn btn-confirm btn-search hide"
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 2085515e349..40443fb3406 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/members'
- add_to_breadcrumbs _("Projects"), admin_projects_path
- breadcrumb_title @project.full_name
- page_title @project.full_name, _("Projects")
diff --git a/app/views/admin/serverless/domains/_form.html.haml b/app/views/admin/serverless/domains/_form.html.haml
index e4b054c7480..85f2260163a 100644
--- a/app/views/admin/serverless/domains/_form.html.haml
+++ b/app/views/admin/serverless/domains/_form.html.haml
@@ -65,7 +65,7 @@
%span.form-text.text-muted
= _("Upload a private key for your certificate")
- = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "gl-button btn btn-success js-serverless-domain-submit", disabled: @domain.persisted?
+ = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "gl-button btn btn-confirm js-serverless-domain-submit", disabled: @domain.persisted?
- if @domain.persisted?
%button.gl-button.btn.btn-danger{ type: 'button', data: { toggle: 'modal', target: "#modal-delete-domain" } }
= _('Delete domain')
diff --git a/app/views/admin/instance_statistics/index.html.haml b/app/views/admin/usage_trends/index.html.haml
index d5902f702e5..a0be48f71ce 100644
--- a/app/views/admin/instance_statistics/index.html.haml
+++ b/app/views/admin/usage_trends/index.html.haml
@@ -1,4 +1,4 @@
- breadcrumb_title _("Usage Trends")
- page_title _("Usage Trends")
-#js-instance-statistics-app
+#js-usage-trends-app
diff --git a/app/views/admin/users/_cohorts_table.html.haml b/app/views/admin/users/_cohorts_table.html.haml
index bb6266b38f6..a92cfb5851a 100644
--- a/app/views/admin/users/_cohorts_table.html.haml
+++ b/app/views/admin/users/_cohorts_table.html.haml
@@ -2,7 +2,7 @@
.bs-callout.clearfix
%p
= s_("Cohorts|User cohorts are shown for the last %{months_included} months. Only users with activity are counted in the 'New users' column; inactive users are counted separately.") % { months_included: @cohorts[:months_included] }
- = link_to sprite_icon('question-o'), help_page_path('user/admin_area/analytics/user_cohorts', anchor: 'cohorts'), title: 'About this feature', target: '_blank'
+ = link_to sprite_icon('question-o'), help_page_path('user/admin_area/user_cohorts', anchor: 'cohorts'), title: 'About this feature', target: '_blank'
.table-holder.d-xl-table
%table.table
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index 40393f0db99..b3ed8369263 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -87,8 +87,8 @@
.form-actions
- if @user.new_record?
- = f.submit 'Create user', class: "btn gl-button btn-success"
- = link_to 'Cancel', admin_users_path, class: "gl-button btn btn-cancel"
+ = f.submit 'Create user', class: "btn gl-button btn-confirm"
+ = link_to 'Cancel', admin_users_path, class: "gl-button btn btn-default btn-cancel"
- else
- = f.submit 'Save changes', class: "btn gl-button btn-success"
- = link_to 'Cancel', admin_user_path(@user), class: "btn gl-button btn-cancel"
+ = f.submit 'Save changes', class: "btn gl-button btn-confirm"
+ = link_to 'Cancel', admin_user_path(@user), class: "gl-button btn btn-default btn-cancel"
diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml
index 57edb9abe90..c79b2e978f2 100644
--- a/app/views/admin/users/_users.html.haml
+++ b/app/views/admin/users/_users.html.haml
@@ -43,7 +43,7 @@
.nav-controls
= render_if_exists 'admin/users/admin_email_users'
= render_if_exists 'admin/users/admin_export_user_permissions'
- = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn gl-button btn-success btn-search float-right'
+ = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn gl-button btn-confirm btn-search float-right'
.filtered-search-block.row-content-block.border-top-0
= form_tag admin_users_path, method: :get do
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 8da0c7f4300..f9b631ed6cf 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -5,7 +5,7 @@
%a.nav-link{ href: '#users', class: active_when(params[:tab] != 'cohorts'), data: { toggle: 'tab' }, role: 'tab' }
= s_('AdminUsers|Users')
%li.nav-item.js-users-tab-item{ role: 'presentation' }
- %a.nav-link{ href: '#cohorts', class: active_when(params[:tab] == 'cohorts'), data: { toggle: 'tab', track: { event: 'i_analytics_cohorts', action: 'click_tab' } }, role: 'tab' }
+ %a.nav-link{ href: '#cohorts', class: active_when(params[:tab] == 'cohorts'), data: { toggle: 'tab' }, role: 'tab' }
= s_('AdminUsers|Cohorts')
.tab-content
diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml
index a1b7f6efe54..392ff927f01 100644
--- a/app/views/ci/variables/_header.html.haml
+++ b/app/views/ci/variables/_header.html.haml
@@ -1,6 +1,6 @@
- expanded = local_assigns.fetch(:expanded)
-%h4
+%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Variables')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml
index bbdbda40297..7f508fd0a59 100644
--- a/app/views/clusters/clusters/_advanced_settings.html.haml
+++ b/app/views/clusters/clusters/_advanced_settings.html.haml
@@ -24,7 +24,7 @@
.text-muted
= html_escape(s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes %{code_open}cluster-admin%{code_close} privileges.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
= link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank'
- = field.submit _('Save changes'), class: 'btn gl-button btn-success'
+ = field.submit _('Save changes'), class: 'btn gl-button btn-confirm'
.sub-section.form-group
%h4
diff --git a/app/views/clusters/clusters/_cluster_list.html.haml b/app/views/clusters/clusters/_cluster_list.html.haml
index 9627d940126..38ed7e334c9 100644
--- a/app/views/clusters/clusters/_cluster_list.html.haml
+++ b/app/views/clusters/clusters/_cluster_list.html.haml
@@ -4,9 +4,9 @@
.top-area.adjust
.gl-display-block.gl-text-right.gl-my-4.gl-w-full
- if clusterable.can_add_cluster?
- = link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-success js-add-cluster gl-py-2', qa_selector: :integrate_kubernetes_cluster_button
+ = link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-confirm js-add-cluster gl-py-2', qa_selector: :integrate_kubernetes_cluster_button
- else
- %span.btn.gl-button.btn-success.js-add-cluster.disabled.gl-py-2
+ %span.btn.gl-button.btn-confirm.js-add-cluster.disabled.gl-py-2
= s_("ClusterIntegration|Connect cluster with certificate")
#js-clusters-list-app{ data: js_clusters_list_data(clusterable.index_path(format: :json)) }
diff --git a/app/views/clusters/clusters/_empty_state.html.haml b/app/views/clusters/clusters/_empty_state.html.haml
index 676a8a8111a..feef3e0027f 100644
--- a/app/views/clusters/clusters/_empty_state.html.haml
+++ b/app/views/clusters/clusters/_empty_state.html.haml
@@ -11,4 +11,4 @@
- if clusterable.can_add_cluster?
.gl-text-center
- = link_to s_('ClusterIntegration|Integrate with a cluster certificate'), clusterable.new_path, class: 'gl-button btn btn-success'
+ = link_to s_('ClusterIntegration|Integrate with a cluster certificate'), clusterable.new_path, class: 'gl-button btn btn-confirm', data: { qa_selector: 'add_kubernetes_cluster_link' }
diff --git a/app/views/clusters/clusters/_provider_details_form.html.haml b/app/views/clusters/clusters/_provider_details_form.html.haml
index 7fc803e9579..a936cdc04dd 100644
--- a/app/views/clusters/clusters/_provider_details_form.html.haml
+++ b/app/views/clusters/clusters/_provider_details_form.html.haml
@@ -55,4 +55,4 @@
= render('clusters/clusters/namespace', platform_field: platform_field, field: field)
.form-group
- = field.submit s_('ClusterIntegration|Save changes'), class: 'gl-button btn btn-success'
+ = field.submit s_('ClusterIntegration|Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index 50e3d29f974..ee2817879be 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -85,4 +85,4 @@
.form-group.js-gke-cluster-creation-submit-container
= field.submit s_('ClusterIntegration|Create Kubernetes cluster'),
- class: 'js-gke-cluster-creation-submit gl-button btn btn-success', disabled: true
+ class: 'js-gke-cluster-creation-submit gl-button btn btn-confirm', disabled: true
diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml
index e2779d1b683..7d82fe06799 100644
--- a/app/views/clusters/clusters/user/_form.html.haml
+++ b/app/views/clusters/clusters/user/_form.html.haml
@@ -60,4 +60,4 @@
= render('clusters/clusters/namespace', platform_field: platform_kubernetes_field)
.form-group
- = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'gl-button btn btn-success', data: { qa_selector: 'add_kubernetes_cluster_button' }
+ = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'add_kubernetes_cluster_button' }
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index c24fe5c6307..b92f35c108c 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -3,7 +3,7 @@
- if current_user.can_create_group?
.page-title-controls
- = link_to _("New group"), new_group_path, class: "gl-button btn btn-success"
+ = link_to _("New group"), new_group_path, class: "gl-button btn btn-confirm", data: { testid: "new-group-button" }
.top-area
%ul.nav-links.mobile-separator.nav.nav-tabs
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index 6c994f3b230..57c0801074b 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -9,7 +9,7 @@
- if current_user.can_create_project?
.page-title-controls
- = link_to _("New project"), new_project_path, class: "gl-button btn btn-success"
+ = link_to _("New project"), new_project_path, class: "gl-button btn btn-confirm"
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= sprite_icon('chevron-lg-left', size: 12)
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index 2640d483615..e96b5695ddc 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -4,7 +4,7 @@
- if current_user && current_user.snippets.any? || @snippets.any?
.page-title-controls
- if can?(current_user, :create_snippet)
- = link_to _("New snippet"), new_snippet_path, class: "gl-button btn btn-success", title: _("New snippet")
+ = link_to _("New snippet"), new_snippet_path, class: "gl-button btn btn-confirm", title: _("New snippet")
.top-area
%ul.nav-links.nav.nav-tabs
diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml
index 1e93613e978..0ddee68e93f 100644
--- a/app/views/dashboard/activity.html.haml
+++ b/app/views/dashboard/activity.html.haml
@@ -3,7 +3,7 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
- page_title _("Activity")
- header_title _("Activity"), activity_dashboard_path
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index afe4f1b84c2..fdfc2c5adb8 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -2,7 +2,7 @@
- page_title _("Groups")
- header_title _("Groups"), dashboard_groups_path
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
= render 'dashboard/groups_head'
- if params[:filter].blank? && @groups.empty?
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index b3ee5034204..5a7eb46771b 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -4,7 +4,7 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues")
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Issues')
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index 2111b66d26e..d47df24b1b9 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -2,7 +2,7 @@
- page_title _("Merge Requests")
- @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username)
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
.page-title-holder.d-flex.align-items-start.flex-column.flex-sm-row.align-items-sm-center
%h1.page-title= _('Merge Requests')
diff --git a/app/views/dashboard/projects/_projects.html.haml b/app/views/dashboard/projects/_projects.html.haml
index 5122164dbcb..06ef89e0b57 100644
--- a/app/views/dashboard/projects/_projects.html.haml
+++ b/app/views/dashboard/projects/_projects.html.haml
@@ -1 +1 @@
-= render 'shared/projects/list', projects: @projects, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true), user: current_user
+= render 'shared/projects/list', projects: @projects, pipeline_status: true, user: current_user
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index 6f4d53c79a7..1f4bd06aea4 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -12,7 +12,7 @@
callouts_feature_id: UserCalloutsHelper::CUSTOMIZE_HOMEPAGE,
track_label: 'home_page' } }
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
diff --git a/app/views/dashboard/projects/shared/_common.html.haml b/app/views/dashboard/projects/shared/_common.html.haml
index aa55f5a4e9c..17dcb072152 100644
--- a/app/views/dashboard/projects/shared/_common.html.haml
+++ b/app/views/dashboard/projects/shared/_common.html.haml
@@ -2,7 +2,7 @@
- breadcrumb_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
= render "projects/last_push"
= render 'dashboard/projects_head', project_tab_filter: :starred
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 9301f24d6a4..d78059b6aed 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -2,7 +2,7 @@
- page_title _("To-Do List")
- header_title _("To-Do List"), dashboard_todos_path
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
- add_page_specific_style 'page_bundles/todos'
.page-title-holder.d-flex.align-items-center
diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml
index bf321bb690b..684af933f3a 100644
--- a/app/views/devise/confirmations/almost_there.haml
+++ b/app/views/devise/confirmations/almost_there.haml
@@ -1,14 +1,14 @@
-.well-confirmation.text-center.gl-mb-6
+.well-confirmation.gl-text-center.gl-mb-6
%h1.gl-mt-0
- Almost there...
+ = _("Almost there...")
%p.lead.gl-mb-6
- Please check your email to confirm your account
+ = _("Please check your email to confirm your account")
%hr
- if Gitlab::CurrentSettings.after_sign_up_text.present?
- .well-confirmation.text-center
+ .well-confirmation.gl-text-center
= markdown_field(Gitlab::CurrentSettings, :after_sign_up_text)
%p.text-center
- No confirmation email received? Please check your spam folder or
-.gl-mb-6.prepend-top-20.text-center
- %a.btn.btn-lg.btn-success{ href: new_user_confirmation_path }
- Request new confirmation email
+ = _("No confirmation email received? Please check your spam folder or")
+.gl-mb-6.prepend-top-20.gl-text-center
+ %a.btn.gl-button.btn-confirm{ href: new_user_confirmation_path }
+ = _("Request new confirmation email")
diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml
index ace80ba16dd..024ccaddaa1 100644
--- a/app/views/devise/confirmations/new.html.haml
+++ b/app/views/devise/confirmations/new.html.haml
@@ -8,7 +8,7 @@
= f.label :email
= f.email_field :email, class: "form-control gl-form-input", required: true, title: 'Please provide a valid email address.', value: nil
.clearfix
- = f.submit "Resend", class: 'gl-button btn btn-success'
+ = f.submit "Resend", class: 'gl-button btn btn-confirm'
.clearfix.prepend-top-20
= render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 270652483b7..98af69d43b7 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -20,4 +20,4 @@
= recaptcha_tags
.submit-container.move-submit-down
- = f.submit _('Sign in'), class: 'gl-button btn btn-success', data: { qa_selector: 'sign_in_button' }
+ = f.submit _('Sign in'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'sign_in_button' }
diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml
index 131544ac0c0..161e23d700e 100644
--- a/app/views/devise/sessions/_new_crowd.html.haml
+++ b/app/views/devise/sessions/_new_crowd.html.haml
@@ -10,4 +10,4 @@
%label{ for: "remember_me" }
= check_box_tag :remember_me, '1', false, id: 'remember_me'
%span Remember me
- = submit_tag "Sign in", class: "btn-success btn"
+ = submit_tag "Sign in", class: "gl-button btn-confirm btn"
diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index 8f397de41b7..19fcabb1a2e 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -16,4 +16,4 @@
%span Remember me
.submit-container.move-submit-down
- = submit_tag submit_message, class: "gl-button btn-success btn", data: { qa_selector: 'sign_in_button' }
+ = submit_tag submit_message, class: "gl-button btn btn-confirm", data: { qa_selector: 'sign_in_button' }
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index 8704bd16a13..404484cfb93 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -11,6 +11,6 @@
= f.text_field :otp_attempt, class: 'form-control gl-form-input', 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: "gl-button btn btn-success", data: { qa_selector: 'verify_code_button' }
+ = f.submit "Verify code", class: "gl-button btn btn-confirm", data: { qa_selector: 'verify_code_button' }
- if @user.two_factor_webauthn_u2f_enabled?
= render "authentication/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/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 705fd9bbd0f..3ec859551ca 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -7,7 +7,7 @@
.d-flex.justify-content-between.flex-wrap
- providers.each do |provider|
- has_icon = provider_has_icon?(provider)
- = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default d-flex align-items-center omniauth-btn text-left oauth-login #{qa_class_for_provider(provider)}" do
+ = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default omniauth-btn oauth-login #{qa_class_for_provider(provider)}" do
- if has_icon
= provider_image_tag(provider)
%span
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index aa2224b3ea3..2fc89f18de6 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -36,7 +36,7 @@
- if show_recaptcha_sign_up?
= recaptcha_tags
.submit-container
- = f.submit button_text, class: 'btn gl-button btn-success', data: { qa_selector: 'new_user_register_button' }
+ = f.submit button_text, class: 'btn gl-button btn-confirm', data: { qa_selector: 'new_user_register_button' }
= render 'devise/shared/terms_of_service_notice'
- if show_omniauth_providers && omniauth_providers_placement == :bottom
= render 'devise/shared/signup_omniauth_providers'
diff --git a/app/views/devise/shared/_signup_omniauth_providers_top.haml b/app/views/devise/shared/_signup_omniauth_providers_top.haml
index 1deacad88c4..a2cf5165c1f 100644
--- a/app/views/devise/shared/_signup_omniauth_providers_top.haml
+++ b/app/views/devise/shared/_signup_omniauth_providers_top.haml
@@ -1,3 +1,3 @@
-= render 'devise/shared/signup_omniauth_provider_list', providers: experiment_enabled_button_based_providers
+= render 'devise/shared/signup_omniauth_provider_list', providers: trial_enabled_button_based_providers
.omniauth-divider.d-flex.align-items-center.text-center
= _("or")
diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml
index d145ac3f359..398a4fa0c5e 100644
--- a/app/views/devise/unlocks/new.html.haml
+++ b/app/views/devise/unlocks/new.html.haml
@@ -8,7 +8,7 @@
= f.label :email
= f.email_field :email, class: 'form-control', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', title: 'Please provide a valid email address.'
.clearfix
- = f.submit 'Resend unlock instructions', class: 'gl-button btn btn-success'
+ = f.submit 'Resend unlock instructions', class: 'gl-button btn btn-confirm'
.clearfix.prepend-top-20
= render 'devise/shared/sign_in_link'
diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml
index 39529e59aee..9f5d87a961f 100644
--- a/app/views/doorkeeper/applications/_form.html.haml
+++ b/app/views/doorkeeper/applications/_form.html.haml
@@ -23,4 +23,4 @@
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
.gl-mt-3
- = f.submit _('Save application'), class: "gl-button btn btn-success"
+ = f.submit _('Save application'), class: "gl-button btn btn-confirm"
diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml
index d89c4bf0161..7ea10296d97 100644
--- a/app/views/doorkeeper/authorizations/new.html.haml
+++ b/app/views/doorkeeper/authorizations/new.html.haml
@@ -50,4 +50,4 @@
= hidden_field_tag :nonce, @pre_auth.nonce
= hidden_field_tag :code_challenge, @pre_auth.code_challenge
= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method
- = submit_tag _("Authorize"), class: "gl-button btn btn-success gl-ml-3", data: { qa_selector: 'authorization_button' }
+ = submit_tag _("Authorize"), class: "gl-button btn btn-confirm gl-ml-3", data: { qa_selector: 'authorization_button' }
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index d6662e1fc31..97dd606855b 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -21,7 +21,8 @@
%ul.content-list.event_commits
= render "events/commit", project: project, event: event
- - create_mr = event.new_ref? && create_mr_button?(project.default_branch, event.ref_name, project) && event.authored_by?(current_user)
+ - create_mr = event.new_ref? && create_mr_button?(from: project.default_branch, to: event.ref_name, source_project: project, target_project: project) && event.authored_by?(current_user)
+ - create_mr_path = create_mr_path(from: project.default_branch, to: event.ref_name, source_project: project, target_project: project) if create_mr
- if event.commits_count > 1
%li.commits-stat
%span ... and #{pluralize(event.commits_count - 1, 'more commit')}.
@@ -40,9 +41,9 @@
- if create_mr
%span
or
- = link_to create_mr_path(project.default_branch, event.ref_name, project) do
+ = link_to create_mr_path do
create a merge request
- elsif create_mr
%li.commits-stat
- = link_to create_mr_path(project.default_branch, event.ref_name, project) do
+ = link_to create_mr_path do
Create Merge Request
diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml
index f36f30d3638..60132818193 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -2,7 +2,7 @@
- page_title _("Groups")
- header_title _("Groups"), dashboard_groups_path
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
- if current_user
= render 'dashboard/groups_head'
diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml
index 44456b6c015..ae59d9c728b 100644
--- a/app/views/explore/projects/index.html.haml
+++ b/app/views/explore/projects/index.html.haml
@@ -3,7 +3,7 @@
- header_title _("Projects"), dashboard_projects_path
- page_canonical_link explore_projects_url
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
- if current_user
= render 'dashboard/projects_head', project_tab_filter: :explore
diff --git a/app/views/explore/projects/page_out_of_bounds.html.haml b/app/views/explore/projects/page_out_of_bounds.html.haml
index 0ee77ffd7d7..c554cce3dc6 100644
--- a/app/views/explore/projects/page_out_of_bounds.html.haml
+++ b/app/views/explore/projects/page_out_of_bounds.html.haml
@@ -2,7 +2,7 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
- if current_user
= render 'dashboard/projects_head', project_tab_filter: :explore
diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml
index ec92852ddde..a1f2fea5134 100644
--- a/app/views/explore/projects/starred.html.haml
+++ b/app/views/explore/projects/starred.html.haml
@@ -2,7 +2,7 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
- if current_user
= render 'dashboard/projects_head', project_tab_filter: :starred
diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml
index ed508fa2506..e23f63b0064 100644
--- a/app/views/explore/projects/trending.html.haml
+++ b/app/views/explore/projects/trending.html.haml
@@ -2,7 +2,7 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
-= render_dashboard_gold_trial(current_user)
+= render_dashboard_ultimate_trial(current_user)
- if current_user
= render 'dashboard/projects_head', project_tab_filter: :explore_trending
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 37c4ecc09f3..2df5a6740b0 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -23,17 +23,14 @@
.home-panel-buttons.col-md-12.col-lg-6
- if current_user
.gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2{ data: { testid: 'group-buttons' } }
- - if Feature.enabled?(:vue_notification_dropdown, @group, default_enabled: :yaml)
- - if @notification_setting
- .js-vue-notification-dropdown{ data: { disabled: emails_disabled, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), group_id: @group.id, container_class: 'gl-mr-3 gl-mt-3 gl-vertical-align-top' } }
- - else
- = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn gl-button gl-sm-w-auto gl-w-full', dropdown_container_class: 'gl-mr-0 gl-px-2 gl-sm-w-auto gl-w-full', emails_disabled: emails_disabled
+ - if @notification_setting
+ .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), group_id: @group.id, container_class: 'gl-mx-2 gl-mt-3 gl-vertical-align-top' } }
- if can_create_subgroups
.gl-px-2.gl-sm-w-auto.gl-w-full
- = link_to _("New subgroup"), new_group_path(parent_id: @group.id), class: "btn btn-success btn-md gl-button btn-success-secondary gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_subgroup_button' }
+ = link_to _("New subgroup"), new_group_path(parent_id: @group.id), class: "btn btn-default gl-button gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_subgroup_button' }
- if can_create_projects
.gl-px-2.gl-sm-w-auto.gl-w-full
- = link_to _("New project"), new_project_path(namespace_id: @group.id), class: "btn btn-success btn-md gl-button gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_project_button' }
+ = link_to _("New project"), new_project_path(namespace_id: @group.id), class: "btn btn-confirm gl-button gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_project_button' }
- if @group.description.present?
.group-home-desc.mt-1
diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml
index 83d2e13d345..2c9d9349f14 100644
--- a/app/views/groups/_import_group_from_another_instance_panel.html.haml
+++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml
@@ -1,6 +1,4 @@
= form_with url: configure_import_bulk_imports_path, class: 'group-form gl-show-field-errors' do |f|
- = form_errors(@group)
-
.gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5
%h4.gl-display-flex
= s_('GroupsNew|Import groups from another instance of GitLab')
@@ -31,4 +29,4 @@
title: s_('GroupsNew|Please fill in your personal access token.'),
id: 'import_gitlab_token'
.gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5
- = f.submit s_('GroupsNew|Connect instance'), class: 'btn gl-button btn-success'
+ = f.submit s_('GroupsNew|Connect instance'), class: 'btn gl-button btn-confirm'
diff --git a/app/views/groups/_import_group_from_file_panel.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml
index 171f3e0371a..c70cc2c4936 100644
--- a/app/views/groups/_import_group_from_file_panel.html.haml
+++ b/app/views/groups/_import_group_from_file_panel.html.haml
@@ -3,8 +3,6 @@
- group_path << parent.full_path + '/' if parent
= form_with url: import_gitlab_group_path, class: 'group-form gl-show-field-errors', multipart: true do |f|
- = form_errors(@group)
-
.gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5
%h4
= _('Import group from file')
@@ -45,6 +43,6 @@
- import_export_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/group/settings/import_export') }
= s_('GroupsNew|To import a group, navigate to the group settings for the GitLab source instance, %{link_start}generate an export file%{link_end}, and upload it here.').html_safe % { link_start: import_export_link_start, link_end: '</a>'.html_safe }
.gl-mt-3
- = render 'shared/file_picker_button', f: f, field: :file, help_text: nil, classes: 'gl-button btn-success-secondary gl-mr-2'
+ = render 'shared/file_picker_button', f: f, field: :file, help_text: nil, classes: 'gl-button btn-confirm-secondary gl-mr-2'
.gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5
- = f.submit _('Import'), class: 'btn gl-button btn-success'
+ = f.submit _('Import'), class: 'btn gl-button btn-confirm'
diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml
index 64860c61082..14a3b0ece95 100644
--- a/app/views/groups/_new_group_fields.html.haml
+++ b/app/views/groups/_new_group_fields.html.haml
@@ -18,5 +18,5 @@
= render_if_exists 'shared/groups/invite_members'
.row
.form-actions.col-sm-12
- = f.submit _('Create group'), class: "btn gl-button btn-success"
+ = f.submit _('Create group'), class: "btn gl-button btn-confirm"
= link_to _('Cancel'), dashboard_groups_path, class: 'btn gl-button btn-default btn-cancel'
diff --git a/app/views/groups/dependency_proxies/_url.html.haml b/app/views/groups/dependency_proxies/_url.html.haml
index 25a2442f4d4..785ad8f94fd 100644
--- a/app/views/groups/dependency_proxies/_url.html.haml
+++ b/app/views/groups/dependency_proxies/_url.html.haml
@@ -1,4 +1,4 @@
-- proxy_url = "#{group_url(@group)}#{DependencyProxy::URL_SUFFIX}"
+- proxy_url = group_dependency_proxy_url(@group)
%h5.prepend-top-20= _('Dependency proxy URL')
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index a5257ff20bc..da00879ecf9 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/members'
- page_title _('Group members')
- can_manage_members = can?(current_user, :admin_group_member, @group)
- show_invited_members = can_manage_members && @invited_members.exists?
@@ -16,8 +17,9 @@
= html_escape(_('You can invite a new member to %{strong_start}%{group_name}%{strong_end}.')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- if can_invite_members_for_group?(@group)
.gl-w-half.gl-xs-w-full
- .gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2.gl-mb-3
- .js-invite-members-trigger.gl-px-2.gl-sm-w-auto.gl-w-full.gl-mb-4{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite members') } }
+ .gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-mb-3
+ .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } }
+ .js-invite-members-trigger{ data: { variant: 'success', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } }
= render 'groups/invite_members_modal', group: @group
- if can_manage_members && !can_invite_members_for_group?(@group)
%hr.gl-mt-4
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index ffb0ade4f73..52060e2be16 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -19,8 +19,8 @@
.form-actions
- if @milestone.new_record?
- = f.submit 'Create milestone', class: "btn-success gl-button btn", data: { qa_selector: "create_milestone_button" }
+ = f.submit 'Create milestone', class: "btn-confirm gl-button btn", data: { qa_selector: "create_milestone_button" }
= link_to "Cancel", group_milestones_path(@group), class: "btn gl-button btn-cancel"
- else
- = f.submit 'Update milestone', class: "btn-success gl-button btn"
+ = f.submit 'Update milestone', class: "btn-confirm gl-button btn"
= link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn gl-button btn-cancel"
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index 2d5dc4c931d..1c7427fef87 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -8,7 +8,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 gl-button btn-success", data: { qa_selector: "new_group_milestone_link" }
+ = link_to _('New milestone'), new_group_milestone_path(@group), class: "btn gl-button btn-confirm", data: { qa_selector: "new_group_milestone_link" }
- if @milestones.blank?
= render 'shared/empty_states/milestones'
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index 784f477673a..9d595d19779 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -7,7 +7,7 @@
projects:
- if can? current_user, :admin_group, @group
.controls
- = link_to new_project_path(namespace_id: @group.id), class: "btn gl-button btn-sm btn-success" do
+ = link_to new_project_path(namespace_id: @group.id), class: "btn gl-button btn-sm btn-confirm" do
New project
%ul.projects-list.content-list.group-settings-projects
- @projects.each do |project|
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
index 899e58050af..fa6bd021e45 100644
--- a/app/views/groups/registry/repositories/index.html.haml
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -12,7 +12,6 @@
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
- "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"is_admin": current_user&.admin.to_s,
is_group_page: "true",
"group_path": @group.full_path,
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index 627807841c5..fddb83114f3 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -8,7 +8,7 @@
%p
= s_('GroupSettings|Changing group URL can have unintended side effects.')
= succeed '.' do
- = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank'
+ = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'change-a-groups-path'), target: '_blank'
.input-group.gl-field-error-anchor
.group-root-path.input-group-prepend.has-tooltip{ title: group_path(@group), :'data-placement' => 'bottom' }
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 8ad5755fef8..f5cd7dde6a4 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -1,3 +1,5 @@
+- enable_search_settings locals: { container_class: 'gl-my-5' }
+
= form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-settings-form' }, authenticity_token: true do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-general-settings' }
= form_errors(@group)
@@ -29,4 +31,4 @@
= link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
= render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
- = f.submit _('Save changes'), class: 'btn gl-button btn-success mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' }
+ = f.submit _('Save changes'), class: 'btn gl-button btn-confirm mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' }
diff --git a/app/views/groups/settings/_pages_settings.html.haml b/app/views/groups/settings/_pages_settings.html.haml
index 273714d4dcc..a7b1813e4f1 100644
--- a/app/views/groups/settings/_pages_settings.html.haml
+++ b/app/views/groups/settings/_pages_settings.html.haml
@@ -2,4 +2,4 @@
= render_if_exists 'shared/pages/max_pages_size_input', form: f
.gl-mt-3
- = f.submit s_('GitLabPages|Save'), class: 'btn gl-button btn-success'
+ = f.submit s_('GitLabPages|Save'), class: 'btn gl-button btn-confirm'
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 6e2c28ba2e8..dc20e796846 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -41,4 +41,4 @@
= render 'groups/settings/two_factor_auth', f: f, group: @group
= 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 gl-button btn-success gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' }
+ = f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' }
diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
index c18fe79be05..dedb87c51ef 100644
--- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
+++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
@@ -12,4 +12,4 @@
.form-text.text-muted
= s_('GroupSettings|The Auto DevOps pipeline runs if no alternative CI configuration file is found.')
= link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank'
- = f.submit _('Save changes'), class: 'btn gl-button btn-success gl-mt-5'
+ = f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mt-5'
diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml
index eb61ecf5536..c5cc3eb693c 100644
--- a/app/views/groups/settings/ci_cd/_form.html.haml
+++ b/app/views/groups/settings/ci_cd/_form.html.haml
@@ -10,4 +10,4 @@
= _("The maximum file size in megabytes for individual job artifacts.")
= link_to sprite_icon('question-o'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank'
- = f.submit _('Save changes'), class: "btn gl-button btn-success"
+ = f.submit _('Save changes'), class: "btn gl-button btn-confirm"
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 1badb7b6ba1..574750d5f57 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -1,9 +1,11 @@
-- 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
+- enable_search_settings locals: { container_class: 'gl-my-5' }
+
-# Given we only have one field in this form which is also admin-only,
-# we don't want to show an empty section to non-admin users,
- if can?(current_user, :update_max_artifacts_size, @group)
diff --git a/app/views/groups/settings/packages_and_registries/index.html.haml b/app/views/groups/settings/packages_and_registries/index.html.haml
index 1a12ad4902b..21eef20a987 100644
--- a/app/views/groups/settings/packages_and_registries/index.html.haml
+++ b/app/views/groups/settings/packages_and_registries/index.html.haml
@@ -2,4 +2,6 @@
- page_title _('Packages & Registries')
- @content_class = 'limit-container-width' unless fluid_layout
+- enable_search_settings locals: { container_class: 'gl-my-5' }
+
%section#js-packages-and-registries-settings{ data: { default_expanded: expanded_by_default?.to_s, group_path: @group.full_path } }
diff --git a/app/views/groups/settings/repository/_initial_branch_name.html.haml b/app/views/groups/settings/repository/_initial_branch_name.html.haml
index 1881ec31b0c..efe690a0c2d 100644
--- a/app/views/groups/settings/repository/_initial_branch_name.html.haml
+++ b/app/views/groups/settings/repository/_initial_branch_name.html.haml
@@ -19,4 +19,4 @@
= (_("Changes affect new repositories only. If not specified, either the configured application-wide default or Git's default name %{branch_name_default} will be used.") % { branch_name_default: fallback_branch_name }).html_safe
= f.hidden_field :redirect_target, value: "repository_settings"
- = f.submit _('Save changes'), class: 'btn gl-button btn-success'
+ = f.submit _('Save changes'), class: 'btn gl-button btn-confirm'
diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml
index a5819320405..b15d36c631a 100644
--- a/app/views/groups/settings/repository/show.html.haml
+++ b/app/views/groups/settings/repository/show.html.haml
@@ -1,6 +1,8 @@
- breadcrumb_title _('Repository Settings')
- page_title _('Repository')
+- enable_search_settings locals: { container_class: 'gl-my-5' }
+
- deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.')
= render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index d1787d36cd2..a1557cda071 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -16,11 +16,6 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
-= content_for :invite_members_sidebar do
- - if can_invite_members_for_group?(@group)
- %li
- .js-invite-members-trigger{ data: { icon: 'plus', classes: 'gl-text-decoration-none! gl-shadow-none!', display_text: _('Invite team members') } }
-
= render partial: 'flash_messages'
= render_if_exists 'trials/banner', namespace: @group
diff --git a/app/views/groups/sidebar/_packages_settings.html.haml b/app/views/groups/sidebar/_packages_settings.html.haml
index 87300ed39ed..78533aba75f 100644
--- a/app/views/groups/sidebar/_packages_settings.html.haml
+++ b/app/views/groups/sidebar/_packages_settings.html.haml
@@ -1,5 +1,5 @@
- if group_packages_list_nav?
= nav_link(controller: :packages_and_registries) do
- = link_to group_settings_packages_and_registries_path(@group), title: _('Packages & Registries') do
+ = link_to group_settings_packages_and_registries_path(@group), title: _('Packages & Registries'), data: { qa_selector: 'group_package_settings_link' } do
%span
= _('Packages & Registries')
diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml
index 19c28d53087..308065da90a 100644
--- a/app/views/import/bitbucket_server/new.html.haml
+++ b/app/views/import/bitbucket_server/new.html.haml
@@ -25,4 +25,4 @@
.col-md-4
= password_field_tag :personal_access_token, '', class: 'form-control gl-mr-3', placeholder: _('Personal Access Token'), size: 40
.form-actions
- = submit_tag _('List your Bitbucket Server repositories'), class: 'btn btn-success'
+ = submit_tag _('List your Bitbucket Server repositories'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/import/bulk_imports/status.html.haml b/app/views/import/bulk_imports/status.html.haml
index 778bc1ef1a4..917d88af75a 100644
--- a/app/views/import/bulk_imports/status.html.haml
+++ b/app/views/import/bulk_imports/status.html.haml
@@ -9,4 +9,5 @@
available_namespaces_path: import_available_namespaces_path(format: :json),
create_bulk_import_path: import_bulk_imports_path(format: :json),
jobs_path: realtime_changes_import_bulk_imports_path(format: :json),
- source_url: @source_url } }
+ source_url: @source_url,
+ group_path_regex: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS } }
diff --git a/app/views/import/fogbugz/new.html.haml b/app/views/import/fogbugz/new.html.haml
index 4daa769215f..c0abac0a633 100644
--- a/app/views/import/fogbugz/new.html.haml
+++ b/app/views/import/fogbugz/new.html.haml
@@ -22,4 +22,4 @@
.col-md-4
= password_field_tag :password, nil, class: 'form-control'
.form-actions
- = submit_tag _('Continue to the next step'), class: 'btn btn-success'
+ = submit_tag _('Continue to the next step'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml
index fb93a3eca0d..47139917379 100644
--- a/app/views/import/fogbugz/new_user_map.html.haml
+++ b/app/views/import/fogbugz/new_user_map.html.haml
@@ -40,4 +40,4 @@
scope: :all, email_user: true, selected: user[:gitlab_user])
.form-actions
- = submit_tag _('Continue to the next step'), class: 'btn btn-success'
+ = submit_tag _('Continue to the next step'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml
index c4670869c93..285d2fb23a3 100644
--- a/app/views/import/gitea/new.html.haml
+++ b/app/views/import/gitea/new.html.haml
@@ -19,4 +19,4 @@
.col-sm-4
= text_field_tag :personal_access_token, nil, class: 'form-control'
.form-actions
- = submit_tag _('List Your Gitea Repositories'), class: 'btn btn-success'
+ = submit_tag _('List Your Gitea Repositories'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml
index 7e49cad7902..32143f823d7 100644
--- a/app/views/import/github/new.html.haml
+++ b/app/views/import/github/new.html.haml
@@ -10,7 +10,7 @@
= import_github_authorize_message
- if github_import_configured? && !has_ci_cd_only_params?
- = link_to status_import_github_path, class: 'btn btn-success gl-button' do
+ = link_to status_import_github_path, class: 'gl-button btn btn-confirm' do
= sprite_icon('github', css_class: 'gl-mr-2')
= title
@@ -30,5 +30,5 @@
= render_if_exists 'import/github/ci_cd_only'
.form-actions.d-flex.justify-content-end
- = link_to _('Cancel'), new_project_path, class: 'btn'
- = submit_tag _('Authenticate'), class: 'btn btn-success ml-2', data: { qa_selector: 'authenticate_button' }
+ = link_to _('Cancel'), new_project_path, class: 'gl-button btn btn-default'
+ = submit_tag _('Authenticate'), class: 'gl-button btn btn-confirm ml-2', data: { qa_selector: 'authenticate_button' }
diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml
index cd477c085f9..8ba62c91e6a 100644
--- a/app/views/import/gitlab_projects/new.html.haml
+++ b/app/views/import/gitlab_projects/new.html.haml
@@ -20,5 +20,5 @@
= file_field_tag :file, class: ''
.row
.form-actions.col-sm-12
- = submit_tag _('Import project'), class: 'btn btn-success'
- = link_to _('Cancel'), new_project_path, class: 'btn btn-cancel'
+ = submit_tag _('Import project'), class: 'gl-button btn btn-confirm'
+ = link_to _('Cancel'), new_project_path, class: 'gl-button btn btn-default btn-cancel'
diff --git a/app/views/import/manifest/_form.html.haml b/app/views/import/manifest/_form.html.haml
index 1a3b945cfe5..365184537cc 100644
--- a/app/views/import/manifest/_form.html.haml
+++ b/app/views/import/manifest/_form.html.haml
@@ -19,5 +19,5 @@
= link_to sprite_icon('question-o'), help_page_path('user/project/import/manifest')
.gl-mb-3
- = submit_tag _('List available repositories'), class: 'gl-button btn btn-success'
+ = submit_tag _('List available repositories'), class: 'gl-button btn btn-confirm'
= link_to _('Cancel'), new_project_path, class: 'gl-button btn btn-default btn-cancel'
diff --git a/app/views/import/phabricator/new.html.haml b/app/views/import/phabricator/new.html.haml
index 5f73a27dbd6..69483512816 100644
--- a/app/views/import/phabricator/new.html.haml
+++ b/app/views/import/phabricator/new.html.haml
@@ -24,4 +24,4 @@
.col-md-4
= password_field_tag :api_token, params[:api_token], class: 'form-control gl-mr-3', placeholder: _('Personal Access Token'), size: 40
.form-actions
- = submit_tag _('Import tasks'), class: 'btn btn-success'
+ = submit_tag _('Import tasks'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml
index b053d14a851..bfc4e65e23d 100644
--- a/app/views/import/shared/_new_project_form.html.haml
+++ b/app/views/import/shared/_new_project_form.html.haml
@@ -1,7 +1,7 @@
.row
.form-group.project-name.col-sm-12
= label_tag :name, _('Project name'), class: 'label-bold'
- = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control input-lg", autofocus: true
+ = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control input-lg", autofocus: true, required: true, aria: { required: true }
.form-group.col-12.col-sm-6
= label_tag :namespace_id, _('Project URL'), class: 'label-bold'
.form-group
@@ -18,4 +18,4 @@
= hidden_field_tag :namespace_id, current_user.namespace_id
.form-group.col-12.col-sm-6.project-path
= label_tag :path, _('Project slug'), class: 'label-bold'
- = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", required: true
+ = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", required: true, aria: { required: true }
diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml
index a549ed3540b..c7873991010 100644
--- a/app/views/jira_connect/subscriptions/index.html.haml
+++ b/app/views/jira_connect/subscriptions/index.html.haml
@@ -1,61 +1,51 @@
-%header.jira-connect-header
- = brand_header_logo
+%header.jira-connect-header.gl-display-flex.gl-align-items-center.gl-justify-content-center.gl-px-5.gl-border-b-solid.gl-border-b-gray-100.gl-border-b-1.gl-bg-white
+ = link_to brand_header_logo, Gitlab.config.gitlab.url, target: '_blank', rel: 'noopener noreferrer'
-.jira-connect-user
+.jira-connect-user.gl-font-base
- if current_user
- user_link = link_to(current_user.to_reference, jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in')
= _('Signed in to GitLab as %{user_link}').html_safe % { user_link: user_link }
- elsif @subscriptions.present?
= link_to _('Sign in to GitLab'), jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in'
-.jira-connect-app
+%main.jira-connect-app.gl-px-5.gl-pt-7.gl-mx-auto
- if current_user.blank? && @subscriptions.empty?
- %h2= s_('JiraService|GitLab for Jira Configuration')
- %p= s_('JiraService|Sign in to GitLab.com to get started.')
+ .jira-connect-app-body.gl-text-center
+ %h2= s_('JiraService|GitLab for Jira Configuration')
+ %p= s_('JiraService|Sign in to GitLab.com to get started.')
- .gl-mt-7
- - sign_in_button_class = new_jira_connect_ui? ? 'btn gl-button btn-confirm' : 'ak-button ak-button__appearance-primary'
- = external_link _('Sign in to GitLab'), jira_connect_users_path, class: "#{sign_in_button_class} js-jira-connect-sign-in"
+ .gl-mt-7
+ = external_link _('Sign in to GitLab'), jira_connect_users_path, class: "btn gl-button btn-confirm js-jira-connect-sign-in"
- .gl-mt-7
- %p Note: this integration only works with accounts on GitLab.com (SaaS).
+ .gl-mt-7
+ %p= s_('Integrations|Note: this integration only works with accounts on GitLab.com (SaaS).')
- else
.js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) }
- - unless new_jira_connect_ui?
- %form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path }
- .ak-field-group
- %label
- GitLab namespace
-
- .ak-field-group.field-group-input
- %input#namespace-input.ak-field-text{ type: 'text', required: true, placeholder: 'e.g. "MyCompany" or "MyCompany/GroupName"' }
- %button.ak-button.ak-button__appearance-primary{ type: 'submit' }
- Link namespace to Jira
-
- - if @subscriptions.present?
- %table.subscriptions.gl-w-full
- %thead
- %tr
- %th Namespace
- %th Added
- %th
- %tbody
- - @subscriptions.each do |subscription|
+ .jira-connect-app-body
+ - if @subscriptions.present?
+ %table.subscriptions.gl-w-full
+ %thead
%tr
- %td= subscription.namespace.full_path
- %td= subscription.created_at
- %td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'js-jira-connect-remove-subscription'
- - else
- %h4.empty-subscriptions
- No linked namespaces
- %p= s_('Integrations|Namespaces are your GitLab groups and subgroups that will be linked to this Jira instance.')
-
- %p.browser-limitations-notice
- %strong Browser limitations:
- Adding a namespace currently works only in browsers that allow cross‑site cookies. Please make sure to use
- %a{ href: 'https://www.mozilla.org/en-US/firefox/', target: '_blank', rel: 'noopener noreferrer' } Firefox
- or enable cross‑site cookies in your browser when adding a namespace.
+ %th= _('Namespace')
+ %th= _('Added')
+ %th
+ %tbody
+ - @subscriptions.each do |subscription|
+ %tr
+ %td= subscription.namespace.full_path
+ %td= subscription.created_at
+ %td= link_to _('Remove'), jira_connect_subscription_path(subscription), class: 'js-jira-connect-remove-subscription'
+ - else
+ .gl-text-center
+ %h4= s_('Integrations|No linked namespaces')
+ %p= s_('Integrations|Namespaces are your GitLab groups and subgroups that will be linked to this Jira instance.')
+
+ %p.jira-connect-app-body.gl-mt-7.gl-font-base.gl-text-center
+ %strong= s_('Integrations|Browser limitations')
+ - firefox_link_url = 'https://www.mozilla.org/en-US/firefox/'
+ - firefox_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: firefox_link_url }
+ = s_('Integrations|Adding a namespace currently works only in browsers that allow cross‑site cookies. Please make sure to use %{firefox_link_start}Firefox%{firefox_link_end} or enable cross‑site cookies in your browser when adding a namespace.').html_safe % { firefox_link_start: firefox_link_start, firefox_link_end: '</a>'.html_safe }
= link_to _('Learn more'), 'https://gitlab.com/gitlab-org/gitlab/-/issues/284211', target: '_blank', rel: 'noopener noreferrer'
= webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 8b430f579e9..601598d65da 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -44,6 +44,7 @@
= yield :page_specific_styles
= stylesheet_link_tag_defer "application_utilities"
= stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations']
+ = stylesheet_link_tag "test_environment", media: "all" if Rails.env.test?
= stylesheet_link_tag_defer "highlight/themes/#{user_color_scheme}"
@@ -54,14 +55,12 @@
= Gon::Base.render_data(nonce: content_security_policy_nonce)
= javascript_include_tag locale_path unless I18n.locale == :en
- -# Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179
- -# = webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled
+ = webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled
= webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
= yield :page_specific_javascripts
= webpack_controller_bundle_tags
- = webpack_bundle_tag "chrome_84_icon_fix" if browser.chrome?([">=84", "<84.0.4147.125"]) || browser.edge?([">=84", "<84.0.522.59"])
= yield :project_javascripts
diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml
index 9d14dfb3786..bdce4eac755 100644
--- a/app/views/layouts/_snowplow.html.haml
+++ b/app/views/layouts/_snowplow.html.haml
@@ -8,3 +8,6 @@
n.src=w;g.parentNode.insertBefore(n,g)}}(window,document,"script","#{asset_url('snowplow/sp.js')}","snowplow"));
window.snowplowOptions = #{Gitlab::Tracking.snowplow_options(@group).to_json}
+
+ gl = window.gl || {};
+ gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new.to_context.to_json.to_json}
diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml
index da45d84a83b..6acd7799875 100644
--- a/app/views/layouts/jira_connect.html.haml
+++ b/app/views/layouts/jira_connect.html.haml
@@ -3,14 +3,10 @@
%meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
%title
GitLab
- - unless new_jira_connect_ui?
- = stylesheet_link_tag 'https://unpkg.com/@atlaskit/css-reset@3.0.6/dist/bundle.css'
- = stylesheet_link_tag 'https://unpkg.com/@atlaskit/reduced-ui-pack@10.5.5/dist/bundle.css'
= yield :page_specific_styles
= javascript_include_tag 'https://connect-cdn.atl-paas.net/all.js'
= Gon::Base.render_data(nonce: content_security_policy_nonce)
= yield :head
%body
- .ac-content
- = yield
+ = yield
diff --git a/app/views/layouts/nav/groups_dropdown/_show.html.haml b/app/views/layouts/nav/groups_dropdown/_show.html.haml
index d0394451a61..a9d88341a19 100644
--- a/app/views/layouts/nav/groups_dropdown/_show.html.haml
+++ b/app/views/layouts/nav/groups_dropdown/_show.html.haml
@@ -8,5 +8,11 @@
= nav_link(path: 'groups#explore') do
= link_to explore_groups_path, data: { track_label: "groups_dropdown_explore_groups", track_event: "click_link" } do
= _('Explore groups')
+ = nav_link(path: 'groups/new#create-group-pane', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do
+ = link_to new_group_path(anchor: 'create-group-pane'), data: { track_label: "groups_dropdown_create_group", track_event: "click_link" } do
+ = _('Create group')
+ = nav_link(path: 'groups/new#import-group-pane') do
+ = link_to new_group_path(anchor: 'import-group-pane'), data: { track_label: "groups_dropdown_import_group", track_event: "click_link" } do
+ = _('Import group')
.frequent-items-dropdown-content
#js-groups-dropdown{ data: { user_name: current_user.username, group: group_meta } }
diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml
index 91f999a9a74..d8bf64fab64 100644
--- a/app/views/layouts/nav/projects_dropdown/_show.html.haml
+++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml
@@ -11,5 +11,16 @@
= nav_link(path: 'projects#trending') do
= link_to explore_root_path, data: { track_label: "projects_dropdown_explore_projects", track_event: "click_link" } do
= _('Explore projects')
+ = nav_link(path: 'projects/new#blank_project',
+ html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' },
+ data: { track_label: "projects_dropdown_blank_project", track_event: "click_link" }) do
+ = link_to new_project_path(anchor: 'blank_project') do
+ = _('Create blank project')
+ = nav_link(path: 'projects/new#import_project') do
+ = link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link" } do
+ = _('Import project')
+ = nav_link(path: 'projects/new#create_from_template') do
+ = link_to new_project_path(anchor: 'create_from_template'), data: { track_label: "projects_dropdown_create_from_template", track_event: "click_link" } do
+ = _('Create from template')
.frequent-items-dropdown-content
#js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } }
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index f887d335807..d756867541b 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -65,11 +65,10 @@
= link_to admin_dev_ops_report_path, title: _('DevOps Report') do
%span
= _('DevOps Report')
- - if Feature.enabled?(:instance_statistics, default_enabled: true)
- = nav_link(controller: :instance_statistics) do
- = link_to admin_instance_statistics_path, title: _('Usage Trends') do
- %span
- = _('Usage Trends')
+ = nav_link(controller: :usage_trends) do
+ = link_to admin_usage_trends_path, title: _('Usage Trends') do
+ %span
+ = _('Usage Trends')
= nav_link(controller: admin_monitoring_nav_links) do
= link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_link' } do
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index e99b2f443be..7350db64462 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -137,8 +137,6 @@
%strong.fly-out-top-item-name
= _('Members')
- = content_for :invite_members_sidebar
-
- if group_sidebar_link?(:settings)
= nav_link(path: group_settings_nav_link_paths) do
= link_to edit_group_path(@group) do
@@ -173,9 +171,9 @@
= _('Repository')
= nav_link(controller: :ci_cd) do
- = link_to group_settings_ci_cd_path(@group), title: _('CI / CD') do
+ = link_to group_settings_ci_cd_path(@group), title: _('CI/CD') do
%span
- = _('CI / CD')
+ = _('CI/CD')
= render 'groups/sidebar/packages_settings'
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index a66110f28e8..4ae81d69c16 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -3,7 +3,7 @@
.context-header
= link_to profile_path, title: _('Profile Settings') do
.avatar-container.s40.settings-avatar
- = image_tag avatar_icon_for_user(current_user, 40), class: "avatar s40 avatar-tile", alt: current_user.name
+ = image_tag avatar_icon_for_user(current_user, 40), class: "avatar s40 avatar-tile js-sidebar-user-avatar", alt: current_user.name, data: { testid: 'sidebar-user-avatar' }
.sidebar-context-title= _('User Settings')
%ul.sidebar-top-level-items
= nav_link(path: 'profiles#show', html_options: {class: 'home'}) do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 8bb009bfd17..4c331dbd69d 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -86,7 +86,7 @@
= render_if_exists 'projects/sidebar/repository_locked_files'
- if project_nav_tab? :issues
- = nav_link(controller: @project.issues_enabled? ? ['projects/issues', :labels, :milestones, :boards] : 'projects/issues') do
+ = nav_link(controller: @project.issues_enabled? ? ['projects/issues', :labels, :milestones, :boards, :iterations] : 'projects/issues') do
= link_to project_issues_path(@project), class: 'shortcuts-issues qa-issues-item' do
.nav-icon-container
= sprite_icon('issues')
@@ -179,13 +179,13 @@
.nav-icon-container
= sprite_icon('rocket')
%span.nav-item-name#js-onboarding-pipelines-link
- = _('CI / CD')
+ = _('CI/CD')
%ul.sidebar-sub-level-items
= nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts, :test_cases, :pipeline_editor], html_options: { class: "fly-out-top-item" }) do
= link_to project_pipelines_path(@project) do
%strong.fly-out-top-item-name
- = _('CI / CD')
+ = _('CI/CD')
%li.divider.fly-out-top-item
- if project_nav_tab? :pipelines
= nav_link(path: ['pipelines#index', 'pipelines#show']) do
@@ -286,7 +286,7 @@
- if project_nav_tab? :clusters
- show_cluster_hint = show_gke_cluster_integration_callout?(@project)
- = nav_link(controller: [:clusters, :user, :gcp]) do
+ = nav_link(controller: [:cluster_agents, :clusters]) do
= link_to project_clusters_path(@project), title: _('Kubernetes'), class: 'shortcuts-kubernetes' do
%span
= _('Kubernetes')
@@ -378,8 +378,6 @@
%strong.fly-out-top-item-name
= _('Members')
- = content_for :invite_members_sidebar
-
- if project_nav_tab? :settings
= nav_link(path: sidebar_settings_paths) do
= link_to edit_project_path(@project) do
@@ -420,9 +418,9 @@
= _('Repository')
- if !@project.archived? && @project.feature_available?(:builds, current_user)
= nav_link(controller: :ci_cd) do
- = link_to project_settings_ci_cd_path(@project), title: _('CI / CD') do
+ = link_to project_settings_ci_cd_path(@project), title: _('CI/CD') do
%span
- = _('CI / CD')
+ = _('CI/CD')
- if settings_operations_available?
= nav_link(controller: [:operations]) do
= link_to project_settings_operations_path(@project), title: _('Operations'), data: { qa_selector: 'operations_settings_link' } do
diff --git a/app/views/notify/_users_list.html.haml b/app/views/notify/_users_list.html.haml
new file mode 100644
index 00000000000..7ba2330fe7a
--- /dev/null
+++ b/app/views/notify/_users_list.html.haml
@@ -0,0 +1,10 @@
+%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;" }
+ = user_label
+ %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.users-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;" }
+ - users.each do |user|
+ %li
+ %img.avatar{ alt: "Avatar", height: "24", src: avatar_icon_for_user(user, 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(user), style: "color: #333333; text-decoration: none; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; vertical-align: top;" }
+ = user.name
diff --git a/app/views/notify/access_token_about_to_expire_email.html.haml b/app/views/notify/access_token_about_to_expire_email.html.haml
index 240c7300c7f..ea27f72764f 100644
--- a/app/views/notify/access_token_about_to_expire_email.html.haml
+++ b/app/views/notify/access_token_about_to_expire_email.html.haml
@@ -1,7 +1,11 @@
%p
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
%p
- = _('One or more of your personal access tokens will expire in %{days_to_expire} days or less.') % { days_to_expire: @days_to_expire }
+ = _('One or more of your personal access tokens will expire in %{days_to_expire} days or less:') % { days_to_expire: @days_to_expire }
%p
- - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
+ %ul
+ - @token_names.each do |token|
+ %li= token
+%p
+ - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
= html_escape(_('You can create a new one or check them in your %{pat_link_start}personal access tokens%{pat_link_end} settings')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }
diff --git a/app/views/notify/access_token_about_to_expire_email.text.erb b/app/views/notify/access_token_about_to_expire_email.text.erb
index edcec51aeb4..dc9b1379e47 100644
--- a/app/views/notify/access_token_about_to_expire_email.text.erb
+++ b/app/views/notify/access_token_about_to_expire_email.text.erb
@@ -1,5 +1,9 @@
<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %>
-<%= _('One or more of your personal access tokens will expire in %{days_to_expire} days or less.') % { days_to_expire: @days_to_expire} %>
+<%= _('One or more of your personal access tokens will expire in %{days_to_expire} days or less:') % { days_to_expire: @days_to_expire } %>
+
+<% @token_names.each do |token| %>
+ - <%= token %>
+<% end %>
<%= _('You can create a new one or check them in your personal access tokens settings %{pat_link}') % { pat_link: @target_url } %>
diff --git a/app/views/notify/change_in_merge_request_draft_status_email.html.haml b/app/views/notify/change_in_merge_request_draft_status_email.html.haml
new file mode 100644
index 00000000000..5604a30d9f1
--- /dev/null
+++ b/app/views/notify/change_in_merge_request_draft_status_email.html.haml
@@ -0,0 +1,2 @@
+%p
+ = _('%{username} changed the draft status of merge request %{mr_reference}' % {username: sanitize_name(@updated_by_user.name), mr_reference: @merge_request.to_reference })
diff --git a/app/views/notify/change_in_merge_request_draft_status_email.text.erb b/app/views/notify/change_in_merge_request_draft_status_email.text.erb
new file mode 100644
index 00000000000..4e2df2dff1d
--- /dev/null
+++ b/app/views/notify/change_in_merge_request_draft_status_email.text.erb
@@ -0,0 +1 @@
+<%= "#{sanitize_name(@updated_by_user.name)} changed the draft status of merge request #{@merge_request.to_reference}" %>
diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml
index 8546da2d7f0..28766f861d9 100644
--- a/app/views/notify/closed_merge_request_email.text.haml
+++ b/app/views/notify/closed_merge_request_email.text.haml
@@ -6,3 +6,4 @@ Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @m
Author: #{sanitize_name(@merge_request.author_name)}
= assignees_label(@merge_request)
+= reviewers_label(@merge_request)
diff --git a/app/views/notify/in_product_marketing_email.html.haml b/app/views/notify/in_product_marketing_email.html.haml
index be517516a98..39f084efe40 100644
--- a/app/views/notify/in_product_marketing_email.html.haml
+++ b/app/views/notify/in_product_marketing_email.html.haml
@@ -177,8 +177,9 @@
%td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" }
%p{ style: "margin: 0 0 20px 0;" }
= in_product_marketing_body_line1(@track, @series, format: :html).html_safe
- %p{ style: "margin: 0 0 20px 0;" }
- = in_product_marketing_body_line2(@track, @series, format: :html).html_safe
+ - in_product_marketing_body_line2(@track, @series, format: :html)&.tap do |line|
+ %p{ style: "margin: 0 0 20px 0;" }
+ = line.html_safe
%tr
%td{ align: "center", style: "padding: 10px 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
.cta_link= cta_link(@track, @series, @group, format: :html)
diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml
index 3d7115856d4..ab663b65199 100644
--- a/app/views/notify/merge_request_status_email.text.haml
+++ b/app/views/notify/merge_request_status_email.text.haml
@@ -6,3 +6,4 @@ Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @m
Author: #{sanitize_name(@merge_request.author_name)}
= assignees_label(@merge_request)
+= reviewers_label(@merge_request)
diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml
index 412a0887186..a23d083747c 100644
--- a/app/views/notify/merge_request_unmergeable_email.text.haml
+++ b/app/views/notify/merge_request_unmergeable_email.text.haml
@@ -6,3 +6,4 @@ Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @m
Author: #{sanitize_name(@merge_request.author_name)}
= assignees_label(@merge_request)
+= reviewers_label(@merge_request)
diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
index 4db213fb229..e7c51c8fb13 100644
--- a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
+++ b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
@@ -42,13 +42,13 @@
}
}
- ul.assignees-list {
+ ul.users-list {
list-style: none;
padding: 0px;
display: block;
margin-top: 0px;
}
- ul.assignees-list li {
+ ul.users-list li {
display: inline-block;
padding-right: 12px;
padding-top: 8px;
@@ -137,16 +137,10 @@
= @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 'users_list', users: @merge_request.assignees, user_label: assignees_label(@merge_request, include_value: false)
+
+ - if @merge_request.reviewers.any?
+ = render 'users_list', users: @merge_request.reviewers, user_label: reviewers_label(@merge_request, include_value: false)
= render_if_exists 'layouts/mailer/additional_text'
diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
index fdc23a6af0f..de29dda6c71 100644
--- a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
+++ b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
@@ -6,3 +6,4 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m
Author: #{sanitize_name(@merge_request.author_name)}
= assignees_label(@merge_request)
+= reviewers_label(@merge_request)
diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml
index 74e6f86f603..a8e07fa8d1c 100644
--- a/app/views/notify/merged_merge_request_email.text.haml
+++ b/app/views/notify/merged_merge_request_email.text.haml
@@ -6,3 +6,4 @@ Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @m
Author: #{sanitize_name(@merge_request.author_name)}
= assignees_label(@merge_request)
+= reviewers_label(@merge_request)
diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb
index 3c78e257a88..0121006852c 100644
--- a/app/views/notify/new_mention_in_merge_request_email.text.erb
+++ b/app/views/notify/new_mention_in_merge_request_email.text.erb
@@ -4,6 +4,7 @@ You have been mentioned in Merge Request <%= @merge_request.to_reference %>
<%= merge_path_description(@merge_request, 'to') %>
Author: <%= sanitize_name(@merge_request.author_name) %>
-= assignees_label(@merge_request)
+<%= assignees_label(@merge_request) %>
+<%= reviewers_label(@merge_request) %>
<%= @merge_request.description %>
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index 8fecdc6e8a6..8fdba10e7a1 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -1,5 +1,8 @@
%p.details
- #{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request:
+ = html_escape(_('%{userLinkStart}%{user}%{linkEnd} created a %{mrLinkStart}merge request%{linkEnd}:')) % {userLinkStart: "<a href=\"#{user_url(@merge_request.author)}\">".html_safe,
+ user: @merge_request.author_name,
+ mrLinkStart: "<a href=\"#{@target_url}\">".html_safe,
+ linkEnd: '</a>'.html_safe}
%p
.branch
@@ -8,6 +11,8 @@
Author: #{@merge_request.author_name}
.assignee
= assignees_label(@merge_request)
+ .reviewer
+ = reviewers_label(@merge_request)
.approvers
= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter
diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb
index f4b0ed0f886..6148af4890e 100644
--- a/app/views/notify/new_merge_request_email.text.erb
+++ b/app/views/notify/new_merge_request_email.text.erb
@@ -3,6 +3,7 @@
<%= merge_path_description(@merge_request, 'to') %>
<%= 'Author:' %> <%= @merge_request.author_name %>
<%= assignees_label(@merge_request) if @merge_request.assignees.any? %>
+<%= reviewers_label(@merge_request) if @merge_request.reviewers.any? %>
<%= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter %>
<%= @merge_request.description %>
diff --git a/app/views/peek/_bar.html.haml b/app/views/peek/_bar.html.haml
index 9725f640be9..8914bfab336 100644
--- a/app/views/peek/_bar.html.haml
+++ b/app/views/peek/_bar.html.haml
@@ -2,5 +2,6 @@
#js-peek{ data: { env: Peek.env,
request_id: peek_request_id,
+ stats_url: ENV.fetch('GITLAB_PERFORMANCE_BAR_STATS_URL', ''),
peek_url: "#{peek_routes_path}/results" },
class: Peek.env }
diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml
index 977116af88f..6691d20c8f7 100644
--- a/app/views/profiles/_email_settings.html.haml
+++ b/app/views/profiles/_email_settings.html.haml
@@ -8,7 +8,7 @@
= form.select :public_email, options_for_select(@user.public_verified_emails, selected: @user.public_email),
{ help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") },
control_class: 'select2 input-lg', disabled: email_change_disabled
-- commit_email_link_url = help_page_path('user/profile/index', anchor: 'commit-email', target: '_blank')
+- commit_email_link_url = help_page_path('user/profile/index', anchor: 'change-the-email-displayed-on-your-commits', target: '_blank')
- commit_email_link_start = '<a href="%{url}">'.html_safe % { url: commit_email_link_url }
- commit_email_docs_link = s_('Profiles|This email will be used for web based operations, such as edits and merges. %{commit_email_link_start}Learn more%{commit_email_link_end}').html_safe % { commit_email_link_start: commit_email_link_start, commit_email_link_end: '</a>'.html_safe }
= form.select :commit_email, options_for_select(commit_email_select_options(@user), selected: selected_commit_email(@user)),
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index e936cc133bd..1f09f3097ad 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -51,7 +51,7 @@
%p
= s_('Profiles|Changing your username can have unintended side effects.')
= succeed '.' do
- = link_to s_('Profiles|Learn more'), help_page_path('user/profile/index', anchor: 'changing-your-username'), target: '_blank'
+ = link_to s_('Profiles|Learn more'), help_page_path('user/profile/index', anchor: 'change-your-username'), target: '_blank'
.col-lg-8
- data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) }
#update-username{ data: data }
diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml
index abbfbd995b6..82083af9ff1 100644
--- a/app/views/profiles/notifications/_group_settings.html.haml
+++ b/app/views/profiles/notifications/_group_settings.html.haml
@@ -9,11 +9,8 @@
= link_to group.name, group_path(group)
.table-section.section-30.text-right
- - if Feature.enabled?(:vue_notification_dropdown, default_enabled: :yaml)
- - if setting
- .js-vue-notification-dropdown{ data: { disabled: emails_disabled, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, group_id: group.id, container_class: 'gl-mr-3', show_label: "true" } }
- - else
- = render 'shared/notifications/button', notification_setting: setting, emails_disabled: emails_disabled
+ - if setting
+ .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, group_id: group.id, container_class: 'gl-mr-3', show_label: "true" } }
.table-section.section-30
= form_for setting, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications gl-display-flex' } do |f|
diff --git a/app/views/profiles/notifications/_project_settings.html.haml b/app/views/profiles/notifications/_project_settings.html.haml
index 8cd552caa3d..e6953d1b32e 100644
--- a/app/views/profiles/notifications/_project_settings.html.haml
+++ b/app/views/profiles/notifications/_project_settings.html.haml
@@ -8,8 +8,5 @@
= link_to_project(project)
.float-right
- - if Feature.enabled?(:vue_notification_dropdown, default_enabled: :yaml)
- - if setting
- .js-vue-notification-dropdown{ data: { disabled: emails_disabled, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, project_id: project.id, container_class: 'gl-mr-3', show_label: "true" } }
- - else
- = render 'shared/notifications/button', notification_setting: setting, emails_disabled: emails_disabled
+ - if setting
+ .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, project_id: project.id, container_class: 'gl-mr-3', show_label: "true" } }
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index cb0ada414ed..853188c563f 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -32,11 +32,8 @@
%br
.clearfix
.form-group.float-left.global-notification-setting
- - if Feature.enabled?(:vue_notification_dropdown, default_enabled: :yaml)
- - if @global_notification_setting
- .js-vue-notification-dropdown{ data: { dropdown_items: notification_dropdown_items(@global_notification_setting).to_json, notification_level: @global_notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), show_label: 'true' } }
- - else
- = render 'shared/notifications/button', notification_setting: @global_notification_setting
+ - if @global_notification_setting
+ .js-vue-notification-dropdown{ data: { dropdown_items: notification_dropdown_items(@global_notification_setting).to_json, notification_level: @global_notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), show_label: 'true' } }
.clearfix
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index cd76a67b692..535964028f4 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -88,6 +88,15 @@
= 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.form-check
+ = f.check_box :markdown_surround_selection, class: 'form-check-input'
+ = f.label :markdown_surround_selection, class: 'form-check-label' do
+ = s_('Preferences|Surround text selection when typing quotes or brackets')
+ .form-text.text-muted
+ - supported_characters = %w(" ' ` \( [ { < * _).map {|char| "<code>#{char}</code>" }.join(', ')
+ - msg = "Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: #{supported_characters}."
+ = s_(msg).html_safe
+
.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 4689fd5272a..7995231c739 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -48,17 +48,17 @@
.col-lg-8
= f.fields_for :status, @user.status do |status_form|
- emoji_button = button_tag type: :button,
- class: 'js-toggle-emoji-menu emoji-menu-toggle-button gl-button btn has-tooltip',
+ class: 'js-toggle-emoji-menu emoji-menu-toggle-button btn gl-button btn-default has-tooltip',
title: s_("Profiles|Add status emoji") do
- if custom_emoji
- = emoji_icon @user.status.emoji
+ = emoji_icon(@user.status.emoji, class: 'gl-mr-0!')
%span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if custom_emoji) }
= sprite_icon('slight-smile', css_class: 'award-control-icon-neutral')
= sprite_icon('smiley', css_class: 'award-control-icon-positive')
= sprite_icon('smile', css_class: 'award-control-icon-super-positive')
- reset_message_button = button_tag type: :button,
id: 'js-clear-user-status-button',
- class: 'clear-user-status gl-button btn has-tooltip',
+ class: 'clear-user-status btn gl-button btn-default has-tooltip',
title: s_("Profiles|Clear status") do
= sprite_icon("close")
@@ -120,7 +120,7 @@
- private_profile_label = capture do
= s_("Profiles|Don't display activity-related personal information on your profiles")
= f.check_box :private_profile, label: private_profile_label, inline: true, wrapper_class: 'mr-0'
- = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'private-profile')
+ = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private')
%h5= s_("Profiles|Private contributions")
= f.check_box :include_private_contributions, label: s_('Profiles|Include private contributions on my profile'), wrapper_class: 'mb-2', inline: true
.help-block
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 0284c779cfa..3853f428447 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -9,7 +9,7 @@
%h4.gl-mt-0
= _('Register Two-Factor Authenticator')
%p
- = _('Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA).')
+ = _('Use a one-time password authenticator on your mobile device or computer to enable two-factor authentication (2FA).')
.col-lg-8
- if current_user.two_factor_otp_enabled?
%p
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 0d3049cd9a2..f5eea9aa9c6 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -3,11 +3,9 @@
- max_project_topic_length = 15
- emails_disabled = @project.emails_disabled?
-= render 'projects/invite_members_modal', project: @project
-
.project-home-panel.js-show-on-project-root.gl-my-5{ class: [("empty-project" if empty_repo)] }
- .row.gl-mb-3
- .home-panel-title-row.col-md-12.col-lg-6.d-flex
+ .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-sm-flex-direction-column.gl-mb-3
+ .home-panel-title-row.gl-display-flex
.avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.gl-w-11.gl-h-11.gl-mr-3.float-none
= project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'image')
.d-flex.flex-column.flex-wrap.align-items-baseline
@@ -43,16 +41,13 @@
= _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown }
- .project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end
+ .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-start.gl-flex-wrap.gl-mt-5
- if current_user
- .d-inline-flex
- - if Feature.enabled?(:vue_notification_dropdown, @project, default_enabled: :yaml)
- - if @notification_setting
- .js-vue-notification-dropdown{ data: { button_size: "small", disabled: emails_disabled, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, container_class: 'gl-mr-3 gl-mt-5 gl-vertical-align-top' } }
- - else
- = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn-xs', dropdown_container_class: 'gl-mr-3', emails_disabled: emails_disabled
+ .gl-display-flex.gl-align-items-start.gl-mr-3
+ - if @notification_setting
+ .js-vue-notification-dropdown{ data: { button_size: "small", disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id } }
- .count-buttons.d-inline-flex
+ .count-buttons.gl-display-flex.gl-align-items-flex-start
= render 'projects/buttons/star'
= render 'projects/buttons/fork'
diff --git a/app/views/projects/_invite_members_link.html.haml b/app/views/projects/_invite_members_link.html.haml
deleted file mode 100644
index 95cfc75d955..00000000000
--- a/app/views/projects/_invite_members_link.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- return unless can_invite_members_for_project?(@project)
-
-%li
- .js-invite-members-trigger{ data: { icon: 'plus', classes: 'gl-text-decoration-none! gl-shadow-none!', display_text: _('Invite team members') } }
diff --git a/app/views/projects/_invite_members_modal.html.haml b/app/views/projects/_invite_members_modal.html.haml
index b1bba5b59ca..00f823b9016 100644
--- a/app/views/projects/_invite_members_modal.html.haml
+++ b/app/views/projects/_invite_members_modal.html.haml
@@ -2,6 +2,6 @@
.js-invite-members-modal{ data: { id: project.id,
name: project.name,
is_project: 'true',
- access_levels: GroupMember.access_level_roles.to_json,
+ access_levels: ProjectMember.access_level_roles.to_json,
default_access_level: Gitlab::Access::GUEST,
help_link: help_page_url('user/permissions') } }
diff --git a/app/views/projects/_merge_request_merge_checks_settings.html.haml b/app/views/projects/_merge_request_merge_checks_settings.html.haml
index b099eb6800e..694d192d079 100644
--- a/app/views/projects/_merge_request_merge_checks_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_checks_settings.html.haml
@@ -2,24 +2,22 @@
.form-group
%b= s_('ProjectSettings|Merge checks')
- %p.text-secondary= s_('ProjectSettings|These checks must pass before merge requests can be merged')
+ %p.text-secondary= s_('ProjectSettings|These checks must pass before merge requests can be merged.')
.form-check.mb-2.builds-feature
= form.check_box :only_allow_merge_if_pipeline_succeeds, class: 'form-check-input'
= form.label :only_allow_merge_if_pipeline_succeeds, class: 'form-check-label' do
= s_('ProjectSettings|Pipelines must succeed')
.text-secondary
- = s_('ProjectSettings|Pipelines need to be configured to enable this feature.')
- = link_to sprite_icon('question-o'),
- help_page_path('ci/merge_request_pipelines/index.md',
- anchor: 'pipelines-for-merge-requests'),
- target: '_blank'
+ - configuring_pipelines_for_merge_requests_help_link_url = help_page_path('ci/merge_request_pipelines/index.md', anchor: 'configuring-pipelines-for-merge-requests')
+ - configuring_pipelines_for_merge_requests_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configuring_pipelines_for_merge_requests_help_link_url }
+ = s_('ProjectSettings|To enable this feature, configure pipelines. %{link_start}How to configure pipelines for merge requests?%{link_end}').html_safe % { link_start: configuring_pipelines_for_merge_requests_help_link_start, link_end: '</a>'.html_safe }
.form-check.mb-2
.gl-pl-6
= form.check_box :allow_merge_on_skipped_pipeline, class: 'form-check-input'
= form.label :allow_merge_on_skipped_pipeline, class: 'form-check-label' do
= s_('ProjectSettings|Skipped pipelines are considered successful')
.text-secondary
- = s_('ProjectSettings|This introduces the risk of merging changes that will not pass the pipeline.')
+ = s_('ProjectSettings|Introduces the risk of merging changes that do not pass the pipeline.')
.form-check.mb-2
= form.check_box :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-input', data: { qa_selector: 'allow_merge_if_all_discussions_are_resolved_checkbox' }
= form.label :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-label' do
diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/_merge_request_merge_method_settings.html.haml
index 1be7f7bb418..f55d840e14b 100644
--- a/app/views/projects/_merge_request_merge_method_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_method_settings.html.haml
@@ -2,32 +2,32 @@
.form-group
%b= s_('ProjectSettings|Merge method')
- %p.text-secondary= s_('ProjectSettings|This will dictate the commit history when you merge a merge request')
+ %p.text-secondary= s_('ProjectSettings|Determine what happens to the commit history when you merge a merge request.')
.form-check.mb-2
= form.radio_button :merge_method, :merge, class: "js-merge-method-radio form-check-input"
= label_tag :project_merge_method_merge, class: 'form-check-label' do
= s_('ProjectSettings|Merge commit')
.text-secondary
- = s_('ProjectSettings|Every merge creates a merge commit')
+ = s_('ProjectSettings|Every merge creates a merge commit.')
.form-check.mb-2
= form.radio_button :merge_method, :rebase_merge, class: "js-merge-method-radio form-check-input"
= label_tag :project_merge_method_rebase_merge, class: 'form-check-label' do
= s_('ProjectSettings|Merge commit with semi-linear history')
.text-secondary
- = s_('ProjectSettings|Every merge creates a merge commit')
+ = s_('ProjectSettings|Every merge creates a merge commit.')
%br
- = s_('ProjectSettings|Fast-forward merges only')
+ = s_('ProjectSettings|Fast-forward merges only.')
%br
- = s_('ProjectSettings|When conflicts arise the user is given the option to rebase')
+ = s_('ProjectSettings|When there is a merge conflict, the user is given the option to rebase.')
.form-check.mb-2
- = form.radio_button :merge_method, :ff, class: "js-merge-method-radio qa-radio-button-merge-ff form-check-input"
+ = form.radio_button :merge_method, :ff, class: "js-merge-method-radio form-check-input", data: { qa_selector: 'merge_ff_radio_button' }
= label_tag :project_merge_method_ff, class: 'form-check-label' do
= s_('ProjectSettings|Fast-forward merge')
.text-secondary
- = s_('ProjectSettings|No merge commits are created')
+ = s_('ProjectSettings|No merge commits are created.')
%br
- = s_('ProjectSettings|Fast-forward merges only')
+ = s_('ProjectSettings|Fast-forward merges only.')
%br
- = s_('ProjectSettings|When conflicts arise the user is given the option to rebase')
+ = s_('ProjectSettings|When there is a merge conflict, the user is given the option to rebase.')
diff --git a/app/views/projects/_merge_request_merge_options_settings.html.haml b/app/views/projects/_merge_request_merge_options_settings.html.haml
index 80dabeceeb0..20f3933d0a8 100644
--- a/app/views/projects/_merge_request_merge_options_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_options_settings.html.haml
@@ -2,7 +2,7 @@
.form-group#project-merge-options{ data: { project_full_path: @project.full_path } }
%b= s_('ProjectSettings|Merge options')
- %p.text-secondary= s_('ProjectSettings|Additional merge request capabilities that influence how and when merges will be performed')
+ %p.text-secondary= s_('ProjectSettings|Additional settings that influence how and when merges are done.')
= render_if_exists 'projects/merge_pipelines_settings', form: form
= render_if_exists 'projects/merge_trains_settings', form: form
.form-check.mb-2
@@ -12,10 +12,10 @@
.form-check.mb-2
= form.check_box :printing_merge_request_link_enabled, class: 'form-check-input'
= form.label :printing_merge_request_link_enabled, class: 'form-check-label' do
- = s_('ProjectSettings|Show link to create/view merge request when pushing from the command line')
+ = s_('ProjectSettings|Show link to create or view a merge request when pushing from the command line')
.form-check.mb-2
= form.check_box :remove_source_branch_after_merge, class: 'form-check-input'
= form.label :remove_source_branch_after_merge, class: 'form-check-label' do
- = s_("ProjectSettings|Enable 'Delete source branch' option by default")
+ = s_('ProjectSettings|Enable "Delete source branch" option by default')
.descr.text-secondary
- = s_('ProjectSettings|Existing merge requests and protected branches are not affected')
+ = s_('ProjectSettings|Existing merge requests and protected branches are not affected.')
diff --git a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml
index 31a85a204be..12ab905479a 100644
--- a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml
@@ -3,15 +3,13 @@
.form-group
%b= s_('ProjectSettings|Merge suggestions')
%p.text-secondary
- = s_('ProjectSettings|The commit message used to apply merge request suggestions')
- = link_to sprite_icon('question-o'),
- help_page_path('user/discussions/index.md',
- anchor: 'configure-the-commit-message-for-applied-suggestions'),
- target: '_blank'
+ - configure_the_commit_message_for_applied_suggestions_help_link_url = help_page_path('user/discussions/index.md', anchor: 'configure-the-commit-message-for-applied-suggestions')
+ - configure_the_commit_message_for_applied_suggestions_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_commit_message_for_applied_suggestions_help_link_url }
+ = s_('ProjectSettings|The commit message used when applying merge request suggestions. %{link_start}Learn more about suggestions.%{link_end}').html_safe % { link_start: configure_the_commit_message_for_applied_suggestions_help_link_start, link_end: '</a>'.html_safe }
.mb-2
- = form.text_field :suggestion_commit_message, class: 'form-control gl-form-input mb-2', placeholder: Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE
+ = form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE
%p.form-text.text-muted
- = s_('ProjectSettings|The variables GitLab supports:')
+ = s_('ProjectSettings|Supported variables:')
- Gitlab::Suggestions::CommitMessage::PLACEHOLDERS.keys.each do |placeholder|
%code
= "%{#{placeholder}}".html_safe
diff --git a/app/views/projects/_merge_request_squash_options_settings.html.haml b/app/views/projects/_merge_request_squash_options_settings.html.haml
index a5dbfeb16d8..61ffd4814f1 100644
--- a/app/views/projects/_merge_request_squash_options_settings.html.haml
+++ b/app/views/projects/_merge_request_squash_options_settings.html.haml
@@ -4,7 +4,7 @@
.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.')
+ = s_('ProjectSettings|Set the default behavior of this option in merge requests. Changes to this 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'
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 41992ee5ea9..8b1bf37ff10 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -8,7 +8,7 @@
.form-group.project-name.col-sm-12
= f.label :name, class: 'label-bold' do
%span= _("Project name")
- = f.text_field :name, placeholder: "My awesome project", class: "form-control input-lg", autofocus: true, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_name", track_value: "" }
+ = f.text_field :name, placeholder: "My awesome project", class: "form-control input-lg", autofocus: true, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true }
.form-group.project-path.col-sm-6
= f.label :namespace_id, class: 'label-bold' do
%span= s_("Project URL")
@@ -33,7 +33,7 @@
.form-group.project-path.col-sm-6
= f.label :path, class: 'label-bold' do
%span= _("Project slug")
- = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", required: true
+ = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", required: true, aria: { required: true }
- if current_user.can_create_group?
.form-text.text-muted
- link_start_group_path = '<a href="%{path}">' % { path: new_group_path }
@@ -55,7 +55,7 @@
%div{ :class => "col-sm-12" }
.form-check
- experiment(:new_project_readme, actor: current_user) do |e|
- = check_box_tag 'project[initialize_with_readme]', '1', e.run, class: 'form-check-input qa-initialize-with-readme-checkbox', data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "init_with_readme", track_value: "" }
+ = check_box_tag 'project[initialize_with_readme]', '1', e.run, class: 'form-check-input', data: { qa_selector: "initialize_with_readme_checkbox", track_label: "#{track_label}", track_event: "activate_form_input", track_property: "init_with_readme", track_value: "" }
= label_tag 'project[initialize_with_readme]', class: 'form-check-label' do
.option-title
%strong= s_('ProjectsNew|Initialize repository with a README')
diff --git a/app/views/projects/_new_project_push_tip.html.haml b/app/views/projects/_new_project_push_tip.html.haml
deleted file mode 100644
index a63b4bed9de..00000000000
--- a/app/views/projects/_new_project_push_tip.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-.push-to-create-popover
- %p
- = label_tag(:push_to_create_tip, _("Private projects can be created in your personal namespace with:"), class: "weight-normal")
-
- %p.input-group.project-tip-command
- %span
- = text_field_tag :push_to_create_tip, push_to_create_project_command, class: "js-select-on-focus form-control monospace", readonly: true, aria: { label: _("Push project from command line") }
- %span.input-group-append
- = clipboard_button(text: push_to_create_project_command, title: _("Copy command"), class: 'input-group-text', placement: "right")
- %p
- = link_to("What does this command do?", help_page_path("user/project/working_with_projects", anchor: "push-to-create-a-new-project"), target: "_blank")
diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml
index 0998cd480d3..53b9e7f3d65 100644
--- a/app/views/projects/_service_desk_settings.html.haml
+++ b/app/views/projects/_service_desk_settings.html.haml
@@ -1,5 +1,5 @@
- expanded = expanded_by_default?
-%section.settings.js-service-desk-setting-wrapper.no-animate#js-service-desk{ class: ('expanded' if expanded) }
+%section.settings.js-service-desk-setting-wrapper.no-animate#js-service-desk{ class: ('expanded' if expanded), data: { qa_selector: 'service_desk_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Service Desk')
%button.btn.gl-button.js-settings-toggle
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index b363f0d4325..3ebac785d55 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -18,7 +18,7 @@
.tree-controls<
= link_to download_project_job_artifacts_path(@project, @build),
rel: 'nofollow', download: '', class: 'gl-button btn btn-default download' do
- = sprite_icon('download')
+ = sprite_icon('download', css_class: 'gl-mr-2')
Download artifacts archive
.tree-content-holder
diff --git a/app/views/projects/blame/_age_map_legend.html.haml b/app/views/projects/blame/_age_map_legend.html.haml
deleted file mode 100644
index 533dc20ffb3..00000000000
--- a/app/views/projects/blame/_age_map_legend.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-%span.left-label Newer
-%span.legend-box.legend-box-0
-%span.legend-box.legend-box-1
-%span.legend-box.legend-box-2
-%span.legend-box.legend-box-3
-%span.legend-box.legend-box-4
-%span.legend-box.legend-box-5
-%span.legend-box.legend-box-6
-%span.legend-box.legend-box-7
-%span.legend-box.legend-box-8
-%span.legend-box.legend-box-9
-%span.right-label Older
diff --git a/app/views/projects/blame/_blame_group.html.haml b/app/views/projects/blame/_blame_group.html.haml
deleted file mode 100644
index e9967814833..00000000000
--- a/app/views/projects/blame/_blame_group.html.haml
+++ /dev/null
@@ -1,26 +0,0 @@
-%tr
- %td.blame-commit{ class: commit_data.age_map_class }
- .commit
- = commit_data.author_avatar
- .commit-row-title
- %span.item-title.str-truncated-100
- = commit_data.commit_link
- %span
- = commit_data.project_blame_link
- &nbsp;
- .light
- = commit_data.commit_author_link
- = _('committed')
- #{commit_data.time_ago_tooltip}
- %td.line-numbers
- - line_count = blame_group[:lines].count
- - (current_line...(current_line + line_count)).each do |i|
- %a.diff-line-num{ href: "#L#{i}", id: "L#{i}", 'data-line-number' => i }
- = link_icon
- = i
- \
- %td.lines
- %pre.code.highlight
- %code
- - blame_group[:lines].each do |line|
- #{line}
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index 2f3d0660caa..cba63d5e6d6 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -6,18 +6,56 @@
.file-holder
= render "projects/blob/header", blob: @blob, blame: true
+
.file-blame-legend
- = render 'age_map_legend'
+ %span.left-label Newer
+ %span.legend-box.legend-box-0
+ %span.legend-box.legend-box-1
+ %span.legend-box.legend-box-2
+ %span.legend-box.legend-box-3
+ %span.legend-box.legend-box-4
+ %span.legend-box.legend-box-5
+ %span.legend-box.legend-box-6
+ %span.legend-box.legend-box-7
+ %span.legend-box.legend-box-8
+ %span.legend-box.legend-box-9
+ %span.right-label Older
+
.table-responsive.file-content.blame.code.js-syntax-highlight
%table
- current_line = 1
- @blame.groups.each do |blame_group|
- commit_data = @blame.commit_data(blame_group[:commit])
+ - line_count = blame_group[:lines].count
+
+ %tr
+ %td.blame-commit{ class: commit_data.age_map_class }
+ .commit
+ = commit_data.author_avatar
+
+ .commit-row-title
+ %span.item-title.str-truncated-100
+ = commit_data.commit_link
+ %span
+ = commit_data.project_blame_link
+ &nbsp;
+
+ .light
+ = commit_data.commit_author_link
+ = _('committed')
+ #{commit_data.time_ago_tooltip}
+
+ %td.line-numbers
+ - (current_line...(current_line + line_count)).each do |i|
+ %a.diff-line-num.gl-justify-content-end{ href: "#L#{i}", id: "L#{i}", 'data-line-number' => i, class: "gl-display-flex!" }
+ = link_icon
+ = i
+ \
- = render 'blame_group',
- blame_group: blame_group,
- current_line: current_line,
- link_icon: link_icon,
- commit_data: commit_data
+ %td.lines
+ %pre.code.highlight
+ %code
+ - blame_group[:lines].each do |line|
+ #{line}
- - current_line += blame_group[:lines].count
+ - current_line += line_count
diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml
index 70a4202a5d0..717c03ad27d 100644
--- a/app/views/projects/blob/_template_selectors.html.haml
+++ b/app/views/projects/blob/_template_selectors.html.haml
@@ -11,7 +11,7 @@
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector qa-metrics-dashboard-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project) } } )
#gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project) } } )
- - if experiment_enabled?(:ci_syntax_templates, subject: current_user)
+ - if experiment_enabled?(:ci_syntax_templates_b, subject: current_user) && @project.namespace.recent?
.gitlab-ci-syntax-yml-selector.js-gitlab-ci-syntax-yml-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Learn CI/CD syntax"), options: { toggle_class: 'js-gitlab-ci-syntax-yml-selector qa-gitlab-ci-syntax-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_syntax_ymls(@project) } } )
.dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden
diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml
index f400c7de5eb..b68c75701b9 100644
--- a/app/views/projects/blob/_upload.html.haml
+++ b/app/views/projects/blob/_upload.html.haml
@@ -17,7 +17,7 @@
%br
.dropzone-alerts.gl-alert.gl-alert-danger.gl-mb-5.data{ style: "display:none" }
- = render 'shared/new_commit_form', placeholder: placeholder
+ = render 'shared/new_commit_form', placeholder: placeholder, ref: local_assigns[:ref]
.form-actions
= button_tag class: 'btn gl-button btn-success btn-upload-file', id: 'submit-all', type: 'button' do
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index fcf073e1e09..6f86ccd7824 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -35,8 +35,8 @@
.gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5
%svg.s24
- - if merge_project && create_mr_button?(@repository.root_ref, branch.name)
- = link_to create_mr_path(@repository.root_ref, branch.name), class: 'gl-button btn btn-default' do
+ - if merge_project && create_mr_button?(from: @repository.root_ref, to: branch.name, source_project: @project, target_project: @project)
+ = link_to create_mr_path(from: @repository.root_ref, to: branch.name, source_project: @project, target_project: @project), class: 'gl-button btn btn-default' do
= _('Merge request')
- if branch.name != @repository.root_ref
diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
index 0ec47744fc9..ee195e69a98 100644
--- a/app/views/projects/buttons/_clone.html.haml
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -2,7 +2,7 @@
- dropdown_class = local_assigns.fetch(:dropdown_class, '')
.git-clone-holder.js-git-clone-holder
- %a#clone-dropdown.gl-button.btn.btn-info.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
+ %a#clone-dropdown.gl-button.btn.btn-confirm.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
%span.gl-mr-2.js-clone-dropdown-label
= _('Clone')
= sprite_icon("chevron-down", css_class: "icon")
@@ -11,19 +11,19 @@
%li{ class: 'gl-px-4!' }
%label.label-bold
= _('Clone with SSH')
- .input-group
+ .input-group.btn-group
= text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: _('Repository clone URL') }
.input-group-append
- = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard")
+ = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default")
= render_if_exists 'projects/buttons/geo'
- if http_enabled?
%li.pt-2{ class: 'gl-px-4!' }
%label.label-bold
= _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase }
- .input-group
+ .input-group.btn-group
= text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: _('Repository clone URL') }
.input-group-append
- = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard")
+ = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default")
= render_if_exists 'projects/buttons/geo'
%li.divider.mt-2
%li.pt-2.gl-new-dropdown-item
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index fad168da71e..e4ec2e44298 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -1,17 +1,16 @@
- unless @project.empty_repo?
- if current_user && can?(current_user, :fork_project, @project)
- .count-badge.d-inline-flex.align-item-stretch.gl-mr-3
+ .count-badge.btn-group
- if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2
- = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center fork-btn' do
+ = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'gl-button btn btn-default btn-sm has-tooltip fork-btn' do
= sprite_icon('fork', css_class: 'icon')
%span= s_('ProjectOverview|Fork')
- else
- can_create_fork = current_user.can?(:create_fork)
- disabled_fork_tooltip = s_('ProjectOverview|You have reached your project limit')
- %span.has-tooltip{ title: (disabled_fork_tooltip unless can_create_fork) }
- = link_to new_project_fork_path(@project), class: "btn btn-default btn-xs count-badge-button d-flex align-items-center fork-btn #{' disabled' unless can_create_fork }", 'aria-label' => (disabled_fork_tooltip unless can_create_fork) do
+ %span.btn-group.has-tooltip{ title: (disabled_fork_tooltip unless can_create_fork) }
+ = link_to new_project_fork_path(@project), class: "gl-button btn btn-default btn-sm fork-btn #{' disabled' unless can_create_fork }", 'aria-label' => (disabled_fork_tooltip unless can_create_fork) do
= sprite_icon('fork', css_class: 'icon')
%span= s_('ProjectOverview|Fork')
- %span.fork-count.count-badge-count.d-flex.align-items-center
- = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Fork'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'count' do
- = @project.forks_count
+ = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'gl-button btn btn-default btn-sm count has-tooltip' do
+ = @project.forks_count
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index 690f0fe10f7..00d518450e9 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -1,21 +1,19 @@
- if current_user
- .count-badge.d-inline-flex.align-item-stretch.gl-mr-3
- %button.count-badge-button.btn.btn-default.btn-xs.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } }
+ .count-badge.d-inline-flex.align-item-stretch.gl-mr-3.btn-group
+ %button.gl-button.btn.btn-default.btn-sm.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } }
- if current_user.starred?(@project)
= sprite_icon('star', css_class: 'icon')
%span.starred= s_('ProjectOverview|Unstar')
- else
= sprite_icon('star-o', css_class: 'icon')
%span= s_('ProjectOverview|Star')
- %span.star-count.count-badge-count.d-flex.align-items-center
- = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do
- = @project.star_count
+ = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm star-count count' do
+ = @project.star_count
- else
- .count-badge.d-inline-flex.align-item-stretch.gl-mr-3
- = link_to new_user_session_path, class: 'btn btn-default btn-xs has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do
+ .count-badge.d-inline-flex.align-item-stretch.gl-mr-3.btn-group
+ = link_to new_user_session_path, class: 'gl-button btn btn-default btn-sm has-tooltip star-btn', title: s_('ProjectOverview|You must sign in to star a project') do
= sprite_icon('star-o', css_class: 'icon')
%span= s_('ProjectOverview|Star')
- %span.star-count.count-badge-count.d-flex.align-items-center
- = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do
- = @project.star_count
+ = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm star-count count' do
+ = @project.star_count
diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml
index 10aed15f380..3e10cf49b66 100644
--- a/app/views/projects/ci/pipeline_editor/show.html.haml
+++ b/app/views/projects/ci/pipeline_editor/show.html.haml
@@ -1,12 +1,14 @@
- page_title s_('Pipelines|Pipeline Editor')
#js-pipeline-editor{ data: { "ci-config-path": @project.ci_config_path_or_default,
+ "commit-sha" => @project.commit ? @project.commit.sha : '',
+ "default-branch" => @project.default_branch,
+ "empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'),
+ "initial-branch-name": params[:branch_name],
+ "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'),
+ "new-merge-request-path" => namespace_project_new_merge_request_path,
"project-path" => @project.path,
"project-full-path" => @project.full_path,
"project-namespace" => @project.namespace.full_path,
- "default-branch" => @project.default_branch,
- "commit-sha" => @project.commit ? @project.commit.sha : '',
- "new-merge-request-path" => namespace_project_new_merge_request_path,
- "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'),
"yml-help-page-path" => help_page_path('ci/yaml/README'),
} }
diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml
index e693082461f..b0112be0e3d 100644
--- a/app/views/projects/cleanup/_show.html.haml
+++ b/app/views/projects/cleanup/_show.html.haml
@@ -2,8 +2,8 @@
%section.settings.no-animate#cleanup{ class: ('expanded' if expanded) }
.settings-header
- %h4= _('Repository cleanup')
- %button.btn.js-settings-toggle
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Repository cleanup')
+ %button.btn.gl-button.btn-default.js-settings-toggle
= expanded ? _('Collapse') : _('Expand')
%p
- link_url = 'https://github.com/newren/git-filter-repo'
diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml
index bda8ec39e81..81a77489075 100644
--- a/app/views/projects/commit/_change.html.haml
+++ b/app/views/projects/commit/_change.html.haml
@@ -18,7 +18,10 @@
.js-cherry-pick-commit-modal{ data: { title: title,
endpoint: cherry_pick_namespace_project_commit_path(commit, namespace_id: @project.namespace.full_path, project_id: @project),
branch: @project.default_branch,
+ target_project_id: @project.id,
+ target_project_name: @project.full_path,
push_code: can?(current_user, :push_code, @project).to_s,
branch_collaboration: @project.branch_allows_collaboration?(current_user, selected_branch).to_s,
existing_branch: ERB::Util.html_escape(selected_branch),
- branches_endpoint: project_branches_path(@project) } }
+ branches_endpoint: refs_project_path(@project),
+ projects: cherry_pick_projects_data(@project).to_json } }
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 53b9fd733ec..974393b893b 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -74,22 +74,25 @@
%span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) }
.spinner.vertical-align-middle
- - last_pipeline = @commit.last_pipeline
- - if can?(current_user, :read_pipeline, last_pipeline)
+ - if can?(current_user, :read_pipeline, @last_pipeline)
.well-segment.pipeline-info
.status-icon-container
- = link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do
- = ci_icon_for_status(last_pipeline.status)
+ = link_to project_pipeline_path(@project, @last_pipeline.id), class: "ci-status-icon-#{@last_pipeline.status}" do
+ = ci_icon_for_status(@last_pipeline.status)
#{ _('Pipeline') }
- = link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id)
- = ci_label_for_status(last_pipeline.status)
- - if last_pipeline.stages_count.nonzero?
- #{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), last_pipeline.stages_count) }
+ = link_to "##{@last_pipeline.id}", project_pipeline_path(@project, @last_pipeline.id)
+ = ci_label_for_status(@last_pipeline.status)
+ - if @last_pipeline.stages_count.nonzero?
+ #{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), @last_pipeline.stages_count) }
.mr-widget-pipeline-graph
- = render 'shared/mini_pipeline_graph', pipeline: last_pipeline, klass: 'js-commit-pipeline-graph'
- - if last_pipeline.duration
+ - if ::Gitlab::Ci::Features.ci_commit_pipeline_mini_graph_vue_enabled?(@project)
+ .stage-cell
+ .js-commit-pipeline-mini-graph{ data: { stages: @last_pipeline_stages.to_json.html_safe } }
+ - else
+ = render 'shared/mini_pipeline_graph', pipeline: @last_pipeline, klass: 'js-commit-pipeline-graph'
+ - if @last_pipeline.duration
in
- = time_interval_in_words last_pipeline.duration
+ = time_interval_in_words @last_pipeline.duration
- if @merge_request
.well-segment
diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml
index 81c354f1c8f..7f52e6ed7eb 100644
--- a/app/views/projects/commit/_pipelines_list.haml
+++ b/app/views/projects/commit/_pipelines_list.haml
@@ -1,8 +1,6 @@
- disable_initialization = local_assigns.fetch(:disable_initialization, false)
#commit-pipeline-table-view{ data: { disable_initialization: disable_initialization,
endpoint: endpoint,
- "help-page-path" => help_page_path('ci/quick_start/README'),
- "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
"empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
"error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
"project-id": @project.id,
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index c708efe7c7b..ceb312450be 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -60,5 +60,5 @@
.commit-sha-group.btn-group.d-none.d-sm-flex
.label.label-monospace.monospace
= commit.short_id
- = clipboard_button(text: commit.id, title: _("Copy commit SHA"), class: "gl-button btn btn-default", container: "body")
+ = clipboard_button(text: commit.id, title: _("Copy commit SHA"), class: "gl-button btn btn-default btn-icon", container: "body")
= link_to_browse_code(project, commit)
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 802df664241..463984a13a2 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -18,9 +18,9 @@
- if @merge_request.present?
.control.d-none.d-md-block
= link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn gl-button'
- - elsif create_mr_button?(@repository.root_ref, @ref)
+ - elsif create_mr_button?(from: @repository.root_ref, to: @ref, source_project: @project, target_project: @project)
.control.d-none.d-md-block
- = link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn gl-button btn-success'
+ = link_to _("Create merge request"), create_mr_path(from: @repository.root_ref, to: @ref, source_project: @project, target_project: @project), class: 'btn gl-button 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
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
deleted file mode 100644
index 17134613b17..00000000000
--- a/app/views/projects/compare/_form.html.haml
+++ /dev/null
@@ -1,28 +0,0 @@
-= 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
- .form-group.dropdown.compare-form-group.to.js-compare-to-dropdown
- .input-group.inline-input-group
- %span.input-group-prepend
- .input-group-text
- = s_("CompareBranches|Source")
- = hidden_field_tag :to, params[:to]
- = button_tag type: 'button', title: params[:to], class: "btn gl-button form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do
- .dropdown-toggle-text.str-truncated.monospace.float-left= params[:to] || _("Select branch/tag")
- = sprite_icon('chevron-down', css_class: 'float-right')
- = render 'shared/ref_dropdown'
- .compare-ellipsis.inline ...
- .form-group.dropdown.compare-form-group.from.js-compare-from-dropdown
- .input-group.inline-input-group
- %span.input-group-prepend
- .input-group-text
- = s_("CompareBranches|Target")
- = hidden_field_tag :from, params[:from]
- = button_tag type: 'button', title: params[:from], class: "btn gl-button form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do
- .dropdown-toggle-text.str-truncated.monospace.float-left= params[:from] || _("Select branch/tag")
- = sprite_icon('chevron-down', css_class: 'float-right')
- = render 'shared/ref_dropdown'
- &nbsp;
- = button_tag s_("CompareBranches|Compare"), class: "btn gl-button btn-success commits-compare-btn"
- - if @merge_request.present?
- = 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: 'gl-ml-3 btn gl-button'
diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml
index 3f9aa24a569..e3ab184ec6f 100644
--- a/app/views/projects/compare/index.html.haml
+++ b/app/views/projects/compare/index.html.haml
@@ -13,8 +13,4 @@
= html_escape(_("Changes are shown as if the %{b_open}source%{b_close} revision was being merged into the %{b_open}target%{b_close} revision.")) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe }
.prepend-top-20
- #js-compare-selector{ data: { project_compare_index_path: project_compare_index_path(@project),
- refs_project_path: refs_project_path(@project),
- params_from: params[:from], params_to: params[:to],
- project_merge_request_path: @merge_request.present? ? project_merge_request_path(@project, @merge_request) : '',
- create_mr_path: create_mr_button? ? create_mr_path : '' } }
+ #js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, params) }
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index 51cf95dc84b..9e9c271e7be 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -2,7 +2,8 @@
- page_title "#{params[:from]}...#{params[:to]}"
.sub-header-block.no-bottom-space
- = render "form"
+ .js-signature-container{ data: { 'signatures-path' => signatures_namespace_project_compare_index_path } }
+ #js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, params) }
- if @commits.present?
= render "projects/commits/commit_list"
diff --git a/app/views/projects/cycle_analytics/_empty_stage.html.haml b/app/views/projects/cycle_analytics/_empty_stage.html.haml
deleted file mode 100644
index cdad0bc7231..00000000000
--- a/app/views/projects/cycle_analytics/_empty_stage.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.empty-stage-container
- .empty-stage
- .icon-no-data
- = custom_icon ('icon_no_data')
- %h4 {{ __('We don\'t have enough data to show this stage.') }}
- %p
- {{currentStage.emptyStageText}}
diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml
deleted file mode 100644
index c3eda398234..00000000000
--- a/app/views/projects/cycle_analytics/_no_access.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.no-access-stage-container
- .no-access-stage
- .icon-lock
- = custom_icon ('icon_lock')
- %h4 {{ __('You need permission.') }}
- %p
- {{ __('Want to see the data? Please ask an administrator for access.') }}
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index fc3710d3609..71730da0595 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,66 +1,6 @@
- page_title _("Value Stream Analytics")
- add_page_specific_style 'page_bundles/cycle_analytics'
+- svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") }
+- initial_data = { request_path: project_cycle_analytics_path(@project) }.merge!(svgs)
-#cycle-analytics{ "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- %gl-loading-icon{ "v-show" => "isLoading", "size" => "lg" }
- .wrapper{ "v-show" => "!isLoading && !hasError" }
- .card
- .card-header
- {{ __('Recent Project Activity') }}
- .d-flex.justify-content-between
- .flex-grow.text-center{ "v-for" => "item in state.summary" }
- %h3.header {{ item.value }}
- %p.text {{ item.title }}
- .flex-grow.align-self-center.text-center
- .dropdown.inline.js-ca-dropdown
- %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" }
- %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }}
- = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3")
- %ul.dropdown-menu.dropdown-menu-right
- %li
- %a{ "href" => "#", "data-value" => "7" }
- {{ n__('Last %d day', 'Last %d days', 7) }}
- %li
- %a{ "href" => "#", "data-value" => "30" }
- {{ n__('Last %d day', 'Last %d days', 30) }}
- %li
- %a{ "href" => "#", "data-value" => "90" }
- {{ n__('Last %d day', 'Last %d days', 90) }}
- .stage-panel-container
- .card.stage-panel
- .card-header.border-bottom-0
- %nav.col-headers
- %ul
- %li.stage-header.pl-5
- %span.stage-name.font-weight-bold
- {{ s__('ProjectLifecycle|Stage') }}
- %span.has-tooltip{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" }
- = sprite_icon('question-o', css_class: 'gl-text-gray-500')
- %li.median-header
- %span.stage-name.font-weight-bold
- {{ __('Median') }}
- %span.has-tooltip{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" }
- = sprite_icon('question-o', css_class: 'gl-text-gray-500')
- %li.event-header.pl-3
- %span.stage-name.font-weight-bold{ "v-if" => "currentStage && currentStage.legend" }
- {{ currentStage ? __(currentStage.legend) : __('Related Issues') }}
- %span.has-tooltip{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" }
- = sprite_icon('question-o', css_class: 'gl-text-gray-500')
- %li.total-time-header.pr-5.text-right
- %span.stage-name.font-weight-bold
- {{ __('Time') }}
- %span.has-tooltip{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" }
- = sprite_icon('question-o', css_class: 'gl-text-gray-500')
- .stage-panel-body
- %nav.stage-nav
- %ul
- %stage-nav-item{ "v-for" => "stage in state.stages", ":key" => '`ca-stage-title-${stage.title}`', '@select' => 'selectStage(stage)', ":title" => "stage.title", ":is-user-allowed" => "stage.isUserAllowed", ":value" => "stage.value", ":is-active" => "stage.active" }
- .section.stage-events.overflow-auto
- %gl-loading-icon{ "v-show" => "isLoadingStage", "size" => "lg" }
- %template{ "v-if" => "currentStage && !currentStage.isUserAllowed" }
- = render partial: "no_access"
- %template{ "v-else" => true }
- %template{ "v-if" => "isEmptyStage && !isLoadingStage" }
- = render partial: "empty_stage"
- %template{ "v-if" => "state.events.length && !isLoadingStage && !isEmptyStage" }
- %component{ ":is" => "currentStage.component", ":stage" => "currentStage", ":items" => "state.events" }
+#js-cycle-analytics{ data: initial_data }
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index 0c7649e93d7..728f035555e 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -2,8 +2,8 @@
%section.settings.no-animate#default-branch-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4= _('Default branch')
- %button.btn.js-settings-toggle
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Default branch')
+ %button.btn.gl-button.btn-default.js-settings-toggle
= expanded ? _('Collapse') : _('Expand')
%p
= _('Set the default branch for this project. All merge requests and commits are made against this branch unless you specify a different one.')
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index 2f533b5848d..cec8948aaa4 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -3,7 +3,7 @@
- can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project)
- diff_page_context = local_assigns.fetch(:diff_page_context, nil)
- load_diff_files_async = Feature.enabled?(:async_commit_diff_files, @project) && diff_page_context == "is-commit"
-- paginate_diffs = local_assigns.fetch(:paginate_diffs, false) && !load_diff_files_async && Feature.enabled?(:paginate_commit_view, @project, type: :development)
+- paginate_diffs = local_assigns.fetch(:paginate_diffs, false) && !load_diff_files_async
- diff_files = conditionally_paginate_diff_files(diffs, paginate: paginate_diffs)
.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 4b198717790..9197b177b7b 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -16,8 +16,9 @@
- unless diff_file.submodule?
.file-actions.d-none.d-sm-block
- if diff_file.blob&.readable_text?
- = link_to '#', class: 'js-toggle-diff-comments btn gl-button active has-tooltip', title: _("Toggle comments for this file"), disabled: @diff_notes_disabled do
- = sprite_icon('comment')
+ %span.has-tooltip{ title: _("Toggle comments for this file") }
+ = link_to '#', class: 'js-toggle-diff-comments btn gl-button btn-default selected', disabled: @diff_notes_disabled do
+ = sprite_icon('comment')
\
- if editable_diff?(diff_file)
- link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {}
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index c26017ccb79..9388c5fad6d 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -19,7 +19,7 @@
%p= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default award emoji.')
.settings-content
- = form_for @project, remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
+ = form_for @project, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' }
%template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project)
.js-project-permissions-form
@@ -27,7 +27,7 @@
= render "visibility_modal"
= f.submit _('Save changes'), class: "btn gl-button btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level }
-%section.qa-merge-request-settings.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] }
+%section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand')
@@ -36,10 +36,10 @@
.settings-content
= render_if_exists 'shared/promotions/promote_mr_features'
- = form_for @project, remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
+ = form_for @project, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
= render 'projects/merge_request_settings', form: f
- = f.submit _('Save changes'), class: "btn gl-button btn-success qa-save-merge-request-changes rspec-save-merge-request-changes"
+ = f.submit _('Save changes'), class: "btn gl-button btn-success rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' }
= render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded
@@ -60,7 +60,7 @@
= render 'projects/service_desk_settings'
-%section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) }
+%section.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded), data: { qa_selector: 'advanced_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand')
@@ -96,8 +96,8 @@
.input-group-prepend
.input-group-text
#{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/
- = f.text_field :path, class: 'form-control qa-project-path-field h-auto'
- = f.submit _('Change path'), class: "gl-button btn btn-warning qa-change-path-button"
+ = f.text_field :path, class: 'form-control h-auto', data: { qa_selector: 'project_path_field' }
+ = f.submit _('Change path'), class: "gl-button btn btn-warning", data: { qa_selector: 'change_path_button' }
= render 'transfer', project: @project
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 2c245c1a914..0c682226df3 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -1,10 +1,7 @@
- @content_class = "limit-container-width" unless fluid_layout
-- default_branch_name = @project.default_branch || "master"
+- default_branch_name = @project.default_branch_or_master
- @skip_current_level_breadcrumb = true
-= content_for :invite_members_sidebar do
- = render partial: 'projects/invite_members_link'
-
= render partial: 'flash_messages', locals: { project: @project }
= render "home_panel"
@@ -77,3 +74,6 @@
%span><
git push -u origin --all
git push -u origin --tags
+
+- if @project.empty_repo_upload_experiment?
+ = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, default_branch_name), ref: default_branch_name, method: :post
diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml
index cfef2a19420..60a4a5c9d70 100644
--- a/app/views/projects/forks/_fork_button.html.haml
+++ b/app/views/projects/forks/_fork_button.html.haml
@@ -1,7 +1,7 @@
- avatar = namespace_icon(namespace, 100)
- can_create_project = current_user.can?(:create_projects, namespace)
-.bordered-box.fork-thumbnail.text-center.gl-m-3{ class: ("disabled" unless can_create_project) }
+.bordered-box.fork-thumbnail.text-center.gl-m-3.gl-pb-5{ class: ("disabled" unless can_create_project) }
- if /no_((\w*)_)*avatar/.match(avatar)
= group_icon(namespace, class: "avatar rect-avatar s100 identicon mx-auto")
- else
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index ccef28a2cf3..267fc3ae986 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -1,18 +1,29 @@
-- page_title _("Fork project")
-
-.row.gl-mt-3
- .col-lg-3
- %h4.gl-mt-0
- = _("Fork project")
- %p
- = _("A fork is a copy of a project.")
- %br
- = _('Forking a repository allows you to make changes without affecting the original project.')
- .col-lg-9
- - if @own_namespace.present?
- .fork-thumbnail-container.js-fork-content
- %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3
- = _("Select a namespace to fork the project")
- = render 'fork_button', namespace: @own_namespace
- #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json) } }
+- page_title s_("ForkProject|Fork project")
+- if Feature.enabled?(:fork_project_form)
+ #fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'),
+ endpoint: new_project_fork_path(@project, format: :json),
+ new_group_path: new_group_path,
+ project_full_path: project_path(@project),
+ visibility_help_path: help_page_path("public_access/public_access"),
+ project_id: @project.id,
+ project_name: @project.name,
+ project_path: @project.path,
+ project_description: @project.description,
+ project_visibility: @project.visibility } }
+- else
+ .row.gl-mt-3
+ .col-lg-3
+ %h4.gl-mt-0
+ = s_("ForkProject|Fork project")
+ %p
+ = s_("ForkProject|A fork is a copy of a project.")
+ %br
+ = s_('ForkProject|Forking a repository allows you to make changes without affecting the original project.')
+ .col-lg-9
+ - if @own_namespace.present?
+ .fork-thumbnail-container.js-fork-content
+ %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3
+ = s_("ForkProject|Select a namespace to fork the project")
+ = render 'fork_button', namespace: @own_namespace
+ #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json) } }
diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml
index dcc8000c0c5..d85c448b29a 100644
--- a/app/views/projects/issues/_form.html.haml
+++ b/app/views/projects/issues/_form.html.haml
@@ -1,3 +1,3 @@
= form_for [@project, @issue],
- html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' } do |f|
+ html: { class: 'issue-form common-note-form gl-mt-3 js-quick-submit js-requires-input' } do |f|
= render 'shared/issuable/form', f: f, issuable: @issue
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 23510713494..838b4538cad 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -22,7 +22,7 @@
#{issuable_reference(issue)}
%span.issuable-authored.d-none.d-sm-inline-block
&middot;
- opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} by
+ created #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} by
- if issue.service_desk_reply_to
#{issue.service_desk_reply_to} via
#{link_to_member(@project, issue.author, avatar: false)}
diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml
index dbf6a1f1b94..06522d9d434 100644
--- a/app/views/projects/issues/_nav_btns.html.haml
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -1,17 +1,15 @@
- show_feed_buttons = local_assigns.fetch(:show_feed_buttons, true)
- show_import_button = local_assigns.fetch(:show_import_button, true) && can?(current_user, :import_issues, @project)
- show_export_button = local_assigns.fetch(:show_export_button, true)
+- issuable_type = 'issues'
+- can_edit = can?(current_user, :admin_project, @project)
+- notification_email = @current_user.present? ? @current_user.notification_email : nil
.nav-controls.issues-nav-controls
- if show_feed_buttons
= render 'shared/issuable/feed_buttons'
- .btn-group
- - if show_export_button
- = render 'shared/issuable/csv_export/button', issuable_type: 'issues'
-
- - if show_import_button
- = render 'projects/issues/import_csv/button'
+ .js-csv-import-export-buttons{ data: { show_export_button: show_export_button.to_s, show_import_button: show_import_button.to_s, issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_issues_path(@project, request.query_parameters), import_csv_issues_path: import_csv_namespace_project_issues_path, container_class: 'gl-mr-3', can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project) } }
- if @can_bulk_update
= button_tag _("Edit issues"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
@@ -19,11 +17,6 @@
= link_to _("New issue"), new_project_issue_path(@project,
issue: { assignee_id: finder.assignee.try(:id),
milestone_id: finder.milestones.first.try(:id) }),
- class: "gl-button btn btn-success",
+ class: "gl-button btn btn-confirm",
id: "new_issue_link"
-- if show_export_button
- = render 'shared/issuable/csv_export/modal', issuable_type: 'issues'
-
-- if show_import_button
- = render 'projects/issues/import_csv/modal'
diff --git a/app/views/projects/issues/_service_desk_empty_state.html.haml b/app/views/projects/issues/_service_desk_empty_state.html.haml
index 40abedea9d4..a4251c8e5dc 100644
--- a/app/views/projects/issues/_service_desk_empty_state.html.haml
+++ b/app/views/projects/issues/_service_desk_empty_state.html.haml
@@ -1,7 +1,7 @@
- service_desk_enabled = @project.service_desk_enabled?
- 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")
+- title_text = s_("ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab")
- if Gitlab::ServiceDesk.supported?
.empty-state
@@ -13,21 +13,21 @@
- if can_edit_project_settings && service_desk_enabled
%p
- = _("Have your users email")
+ = s_("ServiceDesk|Your users can send emails to this address:")
%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')
+ %span= s_("ServiceDesk|Issues created from Service Desk emails appear here. Each comment becomes part of the email conversation.")
+ = link_to _('Learn more.'), help_page_path('user/project/service_desk')
- if can_edit_project_settings && !service_desk_enabled
.text-center
- = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'gl-button btn btn-success'
+ = link_to s_("ServiceDesk|Enable Service Desk"), edit_project_path(@project), class: 'gl-button 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')
+ %h4= s_('ServiceDesk|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')
+ = s_("ServiceDesk|To activate Service Desk on this instance, an instance administrator must first set up incoming email.")
+ = link_to _('Learn more.'), help_page_path('administration/incoming_email', anchor: 'set-it-up')
diff --git a/app/views/projects/issues/_service_desk_info_content.html.haml b/app/views/projects/issues/_service_desk_info_content.html.haml
index 1eb427f4f7c..448a341eba6 100644
--- a/app/views/projects/issues/_service_desk_info_content.html.haml
+++ b/app/views/projects/issues/_service_desk_info_content.html.haml
@@ -1,7 +1,7 @@
- service_desk_enabled = @project.service_desk_enabled?
- 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")
+- title_text = s_("ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab")
.non-empty-state.media
.svg-content
@@ -12,12 +12,12 @@
- if can_edit_project_settings && service_desk_enabled
%p
- = _("Have your users email")
+ = s_("ServiceDesk|Your users can send emails to this address:")
%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')
+ %span= s_("ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.")
+ = link_to _('Learn more.'), help_page_path('user/project/service_desk')
- if can_edit_project_settings && !service_desk_enabled
.gl-mt-3
- = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'gl-button btn btn-success'
+ = link_to s_("ServiceDesk|Enable Service Desk"), edit_project_path(@project), class: 'gl-button btn btn-success'
diff --git a/app/views/projects/issues/import_csv/_button.html.haml b/app/views/projects/issues/import_csv/_button.html.haml
deleted file mode 100644
index 229eb0654a8..00000000000
--- a/app/views/projects/issues/import_csv/_button.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-- type = local_assigns.fetch(:type, :icon)
-- can_edit = can?(current_user, :admin_project, @project)
-
-.dropdown.btn-group
- %button.btn.gl-button.rounded-right.btn-default.btn-icon.text-center{ class: ('has-tooltip' if type == :icon), title: (_('Import issues') if type == :icon),
- 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
- = _('Import issues')
- %ul.dropdown-menu
- %li
- %button{ data: { toggle: 'modal', target: '.issues-import-modal' } }
- = _('Import CSV')
- - if can_edit
- %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/import_csv/_modal.html.haml b/app/views/projects/issues/import_csv/_modal.html.haml
deleted file mode 100644
index e928a71b940..00000000000
--- a/app/views/projects/issues/import_csv/_modal.html.haml
+++ /dev/null
@@ -1,24 +0,0 @@
-.issues-import-modal.modal
- .modal-dialog
- .modal-content
- = form_tag import_csv_namespace_project_issues_path, multipart: true do
- .modal-header
- %h3
- = _('Import issues')
- .svg-content.import-export-svg-container
- = image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration'
- %a.close{ href: '#', 'data-dismiss' => 'modal' } ×
- .modal-body
- .modal-text
- %p
- = _("Your issues will be imported in the background. Once finished, you'll get a confirmation email.")
- .form-group
- = label_tag :file, _('Upload CSV file'), class: 'label-bold'
- %div
- = file_field_tag :file, accept: '.csv,text/csv', required: true
- %p.text-secondary
- = _('It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.')
- = _('The maximum file size allowed is %{size}.') % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
- .modal-footer
- %button{ type: 'submit', class: 'gl-button btn btn-success', title: _('Import issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: ""} }
- = _('Import issues')
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index dd66e00b813..1d300c42768 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -8,7 +8,7 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues")
-.js-projects-issues-root{ data: { can_edit: can?(current_user, :admin_project, @project).to_s,
+.js-jira-issues-import-status{ data: { can_edit: can?(current_user, :admin_project, @project).to_s,
is_jira_configured: @project.jira_service.present?.to_s,
issues_path: project_issues_path(@project),
project_path: @project.full_path } }
@@ -17,16 +17,25 @@
.top-area
= render 'shared/issuable/nav', type: :issues
= render "projects/issues/nav_btns"
- = render 'shared/issuable/search_bar', type: :issues
- - if @can_bulk_update
- = render 'shared/issuable/bulk_update_sidebar', type: :issues
+ - if Feature.enabled?(:vue_issues_list, @project)
+ - data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id)))
+ .js-issues-list{ data: { endpoint: data_endpoint,
+ full_path: @project.full_path,
+ has_blocked_issues_feature: Gitlab.ee? && @project.feature_available?(:blocked_issues).to_s,
+ has_issuable_health_status_feature: Gitlab.ee? && @project.feature_available?(:issuable_health_status).to_s,
+ has_issue_weights_feature: Gitlab.ee? && @project.feature_available?(:issue_weights).to_s } }
+ - else
+ = render 'shared/issuable/search_bar', type: :issues
- .issues-holder
- = render 'issues'
- - if new_issue_email
- .issuable-footer.text-center
- .js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
+ - if @can_bulk_update
+ = render 'shared/issuable/bulk_update_sidebar', type: :issues
+
+ .issues-holder
+ = render 'issues'
+ - if new_issue_email
+ .issuable-footer.text-center
+ .js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
- else
- new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project)
= render 'shared/empty_states/issues', new_project_issue_button_path: new_project_issue_button_path, show_import_button: true
diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml
index d1601d7fd10..b18027f0f25 100644
--- a/app/views/projects/issues/new.html.haml
+++ b/app/views/projects/issues/new.html.haml
@@ -2,7 +2,7 @@
- breadcrumb_title _("New")
- page_title _("New Issue")
-%h3.page-title= _("New Issue")
-%hr
+.top-area.flex-lg-row
+ %h3.page-title= _("New Issue")
= render "form"
diff --git a/app/views/projects/learn_gitlab/index.html.haml b/app/views/projects/learn_gitlab/index.html.haml
index d5fdbc10eb4..94023b21aab 100644
--- a/app/views/projects/learn_gitlab/index.html.haml
+++ b/app/views/projects/learn_gitlab/index.html.haml
@@ -1,4 +1,5 @@
- breadcrumb_title _("Learn GitLab")
- page_title _("Learn GitLab")
+- add_page_specific_style 'page_bundles/learn_gitlab'
#js-learn-gitlab-app{ data: { actions: onboarding_actions_data(@project).to_json } }
diff --git a/app/views/projects/mattermosts/_no_teams.html.haml b/app/views/projects/mattermosts/_no_teams.html.haml
index 00efea81f8f..f375222e311 100644
--- a/app/views/projects/mattermosts/_no_teams.html.haml
+++ b/app/views/projects/mattermosts/_no_teams.html.haml
@@ -1,6 +1,8 @@
- if @teams_error_message
= content_for :flash_message do
- .alert.alert-danger= @teams_error_message
+ .gl-alert.gl-alert-danger
+ = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body= @teams_error_message
%p
You aren’t a member of any team on the Mattermost instance at
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index b1463693fb4..6c0fc9575fc 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -18,7 +18,7 @@
#{issuable_reference(merge_request)}
%span.issuable-authored.d-none.d-sm-inline-block
&middot;
- opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')}
+ created #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')}
by #{link_to_member(@project, merge_request.author, avatar: false)}
= render_if_exists 'shared/issuable/gitlab_team_member_badge', author: merge_request.author
- if merge_request.milestone
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 61747fe2c8d..354c6665a50 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -45,7 +45,7 @@
%li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
- if can_update_merge_request
- = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-default btn-grouped js-issuable-edit qa-edit-button"
+ = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-default btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" }
- if can_update_merge_request && !are_close_and_open_buttons_hidden
= render 'projects/merge_requests/close_reopen_draft_report_toggle'
diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml
index 473490c6c35..511e53b192f 100644
--- a/app/views/projects/merge_requests/_nav_btns.html.haml
+++ b/app/views/projects/merge_requests/_nav_btns.html.haml
@@ -1,10 +1,10 @@
-.btn-group
- = render 'shared/issuable/csv_export/button', issuable_type: 'merge-requests'
+- issuable_type = 'merge-requests'
+- notification_email = @current_user.present? ? @current_user.notification_email : nil
+
+.js-csv-import-export-buttons{ data: { show_export_button: "true", issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_merge_requests_path(@project, request.query_parameters), container_class: 'gl-mr-3' } }
- if @can_bulk_update
- = button_tag "Edit merge requests", class: "gl-button btn gl-mr-3 js-bulk-update-toggle"
+ = button_tag "Edit merge requests", class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
- if merge_project
- = link_to new_merge_request_path, class: "gl-button btn btn-success", title: "New merge request" do
+ = link_to new_merge_request_path, class: "gl-button btn btn-confirm", title: "New merge request" do
New merge request
-
- = render 'shared/issuable/csv_export/modal', issuable_type: 'merge_requests'
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 123affeb5d6..6e6046eba14 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -8,6 +8,7 @@
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.md', anchor: 'security-approvals-in-merge-requests')}';
+ window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md', anchor: 'policies')}';
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')}';
diff --git a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml
deleted file mode 100644
index cb1cb41eb71..00000000000
--- a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-.content-block.oneline-block.files-changed{ "v-if" => "!isLoading && !hasError" }
- .inline-parallel-buttons{ "v-if" => "showDiffViewTypeSwitcher" }
- .btn-group
- %button.btn.gl-button{ ":class" => "{'active': !isParallel}", "@click" => "handleViewTypeChange('inline')" }
- = _('Inline')
- %button.btn.gl-button{ ":class" => "{'active': isParallel}", "@click" => "handleViewTypeChange('parallel')" }
- = _('Side-by-side')
-
- .js-toggle-container
- .commit-stat-summary
- = _('Showing %{conflict_start}%{conflicts_text}%{strong_end} between %{ref_start}%{source_branch}%{strong_end} and %{ref_start}%{target_branch}%{strong_end}').html_safe % { conflict_start: '<strong class="cred">'.html_safe, ref_start: '<strong class="ref-name">'.html_safe, strong_end: '</strong>'.html_safe, conflicts_text: '{{conflictsCountText}}', source_branch: '{{conflictsData.sourceBranch}}', target_branch: '{{conflictsData.targetBranch}}' }
diff --git a/app/views/projects/merge_requests/conflicts/_file_actions.html.haml b/app/views/projects/merge_requests/conflicts/_file_actions.html.haml
deleted file mode 100644
index 220ddf1bad3..00000000000
--- a/app/views/projects/merge_requests/conflicts/_file_actions.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-.file-actions.d-flex.align-items-center.gl-ml-auto.gl-align-self-start
- .btn-group.gl-mr-3{ "v-if" => "file.type === 'text'" }
- %button.btn.gl-button{ ":class" => "{ 'active': file.resolveMode == 'interactive' }",
- '@click' => "onClickResolveModeButton(file, 'interactive')",
- type: 'button' }
- = _('Interactive mode')
- %button.btn.gl-button{ ':class' => "{ 'active': file.resolveMode == 'edit' }",
- '@click' => "onClickResolveModeButton(file, 'edit')",
- type: 'button' }
- = _('Edit inline')
- %a.btn.gl-button.view-file{ ":href" => "file.blobPath" }
- = _('View file @%{commit_sha}') % { commit_sha: '{{conflictsData.shortCommitSha}}' }
diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
deleted file mode 100644
index 87356f33b1e..00000000000
--- a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
+++ /dev/null
@@ -1,24 +0,0 @@
-- branch_name = link_to @merge_request.source_branch, project_tree_path(@merge_request.project, @merge_request.source_branch), class: "ref-name"
-- translation =_('You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}') % { use_ours: '<code>Use Ours</code>', use_theirs: '<code>Use Theirs</code>', branch_name: branch_name }
-
-%hr
-.resolve-conflicts-form
- .form-group.row
- .col-md-4
- %h4= _('Resolve conflicts on source branch')
- .resolve-info{ "v-pre": true }
- = translation.html_safe
- .col-md-8
- %label.label-bold{ "for" => "commit-message" }
- #{ _('Commit message') }
- .commit-message-container
- .max-width-marker
- %textarea.form-control.js-commit-message#commit-message{ "v-model" => "conflictsData.commitMessage", "rows" => "5" }
- .form-group.row
- .offset-md-4.col-md-8
- .row
- .col-6
- %button.btn.gl-button.btn-success.js-submit-button{ type: "button", "@click" => "commit()", ":disabled" => "!readyToCommit" }
- %span {{commitButtonText}}
- .col-6.text-right
- = link_to "Cancel", project_merge_request_path(@merge_request.project, @merge_request), class: "gl-button btn btn-default"
diff --git a/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml b/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml
deleted file mode 100644
index 4ba5ec5795a..00000000000
--- a/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-%diff-file-editor{ "inline-template" => "true", ":file" => "file", ":on-cancel-discard-confirmation" => "cancelDiscardConfirmation", ":on-accept-discard-confirmation" => "acceptDiscardConfirmation" }
- .diff-editor-wrap{ "v-show" => "file.showEditor" }
- .discard-changes-alert-wrap{ "v-if" => "file.promptDiscardConfirmation" }
- .discard-changes-alert
- Are you sure you want to discard your changes?
- .discard-actions
- %button.btn.btn-sm.btn-danger-secondary.gl-button{ "@click" => "acceptDiscardConfirmation(file)" } Discard changes
- %button.btn.btn-default.btn-sm.gl-button{ "@click" => "cancelDiscardConfirmation(file)" } Cancel
- .editor-wrap{ ":class" => "classObject" }
- .editor{ "style" => "height: 350px", data: { 'editor-loading': true } }
diff --git a/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml b/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml
deleted file mode 100644
index 7bd5c437942..00000000000
--- a/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-%inline-conflict-lines{ "inline-template" => "true", ":file" => "file" }
- %table.diff-wrap-lines.code.code-commit.js-syntax-highlight
- %tr.line_holder.diff-inline{ "v-for" => "line in file.inlineLines" }
- %td.diff-line-num.new_line{ ":class" => "lineCssClass(line)", "v-if" => "!line.isHeader" }
- %a {{line.new_line}}
- %td.diff-line-num.old_line{ ":class" => "lineCssClass(line)", "v-if" => "!line.isHeader" }
- %a {{line.old_line}}
- %td.line_content{ ":class" => "lineCssClass(line)", "v-if" => "!line.isHeader", "v-html" => "line.richText" }
- %td.diff-line-num.header{ ":class" => "lineCssClass(line)", "v-if" => "line.isHeader" }
- %td.diff-line-num.header{ ":class" => "lineCssClass(line)", "v-if" => "line.isHeader" }
- %td.line_content.header{ ":class" => "lineCssClass(line)", "v-if" => "line.isHeader" }
- %strong{ "v-html" => "line.richText" }
- %button.btn{ "@click" => "handleSelected(file, line.id, line.section)" }
- {{line.buttonTitle}}
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index 827df540629..e02f126d165 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -1,5 +1,6 @@
- page_title _("Merge Conflicts"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests")
- add_page_specific_style 'page_bundles/merge_conflicts'
+
= render "projects/merge_requests/mr_title"
.merge-request-details.issuable-details
@@ -7,30 +8,7 @@
= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, source_branch: @merge_request.source_branch
-#conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json),
- resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } }
- .loading{ "v-if" => "isLoading" }
- .spinner.spinner-md
-
- .nothing-here-block{ "v-if" => "hasError" }
- {{conflictsData.errorMessage}}
-
- = render partial: "projects/merge_requests/conflicts/commit_stats"
-
- .files-wrapper{ "v-if" => "!isLoading && !hasError" }
- .files
- .diff-file.file-holder.conflict{ "v-for" => "file in conflictsData.files" }
- .js-file-title.file-title.file-title-flex-parent.cursor-default
- .file-header-content
- %file-icon{ ':file-name': 'file.filePath', ':size': '18', 'css-classes': 'gl-mr-2' }
- %strong.file-title-name {{file.filePath}}
- = render partial: 'projects/merge_requests/conflicts/file_actions'
- .diff-content.diff-wrap-lines
- .file-content{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
- = render partial: "projects/merge_requests/conflicts/components/inline_conflict_lines"
- .file-content{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
- %parallel-conflict-lines{ ":file" => "file" }
- %div{ "v-show" => "file.resolveMode === 'edit' || file.type === 'text-editor'" }
- = render partial: "projects/merge_requests/conflicts/components/diff_file_editor"
-
- = render partial: "projects/merge_requests/conflicts/submit_form"
+#conflicts{ data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json),
+ resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request),
+ source_branch_path: project_tree_path(@merge_request.project, @merge_request.source_branch),
+ merge_request_path: project_merge_request_path(@merge_request.project, @merge_request) } }
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 453a34d1e7a..d664ee709dd 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -35,7 +35,7 @@
= tab_link_for @merge_request, :pipelines do
= _("Pipelines")
%span.badge.badge-pill.js-pipelines-mr-count= number_of_pipelines
- = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab qa-diffs-tab", id: "diffs-tab" do
+ = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do
= tab_link_for @merge_request, :diffs do
= _("Changes")
%span.badge.badge-pill= @merge_request.diff_size
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index f6e4442d4fb..56906eb6e66 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -21,8 +21,8 @@
.form-actions
- if @milestone.new_record?
- = f.submit _('Create milestone'), class: 'gl-button btn-success btn', data: { qa_selector: 'create_milestone_button' }
- = link_to _('Cancel'), project_milestones_path(@project), class: 'gl-button btn btn-cancel'
+ = f.submit _('Create milestone'), class: 'gl-button btn-confirm btn', data: { qa_selector: 'create_milestone_button' }
+ = link_to _('Cancel'), project_milestones_path(@project), class: 'gl-button btn btn-default btn-cancel'
- else
- = f.submit _('Save changes'), class: 'gl-button btn-success btn'
- = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'gl-button btn btn-cancel'
+ = f.submit _('Save changes'), class: 'gl-button btn-confirm btn'
+ = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'gl-button btn btn-default btn-cancel'
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index c0df986e1ca..059ef53c42b 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -8,7 +8,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: 'gl-button btn btn-success', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
+ = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-confirm', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
= _('New milestone')
- if @milestones.blank?
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index d6ad6147e6e..5a1e263141d 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -5,8 +5,8 @@
%section.settings.project-mirror-settings.no-animate#js-push-remote-settings{ class: mirror_settings_class, data: { qa_selector: 'mirroring_repositories_settings_content' } }
.settings-header
- %h4= _('Mirroring repositories')
- %button.btn.gl-button.js-settings-toggle
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Mirroring repositories')
+ %button.btn.gl-button.btn-default.js-settings-toggle
= expanded ? _('Collapse') : _('Expand')
%p
= _('Set up your project to automatically push and/or pull changes to/from another repository. Branches, tags, and commits will be synced automatically.')
@@ -35,7 +35,7 @@
= link_to _('Learn more.'), help_page_path('user/project/repository/repository_mirroring', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer'
.panel-footer
- = f.submit _('Mirror repository'), class: 'gl-button btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
+ = f.submit _('Mirror repository'), class: 'gl-button btn btn-confirm js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
- else
.gl-alert.gl-alert-info{ role: 'alert' }
= sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 45e7eae8c70..059d6eb28c5 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -8,7 +8,7 @@
.project-edit-errors
= render 'projects/errors'
- .js-experiment-new-project-creation{ data: { is_ci_cd_available: (ci_cd_projects_available? if Gitlab.ee?), has_errors: @project.errors.any?, new_project_guidelines: brand_new_project_guidelines } }
+ .js-experiment-new-project-creation{ data: { is_ci_cd_available: (ci_cd_projects_available? if Gitlab.ee?), has_errors: @project.errors.any?, new_project_guidelines: brand_new_project_guidelines, push_to_create_project_command: push_to_create_project_command, working_with_projects_help_path: help_page_path("user/project/working_with_projects") } }
.row{ 'v-cloak': true }
.col-lg-3.profile-settings-sidebar
@@ -28,9 +28,6 @@
%p
%strong= _("Tip:")
= _("You can also create a project from the command line.")
- %a.push-new-project-tip{ data: { title: _("Push to create a project") }, href: help_page_path('user/project/working_with_projects', anchor: 'push-to-create-a-new-project'), target: "_blank", rel: "noopener noreferrer" }
- = _("Show command")
- %template.push-new-project-tip-template= render partial: "new_project_push_tip"
.col-lg-9.js-toggle-container
%ul.nav.nav-tabs.nav-links.gitlab-tabs{ role: 'tablist' }
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index 3c7afff57f6..c88ea313287 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -12,8 +12,6 @@
#{ _('This means you can not push code until you create an empty repository or import existing one.') }
%hr
-= render 'projects/invite_members_modal', project: @project
-
.no-repo-actions
= link_to project_repository_path(@project), method: :post, class: 'btn gl-button btn-confirm' do
#{ _('Create empty repository') }
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index 84c8ab0ceba..40352e79175 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -21,7 +21,7 @@
%span.badge.badge-danger
= s_('GitLabPages|Expired')
%div
- = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-success btn-inverted"
+ = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-confirm btn-inverted"
= link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn gl-button btn-danger btn-sm btn-grouped"
- if domain.needs_verification?
%li.list-group-item.bs-callout-warning
diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml
index 51483176d6f..f39941f6f0d 100644
--- a/app/views/projects/pages/_pages_settings.html.haml
+++ b/app/views/projects/pages/_pages_settings.html.haml
@@ -11,4 +11,4 @@
= s_('GitLabPages|Force HTTPS (requires valid certificates)')
.gl-mt-3
- = f.submit s_('GitLabPages|Save'), class: 'btn btn-success gl-button'
+ = f.submit s_('GitLabPages|Save'), class: 'btn btn-confirm gl-button'
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index 4347cbdbd9b..d0d5e675fcb 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -5,7 +5,7 @@
= s_('GitLabPages|Pages')
- if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https)
- = link_to new_project_pages_domain_path(@project), class: 'btn gl-button btn-success float-right', title: s_('GitLabPages|New Domain') do
+ = link_to new_project_pages_domain_path(@project), class: 'btn gl-button btn-confirm float-right', title: s_('GitLabPages|New Domain') do
= s_('GitLabPages|New Domain')
%p.light
diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml
index 0c3ab4f10a6..0b794226c7f 100644
--- a/app/views/projects/pages_domains/new.html.haml
+++ b/app/views/projects/pages_domains/new.html.haml
@@ -7,6 +7,6 @@
= form_for [@project, domain_presenter], html: { class: 'fieldset-form' } do |f|
= render 'form', { f: f }
.form-actions
- = f.submit _('Create New Domain'), class: "btn btn-success"
+ = f.submit _('Create New Domain'), class: "gl-button btn btn-confirm"
.float-right
- = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-cancel'
+ = link_to _('Cancel'), project_pages_path(@project), class: 'gl-button btn btn-default btn-cancel'
diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml
index 54522a70f4a..d16821c3940 100644
--- a/app/views/projects/pages_domains/show.html.haml
+++ b/app/views/projects/pages_domains/show.html.haml
@@ -17,5 +17,5 @@
= form_for [@project, domain_presenter], html: { class: 'fieldset-form' } do |f|
= render 'form', { f: f }
.form-actions.d-flex.justify-content-between
- = f.submit _('Save Changes'), class: "btn btn-success"
- = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-default btn-inverse'
+ = f.submit _('Save Changes'), class: "gl-button btn btn-confirm"
+ = link_to _('Cancel'), project_pages_path(@project), class: 'gl-button btn btn-default btn-inverse'
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index e17c905e092..190bf9bf071 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -27,14 +27,14 @@
%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 gl-button btn-default btn-svg' do
+ = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn gl-button btn-default btn-icon' 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 gl-button btn-default' do
= s_('PipelineSchedules|Take ownership')
- if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
- = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn gl-button btn-default' do
+ = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn gl-button btn-default btn-icon' do
= sprite_icon('pencil')
- if can?(current_user, :admin_pipeline_schedule, pipeline_schedule)
- = link_to pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, class: 'btn gl-button btn-danger', data: { confirm: _("Are you sure you want to delete this pipeline schedule?") } do
+ = link_to pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, class: 'btn gl-button btn-danger btn-icon', data: { confirm: _("Are you sure you want to delete this pipeline schedule?") } do
= sprite_icon('remove')
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 904b3d6f483..58d125acc2d 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -56,7 +56,7 @@
%tr.build-state.responsive-table-border-start
%td.responsive-table-cell.ci-status-icon-failed{ data: { column: _('Status')} }
.d-none.d-md-block.build-icon
- = custom_icon("icon_status_#{build.status}")
+ = sprite_icon("status_#{build.status}")
.d-md-none.build-badge
= render "ci/status/badge", link: false, status: job.detailed_status(current_user)
%td.responsive-table-cell.build-name{ data: { column: _('Name')} }
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index ff728ab2fb3..139f6e3c94d 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,4 +1,4 @@
-- page_title _('CI / CD Analytics')
+- page_title _('CI/CD Analytics')
#js-project-pipelines-charts-app{ data: { project_path: @project.full_path,
should_render_deployment_frequency_charts: should_render_deployment_frequency_charts.to_s } }
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index 6a4dd88ae07..7d7b8a155ac 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -7,8 +7,6 @@
#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
project_id: @project.id,
params: params.to_json,
- "help-page-path" => help_page_path('ci/quick_start/README'),
- "auto-devops-help-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'),
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index 7d5cef2015d..7a3817fe87b 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -6,7 +6,7 @@
= s_('Pipeline|Run Pipeline')
%hr
-- if Feature.enabled?(:new_pipeline_form, @project, default_enabled: true)
+- if Feature.enabled?(:new_pipeline_form, @project, default_enabled: :yaml)
#js-new-pipeline{ data: { project_id: @project.id,
pipelines_path: project_pipelines_path(@project),
config_variables_path: config_variables_namespace_project_pipelines_path(@project.namespace, @project),
@@ -14,8 +14,7 @@
ref_param: params[:ref] || @project.default_branch,
var_param: params[:var].to_json,
file_param: params[:file_var].to_json,
- branch_refs: @project.repository.branch_names.to_json.html_safe,
- tag_refs: @project.repository.tag_names.to_json.html_safe,
+ project_refs_endpoint: refs_project_path(@project, sort: 'updated_desc'),
settings_link: project_settings_ci_cd_path(@project),
max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } }
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index b431ef202b3..68c80833299 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -26,4 +26,4 @@
= render "projects/pipelines/with_tabs", pipeline: @pipeline, pipeline_has_errors: pipeline_has_errors
-.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid } }
+.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } }
diff --git a/app/views/projects/project_members/_groups.html.haml b/app/views/projects/project_members/_groups.html.haml
deleted file mode 100644
index fe8a50ebb42..00000000000
--- a/app/views/projects/project_members/_groups.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-.card.card-without-border
- = render 'shared/members/tab_pane/header' do
- = render 'shared/members/tab_pane/title' do
- = html_escape(_("Groups with access to %{strong_open}%{project_name}%{strong_close}")) % { project_name: sanitize(@project.name, tags: []), strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- = form_tag project_project_members_path(@project), method: :get, class: 'user-search-form gl-mx-n3 gl-my-n3', data: { testid: 'group-link-search-form' } do
- .gl-px-3.gl-py-2
- .search-control-wrap.gl-relative
- = render 'shared/members/search_field', name: 'search_groups'
- %ul.content-list.members-list{ data: { testid: 'project-member-groups' } }
- - @group_links.each do |group_link|
- = render 'shared/members/group', group_link: group_link, can_admin_member: can_manage_project_members?(@project), group_link_path: project_group_link_path(@project, group_link)
diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml
deleted file mode 100644
index 24ca7ebded9..00000000000
--- a/app/views/projects/project_members/_team.html.haml
+++ /dev/null
@@ -1,22 +0,0 @@
-- project = local_assigns.fetch(:project)
-- members = local_assigns.fetch(:members)
-- group = local_assigns.fetch(:group)
-- current_user_is_group_owner = local_assigns.fetch(:current_user_is_group_owner)
-
-.card.card-without-border
- = render 'shared/members/tab_pane/header' do
- = render 'shared/members/tab_pane/title' do
- = html_escape(_("Members of %{strong_open}%{project_name}%{strong_close}")) % { project_name: sanitize(project.name, tags: []), strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- = form_tag project_project_members_path(project), method: :get, class: 'user-search-form gl-display-flex gl-md-align-items-center gl-flex-wrap gl-flex-direction-column gl-md-flex-direction-row gl-mx-n3 gl-my-n3', data: { testid: 'user-search-form' } do
- .gl-px-3.gl-py-2
- .search-control-wrap.gl-relative
- = render 'shared/members/search_field'
- = render 'shared/members/tab_pane/form_item' do
- = label_tag :sort_by, _('Sort by'), class: 'label-bold gl-mr-2 gl-mb-0 gl-py-2 align-self-md-center'
- = render 'shared/members/sort_dropdown'
- %ul.content-list.members-list{ data: { qa_selector: 'members_list', testid: 'members-table' } }
- = render partial: 'shared/members/member',
- collection: members, as: :member,
- locals: { membership_source: project,
- group: group,
- current_user_is_group_owner: current_user_is_group_owner }
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index beb435d268a..c88dae079ae 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -1,6 +1,5 @@
+- add_page_specific_style 'page_bundles/members'
- page_title _("Members")
-- group = @project.group
-- vue_project_members_list_enabled = Feature.enabled?(:vue_project_members_list, @project, default_enabled: :yaml)
.js-remove-member-modal
.row.gl-mt-3
@@ -18,8 +17,9 @@
%p
= html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe }
.col-md-12.col-lg-6
- .gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2.gl-mb-3
- .js-invite-members-trigger.gl-px-2.gl-sm-w-auto.gl-w-full.gl-mb-4{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite members') } }
+ .gl-display-flex.gl-flex-wrap.gl-justify-content-end
+ .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } }
+ .js-invite-members-trigger{ data: { variant: 'success', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } }
= render 'projects/invite_members_modal', project: @project
- else
@@ -75,44 +75,22 @@
%span.badge.badge-pill= @requesters.count
.tab-content
#tab-members.tab-pane{ class: ('active' unless groups_tab_active?) }
- - if vue_project_members_list_enabled
- .js-project-members-list{ data: project_members_list_data_attributes(@project, @project_members) }
- .loading
- .spinner.spinner-md
- - else
- = render 'projects/project_members/team', project: @project, group: group, members: @project_members, current_user_is_group_owner: current_user_is_group_owner?(@project)
+ .js-project-members-list{ data: project_members_list_data_attributes(@project, @project_members) }
+ .loading
+ .spinner.spinner-md
= paginate @project_members, theme: "gitlab", params: { search_groups: nil }
- if show_groups?(@group_links)
#tab-groups.tab-pane{ class: ('active' if groups_tab_active?) }
- - if vue_project_members_list_enabled
- .js-project-group-links-list{ data: project_group_links_list_data_attributes(@project, @group_links) }
- .loading
- .spinner.spinner-md
- - else
- = render 'projects/project_members/groups', group_links: @group_links
+ .js-project-group-links-list{ data: project_group_links_list_data_attributes(@project, @group_links) }
+ .loading
+ .spinner.spinner-md
- if show_invited_members?(@project, @invited_members)
#tab-invited-members.tab-pane
- - if vue_project_members_list_enabled
- .js-project-invited-members-list{ data: project_members_list_data_attributes(@project, @invited_members) }
- .loading
- .spinner.spinner-md
- - else
- .card.card-without-border
- = render 'shared/members/tab_pane/header' do
- = render 'shared/members/tab_pane/title' do
- = html_escape(_('Members invited to %{strong_start}%{project_name}%{strong_end}')) % { project_name: @project.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- %ul.content-list.members-list
- = render partial: 'shared/members/member', collection: @invited_members, as: :member, locals: { membership_source: @project, group: group, current_user_is_group_owner: current_user_is_group_owner?(@project) }
+ .js-project-invited-members-list{ data: project_members_list_data_attributes(@project, @invited_members) }
+ .loading
+ .spinner.spinner-md
- if show_access_requests?(@project, @requesters)
#tab-access-requests.tab-pane
- - if vue_project_members_list_enabled
- .js-project-access-requests-list{ data: project_members_list_data_attributes(@project, @requesters) }
- .loading
- .spinner.spinner-md
- - else
- .card.card-without-border
- = render 'shared/members/tab_pane/header' do
- = render 'shared/members/tab_pane/title' do
- = html_escape(_('Users requesting access to %{strong_start}%{project_name}%{strong_end}')) % { project_name: @project.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- %ul.content-list.members-list
- = render partial: 'shared/members/member', collection: @requesters, as: :member, locals: { membership_source: @project, group: group }
+ .js-project-access-requests-list{ data: project_members_list_data_attributes(@project, @requesters) }
+ .loading
+ .spinner.spinner-md
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 201e2d5b5fb..7908550ca88 100644
--- a/app/views/projects/project_templates/_project_fields_form.html.haml
+++ b/app/views/projects/project_templates/_project_fields_form.html.haml
@@ -8,5 +8,5 @@
.selected-icon.gl-mr-3
.selected-template
.input-group-append
- %button.btn.btn-default.change-template{ type: "button" }
+ %button.btn.gl-button.btn-default.change-template{ type: "button" }
= _('Change template')
diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml
index f07de81d7fd..522e9888bc6 100644
--- a/app/views/projects/protected_branches/shared/_branches_list.html.haml
+++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml
@@ -23,6 +23,12 @@
%th
= s_("ProtectedBranch|Allowed to push")
+ - if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project)
+ %th
+ = s_("ProtectedBranch|Allow force push")
+ %span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Allow force push for all users with push access.'), 'aria-hidden': 'true' }
+ = sprite_icon('question', size: 16, css_class: 'gl-text-gray-500')
+
= render_if_exists 'projects/protected_branches/ee/code_owner_approval_table_head'
- if can_admin_project
diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
index 6ce01566a42..ae03b198bc9 100644
--- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
@@ -21,6 +21,13 @@
= f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push:"), class: 'col-md-2 text-left text-md-right'
.col-md-10
= yield :push_access_levels
+ - if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project)
+ .form-group.row
+ = f.label :allow_force_push, s_("ProtectedBranch|Allow force push:"), class: 'col-md-2 gl-text-left text-md-right'
+ .col-md-10
+ = render "shared/buttons/project_feature_toggle", class_list: "js-force-push-toggle project-feature-toggle"
+ .form-text.gl-text-gray-600.gl-mt-0
+ = s_("ProtectedBranch|Allow force push for all users with push access.")
= render_if_exists 'projects/protected_branches/ee/code_owner_approval_form', f: f
.card-footer
- = f.submit s_('ProtectedBranch|Protect'), class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_button' }
+ = f.submit s_('ProtectedBranch|Protect'), class: 'btn-success gl-button btn', disabled: true, data: { qa_selector: 'protect_button' }
diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml
index 1d3e3fb11ae..c2a5efa7b7c 100644
--- a/app/views/projects/protected_branches/shared/_index.html.haml
+++ b/app/views/projects/protected_branches/shared/_index.html.haml
@@ -2,9 +2,9 @@
%section.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded), data: { qa_selector: 'protected_branches_settings_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
Protected branches
- %button.btn.js-settings-toggle.qa-expand-protected-branches{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle.qa-expand-protected-branches{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Keep stable branches secure, and force developers to use merge requests. #{link_to "What are protected branches?", help_page_path("user/project/protected_branches")}
diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml
index 67eadd39ed6..aab4d366605 100644
--- a/app/views/projects/protected_tags/shared/_index.html.haml
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -2,9 +2,9 @@
%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded), data: { qa_selector: 'protected_tag_settings_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
Protected tags
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Limit access to creating and updating tags. #{link_to "What are protected tags?", help_page_path("user/project/protected_tags")}
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index a2009b96c0d..bbef5150a62 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -15,7 +15,6 @@
"expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
- "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"project_path": @project.full_path,
"gid_prefix": container_repository_gid_prefix,
"is_admin": current_user&.admin.to_s,
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index 41159df1435..7f5acbbe890 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -40,5 +40,5 @@
- if runner.tags.present?
%p
- runner.tags.map(&:name).sort.each do |tag|
- %span.badge.badge-primary
+ %span.badge.gl-badge.sm.badge-pill.badge-primary
= tag
diff --git a/app/views/projects/security/configuration/show.html.haml b/app/views/projects/security/configuration/show.html.haml
index fe47ce327c2..4d6feb9de6d 100644
--- a/app/views/projects/security/configuration/show.html.haml
+++ b/app/views/projects/security/configuration/show.html.haml
@@ -1,4 +1,4 @@
- breadcrumb_title _("Security Configuration")
- page_title _("Security Configuration")
-#js-security-configuration-static{ data: {project_path: @project.full_path} }
+#js-security-configuration-static{ data: { project_path: @project.full_path, upgrade_path: security_upgrade_path } }
diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml
index 717df405fa7..9b8da857398 100644
--- a/app/views/projects/services/prometheus/_configuration_banner.html.haml
+++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml
@@ -3,7 +3,7 @@
- if service.manual_configuration?
.info-well
- = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below')
+ = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration.')
- else
.container-fluid
.row
@@ -13,14 +13,14 @@
= image_tag 'illustrations/monitoring/getting_started.svg'
.col-sm-10
%p.text-success.gl-mt-3
- = s_('PrometheusService|Prometheus is being automatically managed on your clusters')
+ = s_('PrometheusService|GitLab is managing Prometheus on your clusters.')
= link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button'
- else
.col-sm-2
= image_tag 'illustrations/monitoring/loading.svg'
.col-sm-10
%p.gl-mt-3
- = s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments')
+ = 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 gl-button btn-success'
%hr
diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml
index c5b3fd31efa..04bd800c47a 100644
--- a/app/views/projects/services/prometheus/_help.html.haml
+++ b/app/views/projects/services/prometheus/_help.html.haml
@@ -4,4 +4,4 @@
%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.')
+ = s_('PrometheusService|Auto configuration settings are used unless you override their values here.')
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
index 3b03e213983..d1a95886115 100644
--- a/app/views/projects/settings/_general.html.haml
+++ b/app/views/projects/settings/_general.html.haml
@@ -1,13 +1,12 @@
-= form_for [@project], remote: true, html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f|
+= form_for [@project], html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' }
- = form_errors(@project)
%fieldset
.row
.form-group.col-md-5
= f.label :name, class: 'label-bold', for: 'project_name_edit' do
= _('Project name')
- = f.text_field :name, class: 'form-control gl-form-input qa-project-name-field', id: "project_name_edit"
+ = f.text_field :name, class: 'form-control gl-form-input', id: "project_name_edit", data: { qa_selector: 'project_name_field' }
.form-group.col-md-7
= f.label :id, class: 'label-bold' do
@@ -40,4 +39,4 @@
%hr
= link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
- = f.submit _('Save changes'), class: "gl-button btn btn-success gl-mt-6 qa-save-naming-topics-avatar-button"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success gl-mt-6", data: { qa_selector: 'save_naming_topics_avatar_button' }
diff --git a/app/views/projects/settings/ci_cd/_badge.html.haml b/app/views/projects/settings/ci_cd/_badge.html.haml
index 2c3e6387972..38d8c8d26e1 100644
--- a/app/views/projects/settings/ci_cd/_badge.html.haml
+++ b/app/views/projects/settings/ci_cd/_badge.html.haml
@@ -12,21 +12,21 @@
= render 'shared/ref_switcher', destination: 'badges', align_right: true
.card-body
.row
- .col-md-2.text-center
+ .col-md-2.gl-text-center
Markdown
.col-md-10.code.js-syntax-highlight
= highlight_badge('.md', badge.to_markdown, language: 'markdown')
.row
%hr
.row
- .col-md-2.text-center
+ .col-md-2.gl-text-center
HTML
.col-md-10.code.js-syntax-highlight
= highlight_badge('.html', badge.to_html, language: 'html')
.row
%hr
.row
- .col-md-2.text-center
+ .col-md-2.gl-text-center
AsciiDoc
.col-md-10.code.js-syntax-highlight
= highlight_badge('.adoc', badge.to_asciidoc)
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index e0c4a3d624e..3b0073848a6 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -96,7 +96,7 @@
= html_escape(_('The regular expression used to find test coverage output in the job log. For example, use %{regex} for Simplecov (Ruby). Leave blank to disable.')) % { regex: '<code>\(\d+.\d+%\)</code>'.html_safe }
= link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank'
- = f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'save_general_pipelines_changes_button' }
+ = f.submit _('Save changes'), class: "btn gl-button btn-success", data: { qa_selector: 'save_general_pipelines_changes_button' }
%hr
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 51abb8ed791..cca980b5359 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -1,13 +1,15 @@
- @content_class = "limit-container-width" unless fluid_layout
-- page_title _("CI / CD Settings")
-- page_title _("CI / CD")
+- page_title _("CI/CD Settings")
+- page_title _("CI/CD")
- expanded = expanded_by_default?
- general_expanded = @project.errors.empty? ? expanded : true
+- enable_search_settings locals: { container_class: 'gl-my-5' }
+
%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded), data: { qa_selector: 'general_pipelines_settings_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("General pipelines")
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
@@ -18,7 +20,7 @@
%section.settings#autodevops-settings.no-animate{ class: ('expanded' if expanded), data: { qa_selector: 'autodevops_settings_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= s_('CICD|Auto DevOps')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
@@ -35,7 +37,7 @@
%section.settings.no-animate#js-runners-settings{ class: ('expanded' if expanded || params[:expand_runners]), data: { qa_selector: 'runners_settings_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("Runners")
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
@@ -48,7 +50,7 @@
- if Gitlab::CurrentSettings.current_application_settings.keep_latest_artifact?
%section.settings.no-animate#js-artifacts-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("Artifacts")
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
@@ -65,7 +67,7 @@
%section.settings.no-animate#js-pipeline-triggers{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("Pipeline triggers")
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
@@ -78,7 +80,7 @@
- if settings_container_registry_expiration_policy_available?(@project)
%section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("Clean up image tags")
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
@@ -93,7 +95,7 @@
- if can?(current_user, :create_freeze_period, @project)
%section.settings.no-animate#js-deploy-freeze-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("Deploy freezes")
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
diff --git a/app/views/projects/settings/operations/_configuration_banner.html.haml b/app/views/projects/settings/operations/_configuration_banner.html.haml
index 69bbd0edac7..888625689f1 100644
--- a/app/views/projects/settings/operations/_configuration_banner.html.haml
+++ b/app/views/projects/settings/operations/_configuration_banner.html.haml
@@ -3,7 +3,7 @@
- if service.manual_configuration?
.info-well.p-2.mt-2
- = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below')
+ = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration.')
- else
.container-fluid
.row
@@ -13,12 +13,12 @@
= image_tag 'illustrations/monitoring/getting_started.svg'
.col-sm-10
%p.text-success.gl-mt-3
- = s_('PrometheusService|Prometheus is being automatically managed on your clusters')
+ = s_('PrometheusService|GitLab manages Prometheus 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.gl-mt-3
- = s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments')
+ = s_('PrometheusService|Monitor your project’s environments by deploying and configuring Prometheus on your clusters.')
= link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn btn-success'
diff --git a/app/views/projects/settings/operations/_prometheus.html.haml b/app/views/projects/settings/operations/_prometheus.html.haml
index 7ccc829662d..ccf5b5dc75f 100644
--- a/app/views/projects/settings/operations/_prometheus.html.haml
+++ b/app/views/projects/settings/operations/_prometheus.html.haml
@@ -14,4 +14,4 @@
%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.')
+ = s_('PrometheusService|Auto configuration settings are used unless you override their values here.')
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 73722a5a789..5ba796f5720 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -2,6 +2,8 @@
- page_title _('Operations Settings')
- breadcrumb_title _('Operations Settings')
+- enable_search_settings locals: { container_class: 'gl-my-5' }
+
= render 'projects/settings/operations/alert_management'
= render 'projects/settings/operations/incidents'
= render 'projects/settings/operations/error_tracking'
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 24fc137fd29..8ac42ce3f81 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -3,6 +3,8 @@
- @content_class = "limit-container-width" unless fluid_layout
- deploy_token_description = s_('DeployTokens|Deploy tokens allow access to packages, your repository, and registry images.')
+- enable_search_settings locals: { container_class: 'gl-my-5' }
+
= render "projects/default_branch/show"
= render_if_exists "projects/push_rules/index"
= render "projects/mirrors/mirror_repos"
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index e1774c955bc..40faf91eadf 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -6,9 +6,6 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
-= content_for :invite_members_sidebar do
- = render partial: 'projects/invite_members_link'
-
= render partial: 'flash_messages', locals: { project: @project }
= render "projects/last_push"
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 61b357831fd..1072d5bce06 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -2,7 +2,7 @@
- release = @releases.find { |release| release.tag == tag.name }
- commit_status = @tag_pipeline_statuses[tag.name] unless @tag_pipeline_statuses.nil?
-%li.flex-row.allow-wrap.js-tag-list
+%li.flex-row.js-tag-list{ class: "gl-white-space-normal!" }
.row-main-content
= sprite_icon('tag')
= link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name'
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 6d33fbb535e..340f9811f9a 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -7,7 +7,7 @@
.tree-controls
.d-block.d-sm-flex.flex-wrap.align-items-start.gl-children-ml-sm-3<
= render_if_exists 'projects/tree/lock_link'
- #js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } }
+ #js-tree-history-link{ data: { history_link: project_commits_path(@project, @ref) } }
= render 'projects/find_file_link'
= render 'shared/web_ide_button', blob: nil
diff --git a/app/views/projects/update.js.haml b/app/views/projects/update.js.haml
deleted file mode 100644
index c5eecc900b2..00000000000
--- a/app/views/projects/update.js.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-- if @project.valid?
- :plain
- location.href = "#{edit_project_path(@project, anchor: params[:update_section])}";
- location.reload();
-- else
- :plain
- $(".flash-container").html("#{escape_javascript(render('errors'))}");
- $('.save-project-loader').hide();
- $('.project-edit-container').show();
- $('.edit-project .js-btn-success-general-project-settings').enable();
diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml
index 8aad4848aa2..cb8bab7d0de 100644
--- a/app/views/search/results/_issuable.html.haml
+++ b/app/views/search/results/_issuable.html.haml
@@ -5,6 +5,6 @@
= link_to issuable_path(issuable), data: { track_event: 'click_text', track_label: "#{issuable.class.name.downcase}_title", track_property: 'search_result' }, class: 'gl-w-full' do
%span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title
.gl-text-gray-500.gl-my-3
- = sprintf(s_(' %{project_name}#%{issuable_iid} &middot; opened %{issuable_created} by %{author} &middot; updated %{issuable_updated}'), { project_name: issuable.project.full_name, issuable_iid: issuable.iid, issuable_created: time_ago_with_tooltip(issuable.created_at, placement: 'bottom'), issuable_updated: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom'), author: link_to_member(@project, issuable.author, avatar: false) }).html_safe
+ = sprintf(s_(' %{project_name}#%{issuable_iid} &middot; created %{issuable_created} by %{author} &middot; updated %{issuable_updated}'), { project_name: issuable.project.full_name, issuable_iid: issuable.iid, issuable_created: time_ago_with_tooltip(issuable.created_at, placement: 'bottom'), issuable_updated: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom'), author: link_to_member(@project, issuable.author, avatar: false) }).html_safe
.description.term.col-sm-10.gl-px-0
= highlight_and_truncate_issuable(issuable, @search_term, @search_highlight)
diff --git a/app/views/shared/_confirm_fork_modal.html.haml b/app/views/shared/_confirm_fork_modal.html.haml
index 1390d821899..b692dffce37 100644
--- a/app/views/shared/_confirm_fork_modal.html.haml
+++ b/app/views/shared/_confirm_fork_modal.html.haml
@@ -8,5 +8,5 @@
.modal-body.p-3
%p= _("You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.") % { tag_start: '', tag_end: ''}
.modal-footer
- = link_to _('Cancel'), '#', class: "btn btn-cancel", "data-dismiss" => "modal"
- = link_to _('Fork project'), fork_path, class: 'btn btn-success', data: { qa_selector: 'fork_project_button' }, method: :post
+ = link_to _('Cancel'), '#', class: "gl-button btn btn-default btn-cancel", "data-dismiss" => "modal"
+ = link_to _('Fork project'), fork_path, class: 'gl-button btn btn-confirm', data: { qa_selector: 'fork_project_button' }, method: :post
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index c3fac5cd464..eea0c5f37de 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -39,7 +39,7 @@
.gl-alert-body
= _('Changing group URL can have unintended side effects.')
= succeed '.' do
- = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank', class: 'gl-link'
+ = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'change-a-groups-path'), target: '_blank', class: 'gl-link'
- if @group.persisted?
.row
diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml
index 2854b115506..3edfd502f13 100644
--- a/app/views/shared/_mobile_clone_panel.html.haml
+++ b/app/views/shared/_mobile_clone_panel.html.haml
@@ -3,8 +3,8 @@
- http_copy_label = _('Copy %{http_label} clone URL') % { http_label: gitlab_config.protocol.upcase }
.btn-group.mobile-git-clone.js-mobile-git-clone.btn-block
- = clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "btn-primary flex-fill bold justify-content-center input-group-text clone-dropdown-btn js-clone-dropdown-label")
- %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle.flex-grow-0.d-flex-center.w-auto.ml-0{ type: "button", data: { toggle: "dropdown" } }
+ = clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "gl-button btn btn-confirm flex-fill input-group-text clone-dropdown-btn js-clone-dropdown-label")
+ %button.gl-button.btn.btn-confirm.btn-icon.dropdown-toggle.js-dropdown-toggle.flex-grow-0.d-flex-center.w-auto.ml-0{ type: "button", data: { toggle: "dropdown" } }
= sprite_icon("chevron-down", css_class: "dropdown-btn-icon icon")
%ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } }
- if ssh_enabled?
diff --git a/app/views/shared/_new_commit_form.html.haml b/app/views/shared/_new_commit_form.html.haml
index 62ba89e2576..5641c67e462 100644
--- a/app/views/shared/_new_commit_form.html.haml
+++ b/app/views/shared/_new_commit_form.html.haml
@@ -3,8 +3,11 @@
= render 'shared/commit_message_container', placeholder: placeholder
-- if @project.empty_repo?
- = hidden_field_tag 'branch_name', @ref
+- if project.empty_repo?
+ - ref = local_assigns[:ref] || @ref
+ - branch_name_class = project.empty_repo_upload_experiment? ? 'js-branch-name' : nil
+
+ = hidden_field_tag 'branch_name', ref, class: branch_name_class
- else
- if can?(current_user, :push_code, @project)
.form-group.row.branch
diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml
index b327b6f7ee8..3817ff8a56d 100644
--- a/app/views/shared/_new_project_item_select.html.haml
+++ b/app/views/shared/_new_project_item_select.html.haml
@@ -1,7 +1,7 @@
- if any_projects?(@projects)
.project-item-select-holder.btn-group.gl-ml-auto.gl-mr-auto.gl-py-3.gl-relative.gl-display-flex.gl-overflow-hidden
- %a.btn.gl-button.btn-success.new-project-item-link.block-truncated.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] }, class: "gl-m-0!" }
+ %a.btn.gl-button.btn-confirm.new-project-item-link.block-truncated.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] }, class: "gl-m-0!" }
= loading_icon(color: 'light')
= project_select_tag :project_path, class: "project-item-select gl-absolute! gl-visibility-hidden", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled]
- %button.btn.dropdown-toggle.btn-success.btn-md.gl-button.gl-dropdown-toggle.dropdown-toggle-split.new-project-item-select-button.qa-new-project-item-select-button.gl-p-0.gl-w-100{ class: "gl-m-0!", 'aria-label': _('Toggle project select') }
+ %button.btn.dropdown-toggle.btn-confirm.btn-md.gl-button.gl-dropdown-toggle.dropdown-toggle-split.new-project-item-select-button.qa-new-project-item-select-button.gl-p-0.gl-w-100{ class: "gl-m-0!", 'aria-label': _('Toggle project select') }
= sprite_icon('chevron-down')
diff --git a/app/views/shared/_recaptcha_form.html.haml b/app/views/shared/_recaptcha_form.html.haml
index aa9e9a34c90..f524747dea0 100644
--- a/app/views/shared/_recaptcha_form.html.haml
+++ b/app/views/shared/_recaptcha_form.html.haml
@@ -20,4 +20,4 @@
- if has_submit
.row-content-block.footer-block
- = f.submit _("Submit %{humanized_resource_name}") % { humanized_resource_name: humanized_resource_name }, class: 'btn btn-success'
+ = f.submit _("Submit %{humanized_resource_name}") % { humanized_resource_name: humanized_resource_name }, class: 'gl-button btn btn-confirm'
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index 089643f4748..9709ad8428e 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -29,5 +29,9 @@
= f.label :scopes, _('Scopes'), class: 'label-bold'
= render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes
+ - if prefix == :personal_access_token && Feature.enabled?(:personal_access_tokens_scoped_to_projects, current_user)
+ .js-access-tokens-projects
+ %input{ type: 'hidden', name: 'temporary-name', id: 'temporary-id' }
+
.gl-mt-3
- = f.submit _('Create %{type}') % { type: type }, class: 'gl-button btn btn-success', data: { qa_selector: 'create_token_button' }
+ = f.submit _('Create %{type}') % { type: type }, class: 'gl-button btn btn-confirm', data: { qa_selector: 'create_token_button' }
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index ababbdc7eb9..8c0893adaaa 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -2,7 +2,7 @@
- group = local_assigns.fetch(:group, false)
-# TODO: Move group_id and can_admin_list to the board store
See: https://gitlab.com/gitlab-org/gitlab/-/issues/213082
-- can_admin_list = can?(current_user, :admin_list, current_board_parent) == true
+- can_admin_list = can?(current_user, :admin_issue_board_list, current_board_parent) == true
- @no_breadcrumb_container = true
- @no_container = true
- @content_class = "issue-boards-content js-focus-mode-board"
diff --git a/app/views/shared/boards/_switcher.html.haml b/app/views/shared/boards/_switcher.html.haml
index 58e877f20fe..18e0ca20cf7 100644
--- a/app/views/shared/boards/_switcher.html.haml
+++ b/app/views/shared/boards/_switcher.html.haml
@@ -7,7 +7,7 @@
milestone_path: milestones_filter_path(milestone_filter_opts),
board_base_url: board_base_url,
has_missing_boards: (!multiple_boards_available? && current_board_parent.boards.size > 1).to_s,
- can_admin_board: can?(current_user, :admin_board, parent).to_s,
+ can_admin_board: can?(current_user, :admin_issue_board, parent).to_s,
multiple_issue_boards_available: parent.multiple_issue_boards_available?.to_s,
labels_path: labels_filter_path_with_defaults(only_group_labels: true, include_descendant_groups: true),
labels_web_url: parent.is_a?(Project) ? project_labels_path(@project) : group_labels_path(@group),
diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml
index 3daa13fb488..59dd571604b 100644
--- a/app/views/shared/boards/components/_sidebar.html.haml
+++ b/app/views/shared/boards/components/_sidebar.html.haml
@@ -25,7 +25,3 @@
= render "shared/boards/components/sidebar/labels"
= render_if_exists "shared/boards/components/sidebar/weight"
= render "shared/boards/components/sidebar/notifications"
- %remove-btn{ ":issue" => "issue",
- ":issue-update" => "issue.sidebarInfoEndpoint",
- ":list" => "list",
- "v-if" => "canRemove" }
diff --git a/app/views/shared/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml
index 5d9c2cd25b4..be6fe94e497 100644
--- a/app/views/shared/deploy_keys/_index.html.haml
+++ b/app/views/shared/deploy_keys/_index.html.haml
@@ -1,8 +1,8 @@
- expanded = expanded_by_default?
%section.qa-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_keys_settings_content' } }
.settings-header
- %h4= _('Deploy keys')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Deploy keys')
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/deploy_keys/index') }
diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml
index bad25086d9f..25357ccdc65 100644
--- a/app/views/shared/deploy_keys/_project_group_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -21,4 +21,4 @@
= _('Allow this key to push to this repository')
.form-group.row
- = f.submit _("Add key"), class: "btn-success btn"
+ = f.submit _("Add key"), class: "btn gl-button btn-confirm"
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index 052d68baf71..2ddfcf43756 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -48,4 +48,4 @@
.text-secondary= s_('DeployTokens|Allows write access to the package registry.')
.gl-mt-3
- = f.submit s_('DeployTokens|Create deploy token'), class: 'btn gl-button btn-success qa-create-deploy-token'
+ = f.submit s_('DeployTokens|Create deploy token'), class: 'btn gl-button btn-confirm qa-create-deploy-token'
diff --git a/app/views/shared/deploy_tokens/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml
index e64b8634cf5..9d1a24d4c24 100644
--- a/app/views/shared/deploy_tokens/_index.html.haml
+++ b/app/views/shared/deploy_tokens/_index.html.haml
@@ -2,8 +2,8 @@
%section.qa-deploy-tokens-settings.settings.no-animate#js-deploy-tokens{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_tokens_settings_content' } }
.settings-header
- %h4= s_('DeployTokens|Deploy tokens')
- %button.gl-button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' }
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= s_('DeployTokens|Deploy tokens')
+ %button.btn.gl-button.btn-default.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
= description
diff --git a/app/views/shared/empty_states/_deploy_keys.html.haml b/app/views/shared/empty_states/_deploy_keys.html.haml
index 6fca64d805b..6c615de9c56 100644
--- a/app/views/shared/empty_states/_deploy_keys.html.haml
+++ b/app/views/shared/empty_states/_deploy_keys.html.haml
@@ -6,4 +6,4 @@
.text-content.gl-mx-auto.gl-my-0.gl-p-5
%h4.h4= _('Deploy Keys')
%p= _('Deploy keys grant read/write access to all repositories in your instance')
- = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'btn btn-success btn-md gl-button'
+ = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'gl-button btn btn-confirm btn-md'
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
index 997bc7b8a98..8ccf14463c7 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -6,6 +6,8 @@
- opened_issues_count = issuables_count_for_state(:issues, :opened)
- is_opened_state = params[:state] == 'opened'
- is_closed_state = params[:state] == 'closed'
+- issuable_type = 'issues'
+- can_edit = can?(current_user, :admin_project, @project)
.row.empty-state
.col-12
@@ -20,7 +22,7 @@
= _("To widen your search, change or remove filters above")
- if show_new_issue_link?(@project)
.text-center
- = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success"
+ = link_to _("New issue"), new_project_issue_path(@project), class: "gl-button btn btn-confirm"
- elsif is_opened_state && opened_issues_count == 0 && closed_issues_count > 0
%h4.text-center
= _("There are no open issues")
@@ -28,7 +30,7 @@
= _("To keep this project going, create a new issue")
- if show_new_issue_link?(@project)
.text-center
- = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success"
+ = link_to _("New issue"), new_project_issue_path(@project), class: "gl-button btn btn-confirm"
- elsif is_closed_state && opened_issues_count > 0 && closed_issues_count == 0
%h4.text-center
= _("There are no closed issues")
@@ -42,10 +44,10 @@
- if project_select_button
= render 'shared/new_project_item_select', path: 'issues/new', label: _('New issue'), type: :issues, with_feature_enabled: 'issues'
- else
- = link_to _('New issue'), button_path, class: 'btn gl-button btn-success', id: 'new_issue_link'
+ = link_to _('New issue'), button_path, class: 'gl-button btn btn-confirm', id: 'new_issue_link'
- if show_import_button
- = render 'projects/issues/import_csv/button', type: :text
+ .js-csv-import-export-buttons{ data: { show_import_button: show_import_button.to_s, issuable_type: issuable_type, import_csv_issues_path: import_csv_namespace_project_issues_path, can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), container_class: 'gl-display-inline-flex gl-vertical-align-middle', show_label: 'true' } }
%hr
%p.gl-text-center.gl-mb-0
%strong
@@ -62,7 +64,4 @@
%p
= _("The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.")
.text-center
- = link_to _('Register / Sign In'), new_user_session_path, class: 'btn btn-success'
-
-- if show_import_button
- = render 'projects/issues/import_csv/modal'
+ = link_to _('Register / Sign In'), new_user_session_path, class: 'gl-button btn btn-confirm'
diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml
index a739103641e..4e5e04ba4d4 100644
--- a/app/views/shared/empty_states/_labels.html.haml
+++ b/app/views/shared/empty_states/_labels.html.haml
@@ -8,7 +8,7 @@
%p= _("You can also star a label to make it a priority label.")
.text-center
- if can?(current_user, :admin_label, @project)
- = link_to _('New label'), new_project_label_path(@project), class: 'btn btn-success qa-label-create-new', title: _('New label'), id: 'new_label_link'
- = link_to _('Generate a default set of labels'), generate_project_labels_path(@project), method: :post, class: 'btn btn-success btn-inverted', title: _('Generate a default set of labels'), id: 'generate_labels_link'
+ = link_to _('New label'), new_project_label_path(@project), class: 'btn gl-button btn-confirm qa-label-create-new', title: _('New label'), id: 'new_label_link'
+ = link_to _('Generate a default set of labels'), generate_project_labels_path(@project), method: :post, class: 'btn gl-button btn-confirm-secondary', title: _('Generate a default set of labels'), id: 'generate_labels_link'
- if can?(current_user, :admin_label, @group)
- = link_to _('New label'), new_group_label_path(@group), class: 'btn btn-success', title: _('New label'), id: 'new_label_link'
+ = link_to _('New label'), new_group_label_path(@group), class: 'btn gl-button btn-confirm', title: _('New label'), id: 'new_label_link'
diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml
index 837c3afc796..879447f16ae 100644
--- a/app/views/shared/empty_states/_merge_requests.html.haml
+++ b/app/views/shared/empty_states/_merge_requests.html.haml
@@ -20,7 +20,7 @@
= _("To widen your search, change or remove filters above")
.text-center
- if can_create_merge_request
- = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request")
+ = link_to _("New merge request"), project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request")
- elsif is_opened_state && opened_merged_count == 0 && closed_merged_count > 0
%h4.text-center
= _("There are no open merge requests")
@@ -28,7 +28,7 @@
= _("To keep this project going, create a new merge request")
.text-center
- if can_create_merge_request
- = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request")
+ = link_to _("New merge request"), project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request")
- elsif is_closed_state && opened_merged_count > 0 && closed_merged_count == 0
%h4.text-center
= _("There are no closed merge requests")
@@ -42,4 +42,4 @@
- if project_select_button
= render 'shared/new_project_item_select', path: 'merge_requests/new', label: _('New merge request'), type: :merge_requests, with_feature_enabled: 'merge_requests'
- else
- = link_to _('New merge request'), button_path, class: 'btn btn-success', title: _('New merge request'), id: 'new_merge_request_link'
+ = link_to _('New merge request'), button_path, class: 'gl-button btn btn-confirm', title: _('New merge request'), id: 'new_merge_request_link'
diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml
index abcf9740200..42a845846d1 100644
--- a/app/views/shared/empty_states/_profile_tabs.html.haml
+++ b/app/views/shared/empty_states/_profile_tabs.html.haml
@@ -13,9 +13,9 @@
%p= current_user_empty_message_description
- if secondary_button_link.present?
- = link_to secondary_button_label, secondary_button_link, class: 'gl-button btn btn-success btn-inverted'
+ = link_to secondary_button_label, secondary_button_link, class: 'gl-button btn btn-confirm btn-inverted'
- if primary_button_link.present?
- = link_to primary_button_label, primary_button_link, class: 'gl-button btn btn-success'
+ = link_to primary_button_label, primary_button_link, class: 'gl-button btn btn-confirm'
- else
%h5= visitor_empty_message
diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml
index 105efcc3c88..20ca7954479 100644
--- a/app/views/shared/empty_states/_snippets.html.haml
+++ b/app/views/shared/empty_states/_snippets.html.haml
@@ -12,7 +12,7 @@
= s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.')
.mt-2<
- if button_path
- = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn gl-button btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }
+ = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn gl-button btn-confirm', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }
= link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn gl-button btn-default', title: s_('SnippetsEmptyState|Documentation')
- else
%h4.text-center= s_('SnippetsEmptyState|There are no snippets to show.')
diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml
index 4150406a4ea..0bddffa881a 100644
--- a/app/views/shared/empty_states/_wikis.html.haml
+++ b/app/views/shared/empty_states/_wikis.html.haml
@@ -3,7 +3,7 @@
- if can?(current_user, :create_wiki, @wiki.container)
- create_path = wiki_page_path(@wiki, params[:id], view: 'create')
- - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn gl-button btn-success qa-create-first-page-link', title: s_('WikiEmpty|Create your first page')
+ - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn gl-button btn-confirm qa-create-first-page-link', title: s_('WikiEmpty|Create your first page')
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do
%h4.text-left
@@ -18,7 +18,7 @@
- elsif @project && can?(current_user, :read_issue, @project)
- issues_link = link_to s_('WikiEmptyIssueMessage|issue tracker'), project_issues_path(@project)
- - new_issue_link = link_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), class: 'btn gl-button btn-success', title: s_('WikiEmptyIssueMessage|Suggest wiki improvement')
+ - new_issue_link = link_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), class: 'btn gl-button btn-confirm', title: s_('WikiEmptyIssueMessage|Suggest wiki improvement')
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do
%h4
diff --git a/app/views/shared/form_elements/_apply_template_warning.html.haml b/app/views/shared/form_elements/_apply_template_warning.html.haml
index 73be0c741dc..61c0e5c42f4 100644
--- a/app/views/shared/form_elements/_apply_template_warning.html.haml
+++ b/app/views/shared/form_elements/_apply_template_warning.html.haml
@@ -7,7 +7,7 @@
%p
= _("Applying a template will replace the existing issue description. Any changes you have made will be lost.")
- %button.js-override-template.btn.btn-warning.mr-2{ type: 'button' }
+ %button.js-override-template.btn.gl-button.btn-confirm.mr-2{ type: 'button' }
= _("Apply template")
- %button.js-close-btn.js-cancel-btn.btn.btn-inverted{ type: 'button' }
+ %button.js-close-btn.js-cancel-btn.btn.gl-button.btn-default{ type: 'button' }
= _("Cancel")
diff --git a/app/views/shared/gitpod/_enable_gitpod_modal.html.haml b/app/views/shared/gitpod/_enable_gitpod_modal.html.haml
index a6bd1d10e43..dacfbf63db8 100644
--- a/app/views/shared/gitpod/_enable_gitpod_modal.html.haml
+++ b/app/views/shared/gitpod/_enable_gitpod_modal.html.haml
@@ -8,5 +8,5 @@
.modal-body.p-3
%p= (_("To use Gitpod you must first enable the feature in the integrations section of your %{user_prefs}.") % { user_prefs: link_to(_('user preferences'), profile_preferences_path(anchor: 'gitpod')) }).html_safe
.modal-footer
- = link_to _('Cancel'), '#', class: "btn btn-cancel", "data-dismiss" => "modal"
- = link_to _('Enable Gitpod'), profile_path(user: { gitpod_enabled: true}), class: 'btn btn-success', method: :put
+ = link_to _('Cancel'), '#', class: "gl-button btn btn-default btn-cancel", "data-dismiss" => "modal"
+ = link_to _('Enable Gitpod'), profile_path(user: { gitpod_enabled: true}), class: 'gl-button btn btn-confirm', method: :put
diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml
index 1d3bc1d6959..506954c53ca 100644
--- a/app/views/shared/groups/_empty_state.html.haml
+++ b/app/views/shared/groups/_empty_state.html.haml
@@ -9,5 +9,5 @@
- if invite_group_members?(@group)
= link_to _('Invite your team'),
group_group_members_path(@group),
- class: 'gl-button btn btn-success-secondary',
+ class: 'gl-button btn btn-confirm-secondary',
data: { track_event: 'click_invite_team_group_empty_state', track_label: 'invite_team_group_empty_state' }
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 2a91ffbdbaa..41b7d7e9e1b 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -64,17 +64,17 @@
.row-content-block{ class: (is_footer ? "footer-block" : "middle-block") }
.float-right
- if issuable.new_record?
- = link_to 'Cancel', polymorphic_path([@project, issuable.class]), class: 'gl-button btn btn-cancel'
+ = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'gl-button btn btn-cancel'
- else
- if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project)
= link_to 'Delete', polymorphic_path([@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, issuable]), class: 'gl-button btn btn-grouped btn-cancel'
+ = link_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'gl-button btn btn-grouped btn-default btn-cancel'
%span.gl-mr-3
- if issuable.new_record?
- = form.submit "Submit #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-success qa-issuable-create-button'
+ = form.submit "Submit #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm', data: { qa_selector: 'issuable_create_button' }
- else
- = form.submit 'Save changes', class: 'gl-button btn btn-success'
+ = form.submit 'Save changes', class: 'gl-button btn btn-confirm'
- if !issuable.persisted? && !issuable.project.empty_repo? && (guide_url = issuable.project.present.contribution_guide_path)
.inline.gl-mt-3
diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml
index 005b76180fd..cf7e6cf8365 100644
--- a/app/views/shared/issuable/_label_page_create.html.haml
+++ b/app/views/shared/issuable/_label_page_create.html.haml
@@ -19,7 +19,7 @@
%input.js-add-list{ type: "checkbox", name: "add_list", checked: add_list }
%span= _('Add list')
.clearfix
- %button.gl-button.btn.btn-success.float-left.js-new-label-btn{ type: "button" }
+ %button.gl-button.btn.btn-confirm.float-left.js-new-label-btn{ type: "button" }
= _('Create')
%button.gl-button.btn.btn-default.float-right.js-cancel-label-btn{ type: "button" }
= _('Cancel')
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index c715cd8f736..a3d6a2c8e04 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -4,7 +4,7 @@
%ul.nav-links.issues-state-filters.mobile-separator.nav.nav-tabs
%li{ class: active_when(params[:state] == 'opened') }>
- = link_to page_filter_path(state: 'opened'), id: 'state-opened', title: _("Filter by %{page_context_word} that are currently opened.") % { page_context_word: page_context_word }, data: { state: 'opened' } do
+ = link_to page_filter_path(state: 'opened'), id: 'state-opened', title: _("Filter by %{page_context_word} that are currently open.") % { page_context_word: page_context_word }, data: { state: 'opened' } do
#{issuables_state_counter_text(type, :opened, display_count)}
- if type == :merge_requests
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index d1e74cc771e..f5b2868aa6c 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -5,7 +5,10 @@
- placeholder = local_assigns[:placeholder] || _('Search or filter results...')
- is_not_boards_modal_or_productivity_analytics = type != :boards_modal && type != :productivity_analytics
- block_css_class = is_not_boards_modal_or_productivity_analytics ? 'row-content-block second-block' : ''
-- user_can_admin_list = board && can?(current_user, :admin_list, board.resource_parent)
+- if board && board.to_type == "EpicBoard"
+ - user_can_admin_list = can?(current_user, :admin_epic_board_list, board.resource_parent)
+- elsif board
+ - user_can_admin_list = can?(current_user, :admin_issue_board_list, board.resource_parent)
.issues-filters{ class: ("w-100" if type == :boards_modal) }
.issues-details-filters.filtered-search-block.d-flex.flex-column.flex-lg-row{ class: block_css_class, "v-pre" => type == :boards_modal }
@@ -18,191 +21,194 @@
- if @can_bulk_update
.check-all-holder.d-none.d-sm-block.hidden
= check_box_tag "check-all-issues", nil, false, class: "check-all-issues left"
- .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
- .filtered-search-box
- - if type != :boards_modal && type != :boards
- - text = tag.span(sprite_icon('history'), class: "d-md-none") + tag.span(_('Recent searches'), class: "d-none d-md-inline")
- = dropdown_tag(text,
- 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: search_history_storage_prefix } }
- .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(type, placeholder) }
- #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.btn.btn-link{ type: 'button' }
- -# 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.btn.btn-link{ type: 'button' }
- {{ title }}
- %span.btn-helptext
- {{ help }}
- #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
- - if current_user
- %ul{ data: { dropdown: true } }
- = render 'shared/issuable/user_dropdown_item',
- user: current_user
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- = render 'shared/issuable/user_dropdown_item',
- user: User.new(username: '{{username}}', name: '{{name}}'),
- avatar: { lazy: true, url: '{{avatar_url}}' }
- #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.divider.droplab-item-ignore
+ - if Feature.enabled?(:boards_filtered_search, @group)
+ #js-board-filtered-search
+ - else
+ .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
+ .filtered-search-box
+ - if type != :boards_modal && type != :boards
+ - text = tag.span(sprite_icon('history'), class: "d-md-none") + tag.span(_('Recent searches'), class: "d-none d-md-inline")
+ = dropdown_tag(text,
+ 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: search_history_storage_prefix } }
+ .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(type, placeholder) }
+ #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.btn.btn-link{ type: 'button' }
+ -# 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.btn.btn-link{ type: 'button' }
+ {{ title }}
+ %span.btn-helptext
+ {{ help }}
+ #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
- if current_user
+ %ul{ data: { dropdown: true } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: current_user
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
= render 'shared/issuable/user_dropdown_item',
- user: current_user
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- = render 'shared/issuable/user_dropdown_item',
- user: User.new(username: '{{username}}', name: '{{name}}'),
- avatar: { lazy: true, url: '{{avatar_url}}' }
- #js-dropdown-reviewer.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.divider.droplab-item-ignore
- - if current_user
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
+ #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ - if current_user
+ = render 'shared/issuable/user_dropdown_item',
+ user: current_user
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
+ #js-dropdown-reviewer.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ - if current_user
+ = render 'shared/issuable/user_dropdown_item',
+ user: current_user
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
= render 'shared/issuable/user_dropdown_item',
- user: current_user
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- = render 'shared/issuable/user_dropdown_item',
- user: User.new(username: '{{username}}', name: '{{name}}'),
- avatar: { lazy: true, url: '{{avatar_url}}' }
- = render_if_exists 'shared/issuable/approver_dropdown'
- = render_if_exists 'shared/issuable/approved_by_dropdown'
- #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.filter-dropdown-item{ data: { value: 'Upcoming' } }
- %button.btn.btn-link{ type: 'button' }
- = _('Upcoming')
- %li.filter-dropdown-item{ data: { value: 'Started' } }
- %button.btn.btn-link{ type: 'button' }
- = _('Started')
- %li.divider.droplab-item-ignore
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link.js-data-value{ type: 'button' }
- {{title}}
- = render_if_exists 'shared/issuable/filter_iteration', type: type
- #js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.divider.droplab-item-ignore
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link.js-data-value{ type: 'button' }
- {{title}}
- #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.divider.droplab-item-ignore
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link{ type: 'button' }
- %span.dropdown-label-box{ style: 'background: {{color}}' }
- %span.label-title.js-data-value
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
+ = render_if_exists 'shared/issuable/approver_dropdown'
+ = render_if_exists 'shared/issuable/approved_by_dropdown'
+ #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.filter-dropdown-item{ data: { value: 'Upcoming' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Upcoming')
+ %li.filter-dropdown-item{ data: { value: 'Started' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Started')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.js-data-value{ type: 'button' }
+ {{title}}
+ = render_if_exists 'shared/issuable/filter_iteration', type: type
+ #js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
- #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
- = _('None')
- %li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
- = _('Any')
- %li.divider.droplab-item-ignore
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link{ type: 'button' }
- %gl-emoji
- %span.js-data-value.gl-ml-3
- {{name}}
- #js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu
- %ul.filter-dropdown{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
- %button.btn.btn-link{ type: 'button' }
- = _('Yes')
- %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
- %button.btn.btn-link{ type: 'button' }
- = _('No')
- #js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu
- %ul.filter-dropdown{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
- %button.btn.btn-link{ type: 'button' }
- = _('Yes')
- %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
- %button.btn.btn-link{ type: 'button' }
- = _('No')
- - unless disable_target_branch
- #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu
+ #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link{ type: 'button' }
+ %span.dropdown-label-box{ style: 'background: {{color}}' }
+ %span.label-title.js-data-value
+ {{title}}
+ #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link{ type: 'button' }
+ %gl-emoji
+ %span.js-data-value.gl-ml-3
+ {{name}}
+ #js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Yes')
+ %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('No')
+ #js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Yes')
+ %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('No')
+ - unless disable_target_branch
+ #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.js-data-value.monospace
+ {{title}}
+ #js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.btn.btn-link.js-data-value.monospace
+ %button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
- #js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link.js-data-value{ type: 'button' }
- {{title}}
- = render_if_exists 'shared/issuable/filter_weight', type: type
+ = render_if_exists 'shared/issuable/filter_weight', type: type
- = render_if_exists 'shared/issuable/filter_epic', type: type
+ = render_if_exists 'shared/issuable/filter_epic', type: type
- %button.clear-search.hidden{ type: 'button' }
- = sprite_icon('close', size: 16, css_class: 'clear-search-icon')
+ %button.clear-search.hidden{ type: 'button' }
+ = sprite_icon('close', size: 16, css_class: 'clear-search-icon')
.filter-dropdown-container.d-flex.flex-column.flex-md-row
- if type == :boards
#js-board-labels-toggle
- if current_user
#js-board-epics-swimlanes-toggle
- .js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } }
+ .js-board-config{ data: { can_admin_list: user_can_admin_list.to_s, has_scope: board.scoped?.to_s } }
- if user_can_admin_list
- - if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml)
+ - if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml) || board.to_type == "EpicBoard"
.js-create-column-trigger{ data: board_list_data }
- else
= render 'shared/issuable/board_create_list_dropdown', board: board
- if @project
- #js-add-issues-btn{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
+ #js-add-issues-btn{ data: { can_admin_list: can?(current_user, :admin_issue_board_list, @project) } }
#js-toggle-focus-btn
- elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown
= render 'shared/issuable/sort_dropdown'
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index f26f4adc19a..7a1bb9dd3ca 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -34,7 +34,7 @@
- if issuable_sidebar[:supports_milestone]
- milestone = issuable_sidebar[:milestone] || {}
- .block.milestone{ data: { qa_selector: 'milestone_block' } }
+ .block.milestone{ class: 'gl-border-b-0!', data: { qa_selector: 'milestone_block' } }
.sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
= sprite_icon('clock')
%span.milestone-title.collapse-truncated-title
@@ -58,8 +58,10 @@
.selectbox.hide-collapsed
= f.hidden_field 'milestone_id', value: milestone[:id], id: nil
= dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }})
- - if @project.group.present? && issuable_sidebar[:supports_iterations]
- = render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
+
+ - if @project.group.present? && issuable_sidebar[:supports_iterations]
+ .block{ class: 'gl-pt-0!' }
+ = render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if issuable_sidebar[:supports_time_tracking]
#issuable-time-tracker.block
@@ -118,6 +120,8 @@
%script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe
#js-confidential-entry-point
+ = render_if_exists 'shared/issuable/sidebar_cve_id_request', issuable_sidebar: issuable_sidebar
+
%script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe
#js-lock-entry-point
@@ -126,17 +130,8 @@
- if signed_in
.js-sidebar-subscriptions-entry-point
- - project_ref = issuable_sidebar[:reference]
.block.with-sub-blocks
- .project-reference.sub-block
- .sidebar-collapsed-icon.dont-change-state
- = clipboard_button(text: project_ref, title: _('Copy reference'), placement: "left", boundary: 'viewport')
- .cross-project-reference.hide-collapsed
- %span
- = _('Reference:')
- %cite{ title: project_ref }
- = project_ref
- = clipboard_button(text: project_ref, title: _('Copy reference'), placement: "left", boundary: 'viewport')
+ #js-reference-entry-point
- if issuable_type == 'merge_request'
.sidebar-source-branch.sub-block
.sidebar-collapsed-icon.dont-change-state
@@ -163,7 +158,7 @@
= dropdown_content
= dropdown_loading
= dropdown_footer add_content_class: true do
- %button.gl-button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true }
+ %button.gl-button.btn.btn-confirm.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true }
= _('Move')
= loading_icon(css_class: 'gl-vertical-align-text-bottom sidebar-move-issue-confirmation-loading-icon')
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 2b6920ed80f..26986c913f0 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -43,6 +43,9 @@
- options[:dropdown_class] += ' dropdown-extended-height'
- options[:footer_content] = true
- options[:wrapper_class] = 'js-sidebar-assignee-dropdown'
+ - options[:toggle_class] += ' js-invite-members-track'
+ - data['track-event'] = show_invite_members_track_event
+ - options[:data].merge!(data)
- invite_text = _('Invite Members')
- track_label = 'edit_assignee'
diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml
index 0142c87aeb0..1a8f1a2639f 100644
--- a/app/views/shared/issuable/_sidebar_reviewers.html.haml
+++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml
@@ -39,17 +39,4 @@
- data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
- options[:data].merge!(data)
- - if experiment_enabled?(:invite_members_version_a) && can_import_members?
- - options[:dropdown_class] += ' dropdown-extended-height'
- - options[:footer_content] = true
- - options[:wrapper_class] = 'js-sidebar-reviewer-dropdown'
-
- = dropdown_tag(title, options: options) do
- %ul.dropdown-footer-list
- %li
- = link_to _('Invite Members'),
- project_project_members_path(@project),
- title: _('Invite Members'),
- data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': 'edit_reviewer' }
- - else
- = dropdown_tag(title, options: options)
+ = dropdown_tag(title, options: options)
diff --git a/app/views/shared/issuable/csv_export/_button.html.haml b/app/views/shared/issuable/csv_export/_button.html.haml
deleted file mode 100644
index 8134b7eb161..00000000000
--- a/app/views/shared/issuable/csv_export/_button.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- if current_user
- %button.csv_download_link.btn.gl-button.btn-default.btn-icon.has-tooltip{ title: _('Export as CSV'),
- data: { toggle: 'modal', target: ".#{issuable_type}-export-modal", qa_selector: 'export_as_csv_button' } }
- = sprite_icon('export')
diff --git a/app/views/shared/issuable/csv_export/_modal.html.haml b/app/views/shared/issuable/csv_export/_modal.html.haml
deleted file mode 100644
index 4a4c6b90cd9..00000000000
--- a/app/views/shared/issuable/csv_export/_modal.html.haml
+++ /dev/null
@@ -1,29 +0,0 @@
-- class_name = "#{issuable_type.dasherize}-export-modal"
-- if current_user
- .modal.issuable-export-modal{ class: class_name }
- .modal-dialog
- .modal-content{ data: { qa_selector: "export_issuable_modal" } }
- .modal-header
- %h3
- = _("Export %{issuable_type}" % { issuable_type: issuable_type.humanize(capitalize: false) })
- .svg-content.import-export-svg-container
- = image_tag 'illustrations/export-import.svg', role: "presentation", class: 'illustration'
- %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
- = sprite_icon('close', css_class: 'gl-icon')
- .modal-body
- - issuable_count = issuables_count_for_state(issuable_type.to_sym, params[:state])
- - unless issuable_count == -1 # The count timed out
- .modal-subheader
- = sprite_icon('check', css_class: 'gl-icon gl-color-green-400')
- %strong.gl-ml-3
- - if issuable_type.eql?('merge_requests')
- = n_("%{count} merge request selected", "%{count} merge requests selected", issuable_count) % { count: issuable_count }
- - else
- = n_("%{count} issue selected", "%{count} issues selected", issuable_count) % { count: issuable_count }
- .modal-text
- = html_escape(_('The CSV export will be created in the background. Once finished, it will be sent to %{strong_open}%{email}%{strong_close} in an attachment.')) % { email: @current_user.notification_email, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- .modal-footer
- - if issuable_type.eql?('merge_requests')
- = link_to _("Export merge requests"), export_csv_project_merge_requests_path(@project, request.query_parameters), method: :post, class: 'btn gl-button btn-success', data: { track_label: "export_merge_requests_csv", track_event: "click_button", track_value: "" }
- - else
- = link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn gl-button btn-success', data: { track_label: "export_issues_csv", track_event: "click_button", track_value: "", qa_selector: "export_issues_button" }
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index 2df6c3a6afd..604aac9237c 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -28,7 +28,7 @@
= render_suggested_colors
.form-actions
- if @label.persisted?
- = f.submit 'Save changes', class: 'btn gl-button btn-success js-save-button'
+ = f.submit 'Save changes', class: 'btn gl-button btn-confirm js-save-button'
- else
- = f.submit 'Create label', class: 'btn gl-button btn-success js-save-button qa-label-create-button'
- = link_to 'Cancel', back_path, class: 'btn gl-button btn-cancel'
+ = f.submit 'Create label', class: 'btn gl-button btn-confirm js-save-button qa-label-create-button'
+ = link_to 'Cancel', back_path, class: 'btn gl-button btn-default btn-cancel'
diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml
index 6d1d422f227..a0225e35c90 100644
--- a/app/views/shared/labels/_nav.html.haml
+++ b/app/views/shared/labels/_nav.html.haml
@@ -19,6 +19,6 @@
= sprite_icon('search')
= render 'shared/labels/sort_dropdown'
- if labels_or_filters && can_admin_label && @project
- = link_to _('New label'), new_project_label_path(@project), class: "btn gl-button btn-success qa-label-create-new"
+ = link_to _('New label'), new_project_label_path(@project), class: "btn gl-button btn-confirm qa-label-create-new"
- if labels_or_filters && can_admin_label && @group
- = link_to _('New label'), new_group_label_path(@group), class: "btn gl-button btn-success qa-label-create-new"
+ = link_to _('New label'), new_group_label_path(@group), class: "btn gl-button btn-confirm qa-label-create-new"
diff --git a/app/views/shared/members/_invite_group.html.haml b/app/views/shared/members/_invite_group.html.haml
index 5e3a6918ab2..d59f2950df6 100644
--- a/app/views/shared/members/_invite_group.html.haml
+++ b/app/views/shared/members/_invite_group.html.haml
@@ -23,4 +23,4 @@
.clearable-input
= text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: _('Expiration date'), id: 'expires_at_groups'
= sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input gl-text-gray-200')
- = submit_tag _("Invite"), class: "btn btn-success", data: { qa_selector: 'invite_group_button' }
+ = submit_tag _("Invite"), class: "gl-button btn btn-confirm gl-mr-3", data: { qa_selector: 'invite_group_button' }
diff --git a/app/views/shared/members/_invite_member.html.haml b/app/views/shared/members/_invite_member.html.haml
index 0302b2fc3cf..ad0ba6dcedf 100644
--- a/app/views/shared/members/_invite_member.html.haml
+++ b/app/views/shared/members/_invite_member.html.haml
@@ -23,6 +23,6 @@
.clearable-input
= text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date'
= sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input gl-text-gray-200')
- = submit_tag _("Invite"), class: "gl-button btn btn-success", data: { qa_selector: 'invite_member_button' }
+ = submit_tag _("Invite"), class: "gl-button btn btn-confirm gl-mr-3", data: { qa_selector: 'invite_member_button' }
- if can_import_members
= link_to _("Import"), import_path, class: "gl-button btn btn-default", title: _("Import members from another project")
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index c76051a25b2..0ba3e539357 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -107,7 +107,7 @@
- if member.can_approve?
= link_to polymorphic_path([:approve_access_request, member]),
method: :post,
- class: "btn btn-success btn-icon gl-button align-self-center m-0 mb-2 #{'mb-sm-0 ml-sm-2' unless force_mobile_view}",
+ class: "btn btn-confirm btn-icon gl-button align-self-center m-0 mb-2 #{'mb-sm-0 ml-sm-2' unless force_mobile_view}",
title: _('Grant access') do
%span{ class: ('d-block d-sm-none' unless force_mobile_view) }
= _('Grant access')
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 4301bf01858..f52bf1551f4 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -25,7 +25,11 @@
&bull;
- if total_count > recent_releases.count
&bull;
- = link_to n_('%{count} more release', '%{count} more releases', more_count) % { count: more_count }, project_releases_path(milestone.project)
+ - more_text = n_('%{count} more release', '%{count} more releases', more_count) % { count: more_count }
+ - if milestone.project_milestone?
+ = link_to more_text, project_releases_path(milestone.project)
+ - else
+ = more_text
%div
= render('shared/milestone_expired', milestone: milestone)
- if milestone.group_milestone?
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
deleted file mode 100644
index e12531b8a8d..00000000000
--- a/app/views/shared/notifications/_button.html.haml
+++ /dev/null
@@ -1,37 +0,0 @@
-- btn_class = local_assigns.fetch(:btn_class, '')
-- emails_disabled = local_assigns.fetch(:emails_disabled, false)
-
-- if notification_setting
- - if emails_disabled
- - button_title = notification_description(:owner_disabled)
- - aria_label = button_title
- - btn_class << " disabled"
- - else
- - button_title = _("Notification setting")
- - aria_label = _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }
-
- .js-notification-dropdown.notification-dropdown.mr-md-2.home-panel-action-button.dropdown.inline
- = form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f|
- = hidden_setting_source_input(notification_setting)
- = f.hidden_field :level, class: "notification_setting_level"
- .js-notification-toggle-btns
- %div{ class: ("btn-group" if notification_setting.custom?) }
- - if notification_setting.custom?
- %button.dropdown-new.btn.btn-default.btn-icon.gl-button.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
- = sprite_icon("notifications", css_class: "js-notification-loading")
- = notification_title(notification_setting.level)
- %button.btn.dropdown-toggle.gl-display-flex.gl-align-items-center{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
- = sprite_icon('chevron-down')
- .sr-only Toggle dropdown
- - else
- %button.dropdown-new.btn.btn-default.btn-icon.gl-button.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
- .float-left
- = sprite_icon("notifications", css_class: "js-notification-loading")
- = notification_title(notification_setting.level)
- .float-right
- = sprite_icon("chevron-down")
-
- = render "shared/notifications/notification_dropdown", notification_setting: notification_setting
-
- = content_for :scripts_body do
- = render "shared/notifications/custom_notifications", notification_setting: notification_setting
diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml
deleted file mode 100644
index 946e3c67dcf..00000000000
--- a/app/views/shared/notifications/_custom_notifications.html.haml
+++ /dev/null
@@ -1,34 +0,0 @@
-- hide_label = local_assigns.fetch(:hide_label, false)
-
-.modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), "aria-labelledby": "custom-notifications-title" }
- .modal-dialog
- .modal-content
- .modal-header
- %h4#custom-notifications-title.modal-title
- #{ _('Custom notification events') }
- %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
- %span{ "aria-hidden": true } &times;
-
- .modal-body
- .container-fluid
- = form_for notification_setting, html: { class: "custom-notifications-form" } do |f|
- = hidden_setting_source_input(notification_setting)
- = hidden_field_tag("hide_label", true) if hide_label
- .row
- .col-lg-4
- %h4.gl-mt-0= _('Notification events')
- %p
- - notification_link = link_to _('notification emails'), help_page_path('user/profile/notifications'), target: '_blank'
- - paragraph = _('Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.') % { notification_link: notification_link.html_safe }
- #{ paragraph.html_safe }
- .col-lg-8
- - notification_setting.email_events.each_with_index do |event, index|
- - field_id = "#{notifications_menu_identifier("modal", notification_setting)}_notification_setting[#{event}]"
- .form-group
- .form-check{ class: ("gl-mt-0" if index == 0) }
- = check_box("notification_setting", event, id: field_id, class: "js-custom-notification-event form-check-input", checked: notification_setting.public_send(event))
- %label.form-check-label{ for: field_id }
- %strong
- = notification_event_name(event)
- %span.spinner.is-loading.gl-vertical-align-middle.gl-display-none
- = sprite_icon('check', css_class: 'is-done gl-display-none gl-vertical-align-middle gl-text-green-600')
diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml
deleted file mode 100644
index 4b008601783..00000000000
--- a/app/views/shared/notifications/_new_button.html.haml
+++ /dev/null
@@ -1,35 +0,0 @@
-- btn_class = local_assigns.fetch(:btn_class, '')
-- dropdown_container_class = local_assigns.fetch(:dropdown_container_class, '')
-- emails_disabled = local_assigns.fetch(:emails_disabled, false)
-
-- if notification_setting
- - if emails_disabled
- - button_title = notification_description(:owner_disabled)
- - btn_class << " disabled"
- - else
- - button_title = _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }
-
- .js-notification-dropdown.notification-dropdown.home-panel-action-button.gl-mt-3.dropdown.inline{ class: dropdown_container_class }
- = form_for notification_setting, remote: true, html: { class: "notification-form no-label" } do |f|
- = hidden_setting_source_input(notification_setting)
- = hidden_field_tag "hide_label", true
- = f.hidden_field :level, class: "notification_setting_level"
- .js-notification-toggle-btns
- %div{ class: ("btn-group" if notification_setting.custom?) }
- - if notification_setting.custom?
- %button.dropdown-new.btn.gl-button.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => button_title, data: { container: "body", placement: 'top', toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
- = notification_setting_icon(notification_setting)
- %span.js-notification-loading.fa.hidden
- %button.btn.gl-button.btn-default.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" }, class: "#{btn_class}" }
- = sprite_icon("chevron-down", css_class: "icon mr-0")
- .sr-only Toggle dropdown
- - else
- %button.dropdown-new.btn.gl-button.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => button_title, data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
- = notification_setting_icon(notification_setting)
- %span.js-notification-loading.fa.hidden
- = sprite_icon("chevron-down", css_class: "icon")
-
- = render "shared/notifications/notification_dropdown", notification_setting: notification_setting
-
- = content_for :scripts_body do
- = render "shared/notifications/custom_notifications", notification_setting: notification_setting, hide_label: true
diff --git a/app/views/shared/notifications/_notification_dropdown.html.haml b/app/views/shared/notifications/_notification_dropdown.html.haml
deleted file mode 100644
index a6ef2d51171..00000000000
--- a/app/views/shared/notifications/_notification_dropdown.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-%ul.dropdown-menu.dropdown-menu-no-wrap.dropdown-menu-selectable.dropdown-menu-large{ role: "menu", class: [notifications_menu_identifier("dropdown", notification_setting)] }
- - NotificationSetting.levels.each_key do |level|
- - next if level == "custom"
- - next if level == "global" && notification_setting.source.nil?
-
- = notification_list_item(level, notification_setting)
-
- %li.divider
- %li
- %a.update-notification{ href: "#", role: "button", class: ("is-active" if notification_setting.custom?), data: { toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), notification_level: "custom", notification_title: "Custom" } }
- %strong.dropdown-menu-inner-title= s_('NotificationSetting|Custom')
- %span.dropdown-menu-inner-content= notification_description("custom")
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index c0c009f2a86..6fae6a15567 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -2,9 +2,6 @@
- avatar = true unless local_assigns[:avatar] == false
- use_creator_avatar = false unless local_assigns[:use_creator_avatar] == true
- stars = true unless local_assigns[:stars] == false
-- forks = true unless local_assigns[:forks] == false
-- merge_requests = true unless local_assigns[:merge_requests] == false
-- issues = true unless local_assigns[:issues] == false
- pipeline_status = true unless local_assigns[:pipeline_status] == false
- skip_namespace = false unless local_assigns[:skip_namespace] == true
- user = local_assigns[:user]
@@ -40,8 +37,9 @@
- css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil
= render "shared/projects/project", project: project, skip_namespace: skip_namespace,
avatar: avatar, stars: stars, css_class: css_class, use_creator_avatar: use_creator_avatar,
- forks: forks, show_last_commit_as_description: show_last_commit_as_description, user: user, merge_requests: merge_requests,
- issues: issues, pipeline_status: pipeline_status, compact_mode: compact_mode
+ forks: project.forking_enabled?, show_last_commit_as_description: show_last_commit_as_description, user: user,
+ merge_requests: project.merge_requests_enabled?, issues: project.issues_enabled?,
+ pipeline_status: pipeline_status, compact_mode: compact_mode
= paginate_collection(projects, remote: remote) unless skip_pagination
- else
- if @contributed_projects
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 115d0c9a7c5..a33cd7c3b53 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -12,9 +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) && project.last_pipeline.present?
+- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
+- last_pipeline = project.last_pipeline if show_pipeline_status_icon
- css_controls_class = compact_mode ? [] : ["flex-lg-row", "justify-content-lg-between"]
-- css_controls_class << "with-pipeline-status" if show_pipeline_status_icon
+- css_controls_class << "with-pipeline-status" if show_pipeline_status_icon && last_pipeline.present?
- avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar'
%li.project-row.d-flex{ class: css_class }
@@ -68,10 +69,10 @@
.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(" ") }
.icon-container.d-flex.align-items-center
- - if show_pipeline_status_icon
+ - if show_pipeline_status_icon && last_pipeline.present?
- pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref)
%span.icon-wrapper.pipeline-status
- = render 'ci/status/icon', status: project.last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
+ = render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
= render_if_exists 'shared/projects/archived', project: project
- if stars
diff --git a/app/views/shared/projects/_search_bar.html.haml b/app/views/shared/projects/_search_bar.html.haml
index a745da32110..6c3a6ce809f 100644
--- a/app/views/shared/projects/_search_bar.html.haml
+++ b/app/views/shared/projects/_search_bar.html.haml
@@ -13,7 +13,7 @@
.filtered-search-box.m-0
.filtered-search-box-input-container.pl-2
= render 'shared/projects/search_form', admin_view: false, search_form_placeholder: _("Search projects...")
- %button.btn.btn-secondary{ type: 'submit', form: 'project-filter-form' }
+ %button.btn.gl-button.btn-icon.btn-secondary{ type: 'submit', form: 'project-filter-form' }
= sprite_icon('search', css_class: 'search-icon ')
.filtered-search-dropdown.flex-row.align-items-center.mb-2.m-sm-0#filtered-search-visibility-dropdown{ class: flex_grow_and_shrink_xs }
.filtered-search-dropdown-label.p-0.pl-sm-3.font-weight-bold
diff --git a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
index d1b32df7139..75d6d88fbc3 100644
--- a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
+++ b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
@@ -33,3 +33,6 @@
%p.small
= _('Members of %{group} can also push to this branch: %{branch}') % { group: (group_push_access_levels.size > 1 ? 'these groups' : 'this group'), branch: group_push_access_levels.map(&:humanize).to_sentence }
+- if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project)
+ %td
+ = render "shared/buttons/project_feature_toggle", is_checked: protected_branch.allow_force_push, label: s_("ProtectedBranch|Toggle allow force push"), class_list: "js-force-push-toggle project-feature-toggle", data: { qa_selector: 'force_push_toggle_button', qa_branch_name: protected_branch.name }
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index 5f0ecb2ee79..52cf0248f21 100644
--- a/app/views/shared/snippets/_snippet.html.haml
+++ b/app/views/shared/snippets/_snippet.html.haml
@@ -10,6 +10,8 @@
%ul.controls
%li
+ = snippet_file_count(snippet)
+ %li
= link_to gitlab_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if notes_count == 0) do
= sprite_icon('comments', css_class: 'gl-vertical-align-text-bottom')
= notes_count
diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml
index 5fd22665633..d91e3c73c49 100644
--- a/app/views/shared/wikis/_form.html.haml
+++ b/app/views/shared/wikis/_form.html.haml
@@ -70,10 +70,10 @@
.form-actions
- if @page && @page.persisted?
- = f.submit _("Save changes"), class: 'btn gl-button btn-success qa-save-changes-button js-wiki-btn-submit', disabled: 'true'
+ = f.submit _("Save changes"), class: 'btn gl-button btn-confirm qa-save-changes-button js-wiki-btn-submit', disabled: 'true'
.float-right
= link_to _("Cancel"), wiki_page_path(@wiki, @page), class: 'btn gl-button btn-cancel btn-default'
- else
- = f.submit s_("Wiki|Create page"), class: 'btn-success gl-button btn qa-create-page-button rspec-create-page-button js-wiki-btn-submit', disabled: 'true'
+ = f.submit s_("Wiki|Create page"), class: 'btn-confirm gl-button btn qa-create-page-button rspec-create-page-button js-wiki-btn-submit', disabled: 'true'
.float-right
= link_to _("Cancel"), wiki_path(@wiki), class: 'btn gl-button btn-cancel btn-default'
diff --git a/app/views/shared/wikis/_main_links.html.haml b/app/views/shared/wikis/_main_links.html.haml
index 8568c36559a..02794950895 100644
--- a/app/views/shared/wikis/_main_links.html.haml
+++ b/app/views/shared/wikis/_main_links.html.haml
@@ -2,5 +2,5 @@
= link_to wiki_page_path(@wiki, @page, action: :history), class: "btn gl-button", role: "button", data: { qa_selector: 'page_history_button' } do
= s_("Wiki|Page history")
- if can?(current_user, :create_wiki, @wiki.container)
- = link_to wiki_path(@wiki, action: :new), class: "btn gl-button btn-success btn-inverted", role: "button", data: { qa_selector: 'new_page_button' } do
+ = link_to wiki_path(@wiki, action: :new), class: "btn gl-button btn-confirm-secondary", role: "button", data: { qa_selector: 'new_page_button' } do
= s_("Wiki|New page")
diff --git a/app/views/shared/wikis/_sidebar_wiki_page.html.haml b/app/views/shared/wikis/_sidebar_wiki_page.html.haml
index 4259633280a..38a7e6fc813 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), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug } do
+ = link_to wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.human_title } do
= wiki_page.human_title
diff --git a/app/views/shared/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml
index c2b0e474c03..4bdeee3996f 100644
--- a/app/views/shared/wikis/edit.html.haml
+++ b/app/views/shared/wikis/edit.html.haml
@@ -1,7 +1,8 @@
- wiki_page_title @page, @page.persisted? ? _('Edit') : _('New')
- add_page_specific_style 'page_bundles/wiki'
-= wiki_page_errors(@error)
+- if @error
+ #js-wiki-error{ data: { error: @error, wiki_page_path: wiki_page_path(@wiki, @page) } }
.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
= wiki_sidebar_toggle_button
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index cdaa739a7b3..51483df19d7 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -26,13 +26,6 @@
= link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn gl-button btn-default btn-icon',
title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= sprite_icon('error')
- - if current_user && current_user.id != @user.id
- - if current_user.following?(@user)
- = link_to user_unfollow_path(@user, :json) , class: link_classes + 'btn gl-button btn-default', method: :post do
- = _('Unfollow')
- - else
- = link_to user_follow_path(@user, :json) , class: link_classes + 'btn gl-button btn-default', method: :post do
- = _('Follow')
- if can?(current_user, :read_user_profile, @user)
= link_to user_path(@user, rss_url_options), class: link_classes + 'btn gl-button btn-default btn-icon has-tooltip',
title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
@@ -41,6 +34,13 @@
= link_to [:admin, @user], class: link_classes + 'btn gl-button btn-default btn-icon', title: s_('UserProfile|View user in admin area'),
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('user')
+ - if current_user && current_user.id != @user.id
+ - if current_user.following?(@user)
+ = link_to user_unfollow_path(@user, :json) , class: link_classes + 'btn gl-button btn-default', method: :post do
+ = _('Unfollow')
+ - else
+ = link_to user_follow_path(@user, :json) , class: link_classes + 'btn gl-button btn-confirm', method: :post do
+ = _('Follow')
.profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
.avatar-holder
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 59ab0a4d05b..ff26aa7a4be 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -131,6 +131,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:analytics_usage_trends_count_job_trigger
+ :feature_category: :devops_reports
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:authorized_project_update_periodic_recalculate
:feature_category: :source_code_management
:has_external_dependencies:
@@ -347,14 +355,6 @@
:weight: 1
:idempotent:
:tags: []
-- :name: cronjob:releases_create_evidence
- :feature_category: :release_evidence
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent:
- :tags: []
- :name: cronjob:releases_manage_evidence
:feature_category: :release_evidence
:has_external_dependencies:
@@ -1083,6 +1083,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: package_repositories:packages_maven_metadata_sync
+ :feature_category: :package_registry
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: package_repositories:packages_nuget_extraction
:feature_category: :package_registry
:has_external_dependencies:
@@ -1098,8 +1106,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :requires_disk_io
+ :tags: []
- :name: pipeline_background:ci_build_trace_chunk_flush
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1243,8 +1250,7 @@
:resource_boundary: :cpu
:weight: 5
:idempotent:
- :tags:
- - :requires_disk_io
+ :tags: []
- :name: pipeline_processing:build_queue
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1413,6 +1419,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: analytics_usage_trends_counter_job
+ :feature_category: :devops_reports
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: approve_blocked_pending_approval_users
:feature_category: :users
:has_external_dependencies:
@@ -1468,8 +1482,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :tags:
- - :requires_disk_io
+ :tags: []
- :name: ci_delete_objects
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1629,8 +1642,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :requires_disk_io
+ :tags: []
- :name: export_csv
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -1800,6 +1812,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: merge_requests_delete_source_branch
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: metrics_dashboard_prune_old_annotations
:feature_category: :metrics
:has_external_dependencies:
@@ -1832,6 +1852,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: namespaces_onboarding_issue_created
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: namespaces_onboarding_pipeline_created
:feature_category: :subgroups
:has_external_dependencies:
@@ -1840,6 +1868,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: namespaces_onboarding_progress
+ :feature_category: :product_analytics
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: namespaces_onboarding_user_added
:feature_category: :users
:has_external_dependencies:
@@ -1872,6 +1908,14 @@
:weight: 2
:idempotent:
:tags: []
+- :name: packages_composer_cache_update
+ :feature_category: :package_registry
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: pages
:feature_category: :pages
:has_external_dependencies:
@@ -2012,6 +2056,22 @@
:weight: 1
:idempotent:
:tags: []
+- :name: projects_schedule_bulk_repository_shard_moves
+ :feature_category: :gitaly
+ :has_external_dependencies:
+ :urgency: :throttled
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: projects_update_repository_storage
+ :feature_category: :gitaly
+ :has_external_dependencies:
+ :urgency: :throttled
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: prometheus_create_default_alerts
:feature_category: :incident_management
:has_external_dependencies:
@@ -2084,6 +2144,14 @@
:weight: 2
:idempotent:
:tags: []
+- :name: releases_create_evidence
+ :feature_category: :release_evidence
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: remote_mirror_notification
:feature_category: :source_code_management
:has_external_dependencies:
@@ -2172,6 +2240,22 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: snippets_schedule_bulk_repository_shard_moves
+ :feature_category: :gitaly
+ :has_external_dependencies:
+ :urgency: :throttled
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: snippets_update_repository_storage
+ :feature_category: :gitaly
+ :has_external_dependencies:
+ :urgency: :throttled
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: system_hook_push
:feature_category: :source_code_management
:has_external_dependencies:
diff --git a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
index 81a765d5d08..3ec92bc7635 100644
--- a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
+++ b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
@@ -2,31 +2,19 @@
module Analytics
module InstanceStatistics
+ # This worker will be removed in 14.0
class CountJobTriggerWorker
include ApplicationWorker
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
- DEFAULT_DELAY = 3.minutes.freeze
-
feature_category :devops_reports
urgency :low
idempotent!
def perform
- recorded_at = Time.zone.now
-
- worker_arguments = Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder.new(
- measurement_identifiers: ::Analytics::InstanceStatistics::Measurement.measurement_identifier_values,
- recorded_at: recorded_at
- ).execute
-
- perform_in = DEFAULT_DELAY.minutes.from_now
- worker_arguments.each do |args|
- CounterJobWorker.perform_in(perform_in, *args)
-
- perform_in += DEFAULT_DELAY
- end
+ # Delegate to the new worker
+ Analytics::UsageTrends::CountJobTriggerWorker.new.perform
end
end
end
diff --git a/app/workers/analytics/instance_statistics/counter_job_worker.rb b/app/workers/analytics/instance_statistics/counter_job_worker.rb
index c07b2569453..4beed8a3e2f 100644
--- a/app/workers/analytics/instance_statistics/counter_job_worker.rb
+++ b/app/workers/analytics/instance_statistics/counter_job_worker.rb
@@ -2,6 +2,7 @@
module Analytics
module InstanceStatistics
+ # This worker will be removed in 14.0
class CounterJobWorker
include ApplicationWorker
@@ -10,24 +11,9 @@ module Analytics
idempotent!
- def perform(measurement_identifier, min_id, max_id, recorded_at)
- query_scope = ::Analytics::InstanceStatistics::Measurement.identifier_query_mapping[measurement_identifier].call
-
- count = if min_id.nil? || max_id.nil? # table is empty
- 0
- else
- counter(query_scope, min_id, max_id)
- end
-
- return if count == Gitlab::Database::BatchCounter::FALLBACK
-
- InstanceStatistics::Measurement.insert_all([{ recorded_at: recorded_at, count: count, identifier: measurement_identifier }])
- end
-
- private
-
- def counter(query_scope, min_id, max_id)
- Gitlab::Database::BatchCount.batch_count(query_scope, start: min_id, finish: max_id)
+ def perform(*args)
+ # Delegate to the new worker
+ Analytics::UsageTrends::CounterJobWorker.new.perform(*args)
end
end
end
diff --git a/app/workers/analytics/usage_trends/count_job_trigger_worker.rb b/app/workers/analytics/usage_trends/count_job_trigger_worker.rb
new file mode 100644
index 00000000000..37f5c19d64c
--- /dev/null
+++ b/app/workers/analytics/usage_trends/count_job_trigger_worker.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Analytics
+ module UsageTrends
+ class CountJobTriggerWorker
+ extend ::Gitlab::Utils::Override
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ DEFAULT_DELAY = 3.minutes.freeze
+
+ feature_category :devops_reports
+ urgency :low
+
+ idempotent!
+
+ def perform
+ recorded_at = Time.zone.now
+
+ worker_arguments = Gitlab::Analytics::UsageTrends::WorkersArgumentBuilder.new(
+ measurement_identifiers: ::Analytics::UsageTrends::Measurement.measurement_identifier_values,
+ recorded_at: recorded_at
+ ).execute
+
+ perform_in = DEFAULT_DELAY.minutes.from_now
+ worker_arguments.each do |args|
+ CounterJobWorker.perform_in(perform_in, *args)
+
+ perform_in += DEFAULT_DELAY
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/analytics/usage_trends/counter_job_worker.rb b/app/workers/analytics/usage_trends/counter_job_worker.rb
new file mode 100644
index 00000000000..275c6ac2de2
--- /dev/null
+++ b/app/workers/analytics/usage_trends/counter_job_worker.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Analytics
+ module UsageTrends
+ class CounterJobWorker
+ extend ::Gitlab::Utils::Override
+ include ApplicationWorker
+
+ feature_category :devops_reports
+ urgency :low
+
+ idempotent!
+
+ def perform(measurement_identifier, min_id, max_id, recorded_at)
+ query_scope = ::Analytics::UsageTrends::Measurement.identifier_query_mapping[measurement_identifier].call
+
+ count = if min_id.nil? || max_id.nil? # table is empty
+ 0
+ else
+ counter(query_scope, min_id, max_id)
+ end
+
+ return if count == Gitlab::Database::BatchCounter::FALLBACK
+
+ UsageTrends::Measurement.insert_all([{ recorded_at: recorded_at, count: count, identifier: measurement_identifier }])
+ end
+
+ private
+
+ def counter(query_scope, min_id, max_id)
+ Gitlab::Database::BatchCount.batch_count(query_scope, start: min_id, finish: max_id)
+ end
+ end
+ end
+end
diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb
index b0c5bef336a..3ddb5686bf2 100644
--- a/app/workers/archive_trace_worker.rb
+++ b/app/workers/archive_trace_worker.rb
@@ -4,8 +4,6 @@ class ArchiveTraceWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
include PipelineBackgroundQueue
- tags :requires_disk_io
-
# rubocop: disable CodeReuse/ActiveRecord
def perform(job_id)
Ci::Build.without_archived_trace.find_by(id: job_id).try do |job|
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index 4d15bcd16f7..3f99b30fdf7 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -7,7 +7,6 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
queue_namespace :pipeline_processing
urgency :high
worker_resource_boundary :cpu
- tags :requires_disk_io
ARCHIVE_TRACES_IN = 2.minutes.freeze
@@ -35,7 +34,7 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
# We execute these async as these are independent operations.
BuildHooksWorker.perform_async(build.id)
- ExpirePipelineCacheWorker.perform_async(build.pipeline_id) if build.pipeline.cacheable?
+ ExpirePipelineCacheWorker.perform_async(build.pipeline_id)
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
##
diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb
index 94a0197b862..5fab437f49f 100644
--- a/app/workers/chat_notification_worker.rb
+++ b/app/workers/chat_notification_worker.rb
@@ -7,7 +7,6 @@ class ChatNotificationWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: false
feature_category :chatops
- tags :requires_disk_io
urgency :low # Can't be high as it has external dependencies
weight 2
worker_has_external_dependencies!
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index bb6192166b4..042508d08f2 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -133,5 +133,13 @@ module WorkerAttributes
def get_deduplication_options
class_attributes[:deduplication_options] || {}
end
+
+ def big_payload!
+ class_attributes[:big_payload] = true
+ end
+
+ def big_payload?
+ class_attributes[:big_payload]
+ end
end
end
diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb
index 2f0d7fecf19..1a34bf50d87 100644
--- a/app/workers/emails_on_push_worker.rb
+++ b/app/workers/emails_on_push_worker.rb
@@ -96,6 +96,6 @@ class EmailsOnPushWorker # rubocop:disable Scalability/IdempotentWorker
def valid_recipients(recipients)
recipients.split.select do |recipient|
recipient.include?('@')
- end
+ end.uniq(&:downcase)
end
end
diff --git a/app/workers/error_tracking_issue_link_worker.rb b/app/workers/error_tracking_issue_link_worker.rb
index fa8af4f1822..4ad80d57f6b 100644
--- a/app/workers/error_tracking_issue_link_worker.rb
+++ b/app/workers/error_tracking_issue_link_worker.rb
@@ -26,7 +26,7 @@ class ErrorTrackingIssueLinkWorker # rubocop:disable Scalability/IdempotentWorke
logger.info("Linking Sentry issue #{sentry_issue_id} to GitLab issue #{issue.id}")
sentry_client.create_issue_link(integration_id, sentry_issue_id, issue)
- rescue Sentry::Client::Error => e
+ rescue ErrorTracking::SentryClient::Error => e
logger.info("Failed to link Sentry issue #{sentry_issue_id} to GitLab issue #{issue.id} with error: #{e.message}")
end
end
@@ -63,7 +63,7 @@ class ErrorTrackingIssueLinkWorker # rubocop:disable Scalability/IdempotentWorke
sentry_client
.repos(organization_slug)
.find { |repo| repo.project_id == issue.project_id && repo.status == 'active' }
- rescue Sentry::Client::Error => e
+ rescue ErrorTracking::SentryClient::Error => e
logger.info("Unable to retrieve Sentry repo for organization #{organization_slug}, id #{sentry_issue_id}, with error: #{e.message}")
nil
diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb
index a5571473b43..e6cd60a3e47 100644
--- a/app/workers/expire_build_instance_artifacts_worker.rb
+++ b/app/workers/expire_build_instance_artifacts_worker.rb
@@ -4,7 +4,6 @@ class ExpireBuildInstanceArtifactsWorker # rubocop:disable Scalability/Idempoten
include ApplicationWorker
feature_category :continuous_integration
- tags :requires_disk_io
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb
index ce27fed7fb1..77b0edfd7de 100644
--- a/app/workers/expire_job_cache_worker.rb
+++ b/app/workers/expire_job_cache_worker.rb
@@ -16,19 +16,13 @@ class ExpireJobCacheWorker
pipeline = job.pipeline
project = job.project
- Gitlab::EtagCaching::Store.new.tap do |store|
- store.touch(project_pipeline_path(project, pipeline))
- store.touch(project_job_path(project, job))
- end
+ Gitlab::EtagCaching::Store.new.touch(project_job_path(project, job))
+ ExpirePipelineCacheWorker.perform_async(pipeline.id)
end
# rubocop: enable CodeReuse/ActiveRecord
private
- def project_pipeline_path(project, pipeline)
- Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json)
- end
-
def project_job_path(project, job)
Gitlab::Routing.url_helpers.project_build_path(project, job.id, format: :json)
end
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index 0710ef9298b..02039e40e15 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -13,7 +13,7 @@ class ExpirePipelineCacheWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id)
pipeline = Ci::Pipeline.find_by(id: pipeline_id)
- return unless pipeline&.cacheable?
+ return unless pipeline
Ci::ExpirePipelineCacheService.new.execute(pipeline)
end
diff --git a/app/workers/merge_requests/delete_source_branch_worker.rb b/app/workers/merge_requests/delete_source_branch_worker.rb
new file mode 100644
index 00000000000..eb83d10af33
--- /dev/null
+++ b/app/workers/merge_requests/delete_source_branch_worker.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class MergeRequests::DeleteSourceBranchWorker
+ include ApplicationWorker
+
+ feature_category :source_code_management
+ urgency :high
+ idempotent!
+
+ def perform(merge_request_id, source_branch_sha, user_id)
+ merge_request = MergeRequest.find(merge_request_id)
+ user = User.find(user_id)
+
+ # Source branch changed while it's being removed
+ return if merge_request.source_branch_sha != source_branch_sha
+
+ ::Branches::DeleteService.new(merge_request.source_project, user)
+ .execute(merge_request.source_branch)
+
+ ::MergeRequests::RetargetChainService.new(merge_request.source_project, user)
+ .execute(merge_request)
+ rescue ActiveRecord::RecordNotFound
+ end
+end
diff --git a/app/workers/namespaces/in_product_marketing_emails_worker.rb b/app/workers/namespaces/in_product_marketing_emails_worker.rb
index 66d140928a7..f8fa393264a 100644
--- a/app/workers/namespaces/in_product_marketing_emails_worker.rb
+++ b/app/workers/namespaces/in_product_marketing_emails_worker.rb
@@ -9,6 +9,7 @@ module Namespaces
urgency :low
def perform
+ return unless Gitlab::CurrentSettings.in_product_marketing_emails_enabled
return unless Gitlab::Experimentation.active?(:in_product_marketing_emails)
Namespaces::InProductMarketingEmailsService.send_for_all_tracks_and_intervals
diff --git a/app/workers/namespaces/onboarding_issue_created_worker.rb b/app/workers/namespaces/onboarding_issue_created_worker.rb
new file mode 100644
index 00000000000..e5e2c80e821
--- /dev/null
+++ b/app/workers/namespaces/onboarding_issue_created_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class OnboardingIssueCreatedWorker
+ include ApplicationWorker
+
+ feature_category :issue_tracking
+ urgency :low
+
+ deduplicate :until_executing
+ idempotent!
+
+ def perform(namespace_id)
+ namespace = Namespace.find_by_id(namespace_id)
+ return unless namespace
+
+ OnboardingProgressService.new(namespace).execute(action: :issue_created)
+ end
+ end
+end
diff --git a/app/workers/namespaces/onboarding_progress_worker.rb b/app/workers/namespaces/onboarding_progress_worker.rb
new file mode 100644
index 00000000000..9cb5a23cf31
--- /dev/null
+++ b/app/workers/namespaces/onboarding_progress_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class OnboardingProgressWorker
+ include ApplicationWorker
+
+ feature_category :product_analytics
+ urgency :low
+
+ deduplicate :until_executed
+ idempotent!
+
+ def perform(namespace_id, action)
+ namespace = Namespace.find_by_id(namespace_id)
+ return unless namespace && action
+
+ OnboardingProgressService.new(namespace).execute(action: action.to_sym)
+ end
+ end
+end
diff --git a/app/workers/packages/composer/cache_update_worker.rb b/app/workers/packages/composer/cache_update_worker.rb
new file mode 100644
index 00000000000..664fb23284f
--- /dev/null
+++ b/app/workers/packages/composer/cache_update_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Packages
+ module Composer
+ class CacheUpdateWorker
+ include ApplicationWorker
+
+ feature_category :package_registry
+
+ idempotent!
+
+ def perform(project_id, package_name, last_page_sha)
+ project = Project.find_by_id(project_id)
+
+ return unless project
+
+ Gitlab::Composer::Cache.new(project: project, name: package_name, last_page_sha: last_page_sha).execute
+ rescue => e
+ Gitlab::ErrorTracking.log_exception(e, project_id: project_id)
+ end
+ end
+ end
+end
diff --git a/app/workers/packages/maven/metadata/sync_worker.rb b/app/workers/packages/maven/metadata/sync_worker.rb
new file mode 100644
index 00000000000..eb7abf4cdd0
--- /dev/null
+++ b/app/workers/packages/maven/metadata/sync_worker.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module Packages
+ module Maven
+ module Metadata
+ class SyncWorker
+ include ApplicationWorker
+ include Gitlab::Utils::StrongMemoize
+
+ queue_namespace :package_repositories
+ feature_category :package_registry
+
+ deduplicate :until_executing
+ idempotent!
+
+ loggable_arguments 2
+
+ SyncError = Class.new(StandardError)
+
+ def perform(user_id, project_id, package_name)
+ @user_id = user_id
+ @project_id = project_id
+ @package_name = package_name
+
+ return unless valid?
+
+ result = ::Packages::Maven::Metadata::SyncService.new(container: project, current_user: user, params: { package_name: @package_name })
+ .execute
+
+ if result.success?
+ log_extra_metadata_on_done(:message, result.message)
+ else
+ raise SyncError.new(result.message)
+ end
+
+ raise SyncError.new(result.message) unless result.success?
+ end
+
+ private
+
+ def valid?
+ @package_name.present? && user.present? && project.present?
+ end
+
+ def user
+ strong_memoize(:user) do
+ User.find_by_id(@user_id)
+ end
+ end
+
+ def project
+ strong_memoize(:project) do
+ Project.find_by_id(@project_id)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/pages_update_configuration_worker.rb b/app/workers/pages_update_configuration_worker.rb
index 07238bae8c2..6e82e2099c7 100644
--- a/app/workers/pages_update_configuration_worker.rb
+++ b/app/workers/pages_update_configuration_worker.rb
@@ -6,6 +6,12 @@ class PagesUpdateConfigurationWorker
idempotent!
feature_category :pages
+ def self.perform_async(*args)
+ return unless Feature.enabled?(:pages_update_legacy_storage, default_enabled: true)
+
+ super(*args)
+ end
+
def perform(project_id)
project = Project.find_by_id(project_id)
return unless project
diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb
index f9940d9d014..7a016c85a64 100644
--- a/app/workers/personal_access_tokens/expiring_worker.rb
+++ b/app/workers/personal_access_tokens/expiring_worker.rb
@@ -7,17 +7,32 @@ module PersonalAccessTokens
feature_category :authentication_and_authorization
+ MAX_TOKENS = 100
+
def perform(*args)
notification_service = NotificationService.new
limit_date = PersonalAccessToken::DAYS_TO_EXPIRE.days.from_now.to_date
User.with_expiring_and_not_notified_personal_access_tokens(limit_date).find_each do |user|
with_context(user: user) do
- notification_service.access_token_about_to_expire(user)
+ expiring_user_tokens = user.personal_access_tokens.without_impersonation.expiring_and_not_notified(limit_date)
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ # We never materialise the token instances. We need the names to mention them in the
+ # email. Later we trigger an update query on the entire relation, not on individual instances.
+ token_names = expiring_user_tokens.limit(MAX_TOKENS).pluck(:name)
+ # We're limiting to 100 tokens so we avoid loading too many tokens into memory.
+ # At the time of writing this would only affect 69 users on GitLab.com
+
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ notification_service.access_token_about_to_expire(user, token_names)
Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expiring tokens"
- user.personal_access_tokens.without_impersonation.expiring_and_not_notified(limit_date).update_all(expire_notification_delivered: true)
+ expiring_user_tokens.each_batch do |expiring_tokens|
+ expiring_tokens.update_all(expire_notification_delivered: true)
+ end
end
end
end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index ac55f883fc5..313b901c08e 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -123,6 +123,7 @@ class PostReceive # rubocop:disable Scalability/IdempotentWorker
def after_project_changes_hooks(project, user, refs, changes)
experiment(:new_project_readme, actor: user).track_initial_writes(project)
+ experiment(:empty_repo_upload, project: project).track(:initial_write) if project.empty_repo?
repository_update_hook_data = Gitlab::DataBuilder::Repository.update(project, user, changes, refs)
SystemHooksService.new.execute_hooks(repository_update_hook_data, :repository_update_hooks)
Gitlab::UsageDataCounters::SourceCodeCounter.count(:pushes)
diff --git a/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb b/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb
index 4d2a6b47e3c..23d1594e4d9 100644
--- a/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb
+++ b/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb
@@ -1,13 +1,15 @@
# frozen_string_literal: true
-class ProjectScheduleBulkRepositoryShardMovesWorker
- include ApplicationWorker
-
+# This is a compatibility class to avoid calling a non-existent
+# class from sidekiq during deployment.
+#
+# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853.
+# we cannot remove this class entirely because there can be jobs
+# referencing it.
+#
+# We can get rid of this class in 14.0
+# https://gitlab.com/gitlab-org/gitlab/-/issues/322393
+class ProjectScheduleBulkRepositoryShardMovesWorker < Projects::ScheduleBulkRepositoryShardMovesWorker
idempotent!
- feature_category :gitaly
urgency :throttled
-
- def perform(source_storage_name, destination_storage_name = nil)
- Projects::ScheduleBulkRepositoryShardMovesService.new.execute(source_storage_name, destination_storage_name)
- end
end
diff --git a/app/workers/project_update_repository_storage_worker.rb b/app/workers/project_update_repository_storage_worker.rb
index 5636eec8233..0d68c0e16f8 100644
--- a/app/workers/project_update_repository_storage_worker.rb
+++ b/app/workers/project_update_repository_storage_worker.rb
@@ -1,23 +1,15 @@
# frozen_string_literal: true
-class ProjectUpdateRepositoryStorageWorker # rubocop:disable Scalability/IdempotentWorker
- extend ::Gitlab::Utils::Override
- include UpdateRepositoryStorageWorker
-
- private
-
- override :find_repository_storage_move
- def find_repository_storage_move(repository_storage_move_id)
- ProjectRepositoryStorageMove.find(repository_storage_move_id)
- end
-
- override :find_container
- def find_container(container_id)
- Project.find(container_id)
- end
-
- override :update_repository_storage
- def update_repository_storage(repository_storage_move)
- ::Projects::UpdateRepositoryStorageService.new(repository_storage_move).execute
- end
+# This is a compatibility class to avoid calling a non-existent
+# class from sidekiq during deployment.
+#
+# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853.
+# we cannot remove this class entirely because there can be jobs
+# referencing it.
+#
+# We can get rid of this class in 14.0
+# https://gitlab.com/gitlab-org/gitlab/-/issues/322393
+class ProjectUpdateRepositoryStorageWorker < Projects::UpdateRepositoryStorageWorker
+ idempotent!
+ urgency :throttled
end
diff --git a/app/workers/projects/schedule_bulk_repository_shard_moves_worker.rb b/app/workers/projects/schedule_bulk_repository_shard_moves_worker.rb
new file mode 100644
index 00000000000..3841ae9b922
--- /dev/null
+++ b/app/workers/projects/schedule_bulk_repository_shard_moves_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Projects
+ class ScheduleBulkRepositoryShardMovesWorker
+ include ApplicationWorker
+
+ idempotent!
+ feature_category :gitaly
+ urgency :throttled
+
+ def perform(source_storage_name, destination_storage_name = nil)
+ Projects::ScheduleBulkRepositoryShardMovesService.new.execute(source_storage_name, destination_storage_name)
+ end
+ end
+end
diff --git a/app/workers/projects/update_repository_storage_worker.rb b/app/workers/projects/update_repository_storage_worker.rb
new file mode 100644
index 00000000000..f4c44458446
--- /dev/null
+++ b/app/workers/projects/update_repository_storage_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Projects
+ class UpdateRepositoryStorageWorker # rubocop:disable Scalability/IdempotentWorker
+ extend ::Gitlab::Utils::Override
+ include ::UpdateRepositoryStorageWorker
+
+ private
+
+ override :find_repository_storage_move
+ def find_repository_storage_move(repository_storage_move_id)
+ ::Projects::RepositoryStorageMove.find(repository_storage_move_id)
+ end
+
+ override :find_container
+ def find_container(container_id)
+ Project.find(container_id)
+ end
+
+ override :update_repository_storage
+ def update_repository_storage(repository_storage_move)
+ ::Projects::UpdateRepositoryStorageService.new(repository_storage_move).execute
+ end
+ end
+end
diff --git a/app/workers/releases/create_evidence_worker.rb b/app/workers/releases/create_evidence_worker.rb
index db75fae1639..d22329216f9 100644
--- a/app/workers/releases/create_evidence_worker.rb
+++ b/app/workers/releases/create_evidence_worker.rb
@@ -3,7 +3,6 @@
module Releases
class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :release_evidence
diff --git a/app/workers/snippet_schedule_bulk_repository_shard_moves_worker.rb b/app/workers/snippet_schedule_bulk_repository_shard_moves_worker.rb
index 47f24ad3500..94a6b22538b 100644
--- a/app/workers/snippet_schedule_bulk_repository_shard_moves_worker.rb
+++ b/app/workers/snippet_schedule_bulk_repository_shard_moves_worker.rb
@@ -1,13 +1,16 @@
# frozen_string_literal: true
-class SnippetScheduleBulkRepositoryShardMovesWorker
- include ApplicationWorker
-
+# This is a compatibility class to avoid calling a non-existent
+# class from sidekiq during deployment.
+#
+# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853.
+# we cannot remove this class entirely because there can be jobs
+# referencing it.
+#
+# We can get rid of this class in 14.0
+# https://gitlab.com/gitlab-org/gitlab/-/issues/322393
+class SnippetScheduleBulkRepositoryShardMovesWorker < Snippets::ScheduleBulkRepositoryShardMovesWorker
idempotent!
feature_category :gitaly
urgency :throttled
-
- def perform(source_storage_name, destination_storage_name = nil)
- Snippets::ScheduleBulkRepositoryShardMovesService.new.execute(source_storage_name, destination_storage_name)
- end
end
diff --git a/app/workers/snippet_update_repository_storage_worker.rb b/app/workers/snippet_update_repository_storage_worker.rb
index a28a02a0298..befae6db4f4 100644
--- a/app/workers/snippet_update_repository_storage_worker.rb
+++ b/app/workers/snippet_update_repository_storage_worker.rb
@@ -1,23 +1,15 @@
# frozen_string_literal: true
-class SnippetUpdateRepositoryStorageWorker # rubocop:disable Scalability/IdempotentWorker
- extend ::Gitlab::Utils::Override
- include UpdateRepositoryStorageWorker
-
- private
-
- override :find_repository_storage_move
- def find_repository_storage_move(repository_storage_move_id)
- SnippetRepositoryStorageMove.find(repository_storage_move_id)
- end
-
- override :find_container
- def find_container(container_id)
- Snippet.find(container_id)
- end
-
- override :update_repository_storage
- def update_repository_storage(repository_storage_move)
- ::Snippets::UpdateRepositoryStorageService.new(repository_storage_move).execute
- end
+# This is a compatibility class to avoid calling a non-existent
+# class from sidekiq during deployment.
+#
+# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853.
+# we cannot remove this class entirely because there can be jobs
+# referencing it.
+#
+# We can get rid of this class in 14.0
+# https://gitlab.com/gitlab-org/gitlab/-/issues/322393
+class SnippetUpdateRepositoryStorageWorker < Snippets::UpdateRepositoryStorageWorker # rubocop:disable Scalability/IdempotentWorker
+ idempotent!
+ urgency :throttled
end
diff --git a/app/workers/snippets/schedule_bulk_repository_shard_moves_worker.rb b/app/workers/snippets/schedule_bulk_repository_shard_moves_worker.rb
new file mode 100644
index 00000000000..ec3d9dbdf97
--- /dev/null
+++ b/app/workers/snippets/schedule_bulk_repository_shard_moves_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Snippets
+ class ScheduleBulkRepositoryShardMovesWorker
+ include ApplicationWorker
+
+ idempotent!
+ feature_category :gitaly
+ urgency :throttled
+
+ def perform(source_storage_name, destination_storage_name = nil)
+ Snippets::ScheduleBulkRepositoryShardMovesService.new.execute(source_storage_name, destination_storage_name)
+ end
+ end
+end
diff --git a/app/workers/snippets/update_repository_storage_worker.rb b/app/workers/snippets/update_repository_storage_worker.rb
new file mode 100644
index 00000000000..83b655e9986
--- /dev/null
+++ b/app/workers/snippets/update_repository_storage_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Snippets
+ class UpdateRepositoryStorageWorker # rubocop:disable Scalability/IdempotentWorker
+ extend ::Gitlab::Utils::Override
+ include ::UpdateRepositoryStorageWorker
+
+ private
+
+ override :find_repository_storage_move
+ def find_repository_storage_move(repository_storage_move_id)
+ Snippets::RepositoryStorageMove.find(repository_storage_move_id)
+ end
+
+ override :find_container
+ def find_container(container_id)
+ Snippet.find(container_id)
+ end
+
+ override :update_repository_storage
+ def update_repository_storage(repository_storage_move)
+ ::Snippets::UpdateRepositoryStorageService.new(repository_storage_move).execute
+ end
+ end
+end
diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb
index eca0a248a37..bd721df73c6 100644
--- a/app/workers/stuck_ci_jobs_worker.rb
+++ b/app/workers/stuck_ci_jobs_worker.rb
@@ -70,7 +70,7 @@ class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker
def drop_build(type, build, status, timeout, reason)
Gitlab::AppLogger.info "#{self.class}: Dropping #{type} build #{build.id} for runner #{build.runner_id} (status: #{status}, timeout: #{timeout}, reason: #{reason})"
- Gitlab::OptimisticLocking.retry_lock(build, 3) do |b|
+ Gitlab::OptimisticLocking.retry_lock(build, 3, name: 'stuck_ci_jobs_worker_drop_build') do |b|
b.drop(reason)
end
rescue => ex