Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/auth_buttons/atlassian_64.pngbin1512 -> 1364 bytes
-rw-r--r--app/assets/images/auth_buttons/auth0_64.pngbin1815 -> 2873 bytes
-rw-r--r--app/assets/images/auth_buttons/bitbucket_64.pngbin1299 -> 1490 bytes
-rw-r--r--app/assets/images/auth_buttons/facebook_64.pngbin870 -> 1033 bytes
-rw-r--r--app/assets/images/auth_buttons/twitter_64.pngbin3110 -> 3695 bytes
-rw-r--r--app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql1
-rw-r--r--app/assets/javascripts/actioncable_link.js2
-rw-r--r--app/assets/javascripts/activities.js4
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue7
-rw-r--r--app/assets/javascripts/admin/application_settings/setup_service_usage_data.js15
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue15
-rw-r--r--app/assets/javascripts/alert_management/list.js2
-rw-r--r--app/assets/javascripts/alerts_settings/graphql.js12
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql1
-rw-r--r--app/assets/javascripts/analytics/shared/components/daterange.vue33
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_popover.vue (renamed from app/assets/javascripts/cycle_analytics/components/metric_popover.vue)0
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_tile.vue51
-rw-r--r--app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue (renamed from app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue)53
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js45
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js27
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql1
-rw-r--r--app/assets/javascripts/authentication/webauthn/util.js49
-rw-r--r--app/assets/javascripts/awards_handler.js8
-rw-r--r--app/assets/javascripts/badges/components/badge.vue9
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue12
-rw-r--r--app/assets/javascripts/badges/components/badge_list.vue10
-rw-r--r--app/assets/javascripts/badges/components/badge_list_row.vue5
-rw-r--r--app/assets/javascripts/batch_comments/components/diff_file_drafts.vue11
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue18
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/bold.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/code.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/italic.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/link.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/math.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/blockquote.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/heading.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/image.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/list_item.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/paragraph.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/playable.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/text.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/serializer.js2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js19
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue15
-rw-r--r--app/assets/javascripts/blob/components/blob_header_default_actions.vue35
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue18
-rw-r--r--app/assets/javascripts/blob/components/constants.js2
-rw-r--r--app/assets/javascripts/blob/pipeline_tour_success_modal.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue18
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue32
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue48
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_new_item.vue13
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue43
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue54
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue1
-rw-r--r--app/assets/javascripts/boards/config_toggle.js1
-rw-r--r--app/assets/javascripts/boards/graphql.js9
-rw-r--r--app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql7
-rw-r--r--app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql14
-rw-r--r--app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql7
-rw-r--r--app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql14
-rw-r--r--app/assets/javascripts/boards/index.js3
-rw-r--r--app/assets/javascripts/boards/mount_filtered_search_issue_boards.js1
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js17
-rw-r--r--app/assets/javascripts/boards/stores/actions.js2
-rw-r--r--app/assets/javascripts/boards/toggle_focus.js1
-rw-r--r--app/assets/javascripts/broadcast_notification.js4
-rw-r--r--app/assets/javascripts/captcha/apollo_captcha_link.js2
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue6
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue45
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue3
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js1
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/provider.js17
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql9
-rw-r--r--app/assets/javascripts/clusters/agents/index.js11
-rw-r--r--app/assets/javascripts/clusters/agents/router.js22
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue152
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue55
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue2
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_actions.vue34
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_view_all.vue60
-rw-r--r--app/assets/javascripts/clusters_list/components/delete_agent_button.vue (renamed from app/assets/javascripts/clusters_list/components/agent_options.vue)62
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue52
-rw-r--r--app/assets/javascripts/clusters_list/constants.js57
-rw-r--r--app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql7
-rw-r--r--app/assets/javascripts/clusters_list/load_main_view.js7
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js4
-rw-r--r--app/assets/javascripts/contextual_sidebar.js7
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue8
-rw-r--r--app/assets/javascripts/cycle_analytics/components/metric_tile.vue51
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_table.vue2
-rw-r--r--app/assets/javascripts/cycle_analytics/constants.js25
-rw-r--r--app/assets/javascripts/cycle_analytics/utils.js30
-rw-r--r--app/assets/javascripts/deprecated_notes.js8
-rw-r--r--app/assets/javascripts/design_management/components/design_overlay.vue1
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue8
-rw-r--r--app/assets/javascripts/design_management/graphql.js8
-rw-r--r--app/assets/javascripts/design_management/graphql/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/design_management/index.js1
-rw-r--r--app/assets/javascripts/diffs/components/app.vue87
-rw-r--r--app/assets/javascripts/diffs/components/collapsed_files_warning.vue14
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue2
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue17
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue31
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue26
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue45
-rw-r--r--app/assets/javascripts/diffs/components/image_diff_overlay.vue44
-rw-r--r--app/assets/javascripts/diffs/components/merge_conflict_warning.vue14
-rw-r--r--app/assets/javascripts/diffs/constants.js6
-rw-r--r--app/assets/javascripts/diffs/index.js16
-rw-r--r--app/assets/javascripts/diffs/store/actions.js29
-rw-r--r--app/assets/javascripts/diffs/store/getters.js17
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js4
-rw-r--r--app/assets/javascripts/diffs/store/utils.js18
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js94
-rw-r--r--app/assets/javascripts/editor/schema/ci.json3
-rw-r--r--app/assets/javascripts/emoji/awards_app/index.js1
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/actions.js41
-rw-r--r--app/assets/javascripts/emoji/components/utils.js8
-rw-r--r--app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js3
-rw-r--r--app/assets/javascripts/environments/components/canary_ingress.vue15
-rw-r--r--app/assets/javascripts/environments/components/canary_update_modal.vue2
-rw-r--r--app/assets/javascripts/environments/components/commit.vue54
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue68
-rw-r--r--app/assets/javascripts/environments/components/deploy_board_wrapper.vue86
-rw-r--r--app/assets/javascripts/environments/components/deployment.vue217
-rw-r--r--app/assets/javascripts/environments/components/empty_state.vue4
-rw-r--r--app/assets/javascripts/environments/components/environment_pin.vue15
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue77
-rw-r--r--app/assets/javascripts/environments/components/new_environments_app.vue12
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql4
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/set_environment_to_change_canary.mutation.graphql3
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_to_change_canary.query.graphql4
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js11
-rw-r--r--app/assets/javascripts/environments/graphql/typedefs.graphql3
-rw-r--r--app/assets/javascripts/files_comment_button.js4
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js15
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js5
-rw-r--r--app/assets/javascripts/filtered_search/constants.js2
-rw-r--r--app/assets/javascripts/flash.js12
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js74
-rw-r--r--app/assets/javascripts/google_cloud/components/deployments_service_table.vue25
-rw-r--r--app/assets/javascripts/google_cloud/components/home.vue8
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts_form.vue12
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts_list.vue48
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js111
-rw-r--r--app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js17
-rw-r--r--app/assets/javascripts/graphql_shared/possibleTypes.json1
-rw-r--r--app/assets/javascripts/groups/components/app.vue10
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue19
-rw-r--r--app/assets/javascripts/groups/components/invite_members_banner.vue1
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue81
-rw-r--r--app/assets/javascripts/groups/components/transfer_group_form.vue80
-rw-r--r--app/assets/javascripts/groups/constants.js6
-rw-r--r--app/assets/javascripts/groups/init_transfer_group_form.js52
-rw-r--r--app/assets/javascripts/groups/landing.js7
-rw-r--r--app/assets/javascripts/groups/store/groups_store.js1
-rw-r--r--app/assets/javascripts/groups/transfer_dropdown.js39
-rw-r--r--app/assets/javascripts/groups/transfer_edit.js11
-rw-r--r--app/assets/javascripts/groups_select.js2
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue11
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue2
-rw-r--r--app/assets/javascripts/ide/components/preview/clientside.vue18
-rw-r--r--app/assets/javascripts/ide/constants.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/clientside/actions.js4
-rw-r--r--app/assets/javascripts/image_diff/helpers/badge_helper.js14
-rw-r--r--app/assets/javascripts/image_diff/helpers/dom_helper.js4
-rw-r--r--app/assets/javascripts/image_diff/image_diff.js2
-rw-r--r--app/assets/javascripts/image_diff/replaced_image_diff.js2
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue15
-rw-r--r--app/assets/javascripts/integrations/constants.js4
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue19
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue83
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue21
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue19
-rw-r--r--app/assets/javascripts/integrations/edit/event_hub.js3
-rw-r--r--app/assets/javascripts/integrations/edit/store/actions.js3
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue47
-rw-r--r--app/assets/javascripts/invite_members/components/invite_group_trigger.vue2
-rw-r--r--app/assets/javascripts/invite_members/components/invite_groups_modal.vue146
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue419
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue2
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue276
-rw-r--r--app/assets/javascripts/invite_members/constants.js91
-rw-r--r--app/assets/javascripts/invite_members/init_invite_groups_modal.js44
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js3
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_trigger.js1
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/index.js1
-rw-r--r--app/assets/javascripts/issuable/index.js3
-rw-r--r--app/assets/javascripts/issuable/issuable_context.js4
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js1
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js35
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue182
-rw-r--r--app/assets/javascripts/issues/list/components/new_issue_dropdown.vue2
-rw-r--r--app/assets/javascripts/issues/list/constants.js17
-rw-r--r--app/assets/javascripts/issues/list/index.js4
-rw-r--r--app/assets/javascripts/issues/list/queries/issue.fragment.graphql3
-rw-r--r--app/assets/javascripts/issues/list/queries/search_milestones.query.graphql15
-rw-r--r--app/assets/javascripts/issues/list/queries/set_sort_preference.mutation.graphql5
-rw-r--r--app/assets/javascripts/issues/list/utils.js17
-rw-r--r--app/assets/javascripts/issues/manual_ordering.js5
-rw-r--r--app/assets/javascripts/issues/new/index.js2
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/index.js1
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue126
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue5
-rw-r--r--app/assets/javascripts/issues/show/index.js7
-rw-r--r--app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue99
-rw-r--r--app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue33
-rw-r--r--app/assets/javascripts/jira_connect/branches/constants.js3
-rw-r--r--app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql3
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue34
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue58
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue63
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue40
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue43
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue20
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/duration_cell.vue1
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue4
-rw-r--r--app/assets/javascripts/labels/index.js2
-rw-r--r--app/assets/javascripts/lib/apollo/instrumentation_link.js2
-rw-r--r--app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js4
-rw-r--r--app/assets/javascripts/lib/graphql.js99
-rw-r--r--app/assets/javascripts/lib/prosemirror_markdown_serializer.js3
-rw-r--r--app/assets/javascripts/lib/utils/apollo_startup_js_link.js2
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js5
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue34
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js8
-rw-r--r--app/assets/javascripts/lib/utils/constants.js1
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js23
-rw-r--r--app/assets/javascripts/lib/utils/table_utility.js35
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js65
-rw-r--r--app/assets/javascripts/lib/utils/yaml.js121
-rw-r--r--app/assets/javascripts/listbox/index.js67
-rw-r--r--app/assets/javascripts/listbox/redirect_behavior.js22
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue38
-rw-r--r--app/assets/javascripts/main.js2
-rw-r--r--app/assets/javascripts/members/components/avatars/user_avatar.vue14
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue1
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue14
-rw-r--r--app/assets/javascripts/members/constants.js1
-rw-r--r--app/assets/javascripts/merge_conflicts/store/actions.js4
-rw-r--r--app/assets/javascripts/merge_conflicts/store/state.js4
-rw-r--r--app/assets/javascripts/merge_request_tabs.js17
-rw-r--r--app/assets/javascripts/milestones/index.js2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue51
-rw-r--r--app/assets/javascripts/nav/components/top_nav_menu_item.vue2
-rw-r--r--app/assets/javascripts/nav/mount.js1
-rw-r--r--app/assets/javascripts/network/raphael.js11
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue2
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue5
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue8
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue26
-rw-r--r--app/assets/javascripts/notes/discussion_filters.js2
-rw-r--r--app/assets/javascripts/notes/index.js1
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js12
-rw-r--r--app/assets/javascripts/notes/sort_discussions.js1
-rw-r--r--app/assets/javascripts/notifications/components/notifications_dropdown.vue4
-rw-r--r--app/assets/javascripts/notifications/index.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue14
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue14
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue12
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue43
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json17
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js15
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue (renamed from app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue)17
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/tags_loader.vue (renamed from app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/index.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js (renamed from app/assets/javascripts/packages_and_registries/shared/constants.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/quick_start.js9
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue6
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_downloader.js52
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_previewer.js21
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/runners/show/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js7
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/imports/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue16
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue12
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/index/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue8
-rw-r--r--app/assets/javascripts/pages/projects/planning_hierarchy/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/project.js8
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/security/configuration/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/serverless/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue2
-rw-r--r--app/assets/javascripts/pages/users/index.js4
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue12
-rw-r--r--app/assets/javascripts/performance_bar/index.js1
-rw-r--r--app/assets/javascripts/persistent_user_callout.js4
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue36
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue6
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue70
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue55
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue75
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue2
-rw-r--r--app/assets/javascripts/pipeline_new/constants.js7
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/commit.vue224
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/editor.vue94
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/step_nav.vue54
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/widgets/text.vue126
-rw-r--r--app/assets/javascripts/pipeline_wizard/queries/create_commit.graphql9
-rw-r--r--app/assets/javascripts/pipeline_wizard/queries/get_file_meta.graphql12
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue30
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue98
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue22
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/jobs_app.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue102
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue18
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue167
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue25
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue6
-rw-r--r--app/assets/javascripts/pipelines/constants.js9
-rw-r--r--app/assets/javascripts/pipelines/graphql/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql12
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js18
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js10
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_notification.js31
-rw-r--r--app/assets/javascripts/pipelines/pipeline_shared_client.js9
-rw-r--r--app/assets/javascripts/popovers/index.js2
-rw-r--r--app/assets/javascripts/projects/components/project_delete_button.vue71
-rw-r--r--app/assets/javascripts/projects/components/shared/delete_button.vue84
-rw-r--r--app/assets/javascripts/projects/new/components/deployment_target_select.vue61
-rw-r--r--app/assets/javascripts/projects/new/constants.js20
-rw-r--r--app/assets/javascripts/projects/new/index.js14
-rw-r--r--app/assets/javascripts/projects/project_new.js93
-rw-r--r--app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue23
-rw-r--r--app/assets/javascripts/projects/settings/components/transfer_project_form.vue12
-rw-r--r--app/assets/javascripts/projects/settings/constants.js7
-rw-r--r--app/assets/javascripts/projects/settings/init_transfer_project_form.js10
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue12
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue19
-rw-r--r--app/assets/javascripts/related_issues/constants.js25
-rw-r--r--app/assets/javascripts/related_issues/index.js1
-rw-r--r--app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue9
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue95
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue20
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue14
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js61
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue38
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue14
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue9
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue4
-rw-r--r--app/assets/javascripts/repository/components/table/parent_row.vue13
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue1
-rw-r--r--app/assets/javascripts/repository/constants.js51
-rw-r--r--app/assets/javascripts/repository/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/repository/graphql.js9
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql13
-rw-r--r--app/assets/javascripts/right_sidebar.js4
-rw-r--r--app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue77
-rw-r--r--app/assets/javascripts/runner/admin_runner_show/index.js32
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue11
-rw-r--r--app/assets/javascripts/runner/components/cells/link_cell.vue27
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_actions_cell.vue73
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue19
-rw-r--r--app/assets/javascripts/runner/components/runner_assigned_item.vue39
-rw-r--r--app/assets/javascripts/runner/components/runner_detail.vue50
-rw-r--r--app/assets/javascripts/runner/components/runner_details.vue124
-rw-r--r--app/assets/javascripts/runner/components/runner_edit_button.vue26
-rw-r--r--app/assets/javascripts/runner/components/runner_groups.vue37
-rw-r--r--app/assets/javascripts/runner/components/runner_header.vue52
-rw-r--r--app/assets/javascripts/runner/components/runner_jobs.vue82
-rw-r--r--app/assets/javascripts/runner/components/runner_jobs_table.vue95
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue30
-rw-r--r--app/assets/javascripts/runner/components/runner_pagination.vue12
-rw-r--r--app/assets/javascripts/runner/components/runner_pause_button.vue122
-rw-r--r--app/assets/javascripts/runner/components/runner_projects.vue111
-rw-r--r--app/assets/javascripts/runner/components/runner_tags.vue4
-rw-r--r--app/assets/javascripts/runner/components/runner_type_tabs.vue59
-rw-r--r--app/assets/javascripts/runner/constants.js23
-rw-r--r--app/assets/javascripts/runner/graphql/get_group_runners.query.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/get_runner.query.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql36
-rw-r--r--app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql26
-rw-r--r--app/assets/javascripts/runner/graphql/get_runners.query.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql14
-rw-r--r--app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql20
-rw-r--r--app/assets/javascripts/runner/graphql/runner_node.fragment.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql12
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue81
-rw-r--r--app/assets/javascripts/runner/runner_search_utils.js26
-rw-r--r--app/assets/javascripts/runner/utils.js72
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue6
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js34
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue23
-rw-r--r--app/assets/javascripts/security_configuration/components/training_provider_list.vue106
-rw-r--r--app/assets/javascripts/security_configuration/components/upgrade_banner.vue18
-rw-r--r--app/assets/javascripts/security_configuration/constants.js2
-rw-r--r--app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql7
-rw-r--r--app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql16
-rw-r--r--app/assets/javascripts/security_configuration/index.js7
-rw-r--r--app/assets/javascripts/security_configuration/resolver.js56
-rw-r--r--app/assets/javascripts/security_configuration/utils.js13
-rw-r--r--app/assets/javascripts/serverless/components/empty_state.vue25
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue24
-rw-r--r--app/assets/javascripts/serverless/constants.js3
-rw-r--r--app/assets/javascripts/serverless/survey_banner.js36
-rw-r--r--app/assets/javascripts/serverless/survey_banner.vue52
-rw-r--r--app/assets/javascripts/settings_panels.js23
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue36
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue37
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue15
-rw-r--r--app/assets/javascripts/sidebar/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/sidebar/graphql.js15
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js1
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js16
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js2
-rw-r--r--app/assets/javascripts/tabs/constants.js6
-rw-r--r--app/assets/javascripts/terraform/components/states_table.vue17
-rw-r--r--app/assets/javascripts/terraform/index.js2
-rw-r--r--app/assets/javascripts/toggles/index.js65
-rw-r--r--app/assets/javascripts/tooltips/index.js1
-rw-r--r--app/assets/javascripts/user_callout.js6
-rw-r--r--app/assets/javascripts/vue_alerts.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue31
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue66
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue60
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue45
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue99
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js120
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/i18n.js17
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue68
-rw-r--r--app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js14
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue106
-rw-r--r--app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js195
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue255
-rw-r--r--app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue104
-rw-r--r--app/assets/javascripts/vue_shared/components/pikaday.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue148
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js111
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue (renamed from app/assets/javascripts/vue_shared/components/source_viewer.vue)51
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/utils.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/svg_gradient.vue34
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue27
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue16
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue13
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue8
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue8
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue2
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue4
-rw-r--r--app/assets/javascripts/work_items/graphql/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/work_items/graphql/project_work_item_types.query.graphql11
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js9
-rw-r--r--app/assets/javascripts/work_items/index.js4
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue116
-rw-r--r--app/assets/javascripts/work_items_hierarchy/components/app.vue101
-rw-r--r--app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue119
-rw-r--r--app/assets/javascripts/work_items_hierarchy/constants.js62
-rw-r--r--app/assets/javascripts/work_items_hierarchy/hierarchy_util.js10
-rw-r--r--app/assets/javascripts/work_items_hierarchy/static_response.js142
-rw-r--r--app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js26
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss1
-rw-r--r--app/assets/stylesheets/components/design_management/design.scss108
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/blank.scss118
-rw-r--r--app/assets/stylesheets/framework/buttons.scss33
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss5
-rw-r--r--app/assets/stylesheets/framework/diffs.scss67
-rw-r--r--app/assets/stylesheets/framework/files.scss5
-rw-r--r--app/assets/stylesheets/framework/forms.scss4
-rw-r--r--app/assets/stylesheets/framework/header.scss45
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss21
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss5
-rw-r--r--app/assets/stylesheets/framework/typography.scss22
-rw-r--r--app/assets/stylesheets/framework/variables.scss11
-rw-r--r--app/assets/stylesheets/highlight/common.scss22
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss6
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss6
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss7
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss6
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss6
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss17
-rw-r--r--app/assets/stylesheets/mailers/highlighted_diff_email.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/dashboard_projects.scss35
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss641
-rw-r--r--app/assets/stylesheets/page_bundles/milestone.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/project.scss133
-rw-r--r--app/assets/stylesheets/page_bundles/projects_edit.scss25
-rw-r--r--app/assets/stylesheets/pages/clusters.scss14
-rw-r--r--app/assets/stylesheets/pages/groups.scss13
-rw-r--r--app/assets/stylesheets/pages/hierarchy.scss15
-rw-r--r--app/assets/stylesheets/pages/issuable.scss73
-rw-r--r--app/assets/stylesheets/pages/issues.scss31
-rw-r--r--app/assets/stylesheets/pages/login.scss8
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss637
-rw-r--r--app/assets/stylesheets/pages/pages.scss5
-rw-r--r--app/assets/stylesheets/pages/projects.scss162
-rw-r--r--app/assets/stylesheets/pages/settings.scss4
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss111
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss95
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss23
-rw-r--r--app/assets/stylesheets/themes/_dark.scss16
-rw-r--r--app/assets/stylesheets/utilities.scss96
-rw-r--r--app/assets/stylesheets/vendors/tribute.scss41
-rw-r--r--app/controllers/abuse_reports_controller.rb4
-rw-r--r--app/controllers/admin/application_settings_controller.rb9
-rw-r--r--app/controllers/admin/instance_review_controller.rb2
-rw-r--r--app/controllers/admin/runners_controller.rb18
-rw-r--r--app/controllers/admin/users_controller.rb4
-rw-r--r--app/controllers/clusters/base_controller.rb6
-rw-r--r--app/controllers/clusters/clusters_controller.rb2
-rw-r--r--app/controllers/concerns/bizible_csp.rb15
-rw-r--r--app/controllers/concerns/integrations/actions.rb3
-rw-r--r--app/controllers/concerns/integrations/params.rb1
-rw-r--r--app/controllers/concerns/issuable_actions.rb5
-rw-r--r--app/controllers/concerns/multiple_boards_actions.rb2
-rw-r--r--app/controllers/concerns/planning_hierarchy.rb15
-rw-r--r--app/controllers/concerns/uploads_actions.rb8
-rw-r--r--app/controllers/dashboard/projects_controller.rb8
-rw-r--r--app/controllers/dashboard_controller.rb4
-rw-r--r--app/controllers/explore/projects_controller.rb7
-rw-r--r--app/controllers/graphql_controller.rb6
-rw-r--r--app/controllers/groups/boards_controller.rb4
-rw-r--r--app/controllers/groups/dependency_proxy_for_containers_controller.rb2
-rw-r--r--app/controllers/groups/runners_controller.rb16
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb5
-rw-r--r--app/controllers/groups/uploads_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb22
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb2
-rw-r--r--app/controllers/metrics_controller.rb1
-rw-r--r--app/controllers/oauth/authorizations_controller.rb51
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb11
-rw-r--r--app/controllers/projects/badges_controller.rb12
-rw-r--r--app/controllers/projects/boards_controller.rb4
-rw-r--r--app/controllers/projects/branches_controller.rb19
-rw-r--r--app/controllers/projects/cluster_agents_controller.rb2
-rw-r--r--app/controllers/projects/clusters_controller.rb1
-rw-r--r--app/controllers/projects/commit_controller.rb2
-rw-r--r--app/controllers/projects/commits_controller.rb12
-rw-r--r--app/controllers/projects/compare_controller.rb21
-rw-r--r--app/controllers/projects/design_management/designs_controller.rb1
-rw-r--r--app/controllers/projects/forks_controller.rb10
-rw-r--r--app/controllers/projects/google_cloud/deployments_controller.rb55
-rw-r--r--app/controllers/projects/google_cloud_controller.rb2
-rw-r--r--app/controllers/projects/group_links_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb19
-rw-r--r--app/controllers/projects/merge_requests/drafts_controller.rb3
-rw-r--r--app/controllers/projects/merge_requests_controller.rb38
-rw-r--r--app/controllers/projects/packages/infrastructure_registry_controller.rb6
-rw-r--r--app/controllers/projects/pipelines_controller.rb3
-rw-r--r--app/controllers/projects/project_members_controller.rb3
-rw-r--r--app/controllers/projects/refs_controller.rb10
-rw-r--r--app/controllers/projects/repositories_controller.rb2
-rw-r--r--app/controllers/projects/runners_controller.rb2
-rw-r--r--app/controllers/projects/security/configuration_controller.rb2
-rw-r--r--app/controllers/projects/service_desk_controller.rb1
-rw-r--r--app/controllers/projects/service_ping_controller.rb8
-rw-r--r--app/controllers/projects/services_controller.rb3
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb2
-rw-r--r--app/controllers/projects/settings/repository_controller.rb2
-rw-r--r--app/controllers/projects/tags_controller.rb12
-rw-r--r--app/controllers/projects/uploads_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb23
-rw-r--r--app/controllers/registrations_controller.rb10
-rw-r--r--app/controllers/repositories/git_http_client_controller.rb9
-rw-r--r--app/controllers/repositories/lfs_api_controller.rb1
-rw-r--r--app/controllers/search_controller.rb1
-rw-r--r--app/controllers/sessions_controller.rb1
-rw-r--r--app/controllers/users_controller.rb17
-rw-r--r--app/events/ci/job_artifacts_deleted_event.rb20
-rw-r--r--app/events/members/members_added_event.rb16
-rw-r--r--app/events/projects/project_deleted_event.rb16
-rw-r--r--app/experiments/application_experiment.rb26
-rw-r--r--app/experiments/combined_registration_experiment.rb2
-rw-r--r--app/experiments/empty_repo_upload_experiment.rb2
-rw-r--r--app/experiments/force_company_trial_experiment.rb2
-rw-r--r--app/experiments/in_product_guidance_environments_webide_experiment.rb2
-rw-r--r--app/experiments/new_project_readme_content_experiment.rb30
-rw-r--r--app/experiments/new_project_sast_enabled_experiment.rb2
-rw-r--r--app/experiments/require_verification_for_namespace_creation_experiment.rb12
-rw-r--r--app/experiments/security_reports_mr_widget_prompt_experiment.rb2
-rw-r--r--app/experiments/templates/new_project_readme_content/readme_basic.md.tt3
-rw-r--r--app/finders/autocomplete/users_finder.rb8
-rw-r--r--app/finders/ci/jobs_finder.rb14
-rw-r--r--app/finders/ci/runners_finder.rb10
-rw-r--r--app/finders/crm/contacts_finder.rb39
-rw-r--r--app/finders/deployments_finder.rb12
-rw-r--r--app/finders/environments/environments_by_deployments_finder.rb10
-rw-r--r--app/finders/git_refs_finder.rb4
-rw-r--r--app/finders/group_descendants_finder.rb11
-rw-r--r--app/finders/group_projects_finder.rb18
-rw-r--r--app/finders/issues_finder.rb6
-rw-r--r--app/finders/issues_finder/params.rb12
-rw-r--r--app/finders/merge_requests_finder.rb10
-rw-r--r--app/finders/merge_requests_finder/params.rb6
-rw-r--r--app/finders/packages/package_file_finder.rb8
-rw-r--r--app/finders/projects_finder.rb6
-rw-r--r--app/finders/releases_finder.rb14
-rw-r--r--app/finders/users_finder.rb6
-rw-r--r--app/graphql/graphql_triggers.rb4
-rw-r--r--app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb2
-rw-r--r--app/graphql/mutations/alert_management/http_integration/create.rb4
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/create.rb4
-rw-r--r--app/graphql/mutations/boards/create.rb3
-rw-r--r--app/graphql/mutations/branches/create.rb4
-rw-r--r--app/graphql/mutations/ci/ci_cd_settings_update.rb4
-rw-r--r--app/graphql/mutations/ci/job_token_scope/add_project.rb4
-rw-r--r--app/graphql/mutations/ci/job_token_scope/remove_project.rb4
-rw-r--r--app/graphql/mutations/ci/runner/delete.rb2
-rw-r--r--app/graphql/mutations/ci/runner/update.rb7
-rw-r--r--app/graphql/mutations/clusters/agents/create.rb4
-rw-r--r--app/graphql/mutations/commits/create.rb4
-rw-r--r--app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb51
-rw-r--r--app/graphql/mutations/container_expiration_policies/update.rb4
-rw-r--r--app/graphql/mutations/container_repositories/destroy_tags.rb5
-rw-r--r--app/graphql/mutations/custom_emoji/create.rb4
-rw-r--r--app/graphql/mutations/customer_relations/contacts/create.rb4
-rw-r--r--app/graphql/mutations/customer_relations/contacts/update.rb4
-rw-r--r--app/graphql/mutations/customer_relations/organizations/create.rb4
-rw-r--r--app/graphql/mutations/customer_relations/organizations/update.rb4
-rw-r--r--app/graphql/mutations/dependency_proxy/group_settings/update.rb4
-rw-r--r--app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb4
-rw-r--r--app/graphql/mutations/design_management/delete.rb4
-rw-r--r--app/graphql/mutations/groups/update.rb4
-rw-r--r--app/graphql/mutations/issues/create.rb19
-rw-r--r--app/graphql/mutations/issues/set_confidential.rb4
-rw-r--r--app/graphql/mutations/issues/set_escalation_status.rb6
-rw-r--r--app/graphql/mutations/jira_import/import_users.rb4
-rw-r--r--app/graphql/mutations/jira_import/start.rb4
-rw-r--r--app/graphql/mutations/labels/create.rb4
-rw-r--r--app/graphql/mutations/merge_requests/accept.rb12
-rw-r--r--app/graphql/mutations/merge_requests/create.rb4
-rw-r--r--app/graphql/mutations/namespace/package_settings/update.rb4
-rw-r--r--app/graphql/mutations/release_asset_links/create.rb7
-rw-r--r--app/graphql/mutations/snippets/create.rb5
-rw-r--r--app/graphql/mutations/snippets/update.rb5
-rw-r--r--app/graphql/mutations/user_preferences/update.rb28
-rw-r--r--app/graphql/mutations/work_items/create.rb18
-rw-r--r--app/graphql/mutations/work_items/delete.rb47
-rw-r--r--app/graphql/mutations/work_items/update.rb61
-rw-r--r--app/graphql/queries/pipelines/get_pipeline_details.query.graphql1
-rw-r--r--app/graphql/resolvers/ci/project_pipeline_counts_resolver.rb28
-rw-r--r--app/graphql/resolvers/ci/runner_jobs_resolver.rb45
-rw-r--r--app/graphql/resolvers/ci/runners_resolver.rb12
-rw-r--r--app/graphql/resolvers/clusters/agent_tokens_resolver.rb2
-rw-r--r--app/graphql/resolvers/kas/agent_configurations_resolver.rb2
-rw-r--r--app/graphql/resolvers/kas/agent_connections_resolver.rb2
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb19
-rw-r--r--app/graphql/resolvers/paginated_tree_resolver.rb8
-rw-r--r--app/graphql/resolvers/project_jobs_resolver.rb3
-rw-r--r--app/graphql/resolvers/recent_boards_resolver.rb17
-rw-r--r--app/graphql/resolvers/tree_resolver.rb8
-rw-r--r--app/graphql/resolvers/users/groups_resolver.rb6
-rw-r--r--app/graphql/types/admin/analytics/usage_trends/measurement_type.rb3
-rw-r--r--app/graphql/types/alert_management/prometheus_integration_type.rb4
-rw-r--r--app/graphql/types/board_list_type.rb4
-rw-r--r--app/graphql/types/ci/pipeline_counts_type.rb24
-rw-r--r--app/graphql/types/ci/runner_sort_enum.rb2
-rw-r--r--app/graphql/types/ci/runner_status_enum.rb12
-rw-r--r--app/graphql/types/ci/runner_type.rb50
-rw-r--r--app/graphql/types/clusters/agent_activity_event_type.rb2
-rw-r--r--app/graphql/types/clusters/agent_token_type.rb2
-rw-r--r--app/graphql/types/clusters/agent_type.rb2
-rw-r--r--app/graphql/types/commit_type.rb14
-rw-r--r--app/graphql/types/group_invitation_type.rb6
-rw-r--r--app/graphql/types/group_member_type.rb6
-rw-r--r--app/graphql/types/group_type.rb24
-rw-r--r--app/graphql/types/issuable_type.rb4
-rw-r--r--app/graphql/types/issue_type.rb11
-rw-r--r--app/graphql/types/label_type.rb3
-rw-r--r--app/graphql/types/member_interface.rb12
-rw-r--r--app/graphql/types/merge_request_sort_enum.rb2
-rw-r--r--app/graphql/types/merge_requests/assignee_type.rb5
-rw-r--r--app/graphql/types/merge_requests/interacts_with_merge_request.rb5
-rw-r--r--app/graphql/types/merge_requests/reviewer_type.rb5
-rw-r--r--app/graphql/types/metrics/dashboards/annotation_type.rb2
-rw-r--r--app/graphql/types/mutation_type.rb9
-rw-r--r--app/graphql/types/notes/discussion_type.rb4
-rw-r--r--app/graphql/types/packages/package_details_type.rb15
-rw-r--r--app/graphql/types/permission_types/issue.rb2
-rw-r--r--app/graphql/types/permission_types/merge_request.rb9
-rw-r--r--app/graphql/types/project_type.rb14
-rw-r--r--app/graphql/types/query_complexity_type.rb4
-rw-r--r--app/graphql/types/repository/blob_type.rb18
-rw-r--r--app/graphql/types/root_storage_statistics_type.rb1
-rw-r--r--app/graphql/types/subscription_type.rb3
-rw-r--r--app/graphql/types/terraform/state_version_type.rb4
-rw-r--r--app/graphql/types/tree/blob_type.rb5
-rw-r--r--app/graphql/types/tree/submodule_type.rb4
-rw-r--r--app/graphql/types/tree/tree_entry_type.rb7
-rw-r--r--app/graphql/types/user_interface.rb4
-rw-r--r--app/graphql/types/user_preferences_type.rb17
-rw-r--r--app/graphql/types/work_item_state_enum.rb11
-rw-r--r--app/graphql/types/work_item_type.rb2
-rw-r--r--app/graphql/types/work_items/state_event_enum.rb13
-rw-r--r--app/helpers/application_helper.rb5
-rw-r--r--app/helpers/application_settings_helper.rb9
-rw-r--r--app/helpers/avatars_helper.rb30
-rw-r--r--app/helpers/bizible_helper.rb10
-rw-r--r--app/helpers/boards_helper.rb5
-rw-r--r--app/helpers/ci/pipeline_editor_helper.rb4
-rw-r--r--app/helpers/clusters_helper.rb6
-rw-r--r--app/helpers/groups_helper.rb4
-rw-r--r--app/helpers/ide_helper.rb2
-rw-r--r--app/helpers/integrations_helper.rb4
-rw-r--r--app/helpers/invite_members_helper.rb21
-rw-r--r--app/helpers/issuables_description_templates_helper.rb8
-rw-r--r--app/helpers/issuables_helper.rb5
-rw-r--r--app/helpers/issues_helper.rb9
-rw-r--r--app/helpers/learn_gitlab_helper.rb4
-rw-r--r--app/helpers/listbox_helper.rb57
-rw-r--r--app/helpers/nav/top_nav_helper.rb8
-rw-r--r--app/helpers/projects/cluster_agents_helper.rb1
-rw-r--r--app/helpers/projects_helper.rb32
-rw-r--r--app/helpers/search_helper.rb17
-rw-r--r--app/helpers/storage_helper.rb38
-rw-r--r--app/helpers/system_note_helper.rb3
-rw-r--r--app/helpers/tab_helper.rb2
-rw-r--r--app/helpers/tags_helper.rb6
-rw-r--r--app/helpers/tree_helper.rb15
-rw-r--r--app/helpers/users/group_callouts_helper.rb1
-rw-r--r--app/helpers/users_helper.rb7
-rw-r--r--app/models/application_record.rb1
-rw-r--r--app/models/application_setting.rb6
-rw-r--r--app/models/application_setting_implementation.rb14
-rw-r--r--app/models/audit_event.rb5
-rw-r--r--app/models/blob.rb4
-rw-r--r--app/models/board.rb2
-rw-r--r--app/models/ci/namespace_mirror.rb4
-rw-r--r--app/models/ci/pipeline.rb26
-rw-r--r--app/models/ci/runner.rb120
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/commit.rb4
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stage_event_model.rb40
-rw-r--r--app/models/concerns/ci/contextable.rb47
-rw-r--r--app/models/concerns/ci/has_variable.rb17
-rw-r--r--app/models/concerns/cross_database_modification.rb122
-rw-r--r--app/models/concerns/has_environment_scope.rb8
-rw-r--r--app/models/concerns/issuable.rb14
-rw-r--r--app/models/concerns/mirror_authentication.rb9
-rw-r--r--app/models/concerns/noteable.rb6
-rw-r--r--app/models/concerns/packages/debian/distribution.rb8
-rw-r--r--app/models/concerns/resolvable_discussion.rb10
-rw-r--r--app/models/concerns/taskable.rb15
-rw-r--r--app/models/concerns/timebox.rb13
-rw-r--r--app/models/concerns/token_authenticatable.rb12
-rw-r--r--app/models/concerns/token_authenticatable_strategies/base.rb36
-rw-r--r--app/models/container_repository.rb301
-rw-r--r--app/models/customer_relations/contact.rb18
-rw-r--r--app/models/customer_relations/issue_contact.rb6
-rw-r--r--app/models/dependency_proxy/blob.rb4
-rw-r--r--app/models/dependency_proxy/manifest.rb3
-rw-r--r--app/models/deployment.rb14
-rw-r--r--app/models/discussion.rb2
-rw-r--r--app/models/draft_note.rb7
-rw-r--r--app/models/environment.rb4
-rw-r--r--app/models/event.rb4
-rw-r--r--app/models/hooks/web_hook.rb5
-rw-r--r--app/models/instance_configuration.rb9
-rw-r--r--app/models/integration.rb3
-rw-r--r--app/models/integrations/chat_message/base_message.rb5
-rw-r--r--app/models/integrations/datadog.rb54
-rw-r--r--app/models/issue.rb11
-rw-r--r--app/models/key.rb2
-rw-r--r--app/models/loose_foreign_keys/deleted_record.rb32
-rw-r--r--app/models/member.rb15
-rw-r--r--app/models/members/project_member.rb7
-rw-r--r--app/models/merge_request.rb66
-rw-r--r--app/models/milestone.rb11
-rw-r--r--app/models/namespace.rb11
-rw-r--r--app/models/namespace/root_storage_statistics.rb30
-rw-r--r--app/models/namespace_statistics.rb60
-rw-r--r--app/models/namespaces/sync_event.rb4
-rw-r--r--app/models/namespaces/traversal/linear.rb11
-rw-r--r--app/models/namespaces/traversal/linear_scopes.rb22
-rw-r--r--app/models/namespaces/traversal/recursive_scopes.rb5
-rw-r--r--app/models/namespaces/user_namespace.rb4
-rw-r--r--app/models/note.rb27
-rw-r--r--app/models/packages/package.rb2
-rw-r--r--app/models/packages/package_file.rb21
-rw-r--r--app/models/pages_domain.rb4
-rw-r--r--app/models/personal_access_token.rb5
-rw-r--r--app/models/preloaders/single_hierarchy_project_group_plans_preloader.rb17
-rw-r--r--app/models/preloaders/users_max_access_level_in_projects_preloader.rb46
-rw-r--r--app/models/project.rb85
-rw-r--r--app/models/project_import_state.rb6
-rw-r--r--app/models/project_setting.rb12
-rw-r--r--app/models/project_team.rb4
-rw-r--r--app/models/projects/sync_event.rb4
-rw-r--r--app/models/projects/topic.rb23
-rw-r--r--app/models/resource_label_event.rb6
-rw-r--r--app/models/state_note.rb4
-rw-r--r--app/models/system_note_metadata.rb2
-rw-r--r--app/models/user.rb51
-rw-r--r--app/models/users/callout.rb5
-rw-r--r--app/models/users/group_callout.rb7
-rw-r--r--app/models/users_star_project.rb2
-rw-r--r--app/models/vulnerability.rb1
-rw-r--r--app/models/work_item.rb4
-rw-r--r--app/policies/ci/project_pipelines_policy.rb7
-rw-r--r--app/policies/ci/runner_policy.rb4
-rw-r--r--app/policies/group_policy.rb3
-rw-r--r--app/policies/note_policy.rb2
-rw-r--r--app/policies/project_policy.rb8
-rw-r--r--app/policies/work_item_policy.rb12
-rw-r--r--app/presenters/README.md8
-rw-r--r--app/presenters/alert_management/alert_presenter.rb2
-rw-r--r--app/presenters/blob_presenter.rb30
-rw-r--r--app/presenters/blobs/unfold_presenter.rb2
-rw-r--r--app/presenters/ci/pipeline_presenter.rb2
-rw-r--r--app/presenters/ci/runner_presenter.rb4
-rw-r--r--app/presenters/clusterable_presenter.rb4
-rw-r--r--app/presenters/clusters/cluster_presenter.rb2
-rw-r--r--app/presenters/merge_request_presenter.rb2
-rw-r--r--app/presenters/packages/conan/package_presenter.rb6
-rw-r--r--app/presenters/packages/detail/package_presenter.rb6
-rw-r--r--app/presenters/packages/npm/package_presenter.rb6
-rw-r--r--app/presenters/packages/nuget/presenter_helpers.rb11
-rw-r--r--app/presenters/packages/pypi/package_presenter.rb6
-rw-r--r--app/presenters/project_presenter.rb2
-rw-r--r--app/presenters/projects/import_export/project_export_presenter.rb5
-rw-r--r--app/presenters/projects/security/configuration_presenter.rb3
-rw-r--r--app/presenters/snippet_blob_presenter.rb2
-rw-r--r--app/serializers/analytics_summary_entity.rb1
-rw-r--r--app/serializers/codequality_degradation_entity.rb4
-rw-r--r--app/serializers/environment_serializer.rb14
-rw-r--r--app/serializers/group_child_entity.rb4
-rw-r--r--app/serializers/issuable_sidebar_basic_entity.rb1
-rw-r--r--app/serializers/issue_sidebar_basic_entity.rb6
-rw-r--r--app/serializers/member_user_entity.rb1
-rw-r--r--app/serializers/merge_request_poll_cached_widget_entity.rb6
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb2
-rw-r--r--app/serializers/test_case_entity.rb2
-rw-r--r--app/services/alert_management/alerts/update_service.rb15
-rw-r--r--app/services/alert_management/create_alert_issue_service.rb13
-rw-r--r--app/services/audit_event_service.rb4
-rw-r--r--app/services/auth/container_registry_authentication_service.rb41
-rw-r--r--app/services/boards/base_item_move_service.rb4
-rw-r--r--app/services/boards/issues/move_service.rb4
-rw-r--r--app/services/branches/create_service.rb2
-rw-r--r--app/services/ci/after_requeue_job_service.rb10
-rw-r--r--app/services/ci/copy_cross_database_associations_service.rb11
-rw-r--r--app/services/ci/create_downstream_pipeline_service.rb6
-rw-r--r--app/services/ci/pipeline_schedule_service.rb2
-rw-r--r--app/services/ci/process_sync_events_service.rb15
-rw-r--r--app/services/ci/register_job_service.rb2
-rw-r--r--app/services/ci/register_runner_service.rb30
-rw-r--r--app/services/ci/retry_build_service.rb29
-rw-r--r--app/services/ci/unregister_runner_service.rb16
-rw-r--r--app/services/ci/update_build_queue_service.rb2
-rw-r--r--app/services/ci/update_runner_service.rb2
-rw-r--r--app/services/concerns/members/bulk_create_users.rb1
-rw-r--r--app/services/concerns/rate_limited_service.rb19
-rw-r--r--app/services/google_cloud/create_service_accounts_service.rb5
-rw-r--r--app/services/google_cloud/enable_cloud_run_service.rb34
-rw-r--r--app/services/google_cloud/generate_pipeline_service.rb79
-rw-r--r--app/services/groups/create_service.rb11
-rw-r--r--app/services/groups/update_statistics_service.rb28
-rw-r--r--app/services/incident_management/create_incident_label_service.rb22
-rw-r--r--app/services/incident_management/incidents/create_service.rb16
-rw-r--r--app/services/incident_management/issuable_escalation_statuses/after_update_service.rb18
-rw-r--r--app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb29
-rw-r--r--app/services/issuable/common_system_notes_service.rb2
-rw-r--r--app/services/issuable_base_service.rb13
-rw-r--r--app/services/issues/base_service.rb16
-rw-r--r--app/services/issues/close_service.rb4
-rw-r--r--app/services/issues/create_service.rb2
-rw-r--r--app/services/issues/move_service.rb17
-rw-r--r--app/services/issues/reorder_service.rb36
-rw-r--r--app/services/issues/set_crm_contacts_service.rb34
-rw-r--r--app/services/issues/update_service.rb35
-rw-r--r--app/services/loose_foreign_keys/batch_cleaner_service.rb52
-rw-r--r--app/services/members/create_service.rb12
-rw-r--r--app/services/members/creator_service.rb1
-rw-r--r--app/services/merge_requests/after_create_service.rb23
-rw-r--r--app/services/merge_requests/base_service.rb4
-rw-r--r--app/services/merge_requests/bulk_remove_attention_requested_service.rb10
-rw-r--r--app/services/merge_requests/mergeability_check_service.rb2
-rw-r--r--app/services/merge_requests/rebase_service.rb4
-rw-r--r--app/services/merge_requests/squash_service.rb4
-rw-r--r--app/services/packages/maven/metadata/sync_service.rb6
-rw-r--r--app/services/projects/autocomplete_service.rb5
-rw-r--r--app/services/projects/container_repository/delete_tags_service.rb1
-rw-r--r--app/services/projects/create_service.rb4
-rw-r--r--app/services/projects/destroy_service.rb36
-rw-r--r--app/services/projects/import_export/export_service.rb7
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_service.rb4
-rw-r--r--app/services/projects/overwrite_project_service.rb108
-rw-r--r--app/services/projects/readme_renderer_service.rb27
-rw-r--r--app/services/projects/transfer_service.rb14
-rw-r--r--app/services/quick_actions/interpret_service.rb34
-rw-r--r--app/services/resource_events/change_state_service.rb2
-rw-r--r--app/services/security/ci_configuration/container_scanning_create_service.rb25
-rw-r--r--app/services/service_ping/build_payload_service.rb2
-rw-r--r--app/services/service_ping/submit_service.rb24
-rw-r--r--app/services/system_note_service.rb12
-rw-r--r--app/services/system_notes/alert_management_service.rb26
-rw-r--r--app/services/system_notes/incident_service.rb15
-rw-r--r--app/services/system_notes/issuables_service.rb29
-rw-r--r--app/services/task_list_toggle_service.rb2
-rw-r--r--app/services/test_hooks/base_service.rb2
-rw-r--r--app/services/update_container_registry_info_service.rb6
-rw-r--r--app/services/users/destroy_service.rb5
-rw-r--r--app/services/web_hook_service.rb39
-rw-r--r--app/services/work_items/create_service.rb22
-rw-r--r--app/services/work_items/delete_service.rb17
-rw-r--r--app/services/work_items/update_service.rb13
-rw-r--r--app/uploaders/import_export_uploader.rb4
-rw-r--r--app/validators/x509_certificate_credentials_validator.rb2
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml1
-rw-r--r--app/views/admin/application_settings/_note_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml8
-rw-r--r--app/views/admin/application_settings/_usage.html.haml4
-rw-r--r--app/views/admin/application_settings/_users_api_limits.html.haml14
-rw-r--r--app/views/admin/application_settings/network.html.haml12
-rw-r--r--app/views/admin/application_settings/service_usage_data.html.haml16
-rw-r--r--app/views/admin/background_migrations/index.html.haml51
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml58
-rw-r--r--app/views/admin/dashboard/index.html.haml5
-rw-r--r--app/views/admin/groups/_group.html.haml2
-rw-r--r--app/views/admin/hooks/edit.html.haml2
-rw-r--r--app/views/admin/labels/_label.html.haml2
-rw-r--r--app/views/admin/projects/show.html.haml6
-rw-r--r--app/views/admin/runners/edit.html.haml2
-rw-r--r--app/views/admin/runners/show.html.haml2
-rw-r--r--app/views/admin/users/_access_levels.html.haml6
-rw-r--r--app/views/admin/users/_users.html.haml75
-rw-r--r--app/views/admin/users/projects.html.haml4
-rw-r--r--app/views/admin/users/show.html.haml2
-rw-r--r--app/views/ci/runner/_how_to_setup_runner.html.haml2
-rw-r--r--app/views/clusters/clusters/show.html.haml2
-rw-r--r--app/views/dashboard/_projects_nav.html.haml2
-rw-r--r--app/views/dashboard/projects/_blank_state_admin_welcome.html.haml52
-rw-r--r--app/views/dashboard/projects/_blank_state_welcome.html.haml65
-rw-r--r--app/views/dashboard/projects/_zero_authorized_projects.html.haml23
-rw-r--r--app/views/dashboard/projects/index.html.haml1
-rw-r--r--app/views/devise/confirmations/almost_there.haml1
-rw-r--r--app/views/devise/registrations/new.html.haml1
-rw-r--r--app/views/devise/sessions/new.html.haml1
-rw-r--r--app/views/devise/shared/_signup_box.html.haml3
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml2
-rw-r--r--app/views/discussions/_notes.html.haml4
-rw-r--r--app/views/doorkeeper/authorized_applications/_delete_form.html.haml2
-rw-r--r--app/views/groups/_home_panel.html.haml2
-rw-r--r--app/views/groups/_import_group_from_file_panel.html.haml5
-rw-r--r--app/views/groups/_invite_groups_modal.html.haml3
-rw-r--r--app/views/groups/_invite_members_modal.html.haml3
-rw-r--r--app/views/groups/group_members/index.html.haml5
-rw-r--r--app/views/groups/issues.html.haml2
-rw-r--r--app/views/groups/runners/index.html.haml3
-rw-r--r--app/views/groups/settings/_export.html.haml6
-rw-r--r--app/views/groups/settings/_general.html.haml16
-rw-r--r--app/views/groups/settings/_transfer.html.haml15
-rw-r--r--app/views/jira_connect/branches/new.html.haml2
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml8
-rw-r--r--app/views/layouts/_bizible.html.haml14
-rw-r--r--app/views/layouts/_flash.html.haml3
-rw-r--r--app/views/layouts/_page.html.haml3
-rw-r--r--app/views/layouts/header/_default.html.haml24
-rw-r--r--app/views/layouts/header/_help_dropdown.html.haml1
-rw-r--r--app/views/layouts/header/_whats_new_dropdown_item.html.haml3
-rw-r--r--app/views/layouts/nav/_classification_level_banner.html.haml4
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml9
-rw-r--r--app/views/profiles/accounts/_providers.html.haml2
-rw-r--r--app/views/projects/_bitbucket_import_modal.html.haml14
-rw-r--r--app/views/projects/_home_panel.html.haml10
-rw-r--r--app/views/projects/_import_project_pane.html.haml8
-rw-r--r--app/views/projects/_invite_groups_modal.html.haml3
-rw-r--r--app/views/projects/_merge_request_merge_checks_settings.html.haml4
-rw-r--r--app/views/projects/_merge_request_merge_commit_template.html.haml6
-rw-r--r--app/views/projects/_merge_request_merge_method_settings.html.haml6
-rw-r--r--app/views/projects/_merge_request_merge_suggestions_settings.html.haml2
-rw-r--r--app/views/projects/_merge_request_squash_commit_template.html.haml5
-rw-r--r--app/views/projects/_new_project_fields.html.haml48
-rw-r--r--app/views/projects/_new_project_initialize_with_sast.html.haml16
-rw-r--r--app/views/projects/_remove.html.haml3
-rw-r--r--app/views/projects/blob/_blob.html.haml3
-rw-r--r--app/views/projects/blob/_header_content.html.haml4
-rw-r--r--app/views/projects/branches/index.html.haml2
-rw-r--r--app/views/projects/ci/lints/show.html.haml2
-rw-r--r--app/views/projects/commits/_commit.html.haml2
-rw-r--r--app/views/projects/compare/index.html.haml2
-rw-r--r--app/views/projects/edit.html.haml5
-rw-r--r--app/views/projects/environments/index.html.haml2
-rw-r--r--app/views/projects/environments/show.html.haml2
-rw-r--r--app/views/projects/hooks/edit.html.haml2
-rw-r--r--app/views/projects/imports/new.html.haml4
-rw-r--r--app/views/projects/issues/_new_branch.html.haml4
-rw-r--r--app/views/projects/learn_gitlab/index.html.haml3
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml2
-rw-r--r--app/views/projects/merge_requests/show.html.haml2
-rw-r--r--app/views/projects/no_repo.html.haml2
-rw-r--r--app/views/projects/notes/_actions.html.haml30
-rw-r--r--app/views/projects/notes/_more_actions_dropdown.html.haml2
-rw-r--r--app/views/projects/pages/_pages_settings.html.haml15
-rw-r--r--app/views/projects/pages_domains/_dns.html.haml7
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml2
-rw-r--r--app/views/projects/pipelines/_info.html.haml2
-rw-r--r--app/views/projects/pipelines/show.html.haml1
-rw-r--r--app/views/projects/project_members/index.html.haml7
-rw-r--r--app/views/projects/protected_branches/shared/_protected_branch.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_protected_tag.html.haml2
-rw-r--r--app/views/projects/readme_templates/default.md.tt93
-rw-r--r--app/views/projects/security/configuration/show.html.haml4
-rw-r--r--app/views/projects/serverless/functions/index.html.haml2
-rw-r--r--app/views/projects/services/_form.html.haml8
-rw-r--r--app/views/projects/settings/_archive.html.haml4
-rw-r--r--app/views/projects/settings/_general.html.haml3
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/settings/operations/show.html.haml15
-rw-r--r--app/views/projects/starrers/index.html.haml18
-rw-r--r--app/views/projects/tags/index.html.haml3
-rw-r--r--app/views/projects/tracings/show.html.haml15
-rw-r--r--app/views/projects/triggers/_trigger.html.haml2
-rw-r--r--app/views/projects/usage_quotas/index.html.haml11
-rw-r--r--app/views/registrations/welcome/show.html.haml1
-rw-r--r--app/views/shared/_confirm_fork_modal.html.haml12
-rw-r--r--app/views/shared/_gl_toggle.html.haml28
-rw-r--r--app/views/shared/_global_alert.html.haml3
-rw-r--r--app/views/shared/_group_form.html.haml22
-rw-r--r--app/views/shared/_issues.html.haml2
-rw-r--r--app/views/shared/_registration_features_discovery_message.html.haml7
-rw-r--r--app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml9
-rw-r--r--app/views/shared/_web_ide_button.html.haml9
-rw-r--r--app/views/shared/access_tokens/_table.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml2
-rw-r--r--app/views/shared/hook_logs/_content.html.haml2
-rw-r--r--app/views/shared/icons/_icon_resolve_discussion.svg1
-rw-r--r--app/views/shared/icons/_icon_status_success_solid.svg1
-rw-r--r--app/views/shared/icons/_mr_widget_empty_state.svg2
-rw-r--r--app/views/shared/integrations/_form.html.haml4
-rw-r--r--app/views/shared/integrations/edit.html.haml5
-rw-r--r--app/views/shared/issuable/_assignees.html.haml7
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_merge_request_assignees.html.haml8
-rw-r--r--app/views/shared/issuable/_merge_request_reviewers.html.haml8
-rw-r--r--app/views/shared/issuable/_reviewers.html.haml7
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml14
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml5
-rw-r--r--app/views/shared/issuable/_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/form/_title.html.haml6
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml2
-rw-r--r--app/views/shared/members/_access_request_links.html.haml3
-rw-r--r--app/views/shared/notes/_note.html.haml2
-rw-r--r--app/views/shared/planning_hierarchy.html.haml5
-rw-r--r--app/views/shared/web_hooks/_hook.html.haml2
-rw-r--r--app/views/users/_overview.html.haml3
-rw-r--r--app/views/users/show.html.haml6
-rw-r--r--app/views/users/terms/index.html.haml1
-rw-r--r--app/workers/all_queues.yml75
-rw-r--r--app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb1
-rw-r--r--app/workers/auto_devops/disable_worker.rb8
-rw-r--r--app/workers/background_migration/ci_database_worker.rb11
-rw-r--r--app/workers/background_migration/single_database_worker.rb14
-rw-r--r--app/workers/background_migration_worker.rb4
-rw-r--r--app/workers/ci/delete_objects_worker.rb8
-rw-r--r--app/workers/concerns/application_worker.rb19
-rw-r--r--app/workers/container_expiration_policy_worker.rb2
-rw-r--r--app/workers/container_registry/migration/enqueuer_worker.rb116
-rw-r--r--app/workers/container_registry/migration/guard_worker.rb101
-rw-r--r--app/workers/container_registry/migration/observer_worker.rb40
-rw-r--r--app/workers/expire_job_cache_worker.rb2
-rw-r--r--app/workers/groups/update_statistics_worker.rb29
-rw-r--r--app/workers/hashed_storage/migrator_worker.rb3
-rw-r--r--app/workers/hashed_storage/project_migrate_worker.rb3
-rw-r--r--app/workers/hashed_storage/project_rollback_worker.rb3
-rw-r--r--app/workers/hashed_storage/rollbacker_worker.rb3
-rw-r--r--app/workers/loose_foreign_keys/cleanup_worker.rb2
-rw-r--r--app/workers/merge_requests/update_head_pipeline_worker.rb1
-rw-r--r--app/workers/namespaces/process_sync_events_worker.rb8
-rw-r--r--app/workers/namespaces/update_root_statistics_worker.rb17
-rw-r--r--app/workers/pages_update_configuration_worker.rb17
-rw-r--r--app/workers/pipeline_schedule_worker.rb2
-rw-r--r--app/workers/project_export_worker.rb15
-rw-r--r--app/workers/projects/git_garbage_collect_worker.rb10
-rw-r--r--app/workers/projects/process_sync_events_worker.rb8
-rw-r--r--app/workers/run_pipeline_schedule_worker.rb2
-rw-r--r--app/workers/web_hook_worker.rb16
1131 files changed, 15716 insertions, 7224 deletions
diff --git a/app/assets/images/auth_buttons/atlassian_64.png b/app/assets/images/auth_buttons/atlassian_64.png
index 548f1c93318..63169b9a81b 100644
--- a/app/assets/images/auth_buttons/atlassian_64.png
+++ b/app/assets/images/auth_buttons/atlassian_64.png
Binary files differ
diff --git a/app/assets/images/auth_buttons/auth0_64.png b/app/assets/images/auth_buttons/auth0_64.png
index 5ad59659380..3b2d8562d9d 100644
--- a/app/assets/images/auth_buttons/auth0_64.png
+++ b/app/assets/images/auth_buttons/auth0_64.png
Binary files differ
diff --git a/app/assets/images/auth_buttons/bitbucket_64.png b/app/assets/images/auth_buttons/bitbucket_64.png
index 0edf7f52a11..06a68b9bf55 100644
--- a/app/assets/images/auth_buttons/bitbucket_64.png
+++ b/app/assets/images/auth_buttons/bitbucket_64.png
Binary files differ
diff --git a/app/assets/images/auth_buttons/facebook_64.png b/app/assets/images/auth_buttons/facebook_64.png
index 71ffb1c6a1f..34b75de4498 100644
--- a/app/assets/images/auth_buttons/facebook_64.png
+++ b/app/assets/images/auth_buttons/facebook_64.png
Binary files differ
diff --git a/app/assets/images/auth_buttons/twitter_64.png b/app/assets/images/auth_buttons/twitter_64.png
index a4f14de57ae..15596b0f30a 100644
--- a/app/assets/images/auth_buttons/twitter_64.png
+++ b/app/assets/images/auth_buttons/twitter_64.png
Binary files differ
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
index 09278e1776a..cdc8a952ead 100644
--- a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
+++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
@@ -22,6 +22,7 @@ query accessTokensGetProjects(
avatarUrl
}
pageInfo {
+ __typename
...PageInfo
}
}
diff --git a/app/assets/javascripts/actioncable_link.js b/app/assets/javascripts/actioncable_link.js
index 895a34ba157..cf53d9e21b4 100644
--- a/app/assets/javascripts/actioncable_link.js
+++ b/app/assets/javascripts/actioncable_link.js
@@ -1,4 +1,4 @@
-import { ApolloLink, Observable } from 'apollo-link';
+import { ApolloLink, Observable } from '@apollo/client/core';
import { print } from 'graphql';
import cable from '~/actioncable_consumer';
import { uuids } from '~/lib/utils/uuids';
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index f45af5fe08e..74e0e1b6225 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { localTimeAgo } from './lib/utils/datetime_utility';
@@ -55,7 +55,7 @@ export default class Activities {
const filter = $sender.attr('id').split('_')[0];
$('.event-filter .active').removeClass('active');
- Cookies.set('event_filter', filter);
+ setCookie('event_filter', filter);
$sender.closest('li').toggleClass('active');
}
diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
index 90c9113e0e1..96584080d0f 100644
--- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
+++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
@@ -1,5 +1,5 @@
<script>
-import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf } from '@gitlab/ui';
+import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf, GlBadge } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue';
import createFlash from '~/flash';
@@ -21,6 +21,7 @@ export default {
ReviewTabContainer,
GlSearchBoxByType,
GlSprintf,
+ GlBadge,
},
props: {
contextCommitsPath: {
@@ -239,7 +240,7 @@ export default {
<template #title>
<gl-sprintf :message="__(`Commits in %{codeStart}${targetBranch}%{codeEnd}`)">
<template #code="{ content }">
- <code>{{ content }}</code>
+ <code class="gl-ml-2">{{ content }}</code>
</template>
</gl-sprintf>
</template>
@@ -262,7 +263,7 @@ export default {
<gl-tab>
<template #title>
{{ __('Selected commits') }}
- <span class="badge badge-pill">{{ selectedCommitsCount }}</span>
+ <gl-badge size="sm" class="gl-ml-2">{{ selectedCommitsCount }}</gl-badge>
</template>
<review-tab-container
:is-loading="isLoadingContextCommits"
diff --git a/app/assets/javascripts/admin/application_settings/setup_service_usage_data.js b/app/assets/javascripts/admin/application_settings/setup_service_usage_data.js
new file mode 100644
index 00000000000..a88efbd89a8
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/setup_service_usage_data.js
@@ -0,0 +1,15 @@
+import PayloadPreviewer from '~/pages/admin/application_settings/payload_previewer';
+import PayloadDownloader from '~/pages/admin/application_settings/payload_downloader';
+
+export default () => {
+ const payloadPreviewTrigger = document.querySelector('.js-payload-preview-trigger');
+ const payloadDownloadTrigger = document.querySelector('.js-payload-download-trigger');
+
+ if (payloadPreviewTrigger) {
+ new PayloadPreviewer(payloadPreviewTrigger).init();
+ }
+
+ if (payloadDownloadTrigger) {
+ new PayloadDownloader(payloadDownloadTrigger).init();
+ }
+};
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 79a6bac3ba7..84c2b216859 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -15,7 +15,7 @@ import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
-import { s__, __ } from '~/locale';
+import { s__, __, n__ } from '~/locale';
import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue';
import {
tdClass,
@@ -32,8 +32,11 @@ const TH_TEST_ID = { 'data-testid': 'alert-management-severity-sort' };
const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000;
+const MAX_VISIBLE_ASSIGNEES = 4;
+
export default {
trackAlertListViewsOptions,
+ MAX_VISIBLE_ASSIGNEES,
i18n: {
noAlertsMsg: s__(
'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.',
@@ -258,6 +261,13 @@ export default {
this.serverErrorMessage = '';
this.isErrorAlertDismissed = true;
},
+ assigneesBadgeSrOnlyText(item) {
+ return n__(
+ '%d additional assignee',
+ '%d additional assignees',
+ item.assignees.nodes.length - MAX_VISIBLE_ASSIGNEES,
+ );
+ },
},
};
</script>
@@ -365,10 +375,11 @@ export default {
<gl-avatars-inline
:avatars="item.assignees.nodes"
:collapsed="true"
- :max-visible="4"
+ :max-visible="$options.MAX_VISIBLE_ASSIGNEES"
:avatar-size="24"
badge-tooltip-prop="name"
:badge-tooltip-max-chars="100"
+ :badge-sr-only-text="assigneesBadgeSrOnlyText(item)"
>
<template #avatar="{ avatar }">
<gl-avatar-link
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
index b23f8a8eba4..42cbeef56bf 100644
--- a/app/assets/javascripts/alert_management/list.js
+++ b/app/assets/javascripts/alert_management/list.js
@@ -1,4 +1,4 @@
-import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import { defaultDataIdFromObject } from '@apollo/client/core';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js
index b64e2e3eefa..36a98145457 100644
--- a/app/assets/javascripts/alerts_settings/graphql.js
+++ b/app/assets/javascripts/alerts_settings/graphql.js
@@ -1,15 +1,9 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import introspectionQueryResultData from './graphql/fragmentTypes.json';
import getCurrentIntegrationQuery from './graphql/queries/get_current_integration.query.graphql';
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
Vue.use(VueApollo);
const resolvers = {
@@ -55,9 +49,5 @@ const resolvers = {
};
export default new VueApollo({
- defaultClient: createDefaultClient(resolvers, {
- cacheConfig: {
- fragmentMatcher,
- },
- }),
+ defaultClient: createDefaultClient(resolvers),
});
diff --git a/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json b/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json
deleted file mode 100644
index 07dfc43aa6c..00000000000
--- a/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"UNION","name":"AlertManagementIntegration","possibleTypes":[{"name":"AlertManagementHttpIntegration"},{"name":"AlertManagementPrometheusIntegration"}]}]}}
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
index 3cd3f2d92f8..ac9304391f9 100644
--- a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
@@ -5,6 +5,7 @@ query getIntegrations($projectPath: ID!) {
id
alertManagementIntegrations {
nodes {
+ __typename
...IntegrationItem
}
}
diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue
index a5b9c40b9c9..7df66d1b2be 100644
--- a/app/assets/javascripts/analytics/shared/components/daterange.vue
+++ b/app/assets/javascripts/analytics/shared/components/daterange.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDaterangePicker, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlDaterangePicker, GlSprintf } from '@gitlab/ui';
import { getDayDifference } from '~/lib/utils/datetime_utility';
import { __, sprintf } from '~/locale';
import { OFFSET_DATE_BY_ONE } from '../constants';
@@ -8,10 +8,6 @@ export default {
components: {
GlDaterangePicker,
GlSprintf,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
},
props: {
show: {
@@ -56,7 +52,7 @@ export default {
return {
maxDateRangeTooltip: sprintf(
__(
- 'Showing data for workflow items created in this date range. Date range cannot exceed %{maxDateRange} days.',
+ 'Showing data for workflow items created in this date range. Date range limited to %{maxDateRange} days.',
),
{
maxDateRange: this.maxDateRange,
@@ -94,28 +90,15 @@ export default {
:max-date-range="maxDateRange"
:default-max-date="maxDate"
:same-day-selection="includeSelectedDate"
+ :tooltip="maxDateRangeTooltip"
theme="animate-picker"
start-picker-class="js-daterange-picker-from gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-lg-mr-3 gl-mb-2 gl-lg-mb-0"
- end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center"
+ end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center gl-mb-2 gl-lg-mb-0"
label-class="gl-mb-2 gl-lg-mb-0"
- />
- <div
- v-if="maxDateRange"
- class="daterange-indicator d-flex flex-row flex-lg-row align-items-flex-start align-items-lg-center"
>
- <span class="number-of-days pl-2 pr-1">
- <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)">
- <template #numberOfDays>{{ numberOfDays }}</template>
- </gl-sprintf>
- </span>
- <gl-icon
- v-gl-tooltip
- data-testid="helper-icon"
- :title="maxDateRangeTooltip"
- name="question"
- :size="14"
- class="text-secondary"
- />
- </div>
+ <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)">
+ <template #numberOfDays>{{ numberOfDays }}</template>
+ </gl-sprintf>
+ </gl-daterange-picker>
</div>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/metric_popover.vue b/app/assets/javascripts/analytics/shared/components/metric_popover.vue
index 8d90e7b2392..8d90e7b2392 100644
--- a/app/assets/javascripts/cycle_analytics/components/metric_popover.vue
+++ b/app/assets/javascripts/analytics/shared/components/metric_popover.vue
diff --git a/app/assets/javascripts/analytics/shared/components/metric_tile.vue b/app/assets/javascripts/analytics/shared/components/metric_tile.vue
new file mode 100644
index 00000000000..845a3386f6c
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/components/metric_tile.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { redirectTo } from '~/lib/utils/url_utility';
+import MetricPopover from './metric_popover.vue';
+
+export default {
+ name: 'MetricTile',
+ components: {
+ GlSingleStat,
+ MetricPopover,
+ },
+ props: {
+ metric: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ decimalPlaces() {
+ const parsedFloat = parseFloat(this.metric.value);
+ return Number.isNaN(parsedFloat) || Number.isInteger(parsedFloat) ? 0 : 1;
+ },
+ hasLinks() {
+ return this.metric.links?.length && this.metric.links[0].url;
+ },
+ },
+ methods: {
+ clickHandler({ links }) {
+ if (this.hasLinks) {
+ redirectTo(links[0].url);
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div v-bind="$attrs">
+ <gl-single-stat
+ :id="metric.identifier"
+ :value="`${metric.value}`"
+ :title="metric.label"
+ :unit="metric.unit || ''"
+ :should-animate="true"
+ :animation-decimal-places="decimalPlaces"
+ :class="{ 'gl-hover-cursor-pointer': hasLinks }"
+ tabindex="0"
+ @click="clickHandler(metric)"
+ />
+ <metric-popover :metric="metric" :target="metric.identifier" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
index 9671742e564..1a3544e7677 100644
--- a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue
+++ b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
@@ -1,13 +1,11 @@
<script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
-import { GlSingleStat } from '@gitlab/ui/dist/charts';
-import { flatten } from 'lodash';
+import { flatten, isEqual } from 'lodash';
import createFlash from '~/flash';
import { sprintf, s__ } from '~/locale';
-import { redirectTo } from '~/lib/utils/url_utility';
import { METRICS_POPOVER_CONTENT } from '../constants';
import { removeFlash, prepareTimeMetricsData } from '../utils';
-import MetricPopover from './metric_popover.vue';
+import MetricTile from './metric_tile.vue';
const requestData = ({ request, endpoint, path, params, name }) => {
return request({ endpoint, params, requestPath: path })
@@ -33,9 +31,8 @@ const fetchMetricsData = (reqs = [], path, params) => {
export default {
name: 'ValueStreamMetrics',
components: {
- GlSingleStat,
GlSkeletonLoading,
- MetricPopover,
+ MetricTile,
},
props: {
requestPath: {
@@ -50,6 +47,11 @@ export default {
type: Array,
required: true,
},
+ filterFn: {
+ type: Function,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -58,8 +60,10 @@ export default {
};
},
watch: {
- requestParams() {
- this.fetchData();
+ requestParams(newVal, oldVal) {
+ if (!isEqual(newVal, oldVal)) {
+ this.fetchData();
+ }
},
},
mounted() {
@@ -71,40 +75,25 @@ export default {
this.isLoading = true;
return fetchMetricsData(this.requests, this.requestPath, this.requestParams)
.then((data) => {
- this.metrics = data;
+ this.metrics = this.filterFn ? this.filterFn(data) : data;
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
});
},
- hasLinks(links) {
- return links?.length && links[0].url;
- },
- clickHandler({ links }) {
- if (this.hasLinks(links)) {
- redirectTo(links[0].url);
- }
- },
},
};
</script>
<template>
- <div class="gl-display-flex gl-flex-wrap" data-testid="vsa-time-metrics">
+ <div class="gl-display-flex gl-flex-wrap" data-testid="vsa-metrics">
<gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6" />
- <div v-for="metric in metrics" v-show="!isLoading" :key="metric.key" class="gl-my-6 gl-pr-9">
- <gl-single-stat
- :id="metric.key"
- :value="`${metric.value}`"
- :title="metric.label"
- :unit="metric.unit || ''"
- :should-animate="true"
- :animation-decimal-places="1"
- :class="{ 'gl-hover-cursor-pointer': hasLinks(metric.links) }"
- tabindex="0"
- @click="clickHandler(metric)"
- />
- <metric-popover :metric="metric" :target="metric.key" />
- </div>
+ <metric-tile
+ v-for="metric in metrics"
+ v-show="!isLoading"
+ :key="metric.identifier"
+ :metric="metric"
+ class="gl-my-6 gl-pr-9"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index c06bd34f86f..2ac144ceb5e 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -1,4 +1,5 @@
import { masks } from 'dateformat';
+import { s__ } from '~/locale';
export const DATE_RANGE_LIMIT = 180;
export const OFFSET_DATE_BY_ONE = 1;
@@ -11,3 +12,47 @@ export const dateFormats = {
defaultDateTime: 'mmm d, yyyy h:MMtt',
month: 'mmmm',
};
+
+// Some content is duplicated due to backward compatibility.
+// It will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/350614 in 14.9
+export const METRICS_POPOVER_CONTENT = {
+ 'lead-time': {
+ description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
+ },
+ lead_time: {
+ description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
+ },
+ 'cycle-time': {
+ description: s__(
+ "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
+ ),
+ },
+ cycle_time: {
+ description: s__(
+ "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
+ ),
+ },
+ 'lead-time-for-changes': {
+ description: s__(
+ 'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.',
+ ),
+ },
+ lead_time_for_changes: {
+ description: s__(
+ 'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.',
+ ),
+ },
+ issues: { description: s__('ValueStreamAnalytics|Number of new issues created.') },
+ 'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
+ 'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
+ deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
+ 'deployment-frequency': {
+ description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
+ },
+ deployment_frequency: {
+ description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
+ },
+ commits: {
+ description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'),
+ },
+};
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index f55ef99964e..dde429ab278 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -1,4 +1,6 @@
import dateFormat from 'dateformat';
+import { hideFlash } from '~/flash';
+import { slugify } from '~/lib/utils/text_utility';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats } from './constants';
@@ -69,3 +71,28 @@ export const getDataZoomOption = ({
};
});
};
+
+export const removeFlash = (type = 'alert') => {
+ const flashEl = document.querySelector(`.flash-${type}`);
+ if (flashEl) {
+ hideFlash(flashEl);
+ }
+};
+
+/**
+ * Prepares metric data to be rendered in the metric_card component
+ *
+ * @param {MetricData[]} data - The metric data to be rendered
+ * @param {Object} popoverContent - Key value pair of data to display in the popover
+ * @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card
+ */
+export const prepareTimeMetricsData = (data = [], popoverContent = {}) =>
+ data.map(({ title: label, identifier, ...rest }) => {
+ const metricIdentifier = identifier || slugify(label);
+ return {
+ ...rest,
+ label,
+ identifier: metricIdentifier,
+ description: popoverContent[metricIdentifier]?.description || '',
+ };
+ });
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
index 2bde5973600..b353bcdfd0e 100644
--- a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
@@ -1,4 +1,5 @@
fragment Count on UsageTrendsMeasurement {
+ __typename
count
recordedAt
}
diff --git a/app/assets/javascripts/authentication/webauthn/util.js b/app/assets/javascripts/authentication/webauthn/util.js
index 5f06c000afe..eeda2bfaeaf 100644
--- a/app/assets/javascripts/authentication/webauthn/util.js
+++ b/app/assets/javascripts/authentication/webauthn/util.js
@@ -14,31 +14,36 @@ export function isHTTPS() {
export const FLOW_AUTHENTICATE = 'authenticate';
export const FLOW_REGISTER = 'register';
-// adapted from https://stackoverflow.com/a/21797381/8204697
-function base64ToBuffer(base64) {
- const binaryString = window.atob(base64);
- const len = binaryString.length;
- const bytes = new Uint8Array(len);
- for (let i = 0; i < len; i += 1) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- return bytes.buffer;
-}
-
-// adapted from https://stackoverflow.com/a/9458996/8204697
-function bufferToBase64(buffer) {
- if (typeof buffer === 'string') {
- return buffer;
+/**
+ * Converts a base64 string to an ArrayBuffer
+ *
+ * @param {String} str - A base64 encoded string
+ * @returns {ArrayBuffer}
+ */
+export const base64ToBuffer = (str) => {
+ const rawStr = atob(str);
+ const buffer = new ArrayBuffer(rawStr.length);
+ const arr = new Uint8Array(buffer);
+ for (let i = 0; i < rawStr.length; i += 1) {
+ arr[i] = rawStr.charCodeAt(i);
}
+ return arr.buffer;
+};
- let binary = '';
- const bytes = new Uint8Array(buffer);
- const len = bytes.byteLength;
- for (let i = 0; i < len; i += 1) {
- binary += String.fromCharCode(bytes[i]);
+/**
+ * Converts ArrayBuffer to a base64-encoded string
+ *
+ * @param {ArrayBuffer, String} str -
+ * @returns {String} - ArrayBuffer to a base64-encoded string.
+ * When input is a string, returns the input as-is.
+ */
+export const bufferToBase64 = (input) => {
+ if (typeof input === 'string') {
+ return input;
}
- return window.btoa(binary);
-}
+ const arr = new Uint8Array(input);
+ return btoa(String.fromCharCode(...arr));
+};
/**
* Returns a copy of the given object with the id property converted to buffer
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 43ca5b5cf89..aa735df7da5 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -2,10 +2,10 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
-import Cookies from 'js-cookie';
import { uniq } from 'lodash';
+import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils';
import * as Emoji from '~/emoji';
-import { scrollToElement } from '~/lib/utils/common_utils';
+
import { dispose, fixTitle } from '~/tooltips';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
@@ -506,7 +506,7 @@ export class AwardsHandler {
addEmojiToFrequentlyUsedList(emoji) {
if (this.emoji.isEmojiNameValid(emoji)) {
this.frequentlyUsedEmojis = uniq(this.getFrequentlyUsedEmojis().concat(emoji));
- Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 });
+ setCookie('frequently_used_emojis', this.frequentlyUsedEmojis.join(','));
}
}
@@ -514,7 +514,7 @@ export class AwardsHandler {
return (
this.frequentlyUsedEmojis ||
(() => {
- const frequentlyUsedEmojis = uniq((Cookies.get('frequently_used_emojis') || '').split(','));
+ const frequentlyUsedEmojis = uniq((getCookie('frequently_used_emojis') || '').split(','));
this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter((inputName) =>
this.emoji.isEmojiNameValid(inputName),
);
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index 53469ac8999..8bef972cc58 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -74,7 +74,14 @@ export default {
<template>
<div>
- <a v-show="!isLoading && !hasError" :href="linkUrl" target="_blank" rel="noopener noreferrer">
+ <a
+ v-show="!isLoading && !hasError"
+ :href="linkUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ data-qa-selector="badge_image_link"
+ :data-qa-link-url="linkUrl"
+ >
<img
:src="imageUrlWithRetries"
class="project-badge"
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index 2c7e878f044..d1570e16639 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -182,7 +182,7 @@ export default {
@submit.prevent.stop="onSubmit"
>
<gl-form-group :label="s__('Badges|Name')" label-for="badge-name">
- <gl-form-input id="badge-name" v-model="name" />
+ <gl-form-input id="badge-name" v-model="name" data-qa-selector="badge_name_field" />
</gl-form-group>
<div class="form-group">
@@ -191,6 +191,7 @@ export default {
<input
id="badge-link-url"
v-model="linkUrl"
+ data-qa-selector="badge_link_url_field"
type="URL"
class="form-control gl-form-input"
required
@@ -206,6 +207,7 @@ export default {
<input
id="badge-image-url"
v-model="imageUrl"
+ data-qa-selector="badge_image_url_field"
type="URL"
class="form-control gl-form-input"
required
@@ -246,7 +248,13 @@ export default {
</gl-button>
</div>
<div v-else class="form-group">
- <gl-button :loading="isSaving" type="submit" variant="confirm" category="primary">
+ <gl-button
+ :loading="isSaving"
+ type="submit"
+ variant="confirm"
+ category="primary"
+ data-qa-selector="add_badge_button"
+ >
{{ s__('Badges|Add badge') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue
index 86c7b4c7a6e..76625fe9a60 100644
--- a/app/assets/javascripts/badges/components/badge_list.vue
+++ b/app/assets/javascripts/badges/components/badge_list.vue
@@ -34,8 +34,14 @@ export default {
<span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span>
<span v-else>{{ s__('Badges|This project has no badges') }}</span>
</div>
- <div v-else class="card-body">
- <badge-list-row v-for="badge in badges" :key="badge.id" :badge="badge" />
+ <div v-else class="card-body" data-qa-selector="badge_list_content">
+ <badge-list-row
+ v-for="badge in badges"
+ :key="badge.id"
+ :badge="badge"
+ data-qa-selector="badge_list_row"
+ :data-qa-badge-name="badge.name"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
index d8525c15087..4c2b700c7ff 100644
--- a/app/assets/javascripts/badges/components/badge_list_row.vue
+++ b/app/assets/javascripts/badges/components/badge_list_row.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon, GlButton, GlModalDirective } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton, GlModalDirective, GlBadge } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import { PROJECT_BADGE } from '../constants';
@@ -11,6 +11,7 @@ export default {
Badge,
GlLoadingIcon,
GlButton,
+ GlBadge,
},
directives: {
GlModal: GlModalDirective,
@@ -49,7 +50,7 @@ export default {
/>
<div class="table-section section-30">
<label class="label-bold str-truncated mb-0">{{ badge.name }}</label>
- <span class="badge badge-pill">{{ badgeKindText }}</span>
+ <gl-badge size="sm">{{ badgeKindText }}</gl-badge>
</div>
<span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span>
<div class="table-section section-10 table-button-footer">
diff --git a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
index 570954c7200..2ebde10c229 100644
--- a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
+++ b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
@@ -1,11 +1,13 @@
<script>
import { mapGetters } from 'vuex';
import imageDiff from '~/diffs/mixins/image_diff';
+import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
import DraftNote from './draft_note.vue';
export default {
components: {
DraftNote,
+ DesignNotePin,
},
mixins: [imageDiff],
props: {
@@ -31,9 +33,12 @@ export default {
class="discussion-notes diff-discussions position-relative"
>
<div class="notes">
- <span class="d-block btn-transparent badge badge-pill is-draft js-diff-notes-index">
- {{ toggleText(draft, index) }}
- </span>
+ <design-note-pin
+ :label="toggleText(draft, index)"
+ is-draft
+ class="js-diff-notes-index gl-translate-x-n50"
+ size="sm"
+ />
<draft-note :draft="draft" />
</div>
</div>
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index a218624f2d4..c8130c47f5b 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlButton, GlSafeHtmlDirective, GlBadge } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import NoteableNote from '~/notes/components/noteable_note.vue';
import PublishButton from './publish_button.vue';
@@ -9,6 +9,7 @@ export default {
NoteableNote,
PublishButton,
GlButton,
+ GlBadge,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -100,9 +101,7 @@ export default {
@toggleResolveStatus="toggleResolveDiscussion(draft.id)"
>
<template #note-header-info>
- <strong class="badge draft-pending-label gl-mr-2">
- {{ __('Pending') }}
- </strong>
+ <gl-badge variant="warning" class="gl-mr-2">{{ __('Pending') }}</gl-badge>
</template>
</noteable-note>
</ul>
@@ -115,10 +114,15 @@ export default {
></div>
<p class="draft-note-actions d-flex">
- <publish-button :show-count="true" :should-publish="false" category="secondary" />
+ <publish-button
+ :show-count="true"
+ :should-publish="false"
+ category="secondary"
+ :disabled="isPublishingDraft(draft.id)"
+ />
<gl-button
- ref="publishNowButton"
- :loading="isPublishingDraft(draft.id) || isPublishing"
+ :disabled="isPublishing"
+ :loading="isPublishingDraft(draft.id)"
class="gl-ml-3"
@click="publishNow"
>
diff --git a/app/assets/javascripts/behaviors/markdown/marks/bold.js b/app/assets/javascripts/behaviors/markdown/marks/bold.js
index d307edd9fd3..89e373220af 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/bold.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/bold.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Bold as BaseBold } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Bold extends BaseBold {
diff --git a/app/assets/javascripts/behaviors/markdown/marks/code.js b/app/assets/javascripts/behaviors/markdown/marks/code.js
index ccfe2cf5b8d..68368dec676 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/code.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/code.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Code as BaseCode } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Code extends BaseCode {
diff --git a/app/assets/javascripts/behaviors/markdown/marks/italic.js b/app/assets/javascripts/behaviors/markdown/marks/italic.js
index dbef10536ab..7dc86102f18 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/italic.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/italic.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Italic as BaseItalic } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Italic extends BaseItalic {
diff --git a/app/assets/javascripts/behaviors/markdown/marks/link.js b/app/assets/javascripts/behaviors/markdown/marks/link.js
index 1111c51805d..b5e09017d83 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/link.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/link.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Link as BaseLink } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Link extends BaseLink {
diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js
index 382bf5c9b5b..ca25ff7d07d 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/math.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/math.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Mark } from 'tiptap';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::MathFilter
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
index bd5868e5524..8b14a04e2fe 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Blockquote as BaseBlockquote } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Blockquote extends BaseBlockquote {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
index 209e7239998..ef1eafaa419 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { BulletList as BaseBulletList } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class BulletList extends BaseBulletList {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/heading.js b/app/assets/javascripts/behaviors/markdown/nodes/heading.js
index 708da053a2f..29967e61ffa 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/heading.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/heading.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Heading as BaseHeading } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Heading extends BaseHeading {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
index 47a24eae1e8..ee3aa145dc3 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { HorizontalRule as BaseHorizontalRule } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class HorizontalRule extends BaseHorizontalRule {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js
index 4cc28c45739..16647d2f96e 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/image.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js
@@ -1,8 +1,8 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Image as BaseImage } from 'tiptap-extensions';
import { placeholderImage } from '~/lazy_loader';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
export default class Image extends BaseImage {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
index 0f56e89dca6..7204b7c09ba 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { ListItem as BaseListItem } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class ListItem extends BaseListItem {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
index 93d00f27868..5fd098cd46f 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Paragraph extends Node {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/playable.js b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
index 2b667aba2d6..90cbaf9ef4c 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/playable.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
@@ -1,8 +1,8 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable @gitlab/require-i18n-strings */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
/**
* Abstract base class for playable media, like video and audio.
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/text.js b/app/assets/javascripts/behaviors/markdown/nodes/text.js
index 4eab10c9d98..0dc77a12f5c 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/text.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/text.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
export default class Text extends Node {
get name() {
diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js
index b4adf1a413f..a5f97d7748a 100644
--- a/app/assets/javascripts/behaviors/markdown/serializer.js
+++ b/app/assets/javascripts/behaviors/markdown/serializer.js
@@ -1,4 +1,4 @@
-import { MarkdownSerializer } from 'prosemirror-markdown';
+import { MarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
import editorExtensions from './editor_extensions';
const nodes = editorExtensions
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index ac2a4184176..9297b14aac9 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -1,9 +1,9 @@
import $ from 'jquery';
-import Cookies from 'js-cookie';
import { flatten } from 'lodash';
import Mousetrap from 'mousetrap';
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
+
import findAndFollowLink from '~/lib/utils/navigation_utility';
import { refreshCurrentPage, visitUrl } from '~/lib/utils/url_utility';
import {
@@ -161,10 +161,10 @@ export default class Shortcuts {
static onTogglePerfBar(e) {
e.preventDefault();
const performanceBarCookieName = 'perf_bar_enabled';
- if (parseBoolean(Cookies.get(performanceBarCookieName))) {
- Cookies.set(performanceBarCookieName, 'false', { expires: 365, path: '/' });
+ if (parseBoolean(getCookie(performanceBarCookieName))) {
+ setCookie(performanceBarCookieName, 'false', { path: '/' });
} else {
- Cookies.set(performanceBarCookieName, 'true', { expires: 365, path: '/' });
+ setCookie(performanceBarCookieName, 'true', { path: '/' });
}
refreshCurrentPage();
}
@@ -172,8 +172,13 @@ export default class Shortcuts {
static onToggleCanary(e) {
e.preventDefault();
const canaryCookieName = 'gitlab_canary';
- const currentValue = parseBoolean(Cookies.get(canaryCookieName));
- Cookies.set(canaryCookieName, (!currentValue).toString(), { expires: 365, path: '/' });
+ const currentValue = parseBoolean(getCookie(canaryCookieName));
+ setCookie(canaryCookieName, (!currentValue).toString(), {
+ expires: 365,
+ path: '/',
+ // next.gitlab.com uses a leading period. See https://gitlab.com/gitlab-org/gitlab/-/issues/350186
+ domain: `.${window.location.hostname}`,
+ });
refreshCurrentPage();
}
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index 1645469a218..c5ab28e6ec5 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -42,6 +42,11 @@ export default {
required: false,
default: false,
},
+ showPath: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -55,6 +60,9 @@ export default {
showDefaultActions() {
return !this.hideDefaultActions;
},
+ isEmpty() {
+ return this.blob.rawSize === 0;
+ },
},
watch: {
viewer(newVal, oldVal) {
@@ -74,7 +82,7 @@ export default {
<div class="js-file-title file-title-flex-parent">
<div class="gl-display-flex">
<table-of-contents class="gl-pr-2" />
- <blob-filepath :blob="blob">
+ <blob-filepath :blob="blob" :show-path="showPath">
<template #filepath-prepend>
<slot name="prepend"></slot>
</template>
@@ -88,10 +96,13 @@ export default {
<default-actions
v-if="showDefaultActions"
- :raw-path="blob.rawPath"
+ :raw-path="blob.externalStorageUrl || blob.rawPath"
:active-viewer="viewer"
:has-render-error="hasRenderError"
:is-binary="isBinary"
+ :environment-name="blob.environmentFormattedExternalUrl"
+ :environment-path="blob.environmentExternalUrlForRouteMap"
+ :is-empty="isEmpty"
@copy="proxyCopyRequest"
/>
</div>
diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
index 2798a918b15..12bcb24b0cc 100644
--- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue
+++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
import {
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
@@ -37,6 +38,21 @@ export default {
required: false,
default: false,
},
+ environmentName: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ environmentPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ isEmpty: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
downloadUrl() {
@@ -51,6 +67,11 @@ export default {
showCopyButton() {
return !this.hasRenderError && !this.isBinary;
},
+ environmentTitle() {
+ return sprintf(s__('BlobViewer|View on %{environmentName}'), {
+ environmentName: this.environmentName,
+ });
+ },
},
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
@@ -71,6 +92,7 @@ export default {
icon="copy-to-clipboard"
category="primary"
variant="default"
+ class="js-copy-blob-source-btn"
/>
<gl-button
v-if="!isBinary"
@@ -84,6 +106,7 @@ export default {
variant="default"
/>
<gl-button
+ v-if="!isEmpty"
v-gl-tooltip.hover
:aria-label="$options.BTN_DOWNLOAD_TITLE"
:title="$options.BTN_DOWNLOAD_TITLE"
@@ -93,5 +116,17 @@ export default {
category="primary"
variant="default"
/>
+ <gl-button
+ v-if="environmentName && environmentPath"
+ v-gl-tooltip.hover
+ :aria-label="environmentTitle"
+ :title="environmentTitle"
+ :href="environmentPath"
+ data-testid="environment"
+ target="_blank"
+ icon="external-link"
+ category="primary"
+ variant="default"
+ />
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index 90d01358451..62355306655 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -15,6 +15,11 @@ export default {
type: Object,
required: true,
},
+ showPath: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
blobSize() {
@@ -26,6 +31,13 @@ export default {
showLfsBadge() {
return this.blob.storedExternally && this.blob.externalStorage === 'lfs';
},
+ fileName() {
+ if (this.showPath) {
+ return this.blob.path;
+ }
+
+ return this.blob.name;
+ },
},
};
</script>
@@ -33,12 +45,12 @@ export default {
<div class="file-header-content d-flex align-items-center lh-100">
<slot name="filepath-prepend"></slot>
- <template v-if="blob.path">
- <file-icon :file-name="blob.path" :size="16" aria-hidden="true" css-classes="mr-2" />
+ <template v-if="fileName">
+ <file-icon :file-name="fileName" :size="16" aria-hidden="true" css-classes="mr-2" />
<strong
class="file-title-name mr-1 js-blob-header-filepath"
data-qa-selector="file_title_content"
- >{{ blob.path }}</strong
+ >{{ fileName }}</strong
>
</template>
diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js
index a129c537fa5..adac4d6408d 100644
--- a/app/assets/javascripts/blob/components/constants.js
+++ b/app/assets/javascripts/blob/components/constants.js
@@ -42,7 +42,7 @@ export const BLOB_RENDER_ERRORS = {
id: 'load',
text: __('load it anyway'),
conjunction: __('or'),
- href: '#',
+ href: '?expanded=true&viewer=simple',
target: '',
event: BLOB_RENDER_EVENT_LOAD,
},
diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
index 47a0c4ba2d1..b4ca29114cb 100644
--- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
+++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui';
-import Cookies from 'js-cookie';
+import { getCookie, removeCookie } from '~/lib/utils/common_utils';
import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
@@ -62,7 +62,7 @@ export default {
return this.commitCookiePath || this.projectMergeRequestsPath;
},
commitCookiePath() {
- const cookieVal = Cookies.get(this.commitCookie);
+ const cookieVal = getCookie(this.commitCookie);
if (cookieVal !== 'true') return cookieVal;
return '';
@@ -85,7 +85,7 @@ export default {
},
methods: {
disableModalFromRenderingAgain() {
- Cookies.remove(this.commitCookie);
+ removeCookie(this.commitCookie);
},
},
};
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index ea80496c3f5..aee61a5b2a5 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -11,12 +11,10 @@ import { sortBy } from 'lodash';
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';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import { ListType } from '../constants';
-import eventHub from '../eventhub';
import BoardBlockedIcon from './board_blocked_icon.vue';
import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate.vue';
@@ -176,18 +174,10 @@ export default {
)
);
},
- filterByLabel(label) {
- if (!this.updateFilters) return;
+ labelTarget(label) {
const filterPath = window.location.search ? `${window.location.search}&` : '?';
- const filter = `label_name[]=${encodeURIComponent(label.title)}`;
-
- if (!filterPath.includes(filter)) {
- updateHistory({
- url: `${filterPath}${filter}`,
- });
- this.performSearch();
- eventHub.$emit('updateTokens');
- }
+ const value = encodeURIComponent(label.title);
+ return `${filterPath}label_name[]=${value}`;
},
showScopedLabel(label) {
return this.scopedLabelsAvailable && isScopedLabel(label);
@@ -242,7 +232,7 @@ export default {
:description="label.description"
size="sm"
:scoped="showScopedLabel(label)"
- @click="filterByLabel(label)"
+ :target="labelTarget(label)"
/>
</template>
</div>
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 156029b62b0..0320b4d925e 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -184,29 +184,15 @@ export default {
:issuable-type="issuableType"
data-testid="sidebar-milestones"
/>
- <template v-if="!glFeatures.iterationCadences">
- <sidebar-dropdown-widget
- v-if="iterationFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
- issuable-attribute="iteration"
- :workspace-path="projectPathForActiveIssue"
- :attr-workspace-path="groupPathForActiveIssue"
- :issuable-type="issuableType"
- class="gl-mt-5"
- data-testid="iteration-edit"
- />
- </template>
- <template v-else>
- <iteration-sidebar-dropdown-widget
- v-if="iterationFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
- :workspace-path="projectPathForActiveIssue"
- :attr-workspace-path="groupPathForActiveIssue"
- :issuable-type="issuableType"
- class="gl-mt-5"
- data-testid="iteration-edit"
- />
- </template>
+ <iteration-sidebar-dropdown-widget
+ v-if="iterationFeatureAvailable && !isIncidentSidebar"
+ :iid="activeBoardItem.iid"
+ :workspace-path="projectPathForActiveIssue"
+ :attr-workspace-path="groupPathForActiveIssue"
+ :issuable-type="issuableType"
+ class="gl-mt-5"
+ data-testid="iteration-edit"
+ />
</div>
<board-sidebar-time-tracker />
<sidebar-date-widget
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 2599d1c80b8..45192b5304a 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -1,5 +1,5 @@
<script>
-import { pickBy, isEmpty } from 'lodash';
+import { pickBy, isEmpty, mapValues } from 'lodash';
import { mapActions } from 'vuex';
import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
@@ -251,22 +251,36 @@ export default {
);
}
- return {
- ...notParams,
- author_username: authorUsername,
- 'label_name[]': labelName,
- assignee_username: assigneeUsername,
- assignee_id: assigneeId,
- milestone_title: milestoneTitle,
- iteration_id: iterationId,
- search,
- types,
- weight,
- epic_id: isGid(epicId) ? getIdFromGraphQLId(epicId) : epicId,
- my_reaction_emoji: myReactionEmoji,
- release_tag: releaseTag,
- confidential,
- };
+ return mapValues(
+ {
+ ...notParams,
+ author_username: authorUsername,
+ 'label_name[]': labelName,
+ assignee_username: assigneeUsername,
+ assignee_id: assigneeId,
+ milestone_title: milestoneTitle,
+ iteration_id: iterationId,
+ search,
+ types,
+ weight,
+ epic_id: isGid(epicId) ? getIdFromGraphQLId(epicId) : epicId,
+ my_reaction_emoji: myReactionEmoji,
+ release_tag: releaseTag,
+ confidential,
+ },
+ (value) => {
+ if (value || value === false) {
+ // note: need to check array for labels.
+ if (Array.isArray(value)) {
+ return value.map((valueItem) => encodeURIComponent(valueItem));
+ }
+
+ return encodeURIComponent(value);
+ }
+
+ return value;
+ },
+ );
},
},
created() {
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 6ad57fd8985..cc048e2af1a 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -98,9 +98,6 @@ export default {
return this.$options.i18n[this.currentPage].btnText;
},
buttonKind() {
- if (this.isNewForm) {
- return 'success';
- }
if (this.isDeleteForm) {
return 'danger';
}
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index e4c3c3206a8..1024be61359 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -60,6 +60,9 @@ export default {
filters: this.filterParams,
};
},
+ skip() {
+ return this.isEpicBoard;
+ },
},
},
computed: {
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index 84c9191975e..8db366e4995 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -25,7 +25,7 @@ export default {
},
computed: {
...mapState(['selectedProject', 'fullPath']),
- ...mapGetters(['isGroupBoard']),
+ ...mapGetters(['isGroupBoard', 'getBoardItemsByList']),
formEventPrefix() {
return toggleFormEventPrefix.issue;
},
@@ -42,6 +42,7 @@ export default {
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = getMilestone(this.list);
+ const firstItemId = this.getBoardItemsByList(this.list.id)[0]?.id;
return this.addListNewIssue({
list: this.list,
@@ -51,6 +52,7 @@ export default {
assigneeIds: assignees?.map((a) => a?.id),
milestoneId: milestone?.id,
projectPath: this.projectPath,
+ moveAfterId: firstItemId,
},
}).then(() => {
this.cancel();
diff --git a/app/assets/javascripts/boards/components/board_new_item.vue b/app/assets/javascripts/boards/components/board_new_item.vue
index 44574de17d7..600917683cd 100644
--- a/app/assets/javascripts/boards/components/board_new_item.vue
+++ b/app/assets/javascripts/boards/components/board_new_item.vue
@@ -43,6 +43,12 @@ export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${this.list.id}-title`;
},
+ isIssueTitleEmpty() {
+ return this.title.trim() === '';
+ },
+ isCreatingIssueDisabled() {
+ return this.isIssueTitleEmpty || this.disableSubmit;
+ },
},
methods: {
handleFormCancel() {
@@ -54,7 +60,7 @@ export default {
eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.$emit('form-submit', {
- title,
+ title: title.trim(),
list,
});
},
@@ -69,7 +75,7 @@ export default {
<label :for="inputFieldId" class="gl-font-weight-bold">{{ __('Title') }}</label>
<gl-form-input
:id="inputFieldId"
- v-model.trim="title"
+ v-model="title"
:autofocus="true"
autocomplete="off"
type="text"
@@ -78,7 +84,8 @@ export default {
<slot></slot>
<div class="gl-clearfix gl-mt-4">
<gl-button
- :disabled="!title || disableSubmit"
+ data-testid="create-button"
+ :disabled="isCreatingIssueDisabled"
class="gl-float-left js-no-auto-disable"
variant="confirm"
type="submit"
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 6b7c08d05a5..24071c6f0b4 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlDrawer, GlLabel } from '@gitlab/ui';
+import { GlButton, GlDrawer, GlLabel, GlModal, GlModalDirective } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import { LIST, ListType, ListTypeTitles } from '~/boards/constants';
@@ -11,8 +11,14 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
listSettingsText: __('List settings'),
+ i18n: {
+ modalAction: __('Remove list'),
+ modalCopy: __('Are you sure you want to remove this list?'),
+ modalCancel: __('Cancel'),
+ },
components: {
GlButton,
+ GlModal,
GlDrawer,
GlLabel,
MountingPortal,
@@ -21,6 +27,9 @@ export default {
BoardSettingsListTypes: () =>
import('ee_component/boards/components/board_settings_list_types.vue'),
},
+ directives: {
+ GlModal: GlModalDirective,
+ },
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
inject: ['canAdminList', 'scopedLabelsAvailable'],
inheritAttrs: false,
@@ -29,6 +38,7 @@ export default {
ListType,
};
},
+ modalId: 'board-settings-sidebar-modal',
computed: {
...mapGetters(['isSidebarOpen', 'isEpicBoard']),
...mapState(['activeId', 'sidebarType', 'boardLists']),
@@ -59,16 +69,16 @@ export default {
},
methods: {
...mapActions(['unsetActiveId', 'removeList']),
+ handleModalPrimary() {
+ this.deleteBoard();
+ },
showScopedLabels(label) {
return this.scopedLabelsAvailable && isScopedLabel(label);
},
deleteBoard() {
- // eslint-disable-next-line no-alert
- if (window.confirm(__('Are you sure you want to remove this list?'))) {
- this.track('click_button', { label: 'remove_list' });
- this.removeList(this.activeId);
- this.unsetActiveId();
- }
+ this.track('click_button', { label: 'remove_list' });
+ this.removeList(this.activeId);
+ this.unsetActiveId();
},
},
};
@@ -92,11 +102,10 @@ export default {
<template #header>
<div v-if="canAdminList && activeList.id" class="gl-mt-3">
<gl-button
+ v-gl-modal="$options.modalId"
variant="danger"
category="secondary"
size="small"
- data-testid="remove-list"
- @click.stop="deleteBoard"
>{{ __('Remove list') }}
</gl-button>
</div>
@@ -122,5 +131,21 @@ export default {
/>
</template>
</gl-drawer>
+ <gl-modal
+ :modal-id="$options.modalId"
+ :title="$options.i18n.modalAction"
+ size="sm"
+ :action-primary="{
+ text: $options.i18n.modalAction,
+ attributes: [{ variant: 'danger' }],
+ }"
+ :action-secondary="{
+ text: $options.i18n.modalCancel,
+ attributes: [{ variant: 'default' }],
+ }"
+ @primary="handleModalPrimary"
+ >
+ <p>{{ $options.i18n.modalCopy }}</p>
+ </gl-modal>
</mounting-portal>
</template>
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 69343cd78d8..6dbb1ea0050 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -14,8 +14,6 @@ import { mapActions, 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';
-import httpStatusCodes from '~/lib/utils/http_status';
import { s__ } from '~/locale';
import eventHub from '../eventhub';
@@ -23,6 +21,8 @@ import groupBoardsQuery from '../graphql/group_boards.query.graphql';
import projectBoardsQuery from '../graphql/project_boards.query.graphql';
import groupBoardQuery from '../graphql/group_board.query.graphql';
import projectBoardQuery from '../graphql/project_board.query.graphql';
+import groupRecentBoardsQuery from '../graphql/group_recent_boards.query.graphql';
+import projectRecentBoardsQuery from '../graphql/project_recent_boards.query.graphql';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
@@ -40,7 +40,7 @@ export default {
directives: {
GlModalDirective,
},
- inject: ['fullPath', 'recentBoardsEndpoint'],
+ inject: ['fullPath'],
props: {
throttleDuration: {
type: Number,
@@ -158,6 +158,10 @@ export default {
this.scrollFadeInitialized = false;
this.$nextTick(this.setScrollFade);
},
+ recentBoards() {
+ this.scrollFadeInitialized = false;
+ this.$nextTick(this.setScrollFade);
+ },
},
created() {
eventHub.$on('showBoardModal', this.showPage);
@@ -173,11 +177,11 @@ export default {
cancel() {
this.showPage('');
},
- boardUpdate(data) {
+ boardUpdate(data, boardType) {
if (!data?.[this.parentType]) {
return [];
}
- return data[this.parentType].boards.edges.map(({ node }) => ({
+ return data[this.parentType][boardType].edges.map(({ node }) => ({
id: getIdFromGraphQLId(node.id),
name: node.name,
}));
@@ -185,6 +189,9 @@ export default {
boardQuery() {
return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery;
},
+ recentBoardsQuery() {
+ return this.isGroupBoard ? groupRecentBoardsQuery : projectRecentBoardsQuery;
+ },
loadBoards(toggleDropdown = true) {
if (toggleDropdown && this.boards.length > 0) {
return;
@@ -196,39 +203,20 @@ export default {
},
query: this.boardQuery,
loadingKey: 'loadingBoards',
- update: this.boardUpdate,
+ update: (data) => this.boardUpdate(data, 'boards'),
});
this.loadRecentBoards();
},
loadRecentBoards() {
- this.loadingRecentBoards = true;
- // Follow up to fetch recent boards using GraphQL
- // https://gitlab.com/gitlab-org/gitlab/-/issues/300985
- axios
- .get(this.recentBoardsEndpoint)
- .then((res) => {
- this.recentBoards = res.data;
- })
- .catch((err) => {
- /**
- * If user is unauthorized we'd still want to resolve the
- * request to display all boards.
- */
- if (err?.response?.status === httpStatusCodes.UNAUTHORIZED) {
- this.recentBoards = []; // recent boards are empty
- return;
- }
- throw err;
- })
- .then(() => this.$nextTick()) // Wait for boards list in DOM
- .then(() => {
- this.setScrollFade();
- })
- .catch(() => {})
- .finally(() => {
- this.loadingRecentBoards = false;
- });
+ this.$apollo.addSmartQuery('recentBoards', {
+ variables() {
+ return { fullPath: this.fullPath };
+ },
+ query: this.recentBoardsQuery,
+ loadingKey: 'loadingRecentBoards',
+ update: (data) => this.boardUpdate(data, 'recentIssueBoards'),
+ });
},
isScrolledUp() {
const { content } = this.$refs;
diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
index 7fc87f9f672..6bfdbb674a2 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -157,6 +157,7 @@ export default {
symbol: '%',
token: MilestoneToken,
unique: true,
+ shouldSkipSort: true,
fetchMilestones: this.fetchMilestones,
},
{
diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js
index 945a508c55d..1e54c2511b8 100644
--- a/app/assets/javascripts/boards/config_toggle.js
+++ b/app/assets/javascripts/boards/config_toggle.js
@@ -12,6 +12,7 @@ export default () => {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'ConfigToggleRoot',
render(h) {
return h(ConfigToggle, {
props: {
diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js
index 64938cb42ed..95863d4d5ac 100644
--- a/app/assets/javascripts/boards/graphql.js
+++ b/app/assets/javascripts/boards/graphql.js
@@ -1,10 +1,5 @@
-import { IntrospectionFragmentMatcher, defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import { defaultDataIdFromObject } from '@apollo/client/core';
import createDefaultClient from '~/lib/graphql';
-import introspectionQueryResultData from '~/sidebar/fragmentTypes.json';
-
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
export const gqlClient = createDefaultClient(
{},
@@ -14,8 +9,6 @@ export const gqlClient = createDefaultClient(
// eslint-disable-next-line no-underscore-dangle
return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object);
},
-
- fragmentMatcher,
},
},
);
diff --git a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
index 6fe8bb799d6..9e6c26063e9 100644
--- a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
@@ -1,7 +1,12 @@
query GroupBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) {
group(fullPath: $fullPath) {
id
- milestones(includeAncestors: true, searchTitle: $searchTerm, state: $state) {
+ milestones(
+ includeAncestors: true
+ searchTitle: $searchTerm
+ state: $state
+ sort: EXPIRED_LAST_DUE_DATE_ASC
+ ) {
nodes {
id
title
diff --git a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
new file mode 100644
index 00000000000..827c08486b1
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
@@ -0,0 +1,14 @@
+#import "ee_else_ce/boards/graphql/board.fragment.graphql"
+
+query group_recent_boards($fullPath: ID!) {
+ group(fullPath: $fullPath) {
+ id
+ recentIssueBoards {
+ edges {
+ node {
+ ...BoardFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
index d917c7e809d..02aa08f90ef 100644
--- a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
@@ -1,7 +1,12 @@
query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) {
project(fullPath: $fullPath) {
id
- milestones(searchTitle: $searchTerm, includeAncestors: true, state: $state) {
+ milestones(
+ searchTitle: $searchTerm
+ includeAncestors: true
+ state: $state
+ sort: EXPIRED_LAST_DUE_DATE_ASC
+ ) {
nodes {
id
title
diff --git a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
new file mode 100644
index 00000000000..4d38e9b0498
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
@@ -0,0 +1,14 @@
+#import "ee_else_ce/boards/graphql/board.fragment.graphql"
+
+query project_recent_boards($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ recentIssueBoards {
+ edges {
+ node {
+ ...BoardFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index ded3bfded86..f6073f9d981 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -64,6 +64,7 @@ function mountBoardApp(el) {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'BoardAppRoot',
store,
apolloProvider,
provide: {
@@ -121,6 +122,7 @@ export default () => {
// eslint-disable-next-line no-new
new Vue({
el: createColumnTriggerEl,
+ name: 'BoardAddNewColumnTriggerRoot',
components: {
BoardAddNewColumnTrigger,
},
@@ -144,7 +146,6 @@ export default () => {
mountMultipleBoardsSwitcher({
fullPath: $boardApp.dataset.fullPath,
rootPath: $boardApp.dataset.boardsEndpoint,
- recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
allowScopedLabels: $boardApp.dataset.scopedLabels,
labelsManagePath: $boardApp.dataset.labelsManagePath,
});
diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
index a8ade58e316..327fb9ba8d7 100644
--- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
+++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
@@ -18,6 +18,7 @@ export default (apolloProvider, isSignedIn, releasesFetchPath) => {
return new Vue({
el,
+ name: 'BoardFilteredSearchRoot',
provide: {
initialFilterParams,
isSignedIn,
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
index ed32579a9c3..0bc9cfbd867 100644
--- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
+++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
@@ -1,27 +1,14 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue';
import store from '~/boards/stores';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
-import introspectionQueryResultData from '~/sidebar/fragmentTypes.json';
Vue.use(VueApollo);
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- cacheConfig: {
- fragmentMatcher,
- },
- },
- ),
+ defaultClient: createDefaultClient(),
});
export default (params = {}) => {
@@ -29,6 +16,7 @@ export default (params = {}) => {
const { dataset } = boardsSwitcherElement;
return new Vue({
el: boardsSwitcherElement,
+ name: 'BoardsSelectorRoot',
components: {
BoardsSelector,
},
@@ -37,7 +25,6 @@ export default (params = {}) => {
provide: {
fullPath: params.fullPath,
rootPath: params.rootPath,
- recentBoardsEndpoint: params.recentBoardsEndpoint,
allowScopedLabels: params.allowScopedLabels,
labelsManagePath: params.labelsManagePath,
allowLabelCreate: parseBoolean(dataset.canAdminBoard),
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 48ca3239cfd..1ebfcfc331b 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -15,7 +15,6 @@ import {
FilterFields,
ListTypeTitles,
DraggableItemTypes,
- active,
} from 'ee_else_ce/boards/constants';
import {
formatIssueInput,
@@ -210,7 +209,6 @@ export default {
const variables = {
fullPath,
searchTerm,
- state: active,
};
let query;
diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js
index 0a230f72dcc..8f057e192dd 100644
--- a/app/assets/javascripts/boards/toggle_focus.js
+++ b/app/assets/javascripts/boards/toggle_focus.js
@@ -6,6 +6,7 @@ export default () => {
return new Vue({
el: '#js-toggle-focus-btn',
+ name: 'ToggleFocusRoot',
render(h) {
return h(ToggleFocus, {
props: {
diff --git a/app/assets/javascripts/broadcast_notification.js b/app/assets/javascripts/broadcast_notification.js
index 2cf2e922f68..34282c6932e 100644
--- a/app/assets/javascripts/broadcast_notification.js
+++ b/app/assets/javascripts/broadcast_notification.js
@@ -1,4 +1,4 @@
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
const handleOnDismiss = ({ currentTarget }) => {
currentTarget.removeEventListener('click', handleOnDismiss);
@@ -6,7 +6,7 @@ const handleOnDismiss = ({ currentTarget }) => {
dataset: { id, expireDate },
} = currentTarget;
- Cookies.set(`hide_broadcast_message_${id}`, true, { expires: new Date(expireDate) });
+ setCookie(`hide_broadcast_message_${id}`, true, { expires: new Date(expireDate) });
const notification = document.querySelector(`.js-broadcast-notification-${id}`);
notification.parentNode.removeChild(notification);
diff --git a/app/assets/javascripts/captcha/apollo_captcha_link.js b/app/assets/javascripts/captcha/apollo_captcha_link.js
index e49abc10b29..d63ffaf5f1a 100644
--- a/app/assets/javascripts/captcha/apollo_captcha_link.js
+++ b/app/assets/javascripts/captcha/apollo_captcha_link.js
@@ -1,4 +1,4 @@
-import { ApolloLink, Observable } from 'apollo-link';
+import { ApolloLink, Observable } from '@apollo/client/core';
export const apolloCaptchaLink = new ApolloLink((operation, forward) =>
forward(operation).flatMap((result) => {
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 e630ce71bd3..2e198c59926 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
@@ -14,8 +14,8 @@ import {
GlModal,
GlSprintf,
} from '@gitlab/ui';
-import Cookies from 'js-cookie';
import { mapActions, mapState } from 'vuex';
+import { getCookie, setCookie } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import Tracking from '~/tracking';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -59,7 +59,7 @@ export default {
mixins: [glFeatureFlagsMixin(), trackingMixin],
data() {
return {
- isTipDismissed: Cookies.get(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
+ isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
validationErrorEventProperty: '',
};
},
@@ -176,7 +176,7 @@ export default {
'setVariableProtected',
]),
dismissTip() {
- Cookies.set(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 });
+ setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 });
this.isTipDismissed = true;
},
deleteVarAndClose() {
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 9c0ffab7f6b..61636b389da 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
@@ -3,6 +3,7 @@ import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from
import { mapState, mapActions } from 'vuex';
import { s__, __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
import CiVariablePopover from './ci_variable_popover.vue';
@@ -52,10 +53,11 @@ export default {
},
],
components: {
- GlTable,
+ CiVariablePopover,
GlButton,
GlIcon,
- CiVariablePopover,
+ GlTable,
+ TooltipOnTruncate,
},
directives: {
GlModalDirective,
@@ -67,8 +69,8 @@ export default {
valuesButtonText() {
return this.valuesHidden ? __('Reveal values') : __('Hide values');
},
- tableIsNotEmpty() {
- return this.variables && this.variables.length > 0;
+ isTableEmpty() {
+ return !this.variables || this.variables.length === 0;
},
fields() {
return this.$options.fields;
@@ -103,12 +105,14 @@ export default {
<col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
</template>
<template #cell(key)="{ item }">
- <div class="gl-display-flex truncated-container gl-align-items-center">
- <span
- :id="`ci-variable-key-${item.id}`"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate"
- >{{ item.key }}</span
- >
+ <div class="gl-display-flex gl-align-items-center">
+ <tooltip-on-truncate :title="item.key" truncate-target="child">
+ <span
+ :id="`ci-variable-key-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ >{{ item.key }}</span
+ >
+ </tooltip-on-truncate>
<gl-button
v-gl-tooltip
category="tertiary"
@@ -120,7 +124,7 @@ export default {
</div>
</template>
<template #cell(value)="{ item }">
- <div class="gl-display-flex gl-align-items-center truncated-container">
+ <div class="gl-display-flex gl-align-items-center">
<span v-if="valuesHidden">*********************</span>
<span
v-else
@@ -147,10 +151,12 @@ export default {
<gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" />
</template>
<template #cell(environment_scope)="{ item }">
- <div class="d-flex truncated-container">
- <span :id="`ci-variable-env-${item.id}`" class="d-inline-block mw-100 text-truncate">{{
- item.environment_scope
- }}</span>
+ <div class="gl-display-flex">
+ <span
+ :id="`ci-variable-env-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ >{{ item.environment_scope }}</span
+ >
<ci-variable-popover
:target="`ci-variable-env-${item.id}`"
:value="item.environment_scope"
@@ -160,7 +166,6 @@ export default {
</template>
<template #cell(actions)="{ item }">
<gl-button
- ref="edit-ci-variable"
v-gl-modal-directive="$options.modalId"
icon="pencil"
:aria-label="__('Edit')"
@@ -169,17 +174,16 @@ export default {
/>
</template>
<template #empty>
- <p ref="empty-variables" class="text-center empty-variables text-plain">
+ <p class="gl-text-center gl-py-6 gl-text-black-normal gl-mb-0">
{{ __('There are no variables yet.') }}
</p>
</template>
</gl-table>
<div
class="ci-variable-actions gl-display-flex"
- :class="{ 'justify-content-center': !tableIsNotEmpty }"
+ :class="{ 'gl-justify-content-center': isTableEmpty }"
>
<gl-button
- ref="add-ci-variable"
v-gl-modal-directive="$options.modalId"
class="gl-mr-3"
data-qa-selector="add_ci_variable_button"
@@ -188,8 +192,7 @@ export default {
>{{ __('Add variable') }}</gl-button
>
<gl-button
- v-if="tableIsNotEmpty"
- ref="secret-value-reveal-button"
+ v-if="!isTableEmpty"
data-qa-selector="reveal_ci_variable_value_button"
@click="toggleValues(!valuesHidden)"
>{{ valuesButtonText }}</gl-button
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index a53bba6992d..63f068a9327 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -10,7 +10,7 @@ import {
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { MAX_LIST_COUNT } from '../constants';
+import { MAX_LIST_COUNT, TOKEN_STATUS_ACTIVE } from '../constants';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
import TokenTable from './token_table.vue';
import ActivityEvents from './activity_events_list.vue';
@@ -30,6 +30,7 @@ export default {
return {
agentName: this.agentName,
projectPath: this.projectPath,
+ tokenStatus: TOKEN_STATUS_ACTIVE,
...this.cursor,
};
},
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
index 315c7662755..98d4707b4de 100644
--- a/app/assets/javascripts/clusters/agents/constants.js
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -36,3 +36,4 @@ export const EVENT_DETAILS = {
};
export const DEFAULT_ICON = 'token';
+export const TOKEN_STATUS_ACTIVE = 'ACTIVE';
diff --git a/app/assets/javascripts/clusters/agents/graphql/provider.js b/app/assets/javascripts/clusters/agents/graphql/provider.js
index 8b068fa1eee..9153c5252b3 100644
--- a/app/assets/javascripts/clusters/agents/graphql/provider.js
+++ b/app/assets/javascripts/clusters/agents/graphql/provider.js
@@ -1,25 +1,10 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { vulnerabilityLocationTypes } from '~/graphql_shared/fragment_types/vulnerability_location_types';
Vue.use(VueApollo);
-// We create a fragment matcher so that we can create a fragment from an interface
-// Without this, Apollo throws a heuristic fragment matcher warning
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData: vulnerabilityLocationTypes,
-});
-
-const defaultClient = createDefaultClient(
- {},
- {
- cacheConfig: {
- fragmentMatcher,
- },
- },
-);
+const defaultClient = createDefaultClient();
export default new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
index 3662e925261..3610662afc0 100644
--- a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
+++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
@@ -4,6 +4,7 @@
query getClusterAgent(
$projectPath: ID!
$agentName: String!
+ $tokenStatus: AgentTokenStatus!
$first: Int
$last: Int
$afterToken: String
@@ -20,7 +21,13 @@ query getClusterAgent(
name
}
- tokens(first: $first, last: $last, before: $beforeToken, after: $afterToken) {
+ tokens(
+ status: $tokenStatus
+ first: $first
+ last: $last
+ before: $beforeToken
+ after: $afterToken
+ ) {
count
nodes {
diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js
index 6c7fae274f8..ba7b3edba72 100644
--- a/app/assets/javascripts/clusters/agents/index.js
+++ b/app/assets/javascripts/clusters/agents/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import AgentShowPage from 'ee_else_ce/clusters/agents/components/show.vue';
import apolloProvider from './graphql/provider';
+import createRouter from './router';
export default () => {
const el = document.querySelector('#js-cluster-agent-details');
@@ -9,14 +10,22 @@ export default () => {
return null;
}
- const { activityEmptyStateImage, agentName, emptyStateSvgPath, projectPath } = el.dataset;
+ const {
+ activityEmptyStateImage,
+ agentName,
+ canAdminVulnerability,
+ emptyStateSvgPath,
+ projectPath,
+ } = el.dataset;
return new Vue({
el,
apolloProvider,
+ router: createRouter(),
provide: {
activityEmptyStateImage,
agentName,
+ canAdminVulnerability,
emptyStateSvgPath,
projectPath,
},
diff --git a/app/assets/javascripts/clusters/agents/router.js b/app/assets/javascripts/clusters/agents/router.js
new file mode 100644
index 00000000000..162a91dc300
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/router.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+
+Vue.use(VueRouter);
+
+// Vue Router requires a component to render if the route matches, but since we're only using it for
+// querystring handling, we'll create an empty component.
+const EmptyRouterComponent = {
+ render(createElement) {
+ return createElement('div');
+ },
+};
+
+export default () => {
+ // Name and path here don't really matter since we're not rendering anything if the route matches.
+ const routes = [{ path: '/', name: 'cluster_agents', component: EmptyRouterComponent }];
+ return new VueRouter({
+ mode: 'history',
+ base: window.location.pathname,
+ routes,
+ });
+};
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index 695e16b7b4b..61c4904aacf 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -1,23 +1,14 @@
<script>
import { GlLink, GlTable, GlIcon, GlSprintf, GlTooltip, GlPopover } from '@gitlab/ui';
-import { s__, __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { AGENT_STATUSES } from '../constants';
+import { AGENT_STATUSES, I18N_AGENT_TABLE } from '../constants';
import { getAgentConfigPath } from '../clusters_util';
-import AgentOptions from './agent_options.vue';
+import DeleteAgentButton from './delete_agent_button.vue';
export default {
- i18n: {
- nameLabel: s__('ClusterAgents|Name'),
- statusLabel: s__('ClusterAgents|Connection status'),
- lastContactLabel: s__('ClusterAgents|Last contact'),
- configurationLabel: s__('ClusterAgents|Configuration'),
- optionsLabel: __('Options'),
- troubleshootingText: s__('ClusterAgents|Learn how to troubleshoot'),
- neverConnectedText: s__('ClusterAgents|Never'),
- },
+ i18n: I18N_AGENT_TABLE,
components: {
GlLink,
GlTable,
@@ -26,13 +17,15 @@ export default {
GlTooltip,
GlPopover,
TimeAgoTooltip,
- AgentOptions,
+ DeleteAgentButton,
},
mixins: [timeagoMixin],
AGENT_STATUSES,
- troubleshooting_link: helpPagePath('user/clusters/agent/index', {
- anchor: 'troubleshooting',
+ troubleshootingLink: helpPagePath('user/clusters/agent/troubleshooting'),
+ versionUpdateLink: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'update-the-agent-version',
}),
+ inject: ['gitlabVersion'],
props: {
agents: {
required: true,
@@ -69,30 +62,93 @@ export default {
tdClass,
},
{
+ key: 'version',
+ label: this.$options.i18n.versionLabel,
+ tdClass,
+ },
+ {
key: 'configuration',
label: this.$options.i18n.configurationLabel,
tdClass,
},
{
key: 'options',
- label: this.$options.i18n.optionsLabel,
+ label: '',
tdClass,
},
];
},
+ agentsList() {
+ if (!this.agents.length) {
+ return [];
+ }
+
+ return this.agents.map((agent) => {
+ const versions = this.getAgentVersions(agent);
+ return { ...agent, versions };
+ });
+ },
},
methods: {
- getCellId(item) {
+ getStatusCellId(item) {
return `connection-status-${item.name}`;
},
+ getVersionCellId(item) {
+ return `version-${item.name}`;
+ },
+ getPopoverTestId(item) {
+ return `popover-${item.name}`;
+ },
getAgentConfigPath,
+ getAgentVersions(agent) {
+ const agentConnections = agent.connections?.nodes || [];
+
+ const agentVersions = agentConnections.map((agentConnection) =>
+ agentConnection.metadata.version.replace('v', ''),
+ );
+
+ const uniqueAgentVersions = [...new Set(agentVersions)];
+
+ return uniqueAgentVersions.sort((a, b) => a.localeCompare(b));
+ },
+ getAgentVersionString(agent) {
+ return agent.versions[0] || '';
+ },
+ isVersionMismatch(agent) {
+ return agent.versions.length > 1;
+ },
+ isVersionOutdated(agent) {
+ if (!agent.versions.length) return false;
+
+ const [agentMajorVersion, agentMinorVersion] = this.getAgentVersionString(agent).split('.');
+ const [gitlabMajorVersion, gitlabMinorVersion] = this.gitlabVersion.split('.');
+
+ const majorVersionMismatch = agentMajorVersion !== gitlabMajorVersion;
+
+ // We should warn user if their current GitLab and agent versions are more than 1 minor version apart:
+ const minorVersionMismatch = Math.abs(agentMinorVersion - gitlabMinorVersion) > 1;
+
+ return majorVersionMismatch || minorVersionMismatch;
+ },
+
+ getVersionPopoverTitle(agent) {
+ if (this.isVersionMismatch(agent) && this.isVersionOutdated(agent)) {
+ return this.$options.i18n.versionMismatchOutdatedTitle;
+ } else if (this.isVersionMismatch(agent)) {
+ return this.$options.i18n.versionMismatchTitle;
+ } else if (this.isVersionOutdated(agent)) {
+ return this.$options.i18n.versionOutdatedTitle;
+ }
+
+ return null;
+ },
},
};
</script>
<template>
<gl-table
- :items="agents"
+ :items="agentsList"
:fields="fields"
stacked="md"
head-variant="white"
@@ -107,19 +163,23 @@ export default {
</template>
<template #cell(status)="{ item }">
- <span :id="getCellId(item)" class="gl-md-pr-5" data-testid="cluster-agent-connection-status">
+ <span
+ :id="getStatusCellId(item)"
+ class="gl-md-pr-5"
+ data-testid="cluster-agent-connection-status"
+ >
<span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3">
<gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span
>{{ $options.AGENT_STATUSES[item.status].name }}
</span>
- <gl-tooltip v-if="item.status === 'active'" :target="getCellId(item)" placement="right">
+ <gl-tooltip v-if="item.status === 'active'" :target="getStatusCellId(item)" placement="right">
<gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title"
><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template>
</gl-sprintf>
</gl-tooltip>
<gl-popover
v-else
- :target="getCellId(item)"
+ :target="getStatusCellId(item)"
:title="$options.AGENT_STATUSES[item.status].tooltip.title"
placement="right"
container="viewport"
@@ -130,7 +190,7 @@ export default {
>
</p>
<p class="gl-mb-0">
- <gl-link :href="$options.troubleshooting_link" target="_blank" class="gl-font-sm">
+ <gl-link :href="$options.troubleshootingLink" target="_blank" class="gl-font-sm">
{{ $options.i18n.troubleshootingText }}</gl-link
>
</p>
@@ -144,6 +204,52 @@ export default {
</span>
</template>
+ <template #cell(version)="{ item }">
+ <span :id="getVersionCellId(item)" data-testid="cluster-agent-version">
+ {{ getAgentVersionString(item) }}
+
+ <gl-icon
+ v-if="isVersionMismatch(item) || isVersionOutdated(item)"
+ name="warning"
+ class="gl-text-orange-500 gl-ml-2"
+ />
+ </span>
+
+ <gl-popover
+ v-if="isVersionMismatch(item) || isVersionOutdated(item)"
+ :target="getVersionCellId(item)"
+ :title="getVersionPopoverTitle(item)"
+ :data-testid="getPopoverTestId(item)"
+ placement="right"
+ container="viewport"
+ >
+ <div v-if="isVersionMismatch(item) && isVersionOutdated(item)">
+ <p>{{ $options.i18n.versionMismatchText }}</p>
+
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.versionOutdatedText">
+ <template #version>{{ gitlabVersion }}</template>
+ </gl-sprintf>
+ <gl-link :href="$options.versionUpdateLink" class="gl-font-sm">
+ {{ $options.i18n.viewDocsText }}</gl-link
+ >
+ </p>
+ </div>
+ <p v-else-if="isVersionMismatch(item)" class="gl-mb-0">
+ {{ $options.i18n.versionMismatchText }}
+ </p>
+
+ <p v-else-if="isVersionOutdated(item)" class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.versionOutdatedText">
+ <template #version>{{ gitlabVersion }}</template>
+ </gl-sprintf>
+ <gl-link :href="$options.versionUpdateLink" class="gl-font-sm">
+ {{ $options.i18n.viewDocsText }}</gl-link
+ >
+ </p>
+ </gl-popover>
+ </template>
+
<template #cell(configuration)="{ item }">
<span data-testid="cluster-agent-configuration-link">
<gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
@@ -155,7 +261,7 @@ export default {
</template>
<template #cell(options)="{ item }">
- <agent-options
+ <delete-agent-button
:agent="item"
:default-branch-name="defaultBranchName"
:max-agents="maxAgents"
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
index 4fc421e7c31..bf096f53e9d 100644
--- a/app/assets/javascripts/clusters_list/components/agents.vue
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -1,11 +1,29 @@
<script>
-import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui';
-import { MAX_LIST_COUNT, ACTIVE_CONNECTION_TIME } from '../constants';
+import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlBanner } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import {
+ MAX_LIST_COUNT,
+ ACTIVE_CONNECTION_TIME,
+ AGENT_FEEDBACK_ISSUE,
+ AGENT_FEEDBACK_KEY,
+} from '../constants';
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import AgentEmptyState from './agent_empty_state.vue';
import AgentTable from './agent_table.vue';
export default {
+ i18n: {
+ feedbackBannerTitle: s__('ClusterAgents|Tell us what you think'),
+ feedbackBannerText: s__(
+ 'ClusterAgents|We would love to learn more about your experience with the GitLab Agent.',
+ ),
+ feedbackBannerButton: s__('ClusterAgents|Give feedback'),
+ error: s__('ClusterAgents|An error occurred while loading your Agents'),
+ },
+ AGENT_FEEDBACK_ISSUE,
+ AGENT_FEEDBACK_KEY,
apollo: {
agents: {
query: getAgentsQuery,
@@ -31,7 +49,10 @@ export default {
GlAlert,
GlKeysetPagination,
GlLoadingIcon,
+ GlBanner,
+ LocalStorageSync,
},
+ mixins: [glFeatureFlagMixin()],
inject: ['projectPath'],
props: {
defaultBranchName: {
@@ -57,6 +78,7 @@ export default {
last: null,
},
folderList: {},
+ feedbackBannerDismissed: false,
};
},
computed: {
@@ -86,6 +108,12 @@ export default {
treePageInfo() {
return this.agents?.project?.repository?.tree?.trees?.pageInfo || {};
},
+ feedbackBannerEnabled() {
+ return this.glFeatures.showGitlabAgentFeedback;
+ },
+ feedbackBannerClasses() {
+ return this.isChildComponent ? 'gl-my-2' : 'gl-mb-4';
+ },
},
methods: {
reloadAgents() {
@@ -142,6 +170,9 @@ export default {
const count = this.agents?.project?.clusterAgents?.count;
this.$emit('onAgentsLoad', count);
},
+ handleBannerClose() {
+ this.feedbackBannerDismissed = true;
+ },
},
};
</script>
@@ -151,6 +182,24 @@ export default {
<section v-else-if="agentList">
<div v-if="agentList.length">
+ <local-storage-sync
+ v-if="feedbackBannerEnabled"
+ v-model="feedbackBannerDismissed"
+ :storage-key="$options.AGENT_FEEDBACK_KEY"
+ >
+ <gl-banner
+ v-if="!feedbackBannerDismissed"
+ variant="introduction"
+ :class="feedbackBannerClasses"
+ :title="$options.i18n.feedbackBannerTitle"
+ :button-text="$options.i18n.feedbackBannerButton"
+ :button-link="$options.AGENT_FEEDBACK_ISSUE"
+ @close="handleBannerClose"
+ >
+ <p>{{ $options.i18n.feedbackBannerText }}</p>
+ </gl-banner>
+ </local-storage-sync>
+
<agent-table
:agents="agentList"
:default-branch-name="defaultBranchName"
@@ -166,6 +215,6 @@ export default {
</section>
<gl-alert v-else variant="danger" :dismissible="false">
- {{ s__('ClusterAgents|An error occurred while loading your GitLab Agents') }}
+ {{ $options.i18n.error }}
</gl-alert>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 9c330045596..7fb3aa3ff7e 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -57,7 +57,7 @@ export default {
'totalClusters',
]),
contentAlignClasses() {
- return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start';
+ return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-md-justify-content-start';
},
currentPage: {
get() {
diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
index 25f67462223..5b8dc74b84f 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
@@ -1,5 +1,13 @@
<script>
-import { GlDropdown, GlDropdownItem, GlModalDirective } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlModalDirective,
+ GlTooltipDirective,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+} from '@gitlab/ui';
+
import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '../constants';
export default {
@@ -8,11 +16,20 @@ export default {
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
},
directives: {
GlModalDirective,
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['newClusterPath', 'addClusterPath', 'canAddCluster'],
+ computed: {
+ tooltip() {
+ const { connectWithAgent, dropdownDisabledHint } = this.$options.i18n;
+ return this.canAddCluster ? connectWithAgent : dropdownDisabledHint;
+ },
},
- inject: ['newClusterPath', 'addClusterPath'],
};
</script>
@@ -20,22 +37,27 @@ export default {
<div class="nav-controls gl-ml-auto">
<gl-dropdown
ref="dropdown"
- v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ v-gl-modal-directive="canAddCluster && $options.INSTALL_AGENT_MODAL_ID"
+ v-gl-tooltip="tooltip"
category="primary"
variant="confirm"
:text="$options.i18n.actionsButton"
+ :disabled="!canAddCluster"
split
right
>
- <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop>
- {{ $options.i18n.createNewCluster }}
- </gl-dropdown-item>
+ <gl-dropdown-section-header>{{ $options.i18n.agent }}</gl-dropdown-section-header>
<gl-dropdown-item
v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
data-testid="connect-new-agent-link"
>
{{ $options.i18n.connectWithAgent }}
</gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-section-header>{{ $options.i18n.certificate }}</gl-dropdown-section-header>
+ <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop>
+ {{ $options.i18n.createNewCluster }}
+ </gl-dropdown-item>
<gl-dropdown-item :href="addClusterPath" data-testid="connect-cluster-link" @click.stop>
{{ $options.i18n.connectExistingCluster }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
index 0e312d21e4e..b730c0adfa2 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
@@ -8,6 +8,7 @@ import {
GlBadge,
GlLoadingIcon,
GlModalDirective,
+ GlTooltipDirective,
} from '@gitlab/ui';
import { mapState } from 'vuex';
import {
@@ -33,6 +34,7 @@ export default {
},
directives: {
GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
MAX_CLUSTERS_LIST,
INSTALL_AGENT_MODAL_ID,
@@ -40,7 +42,7 @@ export default {
agent: AGENT_CARD_INFO,
certificate: CERTIFICATE_BASED_CARD_INFO,
},
- inject: ['addClusterPath'],
+ inject: ['addClusterPath', 'canAddCluster'],
props: {
defaultBranchName: {
default: '.noBranch',
@@ -91,6 +93,14 @@ export default {
return cardTitle;
},
+ installAgentTooltip() {
+ return this.canAddCluster ? '' : this.$options.i18n.agent.installAgentDisabledHint;
+ },
+ connectExistingClusterTooltip() {
+ return this.canAddCluster
+ ? ''
+ : this.$options.i18n.certificate.connectExistingClusterDisabledHint;
+ },
},
methods: {
cardFooterNumber(number) {
@@ -113,7 +123,7 @@ export default {
<div v-show="!isLoading" data-testid="clusters-cards-container">
<gl-card
header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between gl-py-4"
- body-class="gl-pb-0"
+ body-class="gl-pb-0 cluster-card-item"
footer-class="gl-text-right"
>
<template #header>
@@ -166,20 +176,29 @@ export default {
><gl-sprintf :message="$options.i18n.agent.footerText"
><template #number>{{ cardFooterNumber(totalAgents) }}</template></gl-sprintf
></gl-link
- ><gl-button
- v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
- class="gl-ml-4"
- category="secondary"
- variant="confirm"
- >{{ $options.i18n.agent.actionText }}</gl-button
>
+ <div
+ v-gl-tooltip="installAgentTooltip"
+ class="gl-display-inline-block"
+ tabindex="-1"
+ data-testid="install-agent-button-tooltip"
+ >
+ <gl-button
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ class="gl-ml-4"
+ category="secondary"
+ variant="confirm"
+ :disabled="!canAddCluster"
+ >{{ $options.i18n.agent.actionText }}</gl-button
+ >
+ </div>
</template>
</gl-card>
<gl-card
class="gl-mt-6"
header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between"
- body-class="gl-pb-0"
+ body-class="gl-pb-0 cluster-card-item"
footer-class="gl-text-right"
>
<template #header>
@@ -206,14 +225,23 @@ export default {
><gl-sprintf :message="$options.i18n.certificate.footerText"
><template #number>{{ cardFooterNumber(totalClusters) }}</template></gl-sprintf
></gl-link
- ><gl-button
- category="secondary"
- data-qa-selector="connect_existing_cluster_button"
- variant="confirm"
- class="gl-ml-4"
- :href="addClusterPath"
- >{{ $options.i18n.certificate.actionText }}</gl-button
>
+ <div
+ v-gl-tooltip="connectExistingClusterTooltip"
+ class="gl-display-inline-block"
+ tabindex="-1"
+ data-testid="connect-existing-cluster-button-tooltip"
+ >
+ <gl-button
+ category="secondary"
+ data-qa-selector="connect_existing_cluster_button"
+ variant="confirm"
+ class="gl-ml-4"
+ :href="addClusterPath"
+ :disabled="!canAddCluster"
+ >{{ $options.i18n.certificate.actionText }}</gl-button
+ >
+ </div>
</template>
</gl-card>
</div>
diff --git a/app/assets/javascripts/clusters_list/components/agent_options.vue b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
index a364122ba56..6588d304d5c 100644
--- a/app/assets/javascripts/clusters_list/components/agent_options.vue
+++ b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
@@ -1,36 +1,23 @@
<script>
import {
- GlDropdown,
- GlDropdownItem,
+ GlButton,
GlModal,
GlModalDirective,
GlSprintf,
GlFormGroup,
GlFormInput,
+ GlTooltipDirective,
} from '@gitlab/ui';
-import { s__, __, sprintf } from '~/locale';
-import { DELETE_AGENT_MODAL_ID } from '../constants';
+import { sprintf } from '~/locale';
+import { DELETE_AGENT_BUTTON, DELETE_AGENT_MODAL_ID } from '../constants';
import deleteAgent from '../graphql/mutations/delete_agent.mutation.graphql';
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import { removeAgentFromStore } from '../graphql/cache_update';
export default {
- i18n: {
- dropdownText: __('More options'),
- deleteButton: s__('ClusterAgents|Delete agent'),
- modalTitle: __('Are you sure?'),
- modalBody: s__(
- 'ClusterAgents|Are you sure you want to delete this agent? You cannot undo this.',
- ),
- modalInputLabel: s__('ClusterAgents|To delete the agent, type %{name} to confirm:'),
- modalAction: s__('ClusterAgents|Delete'),
- modalCancel: __('Cancel'),
- successMessage: s__('ClusterAgents|%{name} successfully deleted'),
- defaultError: __('An error occurred. Please try again.'),
- },
+ i18n: DELETE_AGENT_BUTTON,
components: {
- GlDropdown,
- GlDropdownItem,
+ GlButton,
GlModal,
GlSprintf,
GlFormGroup,
@@ -38,8 +25,9 @@ export default {
},
directives: {
GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
- inject: ['projectPath'],
+ inject: ['projectPath', 'canAdminCluster'],
props: {
agent: {
required: true,
@@ -66,6 +54,13 @@ export default {
};
},
computed: {
+ deleteButtonDisabled() {
+ return this.loading || !this.canAdminCluster;
+ },
+ deleteButtonTooltip() {
+ const { deleteButton, disabledHint } = this.$options.i18n;
+ return this.deleteButtonDisabled ? disabledHint : deleteButton;
+ },
getAgentsQueryVariables() {
return {
defaultBranchName: this.defaultBranchName,
@@ -159,19 +154,22 @@ export default {
<template>
<div>
- <gl-dropdown
- icon="ellipsis_v"
- right
- :disabled="loading"
- :text="$options.i18n.dropdownText"
- text-sr-only
- category="tertiary"
- no-caret
+ <div
+ v-gl-tooltip="deleteButtonTooltip"
+ class="gl-display-inline-block"
+ tabindex="-1"
+ data-testid="delete-agent-button-tooltip"
>
- <gl-dropdown-item v-gl-modal-directive="modalId">
- {{ $options.i18n.deleteButton }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-button
+ ref="deleteAgentButton"
+ v-gl-modal-directive="modalId"
+ icon="remove"
+ category="secondary"
+ variant="danger"
+ :disabled="deleteButtonDisabled"
+ :aria-label="$options.i18n.deleteButton"
+ />
+ </div>
<gl-modal
ref="modal"
diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
index 5eef76252bd..8fc0a66cd7e 100644
--- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -111,6 +111,9 @@ export default {
canCancel() {
return !this.registered && !this.registering && this.isAgentRegistrationModal;
},
+ canRegister() {
+ return !this.registered && this.isAgentRegistrationModal;
+ },
agentRegistrationCommand() {
return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
},
@@ -142,6 +145,9 @@ export default {
isAgentRegistrationModal() {
return this.modalType === MODAL_TYPE_REGISTER;
},
+ isKasEnabledInEmptyStateModal() {
+ return this.isEmptyStateModal && !this.kasDisabled;
+ },
},
methods: {
setAgentName(name) {
@@ -350,18 +356,18 @@ export default {
<img :alt="i18n.altText" :src="emptyStateImage" height="100" />
</div>
- <p>
- <gl-sprintf :message="i18n.modalBody">
+ <p v-if="kasDisabled">
+ <gl-sprintf :message="i18n.enableKasText">
<template #link="{ content }">
- <gl-link :href="$options.installAgentPath"> {{ content }}</gl-link>
+ <gl-link :href="$options.enableKasPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
- <p v-if="kasDisabled">
- <gl-sprintf :message="i18n.enableKasText">
+ <p v-else>
+ <gl-sprintf :message="i18n.modalBody">
<template #link="{ content }">
- <gl-link :href="$options.enableKasPath"> {{ content }}</gl-link>
+ <gl-link :href="$options.installAgentPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
@@ -380,7 +386,16 @@ export default {
</gl-button>
<gl-button
- v-else-if="isAgentRegistrationModal"
+ v-if="canCancel"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="cancel"
+ @click="closeModal"
+ >{{ i18n.cancel }}
+ </gl-button>
+
+ <gl-button
+ v-if="canRegister"
:disabled="!nextButtonDisabled"
variant="confirm"
category="primary"
@@ -392,32 +407,21 @@ export default {
</gl-button>
<gl-button
- v-if="canCancel"
+ v-if="isEmptyStateModal"
:data-track-action="$options.EVENT_ACTIONS_CLICK"
:data-track-label="$options.EVENT_LABEL_MODAL"
- data-track-property="cancel"
+ data-track-property="done"
@click="closeModal"
- >{{ i18n.cancel }}
+ >{{ i18n.done }}
</gl-button>
<gl-button
- v-if="isEmptyStateModal"
+ v-if="isKasEnabledInEmptyStateModal"
:href="repositoryPath"
variant="confirm"
- category="secondary"
- data-testid="agent-secondary-button"
- >{{ i18n.secondaryButton }}
- </gl-button>
-
- <gl-button
- v-if="isEmptyStateModal"
- variant="confirm"
category="primary"
- :data-track-action="$options.EVENT_ACTIONS_CLICK"
- :data-track-label="$options.EVENT_LABEL_MODAL"
- data-track-property="done"
- @click="closeModal"
- >{{ i18n.done }}
+ data-testid="agent-primary-button"
+ >{{ i18n.primaryButton }}
</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 380a5d0aada..5cf6fd050a1 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -64,6 +64,27 @@ export const STATUSES = {
creating: { title: __('Creating') },
};
+export const I18N_AGENT_TABLE = {
+ nameLabel: s__('ClusterAgents|Name'),
+ statusLabel: s__('ClusterAgents|Connection status'),
+ lastContactLabel: s__('ClusterAgents|Last contact'),
+ versionLabel: __('Version'),
+ configurationLabel: s__('ClusterAgents|Configuration'),
+ optionsLabel: __('Options'),
+ troubleshootingText: s__('ClusterAgents|Learn how to troubleshoot'),
+ neverConnectedText: s__('ClusterAgents|Never'),
+ versionMismatchTitle: s__('ClusterAgents|Agent version mismatch'),
+ versionMismatchText: s__(
+ "ClusterAgents|The Agent version do not match each other across your cluster's pods. This can happen when a new Agent version was just deployed and Kubernetes is shutting down the old pods.",
+ ),
+ versionOutdatedTitle: s__('ClusterAgents|Agent version update required'),
+ versionOutdatedText: s__(
+ 'ClusterAgents|Your Agent version is out of sync with your GitLab version (v%{version}), which might cause compatibility problems. Update the Agent installed on your cluster to the most recent version.',
+ ),
+ versionMismatchOutdatedTitle: s__('ClusterAgents|Agent version mismatch and update'),
+ viewDocsText: s__('ClusterAgents|How to update the Agent?'),
+};
+
export const I18N_AGENT_MODAL = {
agent_registration: {
registerAgentButton: s__('ClusterAgents|Register'),
@@ -112,7 +133,7 @@ export const I18N_AGENT_MODAL = {
"ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.",
),
altText: s__('ClusterAgents|GitLab Agent for Kubernetes'),
- secondaryButton: s__('ClusterAgents|Go to the repository files'),
+ primaryButton: s__('ClusterAgents|Go to the repository files'),
done: __('Cancel'),
},
};
@@ -176,8 +197,8 @@ export const I18N_CLUSTERS_EMPTY_STATE = {
export const AGENT_CARD_INFO = {
tabName: 'agent',
- title: sprintf(s__('ClusterAgents|%{number} of %{total} agents')),
- emptyTitle: s__('ClusterAgents|No agents'),
+ title: sprintf(s__('ClusterAgents|%{number} of %{total} Agents')),
+ emptyTitle: s__('ClusterAgents|No Agents'),
tooltip: {
label: s__('ClusterAgents|Recommended'),
title: s__('ClusterAgents|GitLab Agent'),
@@ -188,8 +209,11 @@ export const AGENT_CARD_INFO = {
),
link: helpPagePath('user/clusters/agent/index'),
},
- actionText: s__('ClusterAgents|Install a new agent'),
+ actionText: s__('ClusterAgents|Install new Agent'),
footerText: sprintf(s__('ClusterAgents|View all %{number} agents')),
+ installAgentDisabledHint: s__(
+ 'ClusterAgents|Requires a Maintainer or greater role to install new agents',
+ ),
};
export const CERTIFICATE_BASED_CARD_INFO = {
@@ -201,6 +225,9 @@ export const CERTIFICATE_BASED_CARD_INFO = {
actionText: s__('ClusterAgents|Connect existing cluster'),
footerText: sprintf(s__('ClusterAgents|View all %{number} clusters')),
badgeText: s__('ClusterAgents|Deprecated'),
+ connectExistingClusterDisabledHint: s__(
+ 'ClusterAgents|Requires a maintainer or greater role to connect existing clusters',
+ ),
};
export const MAX_CLUSTERS_LIST = 6;
@@ -226,8 +253,25 @@ export const CLUSTERS_TABS = [
export const CLUSTERS_ACTIONS = {
actionsButton: s__('ClusterAgents|Actions'),
createNewCluster: s__('ClusterAgents|Create a new cluster'),
- connectWithAgent: s__('ClusterAgents|Connect with the Agent'),
+ connectWithAgent: s__('ClusterAgents|Connect with Agent'),
connectExistingCluster: s__('ClusterAgents|Connect with a certificate'),
+ agent: s__('ClusterAgents|Agent'),
+ certificate: s__('ClusterAgents|Certificate'),
+ dropdownDisabledHint: s__(
+ 'ClusterAgents|Requires a Maintainer or greater role to perform these actions',
+ ),
+};
+
+export const DELETE_AGENT_BUTTON = {
+ deleteButton: s__('ClusterAgents|Delete agent'),
+ disabledHint: s__('ClusterAgents|Requires a Maintainer or greater role to delete agents'),
+ modalTitle: __('Are you sure?'),
+ modalBody: s__('ClusterAgents|Are you sure you want to delete this agent? You cannot undo this.'),
+ modalInputLabel: s__('ClusterAgents|To delete the agent, type %{name} to confirm:'),
+ modalAction: s__('ClusterAgents|Delete'),
+ modalCancel: __('Cancel'),
+ successMessage: s__('ClusterAgents|%{name} successfully deleted'),
+ defaultError: __('An error occurred. Please try again.'),
};
export const AGENT = 'agent';
@@ -244,3 +288,6 @@ export const MODAL_TYPE_EMPTY = 'empty_state';
export const MODAL_TYPE_REGISTER = 'agent_registration';
export const DELETE_AGENT_MODAL_ID = 'delete-agent-modal-%{agentName}';
+
+export const AGENT_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/342696';
+export const AGENT_FEEDBACK_KEY = 'agent_feedback_banner';
diff --git a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
index cd46dfee170..05d2525ab98 100644
--- a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
+++ b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
@@ -2,6 +2,13 @@ fragment ClusterAgentFragment on ClusterAgent {
id
name
webPath
+ connections {
+ nodes {
+ metadata {
+ version
+ }
+ }
+ }
tokens {
nodes {
id
diff --git a/app/assets/javascripts/clusters_list/load_main_view.js b/app/assets/javascripts/clusters_list/load_main_view.js
index 08c99b46e16..d52b1d4a64d 100644
--- a/app/assets/javascripts/clusters_list/load_main_view.js
+++ b/app/assets/javascripts/clusters_list/load_main_view.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { parseBoolean } from '~/lib/utils/common_utils';
import createDefaultClient from '~/lib/graphql';
import ClustersMainView from './components/clusters_main_view.vue';
import { createStore } from './store';
@@ -24,6 +25,9 @@ export default () => {
addClusterPath,
emptyStateHelpText,
clustersEmptyStateImage,
+ canAddCluster,
+ canAdminCluster,
+ gitlabVersion,
} = el.dataset;
return new Vue({
@@ -37,6 +41,9 @@ export default () => {
addClusterPath,
emptyStateHelpText,
clustersEmptyStateImage,
+ canAddCluster: parseBoolean(canAddCluster),
+ canAdminCluster: parseBoolean(canAdminCluster),
+ gitlabVersion,
},
store: createStore(el.dataset),
render(createElement) {
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index d54fb7cded2..925b411e51c 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -1,8 +1,8 @@
+import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
import {
MarkdownSerializer as ProseMirrorMarkdownSerializer,
defaultMarkdownSerializer,
-} from 'prosemirror-markdown/src/to_markdown';
-import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+} from '~/lib/prosemirror_markdown_serializer';
import Audio from '../extensions/audio';
import Blockquote from '../extensions/blockquote';
import Bold from '../extensions/bold';
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index 08942374120..d1a68e80608 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -1,10 +1,9 @@
import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
-import Cookies from 'js-cookie';
import { debounce } from 'lodash';
+import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
-import { parseBoolean } from '~/lib/utils/common_utils';
export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed';
@@ -59,7 +58,7 @@ export default class ContextualSidebar {
if (!ContextualSidebar.isDesktopBreakpoint()) {
return;
}
- Cookies.set('sidebar_collapsed', value, { expires: 365 * 10 });
+ setCookie('sidebar_collapsed', value, { expires: 365 * 10 });
}
toggleSidebarNav(show) {
@@ -111,7 +110,7 @@ export default class ContextualSidebar {
if (!ContextualSidebar.isDesktopBreakpoint()) {
this.toggleSidebarNav(false);
} else {
- const collapse = parseBoolean(Cookies.get('sidebar_collapsed'));
+ const collapse = parseBoolean(getCookie('sidebar_collapsed'));
this.toggleCollapsedSidebar(collapse, true);
}
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index bdfabb8e846..3d7a34581b3 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -1,12 +1,12 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import Cookies from 'js-cookie';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { getCookie, setCookie } from '~/lib/utils/common_utils';
+import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
import { toYmd } from '~/analytics/shared/utils';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue';
-import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { __ } from '~/locale';
import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants';
@@ -35,7 +35,7 @@ export default {
},
data() {
return {
- isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
+ isOverviewDialogDismissed: getCookie(OVERVIEW_DIALOG_COOKIE),
};
},
computed: {
@@ -134,7 +134,7 @@ export default {
},
dismissOverviewDialog() {
this.isOverviewDialogDismissed = true;
- Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 });
+ setCookie(OVERVIEW_DIALOG_COOKIE, '1');
},
isUserAllowed(id) {
const { permissions } = this;
diff --git a/app/assets/javascripts/cycle_analytics/components/metric_tile.vue b/app/assets/javascripts/cycle_analytics/components/metric_tile.vue
new file mode 100644
index 00000000000..a5c20b237b3
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/metric_tile.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { redirectTo } from '~/lib/utils/url_utility';
+import MetricPopover from '~/analytics/shared/components/metric_popover.vue';
+
+export default {
+ name: 'MetricTile',
+ components: {
+ GlSingleStat,
+ MetricPopover,
+ },
+ props: {
+ metric: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ decimalPlaces() {
+ const parsedFloat = parseFloat(this.metric.value);
+ return Number.isNaN(parsedFloat) || Number.isInteger(parsedFloat) ? 0 : 1;
+ },
+ hasLinks() {
+ return this.metric.links?.length && this.metric.links[0].url;
+ },
+ },
+ methods: {
+ clickHandler({ links }) {
+ if (this.hasLinks) {
+ redirectTo(links[0].url);
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div v-bind="$attrs">
+ <gl-single-stat
+ :id="metric.identifier"
+ :value="`${metric.value}`"
+ :title="metric.label"
+ :unit="metric.unit || ''"
+ :should-animate="true"
+ :animation-decimal-places="decimalPlaces"
+ :class="{ 'gl-hover-cursor-pointer': hasLinks }"
+ tabindex="0"
+ @click="clickHandler(metric)"
+ />
+ <metric-popover :metric="metric" :target="metric.identifier" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
index 8f7a3f99bab..ea5a1291a17 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
@@ -266,7 +266,7 @@ export default {
>
<span class="gl-font-lg">&middot;</span>
<span data-testid="vsa-stage-event-date">
- {{ s__('OpenedNDaysAgo|Opened') }}
+ {{ s__('OpenedNDaysAgo|Created') }}
<gl-link class="gl-text-black-normal" :href="item.url">{{
item.createdAt
}}</gl-link>
diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js
index 7d5822b0824..f0b2bd9dc5b 100644
--- a/app/assets/javascripts/cycle_analytics/constants.js
+++ b/app/assets/javascripts/cycle_analytics/constants.js
@@ -36,31 +36,6 @@ export const OVERVIEW_METRICS = {
RECENT_ACTIVITY: 'RECENT_ACTIVITY',
};
-export const METRICS_POPOVER_CONTENT = {
- 'lead-time': {
- description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
- },
- 'cycle-time': {
- description: s__(
- "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
- ),
- },
- 'lead-time-for-changes': {
- description: s__(
- 'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.',
- ),
- },
- 'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
- 'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
- deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
- 'deployment-frequency': {
- description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
- },
- commits: {
- description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'),
- },
-};
-
export const SUMMARY_METRICS_REQUEST = [
{ endpoint: METRIC_TYPE_SUMMARY, name: __('recent activity'), request: getValueStreamMetrics },
];
diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js
index 9af63f5f9cc..428bb11b950 100644
--- a/app/assets/javascripts/cycle_analytics/utils.js
+++ b/app/assets/javascripts/cycle_analytics/utils.js
@@ -1,14 +1,5 @@
-import { hideFlash } from '~/flash';
import { parseSeconds } from '~/lib/utils/datetime_utility';
import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility';
-import { slugify } from '~/lib/utils/text_utility';
-
-export const removeFlash = (type = 'alert') => {
- const flashEl = document.querySelector(`.flash-${type}`);
- if (flashEl) {
- hideFlash(flashEl);
- }
-};
/**
* Takes the stages and median data, combined with the selected stage, to build an
@@ -80,30 +71,11 @@ export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
* @typedef {Object} TransformedMetricData
* @property {String} label - Title of the metric measured
* @property {String} value - String representing the decimal point value, e.g '1.5'
- * @property {String} key - Slugified string based on the 'title'
+ * @property {String} identifier - Slugified string based on the 'title' or the provided 'identifier' attribute
* @property {String} description - String to display for a description
* @property {String} unit - String representing the decimal point value, e.g '1.5'
*/
-/**
- * Prepares metric data to be rendered in the metric_card component
- *
- * @param {MetricData[]} data - The metric data to be rendered
- * @param {Object} popoverContent - Key value pair of data to display in the popover
- * @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card
- */
-
-export const prepareTimeMetricsData = (data = [], popoverContent = {}) =>
- data.map(({ title: label, ...rest }) => {
- const key = slugify(label);
- return {
- ...rest,
- label,
- key,
- description: popoverContent[key]?.description || '',
- };
- });
-
const extractFeatures = (gon) => ({
cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
});
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 4ab3f140b61..82bbbe891e2 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -13,7 +13,6 @@ deprecated_notes_spec.js is the spec for the legacy, jQuery notes application. I
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import Autosize from 'autosize';
import $ from 'jquery';
-import Cookies from 'js-cookie';
import { escape, uniqueId } from 'lodash';
import Vue from 'vue';
import '~/lib/utils/jquery_at_who';
@@ -28,6 +27,7 @@ import { defaultAutocompleteConfig } from './gfm_auto_complete';
import GLForm from './gl_form';
import axios from './lib/utils/axios_utils';
import {
+ getCookie,
isInViewport,
getPagePath,
scrollToElement,
@@ -121,7 +121,7 @@ export default class Notes {
}
setViewType(view) {
- this.view = Cookies.get('diff_view') || view;
+ this.view = getCookie('diff_view') || view;
}
addBinding() {
@@ -473,7 +473,7 @@ export default class Notes {
}
isParallelView() {
- return Cookies.get('diff_view') === 'parallel';
+ return getCookie('diff_view') === 'parallel';
}
/**
@@ -694,7 +694,7 @@ export default class Notes {
// Convert returned HTML to a jQuery object so we can modify it further
const $noteEntityEl = $(noteEntity.html);
const $noteAvatar = $noteEntityEl.find('.image-diff-avatar-link');
- const $targetNoteBadge = $targetNote.find('.badge');
+ const $targetNoteBadge = $targetNote.find('.design-note-pin');
$noteAvatar.append($targetNoteBadge);
this.revertNoteEditForm($targetNote);
diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue
index b058709b316..674415ec449 100644
--- a/app/assets/javascripts/design_management/components/design_overlay.vue
+++ b/app/assets/javascripts/design_management/components/design_overlay.vue
@@ -286,6 +286,7 @@ export default {
"
:is-inactive="isNoteInactive(note)"
:is-resolved="note.resolved"
+ is-on-image
@mousedown.stop="onNoteMousedown($event, note)"
@mouseup.stop="onNoteMouseup(note)"
/>
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index 6d0ed3b08a3..81d0b6d0df4 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -1,7 +1,7 @@
<script>
import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui';
-import Cookies from 'js-cookie';
-import { parseBoolean, isLoggedIn } from '~/lib/utils/common_utils';
+import { getCookie, setCookie, parseBoolean, isLoggedIn } from '~/lib/utils/common_utils';
+
import { s__ } from '~/locale';
import Participants from '~/sidebar/components/participants/participants.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -53,7 +53,7 @@ export default {
},
data() {
return {
- isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)),
+ isResolvedCommentsPopoverHidden: parseBoolean(getCookie(this.$options.cookieKey)),
discussionWithOpenForm: '',
isLoggedIn: isLoggedIn(),
};
@@ -96,7 +96,7 @@ export default {
methods: {
handleSidebarClick() {
this.isResolvedCommentsPopoverHidden = true;
- Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 });
+ setCookie(this.$options.cookieKey, 'true', { expires: 365 * 10 });
this.updateActiveDiscussion();
},
updateActiveDiscussion(id) {
diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js
index 5cf32cb7fe3..8c44c5a5d0a 100644
--- a/app/assets/javascripts/design_management/graphql.js
+++ b/app/assets/javascripts/design_management/graphql.js
@@ -1,11 +1,10 @@
-import { defaultDataIdFromObject, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
+import { defaultDataIdFromObject } from '@apollo/client/core';
import produce from 'immer';
import { uniqueId } from 'lodash';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
-import introspectionQueryResultData from './graphql/fragmentTypes.json';
import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
import getDesignQuery from './graphql/queries/get_design.query.graphql';
import typeDefs from './graphql/typedefs.graphql';
@@ -13,10 +12,6 @@ import { addPendingTodoToStore } from './utils/cache_update';
import { extractTodoIdFromDeletePath, createPendingTodo } from './utils/design_management_utils';
import { CREATE_DESIGN_TODO_EXISTS_ERROR } from './utils/error_messages';
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
Vue.use(VueApollo);
const resolvers = {
@@ -85,7 +80,6 @@ const defaultClient = createDefaultClient(
}
return defaultDataIdFromObject(object);
},
- fragmentMatcher,
},
typeDefs,
},
diff --git a/app/assets/javascripts/design_management/graphql/fragmentTypes.json b/app/assets/javascripts/design_management/graphql/fragmentTypes.json
deleted file mode 100644
index 0953231ea4c..00000000000
--- a/app/assets/javascripts/design_management/graphql/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]},{"kind":"UNION","name":"NoteableType","possibleTypes":[{"name":"Design"},{"name":"Issue"},{"name":"MergeRequest"}]}]}}
diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js
index 4ae76050aa5..b856ac6c627 100644
--- a/app/assets/javascripts/design_management/index.js
+++ b/app/assets/javascripts/design_management/index.js
@@ -24,6 +24,7 @@ export default () => {
return new Vue({
el,
+ name: 'DesignRoot',
router,
apolloProvider,
provide: {
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 66d06a3a1b6..5707e4d67f9 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -26,10 +26,8 @@ import {
TREE_LIST_WIDTH_STORAGE_KEY,
INITIAL_TREE_WIDTH,
MIN_TREE_WIDTH,
- MAX_TREE_WIDTH,
TREE_HIDE_STATS_WIDTH,
MR_TREE_SHOW_KEY,
- CENTERED_LIMITED_CONTAINER_CLASSES,
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
ALERT_COLLAPSED_FILES,
@@ -55,6 +53,7 @@ import DiffFile from './diff_file.vue';
import HiddenFilesWarning from './hidden_files_warning.vue';
import NoChanges from './no_changes.vue';
import TreeList from './tree_list.vue';
+import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync';
export default {
name: 'DiffsApp',
@@ -64,8 +63,7 @@ export default {
DynamicScrollerItem: () =>
import('vendor/vue-virtual-scroller').then(({ DynamicScrollerItem }) => DynamicScrollerItem),
PreRenderer: () => import('./pre_renderer.vue').then((PreRenderer) => PreRenderer),
- VirtualScrollerScrollSync: () =>
- import('./virtual_scroller_scroll_sync').then((VSSSync) => VSSSync),
+ VirtualScrollerScrollSync,
CompareVersions,
DiffFile,
NoChanges,
@@ -253,13 +251,6 @@ export default {
hideFileStats() {
return this.treeWidth <= TREE_HIDE_STATS_WIDTH;
},
- isLimitedContainer() {
- if (this.glFeatures.mrChangesFluidLayout) {
- return false;
- }
-
- return !this.renderFileTree && !this.isParallelView && !this.isFluidLayout;
- },
isFullChangeset() {
return this.startVersion === null && this.latestDiff;
},
@@ -395,8 +386,6 @@ export default {
this.adjustView();
this.subscribeToEvents();
- this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
-
this.unwatchDiscussions = this.$watch(
() => `${this.diffFiles.length}:${this.$store.state.notes.discussions.length}`,
() => this.setDiscussions(),
@@ -417,10 +406,8 @@ export default {
this.unsubscribeFromEvents();
this.removeEventListeners();
- if (window.gon?.features?.diffsVirtualScrolling) {
- diffsEventHub.$off('scrollToFileHash', this.scrollVirtualScrollerToFileHash);
- diffsEventHub.$off('scrollToIndex', this.scrollVirtualScrollerToIndex);
- }
+ diffsEventHub.$off('scrollToFileHash', this.scrollVirtualScrollerToFileHash);
+ diffsEventHub.$off('scrollToIndex', this.scrollVirtualScrollerToIndex);
},
methods: {
...mapActions(['startTaskList']),
@@ -533,32 +520,27 @@ export default {
);
}
- if (
- window.gon?.features?.diffsVirtualScrolling ||
- window.gon?.features?.diffSearchingUsageData
- ) {
- let keydownTime;
- Mousetrap.bind(['mod+f', 'mod+g'], () => {
- keydownTime = new Date().getTime();
- });
+ let keydownTime;
+ Mousetrap.bind(['mod+f', 'mod+g'], () => {
+ keydownTime = new Date().getTime();
+ });
- window.addEventListener('blur', () => {
- if (keydownTime) {
- const delta = new Date().getTime() - keydownTime;
+ window.addEventListener('blur', () => {
+ if (keydownTime) {
+ const delta = new Date().getTime() - keydownTime;
- // To make sure the user is using the find function we need to wait for blur
- // and max 1000ms to be sure it the search box is filtered
- if (delta >= 0 && delta < 1000) {
- this.disableVirtualScroller();
+ // To make sure the user is using the find function we need to wait for blur
+ // and max 1000ms to be sure it the search box is filtered
+ if (delta >= 0 && delta < 1000) {
+ this.disableVirtualScroller();
- if (window.gon?.features?.diffSearchingUsageData) {
- api.trackRedisHllUserEvent('i_code_review_user_searches_diff');
- api.trackRedisCounterEvent('diff_searches');
- }
+ if (window.gon?.features?.usageDataDiffSearches) {
+ api.trackRedisHllUserEvent('i_code_review_user_searches_diff');
+ api.trackRedisCounterEvent('diff_searches');
}
}
- });
- }
+ }
+ });
},
removeEventListeners() {
Mousetrap.unbind(keysFor(MR_PREVIOUS_FILE_IN_DIFF));
@@ -600,8 +582,6 @@ export default {
this.virtualScrollCurrentIndex = -1;
},
scrollVirtualScrollerToDiffNote() {
- if (!window.gon?.features?.diffsVirtualScrolling) return;
-
const id = window?.location?.hash;
if (id.startsWith('#note_')) {
@@ -616,11 +596,7 @@ export default {
}
},
subscribeToVirtualScrollingEvents() {
- if (
- window.gon?.features?.diffsVirtualScrolling &&
- this.shouldShow &&
- !this.subscribedToVirtualScrollingEvents
- ) {
+ if (this.shouldShow && !this.subscribedToVirtualScrollingEvents) {
diffsEventHub.$on('scrollToFileHash', this.scrollVirtualScrollerToFileHash);
diffsEventHub.$on('scrollToIndex', this.scrollVirtualScrollerToIndex);
@@ -632,7 +608,7 @@ export default {
},
},
minTreeWidth: MIN_TREE_WIDTH,
- maxTreeWidth: MAX_TREE_WIDTH,
+ maxTreeWidth: window.innerWidth / 2,
howToMergeDocsPath: helpPagePath('user/project/merge_requests/reviews/index.md', {
anchor: 'checkout-merge-requests-locally-through-the-head-ref',
}),
@@ -643,10 +619,7 @@ export default {
<div v-show="shouldShow">
<div v-if="isLoading || !isTreeLoaded" class="loading"><gl-loading-icon size="lg" /></div>
<div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane">
- <compare-versions
- :is-limited-container="isLimitedContainer"
- :diff-files-count-text="numTotalFiles"
- />
+ <compare-versions :diff-files-count-text="numTotalFiles" />
<template v-if="!isBatchLoadingError">
<hidden-files-warning
@@ -656,10 +629,7 @@ export default {
:plain-diff-path="plainDiffPath"
:email-patch-path="emailPatchPath"
/>
- <collapsed-files-warning
- v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES"
- :limited="isLimitedContainer"
- />
+ <collapsed-files-warning v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES" />
</template>
<div
@@ -669,7 +639,7 @@ export default {
<div
v-if="renderFileTree"
:style="{ width: `${treeWidth}px` }"
- class="diff-tree-list js-diff-tree-list px-3 pr-md-0"
+ class="diff-tree-list js-diff-tree-list gl-px-5"
>
<panel-resizer
:size.sync="treeWidth"
@@ -681,12 +651,7 @@ export default {
/>
<tree-list :hide-file-stats="hideFileStats" />
</div>
- <div
- class="col-12 col-md-auto diff-files-holder"
- :class="{
- [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer,
- }"
- >
+ <div class="col-12 col-md-auto diff-files-holder">
<commit-widget v-if="commit" :commit="commit" :collapsible="false" />
<gl-alert
v-if="isBatchLoadingError"
diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
index 240f102e600..b7eea32e699 100644
--- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
@@ -2,7 +2,7 @@
import { GlAlert, GlButton } from '@gitlab/ui';
import { mapState } from 'vuex';
-import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants';
+import { EVT_EXPAND_ALL_FILES } from '../constants';
import eventHub from '../event_hub';
export default {
@@ -11,11 +11,6 @@ export default {
GlButton,
},
props: {
- limited: {
- type: Boolean,
- required: false,
- default: false,
- },
dismissed: {
type: Boolean,
required: false,
@@ -29,11 +24,6 @@ export default {
},
computed: {
...mapState('diffs', ['diffFiles']),
- containerClasses() {
- return {
- [CENTERED_LIMITED_CONTAINER_CLASSES]: this.limited,
- };
- },
shouldDisplay() {
return !this.isDismissed && this.diffFiles.length > 1;
},
@@ -53,7 +43,7 @@ export default {
</script>
<template>
- <div v-if="shouldDisplay" data-testid="root" :class="containerClasses" class="col-12">
+ <div v-if="shouldDisplay" data-testid="root" class="col-12">
<gl-alert
:dismissible="true"
:title="__('Some changes are not shown')"
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index e54fde72847..df7cf83b3f0 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -155,9 +155,11 @@ export default {
<gl-button
v-if="commit.description_html && collapsible"
+ v-gl-tooltip
class="js-toggle-button"
size="small"
icon="ellipsis_h"
+ :title="__('Toggle commit description')"
:aria-label="__('Toggle commit description')"
/>
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 442807587d5..2b871680d5e 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -3,7 +3,7 @@ import { GlTooltipDirective, GlIcon, GlLink, GlButtonGroup, GlButton, GlSprintf
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
import { setUrlParams } from '../../lib/utils/url_utility';
-import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants';
+import { EVT_EXPAND_ALL_FILES } from '../constants';
import eventHub from '../event_hub';
import CompareDropdownLayout from './compare_dropdown_layout.vue';
import DiffStats from './diff_stats.vue';
@@ -24,11 +24,6 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
- isLimitedContainer: {
- type: Boolean,
- required: false,
- default: false,
- },
diffFilesCountText: {
type: String,
required: false,
@@ -73,9 +68,6 @@ export default {
return this.commit && (this.commit.next_commit_id || this.commit.prev_commit_id);
},
},
- created() {
- this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
- },
methods: {
...mapActions('diffs', ['setInlineDiffViewType', 'setParallelDiffViewType', 'setShowTreeList']),
expandAllFiles() {
@@ -88,12 +80,7 @@ export default {
<template>
<div class="mr-version-controls border-top">
- <div
- class="mr-version-menus-container content-block"
- :class="{
- [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer,
- }"
- >
+ <div class="mr-version-menus-container content-block">
<gl-button
v-if="hasChanges"
v-gl-tooltip.hover
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index 5e05ec87f84..47a05ce11cc 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -1,12 +1,14 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { mapActions } from 'vuex';
+import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
import noteableDiscussion from '../../notes/components/noteable_discussion.vue';
export default {
components: {
noteableDiscussion,
GlIcon,
+ DesignNotePin,
},
props: {
discussions: {
@@ -62,20 +64,22 @@ export default {
<ul :data-discussion-id="discussion.id" class="notes">
<template v-if="shouldCollapseDiscussions">
<button
- :class="{
- 'diff-notes-collapse': discussion.expanded,
- 'btn-transparent badge badge-pill': !discussion.expanded,
- }"
+ v-if="discussion.expanded"
+ class="diff-notes-collapse js-diff-notes-toggle"
type="button"
- class="js-diff-notes-toggle"
:aria-label="__('Show comments')"
@click="toggleDiscussion({ discussionId: discussion.id })"
>
- <gl-icon v-if="discussion.expanded" name="collapse" class="collapse-icon" />
- <template v-else>
- {{ index + 1 }}
- </template>
+ <gl-icon name="collapse" class="collapse-icon" />
</button>
+ <design-note-pin
+ v-else
+ :label="index + 1"
+ :is-resolved="discussion.resolved"
+ size="sm"
+ class="js-diff-notes-toggle gl-translate-x-n50"
+ @click="toggleDiscussion({ discussionId: discussion.id })"
+ />
</template>
<noteable-discussion
v-show="isExpanded(discussion)"
@@ -87,9 +91,12 @@ export default {
@noteDeleted="deleteNoteHandler"
>
<template v-if="renderAvatarBadge" #avatar-badge>
- <span class="badge badge-pill">
- {{ index + 1 }}
- </span>
+ <design-note-pin
+ :label="index + 1"
+ class="user-avatar"
+ :is-resolved="discussion.resolved"
+ size="sm"
+ />
</template>
</noteable-discussion>
</ul>
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index edff2e67b20..4c7b8e8f667 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -223,25 +223,31 @@ export default {
<template>
<div class="content js-line-expansion-content">
- <a
- v-if="canExpandDown"
- class="gl-mx-2 gl-cursor-pointer js-unfold-down gl-display-inline-block gl-py-4"
+ <button
+ type="button"
+ :disabled="!canExpandDown"
+ class="js-unfold-down gl-mx-2 gl-py-4 gl-cursor-pointer"
@click="handleExpandLines(EXPAND_DOWN)"
>
<gl-icon :size="12" name="expand-down" />
<span>{{ $options.i18n.showMore }}</span>
- </a>
- <a class="gl-mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()">
+ </button>
+ <button
+ type="button"
+ class="js-unfold-all gl-mx-2 gl-py-4 gl-cursor-pointer"
+ @click="handleExpandLines()"
+ >
<gl-icon :size="12" name="expand" />
<span>{{ $options.i18n.showAll }}</span>
- </a>
- <a
- v-if="canExpandUp"
- class="gl-mx-2 gl-cursor-pointer js-unfold gl-display-inline-block gl-py-4"
+ </button>
+ <button
+ type="button"
+ :disabled="!canExpandUp"
+ class="js-unfold gl-mx-2 gl-py-4 gl-cursor-pointer"
@click="handleExpandLines(EXPAND_UP)"
>
<gl-icon :size="12" name="expand-up" />
<span>{{ $options.i18n.showMore }}</span>
- </a>
+ </button>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 238f07ac22c..3cf1f69b08c 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -3,6 +3,7 @@ import {
GlTooltipDirective,
GlSafeHtmlDirective,
GlIcon,
+ GlBadge,
GlButton,
GlButtonGroup,
GlDropdown,
@@ -34,6 +35,7 @@ export default {
GlIcon,
FileIcon,
DiffStats,
+ GlBadge,
GlButton,
GlButtonGroup,
GlDropdown,
@@ -207,7 +209,7 @@ export default {
handler(val) {
const el = this.$el.closest('.vue-recycle-scroller__item-view');
- if (this.glFeatures.diffsVirtualScrolling && el) {
+ if (el) {
// We can't add a style with Vue because of the way the virtual
// scroller library renders the diff files
el.style.zIndex = val ? '1' : null;
@@ -349,7 +351,9 @@ export default {
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
- <span v-if="isUsingLfs" class="badge label label-lfs gl-mr-2"> {{ __('LFS') }} </span>
+ <gl-badge v-if="isUsingLfs" variant="neutral" class="gl-mr-2" data-testid="label-lfs">{{
+ __('LFS')
+ }}</gl-badge>
</div>
<div
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 8562a1d44e7..333bf1b356c 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -153,21 +153,38 @@ export default {
@mousedown="handleParallelLineMouseDown"
>
<template v-for="(line, index) in diffLines">
- <div
- v-if="line.isMatchLineLeft || line.isMatchLineRight"
- :key="`expand-${index}`"
- class="diff-tr line_expansion match"
- >
- <div class="diff-td text-center gl-font-regular">
- <diff-expansion-cell
- :file-hash="diffFile.file_hash"
- :context-lines-path="diffFile.context_lines_path"
- :line="line.left"
- :is-top="index === 0"
- :is-bottom="index + 1 === diffLinesLength"
- />
+ <template v-if="line.isMatchLineLeft || line.isMatchLineRight">
+ <div :key="`expand-${index}`" class="diff-tr line_expansion match">
+ <div class="diff-td text-center gl-font-regular">
+ <diff-expansion-cell
+ :file-hash="diffFile.file_hash"
+ :context-lines-path="diffFile.context_lines_path"
+ :line="line.left"
+ :is-top="index === 0"
+ :is-bottom="index + 1 === diffLinesLength"
+ />
+ </div>
</div>
- </div>
+ <div
+ v-if="line.left.rich_text"
+ :key="`expand-definition-${index}`"
+ class="diff-grid-row diff-tr line_holder match"
+ >
+ <div class="diff-grid-left diff-grid-3-col left-side">
+ <div class="diff-td diff-line-num"></div>
+ <div v-if="inline" class="diff-td diff-line-num"></div>
+ <div class="diff-td line_content left-side gl-white-space-normal!">
+ {{ line.left.rich_text }}
+ </div>
+ </div>
+ <div v-if="!inline" class="diff-grid-right diff-grid-3-col right-side">
+ <div class="diff-td diff-line-num"></div>
+ <div class="diff-td line_content right-side gl-white-space-normal!">
+ {{ line.left.rich_text }}
+ </div>
+ </div>
+ </div>
+ </template>
<diff-row
v-if="!line.isMatchLineLeft && !line.isMatchLineRight"
:key="line.line_code"
diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
index eede8e52292..8871be1f9af 100644
--- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue
+++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
@@ -1,8 +1,8 @@
<script>
-import { GlIcon } from '@gitlab/ui';
import { isArray } from 'lodash';
import { mapActions, mapGetters } from 'vuex';
import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff';
+import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
function calcPercent(pos, renderedSize) {
return (100 * pos) / renderedSize;
@@ -11,7 +11,7 @@ function calcPercent(pos, renderedSize) {
export default {
name: 'ImageDiffOverlay',
components: {
- GlIcon,
+ DesignNotePin,
},
mixins: [imageDiffMixin],
props: {
@@ -36,7 +36,7 @@ export default {
badgeClass: {
type: String,
required: false,
- default: 'badge badge-pill',
+ default: '',
},
shouldToggleDiscussion: {
type: Boolean,
@@ -114,30 +114,28 @@ export default {
>
<span class="sr-only"> {{ __('Add image comment') }} </span>
</button>
- <button
+
+ <design-note-pin
v-for="(discussion, index) in allDiscussions"
:key="discussion.id"
- :style="getPosition(discussion)"
- :class="[badgeClass, { 'is-draft': discussion.isDraft }]"
- :disabled="!shouldToggleDiscussion"
- class="js-image-badge"
- type="button"
+ :label="showCommentIcon ? null : toggleText(discussion, index)"
+ :position="getPosition(discussion)"
:aria-label="__('Show comments')"
+ class="js-image-badge"
+ :class="badgeClass"
+ :is-draft="discussion.isDraft"
+ :is-resolved="discussion.resolved"
+ is-on-image
+ :disabled="!shouldToggleDiscussion"
@click="clickedToggle(discussion)"
- >
- <gl-icon v-if="showCommentIcon" name="image-comment-dark" :size="24" />
- <template v-else>
- {{ toggleText(discussion, index) }}
- </template>
- </button>
- <button
+ />
+
+ <design-note-pin
v-if="canComment && currentCommentForm"
- :style="{ left: `${currentCommentForm.xPercent}%`, top: `${currentCommentForm.yPercent}%` }"
- :aria-label="__('Comment form position')"
- class="btn-transparent comment-indicator position-absolute"
- type="button"
- >
- <gl-icon name="image-comment-dark" :size="24" />
- </button>
+ :position="{
+ left: `${currentCommentForm.xPercent}%`,
+ top: `${currentCommentForm.yPercent}%`,
+ }"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
index 587efd6ed41..6e1e6f5c2d0 100644
--- a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
+++ b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlAlert, GlModalDirective } from '@gitlab/ui';
-import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants';
export default {
components: {
@@ -11,10 +10,6 @@ export default {
GlModalDirective,
},
props: {
- limited: {
- type: Boolean,
- required: true,
- },
mergeable: {
type: Boolean,
required: true,
@@ -24,18 +19,11 @@ export default {
required: true,
},
},
- computed: {
- containerClasses() {
- return {
- [CENTERED_LIMITED_CONTAINER_CLASSES]: this.limited,
- };
- },
- },
};
</script>
<template>
- <div :class="containerClasses">
+ <div>
<gl-alert
:dismissible="false"
:title="__('There are merge conflicts')"
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 93961b07e2e..bbe27c0dbd6 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -29,8 +29,6 @@ export const UNFOLD_COUNT = 20;
export const COUNT_OF_AVATARS_IN_GUTTER = 3;
export const LENGTH_OF_AVATAR_TOOLTIP = 17;
-export const LINES_TO_BE_RENDERED_DIRECTLY = 100;
-
export const DIFF_FILE_SYMLINK_MODE = '120000';
export const DIFF_FILE_DELETED_MODE = '0';
@@ -42,7 +40,6 @@ export const TREE_LIST_WIDTH_STORAGE_KEY = 'mr_tree_list_width';
export const INITIAL_TREE_WIDTH = 320;
export const MIN_TREE_WIDTH = 240;
-export const MAX_TREE_WIDTH = 400;
export const TREE_HIDE_STATS_WIDTH = 260;
export const OLD_LINE_KEY = 'old_line';
@@ -50,9 +47,6 @@ export const NEW_LINE_KEY = 'new_line';
export const TYPE_KEY = 'type';
export const LEFT_LINE_KEY = 'left';
-export const CENTERED_LIMITED_CONTAINER_CLASSES =
- 'container-limited limit-container-width mx-lg-auto px-3';
-
export const MAX_RENDERING_DIFF_LINES = 500;
export const MAX_RENDERING_BULK_ROWS = 30;
export const MIN_RENDERING_MS = 2;
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 260ebdf2141..1691da34c6d 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -1,8 +1,7 @@
-import Cookies from 'js-cookie';
import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { getParameterValues } from '~/lib/utils/url_utility';
+import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils';
+
import eventHub from '../notes/event_hub';
import diffsApp from './components/app.vue';
@@ -58,14 +57,14 @@ export default function initDiffsApp(store) {
// Check for cookie and save that setting for future use.
// Then delete the cookie as we are phasing it out and using the database as SSOT.
// NOTE: This can/should be removed later
- if (Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)) {
- const hideWhitespace = Cookies.get(DIFF_WHITESPACE_COOKIE_NAME);
+ if (getCookie(DIFF_WHITESPACE_COOKIE_NAME)) {
+ const hideWhitespace = getCookie(DIFF_WHITESPACE_COOKIE_NAME);
this.setShowWhitespace({
url: this.endpointUpdateUser,
showWhitespace: hideWhitespace !== '1',
trackClick: false,
});
- Cookies.remove(DIFF_WHITESPACE_COOKIE_NAME);
+ removeCookie(DIFF_WHITESPACE_COOKIE_NAME);
} else {
// This is only to set the the user preference in Vuex for use later
this.setShowWhitespace({
@@ -74,11 +73,6 @@ export default function initDiffsApp(store) {
trackClick: false,
});
}
-
- const vScrollingParam = getParameterValues('virtual_scrolling')[0];
- if (vScrollingParam === 'false' || vScrollingParam === 'true') {
- Cookies.set('diffs_virtual_scrolling', vScrollingParam);
- }
},
methods: {
...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']),
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 692cb913a57..e967be23f42 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -1,9 +1,14 @@
-import Cookies from 'js-cookie';
import Vue from 'vue';
+import {
+ setCookie,
+ handleLocationHash,
+ historyPushState,
+ scrollToElement,
+} from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import { diffViewerModes } from '~/ide/constants';
import axios from '~/lib/utils/axios_utils';
-import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
+
import httpStatusCodes from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
@@ -120,7 +125,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
commit(types.SET_DIFF_DATA_BATCH, { diff_files });
commit(types.SET_BATCH_LOADING_STATE, 'loaded');
- if (window.gon?.features?.diffsVirtualScrolling && !scrolledVirtualScroller) {
+ if (!scrolledVirtualScroller) {
const index = state.diffFiles.findIndex(
(f) =>
f.file_hash === hash || f[INLINE_DIFF_LINES_KEY].find((l) => l.line_code === hash),
@@ -190,9 +195,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
commit(types.SET_BATCH_LOADING_STATE, 'error');
});
- return getBatch().then(
- () => !window.gon?.features?.diffsVirtualScrolling && handleLocationHash(),
- );
+ return getBatch();
};
export const fetchDiffFilesMeta = ({ commit, state }) => {
@@ -369,7 +372,7 @@ export const setRenderIt = ({ commit }, file) => commit(types.RENDER_FILE, file)
export const setInlineDiffViewType = ({ commit }) => {
commit(types.SET_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE);
- Cookies.set(DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE);
+ setCookie(DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE);
const url = mergeUrlParams({ view: INLINE_DIFF_VIEW_TYPE }, window.location.href);
historyPushState(url);
@@ -381,7 +384,7 @@ export const setInlineDiffViewType = ({ commit }) => {
export const setParallelDiffViewType = ({ commit }) => {
commit(types.SET_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE);
- Cookies.set(DIFF_VIEW_COOKIE_NAME, PARALLEL_DIFF_VIEW_TYPE);
+ setCookie(DIFF_VIEW_COOKIE_NAME, PARALLEL_DIFF_VIEW_TYPE);
const url = mergeUrlParams({ view: PARALLEL_DIFF_VIEW_TYPE }, window.location.href);
historyPushState(url);
@@ -524,7 +527,7 @@ export const setCurrentFileHash = ({ commit }, hash) => {
commit(types.SET_CURRENT_DIFF_FILE, hash);
};
-export const scrollToFile = ({ state, commit, getters }, { path, setHash = true }) => {
+export const scrollToFile = ({ state, commit, getters }, { path }) => {
if (!state.treeEntries[path]) return;
const { fileHash } = state.treeEntries[path];
@@ -534,11 +537,9 @@ export const scrollToFile = ({ state, commit, getters }, { path, setHash = true
if (getters.isVirtualScrollingEnabled) {
eventHub.$emit('scrollToFileHash', fileHash);
- if (setHash) {
- setTimeout(() => {
- window.history.replaceState(null, null, `#${fileHash}`);
- });
- }
+ setTimeout(() => {
+ window.history.replaceState(null, null, `#${fileHash}`);
+ });
} else {
document.location.hash = fileHash;
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index ca85be5d829..3a85c1a9fe1 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -1,6 +1,5 @@
-import Cookies from 'js-cookie';
-import { getParameterValues } from '~/lib/utils/url_utility';
import { __, n__ } from '~/locale';
+import { getParameterValues } from '~/lib/utils/url_utility';
import {
PARALLEL_DIFF_VIEW_TYPE,
INLINE_DIFF_VIEW_TYPE,
@@ -175,21 +174,11 @@ export function suggestionCommitMessage(state, _, rootState) {
}
export const isVirtualScrollingEnabled = (state) => {
- const vSrollerCookie = Cookies.get('diffs_virtual_scrolling');
-
- if (state.disableVirtualScroller) {
+ if (state.disableVirtualScroller || getParameterValues('virtual_scrolling')[0] === 'false') {
return false;
}
- if (vSrollerCookie) {
- return vSrollerCookie === 'true';
- }
-
- return (
- !state.viewDiffsFileByFile &&
- (window.gon?.features?.diffsVirtualScrolling ||
- getParameterValues('virtual_scrolling')[0] === 'true')
- );
+ return !state.viewDiffsFileByFile;
};
export const isBatchLoading = (state) => state.batchLoadingState === 'loading';
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 5f66360a040..329db1fe2cf 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -1,10 +1,10 @@
-import Cookies from 'js-cookie';
+import { getCookie } from '~/lib/utils/common_utils';
import { getParameterValues } from '~/lib/utils/url_utility';
import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
const getViewTypeFromQueryString = () => getParameterValues('view')[0];
-const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
+const viewTypeFromCookie = getCookie(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
export default () => ({
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 3f1af68e37a..f2028892a5f 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -9,7 +9,6 @@ import {
NEW_LINE_TYPE,
OLD_LINE_TYPE,
MATCH_LINE_TYPE,
- LINES_TO_BE_RENDERED_DIRECTLY,
INLINE_DIFF_LINES_KEY,
CONFLICT_OUR,
CONFLICT_THEIR,
@@ -380,16 +379,9 @@ function prepareDiffFileLines(file) {
return file;
}
-function finalizeDiffFile(file, index) {
- let renderIt = Boolean(window.gon?.features?.diffsVirtualScrolling);
-
- if (!window.gon?.features?.diffsVirtualScrolling) {
- renderIt =
- index < 3 ? file[INLINE_DIFF_LINES_KEY].length < LINES_TO_BE_RENDERED_DIRECTLY : false;
- }
-
+function finalizeDiffFile(file) {
Object.assign(file, {
- renderIt,
+ renderIt: true,
isShowingFullFile: false,
isLoadingFullFile: false,
discussions: [],
@@ -417,15 +409,13 @@ export function prepareDiffData({ diff, priorFiles = [], meta = false }) {
.map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta }))
.map(ensureBasicDiffFileLines)
.map(prepareDiffFileLines)
- .map((file, index) => finalizeDiffFile(file, priorFiles.length + index));
+ .map((file) => finalizeDiffFile(file));
return deduplicateFilesList([...priorFiles, ...cleanedFiles]);
}
export function getDiffPositionByLineCode(diffFiles) {
- let lines = [];
-
- lines = diffFiles.reduce((acc, diffFile) => {
+ const lines = diffFiles.reduce((acc, diffFile) => {
diffFile[INLINE_DIFF_LINES_KEY].forEach((line) => {
acc.push({ file: diffFile, line });
});
diff --git a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
index 05ce617ca7c..2fba02f212b 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
@@ -20,33 +20,6 @@ export class YamlEditorExtension {
}
/**
- * Extends the source editor with capabilities for yaml files.
- *
- * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
- * @param {YamlEditorExtensionOptions} setupOptions
- */
- onSetup(instance, setupOptions = {}) {
- const { enableComments = false, highlightPath = null, model = null } = setupOptions;
- this.enableComments = enableComments;
- this.highlightPath = highlightPath;
- this.model = model;
-
- if (model) {
- this.initFromModel(instance, model);
- }
-
- instance.onDidChangeModelContent(() => instance.onUpdate());
- }
-
- initFromModel(instance, model) {
- const doc = new Document(model);
- if (this.enableComments) {
- YamlEditorExtension.transformComments(doc);
- }
- instance.setValue(doc.toString());
- }
-
- /**
* @private
* This wraps long comments to a maximum line length of 80 chars.
*
@@ -164,10 +137,10 @@ export class YamlEditorExtension {
if (!path) throw Error(`No path provided.`);
const blob = instance.getValue();
const doc = parseDocument(blob);
- const pathArray = toPath(path);
+ const pathArray = Array.isArray(path) ? path : toPath(path);
if (!doc.getIn(pathArray)) {
- throw Error(`The node ${path} could not be found inside the document.`);
+ return [null, null];
}
const parentNode = doc.getIn(pathArray.slice(0, pathArray.length - 1));
@@ -190,6 +163,33 @@ export class YamlEditorExtension {
return [startLine, endLine];
}
+ /**
+ * Extends the source editor with capabilities for yaml files.
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param {YamlEditorExtensionOptions} setupOptions
+ */
+ onSetup(instance, setupOptions = {}) {
+ const { enableComments = false, highlightPath = null, model = null } = setupOptions;
+ this.enableComments = enableComments;
+ this.highlightPath = highlightPath;
+ this.model = model;
+
+ if (model) {
+ this.initFromModel(instance, model);
+ }
+
+ instance.onDidChangeModelContent(() => instance.onUpdate());
+ }
+
+ initFromModel(instance, model) {
+ const doc = new Document(model);
+ if (this.enableComments) {
+ YamlEditorExtension.transformComments(doc);
+ }
+ instance.setValue(doc.toString());
+ }
+
setDoc(instance, doc) {
if (this.enableComments) {
YamlEditorExtension.transformComments(doc);
@@ -202,18 +202,31 @@ export class YamlEditorExtension {
}
}
- highlight(instance, path) {
+ highlight(instance, path, keepOnNotFound = false) {
// IMPORTANT
// removeHighlight and highlightLines both come from
// SourceEditorExtension. So it has to be installed prior to this extension
if (this.highlightPath === path) return;
- if (!path) {
+
+ if (!path || !path.length) {
instance.removeHighlights();
- } else {
- const res = YamlEditorExtension.locate(instance, path);
- instance.highlightLines(res);
+ this.highlightPath = null;
+ return;
}
- this.highlightPath = path || null;
+
+ const [startLine, endLine] = YamlEditorExtension.locate(instance, path);
+
+ if (startLine === null) {
+ // Path could not be found.
+ if (!keepOnNotFound) {
+ instance.removeHighlights();
+ this.highlightPath = null;
+ }
+ return;
+ }
+
+ instance.highlightLines([startLine, endLine]);
+ this.highlightPath = path;
}
provides() {
@@ -283,18 +296,23 @@ export class YamlEditorExtension {
* Add a line highlight style to the node specified by the path.
*
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
- * @param {string|null|false} path A path to a node of the Editor's value,
+ * @param {string|(string|number)[]|null|false} path A path to a node
+ * of the Editor's
+ * value,
* e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all
* highlights.
+ * @param {boolean} [keepOnNotFound=false] If the passed path cannot
+ * be located, keep the previous highlight state
*/
- highlight: (instance, path) => this.highlight(instance, path),
+ highlight: (instance, path, keepOnNotFound) => this.highlight(instance, path, keepOnNotFound),
/**
* Return the line numbers of a certain node identified by `path` within
* the yaml.
*
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
- * @param {string} path A path to a node, eg. `foo.bar[0]`
+ * @param {string|(string|number)[]} path A path to a node, eg.
+ * `foo.bar[0]`
* @returns {number[]} Array following the schema `[firstLine, lastLine]`
* (both inclusive)
*
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index f0db3e5594b..4d9fe6ff851 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -765,6 +765,9 @@
"filter": {
"oneOf": [
{
+ "type": "null"
+ },
+ {
"$ref": "#/definitions/filter_refs"
},
{
diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js
index 1a084d37762..0986533dcd1 100644
--- a/app/assets/javascripts/emoji/awards_app/index.js
+++ b/app/assets/javascripts/emoji/awards_app/index.js
@@ -12,6 +12,7 @@ export default (el) => {
return new Vue({
el,
+ name: 'AwardsListRoot',
store: createstore(),
computed: {
...mapState(['currentUserId', 'canAwardEmoji', 'awards']),
diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js
index f0340209248..f83bfe614dd 100644
--- a/app/assets/javascripts/emoji/awards_app/store/actions.js
+++ b/app/assets/javascripts/emoji/awards_app/store/actions.js
@@ -33,20 +33,51 @@ export const fetchAwards = async ({ commit, dispatch, state }, page = '1') => {
}
};
+/**
+ * Creates an intermediary award, used for display
+ * until the real award is loaded from the backend.
+ */
+const newOptimisticAward = (name, state) => {
+ const freeId = Math.min(...state.awards.map((a) => a.id), Number.MAX_SAFE_INTEGER) - 1;
+ return {
+ id: freeId,
+ name,
+ user: {
+ id: window.gon.current_user_id,
+ name: window.gon.current_user_fullname,
+ username: window.gon.current_username,
+ },
+ };
+};
+
export const toggleAward = async ({ commit, state }, name) => {
const award = state.awards.find((a) => a.name === name && a.user.id === state.currentUserId);
try {
if (award) {
- await axios.delete(joinPaths(gon.relative_url_root || '', `${state.path}/${award.id}`));
-
commit(REMOVE_AWARD, award.id);
+ await axios
+ .delete(joinPaths(gon.relative_url_root || '', `${state.path}/${award.id}`))
+ .catch((err) => {
+ commit(ADD_NEW_AWARD, award);
+
+ throw err;
+ });
+
showToast(__('Award removed'));
} else {
- const { data } = await axios.post(joinPaths(gon.relative_url_root || '', state.path), {
- name,
- });
+ const optimisticAward = newOptimisticAward(name, state);
+
+ commit(ADD_NEW_AWARD, optimisticAward);
+
+ const { data } = await axios
+ .post(joinPaths(gon.relative_url_root || '', state.path), {
+ name,
+ })
+ .finally(() => {
+ commit(REMOVE_AWARD, optimisticAward.id);
+ });
commit(ADD_NEW_AWARD, data);
diff --git a/app/assets/javascripts/emoji/components/utils.js b/app/assets/javascripts/emoji/components/utils.js
index 3465a8ae7e6..5eec0992896 100644
--- a/app/assets/javascripts/emoji/components/utils.js
+++ b/app/assets/javascripts/emoji/components/utils.js
@@ -1,5 +1,5 @@
-import Cookies from 'js-cookie';
import { chunk, memoize, uniq } from 'lodash';
+import { getCookie, setCookie } from '~/lib/utils/common_utils';
import { initEmojiMap, getEmojiCategoryMap } from '~/emoji';
import {
EMOJIS_PER_ROW,
@@ -13,7 +13,7 @@ export const generateCategoryHeight = (emojisLength) =>
emojisLength * EMOJI_ROW_HEIGHT + CATEGORY_ROW_HEIGHT;
export const getFrequentlyUsedEmojis = () => {
- const savedEmojis = Cookies.get(FREQUENTLY_USED_COOKIE_KEY);
+ const savedEmojis = getCookie(FREQUENTLY_USED_COOKIE_KEY);
if (!savedEmojis) return null;
@@ -30,13 +30,13 @@ export const getFrequentlyUsedEmojis = () => {
export const addToFrequentlyUsed = (emoji) => {
const frequentlyUsedEmojis = uniq(
- (Cookies.get(FREQUENTLY_USED_COOKIE_KEY) || '')
+ (getCookie(FREQUENTLY_USED_COOKIE_KEY) || '')
.split(',')
.filter((e) => e)
.concat(emoji),
);
- Cookies.set(FREQUENTLY_USED_COOKIE_KEY, frequentlyUsedEmojis.join(','), { expires: 365 });
+ setCookie(FREQUENTLY_USED_COOKIE_KEY, frequentlyUsedEmojis.join(','));
};
export const hasFrequentlyUsedEmojis = () => getFrequentlyUsedEmojis() !== null;
diff --git a/app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js b/app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js
new file mode 100644
index 00000000000..012cf949c96
--- /dev/null
+++ b/app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js
@@ -0,0 +1,3 @@
+import { initRedirectListboxBehavior } from '~/listbox/redirect_behavior';
+
+initRedirectListboxBehavior();
diff --git a/app/assets/javascripts/environments/components/canary_ingress.vue b/app/assets/javascripts/environments/components/canary_ingress.vue
index 02d660a91c1..30f3f9dfc75 100644
--- a/app/assets/javascripts/environments/components/canary_ingress.vue
+++ b/app/assets/javascripts/environments/components/canary_ingress.vue
@@ -17,6 +17,11 @@ export default {
required: true,
type: Object,
},
+ graphql: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
},
ingressOptions: Array(100 / 5 + 1)
.fill(0)
@@ -47,11 +52,17 @@ export default {
canaryWeightId() {
return uniqueId('canary-weight-');
},
+ weight() {
+ if (this.graphql) {
+ return this.canaryIngress.canaryWeight;
+ }
+ return this.canaryIngress.canary_weight;
+ },
stableWeight() {
- return (100 - this.canaryIngress.canary_weight).toString();
+ return (100 - this.weight).toString();
},
canaryWeight() {
- return this.canaryIngress.canary_weight.toString();
+ return this.weight.toString();
},
},
methods: {
diff --git a/app/assets/javascripts/environments/components/canary_update_modal.vue b/app/assets/javascripts/environments/components/canary_update_modal.vue
index 8b1121c7158..fd4885a9dbd 100644
--- a/app/assets/javascripts/environments/components/canary_update_modal.vue
+++ b/app/assets/javascripts/environments/components/canary_update_modal.vue
@@ -71,7 +71,7 @@ export default {
mutation: updateCanaryIngress,
variables: {
input: {
- id: this.environment.global_id,
+ id: this.environment.global_id || this.environment.globalId,
weight: this.weight,
},
},
diff --git a/app/assets/javascripts/environments/components/commit.vue b/app/assets/javascripts/environments/components/commit.vue
new file mode 100644
index 00000000000..54b94480685
--- /dev/null
+++ b/app/assets/javascripts/environments/components/commit.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlAvatar, GlAvatarLink, GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import { escape } from 'lodash';
+
+export default {
+ components: {
+ GlAvatar,
+ GlAvatarLink,
+ GlLink,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ commit: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ commitMessage() {
+ return this.commit?.message;
+ },
+ commitAuthorPath() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return this.commit?.author?.path || `mailto:${escape(this.commit?.authorEmail)}`;
+ },
+ commitAuthorAvatar() {
+ return this.commit?.author?.avatarUrl || this.commit?.authorGravatarUrl;
+ },
+ commitAuthor() {
+ return this.commit?.author?.name || this.commit?.authorName;
+ },
+ commitPath() {
+ return this.commit?.commitPath;
+ },
+ },
+};
+</script>
+<template>
+ <div data-testid="deployment-commit" class="gl-display-flex gl-align-items-center">
+ <gl-avatar-link v-gl-tooltip :title="commitAuthor" :href="commitAuthorPath">
+ <gl-avatar :size="16" :src="commitAuthorAvatar" />
+ </gl-avatar-link>
+ <gl-link
+ v-gl-tooltip
+ :title="commitMessage"
+ :href="commitPath"
+ class="gl-ml-3 gl-str-truncated"
+ >
+ {{ commitMessage }}
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index c642a07fd1e..8a379ebdf66 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
/**
* Renders a deploy board.
*
@@ -17,11 +16,11 @@ import {
GlTooltip,
GlTooltipDirective,
GlSafeHtmlDirective as SafeHtml,
+ GlSprintf,
} from '@gitlab/ui';
import { isEmpty } from 'lodash';
-import { n__ } from '~/locale';
+import { s__, n__ } from '~/locale';
import instanceComponent from '~/vue_shared/components/deployment_instance.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { STATUS_MAP, CANARY_STATUS } from '../constants';
import CanaryIngress from './canary_ingress.vue';
@@ -32,13 +31,13 @@ export default {
GlIcon,
GlLoadingIcon,
GlLink,
+ GlSprintf,
GlTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
- mixins: [glFeatureFlagsMixin()],
props: {
deployBoardData: {
type: Object,
@@ -57,6 +56,11 @@ export default {
required: false,
default: '',
},
+ graphql: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
canRenderDeployBoard() {
@@ -65,8 +69,15 @@ export default {
canRenderEmptyState() {
return this.isEmpty;
},
+ canaryIngress() {
+ if (this.graphql) {
+ return this.deployBoardData.canaryIngress;
+ }
+
+ return this.deployBoardData.canary_ingress;
+ },
canRenderCanaryWeight() {
- return !isEmpty(this.deployBoardData.canary_ingress);
+ return !isEmpty(this.canaryIngress);
},
instanceCount() {
const { instances } = this.deployBoardData;
@@ -90,8 +101,20 @@ export default {
deployBoardSvg() {
return deployBoardSvg;
},
+ rollbackUrl() {
+ if (this.graphql) {
+ return this.deployBoardData.rollbackUrl;
+ }
+ return this.deployBoardData.rollback_url;
+ },
+ abortUrl() {
+ if (this.graphql) {
+ return this.deployBoardData.abortUrl;
+ }
+ return this.deployBoardData.abort_url;
+ },
deployBoardActions() {
- return this.deployBoardData.rollback_url || this.deployBoardData.abort_url;
+ return this.rollbackUrl || this.abortUrl;
},
statuses() {
// Canary is not a pod status but it needs to be in the legend.
@@ -106,7 +129,17 @@ export default {
changeCanaryWeight(weight) {
this.$emit('changeCanaryWeight', weight);
},
+ podName(instance) {
+ if (this.graphql) {
+ return instance.podName;
+ }
+
+ return instance.pod_name;
+ },
},
+ emptyStateText: s__(
+ 'DeployBoards|To see deployment progress for your environments, make sure you are deploying to %{codeStart}$KUBE_NAMESPACE%{codeEnd} and annotating with %{codeStart}app.gitlab.com/app=$CI_PROJECT_PATH_SLUG%{codeEnd} and %{codeStart}app.gitlab.com/env=$CI_ENVIRONMENT_SLUG%{codeEnd}.',
+ ),
};
</script>
<template>
@@ -152,7 +185,7 @@ export default {
:key="i"
:status="instance.status"
:tooltip-text="instance.tooltip"
- :pod-name="instance.pod_name"
+ :pod-name="podName(instance)"
:logs-path="logsPath"
:stable="instance.stable"
/>
@@ -163,22 +196,23 @@ export default {
<canary-ingress
v-if="canRenderCanaryWeight"
class="deploy-board-canary-ingress"
- :canary-ingress="deployBoardData.canary_ingress"
+ :canary-ingress="canaryIngress"
+ :graphql="graphql"
@change="changeCanaryWeight"
/>
<section v-if="deployBoardActions" class="deploy-board-actions">
<gl-link
- v-if="deployBoardData.rollback_url"
- :href="deployBoardData.rollback_url"
+ v-if="rollbackUrl"
+ :href="rollbackUrl"
class="btn"
data-method="post"
rel="nofollow"
>{{ __('Rollback') }}</gl-link
>
<gl-link
- v-if="deployBoardData.abort_url"
- :href="deployBoardData.abort_url"
+ v-if="abortUrl"
+ :href="abortUrl"
class="btn btn-danger btn-inverted"
data-method="post"
rel="nofollow"
@@ -196,11 +230,11 @@ export default {
__('Kubernetes deployment not found')
}}</span>
<span>
- To see deployment progress for your environments, make sure you are deploying to
- <code>$KUBE_NAMESPACE</code> and annotating with
- <code>app.gitlab.com/app=$CI_PROJECT_PATH_SLUG</code>
- and
- <code>app.gitlab.com/env=$CI_ENVIRONMENT_SLUG</code>.
+ <gl-sprintf :message="$options.emptyStateText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
</span>
</section>
</div>
diff --git a/app/assets/javascripts/environments/components/deploy_board_wrapper.vue b/app/assets/javascripts/environments/components/deploy_board_wrapper.vue
new file mode 100644
index 00000000000..d9d77088ad3
--- /dev/null
+++ b/app/assets/javascripts/environments/components/deploy_board_wrapper.vue
@@ -0,0 +1,86 @@
+<script>
+import { GlCollapse, GlButton } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import setEnvironmentToChangeCanaryMutation from '../graphql/mutations/set_environment_to_change_canary.mutation.graphql';
+import DeployBoard from './deploy_board.vue';
+
+export default {
+ components: {
+ DeployBoard,
+ GlButton,
+ GlCollapse,
+ },
+ props: {
+ rolloutStatus: {
+ required: true,
+ type: Object,
+ },
+ environment: {
+ required: true,
+ type: Object,
+ },
+ },
+ data() {
+ return { visible: false };
+ },
+ computed: {
+ icon() {
+ return this.visible ? 'angle-down' : 'angle-right';
+ },
+ label() {
+ return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand;
+ },
+ isLoading() {
+ return this.rolloutStatus.status === 'loading';
+ },
+ isEmpty() {
+ return this.rolloutStatus.status === 'not_found';
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.visible = !this.visible;
+ },
+ changeCanaryWeight(weight) {
+ this.$apollo.mutate({
+ mutation: setEnvironmentToChangeCanaryMutation,
+ variables: {
+ environment: this.environment,
+ weight,
+ },
+ });
+ },
+ },
+ i18n: {
+ collapse: __('Collapse'),
+ expand: __('Expand'),
+ pods: s__('DeployBoard|Kubernetes Pods'),
+ },
+};
+</script>
+<template>
+ <div>
+ <div>
+ <gl-button
+ class="gl-mr-4 gl-min-w-fit-content"
+ :icon="icon"
+ :aria-label="label"
+ size="small"
+ category="tertiary"
+ @click="toggleCollapse"
+ />
+ <span>{{ $options.i18n.pods }}</span>
+ </div>
+ <gl-collapse :visible="visible">
+ <deploy-board
+ :deploy-board-data="rolloutStatus"
+ :is-loading="isLoading"
+ :is-empty="isEmpty"
+ :environment="environment"
+ graphql
+ class="gl-reset-bg!"
+ @changeCanaryWeight="changeCanaryWeight"
+ />
+ </gl-collapse>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue
index ef43ca6bc33..f98edb6bb7d 100644
--- a/app/assets/javascripts/environments/components/deployment.vue
+++ b/app/assets/javascripts/environments/components/deployment.vue
@@ -1,25 +1,240 @@
<script>
+import {
+ GlBadge,
+ GlButton,
+ GlCollapse,
+ GlIcon,
+ GlLink,
+ GlTooltipDirective as GlTooltip,
+ GlTruncate,
+} from '@gitlab/ui';
+import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
+import { __, s__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import DeploymentStatusBadge from './deployment_status_badge.vue';
+import Commit from './commit.vue';
export default {
components: {
+ ClipboardButton,
+ Commit,
DeploymentStatusBadge,
+ GlBadge,
+ GlButton,
+ GlCollapse,
+ GlIcon,
+ GlLink,
+ GlTruncate,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip,
},
props: {
deployment: {
type: Object,
required: true,
},
+ latest: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return { visible: false };
},
computed: {
status() {
return this.deployment?.status;
},
+ iid() {
+ return this.deployment?.iid;
+ },
+ shortSha() {
+ return this.commit?.shortId;
+ },
+ createdAt() {
+ return this.deployment?.createdAt;
+ },
+ isMobile() {
+ return !GlBreakpointInstance.isDesktop();
+ },
+ detailsButton() {
+ return this.visible
+ ? { text: this.$options.i18n.hideDetails, icon: 'expand-up' }
+ : { text: this.$options.i18n.showDetails, icon: 'expand-down' };
+ },
+ detailsButtonClasses() {
+ return this.isMobile ? 'gl-sr-only' : '';
+ },
+ commit() {
+ return this.deployment?.commit;
+ },
+ user() {
+ return this.deployment?.user;
+ },
+ username() {
+ return `@${this.user.username}`;
+ },
+ userPath() {
+ return this.user?.path;
+ },
+ deployable() {
+ return this.deployment?.deployable;
+ },
+ jobName() {
+ return this.deployable?.name;
+ },
+ jobPath() {
+ return this.deployable?.buildPath;
+ },
+ refLabel() {
+ return this.deployment?.tag ? this.$options.i18n.tag : this.$options.i18n.branch;
+ },
+ ref() {
+ return this.deployment?.ref;
+ },
+ refName() {
+ return this.ref?.name;
+ },
+ refPath() {
+ return this.ref?.refPath;
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.visible = !this.visible;
+ },
+ },
+ i18n: {
+ latestBadge: s__('Deployment|Latest Deployed'),
+ deploymentId: s__('Deployment|Deployment ID'),
+ copyButton: __('Copy commit SHA'),
+ commitSha: __('Commit SHA'),
+ showDetails: __('Show details'),
+ hideDetails: __('Hide details'),
+ triggerer: s__('Deployment|Triggerer'),
+ job: __('Job'),
+ api: __('API'),
+ branch: __('Branch'),
+ tag: __('Tag'),
},
+ headerClasses: [
+ 'gl-display-flex',
+ 'gl-align-items-flex-start',
+ 'gl-md-align-items-center',
+ 'gl-justify-content-space-between',
+ 'gl-pr-6',
+ ],
+ headerDetailsClasses: [
+ 'gl-display-flex',
+ 'gl-flex-direction-column',
+ 'gl-md-flex-direction-row',
+ 'gl-align-items-flex-start',
+ 'gl-md-align-items-center',
+ 'gl-font-sm',
+ 'gl-text-gray-700',
+ ],
+ deploymentStatusClasses: [
+ 'gl-display-flex',
+ 'gl-gap-x-3',
+ 'gl-mr-0',
+ 'gl-md-mr-5',
+ 'gl-mb-3',
+ 'gl-md-mb-0',
+ ],
};
</script>
<template>
<div>
- <deployment-status-badge v-if="status" :status="status" />
+ <div :class="$options.headerClasses">
+ <div :class="$options.headerDetailsClasses">
+ <div :class="$options.deploymentStatusClasses">
+ <deployment-status-badge v-if="status" :status="status" />
+ <gl-badge v-if="latest" variant="info">{{ $options.i18n.latestBadge }}</gl-badge>
+ </div>
+ <div class="gl-display-flex gl-align-items-center gl-gap-x-5">
+ <div
+ v-if="iid"
+ v-gl-tooltip
+ class="gl-display-flex"
+ :title="$options.i18n.deploymentId"
+ :aria-label="$options.i18n.deploymentId"
+ >
+ <gl-icon ref="deployment-iid-icon" name="deployments" />
+ <span class="gl-ml-2">#{{ iid }}</span>
+ </div>
+ <div
+ v-if="shortSha"
+ data-testid="deployment-commit-sha"
+ class="gl-font-monospace gl-display-flex gl-align-items-center"
+ >
+ <gl-icon ref="deployment-commit-icon" name="commit" class="gl-mr-2" />
+ <span v-gl-tooltip :title="$options.i18n.commitSha">{{ shortSha }}</span>
+ <clipboard-button
+ :text="shortSha"
+ category="tertiary"
+ :title="$options.i18n.copyButton"
+ size="small"
+ />
+ </div>
+ <time-ago-tooltip v-if="createdAt" :time="createdAt" class="gl-display-flex">
+ <template #default="{ timeAgo }">
+ <gl-icon name="calendar" />
+ <span class="gl-mr-2 gl-white-space-nowrap">{{ timeAgo }}</span>
+ </template>
+ </time-ago-tooltip>
+ </div>
+ </div>
+ <gl-button
+ ref="details-toggle"
+ category="tertiary"
+ :icon="detailsButton.icon"
+ :button-text-classes="detailsButtonClasses"
+ @click="toggleCollapse"
+ >
+ {{ detailsButton.text }}
+ </gl-button>
+ </div>
+ <commit v-if="commit" :commit="commit" class="gl-mt-3" />
+ <gl-collapse :visible="visible">
+ <div
+ class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0"
+ >
+ <div v-if="user" class="gl-display-flex gl-flex-direction-column gl-md-max-w-15p">
+ <span class="gl-text-gray-500">{{ $options.i18n.triggerer }}</span>
+ <gl-link :href="userPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="username" with-tooltip />
+ </gl-link>
+ </div>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
+ >
+ <span class="gl-text-gray-500" :class="{ 'gl-ml-3': !deployable }">
+ {{ $options.i18n.job }}
+ </span>
+ <gl-link v-if="jobPath" :href="jobPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="jobName" with-tooltip position="middle" />
+ </gl-link>
+ <span v-else-if="jobName" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="jobName" with-tooltip position="middle" />
+ </span>
+ <gl-badge v-else class="gl-font-monospace gl-mt-3" variant="info">
+ {{ $options.i18n.api }}
+ </gl-badge>
+ </div>
+ <div
+ v-if="ref"
+ class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
+ >
+ <span class="gl-text-gray-500">{{ refLabel }}</span>
+ <gl-link :href="refPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="refName" with-tooltip />
+ </gl-link>
+ </div>
+ </div>
+ </gl-collapse>
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue
index 977da12e8a9..36b9b647af7 100644
--- a/app/assets/javascripts/environments/components/empty_state.vue
+++ b/app/assets/javascripts/environments/components/empty_state.vue
@@ -12,10 +12,10 @@ export default {
<template>
<div class="empty-state">
<div class="text-content">
- <h4 class="blank-state-title js-blank-state-title">
+ <h4 class="js-blank-state-title">
{{ s__("Environments|You don't have any environments right now") }}
</h4>
- <p class="blank-state-text">
+ <p>
{{
s__(`Environments|Environments are places where
code gets deployed, such as staging or production.`)
diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue
index 0b753d53ee3..f5a83b97552 100644
--- a/app/assets/javascripts/environments/components/environment_pin.vue
+++ b/app/assets/javascripts/environments/components/environment_pin.vue
@@ -6,6 +6,7 @@
import { GlDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
import eventHub from '../event_hub';
+import cancelAutoStopMutation from '../graphql/mutations/cancel_auto_stop.mutation.graphql';
export default {
components: {
@@ -16,10 +17,22 @@ export default {
type: String,
required: true,
},
+ graphql: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
methods: {
onPinClick() {
- eventHub.$emit('cancelAutoStop', this.autoStopUrl);
+ if (this.graphql) {
+ this.$apollo.mutate({
+ mutation: cancelAutoStopMutation,
+ variables: { autoStopUrl: this.autoStopUrl },
+ });
+ } else {
+ eventHub.$emit('cancelAutoStop', this.autoStopUrl);
+ }
},
},
title: __('Prevent auto-stopping'),
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index d3624103c13..27a763fb9c4 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -4,10 +4,12 @@ import {
GlDropdown,
GlButton,
GlLink,
+ GlSprintf,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql';
import ExternalUrl from './environment_external_url.vue';
import Actions from './environment_actions.vue';
@@ -18,6 +20,7 @@ import Monitoring from './environment_monitoring.vue';
import Terminal from './environment_terminal_button.vue';
import Delete from './environment_delete.vue';
import Deployment from './deployment.vue';
+import DeployBoardWrapper from './deploy_board_wrapper.vue';
export default {
components: {
@@ -25,19 +28,23 @@ export default {
GlDropdown,
GlButton,
GlLink,
+ GlSprintf,
Actions,
Deployment,
+ DeployBoardWrapper,
ExternalUrl,
StopComponent,
Rollback,
Monitoring,
Pin,
Terminal,
+ TimeAgoTooltip,
Delete,
},
directives: {
GlTooltip,
},
+ inject: ['helpPagePath'],
props: {
environment: {
required: true,
@@ -60,6 +67,10 @@ export default {
i18n: {
collapse: __('Collapse'),
expand: __('Expand'),
+ emptyState: s__(
+ 'Environments|There are no deployments for this environment yet. %{linkStart}Learn more about setting up deployments.%{linkEnd}',
+ ),
+ autoStopIn: s__('Environment|Auto stop %{time}'),
},
data() {
return { visible: false };
@@ -83,12 +94,15 @@ export default {
upcomingDeployment() {
return this.environment?.upcomingDeployment;
},
+ hasDeployment() {
+ return Boolean(this.environment?.upcomingDeployment || this.environment?.lastDeployment);
+ },
actions() {
if (!this.lastDeployment) {
return [];
}
- const { manualActions = [], scheduledActions = [] } = this.lastDeployment;
- const combinedActions = [...manualActions, ...scheduledActions];
+ const { manualActions, scheduledActions } = this.lastDeployment;
+ const combinedActions = [...(manualActions ?? []), ...(scheduledActions ?? [])];
return combinedActions.map((action) => ({
...action,
}));
@@ -133,6 +147,9 @@ export default {
displayName() {
return truncate(this.name, 80);
},
+ rolloutStatus() {
+ return this.environment?.rolloutStatus;
+ },
},
methods: {
toggleCollapse() {
@@ -144,7 +161,15 @@ export default {
'gl-border-t-solid',
'gl-border-1',
'gl-py-5',
- 'gl-pl-7',
+ 'gl-md-pl-7',
+ 'gl-bg-gray-10',
+ ],
+ deployBoardClasses: [
+ 'gl-border-gray-100',
+ 'gl-border-t-solid',
+ 'gl-border-1',
+ 'gl-py-4',
+ 'gl-md-pl-7',
'gl-bg-gray-10',
],
};
@@ -176,7 +201,14 @@ export default {
{{ displayName }}
</gl-link>
</div>
- <div>
+ <div class="gl-display-flex gl-align-items-center">
+ <p v-if="canShowAutoStopDate" class="gl-font-sm gl-text-gray-700 gl-mr-5 gl-mb-0">
+ <gl-sprintf :message="$options.i18n.autoStopIn">
+ <template #time>
+ <time-ago-tooltip :time="environment.autoStopAt" css-class="gl-font-weight-bold" />
+ </template>
+ </gl-sprintf>
+ </p>
<div class="btn-group table-action-buttons" role="group">
<external-url
v-if="externalUrl"
@@ -224,6 +256,7 @@ export default {
<pin
v-if="canShowAutoStopDate"
:auto-stop-url="autoStopPath"
+ graphql
data-track-action="click_button"
data-track-label="environment_pin"
/>
@@ -254,11 +287,37 @@ export default {
</div>
</div>
<gl-collapse :visible="visible">
- <div v-if="lastDeployment" :class="$options.deploymentClasses">
- <deployment :deployment="lastDeployment" :class="{ 'gl-ml-7': inFolder }" />
+ <template v-if="hasDeployment">
+ <div v-if="lastDeployment" :class="$options.deploymentClasses">
+ <deployment
+ :deployment="lastDeployment"
+ :class="{ 'gl-ml-7': inFolder }"
+ latest
+ class="gl-pl-4"
+ />
+ </div>
+ <div v-if="upcomingDeployment" :class="$options.deploymentClasses">
+ <deployment
+ :deployment="upcomingDeployment"
+ :class="{ 'gl-ml-7': inFolder }"
+ class="gl-pl-4"
+ />
+ </div>
+ </template>
+ <div v-else :class="$options.deploymentClasses">
+ <gl-sprintf :message="$options.i18n.emptyState">
+ <template #link="{ content }">
+ <gl-link :href="helpPagePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</div>
- <div v-if="upcomingDeployment" :class="$options.deploymentClasses">
- <deployment :deployment="upcomingDeployment" :class="{ 'gl-ml-7': inFolder }" />
+ <div v-if="rolloutStatus" :class="$options.deployBoardClasses">
+ <deploy-board-wrapper
+ :rollout-status="rolloutStatus"
+ :environment="environment"
+ :class="{ 'gl-ml-7': inFolder }"
+ class="gl-pl-4"
+ />
</div>
</gl-collapse>
</div>
diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue
index cb36e226d0e..3699f39b611 100644
--- a/app/assets/javascripts/environments/components/new_environments_app.vue
+++ b/app/assets/javascripts/environments/components/new_environments_app.vue
@@ -8,16 +8,19 @@ import pageInfoQuery from '../graphql/queries/page_info.query.graphql';
import environmentToDeleteQuery from '../graphql/queries/environment_to_delete.query.graphql';
import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql';
import environmentToStopQuery from '../graphql/queries/environment_to_stop.query.graphql';
+import environmentToChangeCanaryQuery from '../graphql/queries/environment_to_change_canary.query.graphql';
import EnvironmentFolder from './new_environment_folder.vue';
import EnableReviewAppModal from './enable_review_app_modal.vue';
import StopEnvironmentModal from './stop_environment_modal.vue';
import EnvironmentItem from './new_environment_item.vue';
import ConfirmRollbackModal from './confirm_rollback_modal.vue';
import DeleteEnvironmentModal from './delete_environment_modal.vue';
+import CanaryUpdateModal from './canary_update_modal.vue';
export default {
components: {
DeleteEnvironmentModal,
+ CanaryUpdateModal,
ConfirmRollbackModal,
EnvironmentFolder,
EnableReviewAppModal,
@@ -56,6 +59,12 @@ export default {
environmentToStop: {
query: environmentToStopQuery,
},
+ environmentToChangeCanary: {
+ query: environmentToChangeCanaryQuery,
+ },
+ weight: {
+ query: environmentToChangeCanaryQuery,
+ },
},
inject: ['newEnvironmentPath', 'canCreateEnvironment'],
i18n: {
@@ -80,6 +89,8 @@ export default {
environmentToDelete: {},
environmentToRollback: {},
environmentToStop: {},
+ environmentToChangeCanary: {},
+ weight: 0,
};
},
computed: {
@@ -186,6 +197,7 @@ export default {
<delete-environment-modal :environment="environmentToDelete" graphql />
<stop-environment-modal :environment="environmentToStop" graphql />
<confirm-rollback-modal :environment="environmentToRollback" graphql />
+ <canary-update-modal :environment="environmentToChangeCanary" :weight="weight" />
<gl-tabs
:action-secondary="addEnvironment"
:action-primary="openReviewAppModal"
diff --git a/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql
index 22dfb8a7a89..0b473495710 100644
--- a/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql
+++ b/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql
@@ -1,5 +1,5 @@
-mutation cancelAutoStop($environment: LocalEnvironment) {
- cancelAutoStop(environment: $environment) @client {
+mutation cancelAutoStop($autoStopUrl: String!) {
+ cancelAutoStop(autoStopUrl: $autoStopUrl) @client {
errors
}
}
diff --git a/app/assets/javascripts/environments/graphql/mutations/set_environment_to_change_canary.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_change_canary.mutation.graphql
new file mode 100644
index 00000000000..0f48c1f5c05
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_change_canary.mutation.graphql
@@ -0,0 +1,3 @@
+mutation SetEnvironmentToChangeCanary($environment: LocalEnvironmentInput, $weight: Int!) {
+ setEnvironmentToChangeCanary(environment: $environment, weight: $weight) @client
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_to_change_canary.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_to_change_canary.query.graphql
new file mode 100644
index 00000000000..b582ae55ba1
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/environment_to_change_canary.query.graphql
@@ -0,0 +1,4 @@
+query environmentToChangeCanary {
+ environmentToChangeCanary @client
+ weight @client
+}
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index 812fa0c81f0..dc763b77157 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -10,6 +10,7 @@ import pollIntervalQuery from './queries/poll_interval.query.graphql';
import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql';
import environmentToStopQuery from './queries/environment_to_stop.query.graphql';
import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
+import environmentToChangeCanaryQuery from './queries/environment_to_change_canary.query.graphql';
import pageInfoQuery from './queries/page_info.query.graphql';
const buildErrors = (errors = []) => ({
@@ -134,9 +135,15 @@ export const resolvers = (endpoint) => ({
data: { environmentToRollback: environment },
});
},
- cancelAutoStop(_, { environment: { autoStopPath } }) {
+ setEnvironmentToChangeCanary(_, { environment, weight }, { client }) {
+ client.writeQuery({
+ query: environmentToChangeCanaryQuery,
+ data: { environmentToChangeCanary: environment, weight },
+ });
+ },
+ cancelAutoStop(_, { autoStopUrl }) {
return axios
- .post(autoStopPath)
+ .post(autoStopUrl)
.then(() => buildErrors())
.catch((err) =>
buildErrors([
diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql
index c02f6b2838a..b4d1f7326f6 100644
--- a/app/assets/javascripts/environments/graphql/typedefs.graphql
+++ b/app/assets/javascripts/environments/graphql/typedefs.graphql
@@ -77,9 +77,10 @@ extend type Mutation {
stopEnvironment(environment: LocalEnvironmentInput): LocalErrors
deleteEnvironment(environment: LocalEnvironmentInput): LocalErrors
rollbackEnvironment(environment: LocalEnvironmentInput): LocalErrors
- cancelAutoStop(environment: LocalEnvironmentInput): LocalErrors
+ cancelAutoStop(autoStopUrl: String!): LocalErrors
setEnvironmentToDelete(environment: LocalEnvironmentInput): LocalErrors
setEnvironmentToRollback(environment: LocalEnvironmentInput): LocalErrors
setEnvironmentToStop(environment: LocalEnvironmentInput): LocalErrors
+ setEnvironmentToChangeCanary(environment: LocalEnvironmentInput, weight: Int): LocalErrors
action(environment: LocalEnvironmentInput): LocalErrors
}
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 0d7a475eb8e..071c95b8f0a 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -4,7 +4,7 @@
* causes reflows, visit https://gist.github.com/paulirish/5d52fb081b3570c81e3a
*/
-import Cookies from 'js-cookie';
+import { getCookie } from '~/lib/utils/common_utils';
const LINE_NUMBER_CLASS = 'diff-line-num';
const UNFOLDABLE_LINE_CLASS = 'js-unfold';
@@ -29,7 +29,7 @@ export default {
$diffFile.closest(DIFF_CONTAINER_SELECTOR).data('canCreateNote') === '';
}
- this.isParallelView = Cookies.get('diff_view') === 'parallel';
+ this.isParallelView = getCookie('diff_view') === 'parallel';
if (this.userCanCreateNote) {
$diffFile
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index d00e6e59cf5..28a3c54cc8f 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -13,6 +13,21 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
IssuableTokenKeys.tokenKeys.splice(2, 0, reviewerToken);
IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, reviewerToken);
+ if (window.gon?.features?.mrAttentionRequests) {
+ const attentionRequestedToken = {
+ formattedKey: __('Attention'),
+ key: 'attention',
+ type: 'string',
+ param: '',
+ symbol: '@',
+ icon: 'user',
+ tag: '@attention',
+ hideNotEqual: true,
+ };
+ IssuableTokenKeys.tokenKeys.splice(2, 0, attentionRequestedToken);
+ IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, attentionRequestedToken);
+ }
+
const draftToken = {
token: {
formattedKey: __('Draft'),
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index 3cd4d48a4a3..09cef74477c 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -77,6 +77,11 @@ export default class AvailableDropdownMappings {
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-reviewer'),
},
+ attention: {
+ reference: null,
+ gl: DropdownUser,
+ element: this.container.getElementById('js-dropdown-attention-requested'),
+ },
'approved-by': {
reference: null,
gl: DropdownUser,
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index e2d6936acbd..f8b5910de9e 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -1,4 +1,4 @@
-export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by', 'reviewer'];
+export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by', 'reviewer', 'attention'];
export const DROPDOWN_TYPE = {
hint: 'hint',
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index d9c2e55cffe..fa605f8c056 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -18,6 +18,13 @@ const VARIANT_DANGER = 'danger';
const VARIANT_INFO = 'info';
const VARIANT_TIP = 'tip';
+const TYPE_TO_VARIANT = {
+ [FLASH_TYPES.ALERT]: VARIANT_DANGER,
+ [FLASH_TYPES.NOTICE]: VARIANT_INFO,
+ [FLASH_TYPES.SUCCESS]: VARIANT_SUCCESS,
+ [FLASH_TYPES.WARNING]: VARIANT_WARNING,
+};
+
const FLASH_CLOSED_EVENT = 'flashClosed';
const getCloseEl = (flashEl) => {
@@ -61,7 +68,7 @@ const createAction = (config) => `
`;
const createFlashEl = (message, type) => `
- <div class="flash-${type}">
+ <div class="flash-${type}" data-testid="alert-${TYPE_TO_VARIANT[type]}">
<div class="flash-text">
${escape(message)}
<div class="close-icon-wrapper js-close-icon">
@@ -189,6 +196,9 @@ const createAlert = function createAlert({
secondaryButtonLink: secondaryButton?.link,
secondaryButtonText: secondaryButton?.text,
},
+ attrs: {
+ 'data-testid': `alert-${variant}`,
+ },
on,
},
message,
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 69331ff1a06..bf29a356abd 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -86,6 +86,7 @@ export const defaultAutocompleteConfig = {
labels: true,
snippets: true,
vulnerabilities: true,
+ contacts: true,
};
class GfmAutoComplete {
@@ -127,6 +128,7 @@ class GfmAutoComplete {
if (this.enableMap.mergeRequests) this.setupMergeRequests($input);
if (this.enableMap.labels) this.setupLabels($input);
if (this.enableMap.snippets) this.setupSnippets($input);
+ if (this.enableMap.contacts) this.setupContacts($input);
$input.filter('[data-supports-quick-actions="true"]').atwho({
at: '/',
@@ -174,9 +176,16 @@ class GfmAutoComplete {
let tpl = '/${name} ';
let referencePrefix = null;
if (value.params.length > 0) {
- [[referencePrefix]] = value.params;
- if (/^[@%~]/.test(referencePrefix)) {
+ const regexp = /\[[a-z]+:/;
+ const match = regexp.exec(value.params);
+ if (match) {
+ [referencePrefix] = match;
tpl += '<%- referencePrefix %>';
+ } else {
+ [[referencePrefix]] = value.params;
+ if (/^[@%~]/.test(referencePrefix)) {
+ tpl += '<%- referencePrefix %>';
+ }
}
}
return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix });
@@ -266,6 +275,8 @@ class GfmAutoComplete {
UNASSIGN_REVIEWER: '/unassign_reviewer',
REASSIGN: '/reassign',
CC: '/cc',
+ ATTENTION: '/attention',
+ REMOVE_ATTENTION: '/remove_attention',
};
let assignees = [];
let reviewers = [];
@@ -344,6 +355,23 @@ class GfmAutoComplete {
} else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) {
// Only include members which are not assigned as a reviewer to Issuable currently
return data.filter((member) => reviewers.includes(member.search));
+ } else if (
+ command === MEMBER_COMMAND.ATTENTION ||
+ command === MEMBER_COMMAND.REMOVE_ATTENTION
+ ) {
+ const attentionUsers = [
+ ...(SidebarMediator.singleton?.store?.assignees || []),
+ ...(SidebarMediator.singleton?.store?.reviewers || []),
+ ];
+ const attentionRequested = command === MEMBER_COMMAND.REMOVE_ATTENTION;
+
+ return data.filter((member) =>
+ attentionUsers.find(
+ (u) =>
+ createMemberSearchString(u).includes(member.search) &&
+ u.attention_requested === attentionRequested,
+ ),
+ );
}
return data;
@@ -619,6 +647,42 @@ class GfmAutoComplete {
});
}
+ setupContacts($input) {
+ $input.atwho({
+ at: '[contact:',
+ suffix: ']',
+ alias: 'contacts',
+ searchKey: 'search',
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.email != null) {
+ tmpl = GfmAutoComplete.Contacts.templateFunction(value);
+ }
+ return tmpl;
+ },
+ data: GfmAutoComplete.defaultLoadingData,
+ // eslint-disable-next-line no-template-curly-in-string
+ insertTpl: '${atwho-at}${email}',
+ callbacks: {
+ ...this.getDefaultCallbacks(),
+ beforeSave(contacts) {
+ return $.map(contacts, (m) => {
+ if (m.email == null) {
+ return m;
+ }
+ return {
+ id: m.id,
+ email: m.email,
+ firstName: m.first_name,
+ lastName: m.last_name,
+ search: `${m.email}`,
+ };
+ });
+ },
+ },
+ });
+ }
+
getDefaultCallbacks() {
const self = this;
@@ -790,6 +854,7 @@ GfmAutoComplete.atTypeMap = {
'/': 'commands',
'[vulnerability:': 'vulnerabilities',
$: 'snippets',
+ '[contact:': 'contacts',
};
GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
@@ -883,6 +948,11 @@ GfmAutoComplete.Milestones = {
return `<li>${escape(title)}</li>`;
},
};
+GfmAutoComplete.Contacts = {
+ templateFunction({ email, firstName, lastName }) {
+ return `<li><small>${firstName} ${lastName}</small> ${escape(email)}</li>`;
+ },
+};
GfmAutoComplete.Loading = {
template:
'<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>',
diff --git a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue b/app/assets/javascripts/google_cloud/components/deployments_service_table.vue
index 7d27d7cf6b2..26c9fd14dc6 100644
--- a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue
+++ b/app/assets/javascripts/google_cloud/components/deployments_service_table.vue
@@ -2,6 +2,9 @@
import { GlButton, GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
+const cloudRun = 'cloudRun';
+const cloudStorage = 'cloudStorage';
+
const i18n = {
cloudRun: __('Cloud Run'),
cloudRunDescription: __('Deploy container based web apps on Google managed clusters'),
@@ -28,6 +31,13 @@ export default {
required: true,
},
},
+ methods: {
+ actionUrl(key) {
+ if (key === cloudRun) return this.cloudRunUrl;
+ else if (key === cloudStorage) return this.cloudStorageUrl;
+ return '#';
+ },
+ },
fields: [
{ key: 'title', label: i18n.service },
{ key: 'description', label: i18n.description },
@@ -37,12 +47,19 @@ export default {
{
title: i18n.cloudRun,
description: i18n.cloudRunDescription,
- action: { title: i18n.configureViaMergeRequest, disabled: true },
+ action: {
+ key: cloudRun,
+ title: i18n.configureViaMergeRequest,
+ },
},
{
title: i18n.cloudStorage,
description: i18n.cloudStorageDescription,
- action: { title: i18n.configureViaMergeRequest, disabled: true },
+ action: {
+ key: cloudStorage,
+ title: i18n.configureViaMergeRequest,
+ disabled: true,
+ },
},
],
i18n,
@@ -54,7 +71,9 @@ export default {
<p>{{ $options.i18n.deploymentsDescription }}</p>
<gl-table :fields="$options.fields" :items="$options.items">
<template #cell(action)="{ value }">
- <gl-button :disabled="value.disabled">{{ value.title }}</gl-button>
+ <gl-button :disabled="value.disabled" :href="actionUrl(value.key)">
+ {{ value.title }}
+ </gl-button>
</template>
</gl-table>
</div>
diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue
index 8ef110dcf22..c08d8bb7c51 100644
--- a/app/assets/javascripts/google_cloud/components/home.vue
+++ b/app/assets/javascripts/google_cloud/components/home.vue
@@ -23,11 +23,11 @@ export default {
type: String,
required: true,
},
- deploymentsCloudRunUrl: {
+ enableCloudRunUrl: {
type: String,
required: true,
},
- deploymentsCloudStorageUrl: {
+ enableCloudStorageUrl: {
type: String,
required: true,
},
@@ -47,8 +47,8 @@ export default {
</gl-tab>
<gl-tab :title="__('Deployments')">
<deployments-service-table
- :cloud-run-url="deploymentsCloudRunUrl"
- :cloud-storage-url="deploymentsCloudStorageUrl"
+ :cloud-run-url="enableCloudRunUrl"
+ :cloud-storage-url="enableCloudStorageUrl"
/>
</gl-tab>
<gl-tab :title="__('Services')" disabled />
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
index e7a09668473..551783e6c50 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
+++ b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
@@ -1,9 +1,9 @@
<script>
-import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
- components: { GlButton, GlFormGroup, GlFormSelect },
+ components: { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox },
props: {
gcpProjects: { required: true, type: Array },
environments: { required: true, type: Array },
@@ -19,6 +19,9 @@ export default {
environmentDescription: __('Generated service account is linked to the selected environment'),
submitLabel: __('Create service account'),
cancelLabel: __('Cancel'),
+ checkboxLabel: __(
+ 'I understand the responsibilities involved with managing service account keys',
+ ),
},
};
</script>
@@ -59,6 +62,11 @@ export default {
</option>
</gl-form-select>
</gl-form-group>
+ <gl-form-group>
+ <gl-form-checkbox name="confirmation" required>
+ {{ $options.i18n.checkboxLabel }}
+ </gl-form-checkbox>
+ </gl-form-group>
<div class="form-actions row">
<gl-button type="submit" category="primary" variant="confirm">
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
index b70b25a5dc3..4db84746482 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
+++ b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
@@ -1,9 +1,9 @@
<script>
-import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
+import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
- components: { GlButton, GlEmptyState, GlTable },
+ components: { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable },
props: {
list: {
type: Array,
@@ -28,6 +28,22 @@ export default {
],
};
},
+ i18n: {
+ createServiceAccount: __('Create service account'),
+ found: __('✔'),
+ notFound: __('Not found'),
+ noServiceAccountsTitle: __('No service accounts'),
+ noServiceAccountsDescription: __(
+ 'Service Accounts keys authorize GitLab to deploy your Google Cloud project',
+ ),
+ serviceAccountsTitle: __('Service accounts'),
+ serviceAccountsDescription: __(
+ 'Service Accounts keys authorize GitLab to deploy your Google Cloud project',
+ ),
+ secretManagersDescription: __(
+ 'Enhance security by storing service account keys in secret managers - learn more about %{docLinkStart}secret management with GitLab%{docLinkEnd}',
+ ),
+ },
};
</script>
@@ -35,31 +51,39 @@ export default {
<div>
<gl-empty-state
v-if="list.length === 0"
- :title="__('No service accounts')"
- :description="
- __('Service Accounts keys authorize GitLab to deploy your Google Cloud project')
- "
+ :title="$options.i18n.noServiceAccountsTitle"
+ :description="$options.i18n.noServiceAccountsDescription"
:primary-button-link="createUrl"
- :primary-button-text="__('Create service account')"
+ :primary-button-text="$options.i18n.createServiceAccount"
:svg-path="emptyIllustrationUrl"
/>
<div v-else>
- <h2 class="gl-font-size-h2">{{ __('Service Accounts') }}</h2>
- <p>{{ __('Service Accounts keys authorize GitLab to deploy your Google Cloud project') }}</p>
+ <h2 class="gl-font-size-h2">{{ $options.i18n.serviceAccountsTitle }}</h2>
+ <p>{{ $options.i18n.serviceAccountsDescription }}</p>
<gl-table :items="list" :fields="tableFields">
<template #cell(service_account_exists)="{ value }">
- {{ value ? '✔' : __('Not found') }}
+ {{ value ? $options.i18n.found : $options.i18n.notFound }}
</template>
<template #cell(service_account_key_exists)="{ value }">
- {{ value ? '✔' : __('Not found') }}
+ {{ value ? $options.i18n.found : $options.i18n.notFound }}
</template>
</gl-table>
<gl-button :href="createUrl" category="primary" variant="info">
- {{ __('Create service account') }}
+ {{ $options.i18n.createServiceAccount }}
</gl-button>
+
+ <gl-alert class="gl-mt-5" :dismissible="false" variant="tip">
+ <gl-sprintf :message="$options.i18n.secretManagersDescription">
+ <template #docLink="{ content }">
+ <gl-link href="https://docs.gitlab.com/ee/ci/secrets/">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index ab80e15c2ec..55987ce64e6 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -1,5 +1,43 @@
+import { v4 as uuidv4 } from 'uuid';
import { logError } from '~/lib/logger';
+const SKU_PREMIUM = '2c92a00d76f0d5060176f2fb0a5029ff';
+const SKU_ULTIMATE = '2c92a0ff76f0d5250176f2f8c86f305a';
+const PRODUCT_INFO = {
+ [SKU_PREMIUM]: {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Premium',
+ id: '0002',
+ price: '228',
+ variant: 'SaaS',
+ },
+ [SKU_ULTIMATE]: {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Ultimate',
+ id: '0001',
+ price: '1188',
+ variant: 'SaaS',
+ },
+};
+
+const generateProductInfo = (sku, quantity) => {
+ const product = PRODUCT_INFO[sku];
+
+ if (!product) {
+ logError('Unexpected product sku provided to generateProductInfo');
+ return {};
+ }
+
+ const productInfo = {
+ ...product,
+ brand: 'GitLab',
+ category: 'DevOps',
+ quantity,
+ };
+
+ return productInfo;
+};
+
const isSupported = () => Boolean(window.dataLayer) && gon.features?.gitlabGtmDatalayer;
const pushEvent = (event, args = {}) => {
@@ -17,6 +55,22 @@ const pushEvent = (event, args = {}) => {
}
};
+const pushEnhancedEcommerceEvent = (event, args = {}) => {
+ if (!window.dataLayer) {
+ return;
+ }
+
+ try {
+ window.dataLayer.push({ ecommerce: null }); // Clear the previous ecommerce object
+ window.dataLayer.push({
+ event,
+ ...args,
+ });
+ } catch (e) {
+ logError('Unexpected error while pushing to dataLayer', e);
+ }
+};
+
const pushAccountSubmit = (accountType, accountMethod) =>
pushEvent('accountSubmit', { accountType, accountMethod });
@@ -120,3 +174,60 @@ export const trackSaasTrialGetStarted = () => {
pushEvent('saasTrialGetStarted');
});
};
+
+export const trackCheckout = (selectedPlan, quantity) => {
+ if (!isSupported()) {
+ return;
+ }
+
+ const product = generateProductInfo(selectedPlan, quantity);
+
+ if (Object.keys(product).length === 0) {
+ return;
+ }
+
+ const eventData = {
+ ecommerce: {
+ currencyCode: 'USD',
+ checkout: {
+ actionField: { step: 1 },
+ products: [product],
+ },
+ },
+ };
+
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ pushEnhancedEcommerceEvent('EECCheckout', eventData);
+};
+
+export const trackTransaction = (transactionDetails) => {
+ if (!isSupported()) {
+ return;
+ }
+
+ const transactionId = uuidv4();
+ const { paymentOption, revenue, tax, selectedPlan, quantity } = transactionDetails;
+ const product = generateProductInfo(selectedPlan, quantity);
+
+ if (Object.keys(product).length === 0) {
+ return;
+ }
+
+ const eventData = {
+ ecommerce: {
+ currencyCode: 'USD',
+ purchase: {
+ actionField: {
+ id: transactionId,
+ affiliation: 'GitLab',
+ option: paymentOption,
+ revenue: revenue.toString(),
+ tax: tax.toString(),
+ },
+ products: [product],
+ },
+ },
+ };
+
+ pushEnhancedEcommerceEvent('EECtransactionSuccess', eventData);
+};
diff --git a/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js b/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js
deleted file mode 100644
index 30888e20a46..00000000000
--- a/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js
+++ /dev/null
@@ -1,17 +0,0 @@
-export const vulnerabilityLocationTypes = {
- __schema: {
- types: [
- {
- kind: 'UNION',
- name: 'VulnerabilityLocation',
- possibleTypes: [
- { name: 'VulnerabilityLocationContainerScanning' },
- { name: 'VulnerabilityLocationDast' },
- { name: 'VulnerabilityLocationDependencyScanning' },
- { name: 'VulnerabilityLocationSast' },
- { name: 'VulnerabilityLocationSecretDetection' },
- ],
- },
- ],
- },
-};
diff --git a/app/assets/javascripts/graphql_shared/possibleTypes.json b/app/assets/javascripts/graphql_shared/possibleTypes.json
new file mode 100644
index 00000000000..9a24d2a3afc
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/possibleTypes.json
@@ -0,0 +1 @@
+{"AlertManagementIntegration":["AlertManagementHttpIntegration","AlertManagementPrometheusIntegration"],"CurrentUserTodos":["BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest"],"DependencyLinkMetadata":["NugetDependencyLinkMetadata"],"DesignFields":["Design","DesignAtVersion"],"Entry":["Blob","Submodule","TreeEntry"],"Eventable":["BoardEpic","Epic"],"Issuable":["Epic","Issue","MergeRequest"],"JobNeedUnion":["CiBuildNeed","CiJob"],"MemberInterface":["GroupMember","ProjectMember"],"NoteableInterface":["AlertManagementAlert","BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest","Snippet","Vulnerability"],"NoteableType":["Design","Issue","MergeRequest"],"OrchestrationPolicy":["ScanExecutionPolicy","ScanResultPolicy"],"PackageFileMetadata":["ConanFileMetadata","HelmFileMetadata"],"PackageMetadata":["ComposerMetadata","ConanMetadata","MavenMetadata","NugetMetadata","PypiMetadata"],"ResolvableInterface":["Discussion","Note"],"Service":["BaseService","JiraService"],"TimeboxReportInterface":["Iteration","Milestone"],"User":["MergeRequestAssignee","MergeRequestReviewer","UserCore"],"VulnerabilityDetail":["VulnerabilityDetailBase","VulnerabilityDetailBoolean","VulnerabilityDetailCode","VulnerabilityDetailCommit","VulnerabilityDetailDiff","VulnerabilityDetailFileLocation","VulnerabilityDetailInt","VulnerabilityDetailList","VulnerabilityDetailMarkdown","VulnerabilityDetailModuleLocation","VulnerabilityDetailTable","VulnerabilityDetailText","VulnerabilityDetailUrl"],"VulnerabilityLocation":["VulnerabilityLocationClusterImageScanning","VulnerabilityLocationContainerScanning","VulnerabilityLocationCoverageFuzzing","VulnerabilityLocationDast","VulnerabilityLocationDependencyScanning","VulnerabilityLocationGeneric","VulnerabilityLocationSast","VulnerabilityLocationSecretDetection"]}
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index a1ec5942d64..e3147065d5c 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -41,6 +41,7 @@ export default {
},
data() {
return {
+ isModalVisible: false,
isLoading: true,
isSearchEmpty: false,
searchEmptyMessage: '',
@@ -101,6 +102,12 @@ export default {
eventHub.$off(`${this.action}updateGroups`, this.updateGroups);
},
methods: {
+ hideModal() {
+ this.isModalVisible = false;
+ },
+ showModal() {
+ this.isModalVisible = true;
+ },
fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
return this.service
.getGroups(parentId, page, filterGroupsBy, sortBy, archived)
@@ -185,6 +192,7 @@ export default {
showLeaveGroupModal(group, parentGroup) {
this.targetGroup = group;
this.targetParentGroup = parentGroup;
+ this.showModal();
},
leaveGroup() {
this.targetGroup.isBeingRemoved = true;
@@ -256,10 +264,12 @@ export default {
/>
<gl-modal
modal-id="leave-group-modal"
+ :visible="isModalVisible"
:title="__('Are you sure?')"
:action-primary="primaryProps"
:action-cancel="cancelProps"
@primary="leaveGroup"
+ @hide="hideModal"
>
{{ groupLeaveConfirmationMessage }}
</gl-modal>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 10c45abbfa2..707008ec493 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -34,8 +34,8 @@ export default {
),
itemCaret,
itemTypeIcon,
- itemStats,
itemActions,
+ itemStats,
},
props: {
parentGroup: {
@@ -92,6 +92,9 @@ export default {
complianceFramework() {
return this.group.complianceFramework;
},
+ showActionsMenu() {
+ return this.isGroup && (this.group.canEdit || this.group.canRemove || this.group.canLeave);
+ },
},
methods: {
onClickRowGroup(e) {
@@ -197,17 +200,19 @@ export default {
<div v-if="isGroupPendingRemoval">
<gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge>
</div>
- <div class="metadata d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between">
+ <div
+ class="metadata gl-display-flex gl-flex-grow-1 gl-flex-shrink-0 gl-flex-wrap justify-content-md-between"
+ >
+ <item-stats
+ :item="group"
+ class="group-stats gl-mt-2 gl-display-none gl-md-display-flex gl-align-items-center"
+ />
<item-actions
- v-if="isGroup"
+ v-if="showActionsMenu"
:group="group"
:parent-group="parentGroup"
:action="action"
/>
- <item-stats
- :item="group"
- class="group-stats gl-mt-2 d-none d-md-flex gl-align-items-center"
- />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue
index dfc1549fb4a..7afea815197 100644
--- a/app/assets/javascripts/groups/components/invite_members_banner.vue
+++ b/app/assets/javascripts/groups/components/invite_members_banner.vue
@@ -46,7 +46,6 @@ export default {
},
openModal() {
eventHub.$emit('openModal', {
- inviteeType: 'members',
source: this.$options.openModalSource,
});
this.track(this.$options.buttonClickEvent);
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
index df751a3f37e..fc7cfffc22c 100644
--- a/app/assets/javascripts/groups/components/item_actions.vue
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -1,15 +1,17 @@
<script>
-import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui';
+import { GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { COMMON_STR } from '../constants';
import eventHub from '../event_hub';
+const { LEAVE_BTN_TITLE, EDIT_BTN_TITLE, REMOVE_BTN_TITLE, OPTIONS_DROPDOWN_TITLE } = COMMON_STR;
+
export default {
components: {
- GlButton,
+ GlDropdown,
+ GlDropdownItem,
},
directives: {
GlTooltip: GlTooltipDirective,
- GlModal: GlModalDirective,
},
props: {
parentGroup: {
@@ -28,11 +30,8 @@ export default {
},
},
computed: {
- leaveBtnTitle() {
- return COMMON_STR.LEAVE_BTN_TITLE;
- },
- editBtnTitle() {
- return COMMON_STR.EDIT_BTN_TITLE;
+ removeButtonHref() {
+ return `${this.group.editPath}#js-remove-group-form`;
},
},
methods: {
@@ -40,33 +39,51 @@ export default {
eventHub.$emit(`${this.action}showLeaveGroupModal`, this.group, this.parentGroup);
},
},
+ i18n: {
+ leaveBtnTitle: LEAVE_BTN_TITLE,
+ editBtnTitle: EDIT_BTN_TITLE,
+ removeBtnTitle: REMOVE_BTN_TITLE,
+ optionsDropdownTitle: OPTIONS_DROPDOWN_TITLE,
+ },
};
</script>
<template>
- <div class="controls d-flex justify-content-end">
- <gl-button
- v-if="group.canLeave"
- v-gl-tooltip.top
- v-gl-modal.leave-group-modal
- :title="leaveBtnTitle"
- :aria-label="leaveBtnTitle"
- data-testid="leave-group-btn"
- size="small"
- icon="leave"
- class="leave-group gl-ml-3"
- @click.stop="onLeaveGroup"
- />
- <gl-button
- v-if="group.canEdit"
- v-gl-tooltip.top
- :href="group.editPath"
- :title="editBtnTitle"
- :aria-label="editBtnTitle"
- data-testid="edit-group-btn"
- size="small"
- icon="pencil"
- class="edit-group gl-ml-3"
- />
+ <div class="gl-display-flex gl-justify-content-end gl-ml-5">
+ <gl-dropdown
+ v-gl-tooltip.hover.focus="$options.i18n.optionsDropdownTitle"
+ right
+ category="tertiary"
+ icon="ellipsis_v"
+ no-caret
+ :data-testid="`group-${group.id}-dropdown-button`"
+ data-qa-selector="group_dropdown_button"
+ :data-qa-group-id="group.id"
+ >
+ <gl-dropdown-item
+ v-if="group.canEdit"
+ :data-testid="`edit-group-${group.id}-btn`"
+ :href="group.editPath"
+ @click.stop
+ >
+ {{ $options.i18n.editBtnTitle }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="group.canLeave"
+ :data-testid="`leave-group-${group.id}-btn`"
+ @click.stop="onLeaveGroup"
+ >
+ {{ $options.i18n.leaveBtnTitle }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="group.canRemove"
+ :href="removeButtonHref"
+ :data-testid="`remove-group-${group.id}-btn`"
+ variant="danger"
+ @click.stop
+ >
+ {{ $options.i18n.removeBtnTitle }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue
new file mode 100644
index 00000000000..e848f10352d
--- /dev/null
+++ b/app/assets/javascripts/groups/components/transfer_group_form.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlFormGroup } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
+import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue';
+
+export const i18n = {
+ confirmationMessage: __(
+ 'You are going to transfer %{group_name} to another namespace. Are you ABSOLUTELY sure?',
+ ),
+ emptyNamespaceTitle: __('No parent group'),
+ dropdownTitle: s__('GroupSettings|Select parent group'),
+};
+
+export default {
+ name: 'TransferGroupForm',
+ components: {
+ ConfirmDanger,
+ GlFormGroup,
+ NamespaceSelect,
+ },
+ props: {
+ groupNamespaces: {
+ type: Array,
+ required: true,
+ },
+ isPaidGroup: {
+ type: Boolean,
+ required: true,
+ },
+ confirmationPhrase: {
+ type: String,
+ required: true,
+ },
+ confirmButtonText: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedId: null,
+ };
+ },
+ computed: {
+ disableSubmitButton() {
+ return this.isPaidGroup || !this.selectedId;
+ },
+ },
+ methods: {
+ handleSelected({ id }) {
+ this.selectedId = id;
+ },
+ },
+ i18n,
+};
+</script>
+<template>
+ <div>
+ <gl-form-group v-if="!isPaidGroup">
+ <namespace-select
+ :default-text="$options.i18n.dropdownTitle"
+ :group-namespaces="groupNamespaces"
+ :empty-namespace-title="$options.i18n.emptyNamespaceTitle"
+ :include-headers="false"
+ include-empty-namespace
+ data-testid="transfer-group-namespace-select"
+ @select="handleSelected"
+ />
+ <input type="hidden" name="new_parent_group_id" :value="selectedId" />
+ </gl-form-group>
+ <confirm-danger
+ button-class="qa-transfer-button"
+ :disabled="disableSubmitButton"
+ :phrase="confirmationPhrase"
+ :button-text="confirmButtonText"
+ @confirm="$emit('confirm')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index e2722d780dc..005bac1e7b5 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -15,8 +15,10 @@ export const COMMON_STR = {
LEAVE_FORBIDDEN: s__(
'GroupsTree|Failed to leave the group. Please make sure you are not the only owner.',
),
- LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
- EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
+ LEAVE_BTN_TITLE: s__('GroupsTree|Leave group'),
+ EDIT_BTN_TITLE: s__('GroupsTree|Edit'),
+ REMOVE_BTN_TITLE: s__('GroupsTree|Delete'),
+ OPTIONS_DROPDOWN_TITLE: s__('GroupsTree|Options'),
GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'),
GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'),
};
diff --git a/app/assets/javascripts/groups/init_transfer_group_form.js b/app/assets/javascripts/groups/init_transfer_group_form.js
new file mode 100644
index 00000000000..f055b926918
--- /dev/null
+++ b/app/assets/javascripts/groups/init_transfer_group_form.js
@@ -0,0 +1,52 @@
+import Vue from 'vue';
+import { sprintf } from '~/locale';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import TransferGroupForm, { i18n } from './components/transfer_group_form.vue';
+
+const prepareGroups = (rawGroups) => {
+ if (!rawGroups) {
+ return [];
+ }
+
+ return JSON.parse(rawGroups).map(({ id, text: humanName }) => ({
+ id,
+ humanName,
+ }));
+};
+
+export default () => {
+ const el = document.querySelector('.js-transfer-group-form');
+ if (!el) {
+ return false;
+ }
+
+ const {
+ targetFormId = null,
+ buttonText: confirmButtonText = '',
+ groupName = '',
+ parentGroups,
+ isPaidGroup,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ confirmDangerMessage: sprintf(i18n.confirmationMessage, { group_name: groupName }),
+ },
+ render(createElement) {
+ return createElement(TransferGroupForm, {
+ props: {
+ groupNamespaces: prepareGroups(parentGroups),
+ isPaidGroup: parseBoolean(isPaidGroup),
+ confirmButtonText,
+ confirmationPhrase: groupName,
+ },
+ on: {
+ confirm: () => {
+ document.getElementById(targetFormId)?.submit();
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups/landing.js b/app/assets/javascripts/groups/landing.js
index bfb4d9ce67b..ed76bebf843 100644
--- a/app/assets/javascripts/groups/landing.js
+++ b/app/assets/javascripts/groups/landing.js
@@ -1,5 +1,4 @@
-import Cookies from 'js-cookie';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
class Landing {
constructor(landingElement, dismissButton, cookieName) {
@@ -27,11 +26,11 @@ class Landing {
dismissLanding() {
this.landingElement.classList.add('hidden');
- Cookies.set(this.cookieName, 'true', { expires: 365 });
+ setCookie(this.cookieName, 'true');
}
isDismissed() {
- return parseBoolean(Cookies.get(this.cookieName));
+ return parseBoolean(getCookie(this.cookieName));
}
}
diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js
index d3600bd223a..0917b9ceccf 100644
--- a/app/assets/javascripts/groups/store/groups_store.js
+++ b/app/assets/javascripts/groups/store/groups_store.js
@@ -83,6 +83,7 @@ export default class GroupsStore {
leavePath: rawGroupItem.leave_path,
canEdit: rawGroupItem.can_edit,
canLeave: rawGroupItem.can_leave,
+ canRemove: rawGroupItem.can_remove,
type: rawGroupItem.type,
permission: rawGroupItem.permission,
children: groupChildren,
diff --git a/app/assets/javascripts/groups/transfer_dropdown.js b/app/assets/javascripts/groups/transfer_dropdown.js
deleted file mode 100644
index d6343f698c0..00000000000
--- a/app/assets/javascripts/groups/transfer_dropdown.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { __ } from '~/locale';
-
-export default class TransferDropdown {
- constructor() {
- this.groupDropdown = $('.js-groups-dropdown');
- this.parentInput = $('#new_parent_group_id');
- this.data = this.groupDropdown.data('data');
- this.init();
- }
-
- init() {
- this.buildDropdown();
- }
-
- buildDropdown() {
- const extraOptions = [{ id: '-1', text: __('No parent group') }, { type: 'divider' }];
-
- initDeprecatedJQueryDropdown(this.groupDropdown, {
- selectable: true,
- filterable: true,
- toggleLabel: (item) => item.text,
- search: { fields: ['text'] },
- data: extraOptions.concat(this.data),
- text: (item) => item.text,
- clicked: (options) => {
- const { e } = options;
- e.preventDefault();
- this.assignSelected(options.selectedObj);
- },
- });
- }
-
- assignSelected(selected) {
- this.parentInput.val(selected.id);
- this.parentInput.change();
- }
-}
diff --git a/app/assets/javascripts/groups/transfer_edit.js b/app/assets/javascripts/groups/transfer_edit.js
deleted file mode 100644
index bb15e11fd4c..00000000000
--- a/app/assets/javascripts/groups/transfer_edit.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import $ from 'jquery';
-
-export default function setupTransferEdit(formSelector, targetSelector) {
- const $transferForm = $(formSelector);
- const $selectNamespace = $transferForm.find(targetSelector);
-
- $selectNamespace.on('change', () => {
- $transferForm.find(':submit').prop('disabled', !$selectNamespace.val());
- });
- $selectNamespace.trigger('change');
-}
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index bd71c5ebc11..64bba91eb4d 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -28,6 +28,7 @@ const groupsSelect = () => {
const skipGroups = $select.data('skipGroups') || [];
const parentGroupID = $select.data('parentId');
const groupsFilter = $select.data('groupsFilter');
+ const minAccessLevel = $select.data('minAccessLevel');
$select.select2({
placeholder: __('Search for a group'),
@@ -45,6 +46,7 @@ const groupsSelect = () => {
page,
per_page: window.GROUP_SELECT_PER_PAGE,
all_available: allAvailable,
+ min_access_level: minAccessLevel,
};
},
results(data, page) {
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index 846b4d92724..92dacf8c94a 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { leftSidebarViews } from '../constants';
@@ -7,6 +7,7 @@ import { leftSidebarViews } from '../constants';
export default {
components: {
GlIcon,
+ GlBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -82,9 +83,13 @@ export default {
@click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)"
>
<gl-icon name="commit" />
- <div v-if="stagedFiles.length > 0" class="ide-commit-badge badge badge-pill">
+ <gl-badge
+ v-if="stagedFiles.length"
+ class="gl-absolute gl-px-2 gl-top-3 gl-right-3 gl-font-weight-bold gl-bg-gray-900! gl-text-white!"
+ size="sm"
+ >
{{ stagedFiles.length }}
- </div>
+ </gl-badge>
</button>
</li>
</ul>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index 9ec4a07a3d0..44f543d9a76 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -187,7 +187,7 @@ export default {
class="qa-commit-button"
category="primary"
variant="confirm"
- @click="commit"
+ type="submit"
>
{{ __('Commit') }}
</gl-button>
diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue
index 13f2e775fc3..b1f6f2c87b9 100644
--- a/app/assets/javascripts/ide/components/preview/clientside.vue
+++ b/app/assets/javascripts/ide/components/preview/clientside.vue
@@ -4,7 +4,12 @@ import { listen } from 'codesandbox-api';
import { isEmpty, debounce } from 'lodash';
import { Manager } from 'smooshpack';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { packageJsonPath, LIVE_PREVIEW_DEBOUNCE } from '../../constants';
+import {
+ packageJsonPath,
+ LIVE_PREVIEW_DEBOUNCE,
+ PING_USAGE_PREVIEW_KEY,
+ PING_USAGE_PREVIEW_SUCCESS_KEY,
+} from '../../constants';
import eventHub from '../../eventhub';
import { createPathWithExt } from '../../utils';
import Navigator from './navigator.vue';
@@ -62,6 +67,15 @@ export default {
};
},
},
+ watch: {
+ sandpackReady: {
+ handler(val) {
+ if (val) {
+ this.pingUsage(PING_USAGE_PREVIEW_SUCCESS_KEY);
+ }
+ },
+ },
+ },
mounted() {
this.onFilesChangeCallback = debounce(() => this.update(), LIVE_PREVIEW_DEBOUNCE);
eventHub.$on('ide.files.change', this.onFilesChangeCallback);
@@ -101,7 +115,7 @@ export default {
initPreview() {
if (!this.mainEntry) return null;
- this.pingUsage();
+ this.pingUsage(PING_USAGE_PREVIEW_KEY);
return this.loadFileContent(this.mainEntry)
.then(() => this.$nextTick())
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 775b6906498..bfe4c3ac271 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -114,3 +114,7 @@ export const LIVE_PREVIEW_DEBOUNCE = 2000;
export const MAX_MR_FILES_AUTO_OPEN = 10;
export const DEFAULT_BRANCH = 'main';
+
+// Ping Usage Metrics Keys
+export const PING_USAGE_PREVIEW_KEY = 'web_ide_clientside_preview';
+export const PING_USAGE_PREVIEW_SUCCESS_KEY = 'web_ide_clientside_preview_success';
diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js
index e36419cd7eb..1a8e665867f 100644
--- a/app/assets/javascripts/ide/stores/modules/clientside/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js
@@ -1,9 +1,9 @@
import axios from '~/lib/utils/axios_utils';
-export const pingUsage = ({ rootGetters }) => {
+export const pingUsage = ({ rootGetters }, metricName) => {
const { web_url: projectUrl } = rootGetters.currentProject;
- const url = `${projectUrl}/service_ping/web_ide_clientside_preview`;
+ const url = `${projectUrl}/service_ping/${metricName}`;
return axios.post(url);
};
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
index 8ee72235a23..5ff00394e3b 100644
--- a/app/assets/javascripts/image_diff/helpers/badge_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -14,7 +14,15 @@ export function createImageBadge(noteId, { x, y }, classNames = []) {
}
export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
- const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']);
+ const buttonEl = createImageBadge(noteId, coordinate, [
+ 'gl-display-flex',
+ 'gl-align-items-center',
+ 'gl-justify-content-center',
+ 'gl-font-sm',
+ 'design-note-pin',
+ 'on-image',
+ 'gl-absolute',
+ ]);
buttonEl.textContent = badgeText;
containerEl.appendChild(buttonEl);
@@ -30,8 +38,8 @@ export function addImageCommentBadge(containerEl, { coordinate, noteId }) {
export function addAvatarBadge(el, event) {
const { noteId, badgeNumber } = event.detail;
- // Add badge to new comment
- const avatarBadgeEl = el.querySelector(`#${noteId} .badge`);
+ // Add design pin to new comment
+ const avatarBadgeEl = el.querySelector(`#${noteId} .design-note-pin`);
avatarBadgeEl.textContent = badgeNumber;
avatarBadgeEl.classList.remove('hidden');
}
diff --git a/app/assets/javascripts/image_diff/helpers/dom_helper.js b/app/assets/javascripts/image_diff/helpers/dom_helper.js
index a61e5f01f9b..3468a629f5a 100644
--- a/app/assets/javascripts/image_diff/helpers/dom_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/dom_helper.js
@@ -10,12 +10,12 @@ export function setPositionDataAttribute(el, options) {
}
export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) {
- const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .badge');
+ const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .design-note-pin');
avatarBadgeEl.textContent = newBadgeNumber;
}
export function updateDiscussionBadgeNumber(discussionEl, newBadgeNumber) {
- const discussionBadgeEl = discussionEl.querySelector('.badge');
+ const discussionBadgeEl = discussionEl.querySelector('.design-note-pin');
discussionBadgeEl.textContent = newBadgeNumber;
}
diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js
index a0dd8e6f894..e3ca4327efe 100644
--- a/app/assets/javascripts/image_diff/image_diff.js
+++ b/app/assets/javascripts/image_diff/image_diff.js
@@ -118,7 +118,7 @@ export default class ImageDiff {
removeBadge(event) {
const { badgeNumber } = event.detail;
const indexToRemove = badgeNumber - 1;
- const imageBadgeEls = this.imageFrameEl.querySelectorAll('.badge');
+ const imageBadgeEls = this.imageFrameEl.querySelectorAll('.design-note-pin');
if (this.imageBadges.length !== badgeNumber) {
// Cascade badges count numbers for (avatar badges + image badges)
diff --git a/app/assets/javascripts/image_diff/replaced_image_diff.js b/app/assets/javascripts/image_diff/replaced_image_diff.js
index a3d9b8a138a..8b84cc45c21 100644
--- a/app/assets/javascripts/image_diff/replaced_image_diff.js
+++ b/app/assets/javascripts/image_diff/replaced_image_diff.js
@@ -61,7 +61,7 @@ export default class ReplacedImageDiff extends ImageDiff {
this.currentView = newView;
// Clear existing badges on new view
- const existingBadges = this.imageFrameEl.querySelectorAll('.badge');
+ const existingBadges = this.imageFrameEl.querySelectorAll('.design-note-pin');
[...existingBadges].map((badge) => badge.remove());
// Remove existing references to old view image badges
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index 37597da3c8e..7a904bdb6ad 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -12,7 +12,7 @@ import {
} from '@gitlab/ui';
import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils';
import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
+import { s__, n__ } from '~/locale';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import Tracking from '~/tracking';
@@ -38,6 +38,8 @@ import {
import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import getIncidents from '../graphql/queries/get_incidents.query.graphql';
+const MAX_VISIBLE_ASSIGNEES = 4;
+
export default {
trackIncidentCreateNewOptions,
trackIncidentListViewsOptions,
@@ -94,6 +96,7 @@ export default {
thAttr: TH_PUBLISHED_TEST_ID,
},
],
+ MAX_VISIBLE_ASSIGNEES,
components: {
GlLoadingIcon,
GlTable,
@@ -295,6 +298,13 @@ export default {
errorAlertDismissed() {
this.isErrorAlertDismissed = true;
},
+ assigneesBadgeSrOnlyText(item) {
+ return n__(
+ '%d additional assignee',
+ '%d additional assignees',
+ item.assignees.nodes.length - MAX_VISIBLE_ASSIGNEES,
+ );
+ },
isValidSlaDueAt,
},
};
@@ -391,10 +401,11 @@ export default {
<gl-avatars-inline
:avatars="item.assignees.nodes"
:collapsed="true"
- :max-visible="4"
+ :max-visible="$options.MAX_VISIBLE_ASSIGNEES"
:avatar-size="24"
badge-tooltip-prop="name"
:badge-tooltip-max-chars="100"
+ :badge-sr-only-text="assigneesBadgeSrOnlyText(item)"
>
<template #avatar="{ avatar }">
<gl-avatar-link
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index b90658fb13c..004601bc0a3 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -1,7 +1,5 @@
import { s__, __ } from '~/locale';
-export const VALIDATE_INTEGRATION_FORM_EVENT = 'validateIntegrationForm';
-
export const integrationLevels = {
GROUP: 'group',
INSTANCE: 'instance',
@@ -26,5 +24,3 @@ export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection s
export const settingsTabTitle = __('Settings');
export const overridesTabTitle = s__('Integrations|Projects using custom settings');
-
-export const INTEGRATION_FORM_SELECTOR = '.js-integration-settings-form';
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index 4b0579a5beb..b4ceec22822 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -9,8 +9,6 @@ import {
} from '@gitlab/ui';
import { capitalize, lowerCase, isEmpty } from 'lodash';
import { mapGetters } from 'vuex';
-import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
-import eventHub from '../event_hub';
export default {
name: 'DynamicField',
@@ -70,11 +68,15 @@ export default {
required: false,
default: null,
},
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
model: this.value,
- validated: false,
};
},
computed: {
@@ -123,22 +125,13 @@ export default {
};
},
valid() {
- return !this.required || !isEmpty(this.model) || this.isNonEmptyPassword || !this.validated;
+ return !this.required || !isEmpty(this.model) || this.isNonEmptyPassword || !this.isValidated;
},
},
created() {
if (this.isNonEmptyPassword) {
this.model = null;
}
- eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
- beforeDestroy() {
- eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
- methods: {
- validateForm() {
- this.validated = true;
- },
},
helpHtmlConfig: {
ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index c3cc35adfa5..007a384f41e 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -5,16 +5,13 @@ import * as Sentry from '@sentry/browser';
import { mapState, mapActions, mapGetters } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
- VALIDATE_INTEGRATION_FORM_EVENT,
I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
I18N_DEFAULT_ERROR_MESSAGE,
I18N_SUCCESSFUL_CONNECTION_MESSAGE,
- INTEGRATION_FORM_SELECTOR,
integrationLevels,
} from '~/integrations/constants';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import csrf from '~/lib/utils/csrf';
-import eventHub from '../event_hub';
import { testIntegrationSettings } from '../api';
import ActiveCheckbox from './active_checkbox.vue';
import ConfirmationModal from './confirmation_modal.vue';
@@ -57,6 +54,7 @@ export default {
isTesting: false,
isSaving: false,
isResetting: false,
+ isValidated: false,
};
},
computed: {
@@ -83,54 +81,38 @@ export default {
disableButtons() {
return Boolean(this.isSaving || this.isResetting || this.isTesting);
},
- useVueForm() {
- return this.glFeatures?.vueIntegrationForm;
+ form() {
+ return this.$refs.integrationForm.$el;
},
- formContainerProps() {
- return this.useVueForm
- ? {
- ref: 'integrationForm',
- method: 'post',
- class: 'gl-mb-3 gl-show-field-errors integration-settings-form',
- action: this.propsSource.formPath,
- novalidate: !this.integrationActive,
- }
- : {};
- },
- formContainer() {
- return this.useVueForm ? GlForm : 'div';
- },
- },
- mounted() {
- this.form = this.useVueForm
- ? this.$refs.integrationForm.$el
- : document.querySelector(INTEGRATION_FORM_SELECTOR);
},
methods: {
- ...mapActions(['setOverride', 'fetchResetIntegration', 'requestJiraIssueTypes']),
+ ...mapActions(['setOverride', 'requestJiraIssueTypes']),
+ setIsValidated() {
+ this.isValidated = true;
+ },
onSaveClick() {
this.isSaving = true;
if (this.integrationActive && !this.form.checkValidity()) {
this.isSaving = false;
- eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
+ this.setIsValidated();
return;
}
this.form.submit();
},
onTestClick() {
- this.isTesting = true;
-
if (!this.form.checkValidity()) {
- eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
+ this.setIsValidated();
return;
}
+ this.isTesting = true;
+
testIntegrationSettings(this.propsSource.testPath, this.getFormData())
.then(({ data: { error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } }) => {
if (error) {
- eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
+ this.setIsValidated();
this.$toast.show(message);
return;
}
@@ -169,16 +151,6 @@ export default {
},
onToggleIntegrationState(integrationActive) {
this.integrationActive = integrationActive;
- if (!this.form || this.useVueForm) {
- return;
- }
-
- // If integration will be active, enable form validation.
- if (integrationActive) {
- this.form.removeAttribute('novalidate');
- } else {
- this.form.setAttribute('novalidate', true);
- }
},
},
helpHtmlConfig: {
@@ -191,17 +163,21 @@ export default {
</script>
<template>
- <component :is="formContainer" v-bind="formContainerProps">
- <template v-if="useVueForm">
- <input type="hidden" name="_method" value="put" />
- <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
- <input
- type="hidden"
- name="redirect_to"
- :value="propsSource.redirectTo"
- data-testid="redirect-to-field"
- />
- </template>
+ <gl-form
+ ref="integrationForm"
+ method="post"
+ class="gl-mb-3 gl-show-field-errors integration-settings-form"
+ :action="propsSource.formPath"
+ :novalidate="!integrationActive"
+ >
+ <input type="hidden" name="_method" value="put" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ <input
+ type="hidden"
+ name="redirect_to"
+ :value="propsSource.redirectTo"
+ data-testid="redirect-to-field"
+ />
<override-dropdown
v-if="defaultState !== null"
@@ -227,6 +203,7 @@ export default {
v-if="isJira"
:key="`${currentKey}-jira-trigger-fields`"
v-bind="propsSource.triggerFieldsProps"
+ :is-validated="isValidated"
/>
<trigger-fields
v-else-if="propsSource.triggerEvents.length"
@@ -238,11 +215,13 @@ export default {
v-for="field in propsSource.fields"
:key="`${currentKey}-${field.name}`"
v-bind="field"
+ :is-validated="isValidated"
/>
<jira-issues-fields
v-if="isJira && !isInstanceOrGroupLevel"
:key="`${currentKey}-jira-issues-fields`"
v-bind="propsSource.jiraIssuesProps"
+ :is-validated="isValidated"
@request-jira-issue-types="onRequestJiraIssueTypes"
/>
@@ -311,5 +290,5 @@ export default {
</div>
</div>
</div>
- </component>
+ </gl-form>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
index 99498501f6c..7f2f7620a86 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -1,9 +1,7 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
import { mapGetters } from 'vuex';
-import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
import { s__, __ } from '~/locale';
-import eventHub from '../event_hub';
import JiraUpgradeCta from './jira_upgrade_cta.vue';
export default {
@@ -64,29 +62,22 @@ export default {
required: false,
default: '',
},
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
enableJiraIssues: this.initialEnableJiraIssues,
projectKey: this.initialProjectKey,
- validated: false,
};
},
computed: {
...mapGetters(['isInheriting']),
validProjectKey() {
- return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated;
- },
- },
- created() {
- eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
- beforeDestroy() {
- eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
- methods: {
- validateForm() {
- this.validated = true;
+ return !this.enableJiraIssues || Boolean(this.projectKey) || !this.isValidated;
},
},
i18n: {
diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
index 249a3e105b1..df5946b814a 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -9,9 +9,7 @@ import {
} from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
import { s__ } from '~/locale';
-import eventHub from '../event_hub';
const commentDetailOptions = [
{
@@ -92,10 +90,14 @@ export default {
required: false,
default: '',
},
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
- validated: false,
triggerCommit: this.initialTriggerCommit,
triggerMergeRequest: this.initialTriggerMergeRequest,
enableComments: this.initialEnableComments,
@@ -115,19 +117,10 @@ export default {
return this.triggerCommit || this.triggerMergeRequest;
},
validIssueTransitionId() {
- return !this.validated || Boolean(this.jiraIssueTransitionId);
+ return !this.isValidated || Boolean(this.jiraIssueTransitionId);
},
},
- created() {
- eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
- beforeDestroy() {
- eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
methods: {
- validateForm() {
- this.validated = true;
- },
showCustomIssueTransitions(currentOption) {
return (
this.jiraIssueTransitionAutomatic === ISSUE_TRANSITION_CUSTOM &&
diff --git a/app/assets/javascripts/integrations/edit/event_hub.js b/app/assets/javascripts/integrations/edit/event_hub.js
deleted file mode 100644
index e31806ad199..00000000000
--- a/app/assets/javascripts/integrations/edit/event_hub.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import createEventHub from '~/helpers/event_hub_factory';
-
-export default createEventHub();
diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js
index 1398b710d1d..d31d3eb9d82 100644
--- a/app/assets/javascripts/integrations/edit/store/actions.js
+++ b/app/assets/javascripts/integrations/edit/store/actions.js
@@ -1,10 +1,8 @@
import {
- VALIDATE_INTEGRATION_FORM_EVENT,
I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
I18N_DEFAULT_ERROR_MESSAGE,
} from '~/integrations/constants';
import { testIntegrationSettings } from '../api';
-import eventHub from '../event_hub';
import * as types from './mutation_types';
export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override);
@@ -19,7 +17,6 @@ export const requestJiraIssueTypes = ({ commit, dispatch, getters }, formData) =
data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE },
}) => {
if (error || !issuetypes?.length) {
- eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
throw new Error(message);
}
diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js
index ddf6bef7554..eb74b0b1c73 100644
--- a/app/assets/javascripts/integrations/edit/store/mutation_types.js
+++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js
@@ -1,9 +1,5 @@
export const SET_OVERRIDE = 'SET_OVERRIDE';
-export const SET_IS_RESETTING = 'SET_IS_RESETTING';
export const SET_IS_LOADING_JIRA_ISSUE_TYPES = 'SET_IS_LOADING_JIRA_ISSUE_TYPES';
export const SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE = 'SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE';
export const SET_JIRA_ISSUE_TYPES = 'SET_JIRA_ISSUE_TYPES';
-
-export const REQUEST_RESET_INTEGRATION = 'REQUEST_RESET_INTEGRATION';
-export const RECEIVE_RESET_INTEGRATION_ERROR = 'RECEIVE_RESET_INTEGRATION_ERROR';
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue
index 216078ed35e..04a8ec3400f 100644
--- a/app/assets/javascripts/invite_members/components/group_select.vue
+++ b/app/assets/javascripts/invite_members/components/group_select.vue
@@ -24,6 +24,10 @@ export default {
prop: 'selectedGroup',
},
props: {
+ accessLevels: {
+ type: Object,
+ required: true,
+ },
groupsFilter: {
type: String,
required: false,
@@ -34,6 +38,10 @@ export default {
required: false,
default: null,
},
+ invalidGroups: {
+ type: Array,
+ required: true,
+ },
},
data() {
return {
@@ -50,6 +58,13 @@ export default {
isFetchResultEmpty() {
return this.groups.length === 0;
},
+ defaultFetchOptions() {
+ return {
+ exclude_internal: true,
+ active: true,
+ min_access_level: this.accessLevels.Guest,
+ };
+ },
},
watch: {
searchTerm() {
@@ -64,18 +79,26 @@ export default {
this.isFetching = true;
return this.fetchGroups()
.then((response) => {
- this.groups = response.map((group) => ({
- id: group.id,
- name: group.full_name,
- path: group.path,
- avatarUrl: group.avatar_url,
- }));
+ this.groups = this.processGroups(response);
this.isFetching = false;
})
.catch(() => {
this.isFetching = false;
});
}, SEARCH_DELAY),
+ processGroups(response) {
+ const rawGroups = response.map((group) => ({
+ id: group.id,
+ name: group.full_name,
+ path: group.path,
+ avatarUrl: group.avatar_url,
+ }));
+
+ return this.filterOutInvalidGroups(rawGroups);
+ },
+ filterOutInvalidGroups(groups) {
+ return groups.filter((group) => this.invalidGroups.indexOf(group.id) === -1);
+ },
selectGroup(group) {
this.selectedGroup = group;
@@ -84,13 +107,9 @@ export default {
fetchGroups() {
switch (this.groupsFilter) {
case GROUP_FILTERS.DESCENDANT_GROUPS:
- return getDescendentGroups(
- this.parentGroupId,
- this.searchTerm,
- this.$options.defaultFetchOptions,
- );
+ return getDescendentGroups(this.parentGroupId, this.searchTerm, this.defaultFetchOptions);
default:
- return getGroups(this.searchTerm, this.$options.defaultFetchOptions);
+ return getGroups(this.searchTerm, this.defaultFetchOptions);
}
},
},
@@ -99,10 +118,6 @@ export default {
searchPlaceholder: s__('GroupSelect|Search groups'),
emptySearchResult: s__('GroupSelect|No matching results'),
},
- defaultFetchOptions: {
- exclude_internal: true,
- active: true,
- },
};
</script>
<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
index c9de078319a..c08a4d75c59 100644
--- a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
@@ -21,7 +21,7 @@ export default {
},
methods: {
openModal() {
- eventHub.$emit('openModal', { inviteeType: 'group' });
+ eventHub.$emit('openGroupModal');
},
},
};
diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
new file mode 100644
index 00000000000..6598000c464
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
@@ -0,0 +1,146 @@
+<script>
+import { uniqueId } from 'lodash';
+import Api from '~/api';
+import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
+import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants';
+import eventHub from '../event_hub';
+import GroupSelect from './group_select.vue';
+import InviteModalBase from './invite_modal_base.vue';
+
+export default {
+ name: 'InviteMembersModal',
+ components: {
+ GroupSelect,
+ InviteModalBase,
+ },
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ isProject: {
+ type: Boolean,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ accessLevels: {
+ type: Object,
+ required: true,
+ },
+ defaultAccessLevel: {
+ type: Number,
+ required: true,
+ },
+ helpLink: {
+ type: String,
+ required: true,
+ },
+ groupSelectFilter: {
+ type: String,
+ required: false,
+ default: GROUP_FILTERS.ALL,
+ },
+ groupSelectParentId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ invalidGroups: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ modalId: uniqueId('invite-groups-modal-'),
+ groupToBeSharedWith: {},
+ };
+ },
+ computed: {
+ labelIntroText() {
+ return this.$options.labels[this.inviteTo].introText;
+ },
+ inviteTo() {
+ return this.isProject ? 'toProject' : 'toGroup';
+ },
+ toastOptions() {
+ return {
+ onComplete: () => {
+ this.groupToBeSharedWith = {};
+ },
+ };
+ },
+ inviteDisabled() {
+ return Object.keys(this.groupToBeSharedWith).length === 0;
+ },
+ },
+ mounted() {
+ eventHub.$on('openGroupModal', () => {
+ this.openModal();
+ });
+ },
+ methods: {
+ openModal() {
+ this.$root.$emit(BV_SHOW_MODAL, this.modalId);
+ },
+ closeModal() {
+ this.$root.$emit(BV_HIDE_MODAL, this.modalId);
+ },
+ sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
+ const apiShareWithGroup = this.isProject
+ ? Api.projectShareWithGroup.bind(Api)
+ : Api.groupShareWithGroup.bind(Api);
+
+ apiShareWithGroup(this.id, {
+ format: 'json',
+ group_id: this.groupToBeSharedWith.id,
+ group_access: accessLevel,
+ expires_at: expiresAt,
+ })
+ .then(() => {
+ onSuccess();
+ this.showSuccessMessage();
+ })
+ .catch(onError);
+ },
+ resetFields() {
+ this.groupToBeSharedWith = {};
+ },
+ showSuccessMessage() {
+ this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
+ this.closeModal();
+ },
+ },
+ labels: GROUP_MODAL_LABELS,
+};
+</script>
+<template>
+ <invite-modal-base
+ :modal-id="modalId"
+ :modal-title="$options.labels.title"
+ :name="name"
+ :access-levels="accessLevels"
+ :default-access-level="defaultAccessLevel"
+ :help-link="helpLink"
+ v-bind="$attrs"
+ :label-intro-text="labelIntroText"
+ :label-search-field="$options.labels.searchField"
+ :submit-disabled="inviteDisabled"
+ @reset="resetFields"
+ @submit="sendInvite"
+ >
+ <template #select="{ clearValidation }">
+ <group-select
+ v-model="groupToBeSharedWith"
+ :access-levels="accessLevels"
+ :groups-filter="groupSelectFilter"
+ :parent-group-id="groupSelectParentId"
+ :invalid-groups="invalidGroups"
+ @input="clearValidation"
+ />
+ </template>
+ </invite-modal-base>
+</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 91a139a5105..6c0fc5caf26 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -1,56 +1,40 @@
<script>
import {
GlAlert,
- GlFormGroup,
- GlModal,
GlDropdown,
GlDropdownItem,
- GlDatepicker,
GlLink,
GlSprintf,
- GlButton,
- GlFormInput,
GlFormCheckboxGroup,
} from '@gitlab/ui';
-import { partition, isString, unescape, uniqueId } from 'lodash';
+import { partition, isString, uniqueId } from 'lodash';
+import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
-import { sanitize } from '~/lib/dompurify';
-import { BV_SHOW_MODAL } from '~/lib/utils/constants';
+import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
-import { sprintf } from '~/locale';
import {
- GROUP_FILTERS,
USERS_FILTER_ALL,
INVITE_MEMBERS_FOR_TASK,
- MODAL_LABELS,
+ MEMBER_MODAL_LABELS,
LEARN_GITLAB,
} from '../constants';
import eventHub from '../event_hub';
-import {
- responseMessageFromError,
- responseMessageFromSuccess,
-} from '../utils/response_message_parser';
+import { responseMessageFromSuccess } from '../utils/response_message_parser';
import ModalConfetti from './confetti.vue';
-import GroupSelect from './group_select.vue';
import MembersTokenSelect from './members_token_select.vue';
export default {
name: 'InviteMembersModal',
components: {
GlAlert,
- GlFormGroup,
- GlDatepicker,
GlLink,
- GlModal,
GlDropdown,
GlDropdownItem,
GlSprintf,
- GlButton,
- GlFormInput,
GlFormCheckboxGroup,
+ InviteModalBase,
MembersTokenSelect,
- GroupSelect,
ModalConfetti,
},
inject: ['newProjectPath'],
@@ -75,15 +59,9 @@ export default {
type: Number,
required: true,
},
- groupSelectFilter: {
+ helpLink: {
type: String,
- required: false,
- default: GROUP_FILTERS.ALL,
- },
- groupSelectParentId: {
- type: Number,
- required: false,
- default: null,
+ required: true,
},
usersFilter: {
type: String,
@@ -95,10 +73,6 @@ export default {
required: false,
default: null,
},
- helpLink: {
- type: String,
- required: true,
- },
tasksToBeDoneOptions: {
type: Array,
required: true,
@@ -110,73 +84,31 @@ export default {
},
data() {
return {
- visible: true,
modalId: uniqueId('invite-members-modal-'),
- selectedAccessLevel: this.defaultAccessLevel,
- inviteeType: 'members',
newUsersToInvite: [],
- selectedDate: undefined,
selectedTasksToBeDone: [],
selectedTaskProject: this.projects[0],
- groupToBeSharedWith: {},
source: 'unknown',
- invalidFeedbackMessage: '',
- isLoading: false,
mode: 'default',
+ // Kept in sync with "base"
+ selectedAccessLevel: undefined,
};
},
computed: {
isCelebration() {
return this.mode === 'celebrate';
},
- validationState() {
- return this.invalidFeedbackMessage === '' ? null : false;
- },
- isInviteGroup() {
- return this.inviteeType === 'group';
- },
modalTitle() {
- return this.$options.labels[this.inviteeType].modal[this.mode].title;
- },
- introText() {
- return sprintf(this.$options.labels[this.inviteeType][this.inviteTo][this.mode].introText, {
- name: this.name,
- });
+ return this.$options.labels.modal[this.mode].title;
},
inviteTo() {
return this.isProject ? 'toProject' : 'toGroup';
},
- toastOptions() {
- return {
- onComplete: () => {
- this.selectedAccessLevel = this.defaultAccessLevel;
- this.newUsersToInvite = [];
- this.groupToBeSharedWith = {};
- },
- };
- },
- basePostData() {
- return {
- expires_at: this.selectedDate,
- format: 'json',
- };
- },
- selectedRoleName() {
- return Object.keys(this.accessLevels).find(
- (key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
- );
+ labelIntroText() {
+ return this.$options.labels[this.inviteTo][this.mode].introText;
},
inviteDisabled() {
- return (
- this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0
- );
- },
- errorFieldDescription() {
- if (this.inviteeType === 'group') {
- return '';
- }
-
- return this.$options.labels[this.inviteeType].placeHolder;
+ return this.newUsersToInvite.length === 0;
},
tasksToBeDoneEnabled() {
return (
@@ -215,7 +147,7 @@ export default {
});
if (this.tasksToBeDoneEnabled) {
- this.openModal({ inviteeType: 'members', source: 'in_product_marketing_email' });
+ this.openModal({ source: 'in_product_marketing_email' });
this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view);
}
},
@@ -231,72 +163,42 @@ export default {
usersToAddById.map((user) => user.id).join(','),
];
},
- openModal({ mode = 'default', inviteeType, source }) {
+ openModal({ mode = 'default', source }) {
this.mode = mode;
- this.inviteeType = inviteeType;
this.source = source;
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
+ closeModal() {
+ this.$root.$emit(BV_HIDE_MODAL, this.modalId);
+ },
trackEvent(experimentName, eventName) {
const tracking = new ExperimentTracking(experimentName);
tracking.event(eventName);
},
- closeModal() {
- this.resetFields();
- this.$refs.modal.hide();
- },
- sendInvite() {
- if (this.isInviteGroup) {
- this.submitShareWithGroup();
- } else {
- this.submitInviteMembers();
- }
- },
- trackinviteMembersForTask() {
- const label = 'selected_tasks_to_be_done';
- const property = this.selectedTasksToBeDone.join(',');
- const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property });
- tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
- },
- resetFields() {
- this.isLoading = false;
- this.selectedAccessLevel = this.defaultAccessLevel;
- this.selectedDate = undefined;
- this.newUsersToInvite = [];
- this.groupToBeSharedWith = {};
- this.invalidFeedbackMessage = '';
- this.selectedTasksToBeDone = [];
- [this.selectedTaskProject] = this.projects;
- },
- changeSelectedItem(item) {
- this.selectedAccessLevel = item;
- },
- changeSelectedTaskProject(project) {
- this.selectedTaskProject = project;
- },
- submitShareWithGroup() {
- const apiShareWithGroup = this.isProject
- ? Api.projectShareWithGroup.bind(Api)
- : Api.groupShareWithGroup.bind(Api);
-
- apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
- .then(this.showSuccessMessage)
- .catch(this.showInvalidFeedbackMessage);
- },
- submitInviteMembers() {
- this.invalidFeedbackMessage = '';
- this.isLoading = true;
-
+ sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
+ const baseData = {
+ format: 'json',
+ expires_at: expiresAt,
+ access_level: accessLevel,
+ invite_source: this.source,
+ tasks_to_be_done: this.tasksToBeDoneForPost,
+ tasks_project_id: this.tasksProjectForPost,
+ };
if (usersToInviteByEmail !== '') {
const apiInviteByEmail = this.isProject
? Api.inviteProjectMembersByEmail.bind(Api)
: Api.inviteGroupMembersByEmail.bind(Api);
- promises.push(apiInviteByEmail(this.id, this.inviteByEmailPostData(usersToInviteByEmail)));
+ promises.push(
+ apiInviteByEmail(this.id, {
+ ...baseData,
+ email: usersToInviteByEmail,
+ }),
+ );
}
if (usersToAddById !== '') {
@@ -304,188 +206,103 @@ export default {
? Api.addProjectMembersByUserId.bind(Api)
: Api.addGroupMembersByUserId.bind(Api);
- promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
+ promises.push(
+ apiAddByUserId(this.id, {
+ ...baseData,
+ user_id: usersToAddById,
+ }),
+ );
}
this.trackinviteMembersForTask();
Promise.all(promises)
- .then(this.conditionallyShowSuccessMessage)
- .catch(this.showInvalidFeedbackMessage);
- },
- inviteByEmailPostData(usersToInviteByEmail) {
- return {
- ...this.basePostData,
- email: usersToInviteByEmail,
- access_level: this.selectedAccessLevel,
- invite_source: this.source,
- tasks_to_be_done: this.tasksToBeDoneForPost,
- tasks_project_id: this.tasksProjectForPost,
- };
+ .then((responses) => {
+ const message = responseMessageFromSuccess(responses);
+
+ if (message) {
+ onError({
+ response: {
+ data: {
+ message,
+ },
+ },
+ });
+ } else {
+ onSuccess();
+ this.showSuccessMessage();
+ }
+ })
+ .catch(onError);
},
- addByUserIdPostData(usersToAddById) {
- return {
- ...this.basePostData,
- user_id: usersToAddById,
- access_level: this.selectedAccessLevel,
- invite_source: this.source,
- tasks_to_be_done: this.tasksToBeDoneForPost,
- tasks_project_id: this.tasksProjectForPost,
- };
+ trackinviteMembersForTask() {
+ const label = 'selected_tasks_to_be_done';
+ const property = this.selectedTasksToBeDone.join(',');
+ const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property });
+ tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
- shareWithGroupPostData(groupToBeSharedWith) {
- return {
- ...this.basePostData,
- group_id: groupToBeSharedWith,
- group_access: this.selectedAccessLevel,
- };
+ resetFields() {
+ this.newUsersToInvite = [];
+ this.selectedTasksToBeDone = [];
+ [this.selectedTaskProject] = this.projects;
},
- conditionallyShowSuccessMessage(response) {
- const message = this.unescapeMsg(responseMessageFromSuccess(response));
-
- if (message === '') {
- this.showSuccessMessage();
-
- return;
- }
-
- this.invalidFeedbackMessage = message;
- this.isLoading = false;
+ changeSelectedTaskProject(project) {
+ this.selectedTaskProject = project;
},
showSuccessMessage() {
if (this.isOnLearnGitlab) {
eventHub.$emit('showSuccessfulInvitationsAlert');
} else {
- this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
+ this.$toast.show(this.$options.labels.toastMessageSuccessful);
}
- this.closeModal();
- },
- showInvalidFeedbackMessage(response) {
- const message = this.unescapeMsg(responseMessageFromError(response));
- this.isLoading = false;
- this.invalidFeedbackMessage = message || this.$options.labels.invalidFeedbackMessageDefault;
- },
- handleMembersTokenSelectClear() {
- this.invalidFeedbackMessage = '';
+ this.closeModal();
},
- unescapeMsg(message) {
- return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
+ onAccessLevelUpdate(val) {
+ this.selectedAccessLevel = val;
},
},
- labels: MODAL_LABELS,
- membersTokenSelectLabelId: 'invite-members-input',
+ labels: MEMBER_MODAL_LABELS,
};
</script>
<template>
- <gl-modal
- ref="modal"
+ <invite-modal-base
:modal-id="modalId"
- size="sm"
- data-qa-selector="invite_members_modal_content"
- data-testid="invite-members-modal"
- :title="modalTitle"
- :header-close-label="$options.labels.headerCloseLabel"
- @hidden="resetFields"
- @close="resetFields"
- @hide="resetFields"
+ :modal-title="modalTitle"
+ :name="name"
+ :access-levels="accessLevels"
+ :default-access-level="defaultAccessLevel"
+ :help-link="helpLink"
+ :label-intro-text="labelIntroText"
+ :label-search-field="$options.labels.searchField"
+ :form-group-description="$options.labels.placeHolder"
+ :submit-disabled="inviteDisabled"
+ @reset="resetFields"
+ @submit="sendInvite"
+ @access-level="onAccessLevelUpdate"
>
- <div>
- <div class="gl-display-flex">
- <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div>
- <div>
- <p ref="introText">
- <gl-sprintf :message="introText">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- <br />
- <span v-if="isCelebration">{{ $options.labels.members.modal.celebrate.intro }} </span>
- <modal-confetti v-if="isCelebration" />
- </p>
- </div>
- </div>
-
- <gl-form-group
- :invalid-feedback="invalidFeedbackMessage"
- :state="validationState"
- :description="errorFieldDescription"
- data-testid="members-form-group"
- >
- <label :id="$options.membersTokenSelectLabelId" class="col-form-label">{{
- $options.labels[inviteeType].searchField
- }}</label>
- <members-token-select
- v-if="!isInviteGroup"
- v-model="newUsersToInvite"
- class="gl-mb-2"
- :validation-state="validationState"
- :aria-labelledby="$options.membersTokenSelectLabelId"
- :users-filter="usersFilter"
- :filter-id="filterId"
- @clear="handleMembersTokenSelectClear"
- />
- <group-select
- v-if="isInviteGroup"
- v-model="groupToBeSharedWith"
- :groups-filter="groupSelectFilter"
- :parent-group-id="groupSelectParentId"
- @input="handleMembersTokenSelectClear"
- />
- </gl-form-group>
-
- <label class="gl-font-weight-bold">{{ $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"
- 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)"
- >
- <div>{{ item }}</div>
- </gl-dropdown-item>
- </template>
- </gl-dropdown>
- </div>
-
- <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>
- </template>
- </gl-sprintf>
- </div>
-
- <label class="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">
- <gl-datepicker
- v-model="selectedDate"
- class="gl-display-inline!"
- :min-date="new Date()"
- :target="null"
- >
- <template #default="{ formattedDate }">
- <gl-form-input
- class="gl-w-full"
- :value="formattedDate"
- :placeholder="__(`YYYY-MM-DD`)"
- />
- </template>
- </gl-datepicker>
- </div>
+ <template #intro-text-before>
+ <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div>
+ </template>
+ <template #intro-text-after>
+ <br />
+ <span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span>
+ <modal-confetti v-if="isCelebration" />
+ </template>
+ <template #select="{ clearValidation, validationState, labelId }">
+ <members-token-select
+ v-model="newUsersToInvite"
+ class="gl-mb-2"
+ :validation-state="validationState"
+ :aria-labelledby="labelId"
+ :users-filter="usersFilter"
+ :filter-id="filterId"
+ @clear="clearValidation"
+ />
+ </template>
+ <template #form-after>
<div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done">
<label class="gl-mt-5">
- {{ $options.labels.members.tasksToBeDone.title }}
+ {{ $options.labels.tasksToBeDone.title }}
</label>
<template v-if="projects.length">
<gl-form-checkbox-group
@@ -495,7 +312,7 @@ export default {
/>
<template v-if="showTaskProjects">
<label class="gl-mt-5 gl-display-block">
- {{ $options.labels.members.tasksProject.title }}
+ {{ $options.labels.tasksProject.title }}
</label>
<gl-dropdown
class="gl-w-half gl-xs-w-full"
@@ -522,7 +339,7 @@ export default {
:dismissible="false"
data-testid="invite-members-modal-no-projects-alert"
>
- <gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects">
+ <gl-sprintf :message="$options.labels.tasksToBeDone.noProjects">
<template #link="{ content }">
<gl-link :href="newProjectPath" target="_blank" class="gl-label-link">
{{ content }}
@@ -531,22 +348,6 @@ export default {
</gl-sprintf>
</gl-alert>
</div>
- </div>
-
- <template #modal-footer>
- <gl-button data-testid="cancel-button" @click="closeModal">
- {{ $options.labels.cancelButtonText }}
- </gl-button>
- <gl-button
- :disabled="inviteDisabled"
- :loading="isLoading"
- variant="success"
- data-qa-selector="invite_button"
- data-testid="invite-button"
- @click="sendInvite"
- >
- {{ $options.labels.inviteButtonText }}
- </gl-button>
</template>
- </gl-modal>
+ </invite-modal-base>
</template>
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 7dd74f8803a..79b192e2495 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -71,7 +71,7 @@ export default {
return this.triggerElement === targetTriggerElement;
},
openModal() {
- eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource });
+ eventHub.$emit('openModal', { source: this.triggerSource });
},
},
TRIGGER_ELEMENT_BUTTON,
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
new file mode 100644
index 00000000000..fc00f5b9343
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -0,0 +1,276 @@
+<script>
+import {
+ GlFormGroup,
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ GlDatepicker,
+ GlLink,
+ GlSprintf,
+ GlButton,
+ GlFormInput,
+} from '@gitlab/ui';
+import { unescape } from 'lodash';
+import { sanitize } from '~/lib/dompurify';
+import { sprintf } from '~/locale';
+import {
+ ACCESS_LEVEL,
+ ACCESS_EXPIRE_DATE,
+ INVALID_FEEDBACK_MESSAGE_DEFAULT,
+ READ_MORE_TEXT,
+ INVITE_BUTTON_TEXT,
+ CANCEL_BUTTON_TEXT,
+ HEADER_CLOSE_LABEL,
+} from '../constants';
+import { responseMessageFromError } from '../utils/response_message_parser';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlDatepicker,
+ GlLink,
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlButton,
+ GlFormInput,
+ },
+ inheritAttrs: false,
+ props: {
+ modalTitle: {
+ type: String,
+ required: true,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ accessLevels: {
+ type: Object,
+ required: true,
+ },
+ defaultAccessLevel: {
+ type: Number,
+ required: true,
+ },
+ helpLink: {
+ type: String,
+ required: true,
+ },
+ labelIntroText: {
+ type: String,
+ required: true,
+ },
+ labelSearchField: {
+ type: String,
+ required: true,
+ },
+ formGroupDescription: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ submitDisabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ // Be sure to check out reset!
+ return {
+ invalidFeedbackMessage: '',
+ selectedAccessLevel: this.defaultAccessLevel,
+ selectedDate: undefined,
+ isLoading: false,
+ minDate: new Date(),
+ };
+ },
+ computed: {
+ introText() {
+ return sprintf(this.labelIntroText, { name: this.name });
+ },
+ validationState() {
+ return this.invalidFeedbackMessage ? false : null;
+ },
+ selectLabelId() {
+ return `${this.modalId}_select`;
+ },
+ selectedRoleName() {
+ return Object.keys(this.accessLevels).find(
+ (key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
+ );
+ },
+ },
+ watch: {
+ selectedAccessLevel: {
+ immediate: true,
+ handler(val) {
+ this.$emit('access-level', val);
+ },
+ },
+ },
+ methods: {
+ showInvalidFeedbackMessage(response) {
+ const message = this.unescapeMsg(responseMessageFromError(response));
+
+ this.invalidFeedbackMessage = message || INVALID_FEEDBACK_MESSAGE_DEFAULT;
+ },
+ reset() {
+ // This component isn't necessarily disposed,
+ // so we might need to reset it's state.
+ this.isLoading = false;
+ this.invalidFeedbackMessage = '';
+ this.selectedAccessLevel = this.defaultAccessLevel;
+ this.selectedDate = undefined;
+
+ this.$emit('reset');
+ },
+ closeModal() {
+ this.reset();
+ this.$refs.modal.hide();
+ },
+ clearValidation() {
+ this.invalidFeedbackMessage = '';
+ },
+ changeSelectedItem(item) {
+ this.selectedAccessLevel = item;
+ },
+ submit() {
+ this.isLoading = true;
+ this.invalidFeedbackMessage = '';
+
+ this.$emit('submit', {
+ onSuccess: () => {
+ this.isLoading = false;
+ },
+ onError: (...args) => {
+ this.isLoading = false;
+ this.showInvalidFeedbackMessage(...args);
+ },
+ data: {
+ accessLevel: this.selectedAccessLevel,
+ expiresAt: this.selectedDate,
+ },
+ });
+ },
+ unescapeMsg(message) {
+ return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
+ },
+ },
+ HEADER_CLOSE_LABEL,
+ ACCESS_EXPIRE_DATE,
+ ACCESS_LEVEL,
+ READ_MORE_TEXT,
+ INVITE_BUTTON_TEXT,
+ CANCEL_BUTTON_TEXT,
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ :modal-id="modalId"
+ data-qa-selector="invite_members_modal_content"
+ data-testid="invite-modal"
+ size="sm"
+ :title="modalTitle"
+ :header-close-label="$options.HEADER_CLOSE_LABEL"
+ @hidden="reset"
+ @close="reset"
+ @hide="reset"
+ >
+ <div class="gl-display-flex" data-testid="modal-base-intro-text">
+ <slot name="intro-text-before"></slot>
+ <p>
+ <gl-sprintf :message="introText">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <slot name="intro-text-after"></slot>
+ </div>
+
+ <gl-form-group
+ :invalid-feedback="invalidFeedbackMessage"
+ :state="validationState"
+ :description="formGroupDescription"
+ data-testid="members-form-group"
+ >
+ <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label>
+ <slot
+ name="select"
+ v-bind="{ clearValidation, validationState, labelId: selectLabelId }"
+ ></slot>
+ </gl-form-group>
+
+ <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
+ <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)"
+ >
+ <div>{{ item }}</div>
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </div>
+
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
+ <gl-sprintf :message="$options.READ_MORE_TEXT">
+ <template #link="{ content }">
+ <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+
+ <label class="gl-mt-5 gl-display-block" for="expires_at">{{
+ $options.ACCESS_EXPIRE_DATE
+ }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
+ <gl-datepicker
+ v-model="selectedDate"
+ class="gl-display-inline!"
+ :min-date="minDate"
+ :target="null"
+ >
+ <template #default="{ formattedDate }">
+ <gl-form-input class="gl-w-full" :value="formattedDate" :placeholder="__(`YYYY-MM-DD`)" />
+ </template>
+ </gl-datepicker>
+ </div>
+ <slot name="form-after"></slot>
+
+ <template #modal-footer>
+ <gl-button data-testid="cancel-button" @click="closeModal">
+ {{ $options.CANCEL_BUTTON_TEXT }}
+ </gl-button>
+ <gl-button
+ :disabled="submitDisabled"
+ :loading="isLoading"
+ variant="success"
+ data-qa-selector="invite_button"
+ data-testid="invite-button"
+ @click="submit"
+ >
+ {{ $options.INVITE_BUTTON_TEXT }}
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index ec59b3909fe..cf2ee508184 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -72,67 +72,52 @@ export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite');
export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
-export const MODAL_LABELS = {
- members: {
- modal: {
- default: {
- title: MEMBERS_MODAL_DEFAULT_TITLE,
- },
- celebrate: {
- title: MEMBERS_MODAL_CELEBRATE_TITLE,
- intro: MEMBERS_MODAL_CELEBRATE_INTRO,
- },
+export const MEMBER_MODAL_LABELS = {
+ modal: {
+ default: {
+ title: MEMBERS_MODAL_DEFAULT_TITLE,
},
- toGroup: {
- default: {
- introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT,
- },
- },
- toProject: {
- default: {
- introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT,
- },
- celebrate: {
- introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
- },
- },
- searchField: MEMBERS_SEARCH_FIELD,
- placeHolder: MEMBERS_PLACEHOLDER,
- tasksToBeDone: {
- title: MEMBERS_TASKS_TO_BE_DONE_TITLE,
- noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS,
- },
- tasksProject: {
- title: MEMBERS_TASKS_PROJECTS_TITLE,
+ celebrate: {
+ title: MEMBERS_MODAL_CELEBRATE_TITLE,
+ intro: MEMBERS_MODAL_CELEBRATE_INTRO,
},
},
- group: {
- modal: {
- default: {
- title: GROUP_MODAL_DEFAULT_TITLE,
- },
+ toGroup: {
+ default: {
+ introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT,
},
- toGroup: {
- default: {
- introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT,
- },
+ },
+ toProject: {
+ default: {
+ introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT,
},
- toProject: {
- default: {
- introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
- },
+ celebrate: {
+ introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
},
- searchField: GROUP_SEARCH_FIELD,
- placeHolder: GROUP_PLACEHOLDER,
},
- accessLevel: ACCESS_LEVEL,
- accessExpireDate: ACCESS_EXPIRE_DATE,
+ searchField: MEMBERS_SEARCH_FIELD,
+ placeHolder: MEMBERS_PLACEHOLDER,
+ tasksToBeDone: {
+ title: MEMBERS_TASKS_TO_BE_DONE_TITLE,
+ noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS,
+ },
+ tasksProject: {
+ title: MEMBERS_TASKS_PROJECTS_TITLE,
+ },
+ toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
+};
+
+export const GROUP_MODAL_LABELS = {
+ title: GROUP_MODAL_DEFAULT_TITLE,
+ toGroup: {
+ introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT,
+ },
+ toProject: {
+ introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
+ },
+ searchField: GROUP_SEARCH_FIELD,
+ placeHolder: GROUP_PLACEHOLDER,
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
- invalidFeedbackMessageDefault: INVALID_FEEDBACK_MESSAGE_DEFAULT,
- readMoreText: READ_MORE_TEXT,
- inviteButtonText: INVITE_BUTTON_TEXT,
- cancelButtonText: CANCEL_BUTTON_TEXT,
- headerCloseLabel: HEADER_CLOSE_LABEL,
};
export const LEARN_GITLAB = 'learn_gitlab';
diff --git a/app/assets/javascripts/invite_members/init_invite_groups_modal.js b/app/assets/javascripts/invite_members/init_invite_groups_modal.js
new file mode 100644
index 00000000000..be1576ad0b0
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_groups_modal.js
@@ -0,0 +1,44 @@
+import { GlToast } from '@gitlab/ui';
+import Vue from 'vue';
+import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+Vue.use(GlToast);
+
+let initedInviteGroupsModal;
+
+export default function initInviteGroupsModal() {
+ if (initedInviteGroupsModal) {
+ // if we already loaded this in another part of the dom, we don't want to do it again
+ // else we will stack the modals
+ return false;
+ }
+
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/344955
+ // bug lying in wait here for someone to put group and project invite in same screen
+ // once that happens we'll need to mount these differently, perhaps split
+ // group/project to each mount one, with many ways to open it.
+ const el = document.querySelector('.js-invite-groups-modal');
+
+ if (!el) {
+ return false;
+ }
+
+ initedInviteGroupsModal = true;
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(InviteGroupsModal, {
+ props: {
+ ...el.dataset,
+ isProject: parseBoolean(el.dataset.isProject),
+ accessLevels: JSON.parse(el.dataset.accessLevels),
+ defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
+ groupSelectFilter: el.dataset.groupsFilter,
+ groupSelectParentId: parseInt(el.dataset.parentId, 10),
+ invalidGroups: JSON.parse(el.dataset.invalidGroups || '[]'),
+ },
+ }),
+ });
+}
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 2cc056f2ddb..e9d620cedf0 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -28,6 +28,7 @@ export default function initInviteMembersModal() {
return new Vue({
el,
+ name: 'InviteMembersModalRoot',
provide: {
newProjectPath: el.dataset.newProjectPath,
},
@@ -38,8 +39,6 @@ export default function initInviteMembersModal() {
isProject: parseBoolean(el.dataset.isProject),
accessLevels: JSON.parse(el.dataset.accessLevels),
defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
- groupSelectFilter: el.dataset.groupsFilter,
- groupSelectParentId: parseInt(el.dataset.parentId, 10),
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
projects: JSON.parse(el.dataset.projects || '[]'),
usersFilter: el.dataset.usersFilter,
diff --git a/app/assets/javascripts/invite_members/init_invite_members_trigger.js b/app/assets/javascripts/invite_members/init_invite_members_trigger.js
index 935edb35349..54a5eab2e4b 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_trigger.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_trigger.js
@@ -11,6 +11,7 @@ export default function initInviteMembersTrigger() {
return triggers.forEach((el) => {
return new Vue({
el,
+ name: 'InviteMembersTriggerRoot',
render: (createElement) =>
createElement(InviteMembersTrigger, {
props: {
diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js
index dca606556d0..967996b859e 100644
--- a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js
@@ -23,6 +23,7 @@ export function initIssueStatusSelect() {
return new Vue({
el,
+ name: 'StatusSelectRoot',
render: (createElement) => createElement(StatusSelect),
});
}
diff --git a/app/assets/javascripts/issuable/index.js b/app/assets/javascripts/issuable/index.js
index 57bad5182e7..10dbefce503 100644
--- a/app/assets/javascripts/issuable/index.js
+++ b/app/assets/javascripts/issuable/index.js
@@ -32,6 +32,7 @@ export function initCsvImportExportButtons() {
return new Vue({
el,
+ name: 'CsvImportExportButtonsRoot',
provide: {
showExportButton: parseBoolean(showExportButton),
showImportButton: parseBoolean(showImportButton),
@@ -74,6 +75,7 @@ export function initIssuableByEmail() {
return new Vue({
el,
+ name: 'IssuableByEmailRoot',
provide: {
initialEmail,
issuableType,
@@ -97,6 +99,7 @@ export function initIssuableHeaderWarnings(store) {
return new Vue({
el,
+ name: 'IssuableHeaderWarningsRoot',
store,
provide: { hidden: parseBoolean(hidden) },
render: (createElement) => createElement(IssuableHeaderWarnings),
diff --git a/app/assets/javascripts/issuable/issuable_context.js b/app/assets/javascripts/issuable/issuable_context.js
index 453305dd6e0..37001d00a27 100644
--- a/app/assets/javascripts/issuable/issuable_context.js
+++ b/app/assets/javascripts/issuable/issuable_context.js
@@ -1,6 +1,6 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import { loadCSSFile } from '~/lib/utils/css_utils';
import UsersSelect from '~/users_select';
@@ -62,7 +62,7 @@ export default class IssuableContext {
const supportedSizes = ['xs', 'sm', 'md'];
if (supportedSizes.includes(bpBreakpoint)) {
- Cookies.set('collapsed_gutter', true);
+ setCookie('collapsed_gutter', true);
}
});
}
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index 91f47a86cb7..88c1748db0b 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -77,6 +77,7 @@ export default class IssuableForm {
this.initAutosave();
this.form.on('submit', this.handleSubmit);
this.form.on('click', '.btn-cancel', this.resetAutosave);
+ this.form.find('.js-unwrap-on-load').unwrap();
this.initWip();
const $issuableDueDate = $('#issuable-due-date');
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index 5d36396bc6e..a3752c7043c 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -69,11 +69,11 @@ export default class CreateMergeRequestDropdown {
this.regexps = {
branch: {
createBranchPath: new RegExp('(branch_name=)(.+?)(?=&issue)'),
- createMrPath: new RegExp('(branch_name=)(.+?)(?=&ref)'),
+ createMrPath: new RegExp('(source_branch%5D=)(.+?)(?=&)'),
},
ref: {
createBranchPath: new RegExp('(ref=)(.+?)$'),
- createMrPath: new RegExp('(ref=)(.+?)$'),
+ createMrPath: new RegExp('(target_branch%5D=)(.+?)$'),
},
};
@@ -167,23 +167,18 @@ export default class CreateMergeRequestDropdown {
}
createMergeRequest() {
- this.isCreatingMergeRequest = true;
-
- return axios
- .post(this.createMrPath, {
- target_project_id: canCreateConfidentialMergeRequest()
- ? confidentialMergeRequestState.selectedProject.id
- : null,
- })
- .then(({ data }) => {
- this.mergeRequestCreated = true;
- window.location.href = data.url;
- })
- .catch(() =>
- createFlash({
- message: __('Failed to create merge request. Please try again.'),
- }),
- );
+ return new Promise(() => {
+ this.isCreatingMergeRequest = true;
+
+ return this.createBranch().then(() => {
+ window.location.href = canCreateConfidentialMergeRequest()
+ ? this.createMrPath.replace(
+ this.projectPath,
+ confidentialMergeRequestState.selectedProject.pathWithNamespace,
+ )
+ : this.createMrPath;
+ });
+ });
}
disable() {
@@ -562,5 +557,7 @@ export default class CreateMergeRequestDropdown {
this.regexps[target].createMrPath,
pathReplacement,
);
+
+ this.wrapperEl.dataset.createMrPath = this.createMrPath;
}
}
diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue
index 8b15e801f02..3866a7b3305 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -10,16 +10,30 @@ import {
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { orderBy } from 'lodash';
+import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
-import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import createFlash, { FLASH_TYPES } from '~/flash';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ITEM_TYPE } from '~/groups/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
+import axios from '~/lib/utils/axios_utils';
+import { scrollUp } from '~/lib/utils/scroll_utils';
+import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
+import {
+ DEFAULT_NONE_ANY,
+ OPERATOR_IS_ONLY,
+ TOKEN_TITLE_ASSIGNEE,
+ TOKEN_TITLE_AUTHOR,
+ TOKEN_TITLE_CONFIDENTIAL,
+ TOKEN_TITLE_LABEL,
+ TOKEN_TITLE_MILESTONE,
+ TOKEN_TITLE_MY_REACTION,
+ TOKEN_TITLE_RELEASE,
+ TOKEN_TITLE_TYPE,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
import {
@@ -27,8 +41,6 @@ import {
i18n,
MAX_LIST_SIZE,
PAGE_SIZE,
- PARAM_DUE_DATE,
- PARAM_SORT,
PARAM_STATE,
RELATIVE_POSITION_ASC,
TOKEN_TYPE_ASSIGNEE,
@@ -41,37 +53,23 @@ import {
TOKEN_TYPE_TYPE,
UPDATED_DESC,
urlSortParams,
-} from '~/issues/list/constants';
+} from '../constants';
+import eventHub from '../eventhub';
+import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
+import searchLabelsQuery from '../queries/search_labels.query.graphql';
+import searchMilestonesQuery from '../queries/search_milestones.query.graphql';
+import searchUsersQuery from '../queries/search_users.query.graphql';
+import setSortPreferenceMutation from '../queries/set_sort_preference.mutation.graphql';
import {
convertToApiParams,
convertToSearchQuery,
convertToUrlParams,
- getDueDateValue,
getFilterTokens,
getInitialPageParams,
getSortKey,
getSortOptions,
-} from '~/issues/list/utils';
-import axios from '~/lib/utils/axios_utils';
-import { scrollUp } from '~/lib/utils/scroll_utils';
-import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
-import {
- DEFAULT_NONE_ANY,
- OPERATOR_IS_ONLY,
- TOKEN_TITLE_ASSIGNEE,
- TOKEN_TITLE_AUTHOR,
- TOKEN_TITLE_CONFIDENTIAL,
- TOKEN_TITLE_LABEL,
- TOKEN_TITLE_MILESTONE,
- TOKEN_TITLE_MY_REACTION,
- TOKEN_TITLE_RELEASE,
- TOKEN_TITLE_TYPE,
-} from '~/vue_shared/components/filtered_search_bar/constants';
-import eventHub from '../eventhub';
-import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
-import searchLabelsQuery from '../queries/search_labels.query.graphql';
-import searchMilestonesQuery from '../queries/search_milestones.query.graphql';
-import searchUsersQuery from '../queries/search_users.query.graphql';
+ isSortKey,
+} from '../utils';
import NewIssueDropdown from './new_issue_dropdown.vue';
const AuthorToken = () =>
@@ -103,74 +101,31 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: {
- autocompleteAwardEmojisPath: {
- default: '',
- },
- calendarPath: {
- default: '',
- },
- canBulkUpdate: {
- default: false,
- },
- emptyStateSvgPath: {
- default: '',
- },
- exportCsvPath: {
- default: '',
- },
- fullPath: {
- default: '',
- },
- hasAnyIssues: {
- default: false,
- },
- hasAnyProjects: {
- default: false,
- },
- hasBlockedIssuesFeature: {
- default: false,
- },
- hasIssueWeightsFeature: {
- default: false,
- },
- hasMultipleIssueAssigneesFeature: {
- default: false,
- },
- initialEmail: {
- default: '',
- },
- isAnonymousSearchDisabled: {
- default: false,
- },
- isIssueRepositioningDisabled: {
- default: false,
- },
- isProject: {
- default: false,
- },
- isSignedIn: {
- default: false,
- },
- jiraIntegrationPath: {
- default: '',
- },
- newIssuePath: {
- default: '',
- },
- releasesPath: {
- default: '',
- },
- rssPath: {
- default: '',
- },
- showNewIssueLink: {
- default: false,
- },
- signInPath: {
- default: '',
- },
- },
+ inject: [
+ 'autocompleteAwardEmojisPath',
+ 'calendarPath',
+ 'canBulkUpdate',
+ 'emptyStateSvgPath',
+ 'exportCsvPath',
+ 'fullPath',
+ 'hasAnyIssues',
+ 'hasAnyProjects',
+ 'hasBlockedIssuesFeature',
+ 'hasIssueWeightsFeature',
+ 'hasMultipleIssueAssigneesFeature',
+ 'initialEmail',
+ 'initialSort',
+ 'isAnonymousSearchDisabled',
+ 'isIssueRepositioningDisabled',
+ 'isProject',
+ 'isSignedIn',
+ 'jiraIntegrationPath',
+ 'newIssuePath',
+ 'releasesPath',
+ 'rssPath',
+ 'showNewIssueLink',
+ 'signInPath',
+ ],
props: {
eeSearchTokens: {
type: Array,
@@ -181,7 +136,13 @@ export default {
data() {
const state = getParameterByName(PARAM_STATE);
const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
- let sortKey = getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey;
+ const dashboardSortKey = getSortKey(this.initialSort);
+ const graphQLSortKey =
+ isSortKey(this.initialSort?.toUpperCase()) && this.initialSort.toUpperCase();
+
+ // The initial sort is an old enum value when it is saved on the dashboard issues page.
+ // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page.
+ let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey;
if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) {
this.showIssueRepositioningMessage();
@@ -198,7 +159,6 @@ export default {
}
return {
- dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filterTokens: isSearchDisabled ? [] : getFilterTokens(window.location.search),
issues: [],
@@ -221,6 +181,9 @@ export default {
return data[this.namespace]?.issues.nodes ?? [];
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {};
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
},
@@ -341,6 +304,7 @@ export default {
token: MilestoneToken,
fetchMilestones: this.fetchMilestones,
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`,
+ shouldSkipSort: true,
},
{
type: TOKEN_TYPE_LABEL,
@@ -406,7 +370,7 @@ export default {
tokens.sort((a, b) => a.title.localeCompare(b.title));
- return orderBy(tokens, ['title']);
+ return tokens;
},
showPaginationControls() {
return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
@@ -427,7 +391,6 @@ export default {
},
urlParams() {
return {
- due_date: this.dueDateFilter,
search: this.searchQuery,
sort: urlSortParams[this.sortKey],
state: this.state,
@@ -584,7 +547,6 @@ export default {
.put(joinPaths(issueToMove.webPath, 'reorder'), {
move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId),
move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId),
- group_full_path: this.isProject ? undefined : this.fullPath,
})
.then(() => {
const serializedVariables = JSON.stringify(this.queryVariables);
@@ -608,6 +570,25 @@ export default {
this.pageParams = getInitialPageParams(sortKey);
}
this.sortKey = sortKey;
+
+ if (this.isSignedIn) {
+ this.saveSortPreference(sortKey);
+ }
+ },
+ saveSortPreference(sortKey) {
+ this.$apollo
+ .mutate({
+ mutation: setSortPreferenceMutation,
+ variables: { input: { issuesSort: sortKey } },
+ })
+ .then(({ data }) => {
+ if (data.userPreferencesUpdate.errors.length) {
+ throw new Error(data.userPreferencesUpdate.errors);
+ }
+ })
+ .catch((error) => {
+ Sentry.captureException(error);
+ });
},
showAnonymousSearchingMessage() {
createFlash({
@@ -644,6 +625,7 @@ export default {
:tabs="$options.IssuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
+ :truncate-counts="!isProject"
:issuables-loading="$apollo.queries.issues.loading"
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
diff --git a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue
index 71f84050ba8..666e80dfd4b 100644
--- a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue
+++ b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue
@@ -7,10 +7,10 @@ import {
GlSearchBoxByType,
} from '@gitlab/ui';
import createFlash from '~/flash';
-import searchProjectsQuery from '~/issues/list/queries/search_projects.query.graphql';
import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import searchProjectsQuery from '../queries/search_projects.query.graphql';
export default {
i18n: {
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 4a380848b4f..284167a933f 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -55,8 +55,6 @@ export const i18n = {
export const MAX_LIST_SIZE = 10;
export const PAGE_SIZE = 20;
export const PAGE_SIZE_MANUAL = 100;
-export const PARAM_DUE_DATE = 'due_date';
-export const PARAM_SORT = 'sort';
export const PARAM_STATE = 'state';
export const RELATIVE_POSITION = 'relative_position';
@@ -68,21 +66,6 @@ export const largePageSizeParams = {
firstPageSize: PAGE_SIZE_MANUAL,
};
-export const DUE_DATE_NONE = '0';
-export const DUE_DATE_ANY = '';
-export const DUE_DATE_OVERDUE = 'overdue';
-export const DUE_DATE_WEEK = 'week';
-export const DUE_DATE_MONTH = 'month';
-export const DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS = 'next_month_and_previous_two_weeks';
-export const DUE_DATE_VALUES = [
- DUE_DATE_NONE,
- DUE_DATE_ANY,
- DUE_DATE_OVERDUE,
- DUE_DATE_WEEK,
- DUE_DATE_MONTH,
- DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS,
-];
-
export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC';
export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC';
export const CREATED_ASC = 'CREATED_ASC';
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index 01cc82ed8fd..3b2d37eab74 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -30,6 +30,7 @@ export function mountJiraIssuesListApp() {
return new Vue({
el,
+ name: 'JiraIssuesImportStatusRoot',
apolloProvider,
render(createComponent) {
return createComponent(JiraIssuesImportStatusRoot, {
@@ -99,6 +100,7 @@ export function mountIssuesListApp() {
hasMultipleIssueAssigneesFeature,
importCsvIssuesPath,
initialEmail,
+ initialSort,
isAnonymousSearchDisabled,
isIssueRepositioningDisabled,
isProject,
@@ -118,6 +120,7 @@ export function mountIssuesListApp() {
return new Vue({
el,
+ name: 'IssuesListRoot',
apolloProvider,
provide: {
autocompleteAwardEmojisPath,
@@ -133,6 +136,7 @@ export function mountIssuesListApp() {
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
+ initialSort,
isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled),
isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled),
isProject: parseBoolean(isProject),
diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
index 07dae3fd756..430d494deab 100644
--- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
@@ -1,4 +1,5 @@
fragment IssueFragment on Issue {
+ __typename
id
iid
closedAt
@@ -18,6 +19,7 @@ fragment IssueFragment on Issue {
webUrl
assignees {
nodes {
+ __typename
id
avatarUrl
name
@@ -26,6 +28,7 @@ fragment IssueFragment on Issue {
}
}
author {
+ __typename
id
avatarUrl
name
diff --git a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql
index e7eb08104a6..040240cde99 100644
--- a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql
@@ -3,7 +3,13 @@
query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) {
group(fullPath: $fullPath) @skip(if: $isProject) {
id
- milestones(searchTitle: $search, includeAncestors: true, includeDescendants: true) {
+ milestones(
+ searchTitle: $search
+ includeAncestors: true
+ includeDescendants: true
+ sort: EXPIRED_LAST_DUE_DATE_ASC
+ state: active
+ ) {
nodes {
...Milestone
}
@@ -11,7 +17,12 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa
}
project(fullPath: $fullPath) @include(if: $isProject) {
id
- milestones(searchTitle: $search, includeAncestors: true) {
+ milestones(
+ searchTitle: $search
+ includeAncestors: true
+ sort: EXPIRED_LAST_DUE_DATE_ASC
+ state: active
+ ) {
nodes {
...Milestone
}
diff --git a/app/assets/javascripts/issues/list/queries/set_sort_preference.mutation.graphql b/app/assets/javascripts/issues/list/queries/set_sort_preference.mutation.graphql
new file mode 100644
index 00000000000..ed7b5193c9b
--- /dev/null
+++ b/app/assets/javascripts/issues/list/queries/set_sort_preference.mutation.graphql
@@ -0,0 +1,5 @@
+mutation setSortPreference($input: UserPreferencesUpdateInput!) {
+ userPreferencesUpdate(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index 2919bbbfef8..6322968b3f0 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -1,3 +1,9 @@
+import { isPositiveInteger } from '~/lib/utils/number_utils';
+import { __ } from '~/locale';
+import {
+ FILTERED_SEARCH_TERM,
+ OPERATOR_IS_NOT,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import {
API_PARAM,
BLOCKING_ISSUES_ASC,
@@ -7,7 +13,6 @@ import {
defaultPageSizeParams,
DUE_DATE_ASC,
DUE_DATE_DESC,
- DUE_DATE_VALUES,
filters,
LABEL_PRIORITY_ASC,
LABEL_PRIORITY_DESC,
@@ -36,13 +41,7 @@ import {
urlSortParams,
WEIGHT_ASC,
WEIGHT_DESC,
-} from '~/issues/list/constants';
-import { isPositiveInteger } from '~/lib/utils/number_utils';
-import { __ } from '~/locale';
-import {
- FILTERED_SEARCH_TERM,
- OPERATOR_IS_NOT,
-} from '~/vue_shared/components/filtered_search_bar/constants';
+} from './constants';
export const getInitialPageParams = (sortKey) =>
sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams;
@@ -50,7 +49,7 @@ export const getInitialPageParams = (sortKey) =>
export const getSortKey = (sort) =>
Object.keys(urlSortParams).find((key) => urlSortParams[key] === sort);
-export const getDueDateValue = (value) => (DUE_DATE_VALUES.includes(value) ? value : undefined);
+export const isSortKey = (sort) => Object.keys(urlSortParams).includes(sort);
export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) => {
const sortOptions = [
diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js
index c78505d0610..8fb891f62f7 100644
--- a/app/assets/javascripts/issues/manual_ordering.js
+++ b/app/assets/javascripts/issues/manual_ordering.js
@@ -7,12 +7,11 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
-const updateIssue = (url, issueList, { move_before_id, move_after_id }) =>
+const updateIssue = (url, { move_before_id, move_after_id }) =>
axios
.put(`${url}/reorder`, {
move_before_id,
move_after_id,
- group_full_path: issueList.dataset.groupFullPath,
})
.catch(() => {
createFlash({
@@ -52,7 +51,7 @@ const initManualOrdering = () => {
const beforeId = prev && parseInt(prev.dataset.id, 10);
const afterId = next && parseInt(next.dataset.id, 10);
- updateIssue(url, issueList, { move_after_id: afterId, move_before_id: beforeId });
+ updateIssue(url, { move_after_id: afterId, move_before_id: beforeId });
},
}),
);
diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js
index f96cacf2595..91599502996 100644
--- a/app/assets/javascripts/issues/new/index.js
+++ b/app/assets/javascripts/issues/new/index.js
@@ -20,6 +20,7 @@ export function initTitleSuggestions() {
return new Vue({
el,
+ name: 'TitleSuggestionsRoot',
apolloProvider,
data() {
return {
@@ -51,6 +52,7 @@ export function initTypePopover() {
return new Vue({
el,
+ name: 'TypePopoverRoot',
render: (createElement) => createElement(TypePopover),
});
}
diff --git a/app/assets/javascripts/issues/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js
index 5045f7e1a2a..196084093c8 100644
--- a/app/assets/javascripts/issues/related_merge_requests/index.js
+++ b/app/assets/javascripts/issues/related_merge_requests/index.js
@@ -13,6 +13,7 @@ export function initRelatedMergeRequests() {
return new Vue({
el,
+ name: 'RelatedMergeRequestsRoot',
store: createStore(),
render: (createElement) =>
createElement(RelatedMergeRequests, {
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 7be4c13f544..eeccf886b65 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,18 +1,31 @@
<script>
-import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import {
+ GlSafeHtmlDirective as SafeHtml,
+ GlModal,
+ GlModalDirective,
+ GlPopover,
+ GlButton,
+} from '@gitlab/ui';
import $ from 'jquery';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import TaskList from '~/task_list';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import animateMixin from '../mixins/animate';
export default {
directives: {
SafeHtml,
+ GlModal: GlModalDirective,
},
-
- mixins: [animateMixin],
-
+ components: {
+ GlModal,
+ GlPopover,
+ CreateWorkItem,
+ GlButton,
+ },
+ mixins: [animateMixin, glFeatureFlagMixin()],
props: {
canUpdate: {
type: Boolean,
@@ -53,8 +66,15 @@ export default {
preAnimation: false,
pulseAnimation: false,
initialUpdate: true,
+ taskButtons: [],
+ activeTask: {},
};
},
+ computed: {
+ workItemsEnabled() {
+ return this.glFeatures.workItems;
+ },
+ },
watch: {
descriptionHtml(newDescription, oldDescription) {
if (!this.initialUpdate && newDescription !== oldDescription) {
@@ -74,6 +94,10 @@ export default {
mounted() {
this.renderGFM();
this.updateTaskStatusText();
+
+ if (this.workItemsEnabled) {
+ this.renderTaskActions();
+ }
},
methods: {
renderGFM() {
@@ -132,6 +156,63 @@ export default {
$tasksShort.text('');
}
},
+ renderTaskActions() {
+ if (!this.$el?.querySelectorAll) {
+ return;
+ }
+
+ const taskListFields = this.$el.querySelectorAll('.task-list-item');
+
+ taskListFields.forEach((item, index) => {
+ const button = document.createElement('button');
+ button.classList.add(
+ 'btn',
+ 'btn-default',
+ 'btn-md',
+ 'gl-button',
+ 'btn-default-tertiary',
+ 'gl-left-0',
+ 'gl-p-0!',
+ 'gl-top-2',
+ 'gl-absolute',
+ 'js-add-task',
+ );
+ button.id = `js-task-button-${index}`;
+ this.taskButtons.push(button.id);
+ button.innerHTML = `
+ <svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14">
+ <use href="${gon.sprite_icons}#ellipsis_v"></use>
+ </svg>
+ `;
+ item.prepend(button);
+ });
+ },
+ openCreateTaskModal(id) {
+ this.activeTask = { id, title: this.$el.querySelector(`#${id}`).parentElement.innerText };
+ this.$refs.modal.show();
+ },
+ closeCreateTaskModal() {
+ this.$refs.modal.hide();
+ },
+ handleCreateTask(title) {
+ const listItem = this.$el.querySelector(`#${this.activeTask.id}`).parentElement;
+ const taskBadge = document.createElement('span');
+ taskBadge.innerHTML = `
+ <svg data-testid="issue-open-m-icon" role="img" aria-hidden="true" class="gl-icon gl-fill-green-500 s12">
+ <use href="${gon.sprite_icons}#issue-open-m"></use>
+ </svg>
+ <span class="badge badge-info badge-pill gl-badge sm gl-mr-1">
+ ${__('Task')}
+ </span>
+ <a href="#">${title}</a>
+ `;
+ listItem.insertBefore(taskBadge, listItem.lastChild);
+ listItem.removeChild(listItem.lastChild);
+ this.closeCreateTaskModal();
+ },
+ focusButton() {
+ this.$refs.convertButton[0].$el.focus();
+ },
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] },
};
@@ -142,12 +223,14 @@ export default {
v-if="descriptionHtml"
:class="{
'js-task-list-container': canUpdate,
+ 'work-items-enabled': workItemsEnabled,
}"
class="description"
>
<div
ref="gfm-content"
v-safe-html:[$options.safeHtmlConfig]="descriptionHtml"
+ data-testid="gfm-content"
:class="{
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
@@ -157,13 +240,46 @@ export default {
<!-- eslint-disable vue/no-mutating-props -->
<textarea
v-if="descriptionText"
- ref="textarea"
v-model="descriptionText"
:data-update-url="updateUrl"
class="hidden js-task-list-field"
dir="auto"
+ data-testid="textarea"
>
</textarea>
<!-- eslint-enable vue/no-mutating-props -->
+ <gl-modal
+ ref="modal"
+ modal-id="create-task-modal"
+ :title="s__('WorkItem|New Task')"
+ hide-footer
+ body-class="gl-p-0!"
+ >
+ <create-work-item
+ :is-modal="true"
+ :initial-title="activeTask.title"
+ @closeModal="closeCreateTaskModal"
+ @onCreate="handleCreateTask"
+ />
+ </gl-modal>
+ <template v-if="workItemsEnabled">
+ <gl-popover
+ v-for="item in taskButtons"
+ :key="item"
+ :target="item"
+ placement="top"
+ triggers="focus"
+ @shown="focusButton"
+ >
+ <gl-button
+ ref="convertButton"
+ variant="link"
+ data-testid="convert-to-task"
+ class="gl-text-gray-900! gl-text-decoration-none! gl-outline-0!"
+ @click="openCreateTaskModal(item)"
+ >{{ s__('WorkItem|Convert to work item') }}</gl-button
+ >
+ </gl-popover>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index 5476a1ef897..d5ac7b28afc 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -1,13 +1,12 @@
<script>
import markdownField from '~/vue_shared/components/markdown/field.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import updateMixin from '../../mixins/update';
export default {
components: {
markdownField,
},
- mixins: [glFeatureFlagsMixin(), updateMixin],
+ mixins: [updateMixin],
props: {
formState: {
type: Object,
@@ -56,7 +55,7 @@ export default {
v-model="formState.description"
class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
dir="auto"
- :data-supports-quick-actions="!glFeatures.tributeAutocomplete"
+ data-supports-quick-actions="true"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="updateIssuable"
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 7f5a0e32f72..f5c71f9691f 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -44,6 +44,7 @@ export function initIncidentApp(issueData = {}) {
return new Vue({
el,
+ name: 'DescriptionRoot',
apolloProvider,
provide: {
issueType: INCIDENT_TYPE,
@@ -74,6 +75,8 @@ export function initIssueApp(issueData, store) {
return undefined;
}
+ const { fullPath } = el.dataset;
+
if (gon?.features?.fixCommentScroll) {
scrollToTargetOnResize();
}
@@ -84,10 +87,12 @@ export function initIssueApp(issueData, store) {
return new Vue({
el,
+ name: 'DescriptionRoot',
apolloProvider,
store,
provide: {
canCreateIncident,
+ fullPath,
},
computed: {
...mapGetters(['getNoteableData']),
@@ -120,6 +125,7 @@ export function initHeaderActions(store, type = '') {
return new Vue({
el,
+ name: 'HeaderActionsRoot',
apolloProvider,
store,
provide: {
@@ -154,6 +160,7 @@ export function initSentryErrorStackTrace() {
return new Vue({
el,
+ name: 'SentryErrorStackTraceRoot',
store: errorTrackingStore,
render: (createElement) =>
createElement(SentryErrorStackTrace, { props: { issueStackTracePath } }),
diff --git a/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue b/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue
index 66fcb8e10eb..46c27c33f56 100644
--- a/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue
+++ b/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue
@@ -1,5 +1,6 @@
<script>
-import { GlFormGroup, GlButton, GlFormInput, GlForm, GlAlert } from '@gitlab/ui';
+import { GlFormGroup, GlButton, GlFormInput, GlForm, GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import {
CREATE_BRANCH_ERROR_GENERIC,
CREATE_BRANCH_ERROR_WITH_CONTEXT,
@@ -7,6 +8,7 @@ import {
I18N_NEW_BRANCH_LABEL_BRANCH,
I18N_NEW_BRANCH_LABEL_SOURCE,
I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT,
+ I18N_NEW_BRANCH_PERMISSION_ALERT,
} from '../constants';
import createBranchMutation from '../graphql/mutations/create_branch.mutation.graphql';
import ProjectDropdown from './project_dropdown.vue';
@@ -17,6 +19,8 @@ const DEFAULT_ALERT_PARAMS = {
title: '',
message: '',
variant: DEFAULT_ALERT_VARIANT,
+ link: undefined,
+ dismissible: true,
};
export default {
@@ -27,10 +31,16 @@ export default {
GlFormInput,
GlForm,
GlAlert,
+ GlSprintf,
+ GlLink,
ProjectDropdown,
SourceBranchDropdown,
},
- inject: ['initialBranchName'],
+ inject: {
+ initialBranchName: {
+ default: '',
+ },
+ },
data() {
return {
selectedProject: null,
@@ -40,6 +50,7 @@ export default {
alertParams: {
...DEFAULT_ALERT_PARAMS,
},
+ hasPermission: false,
};
},
computed: {
@@ -49,19 +60,38 @@ export default {
showAlert() {
return Boolean(this.alertParams?.message);
},
+ isBranchNameValid() {
+ return (this.branchName ?? '').trim().length > 0;
+ },
disableSubmitButton() {
- return !(this.selectedProject && this.selectedSourceBranchName && this.branchName);
+ return !(this.selectedProject && this.selectedSourceBranchName && this.isBranchNameValid);
},
},
methods: {
- displayAlert({ title, message, variant = DEFAULT_ALERT_VARIANT } = {}) {
+ displayAlert({
+ title,
+ message,
+ variant = DEFAULT_ALERT_VARIANT,
+ link,
+ dismissible = true,
+ } = {}) {
this.alertParams = {
title,
message,
variant,
+ link,
+ dismissible,
};
},
- onAlertDismiss() {
+ setPermissionAlert() {
+ this.displayAlert({
+ message: I18N_NEW_BRANCH_PERMISSION_ALERT,
+ variant: 'warning',
+ link: helpPagePath('user/permissions', { anchor: 'project-members-permissions' }),
+ dismissible: false,
+ });
+ },
+ dismissAlert() {
this.alertParams = {
...DEFAULT_ALERT_PARAMS,
};
@@ -69,6 +99,14 @@ export default {
onProjectSelect(project) {
this.selectedProject = project;
this.selectedSourceBranchName = null; // reset branch selection
+ this.hasPermission = this.selectedProject.userPermissions.pushCode;
+
+ if (!this.hasPermission) {
+ this.setPermissionAlert();
+ } else {
+ // clear alert if the user has permissions for the newly-selected project.
+ this.dismissAlert();
+ }
},
onSourceBranchSelect(branchName) {
this.selectedSourceBranchName = branchName;
@@ -127,10 +165,18 @@ export default {
class="gl-mb-5"
:variant="alertParams.variant"
:title="alertParams.title"
- @dismiss="onAlertDismiss"
+ :dismissible="alertParams.dismissible"
+ @dismiss="dismissAlert"
>
- {{ alertParams.message }}
+ <gl-sprintf :message="alertParams.message">
+ <template #link="{ content }">
+ <gl-link :href="alertParams.link" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
</gl-alert>
+
<gl-form-group :label="$options.i18n.I18N_NEW_BRANCH_LABEL_DROPDOWN" label-for="project-select">
<project-dropdown
id="project-select"
@@ -140,25 +186,28 @@ export default {
/>
</gl-form-group>
- <gl-form-group
- :label="$options.i18n.I18N_NEW_BRANCH_LABEL_BRANCH"
- label-for="branch-name-input"
- >
- <gl-form-input id="branch-name-input" v-model="branchName" type="text" required />
- </gl-form-group>
+ <template v-if="selectedProject && hasPermission">
+ <gl-form-group
+ :label="$options.i18n.I18N_NEW_BRANCH_LABEL_SOURCE"
+ label-for="source-branch-select"
+ >
+ <source-branch-dropdown
+ id="source-branch-select"
+ :selected-project="selectedProject"
+ :selected-branch-name="selectedSourceBranchName"
+ @change="onSourceBranchSelect"
+ @error="onError"
+ />
+ </gl-form-group>
- <gl-form-group
- :label="$options.i18n.I18N_NEW_BRANCH_LABEL_SOURCE"
- label-for="source-branch-select"
- >
- <source-branch-dropdown
- id="source-branch-select"
- :selected-project="selectedProject"
- :selected-branch-name="selectedSourceBranchName"
- @change="onSourceBranchSelect"
- @error="onError"
- />
- </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.I18N_NEW_BRANCH_LABEL_BRANCH"
+ label-for="branch-name-input"
+ class="gl-max-w-62"
+ >
+ <gl-form-input id="branch-name-input" v-model="branchName" type="text" required />
+ </gl-form-group>
+ </template>
<div class="form-actions">
<gl-button
diff --git a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
index 751f3e9639d..88005cccd89 100644
--- a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
+++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
@@ -1,5 +1,11 @@
<script>
-import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ GlDropdownItem,
+ GlAvatarLabeled,
+} from '@gitlab/ui';
import { __ } from '~/locale';
import { PROJECTS_PER_PAGE } from '../constants';
import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
@@ -14,6 +20,7 @@ export default {
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
+ GlAvatarLabeled,
},
props: {
selectedProject: {
@@ -56,7 +63,7 @@ export default {
return Boolean(this.$apollo.queries.projects.loading);
},
projectDropdownText() {
- return this.selectedProject?.nameWithNamespace || __('Select a project');
+ return this.selectedProject?.nameWithNamespace || this.$options.i18n.selectProjectText;
},
},
methods: {
@@ -70,11 +77,19 @@ export default {
return project.id === this.selectedProject?.id;
},
},
+ i18n: {
+ selectProjectText: __('Select a project'),
+ },
};
</script>
<template>
- <gl-dropdown :text="projectDropdownText" :loading="initialProjectsLoading">
+ <gl-dropdown
+ :text="projectDropdownText"
+ :loading="initialProjectsLoading"
+ menu-class="gl-w-auto!"
+ :header-text="$options.i18n.selectProjectText"
+ >
<template #header>
<gl-search-box-by-type v-model.trim="projectSearchQuery" :debounce="250" />
</template>
@@ -85,10 +100,20 @@ export default {
v-for="project in projects"
:key="project.id"
is-check-item
+ is-check-centered
:is-checked="isProjectSelected(project)"
+ :data-testid="`test-project-${project.id}`"
@click="onProjectSelect(project)"
>
- {{ project.nameWithNamespace }}
+ <gl-avatar-labeled
+ class="gl-text-truncate"
+ shape="rect"
+ :size="32"
+ :src="project.avatarUrl"
+ :label="project.name"
+ :entity-name="project.name"
+ :sub-label="project.nameWithNamespace"
+ />
</gl-dropdown-item>
</template>
</gl-dropdown>
diff --git a/app/assets/javascripts/jira_connect/branches/constants.js b/app/assets/javascripts/jira_connect/branches/constants.js
index ab9d3b2c110..43be774ce7c 100644
--- a/app/assets/javascripts/jira_connect/branches/constants.js
+++ b/app/assets/javascripts/jira_connect/branches/constants.js
@@ -23,3 +23,6 @@ export const I18N_NEW_BRANCH_SUCCESS_TITLE = s__(
export const I18N_NEW_BRANCH_SUCCESS_MESSAGE = s__(
'JiraConnect|You can now close this window and return to Jira.',
);
+export const I18N_NEW_BRANCH_PERMISSION_ALERT = s__(
+ "JiraConnect|You don't have permission to create branches for this project. Select a different project or contact the project owner for access. %{linkStart}Learn more.%{linkEnd}",
+);
diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql
index 32fbc1113bc..03e8e3e986b 100644
--- a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql
+++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql
@@ -26,6 +26,9 @@ query jiraGetProjects(
repository {
empty
}
+ userPermissions {
+ pushCode
+ }
}
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
index 5a49d7c1a90..7f035dddafe 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
@@ -30,7 +30,8 @@ export default {
page: 1,
totalItems: 0,
errorMessage: null,
- searchTerm: '',
+ userSearchTerm: '',
+ searchValue: '',
};
},
computed: {
@@ -45,16 +46,11 @@ export default {
},
methods: {
loadGroups() {
- // fetchGroups returns no results for search terms 0 < {length} < 3.
- // The desired UX is to return the unfiltered results for searches {length} < 3.
- // Here, we set the search to an empty string if {length} < 3
- const search = this.searchTerm?.length < MINIMUM_SEARCH_TERM_LENGTH ? '' : this.searchTerm;
-
this.isLoadingMore = true;
return fetchGroups(this.groupsPath, {
page: this.page,
perPage: this.$options.DEFAULT_GROUPS_PER_PAGE,
- search,
+ search: this.searchValue,
})
.then((response) => {
const { page, total } = parseIntPagination(normalizeHeaders(response.headers));
@@ -69,12 +65,24 @@ export default {
this.isLoadingMore = false;
});
},
- onGroupSearch(searchTerm) {
- // keep a copy of the search term for pagination
- this.searchTerm = searchTerm;
- // reset the current page
+ onGroupSearch(userSearchTerm = '') {
+ this.userSearchTerm = userSearchTerm;
+
+ // fetchGroups returns no results for search terms 0 < {length} < 3.
+ // The desired UX is to return the unfiltered results for searches {length} < 3.
+ // Here, we set the search to an empty string '' if {length} < 3
+ const newSearchValue =
+ this.userSearchTerm.length < MINIMUM_SEARCH_TERM_LENGTH ? '' : this.userSearchTerm;
+
+ // don't fetch new results if the search value didn't change.
+ if (newSearchValue === this.searchValue) {
+ return;
+ }
+
+ // reset the page.
this.page = 1;
- return this.loadGroups();
+ this.searchValue = newSearchValue;
+ this.loadGroups();
},
},
DEFAULT_GROUPS_PER_PAGE,
@@ -92,7 +100,7 @@ export default {
debounce="500"
:placeholder="__('Search by name')"
:is-loading="isLoadingMore"
- :value="searchTerm"
+ :value="userSearchTerm"
@input="onGroupSearch"
/>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index 7fd4cc38f11..905e242e977 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -1,13 +1,13 @@
<script>
-import { GlAlert, GlLink, GlSprintf, GlEmptyState } from '@gitlab/ui';
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapState, mapMutations } from 'vuex';
import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
import { SET_ALERT } from '../store/mutation_types';
-import SubscriptionsList from './subscriptions_list.vue';
-import AddNamespaceButton from './add_namespace_button.vue';
-import SignInButton from './sign_in_button.vue';
+import SignInPage from '../pages/sign_in.vue';
+import SubscriptionsPage from '../pages/subscriptions.vue';
import UserLink from './user_link.vue';
+import CompatibilityAlert from './compatibility_alert.vue';
export default {
name: 'JiraConnectApp',
@@ -15,11 +15,10 @@ export default {
GlAlert,
GlLink,
GlSprintf,
- GlEmptyState,
- SubscriptionsList,
- AddNamespaceButton,
- SignInButton,
UserLink,
+ CompatibilityAlert,
+ SignInPage,
+ SubscriptionsPage,
},
inject: {
usersPath: {
@@ -58,11 +57,14 @@ export default {
<template>
<div>
+ <compatibility-alert />
+
<gl-alert
v-if="shouldShowAlert"
class="gl-mb-7"
:variant="alert.variant"
:title="alert.title"
+ data-testid="jira-connect-persisted-alert"
@dismiss="setAlert"
>
<gl-sprintf v-if="alert.linkUrl" :message="alert.message">
@@ -79,43 +81,9 @@ export default {
<user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" />
<h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
- <div class="jira-connect-app-body gl-mx-auto gl-px-5 gl-mb-7">
- <template v-if="hasSubscriptions">
- <div class="gl-display-flex gl-justify-content-end">
- <sign-in-button v-if="!userSignedIn" :users-path="usersPath" />
- <add-namespace-button v-else />
- </div>
-
- <subscriptions-list />
- </template>
- <template v-else>
- <div v-if="!userSignedIn" class="gl-text-center">
- <p class="gl-mb-7">{{ s__('JiraService|Sign in to GitLab.com to get started.') }}</p>
- <sign-in-button class="gl-mb-7" :users-path="usersPath">
- {{ __('Sign in to GitLab') }}
- </sign-in-button>
- <p>
- {{
- s__(
- 'Integrations|Note: this integration only works with accounts on GitLab.com (SaaS).',
- )
- }}
- </p>
- </div>
- <gl-empty-state
- v-else
- :title="s__('Integrations|No linked namespaces')"
- :description="
- s__(
- 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.',
- )
- "
- >
- <template #actions>
- <add-namespace-button />
- </template>
- </gl-empty-state>
- </template>
+ <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7">
+ <sign-in-page v-if="!userSignedIn" :has-subscriptions="hasSubscriptions" />
+ <subscriptions-page v-else :has-subscriptions="hasSubscriptions" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
new file mode 100644
index 00000000000..3cfbd87ac53
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+
+const COMPATIBILITY_ALERT_STATE_KEY = 'compatibility_alert_dismissed';
+
+export default {
+ name: 'CompatibilityAlert',
+ components: {
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ LocalStorageSync,
+ },
+ data() {
+ return {
+ alertDismissed: false,
+ };
+ },
+ computed: {
+ shouldShowAlert() {
+ return !this.alertDismissed;
+ },
+ },
+ methods: {
+ dismissAlert() {
+ this.alertDismissed = true;
+ },
+ },
+ i18n: {
+ title: s__('Integrations|Known limitations'),
+ body: s__(
+ 'Integrations|This integration only works with GitLab.com. Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.',
+ ),
+ },
+ DOCS_LINK_URL: helpPagePath('integration/jira/connect-app'),
+ COMPATIBILITY_ALERT_STATE_KEY,
+};
+</script>
+<template>
+ <local-storage-sync
+ v-model="alertDismissed"
+ :storage-key="$options.COMPATIBILITY_ALERT_STATE_KEY"
+ >
+ <gl-alert
+ v-if="shouldShowAlert"
+ class="gl-mb-7"
+ variant="info"
+ :title="$options.i18n.title"
+ @dismiss="dismissAlert"
+ >
+ <gl-sprintf :message="$options.i18n.body">
+ <template #link="{ content }">
+ <gl-link :href="$options.DOCS_LINK_URL" target="_blank" rel="noopener noreferrer">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ </local-storage-sync>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue
index dc0a77e99c2..627abcdd4a0 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
+import { s__ } from '~/locale';
export default {
components: {
@@ -25,12 +26,15 @@ export default {
this.signInURL = await getGitlabSignInURL(this.usersPath);
},
},
+ i18n: {
+ defaultButtonText: s__('Integrations|Sign in to GitLab'),
+ },
};
</script>
<template>
<gl-button category="primary" variant="info" :href="signInURL" target="_blank">
<slot>
- {{ s__('Integrations|Sign in to add namespaces') }}
+ {{ $options.i18n.defaultButtonText }}
</slot>
</gl-button>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue
new file mode 100644
index 00000000000..2bce5afc72b
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue
@@ -0,0 +1,40 @@
+<script>
+import { s__ } from '~/locale';
+import SubscriptionsList from '../components/subscriptions_list.vue';
+import SignInButton from '../components/sign_in_button.vue';
+
+export default {
+ name: 'SignInPage',
+ components: {
+ SubscriptionsList,
+ SignInButton,
+ },
+ inject: ['usersPath'],
+ props: {
+ hasSubscriptions: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ i18n: {
+ signinButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
+ signInText: s__('JiraService|Sign in to GitLab.com to get started.'),
+ },
+};
+</script>
+
+<template>
+ <div v-if="hasSubscriptions">
+ <div class="gl-display-flex gl-justify-content-end">
+ <sign-in-button :users-path="usersPath">
+ {{ $options.i18n.signinButtonTextWithSubscriptions }}
+ </sign-in-button>
+ </div>
+
+ <subscriptions-list />
+ </div>
+ <div v-else class="gl-text-center">
+ <p class="gl-mb-7">{{ $options.i18n.signInText }}</p>
+ <sign-in-button class="gl-mb-7" :users-path="usersPath" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue
new file mode 100644
index 00000000000..426f2999370
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import SubscriptionsList from '../components/subscriptions_list.vue';
+import AddNamespaceButton from '../components/add_namespace_button.vue';
+
+export default {
+ name: 'SubscriptionsPage',
+ components: {
+ GlEmptyState,
+ SubscriptionsList,
+ AddNamespaceButton,
+ },
+ props: {
+ hasSubscriptions: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="hasSubscriptions">
+ <div class="gl-display-flex gl-justify-content-end">
+ <add-namespace-button />
+ </div>
+
+ <subscriptions-list />
+ </div>
+ <gl-empty-state
+ v-else
+ :title="s__('Integrations|No linked namespaces')"
+ :description="
+ s__(
+ 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.',
+ )
+ "
+ >
+ <template #actions>
+ <add-namespace-button />
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
index 7dfa963a857..753a15871ab 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -58,6 +58,14 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ retryBtnDisabled: false,
+ cancelBtnDisabled: false,
+ playManualBtnDisabled: false,
+ unscheduleBtnDisabled: false,
+ };
+ },
computed: {
hasArtifacts() {
return this.job.artifacts.nodes.find((artifact) => artifact.fileType === FILE_TYPE_ARCHIVE);
@@ -132,15 +140,23 @@ export default {
});
},
cancelJob() {
+ this.cancelBtnDisabled = true;
+
this.postJobAction(this.$options.jobCancel, cancelJobMutation);
},
retryJob() {
+ this.retryBtnDisabled = true;
+
this.postJobAction(this.$options.jobRetry, retryJobMutation);
},
playJob() {
+ this.playManualBtnDisabled = true;
+
this.postJobAction(this.$options.jobPlay, playJobMutation);
},
unscheduleJob() {
+ this.unscheduleBtnDisabled = true;
+
this.postJobAction(this.$options.jobUnschedule, unscheduleJobMutation);
},
},
@@ -155,6 +171,7 @@ export default {
data-testid="cancel-button"
icon="cancel"
:title="$options.CANCEL"
+ :disabled="cancelBtnDisabled"
@click="cancelJob()"
/>
<template v-else-if="isScheduled">
@@ -179,6 +196,7 @@ export default {
<gl-button
icon="time-out"
:title="$options.ACTIONS_UNSCHEDULE"
+ :disabled="unscheduleBtnDisabled"
data-testid="unschedule"
@click="unscheduleJob()"
/>
@@ -189,6 +207,7 @@ export default {
v-if="manualJobPlayable"
icon="play"
:title="$options.ACTIONS_PLAY"
+ :disabled="playManualBtnDisabled"
data-testid="play"
@click="playJob()"
/>
@@ -197,6 +216,7 @@ export default {
icon="repeat"
:title="$options.ACTIONS_RETRY"
:method="currentJobMethod"
+ :disabled="retryBtnDisabled"
data-testid="retry"
@click="retryJob()"
/>
diff --git a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
index ba5732d3d43..19594c4955d 100644
--- a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
@@ -39,6 +39,7 @@ export default {
<time
v-gl-tooltip
:title="tooltipTitle(finishedTime)"
+ :datetime="finishedTime"
data-placement="top"
data-container="body"
>
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
index c786d35ac68..81f42c1e293 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -51,7 +51,9 @@ export default {
},
data() {
return {
- jobs: {},
+ jobs: {
+ list: [],
+ },
hasError: false,
isAlertDismissed: false,
scope: null,
diff --git a/app/assets/javascripts/labels/index.js b/app/assets/javascripts/labels/index.js
index e87ad8d9a06..0d4113bba4c 100644
--- a/app/assets/javascripts/labels/index.js
+++ b/app/assets/javascripts/labels/index.js
@@ -11,6 +11,7 @@ import ProjectLabelSubscription from './project_label_subscription';
export function initDeleteLabelModal(optionalProps = {}) {
new Vue({
+ name: 'DeleteLabelModalRoot',
render(h) {
return h(DeleteLabelModal, {
props: {
@@ -65,6 +66,7 @@ export function initLabelIndex() {
return new Vue({
el: '#js-promote-label-modal',
+ name: 'PromoteLabelModal',
data() {
return {
modalProps: {
diff --git a/app/assets/javascripts/lib/apollo/instrumentation_link.js b/app/assets/javascripts/lib/apollo/instrumentation_link.js
index 2ab364557b8..bbe16d260e7 100644
--- a/app/assets/javascripts/lib/apollo/instrumentation_link.js
+++ b/app/assets/javascripts/lib/apollo/instrumentation_link.js
@@ -1,4 +1,4 @@
-import { ApolloLink } from 'apollo-link';
+import { ApolloLink } from '@apollo/client/core';
import { memoize } from 'lodash';
export const FEATURE_CATEGORY_HEADER = 'x-gitlab-feature-category';
diff --git a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
index 9b7901685b6..b2a86ac257b 100644
--- a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
+++ b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
@@ -1,5 +1,5 @@
-import { Observable } from 'apollo-link';
-import { onError } from 'apollo-link-error';
+import { Observable } from '@apollo/client/core';
+import { onError } from '@apollo/client/link/error';
import { isNavigatingAway } from '~/lib/utils/is_navigating_away';
/**
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index df2e85afe24..f533ba3671c 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -1,11 +1,9 @@
-import { InMemoryCache } from 'apollo-cache-inmemory';
-import { ApolloClient } from 'apollo-client';
-import { ApolloLink } from 'apollo-link';
-import { BatchHttpLink } from 'apollo-link-batch-http';
-import { HttpLink } from 'apollo-link-http';
+import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core';
+import { BatchHttpLink } from '@apollo/client/link/batch-http';
import { createUploadLink } from 'apollo-upload-client';
import ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
+import possibleTypes from '~/graphql_shared/possibleTypes.json';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
import { objectToQuery, queryToObject } from '~/lib/utils/url_utility';
@@ -21,6 +19,36 @@ export const fetchPolicies = {
CACHE_ONLY: 'cache-only',
};
+export const typePolicies = {
+ Repository: {
+ merge: true,
+ },
+ UserPermissions: {
+ merge: true,
+ },
+ MergeRequestPermissions: {
+ merge: true,
+ },
+ ContainerRepositoryConnection: {
+ merge: true,
+ },
+ TimelogConnection: {
+ merge: true,
+ },
+ BranchList: {
+ merge: true,
+ },
+ InstanceSecurityDashboard: {
+ merge: true,
+ },
+ PipelinePermissions: {
+ merge: true,
+ },
+ DesignCollection: {
+ merge: true,
+ },
+};
+
export const stripWhitespaceFromQuery = (url, path) => {
/* eslint-disable-next-line no-unused-vars */
const [_, params] = url.split(path);
@@ -46,6 +74,30 @@ export const stripWhitespaceFromQuery = (url, path) => {
return `${path}?${reassembled}`;
};
+const acs = [];
+
+let pendingApolloMutations = 0;
+
+// ### Why track pendingApolloMutations, but calculate pendingApolloRequests?
+//
+// In Apollo 2, we had a single link for counting operations.
+//
+// With Apollo 3, the `forward().map(...)` of deduped queries is never called.
+// So, we resorted to calculating the sum of `inFlightLinkObservables?.size`.
+// However! Mutations don't use `inFLightLinkObservables`, but since they are likely
+// not deduped we can count them...
+//
+// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55062#note_838943715
+// https://www.apollographql.com/docs/react/v2/networking/network-layer/#query-deduplication
+Object.defineProperty(window, 'pendingApolloRequests', {
+ get() {
+ return acs.reduce(
+ (sum, ac) => sum + (ac?.queryManager?.inFlightLinkObservables?.size || 0),
+ pendingApolloMutations,
+ );
+ },
+});
+
export default (resolvers = {}, config = {}) => {
const {
baseUrl,
@@ -56,6 +108,7 @@ export default (resolvers = {}, config = {}) => {
path = '/api/graphql',
useGet = false,
} = config;
+ let ac = null;
let uri = `${gon.relative_url_root || ''}${path}`;
if (baseUrl) {
@@ -75,16 +128,6 @@ export default (resolvers = {}, config = {}) => {
batchMax,
};
- const requestCounterLink = new ApolloLink((operation, forward) => {
- window.pendingApolloRequests = window.pendingApolloRequests || 0;
- window.pendingApolloRequests += 1;
-
- return forward(operation).map((response) => {
- window.pendingApolloRequests -= 1;
- return response;
- });
- });
-
/*
This custom fetcher intervention is to deal with an issue where we are using GET to access
eTag polling, but Apollo Client adds excessive whitespace, which causes the
@@ -138,6 +181,22 @@ export default (resolvers = {}, config = {}) => {
);
};
+ const hasMutation = (operation) =>
+ (operation?.query?.definitions || []).some((x) => x.operation === 'mutation');
+
+ const requestCounterLink = new ApolloLink((operation, forward) => {
+ if (hasMutation(operation)) {
+ pendingApolloMutations += 1;
+ }
+
+ return forward(operation).map((response) => {
+ if (hasMutation(operation)) {
+ pendingApolloMutations -= 1;
+ }
+ return response;
+ });
+ });
+
const appLink = ApolloLink.split(
hasSubscriptionOperation,
new ActionCableLink(),
@@ -155,19 +214,23 @@ export default (resolvers = {}, config = {}) => {
),
);
- return new ApolloClient({
+ ac = new ApolloClient({
typeDefs,
link: appLink,
cache: new InMemoryCache({
+ typePolicies,
+ possibleTypes,
...cacheConfig,
- freezeResults: true,
}),
resolvers,
- assumeImmutableResults: true,
defaultOptions: {
query: {
fetchPolicy,
},
},
});
+
+ acs.push(ac);
+
+ return ac;
};
diff --git a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
new file mode 100644
index 00000000000..6473683c3af
--- /dev/null
+++ b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
@@ -0,0 +1,3 @@
+// Import from `src/to_markdown` to avoid unnecessary bundling of unused libs
+// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79859
+export * from 'prosemirror-markdown/src/to_markdown';
diff --git a/app/assets/javascripts/lib/utils/apollo_startup_js_link.js b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js
index 014823f3831..f240226e991 100644
--- a/app/assets/javascripts/lib/utils/apollo_startup_js_link.js
+++ b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js
@@ -1,4 +1,4 @@
-import { ApolloLink, Observable } from 'apollo-link';
+import { ApolloLink, Observable } from '@apollo/client/core';
import { parse } from 'graphql';
import { isEqual, pickBy } from 'lodash';
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index eff00dff7a7..cf6ce2c4889 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -705,7 +705,10 @@ export const scopedLabelKey = ({ title = '' }) => {
};
// Methods to set and get Cookie
-export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 });
+export const setCookie = (name, value, attributes) => {
+ const defaults = { expires: 365, secure: Boolean(window.gon?.secure) };
+ Cookies.set(name, value, { ...defaults, ...attributes });
+};
export const getCookie = (name) => Cookies.get(name);
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
index 733d0f69f5d..f3380b7b4ba 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
@@ -1,13 +1,21 @@
<script>
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlSafeHtmlDirective } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
cancelAction: { text: __('Cancel') },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
components: {
GlModal,
},
props: {
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
primaryText: {
type: String,
required: false,
@@ -18,11 +26,27 @@ export default {
required: false,
default: 'confirm',
},
+ modalHtmlMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hideCancel: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
primaryAction() {
return { text: this.primaryText, attributes: { variant: this.primaryVariant } };
},
+ cancelAction() {
+ return this.hideCancel ? null : this.$options.cancelAction;
+ },
+ shouldShowHeader() {
+ return Boolean(this.title?.length);
+ },
},
mounted() {
this.$refs.modal.show();
@@ -36,12 +60,14 @@ export default {
size="sm"
modal-id="confirmationModal"
body-class="gl-display-flex"
+ :title="title"
:action-primary="primaryAction"
- :action-cancel="$options.cancelAction"
- hide-header
+ :action-cancel="cancelAction"
+ :hide-header="!shouldShowHeader"
@primary="$emit('confirmed')"
@hidden="$emit('closed')"
>
- <div class="gl-align-self-center"><slot></slot></div>
+ <div v-if="!modalHtmlMessage" class="gl-align-self-center"><slot></slot></div>
+ <div v-else v-safe-html="modalHtmlMessage" class="gl-align-self-center"></div>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
index fdd0e045d07..a8a89d0644a 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
@@ -1,6 +1,9 @@
import Vue from 'vue';
-export function confirmAction(message, { primaryBtnVariant, primaryBtnText } = {}) {
+export function confirmAction(
+ message,
+ { primaryBtnVariant, primaryBtnText, modalHtmlMessage, title, hideCancel } = {},
+) {
return new Promise((resolve) => {
let confirmed = false;
@@ -15,6 +18,9 @@ export function confirmAction(message, { primaryBtnVariant, primaryBtnText } = {
props: {
primaryVariant: primaryBtnVariant,
primaryText: primaryBtnText,
+ title,
+ modalHtmlMessage,
+ hideCancel,
},
on: {
confirmed() {
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 36c6545164e..379c57f3945 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -1,6 +1,7 @@
export const BYTES_IN_KIB = 1024;
export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250;
export const HIDDEN_CLASS = 'hidden';
+export const THOUSAND = 1000;
export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12;
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index f46263c0e4d..b0e31fe729b 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -1,5 +1,5 @@
import { sprintf, __ } from '~/locale';
-import { BYTES_IN_KIB } from './constants';
+import { BYTES_IN_KIB, THOUSAND } from './constants';
/**
* Function that allows a number with an X amount of decimals
@@ -86,6 +86,27 @@ export function numberToHumanSize(size, digits = 2) {
}
/**
+ * Converts a number to kilos or megas.
+ *
+ * For example:
+ * - 123 becomes 123
+ * - 123456 becomes 123.4k
+ * - 123456789 becomes 123.4m
+ *
+ * @param number Number to format
+ * @param digits The number of digits to appear after the decimal point
+ * @return {string} Formatted number
+ */
+export function numberToMetricPrefix(number, digits = 1) {
+ if (number < THOUSAND) {
+ return number.toString();
+ }
+ if (number < THOUSAND ** 2) {
+ return `${(number / THOUSAND).toFixed(digits)}k`;
+ }
+ return `${(number / THOUSAND ** 2).toFixed(digits)}m`;
+}
+/**
* A simple method that returns the value of a + b
* It seems unessesary, but when combined with a reducer it
* adds up all the values in an array.
diff --git a/app/assets/javascripts/lib/utils/table_utility.js b/app/assets/javascripts/lib/utils/table_utility.js
index 33db7686e0f..6d66335b832 100644
--- a/app/assets/javascripts/lib/utils/table_utility.js
+++ b/app/assets/javascripts/lib/utils/table_utility.js
@@ -1,3 +1,4 @@
+import { convertToSnakeCase, convertToCamelCase } from '~/lib/utils/text_utility';
import { DEFAULT_TH_CLASSES } from './constants';
/**
@@ -7,3 +8,37 @@ import { DEFAULT_TH_CLASSES } from './constants';
* @returns {String} The classes to be used in GlTable fields object.
*/
export const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`;
+
+/**
+ * Converts a GlTable sort-changed event object into string format.
+ * This string can be used as a sort argument on GraphQL queries.
+ *
+ * @param {Object} - The table state context object.
+ * @returns {String} A string with the sort key and direction, for example 'NAME_DESC'.
+ */
+export const sortObjectToString = ({ sortBy, sortDesc }) => {
+ const sortingDirection = sortDesc ? 'DESC' : 'ASC';
+ const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
+
+ return `${sortingColumn}_${sortingDirection}`;
+};
+
+/**
+ * Converts a sort string into a sort state object that can be used to
+ * set the sort order on GlTable.
+ *
+ * @param {String} - The string with the sort key and direction, for example 'NAME_DESC'.
+ * @returns {Object} An object with the sortBy and sortDesc properties.
+ */
+export const sortStringToObject = (sortString) => {
+ let sortBy = null;
+ let sortDesc = null;
+
+ if (sortString && sortString.includes('_')) {
+ const [key, direction] = sortString.split(/_(ASC|DESC)$/);
+ sortBy = convertToCamelCase(key.toLowerCase());
+ sortDesc = direction === 'DESC';
+ }
+
+ return { sortBy, sortDesc };
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 40dd29bea76..ec6789d81ec 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -5,6 +5,12 @@ import { insertText } from '~/lib/utils/common_utils';
const LINK_TAG_PATTERN = '[{text}](url)';
+// at the start of a line, find any amount of whitespace followed by
+// a bullet point character (*+-) and an optional checkbox ([ ] [x])
+// OR a number with a . after it and an optional checkbox ([ ] [x])
+// followed by one or more whitespace characters
+const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isOl>[*+-])|(?<isUl>\d+\.))( \[([x ])\])?\s)(?<content>.)?/;
+
function selectedText(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
}
@@ -13,8 +19,15 @@ function addBlockTags(blockTag, selected) {
return `${blockTag}\n${selected}\n${blockTag}`;
}
-function lineBefore(text, textarea) {
- const split = text.substring(0, textarea.selectionStart).trim().split('\n');
+function lineBefore(text, textarea, trimNewlines = true) {
+ let split = text.substring(0, textarea.selectionStart);
+
+ if (trimNewlines) {
+ split = split.trim();
+ }
+
+ split = split.split('\n');
+
return split[split.length - 1];
}
@@ -284,9 +297,9 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo
}
/* eslint-disable @gitlab/require-i18n-strings */
-export function keypressNoteText(e) {
+function handleSurroundSelectedText(e, textArea) {
if (!gon.markdown_surround_selection) return;
- if (this.selectionStart === this.selectionEnd) return;
+ if (textArea.selectionStart === textArea.selectionEnd) return;
const keys = {
'*': '**{text}**', // wraps with bold character
@@ -306,7 +319,7 @@ export function keypressNoteText(e) {
updateText({
tag,
- textArea: this,
+ textArea,
blockTag: '',
wrap: true,
select: '',
@@ -316,6 +329,48 @@ export function keypressNoteText(e) {
}
/* eslint-enable @gitlab/require-i18n-strings */
+function handleContinueList(e, textArea) {
+ if (!gon.features?.markdownContinueLists) return;
+ if (!(e.key === 'Enter')) return;
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
+ if (textArea.selectionStart !== textArea.selectionEnd) return;
+
+ const currentLine = lineBefore(textArea.value, textArea, false);
+ const result = currentLine.match(LIST_LINE_HEAD_PATTERN);
+
+ if (result) {
+ const { indent, content, leader } = result.groups;
+ const prevLineEmpty = !content;
+
+ if (prevLineEmpty) {
+ // erase previous empty list item - select the text and allow the
+ // natural line feed erase the text
+ textArea.selectionStart = textArea.selectionStart - result[0].length;
+ return;
+ }
+
+ const itemInsert = `${indent}${leader}`;
+
+ e.preventDefault();
+
+ updateText({
+ tag: itemInsert,
+ textArea,
+ blockTag: '',
+ wrap: false,
+ select: '',
+ tagContent: '',
+ });
+ }
+}
+
+export function keypressNoteText(e) {
+ const textArea = this;
+
+ handleContinueList(e, textArea);
+ handleSurroundSelectedText(e, textArea);
+}
+
export function updateTextForToolbarBtn($toolbarBtn) {
return updateText({
textArea: $toolbarBtn.closest('.md-area').find('textarea'),
diff --git a/app/assets/javascripts/lib/utils/yaml.js b/app/assets/javascripts/lib/utils/yaml.js
new file mode 100644
index 00000000000..9270d388342
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/yaml.js
@@ -0,0 +1,121 @@
+/**
+ * This file adds a merge function to be used with a yaml Document as defined by
+ * the yaml@2.x package: https://eemeli.org/yaml/#yaml
+ *
+ * Ultimately, this functionality should be merged upstream into the package,
+ * track the progress of that effort at https://github.com/eemeli/yaml/pull/347
+ * */
+
+import { visit, Scalar, isCollection, isDocument, isScalar, isNode, isMap, isSeq } from 'yaml';
+
+function getPath(ancestry) {
+ return ancestry.reduce((p, { key }) => {
+ return key !== undefined ? [...p, key.value] : p;
+ }, []);
+}
+
+function getFirstChildNode(collection) {
+ let firstChildKey;
+ let type;
+ switch (collection.constructor.name) {
+ case 'YAMLSeq': // eslint-disable-line @gitlab/require-i18n-strings
+ return collection.items.find((i) => isNode(i));
+ case 'YAMLMap': // eslint-disable-line @gitlab/require-i18n-strings
+ firstChildKey = collection.items[0]?.key;
+ if (!firstChildKey) return undefined;
+ return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey);
+ default:
+ type = collection.constructor?.name || typeof collection;
+ throw Error(`Cannot identify a child Node for type ${type}`);
+ }
+}
+
+function moveMetaPropsToFirstChildNode(collection) {
+ const firstChildNode = getFirstChildNode(collection);
+ const { comment, commentBefore, spaceBefore } = collection;
+ if (!(comment || commentBefore || spaceBefore)) return;
+ if (!firstChildNode)
+ throw new Error('Cannot move meta properties to a child of an empty Collection'); // eslint-disable-line @gitlab/require-i18n-strings
+ Object.assign(firstChildNode, { comment, commentBefore, spaceBefore });
+ Object.assign(collection, {
+ comment: undefined,
+ commentBefore: undefined,
+ spaceBefore: undefined,
+ });
+}
+
+function assert(isTypeFn, node, path) {
+ if (![isSeq, isMap].includes(isTypeFn)) {
+ throw new Error('assert() can only be used with isSeq() and isMap()');
+ }
+ const expectedTypeName = isTypeFn === isSeq ? 'YAMLSeq' : 'YAMLMap'; // eslint-disable-line @gitlab/require-i18n-strings
+ if (!isTypeFn(node)) {
+ const type = node?.constructor?.name || typeof node;
+ throw new Error(
+ `Type conflict at "${path.join(
+ '.',
+ )}": Destination node is of type ${type}, the node to be merged is of type ${expectedTypeName}.`,
+ );
+ }
+}
+
+function mergeCollection(target, node, path) {
+ // In case both the source and the target node have comments or spaces
+ // We'll move them to their first child so they do not conflict
+ moveMetaPropsToFirstChildNode(node);
+ if (target.hasIn(path)) {
+ const targetNode = target.getIn(path, true);
+ assert(isSeq(node) ? isSeq : isMap, targetNode, path);
+ moveMetaPropsToFirstChildNode(targetNode);
+ }
+}
+
+function mergePair(target, node, path) {
+ if (!isScalar(node.value)) return undefined;
+ if (target.hasIn([...path, node.key.value])) {
+ target.setIn(path, node);
+ } else {
+ target.addIn(path, node);
+ }
+ return visit.SKIP;
+}
+
+function getVisitorFn(target, options) {
+ return {
+ Map: (_, node, ancestors) => {
+ mergeCollection(target, node, getPath(ancestors));
+ },
+ Pair: (_, node, ancestors) => {
+ mergePair(target, node, getPath(ancestors));
+ },
+ Seq: (_, node, ancestors) => {
+ const path = getPath(ancestors);
+ mergeCollection(target, node, path);
+ if (options.onSequence === 'replace') {
+ target.setIn(path, node);
+ return visit.SKIP;
+ }
+ node.items.forEach((item) => target.addIn(path, item));
+ return visit.SKIP;
+ },
+ };
+}
+
+/** Merge another collection into this */
+export function merge(target, source, options = {}) {
+ const opt = {
+ onSequence: 'replace',
+ ...options,
+ };
+ const sourceNode = target.createNode(isDocument(source) ? source.contents : source);
+ if (!isCollection(sourceNode)) {
+ const type = source?.constructor?.name || typeof source;
+ throw new Error(`Cannot merge type "${type}", expected a Collection`);
+ }
+ if (!isCollection(target.contents)) {
+ // If the target doc is empty add the source to it directly
+ Object.assign(target, { contents: sourceNode });
+ return;
+ }
+ visit(sourceNode, getVisitorFn(target, opt));
+}
diff --git a/app/assets/javascripts/listbox/index.js b/app/assets/javascripts/listbox/index.js
new file mode 100644
index 00000000000..f63171e2785
--- /dev/null
+++ b/app/assets/javascripts/listbox/index.js
@@ -0,0 +1,67 @@
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export function parseAttributes(el) {
+ const { items: itemsString, selected, right: rightString } = el.dataset;
+
+ const items = JSON.parse(itemsString);
+ const right = parseBoolean(rightString);
+
+ const { className } = el;
+
+ return { items, selected, right, className };
+}
+
+export function initListbox(el, { onChange } = {}) {
+ if (!el) return null;
+
+ const { items, selected, right, className } = parseAttributes(el);
+
+ return new Vue({
+ el,
+ data() {
+ return {
+ selected,
+ };
+ },
+ computed: {
+ text() {
+ return items.find(({ value }) => value === this.selected)?.text;
+ },
+ },
+ render(h) {
+ return h(
+ GlDropdown,
+ {
+ props: {
+ text: this.text,
+ right,
+ },
+ class: className,
+ },
+ items.map((item) =>
+ h(
+ GlDropdownItem,
+ {
+ props: {
+ isCheckItem: true,
+ isChecked: this.selected === item.value,
+ },
+ on: {
+ click: () => {
+ this.selected = item.value;
+
+ if (typeof onChange === 'function') {
+ onChange(item);
+ }
+ },
+ },
+ },
+ item.text,
+ ),
+ ),
+ );
+ },
+ });
+}
diff --git a/app/assets/javascripts/listbox/redirect_behavior.js b/app/assets/javascripts/listbox/redirect_behavior.js
new file mode 100644
index 00000000000..7e0ea2c4dfd
--- /dev/null
+++ b/app/assets/javascripts/listbox/redirect_behavior.js
@@ -0,0 +1,22 @@
+import { initListbox } from '~/listbox';
+import { redirectTo } from '~/lib/utils/url_utility';
+
+/**
+ * Instantiates GlListbox components with redirect behavior for tags created
+ * with the `gl_redirect_listbox_tag` HAML helper.
+ *
+ * NOTE: Do not import this script explicitly. Using `gl_redirect_listbox_tag`
+ * automatically injects the `redirect_listbox` bundle, which calls this
+ * function.
+ */
+export function initRedirectListboxBehavior() {
+ const elements = Array.from(document.querySelectorAll('.js-redirect-listbox'));
+
+ return elements.map((el) =>
+ initListbox(el, {
+ onChange({ href }) {
+ redirectTo(href);
+ },
+ }),
+ );
+}
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
index c9e7b034950..b0d31ca315e 100644
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ b/app/assets/javascripts/logs/components/environment_logs.vue
@@ -2,6 +2,7 @@
import {
GlSprintf,
GlAlert,
+ GlLink,
GlDropdown,
GlDropdownSectionHeader,
GlDropdownItem,
@@ -20,6 +21,7 @@ import LogSimpleFilters from './log_simple_filters.vue';
export default {
components: {
GlSprintf,
+ GlLink,
GlAlert,
GlDropdown,
GlDropdownSectionHeader,
@@ -58,6 +60,7 @@ export default {
return {
isElasticStackCalloutDismissed: false,
scrollDownButtonDisabled: true,
+ isDeprecationNoticeDismissed: false,
};
},
computed: {
@@ -151,6 +154,41 @@ export default {
{{ s__('Metrics|Invalid time range, please verify.') }}
</gl-alert>
<gl-alert
+ v-if="!isDeprecationNoticeDismissed"
+ :title="s__('Deprecations|Feature deprecation and removal')"
+ class="mb-3"
+ variant="danger"
+ @dismiss="isDeprecationNoticeDismissed = true"
+ >
+ <gl-sprintf
+ :message="
+ s__(
+ 'Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.',
+ )
+ "
+ >
+ <template #epic="{ content }">
+ <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+
+ <gl-sprintf
+ :message="
+ s__(
+ 'Deprecations|For information on a possible replacement %{epicStart} learn more about Opstrace %{epicEnd}.',
+ )
+ "
+ >
+ <template #epic="{ content }">
+ <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/6976" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <gl-alert
v-if="logs.fetchError"
class="mb-3"
variant="danger"
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 376134afef0..f78b4da181e 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -15,11 +15,11 @@ import { initRails } from '~/lib/utils/rails_ujs';
import * as popovers from '~/popovers';
import * as tooltips from '~/tooltips';
import { initPrefetchLinks } from '~/lib/utils/navigation_utility';
+import { logHelloDeferred } from 'jh_else_ce/lib/logger/hello_deferred';
import initAlertHandler from './alert_handler';
import { addDismissFlashClickListener } from './flash';
import initTodoToggle from './header';
import initLayoutNav from './layout_nav';
-import { logHelloDeferred } from './lib/logger/hello_deferred';
import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils';
import { localTimeAgo } from './lib/utils/datetime/timeago_utility';
import { getLocationHash, visitUrl } from './lib/utils/url_utility';
diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue
index 9687eacb036..ec59f0f681c 100644
--- a/app/assets/javascripts/members/components/avatars/user_avatar.vue
+++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue
@@ -8,10 +8,14 @@ import {
import { generateBadges } from 'ee_else_ce/members/utils';
import { glEmojiTag } from '~/emoji';
import { __ } from '~/locale';
+import { isUserBusy } from '~/set_status_modal/utils';
import { AVATAR_SIZE } from '../../constants';
export default {
name: 'UserAvatar',
+ i18n: {
+ busy: __('Busy'),
+ },
avatarSize: AVATAR_SIZE,
orphanedUserLabel: __('Orphaned member'),
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
@@ -46,7 +50,10 @@ export default {
}).filter((badge) => badge.show);
},
statusEmoji() {
- return this.user?.status?.emoji;
+ return this.user?.showStatus && this.user?.status?.emoji;
+ },
+ isUserBusy() {
+ return isUserBusy(this.user?.availability || '');
},
},
methods: {
@@ -73,6 +80,11 @@ export default {
:entity-id="user.id"
>
<template #meta>
+ <div v-if="isUserBusy" class="gl-p-1">
+ <span class="gl-text-gray-500 gl-font-sm gl-font-weight-normal"
+ >({{ $options.i18n.busy }})</span
+ >
+ </div>
<div v-if="statusEmoji" class="gl-p-1">
<span
v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)"
diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index e9329fb1d88..633dee75237 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -151,6 +151,7 @@ export default {
:search-input-placeholder="filteredSearchBar.placeholder"
:initial-filter-value="initialFilterValue"
data-testid="members-filtered-search-bar"
+ data-qa-selector="members_filtered_search_bar_content"
@onFilter="handleFilter"
/>
</template>
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index e09d16cf680..b4ba9aa36e7 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -11,7 +11,9 @@ import {
ACTIVE_TAB_QUERY_PARAM_NAME,
TAB_QUERY_PARAM_VALUES,
MEMBER_STATE_AWAITING,
+ MEMBER_STATE_ACTIVE,
USER_STATE_BLOCKED_PENDING_APPROVAL,
+ BADGE_LABELS_AWAITING_USER_SIGNUP,
BADGE_LABELS_PENDING_OWNER_APPROVAL,
} from '../../constants';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
@@ -154,8 +156,12 @@ export default {
* @see {@link ~/app/serializers/member_entity.rb}
* @returns {boolean}
*/
- isNewUser(memberInviteMetadata) {
- return memberInviteMetadata && !memberInviteMetadata.userState;
+ isNewUser(memberInviteMetadata, memberState) {
+ return (
+ memberInviteMetadata &&
+ !memberInviteMetadata.userState &&
+ memberState !== MEMBER_STATE_ACTIVE
+ );
},
/**
* Returns whether the user is awaiting root approval
@@ -204,6 +210,10 @@ export default {
* @returns {string}
*/
inviteBadge(memberInviteMetadata, memberState) {
+ if (this.isNewUser(memberInviteMetadata, memberState)) {
+ return BADGE_LABELS_AWAITING_USER_SIGNUP;
+ }
+
if (this.shouldAddPendingOwnerApprovalBadge(memberInviteMetadata, memberState)) {
return BADGE_LABELS_PENDING_OWNER_APPROVAL;
}
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 62241eaed04..273f1acebc7 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -111,6 +111,7 @@ export const MEMBER_STATE_CREATED = 0;
export const MEMBER_STATE_AWAITING = 1;
export const MEMBER_STATE_ACTIVE = 2;
+export const BADGE_LABELS_AWAITING_USER_SIGNUP = __('Awaiting user signup');
export const BADGE_LABELS_PENDING_OWNER_APPROVAL = __('Pending owner approval');
export const DAYS_TO_EXPIRE_SOON = 7;
diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js
index df515c4ac1a..9c101da52f5 100644
--- a/app/assets/javascripts/merge_conflicts/store/actions.js
+++ b/app/assets/javascripts/merge_conflicts/store/actions.js
@@ -1,4 +1,4 @@
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -51,7 +51,7 @@ export const setFailedRequest = ({ commit }, message) => {
export const setViewType = ({ commit }, viewType) => {
commit(types.SET_VIEW_TYPE, viewType);
- Cookies.set('diff_view', viewType);
+ setCookie('diff_view', viewType);
};
export const setSubmitState = ({ commit }, isSubmitting) => {
diff --git a/app/assets/javascripts/merge_conflicts/store/state.js b/app/assets/javascripts/merge_conflicts/store/state.js
index 8f700f58e54..7a2e28183a7 100644
--- a/app/assets/javascripts/merge_conflicts/store/state.js
+++ b/app/assets/javascripts/merge_conflicts/store/state.js
@@ -1,7 +1,7 @@
-import Cookies from 'js-cookie';
+import { getCookie } from '~/lib/utils/common_utils';
import { VIEW_TYPES } from '../constants';
-const diffViewType = Cookies.get('diff_view');
+const diffViewType = getCookie('diff_view');
export default () => ({
isLoading: true,
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index a40caea1223..ad0117844cd 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,20 +1,21 @@
/* eslint-disable no-new, class-methods-use-this */
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
-import Cookies from 'js-cookie';
import Vue from 'vue';
+import {
+ getCookie,
+ parseUrlPathname,
+ isMetaClick,
+ parseBoolean,
+ scrollToElement,
+} from '~/lib/utils/common_utils';
import createEventHub from '~/helpers/event_hub_factory';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import Diff from './diff';
import createFlash from './flash';
import { initDiffStatsDropdown } from './init_diff_stats_dropdown';
import axios from './lib/utils/axios_utils';
-import {
- parseUrlPathname,
- isMetaClick,
- parseBoolean,
- scrollToElement,
-} from './lib/utils/common_utils';
+
import { localTimeAgo } from './lib/utils/datetime_utility';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { __ } from './locale';
@@ -514,7 +515,7 @@ export default class MergeRequestTabs {
// Expand the issuable sidebar unless the user explicitly collapsed it
expandView() {
- if (parseBoolean(Cookies.get('collapsed_gutter'))) {
+ if (parseBoolean(getCookie('collapsed_gutter'))) {
return;
}
const $gutterBtn = $('.js-sidebar-toggle');
diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js
index 2ca5f104b4f..f90fdb04923 100644
--- a/app/assets/javascripts/milestones/index.js
+++ b/app/assets/javascripts/milestones/index.js
@@ -46,6 +46,7 @@ export function initPromoteMilestoneModal() {
return new Vue({
el: promoteMilestoneModal,
+ name: 'PromoteMilestoneModalRoot',
render(createElement) {
return createElement(PromoteMilestoneModal);
},
@@ -80,6 +81,7 @@ export function initDeleteMilestoneModal() {
return new Vue({
el: '#js-delete-milestone-modal',
+ name: 'DeleteMilestoneModalRoot',
data() {
return {
modalProps: {
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index c9767330b73..6467d953500 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,5 +1,13 @@
<script>
-import { GlButton, GlModalDirective, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import {
+ GlButton,
+ GlModalDirective,
+ GlTooltipDirective,
+ GlIcon,
+ GlAlert,
+ GlSprintf,
+ GlLink,
+} from '@gitlab/ui';
import Mousetrap from 'mousetrap';
import VueDraggable from 'vuedraggable';
import { mapActions, mapState, mapGetters } from 'vuex';
@@ -38,6 +46,9 @@ export default {
GroupEmptyState,
VariablesSection,
LinksSection,
+ GlAlert,
+ GlSprintf,
+ GlLink,
},
directives: {
GlModal: GlModalDirective,
@@ -143,6 +154,7 @@ export default {
isRearrangingPanels: false,
originalDocumentTitle: document.title,
hoveredPanel: '',
+ isDeprecationNoticeDismissed: false,
};
},
computed: {
@@ -392,9 +404,44 @@ export default {
},
};
</script>
-
<template>
<div class="prometheus-graphs" data-qa-selector="prometheus_graphs">
+ <div>
+ <gl-alert
+ v-if="!isDeprecationNoticeDismissed"
+ :title="__('Feature deprecation and removal')"
+ class="mb-3"
+ variant="danger"
+ @dismiss="isDeprecationNoticeDismissed = true"
+ >
+ <gl-sprintf
+ :message="
+ s__(
+ 'Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.',
+ )
+ "
+ >
+ <template #epic="{ content }">
+ <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Deprecations|For information on a possible replacement %{epicStart} learn more about Opstrace %{epicEnd}.',
+ )
+ "
+ >
+ <template #epic="{ content }">
+ <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/6976" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ </div>
<dashboard-header
v-if="showHeader"
ref="prometheusGraphsHeader"
diff --git a/app/assets/javascripts/nav/components/top_nav_menu_item.vue b/app/assets/javascripts/nav/components/top_nav_menu_item.vue
index 07c6fa7773a..bf1fd691ca8 100644
--- a/app/assets/javascripts/nav/components/top_nav_menu_item.vue
+++ b/app/assets/javascripts/nav/components/top_nav_menu_item.vue
@@ -35,7 +35,7 @@ export default {
<gl-button
category="tertiary"
:href="menuItem.href"
- class="top-nav-menu-item gl-display-block"
+ class="top-nav-menu-item gl-display-block gl-pr-3!"
:class="[menuItem.css_class, { [$options.ACTIVE_CLASS]: menuItem.active }]"
:aria-label="menuItem.title"
v-bind="dataAttrs"
diff --git a/app/assets/javascripts/nav/mount.js b/app/assets/javascripts/nav/mount.js
index 51b6a31b8cb..7b0cc977107 100644
--- a/app/assets/javascripts/nav/mount.js
+++ b/app/assets/javascripts/nav/mount.js
@@ -12,6 +12,7 @@ const mount = (el, Component) => {
return new Vue({
el,
+ name: 'TopNavRoot',
store,
render(h) {
return h(Component, {
diff --git a/app/assets/javascripts/network/raphael.js b/app/assets/javascripts/network/raphael.js
index 22e06a35d91..e13471c0e51 100644
--- a/app/assets/javascripts/network/raphael.js
+++ b/app/assets/javascripts/network/raphael.js
@@ -1,12 +1,14 @@
import Raphael from 'raphael/raphael';
+import { formatDate } from '~/lib/utils/datetime_utility';
Raphael.prototype.commitTooltip = function commitTooltip(x, y, commit) {
const boxWidth = 300;
const icon = this.image(gon.relative_url_root + commit.author.icon, x, y, 20, 20);
const nameText = this.text(x + 25, y + 10, commit.author.name);
- const idText = this.text(x, y + 35, commit.id);
- const messageText = this.text(x, y + 50, commit.message.replace(/\r?\n/g, ' \n '));
- const textSet = this.set(icon, nameText, idText, messageText).attr({
+ const dateText = this.text(x, y + 35, formatDate(commit.date));
+ const idText = this.text(x, y + 55, commit.id);
+ const messageText = this.text(x, y + 70, commit.message.replace(/\r?\n/g, ' \n '));
+ const textSet = this.set(icon, nameText, dateText, idText, messageText).attr({
'text-anchor': 'start',
font: '12px Monaco, monospace',
});
@@ -14,6 +16,9 @@ Raphael.prototype.commitTooltip = function commitTooltip(x, y, commit) {
font: '14px Arial',
'font-weight': 'bold',
});
+ dateText.attr({
+ fill: '#666',
+ });
idText.attr({
fill: '#AAA',
});
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 996c008b881..a9948fed3b6 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -369,7 +369,7 @@ export default {
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
data-qa-selector="comment_field"
data-testid="comment-field"
- :data-supports-quick-actions="!glFeatures.tributeAutocomplete"
+ data-supports-quick-actions="true"
:aria-label="$options.i18n.comment"
:placeholder="$options.i18n.bodyPlaceholder"
@keydown.up="editCurrentUserLastNote()"
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index b2d5910fd3f..b4f7ba5f960 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -107,7 +107,7 @@ export default {
<td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block">
{{ __('Unable to load the diff') }}
<button
- class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button"
+ class="btn-link btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button"
@click="fetchDiff"
>
{{ __('Try again') }}
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index d6b65ed0e8b..ee22c118e11 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -5,7 +5,6 @@ import { getDraft, updateDraft } from '~/lib/utils/autosave';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import markdownField from '~/vue_shared/components/markdown/field.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
@@ -20,7 +19,7 @@ export default {
GlSprintf,
GlLink,
},
- mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable],
+ mixins: [issuableStateMixin, resolvable],
props: {
noteBody: {
type: String,
@@ -349,7 +348,7 @@ export default {
ref="textarea"
v-model="updatedNoteBody"
:disabled="isSubmitting"
- :data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete"
+ :data-supports-quick-actions="!isEditing"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field"
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 8e32c3b3073..ddf72587ba3 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -5,6 +5,7 @@ import DraftNote from '~/batch_comments/components/draft_note.vue';
import createFlash from '~/flash';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import { isLoggedIn } from '~/lib/utils/common_utils';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { s__, __ } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
@@ -170,12 +171,13 @@ export default {
this.expandDiscussion({ discussionId: this.discussion.id });
}
},
- cancelReplyForm(shouldConfirm, isDirty) {
+ async cancelReplyForm(shouldConfirm, isDirty) {
if (shouldConfirm && isDirty) {
const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
- // eslint-disable-next-line no-alert
- if (!window.confirm(msg)) {
+ const confirmed = await confirmAction(msg);
+
+ if (!confirmed) {
return;
}
}
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 3250a4818c7..7bad10616cc 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -3,6 +3,7 @@ import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
import { escape, isEmpty } from 'lodash';
import { mapGetters, mapActions } from 'vuex';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
@@ -243,14 +244,18 @@ export default {
this.setSelectedCommentPositionHover();
this.$emit('handleEdit');
},
- deleteHandler() {
+ async deleteHandler() {
const typeOfComment = this.note.isDraft ? __('pending comment') : __('comment');
- if (
- // eslint-disable-next-line no-alert
- window.confirm(
- sprintf(__('Are you sure you want to delete this %{typeOfComment}?'), { typeOfComment }),
- )
- ) {
+
+ const msg = sprintf(__('Are you sure you want to delete this %{typeOfComment}?'), {
+ typeOfComment,
+ });
+ const confirmed = await confirmAction(msg, {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: __('Delete Comment'),
+ });
+
+ if (confirmed) {
this.isDeleting = true;
this.$emit('handleDeleteNote', this.note);
@@ -345,10 +350,11 @@ export default {
parent: this.$el,
});
},
- formCancelHandler({ shouldConfirm, isDirty }) {
+ async formCancelHandler({ shouldConfirm, isDirty }) {
if (shouldConfirm && isDirty) {
- // eslint-disable-next-line no-alert
- if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) return;
+ const msg = __('Are you sure you want to cancel editing this comment?');
+ const confirmed = await confirmAction(msg);
+ if (!confirmed) return;
}
this.$refs.noteBody.resetAutoSave();
if (this.oldContent) {
diff --git a/app/assets/javascripts/notes/discussion_filters.js b/app/assets/javascripts/notes/discussion_filters.js
index 7c9e7703d59..104e9d4183a 100644
--- a/app/assets/javascripts/notes/discussion_filters.js
+++ b/app/assets/javascripts/notes/discussion_filters.js
@@ -19,7 +19,7 @@ export default (store) => {
return new Vue({
el: discussionFilterEl,
- name: 'DiscussionFilter',
+ name: 'DiscussionFilterRoot',
components: {
DiscussionFilter,
},
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 2ce60976adb..19fa484d659 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -14,6 +14,7 @@ export default () => {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'NotesRoot',
components: {
notesApp,
},
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index ad529eb99b6..93236b05100 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -3,8 +3,6 @@ import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_
import { updateHistory } from '../../lib/utils/url_utility';
import eventHub from '../event_hub';
-const isDiffsVirtualScrollingEnabled = () => window.gon?.features?.diffsVirtualScrolling;
-
/**
* @param {string} selector
* @returns {boolean}
@@ -15,7 +13,7 @@ function scrollTo(selector, { withoutContext = false } = {}) {
if (el) {
scrollFunction(el, {
- behavior: isDiffsVirtualScrollingEnabled() ? 'auto' : 'smooth',
+ behavior: 'auto',
});
return true;
}
@@ -31,7 +29,7 @@ function updateUrlWithNoteId(noteId) {
replace: true,
};
- if (noteId && isDiffsVirtualScrollingEnabled()) {
+ if (noteId) {
// Temporarily mask the ID to avoid the browser default
// scrolling taking over which is broken with virtual
// scrolling enabled.
@@ -115,17 +113,13 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId)
const isDiffView = window.mrTabs.currentAction === 'diffs';
const targetId = fn(discussionId, isDiffView);
const discussion = self.getDiscussion(targetId);
- const setHash = !isDiffView && !isDiffsVirtualScrollingEnabled();
const discussionFilePath = discussion?.diff_file?.file_path;
- if (isDiffsVirtualScrollingEnabled()) {
- window.location.hash = '';
- }
+ window.location.hash = '';
if (discussionFilePath) {
self.scrollToFile({
path: discussionFilePath,
- setHash,
});
}
diff --git a/app/assets/javascripts/notes/sort_discussions.js b/app/assets/javascripts/notes/sort_discussions.js
index ecfa3223039..ca8df880fe4 100644
--- a/app/assets/javascripts/notes/sort_discussions.js
+++ b/app/assets/javascripts/notes/sort_discussions.js
@@ -8,6 +8,7 @@ export default (store) => {
return new Vue({
el,
+ name: 'SortDiscussionRoot',
store,
render(createElement) {
return createElement(SortDiscussion);
diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown.vue b/app/assets/javascripts/notifications/components/notifications_dropdown.vue
index 69eb2115bf4..6b450c2b5fd 100644
--- a/app/assets/javascripts/notifications/components/notifications_dropdown.vue
+++ b/app/assets/javascripts/notifications/components/notifications_dropdown.vue
@@ -42,6 +42,9 @@ export default {
showLabel: {
default: false,
},
+ noFlip: {
+ default: false,
+ },
},
data() {
return {
@@ -127,6 +130,7 @@ export default {
:disabled="disabled"
:split="isCustomNotification"
:text="buttonText"
+ :no-flip="noFlip"
@click="openNotificationsModal"
>
<notifications-dropdown-item
diff --git a/app/assets/javascripts/notifications/index.js b/app/assets/javascripts/notifications/index.js
index d60a368703c..a81f2c2590b 100644
--- a/app/assets/javascripts/notifications/index.js
+++ b/app/assets/javascripts/notifications/index.js
@@ -21,6 +21,7 @@ export default () => {
projectId,
groupId,
showLabel,
+ noFlip,
} = el.dataset;
return new Vue({
@@ -35,6 +36,7 @@ export default () => {
projectId,
groupId,
showLabel: parseBoolean(showLabel),
+ noFlip: parseBoolean(noFlip),
},
render(h) {
return h(NotificationsDropdown);
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
index 4fda4058711..7659ba5f9ea 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
@@ -7,6 +7,7 @@ import RegistryList from '~/packages_and_registries/shared/components/registry_l
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import {
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
@@ -20,7 +21,6 @@ import {
} from '../../constants/index';
import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql';
import TagsListRow from './tags_list_row.vue';
-import TagsLoader from './tags_loader.vue';
export default {
name: 'TagsList',
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
index bb687ffdb89..931849c9918 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
@@ -5,6 +5,7 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
+import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import DeleteImage from '../components/delete_image.vue';
import DeleteAlert from '../components/details_page/delete_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
@@ -12,7 +13,6 @@ import DetailsHeader from '../components/details_page/details_header.vue';
import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
import StatusAlert from '../components/details_page/status_alert.vue';
import TagsList from '../components/details_page/tags_list.vue';
-import TagsLoader from '../components/details_page/tags_loader.vue';
import {
ALERT_SUCCESS_TAG,
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
index 3274de05803..e2acebf39d6 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
@@ -52,7 +52,7 @@ export default {
),
CliCommands: () =>
import(
- /* webpackChunkName: 'container_registry_components' */ '../components/list_page/cli_commands.vue'
+ /* webpackChunkName: 'container_registry_components' */ '~/packages_and_registries/shared/components/cli_commands.vue'
),
GlModal,
GlSprintf,
@@ -68,7 +68,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [Tracking.mixin()],
- inject: ['config'],
+ inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
loader: {
repeat: 10,
width: 1000,
@@ -96,6 +96,9 @@ export default {
return data[this.graphqlResource]?.containerRepositories.nodes;
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.pageInfo = data[this.graphqlResource]?.containerRepositories?.pageInfo;
this.containerRepositoriesCount = data[this.graphqlResource]?.containerRepositoriesCount;
},
@@ -321,7 +324,12 @@ export default {
:hide-expiration-policy-data="config.isGroupPage"
>
<template #commands>
- <cli-commands v-if="showCommands" />
+ <cli-commands
+ v-if="showCommands"
+ :docker-build-command="dockerBuildCommand"
+ :docker-push-command="dockerPushCommand"
+ :docker-login-command="dockerLoginCommand"
+ />
</template>
</registry-header>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
index 2a479c65d0c..9bab08b8548 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
@@ -21,13 +21,17 @@ export default {
},
},
computed: {
- showModuleCount() {
- return Number.isInteger(this.count);
+ hasModules() {
+ return Number.isInteger(this.count) && this.count > 0;
},
moduleAmountText() {
return n__(`%d Module`, `%d Modules`, this.count);
},
infoMessages() {
+ if (!this.hasModules) {
+ return [];
+ }
+
return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }];
},
},
@@ -43,11 +47,7 @@ export default {
<template>
<title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages">
<template #metadata-amount>
- <metadata-item
- v-if="showModuleCount"
- icon="infrastructure-registry"
- :text="moduleAmountText"
- />
+ <metadata-item v-if="hasModules" icon="infrastructure-registry" :text="moduleAmountText" />
</template>
</title-area>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
index 462618a7f12..184a24047eb 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
@@ -99,7 +99,7 @@ export default {
<template>
<div>
<infrastructure-title :help-url="packageHelpUrl" :count="packagesCount" />
- <infrastructure-search @update="requestPackagesList" />
+ <infrastructure-search v-if="packagesCount > 0" @update="requestPackagesList" />
<package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<template #empty-state>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
index 1afd1b69db0..57ff3cd2a83 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
@@ -61,11 +61,13 @@ export default {
</template>
<template #right-secondary>
- <gl-sprintf :message="__('Created %{timestamp}')">
- <template #timestamp>
- <time-ago-tooltip :time="packageEntity.createdAt" />
- </template>
- </gl-sprintf>
+ <span>
+ <gl-sprintf :message="__('Created %{timestamp}')">
+ <template #timestamp>
+ <time-ago-tooltip :time="packageEntity.createdAt" />
+ </template>
+ </gl-sprintf>
+ </span>
</template>
</list-item>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
index 3483d23e251..c27083261b5 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
@@ -9,6 +9,8 @@ import {
FILTERED_SEARCH_TERM,
FILTERED_SEARCH_TYPE,
} from '~/packages_and_registries/shared/constants';
+import { LIST_KEY_CREATED_AT } from '~/packages_and_registries/package_registry/constants';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import PackageTypeToken from './tokens/package_type_token.vue';
export default {
@@ -22,13 +24,13 @@ export default {
operators: OPERATOR_IS_ONLY,
},
],
- components: { RegistrySearch, UrlSync },
+ components: { RegistrySearch, UrlSync, LocalStorageSync },
inject: ['isGroupPage'],
data() {
return {
filters: [],
sorting: {
- orderBy: 'name',
+ orderBy: LIST_KEY_CREATED_AT,
sort: 'desc',
},
mountRegistrySearch: false,
@@ -94,19 +96,26 @@ export default {
</script>
<template>
- <url-sync>
- <template #default="{ updateQuery }">
- <registry-search
- v-if="mountRegistrySearch"
- :filter="filters"
- :sorting="sorting"
- :tokens="$options.tokens"
- :sortable-fields="sortableFields"
- @sorting:changed="updateSortingAndEmitUpdate"
- @filter:changed="updateFilters"
- @filter:submit="emitUpdate"
- @query:changed="updateQuery"
- />
- </template>
- </url-sync>
+ <local-storage-sync
+ storage-key="package_registry_list_sorting"
+ :value="sorting"
+ as-json
+ @input="updateSorting"
+ >
+ <url-sync>
+ <template #default="{ updateQuery }">
+ <registry-search
+ v-if="mountRegistrySearch"
+ :filter="filters"
+ :sorting="sorting"
+ :tokens="$options.tokens"
+ :sortable-fields="sortableFields"
+ @sorting:changed="updateSortingAndEmitUpdate"
+ @filter:changed="updateFilters"
+ @filter:submit="emitUpdate"
+ @query:changed="updateQuery"
+ />
+ </template>
+ </url-sync>
+ </local-storage-sync>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json
deleted file mode 100644
index c61a653d10b..00000000000
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "__schema": {
- "types": [
- {
- "kind": "UNION",
- "name": "PackageMetadata",
- "possibleTypes": [
- { "name": "ComposerMetadata" },
- { "name": "ConanMetadata" },
- { "name": "MavenMetadata" },
- { "name": "NugetMetadata" },
- { "name": "PypiMetadata" }
- ]
- }
- ]
- }
-}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
index 21d6fbc9e1f..56f95fa2c1f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
@@ -1,22 +1,9 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import introspectionQueryResultData from './fragmentTypes.json';
-
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
Vue.use(VueApollo);
export const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- cacheConfig: {
- fragmentMatcher,
- },
- },
- ),
+ defaultClient: createDefaultClient(),
});
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue
index 07ee3c6083b..de7ab3e6d7b 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue
@@ -10,7 +10,7 @@ import {
COPY_BUILD_TITLE,
PUSH_COMMAND_LABEL,
COPY_PUSH_TITLE,
-} from '../../constants/index';
+} from '../constants';
const trackingLabel = 'quickstart_dropdown';
@@ -20,7 +20,20 @@ export default {
CodeInstruction,
},
mixins: [Tracking.mixin({ label: trackingLabel })],
- inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
+ props: {
+ dockerBuildCommand: {
+ type: String,
+ required: true,
+ },
+ dockerPushCommand: {
+ type: String,
+ required: true,
+ },
+ dockerLoginCommand: {
+ type: String,
+ required: true,
+ },
+ },
trackingLabel,
i18n: {
QUICK_START,
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/packages_and_registries/shared/components/tags_loader.vue
index b7afa5fba33..b7afa5fba33 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/tags_loader.vue
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/index.js b/app/assets/javascripts/packages_and_registries/shared/constants/index.js
new file mode 100644
index 00000000000..7659781d96e
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/index.js
@@ -0,0 +1,2 @@
+export * from './package_registry';
+export * from './quick_start';
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
index afc72a2c627..afc72a2c627 100644
--- a/app/assets/javascripts/packages_and_registries/shared/constants.js
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/quick_start.js b/app/assets/javascripts/packages_and_registries/shared/constants/quick_start.js
new file mode 100644
index 00000000000..6a39c07eba2
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/quick_start.js
@@ -0,0 +1,9 @@
+import { s__ } from '~/locale';
+
+export const QUICK_START = s__('ContainerRegistry|CLI Commands');
+export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login');
+export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command');
+export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image');
+export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command');
+export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image');
+export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command');
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
index c2510a16d2f..3ef75b3ef0e 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
+++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
@@ -140,8 +140,8 @@ export default {
return {
id: 'signup-settings-modal',
text: n__(
- 'ApplicationSettings|By making this change, you will automatically approve %d user with the pending approval status.',
- 'ApplicationSettings|By making this change, you will automatically approve %d users with the pending approval status.',
+ 'ApplicationSettings|By making this change, you will automatically approve %d user who is pending approval.',
+ 'ApplicationSettings|By making this change, you will automatically approve %d users who are pending approval.',
pendingUserCount,
),
actionPrimary: {
@@ -157,7 +157,7 @@ export default {
actionCancel: {
text: __('Cancel'),
},
- title: s__('ApplicationSettings|Approve users in the pending approval status?'),
+ title: s__('ApplicationSettings|Approve users who are pending approval?'),
};
},
},
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
new file mode 100644
index 00000000000..67eee2c3209
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
@@ -0,0 +1,52 @@
+import createFlash from '~/flash';
+import axios from '../../../lib/utils/axios_utils';
+import { __ } from '../../../locale';
+
+export default class PayloadDownloader {
+ constructor(trigger) {
+ this.trigger = trigger;
+ }
+
+ init() {
+ this.spinner = this.trigger.querySelector('.js-spinner');
+ this.text = this.trigger.querySelector('.js-text');
+
+ this.trigger.addEventListener('click', (event) => {
+ event.preventDefault();
+
+ return this.requestPayload();
+ });
+ }
+
+ requestPayload() {
+ this.spinner.classList.add('d-inline-flex');
+
+ return axios
+ .get(this.trigger.dataset.endpoint, {
+ responseType: 'json',
+ })
+ .then(({ data }) => {
+ PayloadDownloader.downloadFile(data);
+ })
+ .catch(() => {
+ createFlash({
+ message: __('Error fetching payload data.'),
+ });
+ })
+ .finally(() => {
+ this.spinner.classList.remove('d-inline-flex');
+ });
+ }
+
+ static downloadFile(data) {
+ const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
+
+ const link = document.createElement('a');
+ link.href = window.URL.createObjectURL(blob);
+ link.download = `${data.recorded_at.slice(0, 10)} payload.json`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(link.href);
+ }
+}
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
index 08f6633f424..c017cf0afa2 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
@@ -5,7 +5,6 @@ import { __ } from '../../../locale';
export default class PayloadPreviewer {
constructor(trigger) {
this.trigger = trigger;
- this.container = document.querySelector(trigger.dataset.payloadSelector);
this.isVisible = false;
this.isInserted = false;
}
@@ -23,21 +22,27 @@ export default class PayloadPreviewer {
});
}
+ getContainer() {
+ return document.querySelector(this.trigger.dataset.payloadSelector);
+ }
+
requestPayload() {
if (this.isInserted) return this.showPayload();
- this.spinner.classList.add('d-inline-flex');
+ this.spinner.classList.add('gl-display-inline-flex');
+
+ const container = this.getContainer();
return axios
- .get(this.container.dataset.endpoint, {
+ .get(container.dataset.endpoint, {
responseType: 'text',
})
.then(({ data }) => {
- this.spinner.classList.remove('d-inline-flex');
+ this.spinner.classList.remove('gl-display-inline-flex');
this.insertPayload(data);
})
.catch(() => {
- this.spinner.classList.remove('d-inline-flex');
+ this.spinner.classList.remove('gl-display-inline-flex');
createFlash({
message: __('Error fetching payload data.'),
});
@@ -46,19 +51,19 @@ export default class PayloadPreviewer {
hidePayload() {
this.isVisible = false;
- this.container.classList.add('d-none');
+ this.getContainer().classList.add('gl-display-none');
this.text.textContent = __('Preview payload');
}
showPayload() {
this.isVisible = true;
- this.container.classList.remove('d-none');
+ this.getContainer().classList.remove('gl-display-none');
this.text.textContent = __('Hide payload');
}
insertPayload(data) {
this.isInserted = true;
- this.container.innerHTML = data;
+ this.getContainer().innerHTML = data;
this.showPayload();
}
}
diff --git a/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js b/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js
new file mode 100644
index 00000000000..8a12e753847
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js
@@ -0,0 +1,3 @@
+import initServiceUsageData from '~/admin/application_settings/setup_service_usage_data';
+
+initServiceUsageData();
diff --git a/app/assets/javascripts/pages/admin/runners/show/index.js b/app/assets/javascripts/pages/admin/runners/show/index.js
new file mode 100644
index 00000000000..f76f3a2430d
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/runners/show/index.js
@@ -0,0 +1,3 @@
+import { initAdminRunnerShow } from '~/runner/admin_runner_show';
+
+initAdminRunnerShow();
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index f6155b2ab2f..96487e14e30 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -1,8 +1,7 @@
import { GROUP_BADGE } from '~/badges/constants';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import initFilePickers from '~/file_pickers';
-import TransferDropdown from '~/groups/transfer_dropdown';
-import setupTransferEdit from '~/groups/transfer_edit';
+import initTransferGroupForm from '~/groups/init_transfer_group_form';
import groupsSelect from '~/groups_select';
import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
@@ -15,11 +14,11 @@ document.addEventListener('DOMContentLoaded', () => {
initFilePickers();
initConfirmDanger();
initSettingsPanels();
+ initTransferGroupForm();
dirtySubmitFactory(
document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'),
);
mountBadgeSettings(GROUP_BADGE);
- setupTransferEdit('.js-group-transfer-form', '#new_parent_group_id');
// Initialize Subgroups selector
groupsSelect();
@@ -28,6 +27,4 @@ document.addEventListener('DOMContentLoaded', () => {
initSearchSettings();
initCascadingSettingsLockPopovers();
-
- 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 01a371920f8..14ce3f775b1 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -1,6 +1,7 @@
import { groupMemberRequestFormatter } from '~/groups/members/utils';
import groupsSelect from '~/groups_select';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
+import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
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';
@@ -56,6 +57,7 @@ groupsSelect();
memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
initInviteMembersModal();
+initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
diff --git a/app/assets/javascripts/pages/projects/imports/new/index.js b/app/assets/javascripts/pages/projects/imports/new/index.js
new file mode 100644
index 00000000000..4acfc5265ac
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/imports/new/index.js
@@ -0,0 +1,3 @@
+import initProjectNew from '~/projects/project_new';
+
+initProjectNew.bindEvents();
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
index 42c40cda601..adae97c6b6f 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
@@ -2,7 +2,8 @@
import { GlProgressBar, GlSprintf, GlAlert } from '@gitlab/ui';
import eventHub from '~/invite_members/event_hub';
import { s__ } from '~/locale';
-import { ACTION_LABELS, ACTION_SECTIONS } from '../constants';
+import { getCookie, removeCookie, parseBoolean } from '~/lib/utils/common_utils';
+import { ACTION_LABELS, ACTION_SECTIONS, INVITE_MODAL_OPEN_COOKIE } from '../constants';
import LearnGitlabSectionCard from './learn_gitlab_section_card.vue';
export default {
@@ -26,7 +27,7 @@ export default {
required: true,
type: Object,
},
- inviteMembersOpen: {
+ inviteMembers: {
type: Boolean,
required: false,
default: false,
@@ -53,7 +54,7 @@ export default {
},
},
mounted() {
- if (this.inviteMembersOpen) {
+ if (this.inviteMembers && this.getCookieForInviteMembers()) {
this.openInviteMembersModal('celebrate');
}
@@ -63,8 +64,15 @@ export default {
eventHub.$off('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert);
},
methods: {
+ getCookieForInviteMembers() {
+ const value = parseBoolean(getCookie(INVITE_MODAL_OPEN_COOKIE));
+
+ removeCookie(INVITE_MODAL_OPEN_COOKIE);
+
+ return value;
+ },
openInviteMembersModal(mode) {
- eventHub.$emit('openModal', { mode, inviteeType: 'members', source: 'learn-gitlab' });
+ eventHub.$emit('openModal', { mode, source: 'learn-gitlab' });
},
handleShowSuccessfulInvitationsAlert() {
this.showSuccessfulInvitationsAlert = true;
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
index 3a401f5cb31..d0ec02bbd0c 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
@@ -31,14 +31,13 @@ export default {
this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding')
);
},
+ openInNewTab() {
+ return ACTION_LABELS[this.action]?.openInNewTab === true;
+ },
},
methods: {
openModal() {
- eventHub.$emit('openModal', {
- inviteeType: 'members',
- source: 'learn_gitlab',
- tasksToBeDoneEnabled: true,
- });
+ eventHub.$emit('openModal', { source: 'learn_gitlab' });
},
},
};
@@ -61,8 +60,9 @@ export default {
</gl-link>
<gl-link
v-else
- target="_blank"
+ :target="openInNewTab ? '_blank' : '_self'"
:href="value.url"
+ data-testid="uncompleted-learn-gitlab-link"
data-track-action="click_link"
:data-track-label="$options.i18n.ACTION_LABELS[action].title"
data-track-property="Growth::Conversion::Experiment::LearnGitLab"
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 9e204aa6746..880cf699e5e 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
@@ -62,6 +62,7 @@ export const ACTION_LABELS = {
description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
section: 'deploy',
position: 1,
+ openInNewTab: true,
},
issueCreated: {
title: s__('LearnGitLab|Create an issue'),
@@ -94,3 +95,5 @@ export const ACTION_SECTIONS = {
),
},
};
+
+export const INVITE_MODAL_OPEN_COOKIE = 'confetti_post_signup';
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
index 1f91cc46946..c62cab1a425 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import LearnGitlab from '../components/learn_gitlab.vue';
function initLearnGitlab() {
@@ -13,13 +13,13 @@ function initLearnGitlab() {
const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions));
const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections));
const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project));
- const { inviteMembersOpen } = el.dataset;
+ const { inviteMembers } = el.dataset;
return new Vue({
el,
render(createElement) {
return createElement(LearnGitlab, {
- props: { actions, sections, project, inviteMembersOpen },
+ props: { actions, sections, project, inviteMembers: parseBoolean(inviteMembers) },
});
},
});
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index 5d830872ed9..50733d8a145 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -1,4 +1,8 @@
-import { initNewProjectCreation, initNewProjectUrlSelect } from '~/projects/new';
+import {
+ initNewProjectCreation,
+ initNewProjectUrlSelect,
+ initDeploymentTargetSelect,
+} from '~/projects/new';
import initProjectVisibilitySelector from '~/projects/project_visibility';
import initProjectNew from '~/projects/project_new';
@@ -6,3 +10,4 @@ initProjectVisibilitySelector();
initProjectNew.bindEvents();
initNewProjectCreation();
initNewProjectUrlSelect();
+initDeploymentTargetSelect();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
index 42b08bcaa7b..ee70ff858be 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -1,8 +1,8 @@
<script>
import { GlButton } from '@gitlab/ui';
-import Cookies from 'js-cookie';
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
+
import Translate from '../../../../../vue_shared/translate';
Vue.use(Translate);
@@ -17,13 +17,13 @@ export default {
inject: ['docsUrl', 'illustrationUrl'],
data() {
return {
- calloutDismissed: parseBoolean(Cookies.get(cookieKey)),
+ calloutDismissed: parseBoolean(getCookie(cookieKey)),
};
},
methods: {
dismissCallout() {
this.calloutDismissed = true;
- Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 });
+ setCookie(cookieKey, this.calloutDismissed);
},
},
};
diff --git a/app/assets/javascripts/pages/projects/planning_hierarchy/index.js b/app/assets/javascripts/pages/projects/planning_hierarchy/index.js
new file mode 100644
index 00000000000..d5dfe2d5f37
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/planning_hierarchy/index.js
@@ -0,0 +1,3 @@
+import { initWorkItemsHierarchy } from '~/work_items_hierarchy/work_items_hierarchy_bundle';
+
+initWorkItemsHierarchy();
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index a26aeeb6db4..0c17bf2f344 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -1,7 +1,7 @@
/* eslint-disable func-names, no-return-assign */
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import initClonePanel from '~/clone_panel';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import createFlash from '~/flash';
@@ -24,19 +24,19 @@ export default class Project {
}
$('.js-hide-no-ssh-message').on('click', function (e) {
- Cookies.set('hide_no_ssh_message', 'false');
+ setCookie('hide_no_ssh_message', 'false');
$(this).parents('.js-no-ssh-key-message').remove();
return e.preventDefault();
});
$('.js-hide-no-password-message').on('click', function (e) {
- Cookies.set('hide_no_password_message', 'false');
+ setCookie('hide_no_password_message', 'false');
$(this).parents('.js-no-password-message').remove();
return e.preventDefault();
});
$('.hide-auto-devops-implicitly-enabled-banner').on('click', function (e) {
const projectId = $(this).data('project-id');
const cookieKey = `hide_auto_devops_implicitly_enabled_banner_${projectId}`;
- Cookies.set(cookieKey, 'false');
+ setCookie(cookieKey, 'false');
$(this).parents('.auto-devops-implicitly-enabled-banner').remove();
return e.preventDefault();
});
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 947bbdacf2c..26c42247cf7 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -3,6 +3,7 @@ import initImportAProjectModal from '~/invite_members/init_import_a_project_moda
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 initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date';
@@ -17,6 +18,7 @@ memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
initImportAProjectModal();
initInviteMembersModal();
+initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
diff --git a/app/assets/javascripts/pages/projects/security/configuration/index.js b/app/assets/javascripts/pages/projects/security/configuration/index.js
index 5f801501b2f..f13a48c1224 100644
--- a/app/assets/javascripts/pages/projects/security/configuration/index.js
+++ b/app/assets/javascripts/pages/projects/security/configuration/index.js
@@ -1,3 +1,3 @@
import { initSecurityConfiguration } from '~/security_configuration';
-initSecurityConfiguration(document.querySelector('#js-security-configuration-static'));
+initSecurityConfiguration(document.querySelector('#js-security-configuration'));
diff --git a/app/assets/javascripts/pages/projects/serverless/index.js b/app/assets/javascripts/pages/projects/serverless/index.js
index 640301dd478..9ae81b327b1 100644
--- a/app/assets/javascripts/pages/projects/serverless/index.js
+++ b/app/assets/javascripts/pages/projects/serverless/index.js
@@ -1,5 +1,3 @@
import ServerlessBundle from '~/serverless/serverless_bundle';
-import initServerlessSurveyBanner from '~/serverless/survey_banner';
-initServerlessSurveyBanner();
new ServerlessBundle(); // eslint-disable-line no-new
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 d5e00f54e91..184bda4410f 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
@@ -280,7 +280,7 @@ export default {
}
return s__(
- 'ProjectSettings|View and edit files in this project. Non-project members will only have read access.',
+ 'ProjectSettings|View and edit files in this project. Non-project members have only read access.',
);
},
cveIdRequestIsDisabled() {
diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js
index 58ceb524360..5cbb7a06bc1 100644
--- a/app/assets/javascripts/pages/users/index.js
+++ b/app/assets/javascripts/pages/users/index.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import UserCallout from '~/user_callout';
import UserTabs from './user_tabs';
@@ -10,7 +10,7 @@ function initUserProfile(action) {
// hide project limit message
$('.hide-project-limit-message').on('click', (e) => {
e.preventDefault();
- Cookies.set('hide_project_limit_message', 'false');
+ setCookie('hide_project_limit_message', 'false');
$(this).parents('.project-limit-message').remove();
});
}
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 ed30198244f..710f49b833c 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -124,6 +124,9 @@ export default {
const fileName = this.requests[0].truncatedUrl;
return `${fileName}_perf_bar_${Date.now()}.json`;
},
+ memoryReportPath() {
+ return mergeUrlParams({ performance_bar: 'memory' }, window.location.href);
+ },
},
mounted() {
this.currentRequest = this.requestId;
@@ -182,6 +185,15 @@ export default {
s__('PerformanceBar|Download')
}}</a>
</div>
+ <div
+ v-if="currentRequest.details && env === 'development'"
+ id="peek-memory-report"
+ class="view"
+ >
+ <a class="gl-text-blue-200" :href="memoryReportPath">{{
+ s__('PerformanceBar|Memory report')
+ }}</a>
+ </div>
<div v-if="currentRequest.details" id="peek-flamegraph" class="view">
<span class="gl-text-white-200">{{ s__('PerformanceBar|Flamegraph with mode:') }}</span>
<a class="gl-text-blue-200" :href="flamegraphPath('wall')">{{
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 66e999ca43b..eb5b50dd1ec 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -20,6 +20,7 @@ const initPerformanceBar = (el) => {
return new Vue({
el,
+ name: 'PerformanceBarRoot',
components: {
PerformanceBarApp: () => import('./components/performance_bar_app.vue'),
},
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index bc83844b8b9..b003302ec8e 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -7,10 +7,11 @@ const DEFERRED_LINK_CLASS = 'deferred-link';
export default class PersistentUserCallout {
constructor(container, options = container.dataset) {
- const { dismissEndpoint, featureId, deferLinks } = options;
+ const { dismissEndpoint, featureId, groupId, deferLinks } = options;
this.container = container;
this.dismissEndpoint = dismissEndpoint;
this.featureId = featureId;
+ this.groupId = groupId;
this.deferLinks = parseBoolean(deferLinks);
this.init();
@@ -52,6 +53,7 @@ export default class PersistentUserCallout {
axios
.post(this.dismissEndpoint, {
feature_name: this.featureId,
+ group_id: this.groupId,
})
.then(() => {
this.container.remove();
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index a7f8704b559..337c204c36a 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -10,6 +10,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-new-user-signups-cap-reached',
'.js-eoa-bronze-plan-banner',
'.js-security-newsletter-callout',
+ '.js-approaching-seats-count-threshold',
];
const initCallouts = () => {
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 54c9688d88f..8ff1aea020f 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
@@ -1,11 +1,11 @@
<script>
-import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
import {
COMMIT_ACTION_CREATE,
COMMIT_ACTION_UPDATE,
COMMIT_FAILURE,
COMMIT_SUCCESS,
+ COMMIT_SUCCESS_WITH_REDIRECT,
} from '../../constants';
import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql';
import updateCurrentBranchMutation from '../../graphql/mutations/client/update_current_branch.mutation.graphql';
@@ -15,9 +15,6 @@ import getCurrentBranch from '../../graphql/queries/client/current_branch.query.
import CommitForm from './commit_form.vue';
-const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
-const MR_TARGET_BRANCH = 'merge_request[target_branch]';
-
export default {
alertTexts: {
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
@@ -29,7 +26,7 @@ export default {
components: {
CommitForm,
},
- inject: ['projectFullPath', 'ciConfigPath', 'newMergeRequestPath'],
+ inject: ['projectFullPath', 'ciConfigPath'],
props: {
ciFileContent: {
type: String,
@@ -74,16 +71,6 @@ export default {
},
},
methods: {
- redirectToNewMergeRequest(sourceBranch) {
- const url = mergeUrlParams(
- {
- [MR_SOURCE_BRANCH]: sourceBranch,
- [MR_TARGET_BRANCH]: this.currentBranch,
- },
- this.newMergeRequestPath,
- );
- redirectTo(url);
- },
async onCommitSubmit({ message, targetBranch, openMergeRequest }) {
this.isSaving = true;
@@ -112,12 +99,25 @@ export default {
if (errors?.length) {
this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors });
- } else if (openMergeRequest) {
- this.redirectToNewMergeRequest(targetBranch);
} else {
- this.$emit('commit', { type: COMMIT_SUCCESS });
+ const commitBranch = targetBranch;
+ const params = openMergeRequest
+ ? {
+ type: COMMIT_SUCCESS_WITH_REDIRECT,
+ params: {
+ sourceBranch: commitBranch,
+ targetBranch: this.currentBranch,
+ },
+ }
+ : { type: COMMIT_SUCCESS };
+
+ this.$emit('commit', {
+ ...params,
+ });
+
this.updateLastCommitBranch(targetBranch);
this.updateCurrentBranch(targetBranch);
+
if (this.currentBranch === targetBranch) {
this.$emit('updateCommitSha');
}
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
index bfbf24c6b13..5177cea900c 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
@@ -5,6 +5,11 @@ import SourceEditor from '~/vue_shared/components/source_editor.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
+ editorOptions: {
+ // Quick suggestions is so that monaco can provide
+ // autocomplete for keywords
+ quickSuggestions: true,
+ },
components: {
SourceEditor,
},
@@ -29,6 +34,7 @@ export default {
<div class="gl-border-solid gl-border-gray-100 gl-border-1 gl-border-t-none!">
<source-editor
ref="editor"
+ :editor-options="$options.editorOptions"
:file-name="ciConfigPath"
v-bind="$attrs"
@[$options.readyEvent]="registerCiSchema($event)"
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
index 4f79a81d539..ead2076ec3b 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -9,7 +9,6 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { produce } from 'immer';
-import { fetchPolicies } from '~/lib/graphql';
import { historyPushState } from '~/lib/utils/common_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -63,8 +62,6 @@ export default {
return {
availableBranches: [],
branchSelected: null,
- filteredBranches: [],
- isSearchingBranches: false,
pageLimit: this.paginationLimit,
pageCounter: 0,
searchTerm: '',
@@ -76,10 +73,9 @@ export default {
query: getAvailableBranchesQuery,
variables() {
return {
- limit: this.paginationLimit,
offset: 0,
projectFullPath: this.projectFullPath,
- searchPattern: '*',
+ ...this.availableBranchesVariables,
};
},
update(data) {
@@ -116,14 +112,24 @@ export default {
},
},
computed: {
- branches() {
- return this.searchTerm.length > 0 ? this.filteredBranches : this.availableBranches;
+ availableBranchesVariables() {
+ if (this.searchTerm.length > 0) {
+ return {
+ limit: this.totalBranches,
+ searchPattern: `*${this.searchTerm}*`,
+ };
+ }
+
+ return {
+ limit: this.paginationLimit,
+ searchPattern: '*',
+ };
},
enableBranchSwitcher() {
- return this.branches.length > 0 || this.searchTerm.length > 0;
+ return this.availableBranches.length > 0 || this.searchTerm.length > 0;
},
isBranchesLoading() {
- return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches;
+ return this.$apollo.queries.availableBranches.loading;
},
},
watch: {
@@ -134,38 +140,21 @@ export default {
},
},
methods: {
- availableBranchesQueryVars(varsOverride = {}) {
- if (this.searchTerm.length > 0) {
- return {
- limit: this.totalBranches,
- offset: 0,
- projectFullPath: this.projectFullPath,
- searchPattern: `*${this.searchTerm}*`,
- ...varsOverride,
- };
- }
-
- return {
- limit: this.paginationLimit,
- offset: this.pageCounter * this.paginationLimit,
- projectFullPath: this.projectFullPath,
- searchPattern: '*',
- ...varsOverride,
- };
- },
// if there is no searchPattern, paginate by {paginationLimit} branches
fetchNextBranches() {
if (
this.isBranchesLoading ||
this.searchTerm.length > 0 ||
- this.branches.length >= this.totalBranches
+ this.availableBranches.length >= this.totalBranches
) {
return;
}
this.$apollo.queries.availableBranches
.fetchMore({
- variables: this.availableBranchesQueryVars(),
+ variables: {
+ offset: this.pageCounter * this.paginationLimit,
+ },
updateQuery(previousResult, { fetchMoreResult }) {
const previousBranches = previousResult.project.repository.branchNames;
const newBranches = fetchMoreResult.project.repository.branchNames;
@@ -204,23 +193,6 @@ export default {
async setSearchTerm(newSearchTerm) {
this.pageCounter = 0;
this.searchTerm = newSearchTerm.trim();
-
- if (this.searchTerm === '') {
- this.pageLimit = this.paginationLimit;
- return;
- }
-
- this.isSearchingBranches = true;
- const fetchResults = await this.$apollo
- .query({
- query: getAvailableBranchesQuery,
- fetchPolicy: fetchPolicies.NETWORK_ONLY,
- variables: this.availableBranchesQueryVars(),
- })
- .catch(this.showFetchError);
-
- this.isSearchingBranches = false;
- this.filteredBranches = fetchResults?.data?.project?.repository?.branchNames || [];
},
showFetchError() {
this.$emit('showError', {
@@ -255,14 +227,14 @@ export default {
</gl-dropdown-section-header>
<gl-infinite-scroll
- :fetched-items="branches.length"
+ :fetched-items="availableBranches.length"
:max-list-height="250"
data-qa-selector="branch_menu_container"
@bottomReached="fetchNextBranches"
>
<template #items>
<gl-dropdown-item
- v-for="branch in branches"
+ v-for="branch in availableBranches"
:key="branch"
:is-checked="currentBranch === branch"
:is-check-item="true"
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index 72b492a5877..4b9c98135ec 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -49,7 +49,7 @@ export default {
pipelineEtag: {
query: getPipelineEtag,
update(data) {
- return data.etags.pipeline;
+ return data.etags?.pipeline;
},
},
pipeline: {
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue
index 7206f19d060..c72cff4c6f8 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue
@@ -5,6 +5,7 @@ import { __, s__ } from '~/locale';
import {
COMMIT_FAILURE,
COMMIT_SUCCESS,
+ COMMIT_SUCCESS_WITH_REDIRECT,
DEFAULT_FAILURE,
DEFAULT_SUCCESS,
LOAD_FAILURE_UNKNOWN,
@@ -21,14 +22,18 @@ export default {
GlAlert,
CodeSnippetAlert,
},
- errorTexts: {
+
+ errors: {
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
[DEFAULT_FAILURE]: __('Something went wrong on our end.'),
[LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
[PIPELINE_FAILURE]: s__('Pipelines|There was a problem with loading the pipeline data.'),
},
- successTexts: {
+ success: {
[COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
+ [COMMIT_SUCCESS_WITH_REDIRECT]: s__(
+ 'Pipelines|Your changes have been successfully committed. Now redirecting to the new merge request page.',
+ ),
[DEFAULT_SUCCESS]: __('Your action succeeded.'),
},
props: {
@@ -65,42 +70,20 @@ export default {
},
computed: {
failure() {
- switch (this.failureType) {
- case LOAD_FAILURE_UNKNOWN:
- return {
- text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN],
- variant: 'danger',
- };
- case COMMIT_FAILURE:
- return {
- text: this.$options.errorTexts[COMMIT_FAILURE],
- variant: 'danger',
- };
- case PIPELINE_FAILURE:
- return {
- text: this.$options.errorTexts[PIPELINE_FAILURE],
- variant: 'danger',
- };
- default:
- return {
- text: this.$options.errorTexts[DEFAULT_FAILURE],
- variant: 'danger',
- };
- }
+ const { errors } = this.$options;
+
+ return {
+ text: errors[this.failureType] ?? errors[DEFAULT_FAILURE],
+ variant: 'danger',
+ };
},
success() {
- switch (this.successType) {
- case COMMIT_SUCCESS:
- return {
- text: this.$options.successTexts[COMMIT_SUCCESS],
- variant: 'info',
- };
- default:
- return {
- text: this.$options.successTexts[DEFAULT_SUCCESS],
- variant: 'info',
- };
- }
+ const { success } = this.$options;
+
+ return {
+ text: success[this.successType] ?? success[DEFAULT_SUCCESS],
+ variant: 'info',
+ };
},
},
created() {
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index bc79b0742e7..a65463d02aa 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -20,6 +20,7 @@ export const EDITOR_APP_VALID_STATUSES = [
export const COMMIT_FAILURE = 'COMMIT_FAILURE';
export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
+export const COMMIT_SUCCESS_WITH_REDIRECT = 'COMMIT_SUCCESS_WITH_REDIRECT';
export const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
export const DEFAULT_SUCCESS = 'DEFAULT_SUCCESS';
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index 90f48195c5e..1da50c55a68 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
-import { queryToObject } from '~/lib/utils/url_utility';
+import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
@@ -11,6 +11,7 @@ import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_stat
import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue';
import {
COMMIT_SHA_POLL_INTERVAL,
+ COMMIT_SUCCESS_WITH_REDIRECT,
EDITOR_APP_STATUS_EMPTY,
EDITOR_APP_STATUS_LOADING,
EDITOR_APP_STATUS_LINT_UNAVAILABLE,
@@ -27,6 +28,9 @@ import getTemplate from './graphql/queries/get_starter_template.query.graphql';
import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql';
import PipelineEditorHome from './pipeline_editor_home.vue';
+const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
+const MR_TARGET_BRANCH = 'merge_request[target_branch]';
+
export default {
components: {
ConfirmUnsavedChangesDialog,
@@ -36,14 +40,7 @@ export default {
PipelineEditorHome,
PipelineEditorMessages,
},
- inject: {
- ciConfigPath: {
- default: '',
- },
- projectFullPath: {
- default: '',
- },
- },
+ inject: ['ciConfigPath', 'newMergeRequestPath', 'projectFullPath'],
data() {
return {
ciConfigData: {},
@@ -57,7 +54,7 @@ export default {
lastCommittedContent: '',
shouldSkipStartScreen: false,
showFailure: false,
- showResetComfirmationModal: false,
+ showResetConfirmationModal: false,
showStartScreen: false,
showSuccess: false,
starterTemplate: '',
@@ -199,7 +196,7 @@ export default {
currentBranch: {
query: getCurrentBranch,
update(data) {
- return data.workBranches.current.name;
+ return data.workBranches?.current?.name;
},
},
starterTemplate: {
@@ -217,7 +214,7 @@ export default {
return data.project?.ciTemplate?.content || '';
},
result({ data }) {
- this.updateCiConfig(data.project?.ciTemplate?.content || '');
+ this.updateCiConfig(data?.project?.ciTemplate?.content || '');
},
error() {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
@@ -271,17 +268,39 @@ export default {
this.checkShouldSkipStartScreen();
},
methods: {
+ checkShouldSkipStartScreen() {
+ const params = queryToObject(window.location.search);
+ this.shouldSkipStartScreen = Boolean(params?.add_new_config_file);
+ },
+ confirmReset() {
+ if (this.hasUnsavedChanges) {
+ this.showResetConfirmationModal = true;
+ }
+ },
hideFailure() {
this.showFailure = false;
},
hideSuccess() {
this.showSuccess = false;
},
- confirmReset() {
- if (this.hasUnsavedChanges) {
- this.showResetComfirmationModal = true;
+ loadTemplateFromURL() {
+ const templateName = queryToObject(window.location.search)?.template;
+
+ if (templateName) {
+ this.starterTemplateName = templateName;
+ this.setNewEmptyCiConfigFile();
}
},
+ redirectToNewMergeRequest(sourceBranch, targetBranch) {
+ const url = mergeUrlParams(
+ {
+ [MR_SOURCE_BRANCH]: sourceBranch,
+ [MR_TARGET_BRANCH]: targetBranch,
+ },
+ this.newMergeRequestPath,
+ );
+ redirectTo(url);
+ },
async refetchContent() {
this.$apollo.queries.initialCiFileContent.skip = false;
await this.$apollo.queries.initialCiFileContent.refetch();
@@ -298,7 +317,7 @@ export default {
this.successType = type;
},
resetContent() {
- this.showResetComfirmationModal = false;
+ this.showResetConfirmationModal = false;
this.currentCiFileContent = this.lastCommittedContent;
},
setAppStatus(appStatus) {
@@ -323,7 +342,7 @@ export default {
this.isFetchingCommitSha = true;
this.$apollo.queries.commitSha.refetch();
},
- updateOnCommit({ type }) {
+ async updateOnCommit({ type, params = {} }) {
this.reportSuccess(type);
if (this.isNewCiConfigFile) {
@@ -333,19 +352,17 @@ export default {
// Keep track of the latest committed content to know
// if the user has made changes to the file that are unsaved.
this.lastCommittedContent = this.currentCiFileContent;
- },
- loadTemplateFromURL() {
- const templateName = queryToObject(window.location.search)?.template;
- if (templateName) {
- this.starterTemplateName = templateName;
- this.setNewEmptyCiConfigFile();
+ if (type === COMMIT_SUCCESS_WITH_REDIRECT) {
+ const { sourceBranch, targetBranch } = params;
+ // This force update does 2 things for us:
+ // 1. It make sure `hasUnsavedChanges` is updated so
+ // we don't show a modal when the user creates an MR
+ // 2. Ensure the commit success banner is visible.
+ await this.$forceUpdate();
+ this.redirectToNewMergeRequest(sourceBranch, targetBranch);
}
},
- checkShouldSkipStartScreen() {
- const params = queryToObject(window.location.search);
- this.shouldSkipStartScreen = Boolean(params?.add_new_config_file);
- },
},
};
</script>
@@ -358,7 +375,7 @@ export default {
@createEmptyConfigFile="setNewEmptyCiConfigFile"
@refetchContent="refetchContent"
/>
- <div v-else>
+ <div v-else class="gl-pr-10">
<pipeline-editor-messages
:failure-type="failureType"
:failure-reasons="failureReasons"
@@ -382,7 +399,7 @@ export default {
@updateCommitSha="updateCommitSha"
/>
<gl-modal
- v-model="showResetComfirmationModal"
+ v-model="showResetConfirmationModal"
modal-id="reset-content"
:title="$options.i18n.resetModal.title"
:action-cancel="$options.i18n.resetModal.actionCancel"
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index 96680080f0c..bb759477e1e 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -90,7 +90,7 @@ export default {
</script>
<template>
- <div class="gl-pr-10 gl-transition-medium gl-w-full">
+ <div class="gl-transition-medium gl-w-full">
<gl-modal
v-if="showSwitchBranchModal"
visible
diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js
index a6c9f3cb746..43f7634083b 100644
--- a/app/assets/javascripts/pipeline_new/constants.js
+++ b/app/assets/javascripts/pipeline_new/constants.js
@@ -1,3 +1,4 @@
+import { __ } from '~/locale';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
export const VARIABLE_TYPE = 'env_var';
@@ -7,5 +8,7 @@ export const CONFIG_VARIABLES_TIMEOUT = 5000;
export const BRANCH_REF_TYPE = 'branch';
export const TAG_REF_TYPE = 'tag';
-export const CC_VALIDATION_REQUIRED_ERROR =
- 'Credit card required to be on file in order to create a pipeline';
+// must match pipeline/chain/validate/after_config.rb
+export const CC_VALIDATION_REQUIRED_ERROR = __(
+ 'Credit card required to be on file in order to create a pipeline',
+);
diff --git a/app/assets/javascripts/pipeline_wizard/components/commit.vue b/app/assets/javascripts/pipeline_wizard/components/commit.vue
new file mode 100644
index 00000000000..518b41c66b1
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/commit.vue
@@ -0,0 +1,224 @@
+<script>
+import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormTextarea } from '@gitlab/ui';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { __, s__, sprintf } from '~/locale';
+import createCommitMutation from '../queries/create_commit.graphql';
+import getFileMetaDataQuery from '../queries/get_file_meta.graphql';
+import StepNav from './step_nav.vue';
+
+export const i18n = {
+ updateFileHeading: s__('PipelineWizard|Commit changes to your file'),
+ createFileHeading: s__('PipelineWizard|Commit your new file'),
+ fieldRequiredFeedback: __('This field is required'),
+ commitMessageLabel: s__('PipelineWizard|Commit Message'),
+ branchSelectorLabel: s__('PipelineWizard|Commit file to Branch'),
+ defaultUpdateCommitMessage: s__('PipelineWizardDefaultCommitMessage|Update %{filename}'),
+ defaultCreateCommitMessage: s__('PipelineWizardDefaultCommitMessage|Add %{filename}'),
+ commitButtonLabel: s__('PipelineWizard|Commit'),
+ commitSuccessMessage: s__('PipelineWizard|The file has been committed.'),
+ errors: {
+ loadError: s__(
+ 'PipelineWizard|There was a problem while checking whether your file already exists in the specified branch.',
+ ),
+ commitError: s__('PipelineWizard|There was a problem committing the changes.'),
+ },
+};
+
+const COMMIT_ACTION = {
+ CREATE: 'CREATE',
+ UPDATE: 'UPDATE',
+};
+
+export default {
+ i18n,
+ name: 'PipelineWizardCommitStep',
+ components: {
+ RefSelector,
+ GlAlert,
+ GlButton,
+ GlForm,
+ GlFormGroup,
+ GlFormTextarea,
+ StepNav,
+ },
+ props: {
+ prev: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ fileContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ filename: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ branch: this.defaultBranch,
+ loading: false,
+ loadError: null,
+ commitError: null,
+ message: null,
+ };
+ },
+ computed: {
+ fileExistsInRepo() {
+ return this.project?.repository?.blobs.nodes.length > 0;
+ },
+ commitAction() {
+ return this.fileExistsInRepo ? COMMIT_ACTION.UPDATE : COMMIT_ACTION.CREATE;
+ },
+ defaultMessage() {
+ return sprintf(
+ this.fileExistsInRepo
+ ? this.$options.i18n.defaultUpdateCommitMessage
+ : this.$options.i18n.defaultCreateCommitMessage,
+ { filename: this.filename },
+ );
+ },
+ isCommitButtonEnabled() {
+ return this.fileExistsCheckInProgress;
+ },
+ fileExistsCheckInProgress() {
+ return this.$apollo.queries.project.loading;
+ },
+ mutationPayload() {
+ return {
+ mutation: createCommitMutation,
+ variables: {
+ input: {
+ projectPath: this.projectPath,
+ branch: this.branch,
+ message: this.message || this.defaultMessage,
+ actions: [
+ {
+ action: this.commitAction,
+ filePath: `/${this.filename}`,
+ content: this.fileContent,
+ },
+ ],
+ },
+ },
+ };
+ },
+ },
+ apollo: {
+ project: {
+ query: getFileMetaDataQuery,
+ variables() {
+ this.loadError = null;
+ return {
+ fullPath: this.projectPath,
+ filePath: this.filename,
+ ref: this.branch,
+ };
+ },
+ error() {
+ this.loadError = this.$options.i18n.errors.loadError;
+ },
+ },
+ },
+ methods: {
+ async commit() {
+ this.loading = true;
+ try {
+ const { data } = await this.$apollo.mutate(this.mutationPayload);
+ const hasError = Boolean(data.commitCreate.errors?.length);
+ if (hasError) {
+ this.commitError = this.$options.i18n.errors.commitError;
+ } else {
+ this.handleCommitSuccess();
+ }
+ } catch (e) {
+ this.commitError = this.$options.i18n.errors.commitError;
+ } finally {
+ this.loading = false;
+ }
+ },
+ handleCommitSuccess() {
+ this.$toast.show(this.$options.i18n.commitSuccessMessage);
+ this.$emit('done');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h4 v-if="fileExistsInRepo" key="create-heading">
+ {{ $options.i18n.updateFileHeading }}
+ </h4>
+ <h4 v-else key="update-heading">
+ {{ $options.i18n.createFileHeading }}
+ </h4>
+ <gl-alert
+ v-if="!!loadError"
+ :dismissible="false"
+ class="gl-mb-5"
+ data-testid="load-error"
+ variant="danger"
+ >
+ {{ loadError }}
+ </gl-alert>
+ <gl-form class="gl-max-w-48">
+ <gl-form-group
+ :invalid-feedback="$options.i18n.fieldRequiredFeedback"
+ :label="$options.i18n.commitMessageLabel"
+ data-testid="commit_message_group"
+ label-for="commit_message"
+ >
+ <gl-form-textarea
+ id="commit_message"
+ v-model="message"
+ :placeholder="defaultMessage"
+ data-testid="commit_message"
+ size="md"
+ @input="(v) => $emit('update:message', v)"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :invalid-feedback="$options.i18n.fieldRequiredFeedback"
+ :label="$options.i18n.branchSelectorLabel"
+ data-testid="branch_selector_group"
+ label-for="branch"
+ >
+ <ref-selector id="branch" v-model="branch" data-testid="branch" :project-id="projectPath" />
+ </gl-form-group>
+ <gl-alert
+ v-if="!!commitError"
+ :dismissible="false"
+ class="gl-mb-5"
+ data-testid="commit-error"
+ variant="danger"
+ >
+ {{ commitError }}
+ </gl-alert>
+ <step-nav show-back-button v-bind="$props" @back="$emit('go-back')">
+ <template #after>
+ <gl-button
+ :disabled="isCommitButtonEnabled"
+ :loading="fileExistsCheckInProgress || loading"
+ category="primary"
+ variant="confirm"
+ @click="commit"
+ >
+ {{ $options.i18n.commitButtonLabel }}
+ </gl-button>
+ </template>
+ </step-nav>
+ </gl-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/editor.vue b/app/assets/javascripts/pipeline_wizard/components/editor.vue
new file mode 100644
index 00000000000..41611233f71
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/editor.vue
@@ -0,0 +1,94 @@
+<script>
+import { debounce } from 'lodash';
+import { isDocument } from 'yaml';
+import { CONTENT_UPDATE_DEBOUNCE } from '~/editor/constants';
+import SourceEditor from '~/editor/source_editor';
+import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext';
+import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
+
+export default {
+ name: 'YamlEditor',
+ props: {
+ doc: {
+ type: Object,
+ required: true,
+ validator: (d) => isDocument(d),
+ },
+ highlight: {
+ type: [String, Array],
+ required: false,
+ default: null,
+ },
+ filename: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ editor: null,
+ isUpdating: false,
+ yamlEditorExtension: null,
+ };
+ },
+ watch: {
+ doc: {
+ handler() {
+ this.updateEditorContent();
+ },
+ deep: true,
+ },
+ highlight(v) {
+ this.requestHighlight(v);
+ },
+ },
+ mounted() {
+ this.editor = new SourceEditor().createInstance({
+ el: this.$el,
+ blobPath: this.filename,
+ language: 'yaml',
+ });
+ [, this.yamlEditorExtension] = this.editor.use([
+ { definition: SourceEditorExtension },
+ {
+ definition: YamlEditorExtension,
+ setupOptions: {
+ highlightPath: this.highlight,
+ },
+ },
+ ]);
+ this.editor.onDidChangeModelContent(
+ debounce(() => this.handleChange(), CONTENT_UPDATE_DEBOUNCE),
+ );
+ this.updateEditorContent();
+ this.emitValue();
+ },
+ methods: {
+ async updateEditorContent() {
+ this.isUpdating = true;
+ this.editor.setDoc(this.doc);
+ this.isUpdating = false;
+ this.requestHighlight(this.highlight);
+ },
+ handleChange() {
+ this.emitValue();
+ if (!this.isUpdating) {
+ this.handleTouch();
+ }
+ },
+ emitValue() {
+ this.$emit('update:yaml', this.editor.getValue());
+ },
+ handleTouch() {
+ this.$emit('touch');
+ },
+ requestHighlight(path) {
+ this.editor.highlight(path, true);
+ },
+ },
+};
+</script>
+
+<template>
+ <div id="source-editor-yaml-editor"></div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/step_nav.vue b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue
new file mode 100644
index 00000000000..8f9198855c6
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+
+export default {
+ name: 'StepNav',
+ components: {
+ GlButton,
+ },
+ props: {
+ showBackButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showNextButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ nextButtonEnabled: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <slot name="before"></slot>
+ <gl-button
+ v-if="showBackButton"
+ category="secondary"
+ data-testid="back-button"
+ @click="$emit('back')"
+ >
+ {{ __('Back') }}
+ </gl-button>
+ <gl-button
+ v-if="showNextButton"
+ :disabled="!nextButtonEnabled"
+ category="primary"
+ data-testid="next-button"
+ variant="confirm"
+ @click="$emit('next')"
+ >
+ {{ __('Next') }}
+ </gl-button>
+ <slot name="after"></slot>
+ </div>
+</template>
+
+<style scoped></style>
diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue
new file mode 100644
index 00000000000..26235b20ce9
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue
@@ -0,0 +1,126 @@
+<script>
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { s__ } from '~/locale';
+
+const VALIDATION_STATE = {
+ NO_VALIDATION: null,
+ INVALID: false,
+ VALID: true,
+};
+
+export default {
+ name: 'TextWidget',
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ description: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ invalidFeedback: {
+ type: String,
+ required: false,
+ default: s__('PipelineWizardInputValidation|This value is not valid'),
+ },
+ id: {
+ type: String,
+ required: false,
+ default: () => uniqueId('textWidget-'),
+ },
+ pattern: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ validate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ required: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ default: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ touched: false,
+ value: this.default,
+ };
+ },
+ computed: {
+ validationState() {
+ if (!this.showValidationState) return VALIDATION_STATE.NO_VALIDATION;
+ if (this.isRequiredButEmpty) return VALIDATION_STATE.INVALID;
+ return this.needsValidationAndPasses ? VALIDATION_STATE.VALID : VALIDATION_STATE.INVALID;
+ },
+ showValidationState() {
+ return this.touched || this.validate;
+ },
+ isRequiredButEmpty() {
+ return this.required && !this.value;
+ },
+ needsValidationAndPasses() {
+ return !this.pattern || new RegExp(this.pattern).test(this.value);
+ },
+ invalidFeedbackMessage() {
+ return this.isRequiredButEmpty
+ ? s__('PipelineWizardInputValidation|This field is required')
+ : this.invalidFeedback;
+ },
+ },
+ watch: {
+ validationState(v) {
+ this.$emit('update:valid', v);
+ },
+ value(v) {
+ this.$emit('input', v.trim());
+ },
+ },
+ created() {
+ if (this.default) {
+ this.$emit('input', this.value);
+ }
+ },
+};
+</script>
+
+<template>
+ <div data-testid="text-widget">
+ <gl-form-group
+ :description="description"
+ :invalid-feedback="invalidFeedbackMessage"
+ :label="label"
+ :label-for="id"
+ :state="validationState"
+ >
+ <gl-form-input
+ :id="id"
+ v-model="value"
+ :placeholder="placeholder"
+ :state="validationState"
+ type="text"
+ @blur="touched = true"
+ />
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/queries/create_commit.graphql b/app/assets/javascripts/pipeline_wizard/queries/create_commit.graphql
new file mode 100644
index 00000000000..9abf8eff587
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/queries/create_commit.graphql
@@ -0,0 +1,9 @@
+mutation CreateCommit($input: CommitCreateInput!) {
+ commitCreate(input: $input) {
+ commit {
+ id
+ }
+ content
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pipeline_wizard/queries/get_file_meta.graphql b/app/assets/javascripts/pipeline_wizard/queries/get_file_meta.graphql
new file mode 100644
index 00000000000..87f014fade6
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/queries/get_file_meta.graphql
@@ -0,0 +1,12 @@
+query GetFileMetadata($fullPath: ID!, $filePath: String!, $ref: String) {
+ project(fullPath: $fullPath) {
+ id
+ repository {
+ blobs(paths: [$filePath], ref: $ref) {
+ nodes {
+ id
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index 12c3f9a7f40..795ba91a164 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -59,7 +59,11 @@ export default {
</script>
<template>
<!-- eslint-disable @gitlab/vue-no-data-toggle -->
- <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright">
+ <div
+ :id="computedJobId"
+ class="ci-job-dropdown-container dropdown dropright"
+ data-qa-selector="job_dropdown_container"
+ >
<button
type="button"
data-toggle="dropdown"
@@ -79,7 +83,10 @@ export default {
</div>
</button>
- <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown">
+ <ul
+ class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown"
+ data-qa-selector="jobs_dropdown_menu"
+ >
<li class="scrollable-menu">
<ul>
<li v-for="job in group.jobs" :key="job.id">
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index ee58dcc4882..795b95421c7 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-import { sprintf } from '~/locale';
+import { sprintf, __ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { reportToSentry } from '../../utils';
import ActionComponent from '../jobs_shared/action_component.vue';
@@ -160,6 +160,21 @@ export default {
hasAction() {
return this.job.status && this.job.status.action && this.job.status.action.path;
},
+ hasUnauthorizedManualAction() {
+ return (
+ !this.hasAction &&
+ this.job.status?.group === 'manual' &&
+ this.job.status?.label?.includes('(not allowed)')
+ );
+ },
+ unauthorizedManualActionIcon() {
+ /*
+ The action object is not available when the user cannot run the action.
+ So we can show the correct icon, extract the action name from the label instead:
+ "manual play action (not allowed)" or "manual stop action (not allowed)"
+ */
+ return this.job.status?.label?.split(' ')[1];
+ },
relatedDownstreamHovered() {
return this.job.name === this.sourceJobHovered;
},
@@ -198,6 +213,9 @@ export default {
this.$emit('pipelineActionRequestComplete');
},
},
+ i18n: {
+ unauthorizedTooltip: __('You are not authorized to run this manual job'),
+ },
};
</script>
<template>
@@ -242,8 +260,16 @@ export default {
:link="status.action.path"
:action-icon="status.action.icon"
class="gl-mr-1"
- data-qa-selector="action_button"
+ data-qa-selector="job_action_button"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
+ <action-component
+ v-if="hasUnauthorizedManualAction"
+ disabled
+ :tooltip-text="$options.i18n.unauthorizedTooltip"
+ :action-icon="unauthorizedManualActionIcon"
+ :link="`unauthorized-${computedJobId}`"
+ class="gl-mr-1"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index e0c1dcc5be5..c59f56fc68f 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui';
+import { GlBadge, GlButton, GlLink, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
@@ -12,10 +12,10 @@ export default {
},
components: {
CiStatus,
+ GlBadge,
GlButton,
GlLink,
GlLoadingIcon,
- GlBadge,
},
props: {
columnTitle: {
@@ -26,6 +26,10 @@ export default {
type: Boolean,
required: true,
},
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
pipeline: {
type: Object,
required: true,
@@ -34,33 +38,40 @@ export default {
type: String,
required: true,
},
- isLoading: {
- type: Boolean,
- required: true,
- },
},
computed: {
- tooltipText() {
- return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} -
- ${this.sourceJobInfo}`;
+ buttonBorderClass() {
+ return this.isUpstream ? 'gl-border-r-1!' : 'gl-border-l-1!';
},
buttonId() {
return `js-linked-pipeline-${this.pipeline.id}`;
},
- pipelineStatus() {
- return this.pipeline.status;
+ cardSpacingClass() {
+ return this.isDownstream ? 'gl-pr-0' : '';
},
- projectName() {
- return this.pipeline.project.name;
+ expandedIcon() {
+ if (this.isUpstream) {
+ return this.expanded ? 'angle-right' : 'angle-left';
+ }
+ return this.expanded ? 'angle-left' : 'angle-right';
+ },
+ childPipeline() {
+ return this.isDownstream && this.isSameProject;
},
downstreamTitle() {
return this.childPipeline ? this.sourceJobName : this.pipeline.project.name;
},
- parentPipeline() {
- return this.isUpstream && this.isSameProject;
+ flexDirection() {
+ return this.isUpstream ? 'gl-flex-direction-row-reverse' : 'gl-flex-direction-row';
},
- childPipeline() {
- return this.isDownstream && this.isSameProject;
+ isDownstream() {
+ return this.type === DOWNSTREAM;
+ },
+ isSameProject() {
+ return !this.pipeline.multiproject;
+ },
+ isUpstream() {
+ return this.type === UPSTREAM;
},
label() {
if (this.parentPipeline) {
@@ -70,17 +81,17 @@ export default {
}
return __('Multi-project');
},
+ parentPipeline() {
+ return this.isUpstream && this.isSameProject;
+ },
pipelineIsLoading() {
return Boolean(this.isLoading || this.pipeline.isLoading);
},
- isDownstream() {
- return this.type === DOWNSTREAM;
- },
- isUpstream() {
- return this.type === UPSTREAM;
+ pipelineStatus() {
+ return this.pipeline.status;
},
- isSameProject() {
- return !this.pipeline.multiproject;
+ projectName() {
+ return this.pipeline.project.name;
},
sourceJobName() {
return this.pipeline.sourceJob?.name ?? '';
@@ -88,28 +99,23 @@ export default {
sourceJobInfo() {
return this.isDownstream ? sprintf(__('Created by %{job}'), { job: this.sourceJobName }) : '';
},
- expandedIcon() {
- if (this.isUpstream) {
- return this.expanded ? 'angle-right' : 'angle-left';
- }
- return this.expanded ? 'angle-left' : 'angle-right';
- },
- expandButtonPosition() {
- return this.isUpstream ? 'gl-left-0 gl-border-r-1!' : 'gl-right-0 gl-border-l-1!';
+ tooltipText() {
+ return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} -
+ ${this.sourceJobInfo}`;
},
},
errorCaptured(err, _vm, info) {
reportToSentry('linked_pipeline', `error: ${err}, info: ${info}`);
},
methods: {
+ hideTooltips() {
+ this.$root.$emit(BV_HIDE_TOOLTIP);
+ },
onClickLinkedPipeline() {
this.hideTooltips();
this.$emit('pipelineClicked', this.$refs.linkedPipeline);
this.$emit('pipelineExpandToggle', this.sourceJobName, !this.expanded);
},
- hideTooltips() {
- this.$root.$emit(BV_HIDE_TOOLTIP);
- },
onDownstreamHovered() {
this.$emit('downstreamHovered', this.sourceJobName);
},
@@ -124,27 +130,23 @@ export default {
<div
ref="linkedPipeline"
v-gl-tooltip
- class="gl-downstream-pipeline-job-width"
+ class="gl-h-full gl-display-flex! gl-border-solid gl-border-gray-100 gl-border-1"
+ :class="flexDirection"
:title="tooltipText"
data-qa-selector="child_pipeline"
@mouseover="onDownstreamHovered"
@mouseleave="onDownstreamHoverLeave"
>
- <div
- class="gl-relative gl-bg-white gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1"
- :class="{ 'gl-pl-9': isUpstream }"
- >
- <div class="gl-display-flex gl-pr-7 gl-pipeline-job-width">
+ <div class="gl-w-full gl-bg-white gl-p-3" :class="cardSpacingClass">
+ <div class="gl-display-flex gl-pr-3">
<ci-status
v-if="!pipelineIsLoading"
:status="pipelineStatus"
:size="24"
css-classes="gl-top-0 gl-pr-2"
/>
- <div v-else class="gl-pr-2"><gl-loading-icon size="sm" inline /></div>
- <div
- class="gl-display-flex gl-flex-direction-column gl-pipeline-job-width gl-text-truncate"
- >
+ <div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div>
+ <div class="gl-display-flex gl-flex-direction-column gl-downstream-pipeline-job-width">
<span class="gl-text-truncate" data-testid="downstream-title">
{{ downstreamTitle }}
</span>
@@ -160,10 +162,12 @@ export default {
{{ label }}
</gl-badge>
</div>
+ </div>
+ <div class="gl-display-flex">
<gl-button
:id="buttonId"
- class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!"
- :class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`"
+ class="gl-shadow-none! gl-rounded-0!"
+ :class="`js-pipeline-expand-${pipeline.id} ${buttonBorderClass}`"
:icon="expandedIcon"
:aria-label="__('Expand pipeline')"
data-testid="expand-pipeline-button"
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 8088858f381..6a4d1bb44f2 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -1,9 +1,22 @@
<script>
-import { GlAlert, GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlButton,
+ GlLoadingIcon,
+ GlModal,
+ GlModalDirective,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import ciHeader from '~/vue_shared/components/header_ci_component.vue';
-import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants';
+import {
+ LOAD_FAILURE,
+ POST_FAILURE,
+ DELETE_FAILURE,
+ DEFAULT,
+ BUTTON_TOOLTIP_RETRY,
+} from '../constants';
import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
@@ -15,6 +28,7 @@ const POLL_INTERVAL = 10000;
export default {
name: 'PipelineHeaderSection',
+ BUTTON_TOOLTIP_RETRY,
pipelineCancel: 'pipelineCancel',
pipelineRetry: 'pipelineRetry',
finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'],
@@ -27,6 +41,7 @@ export default {
},
directives: {
GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
errorTexts: {
[LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'),
@@ -225,6 +240,9 @@ export default {
>
<gl-button
v-if="canRetryPipeline"
+ v-gl-tooltip
+ :aria-label="$options.BUTTON_TOOLTIP_RETRY"
+ :title="$options.BUTTON_TOOLTIP_RETRY"
:loading="isRetrying"
:disabled="isRetrying"
category="secondary"
diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
index e11073aee33..99fb5c146ba 100644
--- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
@@ -36,10 +36,13 @@ export default {
return data.project?.pipeline?.jobs?.nodes || [];
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.jobsPageInfo = data.project?.pipeline?.jobs?.pageInfo || {};
},
error() {
- createFlash({ message: __('An error occured while fetching the pipelines jobs.') });
+ createFlash({ message: __('An error occurred while fetching the pipelines jobs.') });
},
},
},
diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
index efad43ddd4f..ca2537ca4f4 100644
--- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
@@ -92,14 +92,20 @@ export default {
<template>
<gl-button
:id="`js-ci-action-${link}`"
- v-gl-tooltip="{ boundary: 'viewport' }"
- :title="tooltipText"
:class="cssClass"
:disabled="isDisabled"
class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
+ data-testid="ci-action-component"
@click.stop="onClickAction"
>
- <gl-loading-icon v-if="isLoading" size="sm" class="js-action-icon-loading" />
- <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" />
+ <div
+ v-gl-tooltip.viewport
+ :title="tooltipText"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-h-full"
+ data-testid="ci-action-icon-tooltip-wrapper"
+ >
+ <gl-loading-icon v-if="isLoading" size="sm" class="js-action-icon-loading" />
+ <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" />
+ </div>
</gl-button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue b/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue
new file mode 100644
index 00000000000..b8f9f84c217
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue
@@ -0,0 +1,102 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import getPipelineWarnings from '../../graphql/queries/get_pipeline_warnings.query.graphql';
+
+export default {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ expectedMessage: 'will be removed in',
+ i18n: {
+ title: __('Found warning in your .gitlab-ci.yml'),
+ rootTypesWarning: __(
+ '%{codeStart}types%{codeEnd} is deprecated and will be removed in 15.0. Use %{codeStart}stages%{codeEnd} instead. %{linkStart}Learn More %{linkEnd}',
+ ),
+ typeWarning: __(
+ '%{codeStart}type%{codeEnd} is deprecated and will be removed in 15.0. Use %{codeStart}stage%{codeEnd} instead. %{linkStart}Learn More %{linkEnd}',
+ ),
+ },
+ components: {
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ },
+ inject: ['deprecatedKeywordsDocPath', 'fullPath', 'pipelineIid'],
+ apollo: {
+ warnings: {
+ query: getPipelineWarnings,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.pipelineIid,
+ };
+ },
+ update(data) {
+ return data?.project?.pipeline?.warningMessages || [];
+ },
+ error() {
+ this.hasError = true;
+ },
+ },
+ },
+ data() {
+ return {
+ warnings: [],
+ hasError: false,
+ };
+ },
+ computed: {
+ deprecationWarnings() {
+ return this.warnings.filter(({ content }) => {
+ return content.includes(this.$options.expectedMessage);
+ });
+ },
+ formattedWarnings() {
+ // The API doesn't have a mechanism currently to return a
+ // type instead of just the error message. To work around this,
+ // we check if the deprecation message is found within the warnings
+ // and show a FE version of that message with the link to the documentation
+ // and translated. We can have only 2 types of warnings: root types and individual
+ // type. If the word `root` is present, then we know it's the root type deprecation
+ // and if not, it's the normal type. This has to be deleted in 15.0.
+ // Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/350810
+ return this.deprecationWarnings.map(({ content }) => {
+ if (content.includes('root')) {
+ return this.$options.i18n.rootTypesWarning;
+ }
+ return this.$options.i18n.typeWarning;
+ });
+ },
+ hasDeprecationWarning() {
+ return this.formattedWarnings.length > 0;
+ },
+ showWarning() {
+ return (
+ !this.$apollo.queries.warnings?.loading && !this.hasError && this.hasDeprecationWarning
+ );
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="showWarning"
+ :title="$options.i18n.title"
+ variant="warning"
+ :dismissible="false"
+ >
+ <ul class="gl-mb-0">
+ <li v-for="warning in formattedWarnings" :key="warning">
+ <gl-sprintf :message="warning">
+ <template #code="{ content }">
+ <code> {{ content }}</code>
+ </template>
+ <template #link="{ content }">
+ <gl-link :href="deprecatedKeywordsDocPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ </gl-alert>
+ </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
index b6c178d20b0..fa0e153b2af 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -1,15 +1,13 @@
<script>
import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
import eventHub from '../../event_hub';
+import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '../../constants';
import PipelineMultiActions from './pipeline_multi_actions.vue';
import PipelinesManualActions from './pipelines_manual_actions.vue';
export default {
- i18n: {
- cancelTitle: __('Cancel'),
- redeployTitle: __('Retry'),
- },
+ BUTTON_TOOLTIP_RETRY,
+ BUTTON_TOOLTIP_CANCEL,
directives: {
GlTooltip: GlTooltipDirective,
GlModalDirective,
@@ -75,12 +73,13 @@ export default {
<gl-button
v-if="pipeline.flags.retryable"
v-gl-tooltip.hover
- :aria-label="$options.i18n.redeployTitle"
- :title="$options.i18n.redeployTitle"
+ :aria-label="$options.BUTTON_TOOLTIP_RETRY"
+ :title="$options.BUTTON_TOOLTIP_RETRY"
:disabled="isRetrying"
:loading="isRetrying"
class="js-pipelines-retry-button"
data-qa-selector="pipeline_retry_button"
+ data-testid="pipelines-retry-button"
icon="repeat"
variant="default"
category="secondary"
@@ -91,14 +90,15 @@ export default {
v-if="pipeline.flags.cancelable"
v-gl-tooltip.hover
v-gl-modal-directive="'confirmation-modal'"
- :aria-label="$options.i18n.cancelTitle"
- :title="$options.i18n.cancelTitle"
+ :aria-label="$options.BUTTON_TOOLTIP_CANCEL"
+ :title="$options.BUTTON_TOOLTIP_CANCEL"
:loading="isCancelling"
:disabled="isCancelling"
icon="cancel"
variant="danger"
category="primary"
class="js-pipelines-cancel-button gl-ml-1"
+ data-testid="pipelines-cancel-button"
@click="handleCancelClick"
/>
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 0528e4c147c..b29c8426301 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
@@ -26,7 +26,7 @@ export default {
v-if="user"
:link-href="user.path"
:img-src="user.avatar_url"
- :img-size="26"
+ :img-size="32"
:tooltip-text="user.name"
class="gl-ml-3 js-pipeline-url-user"
/>
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 e2f30d5a8e6..52da4d01468 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -1,15 +1,19 @@
<script>
-import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
+import { GlIcon, GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { SCHEDULE_ORIGIN } from '../../constants';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import { SCHEDULE_ORIGIN, ICONS } from '../../constants';
export default {
components: {
+ GlIcon,
GlLink,
GlPopover,
GlSprintf,
GlBadge,
+ TooltipOnTruncate,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -33,11 +37,12 @@ export default {
type: String,
required: true,
},
+ viewType: {
+ type: String,
+ required: true,
+ },
},
computed: {
- user() {
- return this.pipeline.user;
- },
isScheduled() {
return this.pipeline.source === SCHEDULE_ORIGIN;
},
@@ -53,12 +58,160 @@ export default {
autoDevopsHelpPath() {
return helpPagePath('topics/autodevops/index.md');
},
+ mergeRequestRef() {
+ return this.pipeline?.merge_request;
+ },
+ commitRef() {
+ return this.pipeline?.ref;
+ },
+ commitTag() {
+ return this.commitRef?.tag;
+ },
+ commitUrl() {
+ return this.pipeline?.commit?.commit_path;
+ },
+ commitShortSha() {
+ return this.pipeline?.commit?.short_id;
+ },
+ refUrl() {
+ return this.commitRef?.ref_url || this.commitRef?.path;
+ },
+ tooltipTitle() {
+ return this.mergeRequestRef?.title || this.commitRef?.name;
+ },
+ commitAuthor() {
+ let commitAuthorInformation;
+ const pipelineCommit = this.pipeline?.commit;
+ const pipelineCommitAuthor = pipelineCommit?.author;
+
+ if (!pipelineCommit) {
+ return null;
+ }
+
+ // 1. person who is an author of a commit might be a GitLab user
+ if (pipelineCommitAuthor) {
+ // 2. if person who is an author of a commit is a GitLab user
+ // they can have a GitLab avatar
+ if (pipelineCommitAuthor?.avatar_url) {
+ commitAuthorInformation = pipelineCommitAuthor;
+
+ // 3. If GitLab user does not have avatar, they might have a Gravatar
+ } else if (pipelineCommit.author_gravatar_url) {
+ commitAuthorInformation = {
+ ...pipelineCommitAuthor,
+ avatar_url: pipelineCommit.author_gravatar_url,
+ };
+ }
+ // 4. If committer is not a GitLab User, they can have a Gravatar
+ } else {
+ commitAuthorInformation = {
+ avatar_url: pipelineCommit.author_gravatar_url,
+ path: `mailto:${pipelineCommit.author_email}`,
+ username: pipelineCommit.author_name,
+ };
+ }
+
+ return commitAuthorInformation;
+ },
+ commitIcon() {
+ let name = '';
+
+ if (this.commitTag) {
+ name = ICONS.TAG;
+ } else if (this.mergeRequestRef) {
+ name = ICONS.MR;
+ } else {
+ name = ICONS.BRANCH;
+ }
+
+ return name;
+ },
+ commitIconTooltipTitle() {
+ switch (this.commitIcon) {
+ case ICONS.TAG:
+ return __('Tag');
+ case ICONS.MR:
+ return __('Merge Request');
+ default:
+ return __('Branch');
+ }
+ },
+ commitTitleText() {
+ return this.pipeline?.commit?.title || __("Can't find HEAD commit for this branch");
+ },
+ hasAuthor() {
+ return (
+ this.commitAuthor?.avatar_url && this.commitAuthor?.path && this.commitAuthor?.username
+ );
+ },
+ userImageAltDescription() {
+ return this.commitAuthor?.username
+ ? sprintf(__("%{username}'s avatar"), { username: this.commitAuthor.username })
+ : null;
+ },
+ rearrangePipelinesTable() {
+ return this.glFeatures?.rearrangePipelinesTable;
+ },
},
};
</script>
<template>
<div class="pipeline-tags" data-testid="pipeline-url-table-cell">
+ <template v-if="rearrangePipelinesTable">
+ <div class="commit-title gl-mb-2" data-testid="commit-title-container">
+ <span class="gl-display-flex">
+ <tooltip-on-truncate :title="commitTitleText" class="flex-truncate-child gl-flex-grow-1">
+ <gl-link
+ :href="pipeline.path"
+ class="commit-row-message gl-text-blue-600!"
+ data-testid="commit-title"
+ data-qa-selector="pipeline_url_link"
+ >{{ commitTitleText }}</gl-link
+ >
+ </tooltip-on-truncate>
+ </span>
+ </div>
+ <div class="gl-mb-2">
+ <span class="gl-font-weight-bold gl-text-gray-500" data-testid="pipeline-identifier">
+ #{{ pipeline[pipelineKey] }}
+ </span>
+ <!--Commit row-->
+ <div class="icon-container gl-display-inline-block">
+ <gl-icon
+ v-gl-tooltip
+ :name="commitIcon"
+ :title="commitIconTooltipTitle"
+ data-testid="commit-icon-type"
+ />
+ </div>
+ <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top">
+ <gl-link
+ v-if="mergeRequestRef"
+ :href="mergeRequestRef.path"
+ class="ref-name"
+ data-testid="merge-request-ref"
+ >{{ mergeRequestRef.iid }}</gl-link
+ >
+ <gl-link v-else :href="refUrl" class="ref-name" data-testid="commit-ref-name">{{
+ commitRef.name
+ }}</gl-link>
+ </tooltip-on-truncate>
+ <gl-icon
+ v-gl-tooltip
+ name="commit"
+ class="commit-icon"
+ :title="__('Commit')"
+ data-testid="commit-icon"
+ />
+
+ <gl-link :href="commitUrl" class="commit-sha mr-0" data-testid="commit-short-sha">{{
+ commitShortSha
+ }}</gl-link>
+ <!--End of commit row-->
+ </div>
+ </template>
<gl-link
+ v-if="!rearrangePipelinesTable"
:href="pipeline.path"
class="gl-text-decoration-underline"
data-testid="pipeline-url-link"
@@ -66,7 +219,7 @@ export default {
>
#{{ pipeline[pipelineKey] }}
</gl-link>
- <div class="label-container">
+ <div class="label-container gl-mt-1">
<gl-badge
v-if="isScheduled"
v-gl-tooltip
@@ -163,7 +316,7 @@ export default {
v-gl-tooltip
:title="
__(
- 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.',
+ 'Merge request pipelines are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for merge request pipelines.',
)
"
variant="info"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
index b94f1a42039..47fffa8a6b2 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
@@ -2,6 +2,7 @@
import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { s__, __, sprintf } from '~/locale';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
import eventHub from '../../event_hub';
@@ -28,7 +29,7 @@ export default {
};
},
methods: {
- onClickAction(action) {
+ async onClickAction(action) {
if (action.scheduled_at) {
const confirmationMessage = sprintf(
s__(
@@ -36,9 +37,10 @@ export default {
),
{ jobName: action.name },
);
- // https://gitlab.com/gitlab-org/gitlab-foss/issues/52156
- // eslint-disable-next-line no-alert
- if (!window.confirm(confirmationMessage)) {
+
+ const confirmed = await confirmAction(confirmationMessage);
+
+ if (!confirmed) {
return;
}
}
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
index f56457a4162..54901c2d13f 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
@@ -3,12 +3,16 @@ import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.v
import { PIPELINE_STATUSES } from '~/code_quality_walkthrough/constants';
import { CHILD_VIEW } from '~/pipelines/constants';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import PipelinesTimeago from './time_ago.vue';
export default {
components: {
CodeQualityWalkthrough,
CiBadge,
+ PipelinesTimeago,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
pipeline: {
type: Object,
@@ -40,6 +44,9 @@ export default {
codeQualityBuildPath() {
return this.pipeline?.details?.code_quality_build_path;
},
+ rearrangePipelinesTable() {
+ return this.glFeatures?.rearrangePipelinesTable;
+ },
},
};
</script>
@@ -48,11 +55,13 @@ export default {
<div>
<ci-badge
id="js-code-quality-walkthrough"
+ class="gl-mb-3"
:status="pipelineStatus"
:show-text="!isChildView"
:icon-classes="'gl-vertical-align-middle!'"
data-qa-selector="pipeline_commit_status"
/>
+ <pipelines-timeago v-if="rearrangePipelinesTable" class="gl-mt-3" :pipeline="pipeline" />
<code-quality-walkthrough
v-if="shouldRenderCodeQualityWalkthrough"
:step="codeQualityStep"
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 d64decc81ec..9919a18cb99 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,6 +1,7 @@
<script>
import { GlTableLite, GlTooltipDirective } from '@gitlab/ui';
-import { s__ } from '~/locale';
+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';
@@ -33,6 +34,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
pipelines: {
type: Array,
@@ -72,16 +74,18 @@ export default {
key: 'status',
label: s__('Pipeline|Status'),
thClass: DEFAULT_TH_CLASSES,
- columnClass: 'gl-w-10p',
+ columnClass: this.rearrangePipelinesTable ? 'gl-w-15p' : 'gl-w-10p',
tdClass: DEFAULT_TD_CLASS,
thAttr: { 'data-testid': 'status-th' },
},
{
key: 'pipeline',
- label: this.pipelineKeyOption.label,
+ label: this.rearrangePipelinesTable ? __('Pipeline') : this.pipelineKeyOption.label,
thClass: DEFAULT_TH_CLASSES,
- tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
- columnClass: 'gl-w-10p',
+ tdClass: this.rearrangePipelinesTable
+ ? `${DEFAULT_TD_CLASS}`
+ : `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
+ columnClass: this.rearrangePipelinesTable ? 'gl-w-30p' : 'gl-w-10p',
thAttr: { 'data-testid': 'pipeline-th' },
},
{
@@ -113,7 +117,7 @@ export default {
label: s__('Pipeline|Duration'),
thClass: DEFAULT_TH_CLASSES,
tdClass: DEFAULT_TD_CLASS,
- columnClass: 'gl-w-15p',
+ columnClass: this.rearrangePipelinesTable ? 'gl-w-5p' : 'gl-w-15p',
thAttr: { 'data-testid': 'timeago-th' },
},
{
@@ -124,7 +128,13 @@ export default {
thAttr: { 'data-testid': 'actions-th' },
},
];
- return fields;
+
+ return !this.rearrangePipelinesTable
+ ? fields
+ : fields.filter((field) => !['commit', 'timeago'].includes(field.key));
+ },
+ rearrangePipelinesTable() {
+ return this.glFeatures?.rearrangePipelinesTable;
},
},
watch: {
@@ -182,6 +192,7 @@ export default {
:pipeline="item"
:pipeline-schedule-url="pipelineScheduleUrl"
:pipeline-key="pipelineKeyOption.key"
+ :view-type="viewType"
/>
</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 e6b03751350..c45e3f24567 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -54,11 +54,14 @@ export default {
showSkipped() {
return !this.duration && !this.finishedTime && this.skipped;
},
+ shouldDisplayAsBlock() {
+ return this.glFeatures?.rearrangePipelinesTable;
+ },
},
};
</script>
<template>
- <div>
+ <div class="{ 'gl-display-block': shouldDisplayAsBlock }">
<span v-if="showInProgress" data-testid="pipeline-in-progress">
<gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" />
<gl-icon
@@ -87,6 +90,7 @@ export default {
<time
v-gl-tooltip
:title="tooltipTitle(finishedTime)"
+ :datetime="finishedTime"
data-placement="top"
data-container="body"
>
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 410fc7b82cd..36f708ff2af 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -10,6 +10,12 @@ export const SCHEDULE_ORIGIN = 'schedule';
export const NEEDS_PROPERTY = 'needs';
export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds';
+export const ICONS = {
+ TAG: 'tag',
+ MR: 'git-merge',
+ BRANCH: 'branch',
+};
+
export const TestStatus = {
FAILED: 'failed',
SKIPPED: 'skipped',
@@ -53,3 +59,6 @@ export const PipelineKeyOptions = [
];
export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.');
+
+export const BUTTON_TOOLTIP_RETRY = __('Retry failed jobs');
+export const BUTTON_TOOLTIP_CANCEL = __('Cancel');
diff --git a/app/assets/javascripts/pipelines/graphql/fragmentTypes.json b/app/assets/javascripts/pipelines/graphql/fragmentTypes.json
deleted file mode 100644
index 4601b74b5c1..00000000000
--- a/app/assets/javascripts/pipelines/graphql/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"UNION","name":"JobNeedUnion","possibleTypes":[{"name":"CiBuildNeed"},{"name":"CiJob"}]}]}} \ No newline at end of file
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql
new file mode 100644
index 00000000000..cd1d2b62a3d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql
@@ -0,0 +1,12 @@
+query getPipelineWarnings($fullPath: ID!, $iid: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $iid) {
+ id
+ warningMessages {
+ content
+ id
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index 3201f88a9e3..c4f7665c91d 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -1,6 +1,7 @@
import Visibility from 'visibilityjs';
import createFlash from '~/flash';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import { validateParams } from '~/pipelines/utils';
@@ -195,11 +196,20 @@ export default {
this.$toast.show(TOAST_MESSAGE);
this.updateTable();
})
- .catch(() => {
+ .catch((e) => {
+ const unauthorized = e.response.status === httpStatusCodes.UNAUTHORIZED;
+ const badRequest = e.response.status === httpStatusCodes.BAD_REQUEST;
+
+ let errorMessage = __(
+ 'An error occurred while trying to run a new pipeline for this merge request.',
+ );
+
+ if (unauthorized || badRequest) {
+ errorMessage = __('You do not have permission to run a pipeline on this branch.');
+ }
+
createFlash({
- message: __(
- 'An error occurred while trying to run a new pipeline for this merge request.',
- ),
+ message: errorMessage,
});
})
.finally(() => this.store.toggleIsRunningPipeline(false));
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index ae8b2503c79..bfb95e5ab0c 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -3,6 +3,7 @@ import { __ } from '~/locale';
import createDagApp from './pipeline_details_dag';
import { createPipelinesDetailApp } from './pipeline_details_graph';
import { createPipelineHeaderApp } from './pipeline_details_header';
+import { createPipelineNotificationApp } from './pipeline_details_notification';
import { createPipelineJobsApp } from './pipeline_details_jobs';
import { apolloProvider } from './pipeline_shared_client';
import { createTestDetails } from './pipeline_test_details';
@@ -11,6 +12,7 @@ const SELECTORS = {
PIPELINE_DETAILS: '.js-pipeline-details-vue',
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
PIPELINE_HEADER: '#js-pipeline-header-vue',
+ PIPELINE_NOTIFICATION: '#js-pipeline-notification',
PIPELINE_TESTS: '#js-pipeline-tests-detail',
PIPELINE_JOBS: '#js-pipeline-jobs-vue',
};
@@ -43,6 +45,14 @@ export default async function initPipelineDetailsBundle() {
}
try {
+ createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider);
+ } catch {
+ createFlash({
+ message: __('An error occurred while loading a section of this page.'),
+ });
+ }
+
+ try {
createDagApp(apolloProvider);
} catch {
createFlash({
diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js
new file mode 100644
index 00000000000..0061be843c5
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_notification.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import DeprecatedKeywordNotification from './components/notification/deprecated_type_keyword_notification.vue';
+
+Vue.use(VueApollo);
+
+export const createPipelineNotificationApp = (elSelector, apolloProvider) => {
+ const el = document.querySelector(elSelector);
+
+ if (!el) {
+ return;
+ }
+
+ const { deprecatedKeywordsDocPath, fullPath, pipelineIid } = el?.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ DeprecatedKeywordNotification,
+ },
+ provide: {
+ deprecatedKeywordsDocPath,
+ fullPath,
+ pipelineIid,
+ },
+ apolloProvider,
+ render(createElement) {
+ return createElement('deprecated-keyword-notification');
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js
index 84276588d6a..c3be487caae 100644
--- a/app/assets/javascripts/pipelines/pipeline_shared_client.js
+++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js
@@ -1,19 +1,10 @@
import VueApollo from 'vue-apollo';
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
-import introspectionQueryResultData from './graphql/fragmentTypes.json';
-
-export const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
export const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
- cacheConfig: {
- fragmentMatcher,
- },
useGet: true,
},
),
diff --git a/app/assets/javascripts/popovers/index.js b/app/assets/javascripts/popovers/index.js
index 7db669b8c52..94340ae16a0 100644
--- a/app/assets/javascripts/popovers/index.js
+++ b/app/assets/javascripts/popovers/index.js
@@ -13,7 +13,7 @@ const getPopoversApp = () => {
document.body.appendChild(container);
const Popovers = Vue.extend(PopoversComponent);
- app = new Popovers();
+ app = new Popovers({ name: 'PopoversRoot' });
app.$mount(`#${APP_ELEMENT_ID}`);
}
diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue
index eaf93e2da4f..924b6f55db4 100644
--- a/app/assets/javascripts/projects/components/project_delete_button.vue
+++ b/app/assets/javascripts/projects/components/project_delete_button.vue
@@ -1,12 +1,8 @@
<script>
-import { GlAlert, GlSprintf } from '@gitlab/ui';
-import { __ } from '~/locale';
import SharedDeleteButton from './shared/delete_button.vue';
export default {
components: {
- GlSprintf,
- GlAlert,
SharedDeleteButton,
},
props: {
@@ -39,66 +35,17 @@ export default {
required: true,
},
},
- strings: {
- alertTitle: __('You are about to permanently delete this project'),
- alertBody: __(
- 'After a project is permanently deleted, it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd}, including issues, merge requests etc.',
- ),
- isNotForkMessage: __(
- 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:',
- ),
- isForkMessage: __('This forked project has the following:'),
- },
};
</script>
<template>
- <shared-delete-button v-bind="{ confirmPhrase, formPath }">
- <template #modal-body>
- <gl-alert
- class="gl-mb-5"
- variant="danger"
- :title="$options.strings.alertTitle"
- :dismissible="false"
- >
- <p>
- <gl-sprintf v-if="isFork" :message="$options.strings.isForkMessage" />
- <gl-sprintf v-else :message="$options.strings.isNotForkMessage">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <ul>
- <li>
- <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)">
- <template #issuesCount>{{ issuesCount }}</template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf
- :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)"
- >
- <template #mergeRequestsCount>{{ mergeRequestsCount }}</template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)">
- <template #forksCount>{{ forksCount }}</template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf :message="n__('%d star', '%d stars', starsCount)">
- <template #starsCount>{{ starsCount }}</template>
- </gl-sprintf>
- </li>
- </ul>
- <gl-sprintf :message="$options.strings.alertBody">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </gl-alert>
- </template>
- </shared-delete-button>
+ <shared-delete-button
+ :confirm-phrase="confirmPhrase"
+ :form-path="formPath"
+ :is-fork="isFork"
+ :issues-count="issuesCount"
+ :merge-requests-count="mergeRequestsCount"
+ :forks-count="forksCount"
+ :stars-count="starsCount"
+ />
</template>
diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue
index 2e46f437ace..fd71a246a26 100644
--- a/app/assets/javascripts/projects/components/shared/delete_button.vue
+++ b/app/assets/javascripts/projects/components/shared/delete_button.vue
@@ -1,14 +1,16 @@
<script>
-import { GlModal, GlModalDirective, GlFormInput, GlButton } from '@gitlab/ui';
+import { GlModal, GlModalDirective, GlFormInput, GlButton, GlAlert, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import csrf from '~/lib/utils/csrf';
import { __ } from '~/locale';
export default {
components: {
+ GlAlert,
GlModal,
GlFormInput,
GlButton,
+ GlSprintf,
},
directives: {
GlModal: GlModalDirective,
@@ -22,6 +24,26 @@ export default {
type: String,
required: true,
},
+ isFork: {
+ type: Boolean,
+ required: true,
+ },
+ issuesCount: {
+ type: Number,
+ required: true,
+ },
+ mergeRequestsCount: {
+ type: Number,
+ required: true,
+ },
+ forksCount: {
+ type: Number,
+ required: true,
+ },
+ starsCount: {
+ type: Number,
+ required: true,
+ },
},
data() {
return {
@@ -55,8 +77,17 @@ export default {
},
strings: {
deleteProject: __('Delete project'),
- title: __('Delete project. Are you ABSOLUTELY SURE?'),
- confirmText: __('Please type the following to confirm:'),
+ title: __('Are you absolutely sure?'),
+ confirmText: __('Enter the following to confirm:'),
+ isForkAlertTitle: __('You are about to delete this forked project containing:'),
+ isNotForkAlertTitle: __('You are about to delete this project containing:'),
+ isForkAlertBody: __('This process deletes the project repository and all related resources.'),
+ isNotForkAlertBody: __(
+ 'This project is %{strongStart}NOT%{strongEnd} a fork. This process deletes the project repository and all related resources.',
+ ),
+ isNotForkMessage: __(
+ 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:',
+ ),
},
};
</script>
@@ -83,7 +114,52 @@ export default {
>
<template #modal-title>{{ $options.strings.title }}</template>
<div>
- <slot name="modal-body"></slot>
+ <gl-alert class="gl-mb-5" variant="danger" :dismissible="false">
+ <h4 v-if="isFork" data-testid="delete-alert-title" class="gl-alert-title">
+ {{ $options.strings.isForkAlertTitle }}
+ </h4>
+ <h4 v-else data-testid="delete-alert-title" class="gl-alert-title">
+ {{ $options.strings.isNotForkAlertTitle }}
+ </h4>
+ <ul>
+ <li>
+ <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)">
+ <template #issuesCount>{{ issuesCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf
+ :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)"
+ >
+ <template #mergeRequestsCount>{{ mergeRequestsCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)">
+ <template #forksCount>{{ forksCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="n__('%d star', '%d stars', starsCount)">
+ <template #starsCount>{{ starsCount }}</template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ <gl-sprintf
+ v-if="isFork"
+ data-testid="delete-alert-body"
+ :message="$options.strings.isForkAlertBody"
+ />
+ <gl-sprintf
+ v-else
+ data-testid="delete-alert-body"
+ :message="$options.strings.isNotForkAlertBody"
+ >
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<p class="gl-mb-1">{{ $options.strings.confirmText }}</p>
<p>
<code class="gl-white-space-pre-wrap">{{ confirmPhrase }}</code>
diff --git a/app/assets/javascripts/projects/new/components/deployment_target_select.vue b/app/assets/javascripts/projects/new/components/deployment_target_select.vue
new file mode 100644
index 00000000000..f3b7e39f148
--- /dev/null
+++ b/app/assets/javascripts/projects/new/components/deployment_target_select.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import {
+ DEPLOYMENT_TARGET_SELECTIONS,
+ DEPLOYMENT_TARGET_LABEL,
+ DEPLOYMENT_TARGET_EVENT,
+ NEW_PROJECT_FORM,
+} from '../constants';
+
+const trackingMixin = Tracking.mixin({ label: DEPLOYMENT_TARGET_LABEL });
+
+export default {
+ i18n: {
+ deploymentTargetLabel: s__('Deployment Target|Project deployment target (optional)'),
+ defaultOption: s__('Deployment Target|Select the deployment target'),
+ },
+ deploymentTargets: DEPLOYMENT_TARGET_SELECTIONS,
+ selectId: 'deployment-target-select',
+ components: {
+ GlFormGroup,
+ GlFormSelect,
+ },
+ mixins: [trackingMixin],
+ data() {
+ return {
+ selectedTarget: null,
+ formSubmitted: false,
+ };
+ },
+ mounted() {
+ const form = document.getElementById(NEW_PROJECT_FORM);
+ form.addEventListener('submit', () => {
+ this.formSubmitted = true;
+ this.trackSelection();
+ });
+ },
+ methods: {
+ trackSelection() {
+ if (this.formSubmitted && this.selectedTarget) {
+ this.track(DEPLOYMENT_TARGET_EVENT, { property: this.selectedTarget });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :label="$options.i18n.deploymentTargetLabel" :label-for="$options.selectId">
+ <gl-form-select
+ :id="$options.selectId"
+ v-model="selectedTarget"
+ :options="$options.deploymentTargets"
+ >
+ <template #first>
+ <option :value="null" disabled>{{ $options.i18n.defaultOption }}</option>
+ </template>
+ </gl-form-select>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/projects/new/constants.js b/app/assets/javascripts/projects/new/constants.js
new file mode 100644
index 00000000000..c5e6722981b
--- /dev/null
+++ b/app/assets/javascripts/projects/new/constants.js
@@ -0,0 +1,20 @@
+import { s__ } from '~/locale';
+
+export const DEPLOYMENT_TARGET_SELECTIONS = [
+ s__('DeploymentTarget|Kubernetes (GKE, EKS, OpenShift, and so on)'),
+ s__('DeploymentTarget|Managed container runtime (Fargate, Cloud Run, DigitalOcean App)'),
+ s__('DeploymentTarget|Self-managed container runtime (Podman, Docker Swarm, Docker Compose)'),
+ s__('DeploymentTarget|Heroku'),
+ s__('DeploymentTarget|Virtual machine (for example, EC2)'),
+ s__('DeploymentTarget|Mobile app store'),
+ s__('DeploymentTarget|Registry (package or container)'),
+ s__('DeploymentTarget|Infrastructure provider (Terraform, Cloudformation, and so on)'),
+ s__('DeploymentTarget|Serverless backend (Lambda, Cloud functions)'),
+ s__('DeploymentTarget|GitLab Pages'),
+ s__('DeploymentTarget|Other hosting service'),
+ s__('DeploymentTarget|No deployment planned'),
+];
+
+export const NEW_PROJECT_FORM = 'new_project';
+export const DEPLOYMENT_TARGET_LABEL = 'new_project_deployment_target';
+export const DEPLOYMENT_TARGET_EVENT = 'select_deployment_target';
diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js
index 010c6a29ae3..4de9b8a6f47 100644
--- a/app/assets/javascripts/projects/new/index.js
+++ b/app/assets/javascripts/projects/new/index.js
@@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import NewProjectCreationApp from './components/app.vue';
import NewProjectUrlSelect from './components/new_project_url_select.vue';
+import DeploymentTargetSelect from './components/deployment_target_select.vue';
export function initNewProjectCreation() {
const el = document.querySelector('.js-new-project-creation');
@@ -64,3 +65,16 @@ export function initNewProjectUrlSelect() {
}),
);
}
+
+export function initDeploymentTargetSelect() {
+ const el = document.querySelector('.js-deployment-target-select');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ render: (createElement) => createElement(DeploymentTargetSelect),
+ });
+}
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 8d71a3dab68..62e2cec874a 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,8 @@
import $ from 'jquery';
import { debounce } from 'lodash';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../lib/utils/constants';
import axios from '../lib/utils/axios_utils';
import {
convertToTitleCase,
@@ -13,20 +15,26 @@ let hasUserDefinedProjectPath = false;
let hasUserDefinedProjectName = false;
const invalidInputClass = 'gl-field-error-outline';
+const cancelSource = axios.CancelToken.source();
+const endpoint = `${gon.relative_url_root}/import/url/validate`;
+let importCredentialsValidationPromise = null;
const validateImportCredentials = (url, user, password) => {
- const endpoint = `${gon.relative_url_root}/import/url/validate`;
- return axios
- .post(endpoint, {
- url,
- user,
- password,
- })
+ cancelSource.cancel();
+ importCredentialsValidationPromise = axios
+ .post(endpoint, { url, user, password }, { cancelToken: cancelSource.cancel() })
.then(({ data }) => data)
- .catch(() => ({
- // intentionally reporting success in case of validation error
- // we do not want to block users from trying import in case of validation exception
- success: true,
- }));
+ .catch((thrown) =>
+ axios.isCancel(thrown)
+ ? {
+ cancelled: true,
+ }
+ : {
+ // intentionally reporting success in case of validation error
+ // we do not want to block users from trying import in case of validation exception
+ success: true,
+ },
+ );
+ return importCredentialsValidationPromise;
};
const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
@@ -72,7 +80,7 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => {
.parents('.toggle-import-form')
.find('#project_path');
- if (hasUserDefinedProjectPath) {
+ if (hasUserDefinedProjectPath || $currentProjectPath.length === 0) {
return;
}
@@ -98,6 +106,21 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => {
};
const bindHowToImport = () => {
+ const importLinks = document.querySelectorAll('.js-how-to-import-link');
+
+ importLinks.forEach((link) => {
+ const { modalTitle: title, modalMessage: modalHtmlMessage } = link.dataset;
+
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ confirmAction('', {
+ modalHtmlMessage,
+ title,
+ hideCancel: true,
+ });
+ });
+ });
+
$('.how_to_import_link').on('click', (e) => {
e.preventDefault();
$(e.currentTarget).next('.modal').show();
@@ -114,7 +137,7 @@ const bindEvents = () => {
const $projectImportUrlUser = $('#project_import_url_user');
const $projectImportUrlPassword = $('#project_import_url_password');
const $projectImportUrlError = $('.js-import-url-error');
- const $projectImportForm = $('.project-import form');
+ const $projectImportForm = $('form.js-project-import');
const $projectPath = $('.tab-pane.active #project_path');
const $useTemplateBtn = $('.template-button > input');
const $projectFieldsForm = $('.project-fields-form');
@@ -124,7 +147,7 @@ const bindEvents = () => {
const $projectTemplateButtons = $('.project-templates-buttons');
const $projectName = $('.tab-pane.active #project_name');
- if ($newProjectForm.length !== 1) {
+ if ($newProjectForm.length !== 1 && $projectImportForm.length !== 1) {
return;
}
@@ -168,20 +191,28 @@ const bindEvents = () => {
$projectPath.val($projectPath.val().trim());
});
- const updateUrlPathWarningVisibility = debounce(async () => {
- const { success: isUrlValid } = await validateImportCredentials(
+ const updateUrlPathWarningVisibility = async () => {
+ const { success: isUrlValid, cancelled } = await validateImportCredentials(
$projectImportUrl.val(),
$projectImportUrlUser.val(),
$projectImportUrlPassword.val(),
);
+ if (cancelled) {
+ return;
+ }
+
$projectImportUrl.toggleClass(invalidInputClass, !isUrlValid);
$projectImportUrlError.toggleClass('hide', isUrlValid);
- }, 500);
+ };
+ const debouncedUpdateUrlPathWarningVisibility = debounce(
+ updateUrlPathWarningVisibility,
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ );
let isProjectImportUrlDirty = false;
$projectImportUrl.on('blur', () => {
isProjectImportUrlDirty = true;
- updateUrlPathWarningVisibility();
+ debouncedUpdateUrlPathWarningVisibility();
});
$projectImportUrl.on('keyup', () => {
deriveProjectPathFromUrl($projectImportUrl);
@@ -190,17 +221,33 @@ const bindEvents = () => {
[$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => {
$f.on('input', () => {
if (isProjectImportUrlDirty) {
- updateUrlPathWarningVisibility();
+ debouncedUpdateUrlPathWarningVisibility();
}
});
});
- $projectImportForm.on('submit', (e) => {
+ $projectImportForm.on('submit', async (e) => {
+ e.preventDefault();
+
+ if (importCredentialsValidationPromise === null) {
+ // we didn't validate credentials yet
+ debouncedUpdateUrlPathWarningVisibility.cancel();
+ updateUrlPathWarningVisibility();
+ }
+
+ const submitBtn = $projectImportForm.find('input[type="submit"]');
+
+ submitBtn.disable();
+ await importCredentialsValidationPromise;
+ submitBtn.enable();
+
const $invalidFields = $projectImportForm.find(`.${invalidInputClass}`);
if ($invalidFields.length > 0) {
$invalidFields[0].focus();
- e.preventDefault();
- e.stopPropagation();
+ } else {
+ // calling .submit() on HTMLFormElement does not trigger 'submit' event
+ // We are using this behavior to bypass this handler and avoid infinite loop
+ $projectImportForm[0].submit();
}
});
diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
index 91d8fca0487..aa3235b1515 100644
--- a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
+++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
@@ -2,6 +2,7 @@
import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
+import { CC_VALIDATION_REQUIRED_ERROR } from '../constants';
const DEFAULT_ERROR_MESSAGE = __('An error occurred while updating the configuration.');
const REQUIRES_VALIDATION_TEXT = s__(
@@ -47,11 +48,13 @@ export default {
};
},
computed: {
- showCreditCardValidation() {
+ ccRequiredError() {
+ return this.errorMessage === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed;
+ },
+ genericError() {
return (
- this.isCreditCardValidationRequired &&
- !this.isSharedRunnerEnabled &&
- !this.successfulValidation &&
+ this.errorMessage &&
+ this.errorMessage !== CC_VALIDATION_REQUIRED_ERROR &&
!this.ccAlertDismissed
);
},
@@ -62,6 +65,7 @@ export default {
},
toggleSharedRunners() {
this.isLoading = true;
+ this.ccAlertDismissed = false;
this.errorMessage = null;
axios
@@ -82,20 +86,19 @@ export default {
<template>
<div>
<section class="gl-mt-5">
- <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" :dismissible="false">
- {{ errorMessage }}
- </gl-alert>
-
<cc-validation-required-alert
- v-if="showCreditCardValidation"
+ v-if="ccRequiredError"
class="gl-pb-5"
:custom-message="$options.i18n.REQUIRES_VALIDATION_TEXT"
@verifiedCreditCard="creditCardValidated"
@dismiss="ccAlertDismissed = true"
/>
+ <gl-alert v-if="genericError" class="gl-mb-3" variant="danger" :dismissible="false">
+ {{ errorMessage }}
+ </gl-alert>
+
<gl-toggle
- v-else
ref="sharedRunnersToggle"
:disabled="isDisabledAndUnoverridable"
:is-loading="isLoading"
diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
index b98e1101884..fe968e74c6d 100644
--- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
+++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
@@ -11,8 +11,12 @@ export default {
ConfirmDanger,
},
props: {
- namespaces: {
- type: Object,
+ groupNamespaces: {
+ type: Array,
+ required: true,
+ },
+ userNamespaces: {
+ type: Array,
required: true,
},
confirmationPhrase: {
@@ -44,10 +48,10 @@ export default {
<div>
<gl-form-group>
<namespace-select
- class="qa-namespaces-list"
data-testid="transfer-project-namespace"
:full-width="true"
- :data="namespaces"
+ :group-namespaces="groupNamespaces"
+ :user-namespaces="userNamespaces"
:selected-namespace="selectedNamespace"
@select="handleSelect"
/>
diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js
index f5591c43dc4..9cf1afd334f 100644
--- a/app/assets/javascripts/projects/settings/constants.js
+++ b/app/assets/javascripts/projects/settings/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
@@ -18,3 +20,8 @@ export const ACCESS_LEVELS = {
};
export const ACCESS_LEVEL_NONE = 0;
+
+// must match shared_runners_setting in update_service.rb
+export const CC_VALIDATION_REQUIRED_ERROR = __(
+ 'Shared runners enabled cannot be enabled until a valid credit card is on file',
+);
diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
index 47b49031dc9..a5f720bffaa 100644
--- a/app/assets/javascripts/projects/settings/init_transfer_project_form.js
+++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
@@ -3,10 +3,14 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import TransferProjectForm from './components/transfer_project_form.vue';
const prepareNamespaces = (rawNamespaces = '') => {
+ if (!rawNamespaces) {
+ return { groupNamespaces: [], userNamespaces: [] };
+ }
+
const data = JSON.parse(rawNamespaces);
return {
- group: data?.group.map(convertObjectPropsToCamelCase),
- user: data?.user.map(convertObjectPropsToCamelCase),
+ groupNamespaces: data?.group?.map(convertObjectPropsToCamelCase) || [],
+ userNamespaces: data?.user?.map(convertObjectPropsToCamelCase) || [],
};
};
@@ -35,7 +39,7 @@ export default () => {
props: {
confirmButtonText,
confirmationPhrase,
- namespaces: prepareNamespaces(namespaces),
+ ...prepareNamespaces(namespaces),
},
on: {
selectNamespace: (id) => {
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index f936c03c5d3..9ee2e7a4ffd 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -9,6 +9,8 @@ import {
linkedIssueTypesMap,
addRelatedIssueErrorMap,
addRelatedItemErrorMap,
+ issuablesFormCategoryHeaderTextMap,
+ issuablesFormInputTextMap,
} from '../constants';
import RelatedIssuableInput from './related_issuable_input.vue';
@@ -134,6 +136,12 @@ export default {
epics: mergeUrlParams({ confidential_only: true }, this.autoCompleteSources.epics),
};
},
+ issuableCategoryHeaderText() {
+ return issuablesFormCategoryHeaderTextMap[this.issuableType];
+ },
+ issuableInputText() {
+ return issuablesFormInputTextMap[this.issuableType];
+ },
},
methods: {
onPendingIssuableRemoveRequest(params) {
@@ -162,7 +170,7 @@ export default {
<form @submit.prevent="onFormSubmit">
<template v-if="showCategorizedIssues">
<gl-form-group
- :label="__('The current issue')"
+ :label="issuableCategoryHeaderText"
label-for="linked-issue-type-radio"
label-class="label-bold"
class="mb-2"
@@ -175,7 +183,7 @@ export default {
/>
</gl-form-group>
<p class="bold">
- {{ __('the following issue(s)') }}
+ {{ issuableInputText }}
</p>
</template>
<related-issuable-input
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index 94535e1b8c9..bc97fab9ad2 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -5,6 +5,9 @@ import {
issuableQaClassMap,
linkedIssueTypesMap,
linkedIssueTypesTextMap,
+ issuablesBlockHeaderTextMap,
+ issuablesBlockHelpTextMap,
+ issuablesBlockAddButtonTextMap,
} from '../constants';
import AddIssuableForm from './add_issuable_form.vue';
import RelatedIssuesList from './related_issues_list.vue';
@@ -105,6 +108,15 @@ export default {
hasBody() {
return this.isFormVisible || this.shouldShowTokenBody;
},
+ headerText() {
+ return issuablesBlockHeaderTextMap[this.issuableType];
+ },
+ helpLinkText() {
+ return issuablesBlockHelpTextMap[this.issuableType];
+ },
+ addIssuableButtonText() {
+ return issuablesBlockAddButtonTextMap[this.issuableType];
+ },
badgeLabel() {
return this.isFetching && this.relatedIssues.length === 0 ? '...' : this.relatedIssues.length;
},
@@ -138,13 +150,14 @@ export default {
href="#related-issues"
aria-hidden="true"
/>
- <slot name="header-text">{{ __('Linked issues') }}</slot>
+ <slot name="header-text">{{ headerText }}</slot>
<gl-link
v-if="hasHelpPath"
:href="helpPath"
target="_blank"
class="gl-display-flex gl-align-items-center gl-ml-2 gl-text-gray-500"
- :aria-label="__('Read more about related issues')"
+ data-testid="help-link"
+ :aria-label="helpLinkText"
>
<gl-icon name="question" :size="12" />
</gl-link>
@@ -160,7 +173,7 @@ export default {
v-if="canAdmin"
data-qa-selector="related_issues_plus_button"
icon="plus"
- :aria-label="__('Add a related issue')"
+ :aria-label="addIssuableButtonText"
:class="qaClass"
@click="$emit('toggleAddRelatedIssuesForm', $event)"
/>
diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js
index 89eae069a24..f911468d8f1 100644
--- a/app/assets/javascripts/related_issues/constants.js
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -104,3 +104,28 @@ export const PathIdSeparator = {
Epic: '&',
Issue: '#',
};
+
+export const issuablesBlockHeaderTextMap = {
+ [issuableTypesMap.ISSUE]: __('Linked issues'),
+ [issuableTypesMap.EPIC]: __('Linked epics'),
+};
+
+export const issuablesBlockHelpTextMap = {
+ [issuableTypesMap.ISSUE]: __('Read more about related issues'),
+ [issuableTypesMap.EPIC]: __('Read more about related epics'),
+};
+
+export const issuablesBlockAddButtonTextMap = {
+ [issuableTypesMap.ISSUE]: __('Add a related issue'),
+ [issuableTypesMap.EPIC]: __('Add a related epic'),
+};
+
+export const issuablesFormCategoryHeaderTextMap = {
+ [issuableTypesMap.ISSUE]: __('The current issue'),
+ [issuableTypesMap.EPIC]: __('The current epic'),
+};
+
+export const issuablesFormInputTextMap = {
+ [issuableTypesMap.ISSUE]: __('the following issue(s)'),
+ [issuableTypesMap.EPIC]: __('the following epic(s)'),
+};
diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index 0ee99df1455..35858be90b2 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -8,6 +8,7 @@ export default function initRelatedIssues() {
// eslint-disable-next-line no-new
new Vue({
el: relatedIssuesRootElement,
+ name: 'RelatedIssuesRoot',
components: {
relatedIssuesRoot: RelatedIssuesRoot,
},
diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
index ed4f3c4e0fe..05ab5c2cc90 100644
--- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
+++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
@@ -1,9 +1,10 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlBadge, GlLink } from '@gitlab/ui';
export default {
name: 'AccessibilityIssueBody',
components: {
+ GlBadge,
GlLink,
},
props: {
@@ -38,9 +39,9 @@ export default {
<template>
<div class="report-block-list-issue-description gl-mt-2 gl-mb-2">
<div ref="accessibility-issue-description" class="report-block-list-issue-description-text">
- <div v-if="isNew" ref="accessibility-issue-is-new-badge" class="badge badge-danger gl-mr-2">
- {{ s__('AccessibilityReport|New') }}
- </div>
+ <gl-badge v-if="isNew" class="gl-mr-2" variant="danger">{{
+ s__('AccessibilityReport|New')
+ }}</gl-badge>
<div>
{{
sprintf(
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 9368d7e6058..52963b49f68 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -9,12 +9,14 @@ import axios from '~/lib/utils/axios_utils';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getRefMixin from '../mixins/get_ref';
import blobInfoQuery from '../queries/blob_info.query.graphql';
+import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE } from '../constants';
import BlobButtonGroup from './blob_button_group.vue';
import BlobEdit from './blob_edit.vue';
import ForkSuggestion from './fork_suggestion.vue';
-import { loadViewer, viewerProps } from './blob_viewers';
+import { loadViewer } from './blob_viewers';
export default {
i18n: {
@@ -29,7 +31,7 @@ export default {
GlButton,
ForkSuggestion,
},
- mixins: [getRefMixin],
+ mixins: [getRefMixin, glFeatureFlagMixin()],
inject: {
originalBranch: {
default: '',
@@ -43,12 +45,11 @@ export default {
projectPath: this.projectPath,
filePath: this.path,
ref: this.originalBranch || this.ref,
+ shouldFetchRawText: Boolean(this.glFeatures.highlightJs),
};
},
result() {
- this.switchViewer(
- this.hasRichViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER,
- );
+ this.switchViewer(this.hasRichViewer ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER);
},
error() {
this.displayError();
@@ -78,50 +79,7 @@ export default {
isBinary: false,
isLoadingLegacyViewer: false,
activeViewerType: SIMPLE_BLOB_VIEWER,
- project: {
- userPermissions: {
- pushCode: false,
- downloadCode: false,
- createMergeRequestIn: false,
- forkProject: false,
- },
- pathLocks: {
- nodes: [],
- },
- repository: {
- empty: true,
- blobs: {
- nodes: [
- {
- name: '',
- size: '',
- rawTextBlob: '',
- type: '',
- fileType: '',
- tooLarge: false,
- path: '',
- editBlobPath: '',
- ideEditPath: '',
- forkAndEditPath: '',
- ideForkAndEditPath: '',
- storedExternally: false,
- externalStorage: '',
- canModifyBlob: false,
- canCurrentUserPushToBranch: false,
- archived: false,
- rawPath: '',
- externalStorageUrl: '',
- replacePath: '',
- pipelineEditorPath: '',
- deletePath: '',
- simpleViewer: {},
- richViewer: null,
- webPath: '',
- },
- ],
- },
- },
- },
+ project: DEFAULT_BLOB_INFO,
};
},
computed: {
@@ -132,7 +90,7 @@ export default {
return this.$apollo.queries.project.loading;
},
isBinaryFileType() {
- return this.isBinary || this.blobInfo.simpleViewer?.fileType !== 'text';
+ return this.isBinary || this.blobInfo.simpleViewer?.fileType !== TEXT_FILE_TYPE;
},
blobInfo() {
const nodes = this.project?.repository?.blobs?.nodes || [];
@@ -151,11 +109,16 @@ export default {
},
blobViewer() {
const { fileType } = this.viewer;
- return loadViewer(fileType);
+ return this.shouldLoadLegacyViewer ? null : loadViewer(fileType, this.isUsingLfs);
},
- viewerProps() {
- const { fileType } = this.viewer;
- return viewerProps(fileType, this.blobInfo);
+ shouldLoadLegacyViewer() {
+ return this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs;
+ },
+ legacyViewerLoaded() {
+ return (
+ (this.activeViewerType === SIMPLE_BLOB_VIEWER && this.legacySimpleViewer) ||
+ (this.activeViewerType === RICH_BLOB_VIEWER && this.legacyRichViewer)
+ );
},
canLock() {
const { pushCode, downloadCode } = this.project.userPermissions;
@@ -183,18 +146,23 @@ export default {
? this.blobInfo.ideForkAndEditPath
: this.blobInfo.forkAndEditPath;
},
+ isUsingLfs() {
+ return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE;
+ },
},
methods: {
- loadLegacyViewer(type) {
- if (this.legacyViewerLoaded(type)) {
+ loadLegacyViewer() {
+ if (this.legacyViewerLoaded) {
return;
}
+ const type = this.activeViewerType;
+
this.isLoadingLegacyViewer = true;
axios
.get(`${this.blobInfo.webPath}?format=json&viewer=${type}`)
.then(({ data: { html, binary } }) => {
- if (type === 'simple') {
+ if (type === SIMPLE_BLOB_VIEWER) {
this.legacySimpleViewer = html;
} else {
this.legacyRichViewer = html;
@@ -205,12 +173,6 @@ export default {
})
.catch(() => this.displayError());
},
- legacyViewerLoaded(type) {
- return (
- (type === SIMPLE_BLOB_VIEWER && this.legacySimpleViewer) ||
- (type === RICH_BLOB_VIEWER && this.legacyRichViewer)
- );
- },
displayError() {
createFlash({ message: __('An error occurred while loading the file. Please try again.') });
},
@@ -218,7 +180,7 @@ export default {
this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER;
if (!this.blobViewer) {
- this.loadLegacyViewer(this.activeViewerType);
+ this.loadLegacyViewer();
}
},
editBlob(target) {
@@ -243,10 +205,11 @@ export default {
<div v-if="blobInfo && !isLoading" class="file-holder">
<blob-header
:blob="blobInfo"
- :hide-viewer-switcher="!hasRichViewer || isBinaryFileType"
+ :hide-viewer-switcher="!hasRichViewer || isBinaryFileType || isUsingLfs"
:is-binary="isBinaryFileType"
:active-viewer-type="viewer.type"
:has-render-error="hasRenderError"
+ :show-path="false"
@viewer-changed="switchViewer"
>
<template #actions>
@@ -303,7 +266,7 @@ export default {
:hide-line-numbers="true"
:loading="isLoadingLegacyViewer"
/>
- <component :is="blobViewer" v-else v-bind="viewerProps" class="blob-viewer" />
+ <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
index 48fa33eb558..f7b318c64d9 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
@@ -9,19 +9,17 @@ export default {
GlLink,
},
props: {
- fileName: {
- type: String,
+ blob: {
+ type: Object,
required: true,
},
- filePath: {
- type: String,
- required: true,
- },
- fileSize: {
- type: Number,
- required: false,
- default: 0,
- },
+ },
+ data() {
+ return {
+ fileName: this.blob.name,
+ filePath: this.blob.rawPath,
+ fileSize: this.blob.rawSize || 0,
+ };
},
computed: {
downloadFileSize() {
diff --git a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
index 83d36209bb3..5027f7877aa 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
@@ -1,15 +1,17 @@
<script>
export default {
props: {
- url: {
- type: String,
- required: true,
- },
- alt: {
- type: String,
+ blob: {
+ type: Object,
required: true,
},
},
+ data() {
+ return {
+ url: this.blob.rawPath,
+ alt: this.blob.name,
+ };
+ },
};
</script>
<template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index 8f6f2d15215..e942f59e7d8 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -1,48 +1,19 @@
-export const loadViewer = (type) => {
- switch (type) {
- case 'empty':
- return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue');
- case 'text':
- return gon.features.highlightJs
- ? () =>
- import(
- /* webpackChunkName: 'blob_text_viewer' */ '~/vue_shared/components/source_viewer.vue'
- )
- : null;
- case 'download':
- return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue');
- case 'image':
- return () => import(/* webpackChunkName: 'blob_image_viewer' */ './image_viewer.vue');
- case 'video':
- return () => import(/* webpackChunkName: 'blob_video_viewer' */ './video_viewer.vue');
- case 'pdf':
- return () => import(/* webpackChunkName: 'blob_pdf_viewer' */ './pdf_viewer.vue');
- default:
- return null;
- }
+const viewers = {
+ download: () => import('./download_viewer.vue'),
+ image: () => import('./image_viewer.vue'),
+ video: () => import('./video_viewer.vue'),
+ empty: () => import('./empty_viewer.vue'),
+ text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'),
+ pdf: () => import('./pdf_viewer.vue'),
+ lfs: () => import('./lfs_viewer.vue'),
};
-export const viewerProps = (type, blob) => {
- return {
- text: {
- content: blob.rawTextBlob,
- autoDetect: true, // We'll eventually disable autoDetect and pass the language explicitly to reduce the footprint (https://gitlab.com/gitlab-org/gitlab/-/issues/348145)
- },
- download: {
- fileName: blob.name,
- filePath: blob.rawPath,
- fileSize: blob.rawSize,
- },
- image: {
- url: blob.rawPath,
- alt: blob.name,
- },
- video: {
- url: blob.rawPath,
- },
- pdf: {
- url: blob.rawPath,
- fileSize: blob.rawSize,
- },
- }[type];
+export const loadViewer = (type, isUsingLfs) => {
+ let viewer = viewers[type];
+
+ if (!viewer && isUsingLfs) {
+ viewer = viewers.lfs;
+ }
+
+ return viewer;
};
diff --git a/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue
new file mode 100644
index 00000000000..6dc7e10662e
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ lfsText: __(
+ 'This content could not be displayed because it is stored in LFS. You can %{linkStart}download it%{linkEnd} instead.',
+ ),
+ },
+ components: {
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ fileName: this.blob.name,
+ filePath: this.blob.rawPath,
+ };
+ },
+};
+</script>
+
+<template>
+ <div class="gl-text-center gl-py-13 gl-bg-gray-50" data-type="lfs">
+ <gl-sprintf :message="$options.i18n.lfsText">
+ <template #link="{ content }">
+ <gl-link :href="filePath" :download="fileName" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
index 803a357df52..c3df5984426 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
@@ -11,17 +11,17 @@ export default {
tooLargeButtonText: __('Download PDF'),
},
props: {
- url: {
- type: String,
- required: true,
- },
- fileSize: {
- type: Number,
+ blob: {
+ type: Object,
required: true,
},
},
data() {
- return { totalPages: 0 };
+ return {
+ url: this.blob.rawPath,
+ fileSize: this.blob.rawSize,
+ totalPages: 0,
+ };
},
computed: {
tooLargeToDisplay() {
diff --git a/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue
index dec0c4802ca..260b831f4d1 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue
@@ -1,11 +1,16 @@
<script>
export default {
props: {
- url: {
- type: String,
+ blob: {
+ type: Object,
required: true,
},
},
+ data() {
+ return {
+ url: this.blob.rawPath,
+ };
+ },
};
</script>
<template>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 43e114a91d3..c3d121505b6 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -139,8 +139,10 @@ export default {
/>
<gl-button
v-if="commit.descriptionHtml"
+ v-gl-tooltip
:class="{ open: showDescription }"
- :aria-label="__('Show commit description')"
+ :title="__('Toggle commit description')"
+ :aria-label="__('Toggle commit description')"
class="text-expander gl-vertical-align-bottom!"
icon="ellipsis_h"
@click="toggleShowDescription"
diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue
index fb0e505a16e..8a081944600 100644
--- a/app/assets/javascripts/repository/components/table/parent_row.vue
+++ b/app/assets/javascripts/repository/components/table/parent_row.vue
@@ -1,10 +1,13 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
GlLoadingIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
commitRef: {
type: String,
@@ -41,7 +44,13 @@ export default {
<template>
<tr class="tree-item">
- <td colspan="3" class="tree-item-file-name" @click.self="clickRow">
+ <td
+ v-gl-tooltip.left.viewport
+ :title="__('Go to parent directory')"
+ colspan="3"
+ class="tree-item-file-name"
+ @click.self="clickRow"
+ >
<gl-loading-icon
v-if="parentPath === loadingPath"
size="sm"
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 8fcec5fb893..7aac35e7613 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -195,6 +195,7 @@ export default {
projectPath: this.projectPath,
filePath: this.path,
ref: this.ref,
+ shouldFetchRawText: Boolean(this.glFeatures.highlightJs),
});
},
apolloQuery(query, variables) {
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index d01757d6141..e206d9bfbd2 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -25,3 +25,54 @@ export const PDF_MAX_FILE_SIZE = 10000000; // 10 MB
export const PDF_MAX_PAGE_LIMIT = 50;
export const ROW_APPEAR_DELAY = 150;
+
+export const DEFAULT_BLOB_INFO = {
+ userPermissions: {
+ pushCode: false,
+ downloadCode: false,
+ createMergeRequestIn: false,
+ forkProject: false,
+ },
+ pathLocks: {
+ nodes: [],
+ },
+ repository: {
+ empty: true,
+ blobs: {
+ nodes: [
+ {
+ name: '',
+ size: '',
+ rawTextBlob: '',
+ type: '',
+ fileType: '',
+ tooLarge: false,
+ path: '',
+ editBlobPath: '',
+ ideEditPath: '',
+ forkAndEditPath: '',
+ ideForkAndEditPath: '',
+ storedExternally: false,
+ externalStorage: '',
+ environmentFormattedExternalUrl: '',
+ environmentExternalUrlForRouteMap: '',
+ canModifyBlob: false,
+ canCurrentUserPushToBranch: false,
+ archived: false,
+ rawPath: '',
+ externalStorageUrl: '',
+ replacePath: '',
+ pipelineEditorPath: '',
+ deletePath: '',
+ simpleViewer: {},
+ richViewer: null,
+ webPath: '',
+ },
+ ],
+ },
+ },
+};
+
+export const TEXT_FILE_TYPE = 'text';
+
+export const LFS_STORAGE = 'lfs';
diff --git a/app/assets/javascripts/repository/fragmentTypes.json b/app/assets/javascripts/repository/fragmentTypes.json
deleted file mode 100644
index 949ebca432b..00000000000
--- a/app/assets/javascripts/repository/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"INTERFACE","name":"Entry","possibleTypes":[{"name":"Blob"},{"name":"Submodule"},{"name":"TreeEntry"}]}]}}
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
index 96d712ce9b4..29aabe1b00f 100644
--- a/app/assets/javascripts/repository/graphql.js
+++ b/app/assets/javascripts/repository/graphql.js
@@ -1,19 +1,11 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
-import introspectionQueryResultData from './fragmentTypes.json';
import { fetchLogsTree } from './log_tree';
Vue.use(VueApollo);
-// We create a fragment matcher so that we can create a fragment from an interface
-// Without this, Apollo throws a heuristic fragment matcher warning
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
const defaultClient = createDefaultClient(
{
Query: {
@@ -43,7 +35,6 @@ const defaultClient = createDefaultClient(
},
{
cacheConfig: {
- fragmentMatcher,
dataIdFromObject: (obj) => {
/* eslint-disable @gitlab/require-i18n-strings */
// eslint-disable-next-line no-underscore-dangle
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index ae20a0f0bc4..78323fdc5f4 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -1,6 +1,11 @@
#import "ee_else_ce/repository/queries/path_locks.fragment.graphql"
-query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
+query getBlobInfo(
+ $projectPath: ID!
+ $filePath: String!
+ $ref: String!
+ $shouldFetchRawText: Boolean!
+) {
project(fullPath: $projectPath) {
userPermissions {
pushCode
@@ -18,18 +23,22 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
name
size
rawSize
- rawTextBlob
+ rawTextBlob @include(if: $shouldFetchRawText)
fileType
+ language
path
editBlobPath
ideEditPath
forkAndEditPath
ideForkAndEditPath
+ environmentFormattedExternalUrl
+ environmentExternalUrlForRouteMap
canModifyBlob
canCurrentUserPushToBranch
archived
storedExternally
externalStorage
+ externalStorageUrl
rawPath
replacePath
pipelineEditorPath
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index ee9533bbec3..009afe03ea6 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -1,7 +1,7 @@
/* eslint-disable func-names, consistent-return, no-param-reassign */
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import { hide, fixTitle } from '~/tooltips';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
@@ -80,7 +80,7 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
hide($this);
if (!triggered) {
- Cookies.set('collapsed_gutter', $('.right-sidebar').hasClass('right-sidebar-collapsed'));
+ setCookie('collapsed_gutter', $('.right-sidebar').hasClass('right-sidebar-collapsed'));
}
};
diff --git a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
new file mode 100644
index 00000000000..2795ddbbbcb
--- /dev/null
+++ b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import RunnerEditButton from '../components/runner_edit_button.vue';
+import RunnerPauseButton from '../components/runner_pause_button.vue';
+import RunnerHeader from '../components/runner_header.vue';
+import RunnerDetails from '../components/runner_details.vue';
+import { I18N_FETCH_ERROR } from '../constants';
+import getRunnerQuery from '../graphql/get_runner.query.graphql';
+import { captureException } from '../sentry_utils';
+
+export default {
+ name: 'AdminRunnerShowApp',
+ components: {
+ RunnerEditButton,
+ RunnerPauseButton,
+ RunnerHeader,
+ RunnerDetails,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runnerId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ runner: null,
+ };
+ },
+ apollo: {
+ runner: {
+ query: getRunnerQuery,
+ variables() {
+ return {
+ id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
+ };
+ },
+ error(error) {
+ createAlert({ message: I18N_FETCH_ERROR });
+
+ this.reportToSentry(error);
+ },
+ },
+ },
+ computed: {
+ canUpdate() {
+ return this.runner.userPermissions?.updateRunner;
+ },
+ },
+ errorCaptured(error) {
+ this.reportToSentry(error);
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <runner-header v-if="runner" :runner="runner">
+ <template #actions>
+ <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
+ <runner-pause-button v-if="canUpdate" :runner="runner" />
+ </template>
+ </runner-header>
+
+ <runner-details :runner="runner" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/admin_runner_show/index.js b/app/assets/javascripts/runner/admin_runner_show/index.js
new file mode 100644
index 00000000000..a781898cf8d
--- /dev/null
+++ b/app/assets/javascripts/runner/admin_runner_show/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import AdminRunnerShowApp from './admin_runner_show_app.vue';
+
+Vue.use(VueApollo);
+
+export const initAdminRunnerShow = (selector = '#js-admin-runner-show') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { runnerId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(AdminRunnerShowApp, {
+ props: {
+ runnerId,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
index bb2bac531a7..a968d4029f8 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -99,7 +99,10 @@ export default {
allRunnersCount: {
...runnersCountSmartQuery,
variables() {
- return this.countVariables;
+ return {
+ ...this.countVariables,
+ type: null,
+ };
},
},
instanceRunnersCount: {
@@ -276,7 +279,11 @@ export default {
</gl-link>
</template>
</runner-list>
- <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
+ <runner-pagination
+ v-model="search.pagination"
+ class="gl-mt-3"
+ :page-info="runners.pageInfo"
+ />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/cells/link_cell.vue b/app/assets/javascripts/runner/components/cells/link_cell.vue
new file mode 100644
index 00000000000..2843ddbacaf
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/link_cell.vue
@@ -0,0 +1,27 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ props: {
+ href: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ component() {
+ if (this.href) {
+ return GlLink;
+ }
+ return 'span';
+ },
+ },
+};
+</script>
+
+<template>
+ <component :is="component" :href="href" v-bind="$attrs" v-on="$listeners">
+ <slot></slot>
+ </component>
+</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
index 0934508c87f..ae9c774f2a2 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -1,16 +1,14 @@
<script>
import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { __, s__, sprintf } from '~/locale';
+import { s__, sprintf } from '~/locale';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
-import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import RunnerEditButton from '../runner_edit_button.vue';
+import RunnerPauseButton from '../runner_pause_button.vue';
import RunnerDeleteModal from '../runner_delete_modal.vue';
-const I18N_EDIT = __('Edit');
-const I18N_PAUSE = __('Pause');
-const I18N_RESUME = __('Resume');
const I18N_DELETE = s__('Runners|Delete runner');
const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
@@ -19,6 +17,8 @@ export default {
components: {
GlButton,
GlButtonGroup,
+ RunnerEditButton,
+ RunnerPauseButton,
RunnerDeleteModal,
},
directives: {
@@ -38,20 +38,6 @@ export default {
};
},
computed: {
- isActive() {
- return this.runner.active;
- },
- toggleActiveIcon() {
- return this.isActive ? 'pause' : 'play';
- },
- toggleActiveTitle() {
- if (this.updating) {
- // Prevent a "sticky" tooltip: If this button is disabled,
- // mouseout listeners don't run leaving the tooltip stuck
- return '';
- }
- return this.isActive ? I18N_PAUSE : I18N_RESUME;
- },
deleteTitle() {
if (this.deleting) {
// Prevent a "sticky" tooltip: If this button is disabled,
@@ -77,35 +63,6 @@ export default {
},
},
methods: {
- async onToggleActive() {
- this.updating = true;
- try {
- const toggledActive = !this.runner.active;
-
- const {
- data: {
- runnerUpdate: { errors },
- },
- } = await this.$apollo.mutate({
- mutation: runnerActionsUpdateMutation,
- variables: {
- input: {
- id: this.runner.id,
- active: toggledActive,
- },
- },
- });
-
- if (errors && errors.length) {
- throw new Error(errors.join(' '));
- }
- } catch (e) {
- this.onError(e);
- } finally {
- this.updating = false;
- }
- },
-
async onDelete() {
// Deleting stays "true" until this row is removed,
// should only change back if the operation fails.
@@ -147,7 +104,6 @@ export default {
captureException({ error, component: this.$options.name });
},
},
- I18N_EDIT,
I18N_DELETE,
};
</script>
@@ -161,23 +117,8 @@ export default {
See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
-->
- <gl-button
- v-if="canUpdate && runner.editAdminUrl"
- v-gl-tooltip.hover.viewport="$options.I18N_EDIT"
- :href="runner.editAdminUrl"
- :aria-label="$options.I18N_EDIT"
- icon="pencil"
- data-testid="edit-runner"
- />
- <gl-button
- v-if="canUpdate"
- v-gl-tooltip.hover.viewport="toggleActiveTitle"
- :aria-label="toggleActiveTitle"
- :icon="toggleActiveIcon"
- :loading="updating"
- data-testid="toggle-active-runner"
- @click="onToggleActive"
- />
+ <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
+ <runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" />
<gl-button
v-if="canDelete"
v-gl-tooltip.hover.viewport="deleteTitle"
diff --git a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
index 0e259807f98..54c35e483dc 100644
--- a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
+++ b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
@@ -11,8 +11,10 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
export default {
name: 'RunnerRegistrationTokenReset',
i18n: {
- modalTitle: __('Reset registration token'),
+ modalAction: s__('Runners|Reset token'),
+ modalCancel: __('Cancel'),
modalCopy: __('Are you sure you want to reset the registration token?'),
+ modalTitle: __('Reset registration token'),
},
components: {
GlDropdownItem,
@@ -30,7 +32,7 @@ export default {
default: null,
},
},
- modalID: 'token-reset-modal',
+ modalId: 'token-reset-modal',
props: {
type: {
type: String,
@@ -111,10 +113,19 @@ export default {
};
</script>
<template>
- <gl-dropdown-item v-gl-modal="$options.modalID">
+ <gl-dropdown-item v-gl-modal="$options.modalId">
{{ __('Reset registration token') }}
<gl-modal
- :modal-id="$options.modalID"
+ size="sm"
+ :modal-id="$options.modalId"
+ :action-primary="{
+ text: $options.i18n.modalAction,
+ attributes: [{ variant: 'danger' }],
+ }"
+ :action-secondary="{
+ text: $options.i18n.modalCancel,
+ attributes: [{ variant: 'default' }],
+ }"
:title="$options.i18n.modalTitle"
@primary="handleModalPrimary"
>
diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/runner/components/runner_assigned_item.vue
new file mode 100644
index 00000000000..ea8074199a6
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_assigned_item.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlAvatar, GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAvatar,
+ GlLink,
+ },
+ props: {
+ href: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ fullName: {
+ type: String,
+ required: true,
+ },
+ avatarUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center gl-py-5">
+ <gl-link :href="href" data-testid="item-avatar" class="gl-text-decoration-none! gl-mr-3">
+ <gl-avatar shape="rect" :entity-name="name" :alt="name" :src="avatarUrl" :size="48" />
+ </gl-link>
+
+ <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue
new file mode 100644
index 00000000000..b1234818b7e
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_detail.vue
@@ -0,0 +1,50 @@
+<script>
+import { __ } from '~/locale';
+
+/**
+ * Usage:
+ *
+ * With a `value` prop:
+ *
+ * <runner-detail label="Field Name" :value="value" />
+ *
+ * Or a `value` slot:
+ *
+ * <runner-detail label="Field Name">
+ * <template #value>
+ * <strong>{{ value }}</strong>
+ * </template>
+ * </runner-detail>
+ *
+ */
+export default {
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ default: null,
+ required: false,
+ },
+ emptyValue: {
+ type: String,
+ default: __('None'),
+ required: false,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-pb-4">
+ <dt class="gl-mr-2">{{ label }}</dt>
+ <dd class="gl-mb-0">
+ <template v-if="value || $slots.value">
+ <slot name="value">{{ value }}</slot>
+ </template>
+ <span v-else class="gl-text-gray-500">{{ emptyValue }}</span>
+ </dd>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue
new file mode 100644
index 00000000000..b6a5ffc7a64
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_details.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlBadge, GlTabs, GlTab, GlIntersperse } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
+import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+import { formatJobCount } from '../utils';
+import RunnerDetail from './runner_detail.vue';
+import RunnerGroups from './runner_groups.vue';
+import RunnerProjects from './runner_projects.vue';
+import RunnerJobs from './runner_jobs.vue';
+import RunnerTags from './runner_tags.vue';
+
+export default {
+ components: {
+ GlBadge,
+ GlTabs,
+ GlTab,
+ GlIntersperse,
+ RunnerDetail,
+ RunnerGroups,
+ RunnerProjects,
+ RunnerJobs,
+ RunnerTags,
+ TimeAgo,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ maximumTimeout() {
+ const { maximumTimeout } = this.runner;
+ if (typeof maximumTimeout !== 'number') {
+ return null;
+ }
+ return timeIntervalInWords(maximumTimeout);
+ },
+ configTextProtected() {
+ if (this.runner.accessLevel === ACCESS_LEVEL_REF_PROTECTED) {
+ return s__('Runners|Protected');
+ }
+ return null;
+ },
+ configTextUntagged() {
+ if (this.runner.runUntagged) {
+ return s__('Runners|Runs untagged jobs');
+ }
+ return null;
+ },
+ isGroupRunner() {
+ return this.runner?.runnerType === GROUP_TYPE;
+ },
+ isProjectRunner() {
+ return this.runner?.runnerType === PROJECT_TYPE;
+ },
+ jobCount() {
+ return formatJobCount(this.runner?.jobCount);
+ },
+ },
+ ACCESS_LEVEL_REF_PROTECTED,
+};
+</script>
+
+<template>
+ <gl-tabs>
+ <gl-tab>
+ <template #title>{{ s__('Runners|Details') }}</template>
+
+ <template v-if="runner">
+ <div class="gl-pt-4">
+ <dl class="gl-mb-0" data-testid="runner-details-list">
+ <runner-detail :label="s__('Runners|Description')" :value="runner.description" />
+ <runner-detail
+ :label="s__('Runners|Last contact')"
+ :empty-value="s__('Runners|Never contacted')"
+ >
+ <template #value>
+ <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" />
+ </template>
+ </runner-detail>
+ <runner-detail :label="s__('Runners|Version')" :value="runner.version" />
+ <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" />
+ <runner-detail :label="s__('Runners|Configuration')">
+ <template #value>
+ <gl-intersperse v-if="configTextProtected || configTextUntagged">
+ <span v-if="configTextProtected">{{ configTextProtected }}</span>
+ <span v-if="configTextUntagged">{{ configTextUntagged }}</span>
+ </gl-intersperse>
+ </template>
+ </runner-detail>
+ <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" />
+ <runner-detail :label="s__('Runners|Tags')">
+ <template #value>
+ <runner-tags
+ v-if="runner.tagList && runner.tagList.length"
+ class="gl-vertical-align-middle"
+ :tag-list="runner.tagList"
+ size="sm"
+ />
+ </template>
+ </runner-detail>
+ </dl>
+ </div>
+
+ <runner-groups v-if="isGroupRunner" :runner="runner" />
+ <runner-projects v-if="isProjectRunner" :runner="runner" />
+ </template>
+ </gl-tab>
+ <gl-tab>
+ <template #title>
+ {{ s__('Runners|Jobs') }}
+ <gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm">
+ {{ jobCount }}
+ </gl-badge>
+ </template>
+
+ <runner-jobs v-if="runner" :runner="runner" />
+ </gl-tab>
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_edit_button.vue b/app/assets/javascripts/runner/components/runner_edit_button.vue
new file mode 100644
index 00000000000..b115be09e69
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_edit_button.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const I18N_EDIT = __('Edit');
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ I18N_EDIT,
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip="$options.I18N_EDIT"
+ v-bind="$attrs"
+ :aria-label="$options.I18N_EDIT"
+ icon="pencil"
+ v-on="$listeners"
+ />
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_groups.vue b/app/assets/javascripts/runner/components/runner_groups.vue
new file mode 100644
index 00000000000..c3b35bd52a9
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_groups.vue
@@ -0,0 +1,37 @@
+<script>
+import RunnerAssignedItem from './runner_assigned_item.vue';
+
+export default {
+ components: {
+ RunnerAssignedItem,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ groups() {
+ return this.runner.groups?.nodes || [];
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid">
+ <h3 class="gl-font-lg gl-mt-5 gl-mb-0">{{ s__('Runners|Assigned Group') }}</h3>
+ <template v-if="groups.length">
+ <runner-assigned-item
+ v-for="group in groups"
+ :key="group.id"
+ :href="group.webUrl"
+ :name="group.name"
+ :full-name="group.fullName"
+ :avatar-url="group.avatarUrl"
+ />
+ </template>
+ <span v-else class="gl-text-gray-500">{{ __('None') }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_header.vue b/app/assets/javascripts/runner/components/runner_header.vue
index 09f58df7bd0..abc07cec1ad 100644
--- a/app/assets/javascripts/runner/components/runner_header.vue
+++ b/app/assets/javascripts/runner/components/runner_header.vue
@@ -1,19 +1,23 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { sprintf } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { I18N_DETAILS_TITLE } from '../constants';
+import { I18N_DETAILS_TITLE, I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants';
import RunnerTypeBadge from './runner_type_badge.vue';
import RunnerStatusBadge from './runner_status_badge.vue';
export default {
components: {
+ GlIcon,
GlSprintf,
TimeAgo,
RunnerTypeBadge,
RunnerStatusBadge,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
runner: {
type: Object,
@@ -29,24 +33,36 @@ export default {
return sprintf(I18N_DETAILS_TITLE, { runner_id: id });
},
},
+ I18N_LOCKED_RUNNER_DESCRIPTION,
};
</script>
<template>
- <div class="gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100">
- <runner-status-badge :runner="runner" />
- <runner-type-badge v-if="runner" :type="runner.runnerType" />
- <template v-if="runner.createdAt">
- <gl-sprintf :message="__('%{runner} created %{timeago}')">
- <template #runner>
- <strong>{{ heading }}</strong>
- </template>
- <template #timeago>
- <time-ago :time="runner.createdAt" />
- </template>
- </gl-sprintf>
- </template>
- <template v-else>
- <strong>{{ heading }}</strong>
- </template>
+ <div
+ class="gl-display-flex gl-align-items-center gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ >
+ <div>
+ <runner-status-badge :runner="runner" />
+ <runner-type-badge v-if="runner" :type="runner.runnerType" />
+ <template v-if="runner.createdAt">
+ <gl-sprintf :message="__('%{runner} created %{timeago}')">
+ <template #runner>
+ <strong>{{ heading }}</strong>
+ <gl-icon
+ v-if="runner.locked"
+ v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
+ name="lock"
+ :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
+ />
+ </template>
+ <template #timeago>
+ <time-ago :time="runner.createdAt" />
+ </template>
+ </gl-sprintf>
+ </template>
+ <template v-else>
+ <strong>{{ heading }}</strong>
+ </template>
+ </div>
+ <div class="gl-ml-auto gl-flex-shrink-0"><slot name="actions"></slot></div>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue
new file mode 100644
index 00000000000..c13e7e90168
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_jobs.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlSkeletonLoading } from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import getRunnerJobsQuery from '../graphql/get_runner_jobs.query.graphql';
+import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants';
+import { captureException } from '../sentry_utils';
+import { getPaginationVariables } from '../utils';
+import RunnerJobsTable from './runner_jobs_table.vue';
+import RunnerPagination from './runner_pagination.vue';
+
+export default {
+ name: 'RunnerJobs',
+ components: {
+ GlSkeletonLoading,
+ RunnerJobsTable,
+ RunnerPagination,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ jobs: {
+ items: [],
+ pageInfo: {},
+ },
+ pagination: {
+ page: 1,
+ },
+ };
+ },
+ apollo: {
+ jobs: {
+ query: getRunnerJobsQuery,
+ variables() {
+ return this.variables;
+ },
+ update({ runner }) {
+ return {
+ items: runner?.jobs?.nodes || [],
+ pageInfo: runner?.jobs?.pageInfo || {},
+ };
+ },
+ error(error) {
+ createAlert({ message: I18N_FETCH_ERROR });
+ this.reportToSentry(error);
+ },
+ },
+ },
+ computed: {
+ variables() {
+ const { id } = this.runner;
+ return {
+ id,
+ ...getPaginationVariables(this.pagination, RUNNER_DETAILS_JOBS_PAGE_SIZE),
+ };
+ },
+ loading() {
+ return this.$apollo.queries.jobs.loading;
+ },
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+ },
+ I18N_NO_JOBS_FOUND,
+};
+</script>
+
+<template>
+ <div class="gl-pt-3">
+ <gl-skeleton-loading v-if="loading" class="gl-py-5" />
+ <runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" />
+ <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p>
+
+ <runner-pagination v-model="pagination" :disabled="loading" :page-info="jobs.pageInfo" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_jobs_table.vue b/app/assets/javascripts/runner/components/runner_jobs_table.vue
new file mode 100644
index 00000000000..7817577bab0
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_jobs_table.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlTableLite } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import RunnerTags from '~/runner/components/runner_tags.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { tableField } from '../utils';
+import LinkCell from './cells/link_cell.vue';
+
+export default {
+ components: {
+ CiBadge,
+ GlTableLite,
+ LinkCell,
+ RunnerTags,
+ TimeAgo,
+ },
+ props: {
+ jobs: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ trAttr(job) {
+ if (job?.id) {
+ return { 'data-testid': `job-row-${getIdFromGraphQLId(job.id)}` };
+ }
+ return {};
+ },
+ jobId(job) {
+ return getIdFromGraphQLId(job.id);
+ },
+ jobPath(job) {
+ return job.detailedStatus?.detailsPath;
+ },
+ projectName(job) {
+ return job.pipeline?.project?.name;
+ },
+ projectWebUrl(job) {
+ return job.pipeline?.project?.webUrl;
+ },
+ commitShortSha(job) {
+ return job.shortSha;
+ },
+ commitPath(job) {
+ return job.commitPath;
+ },
+ },
+ fields: [
+ tableField({ key: 'status', label: s__('Job|Status') }),
+ tableField({ key: 'job', label: __('Job') }),
+ tableField({ key: 'project', label: __('Project') }),
+ tableField({ key: 'commit', label: __('Commit') }),
+ tableField({ key: 'finished_at', label: s__('Job|Finished at') }),
+ tableField({ key: 'tags', label: s__('Runners|Tags') }),
+ ],
+};
+</script>
+
+<template>
+ <gl-table-lite
+ :items="jobs"
+ :fields="$options.fields"
+ :tbody-tr-attr="trAttr"
+ primary-key="id"
+ stacked="md"
+ fixed
+ >
+ <template #cell(status)="{ item = {} }">
+ <ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" />
+ </template>
+
+ <template #cell(job)="{ item = {} }">
+ <link-cell :href="jobPath(item)"> #{{ jobId(item) }} </link-cell>
+ </template>
+
+ <template #cell(project)="{ item = {} }">
+ <link-cell :href="projectWebUrl(item)">{{ projectName(item) }}</link-cell>
+ </template>
+
+ <template #cell(commit)="{ item = {} }">
+ <link-cell :href="commitPath(item)"> {{ commitShortSha(item) }}</link-cell>
+ </template>
+
+ <template #cell(tags)="{ item = {} }">
+ <runner-tags :tag-list="item.tags" />
+ </template>
+
+ <template #cell(finished_at)="{ item = {} }">
+ <time-ago v-if="item.finishedAt" :time="item.finishedAt" />
+ </template>
+ </gl-table-lite>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index 023308dbac2..bb36882d3ae 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -2,31 +2,14 @@
import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { formatNumber, __, s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import { RUNNER_JOB_COUNT_LIMIT } from '../constants';
+import { formatJobCount, tableField } from '../utils';
import RunnerActionsCell from './cells/runner_actions_cell.vue';
import RunnerSummaryCell from './cells/runner_summary_cell.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue';
-const tableField = ({ key, label = '', thClasses = [] }) => {
- return {
- key,
- label,
- thClass: [
- 'gl-bg-transparent!',
- 'gl-border-b-solid!',
- 'gl-border-b-gray-100!',
- 'gl-border-b-1!',
- ...thClasses,
- ],
- tdAttr: {
- 'data-testid': `td-${key}`,
- },
- };
-};
-
export default {
components: {
GlTable,
@@ -54,10 +37,7 @@ export default {
},
methods: {
formatJobCount(jobCount) {
- if (jobCount > RUNNER_JOB_COUNT_LIMIT) {
- return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`;
- }
- return formatNumber(jobCount);
+ return formatJobCount(jobCount);
},
runnerTrAttr(runner) {
if (runner) {
@@ -70,9 +50,9 @@ export default {
},
fields: [
tableField({ key: 'status', label: s__('Runners|Status') }),
- tableField({ key: 'summary', label: s__('Runners|Runner ID'), thClasses: ['gl-lg-w-25p'] }),
+ tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'version', label: __('Version') }),
- tableField({ key: 'ipAddress', label: __('IP Address') }),
+ tableField({ key: 'ipAddress', label: __('IP') }),
tableField({ key: 'jobCount', label: __('Jobs') }),
tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'contactedAt', label: __('Last contact') }),
diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/runner/components/runner_pagination.vue
index 8645b90f5cd..b683a7f2330 100644
--- a/app/assets/javascripts/runner/components/runner_pagination.vue
+++ b/app/assets/javascripts/runner/components/runner_pagination.vue
@@ -29,7 +29,14 @@ export default {
},
methods: {
handlePageChange(page) {
- if (page > this.value.page) {
+ if (page === 1) {
+ // Small optimization for first page
+ // If we have loaded using "first",
+ // page is already cached.
+ this.$emit('input', {
+ page,
+ });
+ } else if (page > this.value.page) {
this.$emit('input', {
page,
after: this.pageInfo.endCursor,
@@ -47,11 +54,12 @@ export default {
<template>
<gl-pagination
+ v-bind="$attrs"
:value="value.page"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
- class="gl-pagination gl-mt-3"
+ class="gl-pagination"
@input="handlePageChange"
/>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/runner/components/runner_pause_button.vue
new file mode 100644
index 00000000000..a8b259f5b90
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_pause_button.vue
@@ -0,0 +1,122 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql';
+import { createAlert } from '~/flash';
+import { captureException } from '~/runner/sentry_utils';
+import { I18N_PAUSE, I18N_RESUME } from '../constants';
+
+export default {
+ name: 'RunnerPauseButton',
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ compact: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ updating: false,
+ };
+ },
+ computed: {
+ isActive() {
+ return this.runner.active;
+ },
+ icon() {
+ return this.isActive ? 'pause' : 'play';
+ },
+ label() {
+ return this.isActive ? I18N_PAUSE : I18N_RESUME;
+ },
+ buttonContent() {
+ if (this.compact) {
+ return null;
+ }
+ return this.label;
+ },
+ ariaLabel() {
+ if (this.compact) {
+ return this.label;
+ }
+ return null;
+ },
+ tooltip() {
+ // Only show tooltip when compact.
+ // Also prevent a "sticky" tooltip: If this button is
+ // disabled, mouseout listeners don't run leaving the tooltip stuck
+ if (this.compact && !this.updating) {
+ return this.label;
+ }
+ return '';
+ },
+ },
+ methods: {
+ async onToggle() {
+ this.updating = true;
+ try {
+ const input = {
+ id: this.runner.id,
+ active: !this.isActive,
+ };
+
+ const {
+ data: {
+ runnerUpdate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: runnerToggleActiveMutation,
+ variables: {
+ input,
+ },
+ });
+
+ if (errors && errors.length) {
+ throw new Error(errors.join(' '));
+ }
+ } catch (e) {
+ this.onError(e);
+ } finally {
+ this.updating = false;
+ }
+ },
+ onError(error) {
+ const { message } = error;
+ createAlert({ message });
+
+ this.reportToSentry(error);
+ },
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover.viewport="tooltip"
+ v-bind="$attrs"
+ :aria-label="ariaLabel"
+ :icon="icon"
+ :loading="updating"
+ @click="onToggle"
+ v-on="$listeners"
+ >
+ <!--
+ Use <template v-if> to ensure a square button is shown when compact: true.
+ Sending empty content will still show a distorted/rectangular button.
+ -->
+ <template v-if="buttonContent">{{ buttonContent }}</template>
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue
new file mode 100644
index 00000000000..c4065a24ff2
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_projects.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlSkeletonLoading } from '@gitlab/ui';
+import { sprintf, formatNumber } from '~/locale';
+import { createAlert } from '~/flash';
+import getRunnerProjectsQuery from '../graphql/get_runner_projects.query.graphql';
+import {
+ I18N_ASSIGNED_PROJECTS,
+ I18N_NONE,
+ I18N_FETCH_ERROR,
+ RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+} from '../constants';
+import { getPaginationVariables } from '../utils';
+import { captureException } from '../sentry_utils';
+import RunnerAssignedItem from './runner_assigned_item.vue';
+import RunnerPagination from './runner_pagination.vue';
+
+export default {
+ name: 'RunnerProjects',
+ components: {
+ GlSkeletonLoading,
+ RunnerAssignedItem,
+ RunnerPagination,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ projects: {
+ items: [],
+ pageInfo: {},
+ count: 0,
+ },
+ pagination: {
+ page: 1,
+ },
+ };
+ },
+ apollo: {
+ projects: {
+ query: getRunnerProjectsQuery,
+ variables() {
+ return this.variables;
+ },
+ update(data) {
+ const { runner } = data;
+ return {
+ count: runner?.projectCount || 0,
+ items: runner?.projects?.nodes || [],
+ pageInfo: runner?.projects?.pageInfo || {},
+ };
+ },
+ error(error) {
+ createAlert({ message: I18N_FETCH_ERROR });
+
+ this.reportToSentry(error);
+ },
+ },
+ },
+ computed: {
+ variables() {
+ const { id } = this.runner;
+ return {
+ id,
+ ...getPaginationVariables(this.pagination, RUNNER_DETAILS_PROJECTS_PAGE_SIZE),
+ };
+ },
+ loading() {
+ return this.$apollo.queries.projects.loading;
+ },
+ heading() {
+ return sprintf(I18N_ASSIGNED_PROJECTS, {
+ projectCount: formatNumber(this.projects.count),
+ });
+ },
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+ },
+ I18N_NONE,
+};
+</script>
+
+<template>
+ <div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid">
+ <h3 class="gl-font-lg gl-mt-5 gl-mb-0">
+ {{ heading }}
+ </h3>
+
+ <gl-skeleton-loading v-if="loading" class="gl-py-5" />
+ <template v-else-if="projects.items.length">
+ <runner-assigned-item
+ v-for="(project, i) in projects.items"
+ :key="project.id"
+ :class="{ 'gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid': i !== 0 }"
+ :href="project.webUrl"
+ :name="project.name"
+ :full-name="project.nameWithNamespace"
+ :avatar-url="project.avatarUrl"
+ />
+ </template>
+ <span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span>
+
+ <runner-pagination v-model="pagination" :disabled="loading" :page-info="projects.pageInfo" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue
index 8da5e33076f..797d2a35b2c 100644
--- a/app/assets/javascripts/runner/components/runner_tags.vue
+++ b/app/assets/javascripts/runner/components/runner_tags.vue
@@ -20,7 +20,7 @@ export default {
};
</script>
<template>
- <div>
+ <span>
<runner-tag
v-for="tag in tagList"
:key="tag"
@@ -28,5 +28,5 @@ export default {
:tag="tag"
:size="size"
/>
- </div>
+ </span>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_type_tabs.vue b/app/assets/javascripts/runner/components/runner_type_tabs.vue
index b767dafaccf..25ed6600dc9 100644
--- a/app/assets/javascripts/runner/components/runner_type_tabs.vue
+++ b/app/assets/javascripts/runner/components/runner_type_tabs.vue
@@ -1,27 +1,21 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
-import { s__ } from '~/locale';
import { searchValidator } from '~/runner/runner_search_utils';
-import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+import {
+ INSTANCE_TYPE,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+ I18N_ALL_TYPES,
+ I18N_INSTANCE_TYPE,
+ I18N_GROUP_TYPE,
+ I18N_PROJECT_TYPE,
+} from '../constants';
-const tabs = [
- {
- title: s__('Runners|All'),
- runnerType: null,
- },
- {
- title: s__('Runners|Instance'),
- runnerType: INSTANCE_TYPE,
- },
- {
- title: s__('Runners|Group'),
- runnerType: GROUP_TYPE,
- },
- {
- title: s__('Runners|Project'),
- runnerType: PROJECT_TYPE,
- },
-];
+const I18N_TAB_TITLES = {
+ [INSTANCE_TYPE]: I18N_INSTANCE_TYPE,
+ [GROUP_TYPE]: I18N_GROUP_TYPE,
+ [PROJECT_TYPE]: I18N_PROJECT_TYPE,
+};
export default {
components: {
@@ -29,12 +23,34 @@ export default {
GlTab,
},
props: {
+ runnerTypes: {
+ type: Array,
+ required: false,
+ default: () => [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE],
+ },
value: {
type: Object,
required: true,
validator: searchValidator,
},
},
+ computed: {
+ tabs() {
+ const tabs = this.runnerTypes.map((runnerType) => ({
+ title: I18N_TAB_TITLES[runnerType],
+ runnerType,
+ }));
+
+ // Always add a "All" tab that resets filters
+ return [
+ {
+ title: I18N_ALL_TYPES,
+ runnerType: null,
+ },
+ ...tabs,
+ ];
+ },
+ },
methods: {
onTabSelected({ runnerType }) {
this.$emit('input', {
@@ -47,13 +63,12 @@ export default {
return runnerType === this.value.runnerType;
},
},
- tabs,
};
</script>
<template>
<gl-tabs v-bind="$attrs" data-testid="runner-type-tabs">
<gl-tab
- v-for="tab in $options.tabs"
+ v-for="tab in tabs"
:key="`${tab.runnerType}`"
:active="isTabActive(tab)"
@click="onTabSelected(tab)"
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index ce8019ffaa0..1544efaaae2 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -1,13 +1,20 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000;
-export const GROUP_RUNNER_COUNT_LIMIT = 1000;
+
+export const RUNNER_DETAILS_PROJECTS_PAGE_SIZE = 5;
+export const RUNNER_DETAILS_JOBS_PAGE_SIZE = 30;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
// Type
+
+export const I18N_ALL_TYPES = s__('Runners|All');
+export const I18N_INSTANCE_TYPE = s__('Runners|Instance');
+export const I18N_GROUP_TYPE = s__('Runners|Group');
+export const I18N_PROJECT_TYPE = s__('Runners|Project');
export const I18N_INSTANCE_RUNNER_DESCRIPTION = s__('Runners|Available to all projects');
export const I18N_GROUP_RUNNER_DESCRIPTION = s__(
'Runners|Available to all projects and subgroups in the group',
@@ -28,9 +35,21 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__(
'Runners|No contact from this runner in over 3 months',
);
+// Active flag
+export const I18N_PAUSE = __('Pause');
+export const I18N_RESUME = __('Resume');
+
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
+// Runner details
+
+export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
+export const I18N_NONE = __('None');
+export const I18N_NO_JOBS_FOUND = s__('Runner|This runner has not run any jobs.');
+
+// Styles
+
export const RUNNER_TAG_BADGE_VARIANT = 'neutral';
export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
index f7bcd683718..986dd16b992 100644
--- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
@@ -28,10 +28,12 @@ query getGroupRunners(
edges {
webUrl
node {
+ __typename
...RunnerNode
}
}
pageInfo {
+ __typename
...PageInfo
}
}
diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
index 59c55eae060..f6ce8281c64 100644
--- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
@@ -4,6 +4,7 @@ query getRunner($id: CiRunnerID!) {
# We have an id in deeply nested fragment
# eslint-disable-next-line @graphql-eslint/require-id-when-available
runner(id: $id) {
+ __typename
...RunnerDetails
}
}
diff --git a/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql
new file mode 100644
index 00000000000..2b1decd3ddd
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql
@@ -0,0 +1,36 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, $after: String) {
+ runner(id: $id) {
+ id
+ projectCount
+ jobs(before: $before, after: $after, first: $first, last: $last) {
+ nodes {
+ id
+ detailedStatus {
+ # fields for `<ci-badge>`
+ id
+ detailsPath
+ group
+ icon
+ text
+ }
+ pipeline {
+ id
+ project {
+ id
+ name
+ webUrl
+ }
+ }
+ shortSha
+ commitPath
+ tags
+ finishedAt
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql
new file mode 100644
index 00000000000..f97237b8267
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql
@@ -0,0 +1,26 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getRunnerProjects(
+ $id: CiRunnerID!
+ $first: Int
+ $last: Int
+ $before: String
+ $after: String
+) {
+ runner(id: $id) {
+ id
+ projectCount
+ projects(first: $first, last: $last, before: $before, after: $after) {
+ nodes {
+ id
+ avatarUrl
+ name
+ nameWithNamespace
+ webUrl
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
index 05df399fa6a..ed03a8c34ae 100644
--- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
@@ -29,6 +29,7 @@ query getRunners(
editAdminUrl
}
pageInfo {
+ __typename
...PageInfo
}
}
diff --git a/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql
deleted file mode 100644
index 547cc43907c..00000000000
--- a/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql
+++ /dev/null
@@ -1,14 +0,0 @@
-#import "~/runner/graphql/runner_node.fragment.graphql"
-
-# Mutation for updates within the runners list via action
-# buttons (play, pause, ...), loads attributes shown in the
-# runner list.
-
-mutation runnerActionsUpdate($input: RunnerUpdateInput!) {
- runnerUpdate(input: $input) {
- runner {
- ...RunnerNode
- }
- errors
- }
-}
diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
index 8e968343b9b..74760bbaa07 100644
--- a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
@@ -8,7 +8,27 @@ fragment RunnerDetailsShared on CiRunner {
ipAddress
description
maximumTimeout
+ jobCount
tagList
createdAt
status(legacyMode: null)
+ contactedAt
+ version
+ editAdminUrl
+ userPermissions {
+ updateRunner
+ deleteRunner
+ }
+ groups {
+ # Only a single group can be loaded here, while projects
+ # are loaded separately using the query with pagination
+ # parameters `get_runner_projects.query.graphql`.
+ nodes {
+ id
+ avatarUrl
+ name
+ fullName
+ webUrl
+ }
+ }
}
diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
index 4a771d779dc..fbdef817f2f 100644
--- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
@@ -1,4 +1,5 @@
fragment RunnerNode on CiRunner {
+ __typename
id
description
runnerType
diff --git a/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql
new file mode 100644
index 00000000000..9b15570dbc0
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql
@@ -0,0 +1,12 @@
+# Mutation executed for the pause/resume button in the
+# runner list and details views.
+
+mutation runnerToggleActive($input: RunnerUpdateInput!) {
+ runnerUpdate(input: $input) {
+ runner {
+ id
+ active
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
index 3a7b58e3dc9..c4ee0ad4dfb 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -1,9 +1,9 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlBadge, GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
-import { formatNumber, sprintf, s__ } from '~/locale';
+import { formatNumber } from '~/locale';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
@@ -18,7 +18,7 @@ import {
I18N_FETCH_ERROR,
GROUP_FILTERED_SEARCH_NAMESPACE,
GROUP_TYPE,
- GROUP_RUNNER_COUNT_LIMIT,
+ PROJECT_TYPE,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_STALE,
@@ -46,6 +46,7 @@ const runnersCountSmartQuery = {
export default {
name: 'GroupRunnersApp',
components: {
+ GlBadge,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -131,6 +132,33 @@ export default {
};
},
},
+ allRunnersCount: {
+ ...runnersCountSmartQuery,
+ variables() {
+ return {
+ ...this.countVariables,
+ type: null,
+ };
+ },
+ },
+ groupRunnersCount: {
+ ...runnersCountSmartQuery,
+ variables() {
+ return {
+ ...this.countVariables,
+ type: GROUP_TYPE,
+ };
+ },
+ },
+ projectRunnersCount: {
+ ...runnersCountSmartQuery,
+ variables() {
+ return {
+ ...this.countVariables,
+ type: PROJECT_TYPE,
+ };
+ },
+ },
},
computed: {
variables() {
@@ -139,23 +167,17 @@ export default {
groupFullPath: this.groupFullPath,
};
},
+ countVariables() {
+ // Exclude pagination variables, leave only filters variables
+ const { sort, before, last, after, first, ...countVariables } = this.variables;
+ return countVariables;
+ },
runnersLoading() {
return this.$apollo.queries.runners.loading;
},
noRunnersFound() {
return !this.runnersLoading && !this.runners.items.length;
},
- groupRunnersCount() {
- if (this.groupRunnersLimitedCount > GROUP_RUNNER_COUNT_LIMIT) {
- return `${formatNumber(GROUP_RUNNER_COUNT_LIMIT)}+`;
- }
- return formatNumber(this.groupRunnersLimitedCount);
- },
- runnerCountMessage() {
- return sprintf(s__('Runners|Runners in this group: %{groupRunnersCount}'), {
- groupRunnersCount: this.groupRunnersCount,
- });
- },
searchTokens() {
return [statusTokenConfig];
},
@@ -179,10 +201,31 @@ export default {
this.reportToSentry(error);
},
methods: {
+ tabCount({ runnerType }) {
+ let count;
+ switch (runnerType) {
+ case null:
+ count = this.allRunnersCount;
+ break;
+ case GROUP_TYPE:
+ count = this.groupRunnersCount;
+ break;
+ case PROJECT_TYPE:
+ count = this.projectRunnersCount;
+ break;
+ default:
+ return null;
+ }
+ if (typeof count === 'number') {
+ return formatNumber(count);
+ }
+ return null;
+ },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
+ TABS_RUNNER_TYPES: [GROUP_TYPE, PROJECT_TYPE],
GROUP_TYPE,
};
</script>
@@ -198,9 +241,17 @@ export default {
<div class="gl-display-flex gl-align-items-center">
<runner-type-tabs
v-model="search"
+ :runner-types="$options.TABS_RUNNER_TYPES"
content-class="gl-display-none"
nav-class="gl-border-none!"
- />
+ >
+ <template #title="{ tab }">
+ {{ tab.title }}
+ <gl-badge v-if="tabCount(tab)" class="gl-ml-1" size="sm">
+ {{ tabCount(tab) }}
+ </gl-badge>
+ </template>
+ </runner-type-tabs>
<registration-dropdown
class="gl-ml-auto"
diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js
index c80a73948b8..fe141332be3 100644
--- a/app/assets/javascripts/runner/runner_search_utils.js
+++ b/app/assets/javascripts/runner/runner_search_utils.js
@@ -18,6 +18,7 @@ import {
RUNNER_PAGE_SIZE,
STATUS_NEVER_CONTACTED,
} from './constants';
+import { getPaginationVariables } from './utils';
/**
* The filters and sorting of the runners are built around
@@ -184,30 +185,27 @@ export const fromSearchToVariables = ({
sort = null,
pagination = {},
} = {}) => {
- const variables = {};
+ const filterVariables = {};
const queryObj = filterToQueryObject(processFilters(filters), {
filteredSearchTermKey: PARAM_KEY_SEARCH,
});
- [variables.status] = queryObj[PARAM_KEY_STATUS] || [];
- variables.search = queryObj[PARAM_KEY_SEARCH];
- variables.tagList = queryObj[PARAM_KEY_TAG];
+ [filterVariables.status] = queryObj[PARAM_KEY_STATUS] || [];
+ filterVariables.search = queryObj[PARAM_KEY_SEARCH];
+ filterVariables.tagList = queryObj[PARAM_KEY_TAG];
if (runnerType) {
- variables.type = runnerType;
+ filterVariables.type = runnerType;
}
if (sort) {
- variables.sort = sort;
+ filterVariables.sort = sort;
}
- if (pagination.before) {
- variables.before = pagination.before;
- variables.last = RUNNER_PAGE_SIZE;
- } else {
- variables.after = pagination.after;
- variables.first = RUNNER_PAGE_SIZE;
- }
+ const paginationVariables = getPaginationVariables(pagination, RUNNER_PAGE_SIZE);
- return variables;
+ return {
+ ...filterVariables,
+ ...paginationVariables,
+ };
};
diff --git a/app/assets/javascripts/runner/utils.js b/app/assets/javascripts/runner/utils.js
new file mode 100644
index 00000000000..6e4c8c45e7b
--- /dev/null
+++ b/app/assets/javascripts/runner/utils.js
@@ -0,0 +1,72 @@
+import { formatNumber } from '~/locale';
+import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
+import { RUNNER_JOB_COUNT_LIMIT } from './constants';
+
+/**
+ * Formats a job count, limited to a max number
+ *
+ * @param {Number} jobCount
+ * @returns Formatted string
+ */
+export const formatJobCount = (jobCount) => {
+ if (typeof jobCount !== 'number') {
+ return '';
+ }
+ if (jobCount > RUNNER_JOB_COUNT_LIMIT) {
+ return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`;
+ }
+ return formatNumber(jobCount);
+};
+
+/**
+ * Returns a GlTable fields with a given key and label
+ *
+ * @param {Object} options
+ * @returns Field object to add to GlTable fields
+ */
+export const tableField = ({ key, label = '', thClasses = [] }) => {
+ return {
+ key,
+ label,
+ thClass: [DEFAULT_TH_CLASSES, ...thClasses],
+ tdAttr: {
+ 'data-testid': `td-${key}`,
+ },
+ };
+};
+
+/**
+ * Returns variables for a GraphQL query that uses keyset
+ * pagination.
+ *
+ * https://docs.gitlab.com/ee/development/graphql_guide/pagination.html#keyset-pagination
+ *
+ * @param {Object} pagination - Contains before, after, page
+ * @param {Number} pageSize
+ * @returns Variables
+ */
+export const getPaginationVariables = (pagination, pageSize = 10) => {
+ const { before, after } = pagination;
+
+ // first + after: Next page
+ // Get the first N items after item X
+ if (after) {
+ return {
+ after,
+ first: pageSize,
+ };
+ }
+
+ // last + before: Prev page
+ // Get the first N items before item X, when you click on Prev
+ if (before) {
+ return {
+ before,
+ last: pageSize,
+ };
+ }
+
+ // first page
+ // Get the first N items
+ return { first: pageSize };
+};
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index d228f77f27d..c48c9067250 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -50,7 +50,7 @@ export default {
TrainingProviderList,
},
mixins: [glFeatureFlagsMixin()],
- inject: ['projectPath'],
+ inject: ['projectFullPath'],
props: {
augmentedSecurityFeatures: {
type: Array,
@@ -107,14 +107,14 @@ export default {
shouldShowAutoDevopsEnabledAlert() {
return (
this.autoDevopsEnabled &&
- !this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectPath)
+ !this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectFullPath)
);
},
},
methods: {
dismissAutoDevopsEnabledAlert() {
const dismissedProjects = new Set(this.autoDevopsEnabledAlertDismissedProjects);
- dismissedProjects.add(this.projectPath);
+ dismissedProjects.add(this.projectFullPath);
this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects);
},
onError(message) {
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 034dba29196..81d222438e3 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -123,7 +123,7 @@ export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath(
export const CORPUS_MANAGEMENT_NAME = __('Corpus Management');
export const CORPUS_MANAGEMENT_DESCRIPTION = s__(
- 'SecurityConfiguration|Manage corpus files used as mutation sources in coverage fuzzing.',
+ 'SecurityConfiguration|Manage corpus files used as seed inputs with coverage-guided fuzzing.',
);
export const CORPUS_MANAGEMENT_CONFIG_TEXT = s__('SecurityConfiguration|Manage corpus');
@@ -159,15 +159,6 @@ export const securityFeatures = [
helpPath: SAST_HELP_PATH,
configurationHelpPath: SAST_CONFIG_HELP_PATH,
type: REPORT_TYPE_SAST,
- // This field is currently hardcoded because SAST is always available.
- // It will eventually come from the Backend, the progress is tracked in
- // https://gitlab.com/gitlab-org/gitlab/-/issues/331622
- available: true,
-
- // This field is currently hardcoded because SAST can always be enabled via MR
- // It will eventually come from the Backend, the progress is tracked in
- // https://gitlab.com/gitlab-org/gitlab/-/issues/331621
- canEnableByMergeRequest: true,
},
{
name: SAST_IAC_NAME,
@@ -176,15 +167,6 @@ export const securityFeatures = [
helpPath: SAST_IAC_HELP_PATH,
configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH,
type: REPORT_TYPE_SAST_IAC,
-
- // This field is currently hardcoded because SAST IaC is always available.
- // It will eventually come from the Backend, the progress is tracked in
- // https://gitlab.com/gitlab-org/gitlab/-/issues/331622
- available: true,
-
- // This field will eventually come from the backend, the progress is
- // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621
- canEnableByMergeRequest: true,
},
{
name: DAST_NAME,
@@ -206,10 +188,6 @@ export const securityFeatures = [
helpPath: DEPENDENCY_SCANNING_HELP_PATH,
configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH,
type: REPORT_TYPE_DEPENDENCY_SCANNING,
-
- // This field will eventually come from the backend, the progress is
- // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621
- canEnableByMergeRequest: true,
},
{
name: CONTAINER_SCANNING_NAME,
@@ -231,16 +209,6 @@ export const securityFeatures = [
helpPath: SECRET_DETECTION_HELP_PATH,
configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH,
type: REPORT_TYPE_SECRET_DETECTION,
-
- // This field is currently hardcoded because Secret Detection is always
- // available. It will eventually come from the Backend, the progress is
- // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/333113
- available: true,
-
- // This field is currently hardcoded because SAST can always be enabled via MR
- // It will eventually come from the Backend, the progress is tracked in
- // https://gitlab.com/gitlab-org/gitlab/-/issues/331621
- canEnableByMergeRequest: true,
},
{
name: API_FUZZING_NAME,
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index 33d72b54f86..1c37d8008de 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -24,9 +24,6 @@ export default {
enabled() {
return this.available && this.feature.configured;
},
- hasStatus() {
- return !this.available || typeof this.feature.configured === 'boolean';
- },
shortName() {
return this.feature.shortName ?? this.feature.name;
},
@@ -93,19 +90,17 @@ export default {
data-testid="feature-status"
:data-qa-selector="`${feature.type}_status`"
>
- <template v-if="hasStatus">
- <template v-if="enabled">
- <gl-icon name="check-circle-filled" />
- <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
- </template>
+ <template v-if="enabled">
+ <gl-icon name="check-circle-filled" />
+ <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
+ </template>
- <template v-else-if="available">
- {{ $options.i18n.notEnabled }}
- </template>
+ <template v-else-if="available">
+ {{ $options.i18n.notEnabled }}
+ </template>
- <template v-else>
- {{ $options.i18n.availableWith }}
- </template>
+ <template v-else>
+ {{ $options.i18n.availableWith }}
</template>
</div>
</div>
diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
index ca4596e16b3..539e2bff17c 100644
--- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue
+++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
@@ -1,6 +1,13 @@
<script>
import { GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import Tracking from '~/tracking';
import { __ } from '~/locale';
+import {
+ TRACK_TOGGLE_TRAINING_PROVIDER_ACTION,
+ TRACK_TOGGLE_TRAINING_PROVIDER_LABEL,
+} from '~/security_configuration/constants';
+import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql';
import configureSecurityTrainingProvidersMutation from '../graphql/configure_security_training_providers.mutation.graphql';
@@ -21,10 +28,19 @@ export default {
GlLink,
GlSkeletonLoader,
},
- inject: ['projectPath'],
+ mixins: [Tracking.mixin()],
+ inject: ['projectFullPath'],
apollo: {
securityTrainingProviders: {
query: securityTrainingProvidersQuery,
+ variables() {
+ return {
+ fullPath: this.projectFullPath,
+ };
+ },
+ update({ project }) {
+ return project?.securityTrainingProviders;
+ },
error() {
this.errorMessage = this.$options.i18n.providerQueryErrorMessage;
},
@@ -33,8 +49,9 @@ export default {
data() {
return {
errorMessage: '',
- toggleLoading: false,
+ providerLoadingId: null,
securityTrainingProviders: [],
+ hasTouchedConfiguration: false,
};
},
computed: {
@@ -42,33 +59,59 @@ export default {
return this.$apollo.queries.securityTrainingProviders.loading;
},
},
+ created() {
+ const unwatchConfigChance = this.$watch('hasTouchedConfiguration', () => {
+ this.dismissFeaturePromotionCallout();
+ unwatchConfigChance();
+ });
+ },
methods: {
- toggleProvider(selectedProviderId) {
- const toggledProviders = this.securityTrainingProviders.map((provider) => ({
- ...provider,
- ...(provider.id === selectedProviderId && { isEnabled: !provider.isEnabled }),
- }));
+ async dismissFeaturePromotionCallout() {
+ try {
+ const {
+ data: {
+ userCalloutCreate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: dismissUserCalloutMutation,
+ variables: {
+ input: {
+ featureName: 'security_training_feature_promotion',
+ },
+ },
+ });
- const enabledProviderIds = toggledProviders
- .filter(({ isEnabled }) => isEnabled)
- .map(({ id }) => id);
+ // handle errors reported from the backend
+ if (errors?.length > 0) {
+ throw new Error(errors[0]);
+ }
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ },
+ toggleProvider(provider) {
+ const { isEnabled } = provider;
+ const toggledIsEnabled = !isEnabled;
- this.storeEnabledProviders(toggledProviders, enabledProviderIds);
+ this.trackProviderToggle(provider.id, toggledIsEnabled);
+ this.storeProvider({ ...provider, isEnabled: toggledIsEnabled });
},
- async storeEnabledProviders(toggledProviders, enabledProviderIds) {
- this.toggleLoading = true;
+ async storeProvider({ id, isEnabled, isPrimary }) {
+ this.providerLoadingId = id;
try {
const {
data: {
- configureSecurityTrainingProviders: { errors = [] },
+ securityTrainingUpdate: { errors = [] },
},
} = await this.$apollo.mutate({
mutation: configureSecurityTrainingProvidersMutation,
variables: {
input: {
- enabledProviders: enabledProviderIds,
- fullPath: this.projectPath,
+ projectPath: this.projectFullPath,
+ providerId: id,
+ isEnabled,
+ isPrimary,
},
},
});
@@ -77,12 +120,23 @@ export default {
// throwing an error here means we can handle scenarios within the `catch` block below
throw new Error();
}
+
+ this.hasTouchedConfiguration = true;
} catch {
this.errorMessage = this.$options.i18n.configMutationErrorMessage;
} finally {
- this.toggleLoading = false;
+ this.providerLoadingId = null;
}
},
+ trackProviderToggle(providerId, providerIsEnabled) {
+ this.track(TRACK_TOGGLE_TRAINING_PROVIDER_ACTION, {
+ label: TRACK_TOGGLE_TRAINING_PROVIDER_LABEL,
+ property: providerId,
+ extra: {
+ providerIsEnabled,
+ },
+ });
+ },
},
i18n,
};
@@ -104,25 +158,21 @@ export default {
</gl-skeleton-loader>
</div>
<ul v-else class="gl-list-style-none gl-m-0 gl-p-0">
- <li
- v-for="{ id, isEnabled, name, description, url } in securityTrainingProviders"
- :key="id"
- class="gl-mb-6"
- >
+ <li v-for="provider in securityTrainingProviders" :key="provider.id" class="gl-mb-6">
<gl-card>
<div class="gl-display-flex">
<gl-toggle
- :value="isEnabled"
+ :value="provider.isEnabled"
:label="__('Training mode')"
label-position="hidden"
- :is-loading="toggleLoading"
- @change="toggleProvider(id)"
+ :is-loading="providerLoadingId === provider.id"
+ @change="toggleProvider(provider)"
/>
<div class="gl-ml-5">
- <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ name }}</h3>
+ <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ provider.name }}</h3>
<p>
- {{ description }}
- <gl-link :href="url" target="_blank">{{ __('Learn more.') }}</gl-link>
+ {{ provider.description }}
+ <gl-link :href="provider.url" target="_blank">{{ __('Learn more.') }}</gl-link>
</p>
</div>
</div>
diff --git a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue
index 79e6b9d7a23..891d7bf2eb0 100644
--- a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue
+++ b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue
@@ -1,11 +1,16 @@
<script>
import { GlBanner } from '@gitlab/ui';
import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+
+export const SECURITY_UPGRADE_BANNER = 'security_upgrade_banner';
+export const UPGRADE_OR_FREE_TRIAL = 'upgrade_or_free_trial';
export default {
components: {
GlBanner,
},
+ mixins: [Tracking.mixin({ property: SECURITY_UPGRADE_BANNER })],
inject: ['upgradePath'],
i18n: {
title: s__('SecurityConfiguration|Secure your project'),
@@ -22,6 +27,17 @@ export default {
],
buttonText: s__('SecurityConfiguration|Upgrade or start a free trial'),
},
+ mounted() {
+ this.track('render', { label: SECURITY_UPGRADE_BANNER });
+ },
+ methods: {
+ bannerClosed() {
+ this.track('dismiss_banner', { label: SECURITY_UPGRADE_BANNER });
+ },
+ bannerButtonClicked() {
+ this.track('click_button', { label: UPGRADE_OR_FREE_TRIAL });
+ },
+ },
};
</script>
@@ -31,6 +47,8 @@ export default {
:button-text="$options.i18n.buttonText"
:button-link="upgradePath"
variant="introduction"
+ @close="bannerClosed"
+ @primary="bannerButtonClicked"
v-on="$listeners"
>
<p>{{ $options.i18n.bodyStart }}</p>
diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js
new file mode 100644
index 00000000000..dc76436e91d
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/constants.js
@@ -0,0 +1,2 @@
+export const TRACK_TOGGLE_TRAINING_PROVIDER_ACTION = 'toggle_security_training_provider';
+export const TRACK_TOGGLE_TRAINING_PROVIDER_LABEL = 'update_security_training_provider';
diff --git a/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql b/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql
index 660e0fadafb..3528bfaf7b8 100644
--- a/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql
+++ b/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql
@@ -1,9 +1,10 @@
-mutation configureSecurityTrainingProviders($input: configureSecurityTrainingProvidersInput!) {
- configureSecurityTrainingProviders(input: $input) @client {
+mutation updateSecurityTraining($input: SecurityTrainingUpdateInput!) {
+ securityTrainingUpdate(input: $input) {
errors
- securityTrainingProviders {
+ training {
id
isEnabled
+ isPrimary
}
}
}
diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql
index e0c5715ba8e..2baeda318f3 100644
--- a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql
+++ b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql
@@ -1,9 +1,13 @@
-query Query {
- securityTrainingProviders @client {
- name
+query getSecurityTrainingProviders($fullPath: ID!) {
+ project(fullPath: $fullPath) {
id
- description
- isEnabled
- url
+ securityTrainingProviders {
+ name
+ id
+ description
+ isPrimary
+ isEnabled
+ url
+ }
}
}
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index 24c0585e077..8416692dd27 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -5,7 +5,6 @@ import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils';
import SecurityConfigurationApp from './components/app.vue';
import { securityFeatures, complianceFeatures } from './components/constants';
import { augmentFeatures } from './utils';
-import tempResolvers from './resolver';
export const initSecurityConfiguration = (el) => {
if (!el) {
@@ -15,11 +14,11 @@ export const initSecurityConfiguration = (el) => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(tempResolvers),
+ defaultClient: createDefaultClient(),
});
const {
- projectPath,
+ projectFullPath,
upgradePath,
features,
latestPipelinePath,
@@ -38,7 +37,7 @@ export const initSecurityConfiguration = (el) => {
el,
apolloProvider,
provide: {
- projectPath,
+ projectFullPath,
upgradePath,
autoDevopsHelpPagePath,
autoDevopsPath,
diff --git a/app/assets/javascripts/security_configuration/resolver.js b/app/assets/javascripts/security_configuration/resolver.js
deleted file mode 100644
index 93175d4a3d1..00000000000
--- a/app/assets/javascripts/security_configuration/resolver.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import produce from 'immer';
-import { __ } from '~/locale';
-import securityTrainingProvidersQuery from './graphql/security_training_providers.query.graphql';
-
-// Note: this is behind a feature flag and only a placeholder
-// until the actual GraphQL fields have been added
-// https://gitlab.com/gitlab-org/gi tlab/-/issues/346480
-export default {
- Query: {
- securityTrainingProviders() {
- return [
- {
- __typename: 'SecurityTrainingProvider',
- id: 101,
- name: __('Kontra'),
- description: __('Interactive developer security education.'),
- url: 'https://application.security/',
- isEnabled: false,
- },
- {
- __typename: 'SecurityTrainingProvider',
- id: 102,
- name: __('SecureCodeWarrior'),
- description: __('Security training with guide and learning pathways.'),
- url: 'https://www.securecodewarrior.com/',
- isEnabled: true,
- },
- ];
- },
- },
-
- Mutation: {
- configureSecurityTrainingProviders: (
- _,
- { input: { enabledProviders, primaryProvider } },
- { cache },
- ) => {
- const sourceData = cache.readQuery({
- query: securityTrainingProvidersQuery,
- });
-
- const data = produce(sourceData.securityTrainingProviders, (draftData) => {
- /* eslint-disable no-param-reassign */
- draftData.forEach((provider) => {
- provider.isPrimary = provider.id === primaryProvider;
- provider.isEnabled =
- provider.id === primaryProvider || enabledProviders.includes(provider.id);
- });
- });
- return {
- __typename: 'configureSecurityTrainingProvidersPayload',
- securityTrainingProviders: data,
- };
- },
- },
-};
diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js
index 47231497b8f..173560f8370 100644
--- a/app/assets/javascripts/security_configuration/utils.js
+++ b/app/assets/javascripts/security_configuration/utils.js
@@ -1,6 +1,19 @@
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants';
+/**
+ * This function takes in 3 arrays of objects, securityFeatures, complianceFeatures and features.
+ * securityFeatures and complianceFeatures are static arrays living in the constants.
+ * features is dynamic and coming from the backend.
+ * This function builds a superset of those arrays.
+ * It looks for matching keys within the dynamic and the static arrays
+ * and will enrich the objects with the available static data.
+ * @param [{}] securityFeatures
+ * @param [{}] complianceFeatures
+ * @param [{}] features
+ * @returns {Object} Object with enriched features from constants divided into Security and Compliance Features
+ */
+
export const augmentFeatures = (securityFeatures, complianceFeatures, features = []) => {
const featuresByType = features.reduce((acc, feature) => {
acc[feature.type] = convertObjectPropsToCamelCase(feature, { deep: true });
diff --git a/app/assets/javascripts/serverless/components/empty_state.vue b/app/assets/javascripts/serverless/components/empty_state.vue
index 8a5ed9debb3..6d1cea519c4 100644
--- a/app/assets/javascripts/serverless/components/empty_state.vue
+++ b/app/assets/javascripts/serverless/components/empty_state.vue
@@ -1,6 +1,8 @@
<script>
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { mapState } from 'vuex';
+import { s__ } from '~/locale';
+import { DEPRECATION_POST_LINK } from '../constants';
export default {
components: {
@@ -8,6 +10,13 @@ export default {
GlLink,
GlSprintf,
},
+ i18n: {
+ title: s__('Serverless|Getting started with serverless'),
+ description: s__(
+ 'Serverless|Serverless was %{postLinkStart}deprecated%{postLinkEnd}. But if you opt to use it, you must install Knative in your Kubernetes cluster first. %{linkStart}Learn more.%{linkEnd}',
+ ),
+ },
+ deprecationPostLink: DEPRECATION_POST_LINK,
computed: {
...mapState(['emptyImagePath', 'helpPath']),
},
@@ -15,18 +24,12 @@ export default {
</script>
<template>
- <gl-empty-state
- :svg-path="emptyImagePath"
- :title="s__('Serverless|Getting started with serverless')"
- >
+ <gl-empty-state :svg-path="emptyImagePath" :title="$options.i18n.title">
<template #description>
- <gl-sprintf
- :message="
- s__(
- 'Serverless|In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. %{linkStart}More information%{linkEnd}',
- )
- "
- >
+ <gl-sprintf :message="$options.i18n.description">
+ <template #postLink="{ content }">
+ <gl-link :href="$options.deprecationPostLink" target="_blank">{{ content }}</gl-link>
+ </template>
<template #link="{ content }">
<gl-link :href="helpPath">{{ content }}</gl-link>
</template>
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index b2d7aa75051..e9461aa3ead 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,8 +1,14 @@
<script>
-import { GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import {
+ GlLink,
+ GlAlert,
+ GlSprintf,
+ GlLoadingIcon,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { sprintf, s__ } from '~/locale';
-import { CHECKING_INSTALLED } from '../constants';
+import { CHECKING_INSTALLED, DEPRECATION_POST_LINK } from '../constants';
import EmptyState from './empty_state.vue';
import EnvironmentRow from './environment_row.vue';
@@ -11,11 +17,14 @@ export default {
EnvironmentRow,
EmptyState,
GlLink,
+ GlAlert,
+ GlSprintf,
GlLoadingIcon,
},
directives: {
SafeHtml,
},
+ deprecationPostLink: DEPRECATION_POST_LINK,
computed: {
...mapState(['installed', 'isLoading', 'hasFunctionData', 'helpPath', 'statusPath']),
...mapGetters(['getFunctions']),
@@ -65,6 +74,17 @@ export default {
<template>
<section id="serverless-functions" class="flex-grow">
+ <gl-alert class="gl-mt-6" variant="warning" :dismissible="false">
+ <gl-sprintf
+ :message="s__('Serverless|Serverless was %{linkStart}deprecated%{linkEnd} in GitLab 14.3.')"
+ ><template #link="{ content }"
+ ><gl-link :href="$options.deprecationPostLink" target="_blank">{{
+ content
+ }}</gl-link></template
+ ></gl-sprintf
+ >
+ </gl-alert>
+
<gl-loading-icon v-if="checkingInstalled" size="lg" class="gl-mt-3 gl-mb-3" />
<div v-else-if="isInstalled">
diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js
index 2fa15e56ccb..42c9ee983b4 100644
--- a/app/assets/javascripts/serverless/constants.js
+++ b/app/assets/javascripts/serverless/constants.js
@@ -5,3 +5,6 @@ export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-ax
export const CHECKING_INSTALLED = 'checking'; // The backend is still determining whether or not Knative is installed
export const TIMEOUT = 'timeout';
+
+export const DEPRECATION_POST_LINK =
+ 'https://about.gitlab.com/releases/2021/09/22/gitlab-14-3-released/#gitlab-serverless';
diff --git a/app/assets/javascripts/serverless/survey_banner.js b/app/assets/javascripts/serverless/survey_banner.js
deleted file mode 100644
index 070e8f4c661..00000000000
--- a/app/assets/javascripts/serverless/survey_banner.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import Vue from 'vue';
-import { setUrlParams } from '~/lib/utils/url_utility';
-import SurveyBanner from './survey_banner.vue';
-
-let bannerInstance;
-const SURVEY_URL_BASE = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_00PfofFfY9s8Shf';
-
-export default function initServerlessSurveyBanner() {
- const el = document.querySelector('.js-serverless-survey-banner');
- if (el && !bannerInstance) {
- const { userName, userEmail } = el.dataset;
-
- // pre-populate survey fields
- const surveyUrl = setUrlParams(
- {
- Q_PopulateResponse: JSON.stringify({
- QID1: userEmail,
- QID2: userName,
- QID16: '1', // selects "yes" to "do you currently use GitLab?"
- }),
- },
- SURVEY_URL_BASE,
- );
-
- bannerInstance = new Vue({
- el,
- render(createElement) {
- return createElement(SurveyBanner, {
- props: {
- surveyUrl,
- },
- });
- },
- });
- }
-}
diff --git a/app/assets/javascripts/serverless/survey_banner.vue b/app/assets/javascripts/serverless/survey_banner.vue
deleted file mode 100644
index c48c294c0f7..00000000000
--- a/app/assets/javascripts/serverless/survey_banner.vue
+++ /dev/null
@@ -1,52 +0,0 @@
-<script>
-import { GlBanner } from '@gitlab/ui';
-import Cookies from 'js-cookie';
-import { parseBoolean } from '~/lib/utils/common_utils';
-
-export default {
- components: {
- GlBanner,
- },
- props: {
- surveyUrl: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- visible: true,
- };
- },
- created() {
- if (parseBoolean(Cookies.get('hide_serverless_survey'))) {
- this.visible = false;
- }
- },
- methods: {
- handleClose() {
- Cookies.set('hide_serverless_survey', 'true', { expires: 365 * 10 });
- this.visible = false;
- },
- },
-};
-</script>
-
-<template>
- <gl-banner
- v-if="visible"
- class="mt-4"
- :title="s__('Serverless|Help shape the future of Serverless at GitLab')"
- :button-text="s__('Serverless|Sign up for First Look')"
- :button-link="surveyUrl"
- @close="handleClose"
- >
- <p>
- {{
- s__(
- 'Serverless|We are continually striving to improve our Serverless functionality. As a Knative user, we would love to hear how we can make this experience better for you. Sign up for GitLab First Look today and we will be in touch shortly.',
- )
- }}
- </p>
- </gl-banner>
-</template>
diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js
index 2c6da5669ef..fe5b21713a2 100644
--- a/app/assets/javascripts/settings_panels.js
+++ b/app/assets/javascripts/settings_panels.js
@@ -18,8 +18,6 @@ export function expandSection(sectionArg) {
const $section = $(sectionArg);
$section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Collapse'));
- // eslint-disable-next-line @gitlab/no-global-event-off
- $section.find('.settings-content').off('scroll.expandSection').scrollTop(0);
$section.addClass('expanded');
if (!$section.hasClass('no-animate')) {
$section
@@ -32,7 +30,6 @@ export function closeSection(sectionArg) {
const $section = $(sectionArg);
$section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Expand'));
- $section.find('.settings-content').on('scroll.expandSection', () => expandSection($section));
$section.removeClass('expanded');
if (!$section.hasClass('no-animate')) {
$section
@@ -55,18 +52,16 @@ export default function initSettingsPanels() {
const $section = $(elm);
$section.on('click.toggleSection', '.js-settings-toggle', () => toggleSection($section));
- if (!isExpanded($section)) {
- $section.find('.settings-content').on('scroll.expandSection', () => {
- $section.removeClass('no-animate');
+ if (window.location.hash) {
+ const $target = $(window.location.hash);
+ if (
+ $target.length &&
+ !isExpanded($section) &&
+ ($section.is($target) || $section.find($target).length)
+ ) {
+ $section.addClass('no-animate');
expandSection($section);
- });
+ }
}
});
-
- if (window.location.hash) {
- const $target = $(window.location.hash);
- if ($target.length && $target.hasClass('settings')) {
- expandSection($target);
- }
- }
}
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 5b4dc20e9c8..18654b73ab3 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -96,6 +96,9 @@ export default {
return data.workspace?.issuable;
},
result({ data }) {
+ if (!data) {
+ return;
+ }
const issuable = data.workspace?.issuable;
if (issuable) {
this.selected = cloneDeep(issuable.assignees.nodes);
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
index dc0f2b54a7b..f234c5ea3c9 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -66,6 +66,9 @@ export default {
return data.workspace?.issuable?.confidential || false;
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.$emit('confidentialityUpdated', data.workspace?.issuable?.confidential);
},
error() {
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
index 404bcc3122a..be7a89c2869 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -86,6 +86,9 @@ export default {
return data.workspace?.issuable || {};
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.$emit(`${this.dateType}Updated`, data.workspace?.issuable?.[this.dateType]);
},
error() {
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index da792b3a2aa..ec23e817127 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -10,6 +10,7 @@ import {
GlIcon,
GlTooltipDirective,
} from '@gitlab/ui';
+import { kebabCase, snakeCase } from 'lodash';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issues/constants';
@@ -221,6 +222,12 @@ export default {
// MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311
return this.issuableAttribute === IssuableType.Epic;
},
+ formatIssuableAttribute() {
+ return {
+ kebab: kebabCase(this.issuableAttribute),
+ snake: snakeCase(this.issuableAttribute),
+ };
+ },
},
methods: {
updateAttribute(attributeId) {
@@ -300,21 +307,28 @@ export default {
<sidebar-editable-item
ref="editable"
:title="attributeTypeTitle"
- :data-testid="`${issuableAttribute}-edit`"
+ :data-testid="`${formatIssuableAttribute.kebab}-edit`"
:tracking="tracking"
:loading="updating || loading"
@open="handleOpen"
@close="handleClose"
>
<template #collapsed>
- <div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon">
- <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="attributeTypeIcon" />
- <span class="collapse-truncated-title">
- {{ attributeTitle }}
- </span>
- </div>
+ <slot name="value-collapsed" :current-attribute="currentAttribute">
+ <div
+ v-if="isClassicSidebar"
+ v-gl-tooltip.left.viewport
+ :title="attributeTypeTitle"
+ class="sidebar-collapsed-icon"
+ >
+ <gl-icon :aria-label="attributeTypeTitle" :name="attributeTypeIcon" />
+ <span class="collapse-truncated-title">
+ {{ attributeTitle }}
+ </span>
+ </div>
+ </slot>
<div
- :data-testid="`select-${issuableAttribute}`"
+ :data-testid="`select-${formatIssuableAttribute.kebab}`"
:class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'"
>
<span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span>
@@ -332,7 +346,7 @@ export default {
v-gl-tooltip="tooltipText"
class="gl-text-gray-900! gl-font-weight-bold"
:href="attributeUrl"
- :data-qa-selector="`${issuableAttribute}_link`"
+ :data-qa-selector="`${formatIssuableAttribute.snake}_link`"
>
{{ attributeTitle }}
<span v-if="isAttributeOverdue(currentAttribute)">{{ $options.i18n.expired }}</span>
@@ -354,7 +368,7 @@ export default {
>
<gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item
- :data-testid="`no-${issuableAttribute}-item`"
+ :data-testid="`no-${formatIssuableAttribute.kebab}-item`"
:is-check-item="true"
:is-checked="isAttributeChecked($options.noAttributeId)"
@click="updateAttribute($options.noAttributeId)"
@@ -384,7 +398,7 @@ export default {
:key="attrItem.id"
:is-check-item="true"
:is-checked="isAttributeChecked(attrItem.id)"
- :data-testid="`${issuableAttribute}-items`"
+ :data-testid="`${formatIssuableAttribute.kebab}-items`"
@click="updateAttribute(attrItem.id)"
>
{{ attrItem.title }}
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
index 701833c4e95..7a10a9f3a4c 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -61,6 +61,9 @@ export default {
return data.workspace?.issuable?.subscribed || false;
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.emailsDisabled = this.parentIsGroup
? data.workspace?.emailsDisabled
: data.workspace?.issuable?.emailsDisabled;
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 7c157fe2775..bb90ef8e444 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
@@ -38,7 +38,10 @@ export default {
</script>
<template>
- <div data-testid="helpPane" class="time-tracking-help-state">
+ <div
+ data-testid="helpPane"
+ class="sidebar-help-state gl-bg-white gl-border-gray-100 gl-border-t-solid gl-border-b-solid gl-border-1"
+ >
<div class="time-tracking-info">
<h4>{{ __('Track time with quick actions') }}</h4>
<p>{{ __('Quick actions can be used in description and comment boxes.') }}</p>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index 91c67a03dfb..d222a2af382 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlLink, GlModal, GlModalDirective, GlLoadingIcon } from '@gitlab/ui';
+import { GlIcon, GlLink, GlModal, GlButton, GlModalDirective, GlLoadingIcon } from '@gitlab/ui';
import { IssuableType } from '~/issues/constants';
import { s__, __ } from '~/locale';
import { timeTrackingQueries } from '~/sidebar/constants';
@@ -21,6 +21,7 @@ export default {
GlIcon,
GlLink,
GlModal,
+ GlButton,
GlLoadingIcon,
TimeTrackingCollapsedState,
TimeTrackingSpentOnlyPane,
@@ -187,7 +188,11 @@ export default {
</script>
<template>
- <div v-cloak class="time-tracker time-tracking-component-wrap" data-testid="time-tracker">
+ <div
+ v-cloak
+ class="time-tracker time-tracking-component-wrap sidebar-help-wrap"
+ data-testid="time-tracker"
+ >
<time-tracking-collapsed-state
v-if="showCollapsed"
:show-comparison-state="showComparisonState"
@@ -198,25 +203,21 @@ export default {
:time-spent-human-readable="humanTotalTimeSpent"
:time-estimate-human-readable="humanTimeEstimate"
/>
- <div class="hide-collapsed gl-line-height-20 gl-text-gray-900">
+ <div
+ class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center"
+ >
{{ __('Time tracking') }}
<gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" inline />
- <div
- v-if="!showHelpState"
- data-testid="helpButton"
- class="help-button float-right"
- @click="toggleHelpState(true)"
+ <gl-button
+ :data-testid="showHelpState ? 'closeHelpButton' : 'helpButton'"
+ category="tertiary"
+ size="small"
+ variant="link"
+ class="gl-ml-auto"
+ @click="toggleHelpState(!showHelpState)"
>
- <gl-icon name="question-o" />
- </div>
- <div
- v-else
- data-testid="closeHelpButton"
- class="close-help-button float-right"
- @click="toggleHelpState(false)"
- >
- <gl-icon name="close" />
- </div>
+ <gl-icon :name="showHelpState ? 'close' : 'question-o'" class="gl-text-gray-900!" />
+ </gl-button>
</div>
<div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed">
<div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane">
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
index a9c4203af22..eabba619af5 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
@@ -59,6 +59,10 @@ export default {
return data.workspace?.issuable?.currentUserTodos.nodes[0]?.id;
},
result({ data }) {
+ if (!data) {
+ return;
+ }
+
const currentUserTodos = data.workspace?.issuable?.currentUserTodos?.nodes ?? [];
this.todoId = currentUserTodos[0]?.id;
this.$emit('todoUpdated', currentUserTodos.length > 0);
@@ -177,19 +181,14 @@ export default {
/>
<gl-button
v-if="isClassicSidebar"
+ v-gl-tooltip.left.viewport
+ :title="tootltipTitle"
category="tertiary"
type="reset"
class="sidebar-collapsed-icon sidebar-collapsed-container gl-rounded-0! gl-shadow-none!"
@click.stop.prevent="toggleTodo"
>
- <gl-icon
- v-gl-tooltip.left.viewport
- :title="tootltipTitle"
- :size="16"
- :class="{ 'todo-undone': hasTodo }"
- :name="collapsedButtonIcon"
- :aria-label="collapsedButtonIcon"
- />
+ <gl-icon :class="{ 'todo-undone': hasTodo }" :name="collapsedButtonIcon" />
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/fragmentTypes.json b/app/assets/javascripts/sidebar/fragmentTypes.json
deleted file mode 100644
index a1c68bba454..00000000000
--- a/app/assets/javascripts/sidebar/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"UNION","name":"Issuable","possibleTypes":[{"name":"Issue"},{"name":"MergeRequest"}]}, {"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]}]}}
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
index 5b2ce3fe446..fc757922f09 100644
--- a/app/assets/javascripts/sidebar/graphql.js
+++ b/app/assets/javascripts/sidebar/graphql.js
@@ -1,15 +1,11 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import produce from 'immer';
import VueApollo from 'vue-apollo';
import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
+import { resolvers as workItemResolvers } from '~/work_items/graphql/resolvers';
import createDefaultClient from '~/lib/graphql';
-import introspectionQueryResultData from './fragmentTypes.json';
-
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
const resolvers = {
+ ...workItemResolvers,
Mutation: {
updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
const sourceData = cache.readQuery({ query: getIssueStateQuery });
@@ -18,14 +14,11 @@ const resolvers = {
});
cache.writeQuery({ query: getIssueStateQuery, data });
},
+ ...workItemResolvers.Mutation,
},
};
-export const defaultClient = createDefaultClient(resolvers, {
- cacheConfig: {
- fragmentMatcher,
- },
-});
+export const defaultClient = createDefaultClient(resolvers);
export const apolloProvider = new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index 1947c4801db..2aacce2fb00 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -21,6 +21,7 @@ export default class SidebarMilestone {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarMilestoneRoot',
components: {
timeTracker,
},
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 6363422259e..c29784aa328 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -57,6 +57,7 @@ function mountSidebarToDoWidget() {
return new Vue({
el,
+ name: 'SidebarTodoRoot',
apolloProvider,
components: {
SidebarTodoWidget,
@@ -103,6 +104,7 @@ function mountAssigneesComponentDeprecated(mediator) {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarAssigneesRoot',
apolloProvider,
components: {
SidebarAssignees,
@@ -135,6 +137,7 @@ function mountAssigneesComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarAssigneesRoot',
apolloProvider,
components: {
SidebarAssigneesWidget,
@@ -185,6 +188,7 @@ function mountReviewersComponent(mediator) {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarReviewersRoot',
apolloProvider,
components: {
SidebarReviewers,
@@ -218,6 +222,7 @@ function mountCrmContactsComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarCrmContactsRoot',
apolloProvider,
components: {
CrmContacts,
@@ -242,6 +247,7 @@ function mountMilestoneSelect() {
return new Vue({
el,
+ name: 'SidebarMilestoneRoot',
apolloProvider,
components: {
SidebarDropdownWidget,
@@ -274,6 +280,7 @@ export function mountSidebarLabels() {
return new Vue({
el,
+ name: 'SidebarLabelsRoot',
apolloProvider,
components: {
@@ -328,6 +335,7 @@ function mountConfidentialComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarConfidentialRoot',
apolloProvider,
components: {
SidebarConfidentialityWidget,
@@ -362,6 +370,7 @@ function mountDueDateComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarDueDateRoot',
apolloProvider,
components: {
SidebarDueDateWidget,
@@ -392,6 +401,7 @@ function mountReferenceComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarReferenceRoot',
apolloProvider,
components: {
SidebarReferenceWidget,
@@ -428,6 +438,7 @@ function mountLockComponent(store) {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarLockRoot',
store,
provide: {
fullPath,
@@ -451,6 +462,7 @@ function mountParticipantsComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarParticipantsRoot',
apolloProvider,
components: {
SidebarParticipantsWidget,
@@ -479,6 +491,7 @@ function mountSubscriptionsComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarSubscriptionsRoot',
apolloProvider,
components: {
SidebarSubscriptionsWidget,
@@ -509,6 +522,7 @@ function mountTimeTrackingComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarTimeTrackingRoot',
apolloProvider,
provide: { issuableType },
render: (createElement) =>
@@ -534,6 +548,7 @@ function mountSeverityComponent() {
return new Vue({
el: severityContainerEl,
+ name: 'SidebarSeverityRoot',
apolloProvider,
components: {
SidebarSeverity,
@@ -562,6 +577,7 @@ function mountCopyEmailComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarCopyEmailRoot',
render: (createElement) =>
createElement(CopyEmailToClipboard, { props: { issueEmailAddress: createNoteEmail } }),
});
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 25468d4a697..4664bb56958 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,4 +1,4 @@
-import Store from 'ee_else_ce/sidebar/stores/sidebar_store';
+import Store from '~/sidebar/stores/sidebar_store';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
diff --git a/app/assets/javascripts/tabs/constants.js b/app/assets/javascripts/tabs/constants.js
index 3b84d7394d4..90c9a89d652 100644
--- a/app/assets/javascripts/tabs/constants.js
+++ b/app/assets/javascripts/tabs/constants.js
@@ -1,8 +1,4 @@
-export const ACTIVE_TAB_CLASSES = Object.freeze([
- 'active',
- 'gl-tab-nav-item-active',
- 'gl-tab-nav-item-active-indigo',
-]);
+export const ACTIVE_TAB_CLASSES = Object.freeze(['active', 'gl-tab-nav-item-active']);
export const ACTIVE_PANEL_CLASS = 'active';
diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue
index d066834540f..efc2991f40f 100644
--- a/app/assets/javascripts/terraform/components/states_table.vue
+++ b/app/assets/javascripts/terraform/components/states_table.vue
@@ -1,14 +1,5 @@
<script>
-import {
- GlAlert,
- GlBadge,
- GlIcon,
- GlLink,
- GlLoadingIcon,
- GlSprintf,
- GlTable,
- GlTooltip,
-} from '@gitlab/ui';
+import { GlAlert, GlBadge, GlLink, GlLoadingIcon, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
@@ -21,7 +12,6 @@ export default {
CiBadge,
GlAlert,
GlBadge,
- GlIcon,
GlLink,
GlLoadingIcon,
GlSprintf,
@@ -128,7 +118,7 @@ export default {
>
<template #cell(name)="{ item }">
<div
- class="gl-display-flex align-items-center gl-justify-content-end gl-justify-content-md-start"
+ class="gl-display-flex align-items-center gl-justify-content-end gl-md-justify-content-start"
data-testid="terraform-states-table-name"
>
<p class="gl-font-weight-bold gl-m-0 gl-text-gray-900">
@@ -156,8 +146,7 @@ export default {
:id="`terraformLockedBadgeContainer${item.name}`"
class="gl-mx-3"
>
- <gl-badge :id="`terraformLockedBadge${item.name}`">
- <gl-icon name="lock" />
+ <gl-badge :id="`terraformLockedBadge${item.name}`" icon="lock">
{{ $options.i18n.locked }}
</gl-badge>
diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js
index 1b8cab0d51e..34261f3c4db 100644
--- a/app/assets/javascripts/terraform/index.js
+++ b/app/assets/javascripts/terraform/index.js
@@ -1,5 +1,5 @@
+import { defaultDataIdFromObject } from '@apollo/client/core';
import { GlToast } from '@gitlab/ui';
-import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
diff --git a/app/assets/javascripts/toggles/index.js b/app/assets/javascripts/toggles/index.js
new file mode 100644
index 00000000000..046b9fc7dcd
--- /dev/null
+++ b/app/assets/javascripts/toggles/index.js
@@ -0,0 +1,65 @@
+import { kebabCase } from 'lodash';
+import Vue from 'vue';
+import { GlToggle } from '@gitlab/ui';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export const initToggle = (el) => {
+ if (!el) {
+ return false;
+ }
+
+ const {
+ name,
+ isChecked,
+ disabled,
+ isLoading,
+ label,
+ help,
+ labelPosition,
+ ...dataset
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: parseBoolean(disabled),
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: parseBoolean(isLoading),
+ },
+ },
+ data() {
+ return {
+ value: parseBoolean(isChecked),
+ };
+ },
+ render(h) {
+ return h(GlToggle, {
+ props: {
+ name,
+ value: this.value,
+ disabled: this.disabled,
+ isLoading: this.isLoading,
+ label,
+ help,
+ labelPosition,
+ },
+ class: el.className,
+ attrs: Object.fromEntries(
+ Object.entries(dataset).map(([key, value]) => [`data-${kebabCase(key)}`, value]),
+ ),
+ on: {
+ change: (newValue) => {
+ this.value = newValue;
+ this.$emit('change', newValue);
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js
index 49a43b120e0..4639671984a 100644
--- a/app/assets/javascripts/tooltips/index.js
+++ b/app/assets/javascripts/tooltips/index.js
@@ -21,6 +21,7 @@ const tooltipsApp = () => {
document.body.appendChild(container);
app = new Vue({
+ name: 'TooltipsRoot',
render(h) {
return h(Tooltips, {
props: {
diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js
index 44e54c85f3c..ee23f8c5a0c 100644
--- a/app/assets/javascripts/user_callout.js
+++ b/app/assets/javascripts/user_callout.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { getCookie, setCookie } from '~/lib/utils/common_utils';
export default class UserCallout {
constructor(options = {}) {
@@ -9,7 +9,7 @@ export default class UserCallout {
this.userCalloutBody = $(`.${className}`);
this.cookieName = this.userCalloutBody.data('uid');
- this.isCalloutDismissed = Cookies.get(this.cookieName);
+ this.isCalloutDismissed = getCookie(this.cookieName);
this.init();
}
@@ -30,7 +30,7 @@ export default class UserCallout {
cookieOptions.path = this.userCalloutBody.data('projectPath');
}
- Cookies.set(this.cookieName, 'true', cookieOptions);
+ setCookie(this.cookieName, 'true', cookieOptions);
if ($currentTarget.hasClass('close') || $currentTarget.hasClass('js-close')) {
this.userCalloutBody.remove();
diff --git a/app/assets/javascripts/vue_alerts.js b/app/assets/javascripts/vue_alerts.js
index b44f787cf30..f3bf121c0f8 100644
--- a/app/assets/javascripts/vue_alerts.js
+++ b/app/assets/javascripts/vue_alerts.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
-import Cookies from 'js-cookie';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { setCookie, parseBoolean } from '~/lib/utils/common_utils';
+
import DismissibleAlert from '~/vue_shared/components/dismissible_alert.vue';
const getCookieExpirationPeriod = (expirationPeriod) => {
@@ -33,7 +33,7 @@ const mountVueAlert = (el) => {
if (!dismissCookieName) {
return;
}
- Cookies.set(dismissCookieName, true, {
+ setCookie(dismissCookieName, true, {
expires: getCookieExpirationPeriod(dismissCookieExpire),
});
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
index 5ef7c2f72e0..7ba387c79b1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
@@ -1,5 +1,6 @@
<script>
import createFlash from '~/flash';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -79,6 +80,7 @@ export default {
[STOPPING]: {
actionName: STOPPING,
buttonText: s__('MrDeploymentActions|Stop environment'),
+ buttonVariant: 'danger',
busyText: __('This environment is being deployed'),
confirmMessage: __('Are you sure you want to stop this environment?'),
errorMessage: __('Something went wrong while stopping this environment. Please try again.'),
@@ -86,6 +88,7 @@ export default {
[DEPLOYING]: {
actionName: DEPLOYING,
buttonText: s__('MrDeploymentActions|Deploy'),
+ buttonVariant: 'confirm',
busyText: __('This environment is being deployed'),
confirmMessage: __('Are you sure you want to deploy this environment?'),
errorMessage: __('Something went wrong while deploying this environment. Please try again.'),
@@ -93,14 +96,27 @@ export default {
[REDEPLOYING]: {
actionName: REDEPLOYING,
buttonText: s__('MrDeploymentActions|Re-deploy'),
+ buttonVariant: 'confirm',
busyText: __('This environment is being re-deployed'),
confirmMessage: __('Are you sure you want to re-deploy this environment?'),
errorMessage: __('Something went wrong while deploying this environment. Please try again.'),
},
},
methods: {
- executeAction(endpoint, { actionName, confirmMessage, errorMessage }) {
- const isConfirmed = confirm(confirmMessage); //eslint-disable-line
+ async executeAction(
+ endpoint,
+ {
+ actionName,
+ buttonText: primaryBtnText,
+ buttonVariant: primaryBtnVariant,
+ confirmMessage,
+ errorMessage,
+ },
+ ) {
+ const isConfirmed = await confirmAction(confirmMessage, {
+ primaryBtnVariant,
+ primaryBtnText,
+ });
if (isConfirmed) {
this.actionInProgress = actionName;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index 7322958e6df..a25b4ab54e5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -128,10 +128,12 @@ export default {
api.trackRedisHllUserEvent(this.$options.expandEvent);
}
}),
- toggleCollapsed() {
- this.isCollapsed = !this.isCollapsed;
+ toggleCollapsed(e) {
+ if (!e?.target?.closest('.btn:not(.btn-icon),a')) {
+ this.isCollapsed = !this.isCollapsed;
- this.triggerRedisTracking();
+ this.triggerRedisTracking();
+ }
},
initExtensionPolling() {
const poll = new Poll({
@@ -139,7 +141,7 @@ export default {
fetchData: () => this.fetchCollapsedData(this.$props),
},
method: 'fetchData',
- successCallback: (data) => {
+ successCallback: ({ data }) => {
if (Object.keys(data).length > 0) {
poll.stop();
this.setCollapsedData(data);
@@ -207,6 +209,19 @@ export default {
this.showFade = true;
}
},
+ onRowMouseDown() {
+ this.down = Number(new Date());
+ },
+ onRowMouseUp(e) {
+ const up = Number(new Date());
+
+ // To allow for text to be selected we check if the the user is clicking
+ // or selecting, if they are selecting the time difference should be
+ // more than 200ms
+ if (up - this.down < 200) {
+ this.toggleCollapsed(e);
+ }
+ },
generateText,
},
EXTENSION_ICON_CLASS,
@@ -215,7 +230,7 @@ export default {
<template>
<section class="media-section" data-testid="widget-extension">
- <div class="media gl-p-5">
+ <div class="media gl-p-5 gl-cursor-pointer" @mousedown="onRowMouseDown" @mouseup="onRowMouseUp">
<status-icon
:name="$options.label || $options.name"
:is-loading="isLoadingSummary"
@@ -253,7 +268,7 @@ export default {
category="tertiary"
data-testid="toggle-button"
size="small"
- @click="toggleCollapsed"
+ @click.self="toggleCollapsed"
/>
</div>
</div>
@@ -317,9 +332,13 @@ export default {
<div v-if="data.link">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div>
+ <div v-if="data.supportingText">
+ <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p>
+ </div>
<gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
{{ data.badge.text }}
</gl-badge>
+
<actions
:widget="$options.label || $options.name"
:tertiary-buttons="data.actions"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
index cd5b7c3110d..8b410926c46 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
@@ -90,7 +90,7 @@ export default {
</template>
<div class="row">
<div
- class="col-md-5 order-md-last col-12 gl-mt-5 gl-mt-md-n2! gl-pt-md-2 svg-content svg-225"
+ class="col-md-5 order-md-last col-12 gl-mt-5 gl-md-mt-n2! gl-md-pt-2 svg-content svg-225"
>
<img data-testid="pipeline-image" :src="pipelineSvgPath" />
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue
new file mode 100644
index 00000000000..7279ad971be
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlModal, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ name: 'MergeFailedPipelineConfirmationDialog',
+ i18n: {
+ primary: __('Merge unverified changes'),
+ cancel: __('Cancel'),
+ info: __(
+ 'The latest pipeline for this merge request did not succeed. The latest changes are unverified.',
+ ),
+ confirmation: __('Are you sure you want to attempt to merge?'),
+ title: __('Merge unverified changes?'),
+ },
+ components: {
+ GlModal,
+ GlButton,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ hide() {
+ this.$refs.modal.hide();
+ },
+ cancel() {
+ this.hide();
+ this.$emit('cancel');
+ },
+ focusCancelButton() {
+ this.$refs.cancelButton.$el.focus();
+ },
+ mergeChanges() {
+ this.$emit('mergeWithFailedPipeline');
+ this.hide();
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ size="sm"
+ modal-id="merge-train-failed-pipeline-confirmation-dialog"
+ :title="$options.i18n.title"
+ :visible="visible"
+ data-testid="merge-failed-pipeline-confirmation-dialog"
+ @shown="focusCancelButton"
+ @hide="$emit('cancel')"
+ >
+ <p>{{ $options.i18n.info }}</p>
+ <p>{{ $options.i18n.confirmation }}</p>
+ <template #modal-footer>
+ <gl-button ref="cancelButton" data-testid="merge-cancel-btn" @click="cancel">{{
+ $options.i18n.cancel
+ }}</gl-button>
+ <gl-button variant="danger" data-testid="merge-unverified-changes" @click="mergeChanges">
+ {{ $options.i18n.primary }}
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
index 247877a8235..e0c4679b983 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -1,7 +1,14 @@
<script>
-import { MERGE_ACTIVE_STATUS_PHRASES } from '../../constants';
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
+import simplePoll from '~/lib/utils/simple_poll';
+import MergeRequest from '../../../merge_request';
+import eventHub from '../../event_hub';
+import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants';
import statusIcon from '../mr_widget_status_icon.vue';
+const { transitions } = STATE_MACHINE;
+const { MERGE_FAILURE } = transitions;
+
export default {
name: 'MRWidgetMerging',
components: {
@@ -12,6 +19,10 @@ export default {
type: Object,
required: true,
},
+ service: {
+ type: Object,
+ required: true,
+ },
},
data() {
const statusCount = MERGE_ACTIVE_STATUS_PHRASES.length;
@@ -20,6 +31,53 @@ export default {
mergeStatus: MERGE_ACTIVE_STATUS_PHRASES[Math.floor(Math.random() * statusCount)],
};
},
+ mounted() {
+ this.initiateMergePolling();
+ },
+ methods: {
+ initiateMergePolling() {
+ simplePoll(
+ (continuePolling, stopPolling) => {
+ this.handleMergePolling(continuePolling, stopPolling);
+ },
+ { timeout: 0 },
+ );
+ },
+ handleMergePolling(continuePolling, stopPolling) {
+ this.service
+ .poll()
+ .then((res) => res.data)
+ .then((data) => {
+ if (data.state === 'merged') {
+ // If state is merged we should update the widget and stop the polling
+ eventHub.$emit('MRWidgetUpdateRequested');
+ eventHub.$emit('FetchActionsContent');
+ MergeRequest.hideCloseButton();
+ MergeRequest.decreaseCounter();
+ stopPolling();
+
+ refreshUserMergeRequestCounts();
+
+ // If user checked remove source branch and we didn't remove the branch yet
+ // we should start another polling for source branch remove process
+ if (this.removeSourceBranch && data.source_branch_exists) {
+ this.initiateRemoveSourceBranchPolling();
+ }
+ } else if (data.merge_error) {
+ eventHub.$emit('FailedToMerge', data.merge_error);
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
+ stopPolling();
+ } else {
+ // MR is not merged yet, continue polling until the state becomes 'merged'
+ continuePolling();
+ }
+ })
+ .catch(() => {
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
+ stopPolling();
+ });
+ },
+ },
};
</script>
<template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
index 5b03eda2eac..cadbd9c28a9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -1,9 +1,14 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { GlIcon, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
+import { sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import missingBranchQuery from '../../queries/states/missing_branch.query.graphql';
+import {
+ MR_WIDGET_MISSING_BRANCH_WHICH,
+ MR_WIDGET_MISSING_BRANCH_RESTORE,
+ MR_WIDGET_MISSING_BRANCH_MANUALCLI,
+} from '../../i18n';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
@@ -13,6 +18,7 @@ export default {
},
components: {
GlIcon,
+ GlSprintf,
statusIcon,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
@@ -45,26 +51,20 @@ export default {
return this.mr.sourceBranchRemoved;
},
- missingBranchName() {
+ type() {
return this.sourceBranchRemoved ? 'source' : 'target';
},
- missingBranchNameMessage() {
- return sprintf(
- s__('mrWidget| Please restore it or use a different %{missingBranchName} branch'),
- {
- missingBranchName: this.missingBranchName,
- },
- );
+ name() {
+ return this.type === 'source' ? this.mr.sourceBranch : this.mr.targetBranch;
+ },
+ warning() {
+ return sprintf(MR_WIDGET_MISSING_BRANCH_WHICH, { type: this.type, name: this.name });
+ },
+ restore() {
+ return sprintf(MR_WIDGET_MISSING_BRANCH_RESTORE, { type: this.type });
},
message() {
- return sprintf(
- s__(
- 'mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line',
- ),
- {
- missingBranchName: this.missingBranchName,
- },
- );
+ return sprintf(MR_WIDGET_MISSING_BRANCH_MANUALCLI, { type: this.type });
},
},
};
@@ -79,9 +79,14 @@ export default {
'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget,
}"
class="bold js-branch-text"
+ data-testid="widget-content"
>
- <span class="capitalize" data-testid="missingBranchName"> {{ missingBranchName }} </span>
- {{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }}
+ <gl-sprintf :message="warning">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ {{ restore }}
<gl-icon
v-gl-tooltip
:title="message"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
index d88dad2e086..d204befef58 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
@@ -20,7 +20,7 @@ export default {
},
i18n: {
failedMessage: s__(
- `mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure, or check the %{linkStart}troubleshooting documentation%{linkEnd} to see other possible actions.`,
+ `mrWidget|Merge blocked: pipeline must succeed. Push a commit that fixes the failure, or %{linkStart}learn about other solutions.%{linkEnd}`,
),
},
};
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 06ce312bd4c..bc094501e89 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
@@ -14,7 +14,6 @@ import {
import { isEmpty } from 'lodash';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
-import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import createFlash from '~/flash';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
@@ -22,11 +21,8 @@ import { __, s__ } from '~/locale';
import SmartInterval from '~/smart_interval';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { helpPagePath } from '~/helpers/help_page_helper';
-import MergeRequest from '../../../merge_request';
import {
AUTO_MERGE_STRATEGIES,
- DANGER,
- CONFIRM,
WARNING,
MT_MERGE_STRATEGY,
PIPELINE_FAILED_STATE,
@@ -42,6 +38,7 @@ import CommitEdit from './commit_edit.vue';
import CommitMessageDropdown from './commit_message_dropdown.vue';
import CommitsHeader from './commits_header.vue';
import SquashBeforeMerge from './squash_before_merge.vue';
+import MergeFailedPipelineConfirmationDialog from './merge_failed_pipeline_confirmation_dialog.vue';
const PIPELINE_RUNNING_STATE = 'running';
const PIPELINE_PENDING_STATE = 'pending';
@@ -52,7 +49,7 @@ const MERGE_SUCCESS_STATUS = 'success';
const MERGE_HOOK_VALIDATION_ERROR_STATUS = 'hook_validation_error';
const { transitions } = STATE_MACHINE;
-const { MERGE, MERGED, MERGE_FAILURE, AUTO_MERGE } = transitions;
+const { MERGE, MERGE_FAILURE, AUTO_MERGE, MERGING } = transitions;
export default {
name: 'ReadyToMerge',
@@ -106,6 +103,7 @@ export default {
GlDropdownItem,
GlFormCheckbox,
GlSkeletonLoader,
+ MergeFailedPipelineConfirmationDialog,
MergeTrainHelperIcon: () =>
import('ee_component/vue_merge_request_widget/components/merge_train_helper_icon.vue'),
MergeImmediatelyConfirmationDialog: () =>
@@ -138,7 +136,8 @@ export default {
squashBeforeMerge: this.mr.squashIsSelected,
isSquashReadOnly: this.mr.squashIsReadonly,
squashCommitMessage: this.mr.squashCommitMessage,
- isPipelineFailedModalVisible: false,
+ isPipelineFailedModalVisibleMergeTrain: false,
+ isPipelineFailedModalVisibleNormalMerge: false,
editCommitMessage: false,
};
},
@@ -166,6 +165,9 @@ export default {
return this.mr.isPipelineFailed;
},
+ showMergeFailedPipelineConfirmationDialog() {
+ return this.status === PIPELINE_FAILED_STATE && this.isPipelineFailed;
+ },
isMergeAllowed() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
return this.state.mergeable;
@@ -248,13 +250,6 @@ export default {
return PIPELINE_SUCCESS_STATE;
},
- mergeButtonVariant() {
- if (this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) {
- return DANGER;
- }
-
- return CONFIRM;
- },
iconClass() {
if (this.shouldRenderMergeTrainHelperIcon && !this.mr.preventMerge) {
return PIPELINE_RUNNING_STATE;
@@ -279,6 +274,10 @@ export default {
return this.autoMergeText;
}
+ if (this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) {
+ return __('Merge...');
+ }
+
return __('Merge');
},
hasPipelineMustSucceedConflict() {
@@ -361,8 +360,13 @@ export default {
return this.$apollo.queries.state.refetch();
},
handleMergeButtonClick(useAutoMerge, mergeImmediately = false, confirmationClicked = false) {
- if (this.showFailedPipelineModal && !confirmationClicked) {
- this.isPipelineFailedModalVisible = true;
+ if (this.showMergeFailedPipelineConfirmationDialog && !confirmationClicked) {
+ this.isPipelineFailedModalVisibleNormalMerge = true;
+ return;
+ }
+
+ if (this.showFailedPipelineModalMergeTrain && !confirmationClicked) {
+ this.isPipelineFailedModalVisibleMergeTrain = true;
return;
}
@@ -406,7 +410,7 @@ export default {
eventHub.$emit('MRWidgetUpdateRequested');
this.mr.transitionStateMachine({ transition: AUTO_MERGE });
} else if (data.status === MERGE_SUCCESS_STATUS) {
- this.initiateMergePolling();
+ this.mr.transitionStateMachine({ transition: MERGING });
} else if (hasError) {
eventHub.$emit('FailedToMerge', data.merge_error);
this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
@@ -434,51 +438,8 @@ export default {
onMergeImmediatelyConfirmation() {
this.handleMergeButtonClick(false, true, true);
},
- initiateMergePolling() {
- simplePoll(
- (continuePolling, stopPolling) => {
- this.handleMergePolling(continuePolling, stopPolling);
- },
- { timeout: 0 },
- );
- },
- handleMergePolling(continuePolling, stopPolling) {
- this.service
- .poll()
- .then((res) => res.data)
- .then((data) => {
- if (data.state === 'merged') {
- // If state is merged we should update the widget and stop the polling
- eventHub.$emit('MRWidgetUpdateRequested');
- eventHub.$emit('FetchActionsContent');
- MergeRequest.hideCloseButton();
- MergeRequest.decreaseCounter();
- this.mr.transitionStateMachine({ transition: MERGED });
- stopPolling();
-
- refreshUserMergeRequestCounts();
-
- // If user checked remove source branch and we didn't remove the branch yet
- // we should start another polling for source branch remove process
- if (this.removeSourceBranch && data.source_branch_exists) {
- this.initiateRemoveSourceBranchPolling();
- }
- } else if (data.merge_error) {
- eventHub.$emit('FailedToMerge', data.merge_error);
- this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
- stopPolling();
- } else {
- // MR is not merged yet, continue polling until the state becomes 'merged'
- continuePolling();
- }
- })
- .catch(() => {
- createFlash({
- message: __('Something went wrong while merging this merge request. Please try again.'),
- });
- this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
- stopPolling();
- });
+ onMergeWithFailedPipelineConfirmation() {
+ this.handleMergeButtonClick(false, true, true);
},
initiateRemoveSourceBranchPolling() {
// We need to show source branch is being removed spinner in another component
@@ -559,7 +520,7 @@ export default {
category="primary"
class="accept-merge-request"
data-testid="merge-button"
- :variant="mergeButtonVariant"
+ variant="confirm"
:disabled="isMergeButtonDisabled"
:loading="isMakingRequest"
data-qa-selector="merge_button"
@@ -570,7 +531,7 @@ export default {
v-if="shouldShowMergeImmediatelyDropdown"
v-gl-tooltip.hover.focus="__('Select merge moment')"
:disabled="isMergeButtonDisabled"
- :variant="mergeButtonVariant"
+ variant="confirm"
data-qa-selector="merge_moment_dropdown"
toggle-class="btn-icon js-merge-moment"
>
@@ -593,18 +554,22 @@ export default {
/>
</gl-dropdown>
<merge-train-failed-pipeline-confirmation-dialog
- :visible="isPipelineFailedModalVisible"
+ :visible="isPipelineFailedModalVisibleMergeTrain"
@startMergeTrain="onStartMergeTrainConfirmation"
- @cancel="isPipelineFailedModalVisible = false"
+ @cancel="isPipelineFailedModalVisibleMergeTrain = false"
+ />
+ <merge-failed-pipeline-confirmation-dialog
+ :visible="isPipelineFailedModalVisibleNormalMerge"
+ @mergeWithFailedPipeline="onMergeWithFailedPipelineConfirmation"
+ @cancel="isPipelineFailedModalVisibleNormalMerge = false"
/>
</gl-button-group>
+ <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" />
<div
v-if="shouldShowMergeControls"
:class="{ 'gl-w-full gl-order-n1 gl-mb-5': glFeatures.restructuredMrWidget }"
class="gl-display-flex gl-align-items-center gl-flex-wrap"
>
- <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" />
-
<gl-form-checkbox
v-if="canRemoveSourceBranch"
id="remove-source-branch-input"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index 8cf6383c26a..25ba4bf12af 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -43,8 +43,8 @@ export default {
class="gl-ml-3"
size="small"
:icon="glFeatures.restructuredMrWidget ? undefined : 'comment-next'"
- :variant="glFeatures.restructuredMrWidget && 'confirm'"
- :category="glFeatures.restructuredMrWidget && 'secondary'"
+ :variant="glFeatures.restructuredMrWidget ? 'confirm' : 'default'"
+ :category="glFeatures.restructuredMrWidget ? 'secondary' : 'primary'"
@click="jumpToFirstUnresolvedDiscussion"
>
{{ s__('mrWidget|Jump to first unresolved thread') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index 32effb91043..d337a554663 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -68,6 +68,7 @@ const STATE_MACHINE = {
states: {
IDLE: 'IDLE',
MERGING: 'MERGING',
+ MERGED: 'MERGED',
AUTO_MERGE: 'AUTO_MERGE',
},
transitions: {
@@ -75,6 +76,7 @@ const STATE_MACHINE = {
AUTO_MERGE: 'start-auto-merge',
MERGE_FAILURE: 'merge-failed',
MERGED: 'merge-done',
+ MERGING: 'merging',
},
};
const { states, transitions } = STATE_MACHINE;
@@ -86,11 +88,12 @@ STATE_MACHINE.definition = {
on: {
[transitions.MERGE]: states.MERGING,
[transitions.AUTO_MERGE]: states.AUTO_MERGE,
+ [transitions.MERGING]: states.MERGING,
},
},
[states.MERGING]: {
on: {
- [transitions.MERGED]: states.IDLE,
+ [transitions.MERGED]: states.MERGED,
[transitions.MERGE_FAILURE]: states.IDLE,
},
},
@@ -110,6 +113,7 @@ export const stateToTransitionMap = {
};
export const stateToComponentMap = {
[states.MERGING]: classStateMap[stateKey.merging],
+ [states.MERGED]: classStateMap[stateKey.merged],
[states.AUTO_MERGE]: classStateMap[stateKey.autoMergeEnabled],
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
new file mode 100644
index 00000000000..168f10bd148
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
@@ -0,0 +1,120 @@
+import { uniqueId } from 'lodash';
+import { __, n__, s__, sprintf } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import { EXTENSION_ICONS } from '../../constants';
+
+export default {
+ name: 'WidgetAccessibility',
+ enablePolling: true,
+ i18n: {
+ loading: s__('Reports|Accessibility scanning results are being parsed'),
+ error: s__('Reports|Accessibility scanning failed loading results'),
+ },
+ props: ['accessibilityReportPath'],
+ computed: {
+ statusIcon() {
+ return this.collapsedData.status === 'failed'
+ ? EXTENSION_ICONS.warning
+ : EXTENSION_ICONS.success;
+ },
+ },
+ methods: {
+ summary() {
+ const numOfResults = this.collapsedData?.summary?.errored || 0;
+
+ const successText = s__(
+ 'Reports|Accessibility scanning detected no issues for the source branch only',
+ );
+ const warningText = sprintf(
+ n__(
+ 'Reports|Accessibility scanning detected %{strong_start}%{number}%{strong_end} issue for the source branch only',
+ 'Reports|Accessibility scanning detected %{strong_start}%{number}%{strong_end} issues for the source branch only',
+ numOfResults,
+ ),
+ {
+ number: numOfResults,
+ },
+ false,
+ );
+
+ return numOfResults === 0 ? successText : warningText;
+ },
+ fetchCollapsedData() {
+ return axios.get(this.accessibilityReportPath);
+ },
+ fetchFullData() {
+ return Promise.resolve(this.prepareReports());
+ },
+ parsedTECHSCode(code) {
+ /*
+ * In issue code looks like "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail"
+ * or "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent"
+ *
+ * The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation.
+ * Here we simply split the string on `.` and get the code in the 5th position
+ */
+ return code?.split('.')[4];
+ },
+ formatLearnMoreUrl(code) {
+ const parsed = this.parsedTECHSCode(code);
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `https://www.w3.org/TR/WCAG20-TECHS/${parsed || 'Overview'}.html`;
+ },
+ formatText(code) {
+ return sprintf(
+ s__(
+ 'AccessibilityReport|The accessibility scanning found an error of the following type: %{code}',
+ ),
+ { code },
+ );
+ },
+ formatMessage(message) {
+ return sprintf(s__('AccessibilityReport|Message: %{message}'), { message });
+ },
+ prepareReports() {
+ const { new_errors, existing_errors, resolved_errors } = this.collapsedData;
+
+ const newErrors = new_errors.map((error) => {
+ return {
+ header: __('New'),
+ id: uniqueId('new-error-'),
+ text: this.formatText(error.code),
+ icon: { name: EXTENSION_ICONS.failed },
+ link: {
+ href: this.formatLearnMoreUrl(error.code),
+ text: __('Learn more'),
+ },
+ supportingText: this.formatMessage(error.message),
+ };
+ });
+
+ const existingErrors = existing_errors.map((error) => {
+ return {
+ id: uniqueId('existing-error-'),
+ text: this.formatText(error.code),
+ icon: { name: EXTENSION_ICONS.failed },
+ link: {
+ href: this.formatLearnMoreUrl(error.code),
+ text: __('Learn more'),
+ },
+ supportingText: this.formatMessage(error.message),
+ };
+ });
+
+ const resolvedErrors = resolved_errors.map((error) => {
+ return {
+ id: uniqueId('resolved-error-'),
+ text: this.formatText(error.code),
+ icon: { name: EXTENSION_ICONS.success },
+ link: {
+ href: this.formatLearnMoreUrl(error.code),
+ text: __('Learn more'),
+ },
+ supportingText: this.formatMessage(error.message),
+ };
+ });
+
+ return [...newErrors, ...existingErrors, ...resolvedErrors];
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
index ba3336df2eb..4aeebf095c4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -25,9 +25,9 @@ export default {
n__(
'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} change',
'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} changes',
- changesFound,
+ count,
),
- { changesFound },
+ { changesFound: count },
);
},
// Status icon to be used next to the summary text
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
index a564acada02..8fcc4f818ec 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
@@ -73,26 +73,30 @@ export default {
return `${title}${subtitle}`;
},
fetchCollapsedData() {
- return Promise.resolve(this.fetchPlans().then(this.prepareReports));
- },
- fetchFullData() {
- const { valid, invalid } = this.collapsedData;
- return Promise.resolve([...valid, ...invalid]);
- },
- // Custom methods
- fetchPlans() {
return axios
.get(this.terraformReportsPath)
- .then(({ data }) => {
- return Object.keys(data).map((key) => {
- return data[key];
+ .then((res) => {
+ const reports = Object.keys(res.data).map((key) => {
+ return res.data[key];
});
+
+ const formattedData = this.prepareReports(reports);
+
+ return {
+ ...res,
+ data: formattedData,
+ };
})
.catch(() => {
- const invalidData = { tf_report_error: 'api_error' };
- return [invalidData];
+ const formattedData = this.prepareReports([{ tf_report_error: 'api_error' }]);
+
+ return { data: formattedData };
});
},
+ fetchFullData() {
+ const { valid, invalid } = this.collapsedData;
+ return Promise.resolve([...valid, ...invalid]);
+ },
createReportRow(report, iconName) {
const addNum = Number(report.create);
const changeNum = Number(report.update);
diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js
index c88e795e5f3..454a14faabb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/i18n.js
+++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js
@@ -1,4 +1,14 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+
+export const MR_WIDGET_MISSING_BRANCH_WHICH = s__(
+ 'mrWidget|The %{type} branch %{codeStart}%{name}%{codeEnd} does not exist.',
+);
+export const MR_WIDGET_MISSING_BRANCH_RESTORE = s__(
+ 'mrWidget|Please restore it or use a different %{type} branch.',
+);
+export const MR_WIDGET_MISSING_BRANCH_MANUALCLI = s__(
+ 'mrWidget|If the %{type} branch exists in your local repository, you can merge this merge request manually using the command line.',
+);
export const SQUASH_BEFORE_MERGE = {
tooltipTitle: __('Required in this project.'),
@@ -10,3 +20,8 @@ export const I18N_SHA_MISMATCH = {
warningMessage: __('Merge blocked: new changes were just added.'),
actionButtonLabel: __('Review changes'),
};
+
+export const MERGE_TRAIN_BUTTON_TEXT = {
+ failed: __('Start merge train...'),
+ passed: __('Start merge train'),
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index fa618756bb5..247a3711fc8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -48,7 +48,7 @@ export default {
pipelineId() {
return this.pipeline.id;
},
- showFailedPipelineModal() {
+ showFailedPipelineModalMergeTrain() {
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 83a07240403..11de58aa344 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
@@ -45,6 +45,7 @@ import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import getStateQuery from './queries/get_state.query.graphql';
import terraformExtension from './extensions/terraform';
+import accessibilityExtension from './extensions/accessibility';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
@@ -205,7 +206,7 @@ export default {
);
},
shouldShowAccessibilityReport() {
- return this.mr.accessibilityReportPath;
+ return Boolean(this.mr?.accessibilityReportPath);
},
formattedHumanAccess() {
return (this.mr.humanAccess || '').toLowerCase();
@@ -240,6 +241,11 @@ export default {
this.registerTerraformPlans();
}
},
+ shouldShowAccessibilityReport(newVal) {
+ if (newVal) {
+ this.registerAccessibilityExtension();
+ }
+ },
},
mounted() {
MRWidgetService.fetchInitialData()
@@ -478,6 +484,11 @@ export default {
registerExtension(terraformExtension);
}
},
+ registerAccessibilityExtension() {
+ if (this.shouldShowAccessibilityReport && this.shouldShowExtension) {
+ registerExtension(accessibilityExtension);
+ }
+ },
},
};
</script>
@@ -567,7 +578,7 @@ export default {
:endpoint="mr.accessibilityReportPath"
/>
- <div class="mr-widget-section">
+ <div class="mr-widget-section" data-qa-selector="mr_widget_content">
<component :is="componentName" :mr="mr" :service="service" />
<ready-to-merge
v-if="isRestructuredMrWidgetEnabled && mr.commitsCount"
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
index 0b8396b4461..25c44beaf18 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
@@ -3,7 +3,6 @@ query getState($projectPath: ID!, $iid: String!) {
id
archived
onlyAllowMergeIfPipelineSucceeds
-
mergeRequest(iid: $iid) {
id
autoMergeEnabled
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
index 2d79d35cf24..ad93a3a7371 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
@@ -4,6 +4,7 @@ query autoMergeEnabled($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
id
mergeRequest(iid: $iid) {
+ id
...autoMergeEnabled
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql
index f713739f65a..556ecee254d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql
@@ -2,6 +2,7 @@
query readyToMerge($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ id
...ReadyToMerge
}
}
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
index 9f1da9ae173..d0155c18b9c 100644
--- a/app/assets/javascripts/vue_shared/alert_details/index.js
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -1,4 +1,4 @@
-import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import { defaultDataIdFromObject } from '@apollo/client/core';
import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@@ -70,6 +70,7 @@ export default (selector) => {
// eslint-disable-next-line no-new
new Vue({
el: selector,
+ name: 'AlertDetailsRoot',
components: {
AlertDetails,
},
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 82a28d4cb5f..b6010d4b70c 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -75,6 +75,9 @@ export default {
return this.noteAuthorId === this.currentUserId;
},
},
+ mounted() {
+ this.virtualScrollerItem = this.$el.closest('.vue-recycle-scroller__item-view');
+ },
methods: {
getAwardClassBindings(awardList) {
return {
@@ -162,6 +165,10 @@ export default {
},
setIsMenuOpen(menuOpen) {
this.isMenuOpen = menuOpen;
+
+ if (this.virtualScrollerItem) {
+ this.virtualScrollerItem.style.zIndex = this.isMenuOpen ? 1 : null;
+ }
},
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index 2c74d56f617..3aaa7d915ea 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -1,6 +1,7 @@
<script>
import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import LineHighlighter from '~/blob/line_highlighter';
import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins';
@@ -20,13 +21,22 @@ export default {
};
},
computed: {
+ refactorBlobViewerEnabled() {
+ return this.glFeatures.refactorBlobViewer;
+ },
+
lineNumbers() {
return this.content.split('\n').length;
},
},
mounted() {
- const { hash } = window.location;
- if (hash) this.scrollToLine(hash, true);
+ if (this.refactorBlobViewerEnabled) {
+ // This line will be removed once we start using highlight.js on the frontend (https://gitlab.com/groups/gitlab-org/-/epics/7146)
+ new LineHighlighter(); // eslint-disable-line no-new
+ } else {
+ const { hash } = window.location;
+ if (hash) this.scrollToLine(hash, true);
+ }
},
methods: {
scrollToLine(hash, scroll = false) {
@@ -51,7 +61,7 @@ export default {
<template>
<div>
<div class="file-content code js-syntax-highlight" :class="$options.userColorScheme">
- <div v-if="!hideLineNumbers" class="line-numbers">
+ <div v-if="!hideLineNumbers" class="line-numbers gl-pt-0!">
<a
v-for="line in lineNumbers"
:id="`L${line}`"
@@ -67,7 +77,7 @@ export default {
</div>
<div class="blob-content">
<pre
- class="code highlight"
+ class="code highlight gl-p-0! gl-display-flex"
><code v-safe-html="content" :data-blob-hash="blobHash"></code></pre>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
index 0575d7f6404..8b76af05ffe 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
@@ -45,7 +45,8 @@ export default {
:chart-data="chart.data"
:area-chart-options="chartOptions"
>
- {{ dateRange }}
+ <p>{{ dateRange }}</p>
+ <slot name="metrics" :selected-chart="selectedChart"></slot>
<template #tooltip-title>
<slot name="tooltip-title"></slot>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue
new file mode 100644
index 00000000000..64e3b5d0bae
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue
@@ -0,0 +1,68 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export const i18n = {
+ btnText: __('Fork project'),
+ title: __('Fork project?'),
+ message: __(
+ 'You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.',
+ ),
+};
+
+export default {
+ name: 'ConfirmForkModal',
+ components: {
+ GlModal,
+ },
+ model: {
+ prop: 'visible',
+ event: 'change',
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ forkPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ btnActions() {
+ return {
+ cancel: { text: __('Cancel') },
+ primary: {
+ text: this.$options.i18n.btnText,
+ attributes: {
+ href: this.forkPath,
+ variant: 'confirm',
+ 'data-qa-selector': 'fork_project_button',
+ 'data-method': 'post',
+ },
+ },
+ };
+ },
+ },
+ i18n,
+};
+</script>
+<template>
+ <gl-modal
+ :visible="visible"
+ data-qa-selector="confirm_fork_modal"
+ :modal-id="modalId"
+ :title="$options.i18n.title"
+ :action-primary="btnActions.primary"
+ :action-cancel="btnActions.cancel"
+ @change="$emit('change', $event)"
+ >
+ <p>{{ $options.i18n.message }}</p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue
index cb038a8c4e1..c411496fad1 100644
--- a/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue
+++ b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue
@@ -28,12 +28,37 @@ export default {
required: false,
default: false,
},
+ isOnImage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isDraft: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ size: {
+ type: String,
+ required: false,
+ default: 'md',
+ validator: (value) => ['sm', 'md'].includes(value),
+ },
+ ariaLabel: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
isNewNote() {
return this.label === null;
},
pinLabel() {
+ if (this.ariaLabel) {
+ return this.ariaLabel;
+ }
+
return this.isNewNote
? __('Comment form position')
: sprintf(__("Comment '%{label}' position"), { label: this.label });
@@ -51,7 +76,10 @@ export default {
'js-image-badge design-note-pin': !isNewNote,
resolved: isResolved,
inactive: isInactive,
+ draft: isDraft,
+ 'on-image': isOnImage,
'gl-absolute': position,
+ small: size === 'sm',
}"
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm"
type="button"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 810d9f782b9..3d48c74b40b 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -23,9 +23,19 @@ export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title:
export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') };
export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+export const DEFAULT_MILESTONE_UPCOMING = {
+ value: FILTER_UPCOMING,
+ text: __('Upcoming'),
+ title: __('Upcoming'),
+};
+export const DEFAULT_MILESTONE_STARTED = {
+ value: FILTER_STARTED,
+ text: __('Started'),
+ title: __('Started'),
+};
export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([
- { value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming') },
- { value: FILTER_STARTED, text: __('Started'), title: __('Started') },
+ DEFAULT_MILESTONE_UPCOMING,
+ DEFAULT_MILESTONE_STARTED,
]);
export const SortDirection = {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index bbc1888bc0b..157068b2c0f 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -163,19 +163,22 @@ export default {
},
},
methods: {
- handleInput: debounce(function debouncedSearch({ data }) {
- this.searchKey = data;
+ handleInput: debounce(function debouncedSearch({ data, operator }) {
+ // Prevent fetching suggestions when data or operator is not present
+ if (data || operator) {
+ this.searchKey = data;
- if (!this.suggestionsLoading && !this.activeTokenValue) {
- let search = this.searchTerm ? this.searchTerm : data;
+ if (!this.suggestionsLoading && !this.activeTokenValue) {
+ let search = this.searchTerm ? this.searchTerm : data;
- if (search.startsWith('"') && search.endsWith('"')) {
- search = stripQuotes(search);
- } else if (search.startsWith('"')) {
- search = search.slice(1, search.length);
- }
+ if (search.startsWith('"') && search.endsWith('"')) {
+ search = stripQuotes(search);
+ } else if (search.startsWith('"')) {
+ search = search.slice(1, search.length);
+ }
- this.$emit('fetch-suggestions', search);
+ this.$emit('fetch-suggestions', search);
+ }
}
}, DEBOUNCE_DELAY),
handleTokenValueSelected(selectedValue) {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
index 0d3394788fa..11c081ab4f8 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
@@ -57,7 +57,12 @@ export default {
.fetchMilestones(searchTerm)
.then((response) => {
const data = Array.isArray(response) ? response : response.data;
- this.milestones = data.slice().sort(sortMilestonesByDueDate);
+
+ if (this.config.shouldSkipSort) {
+ this.milestones = data;
+ } else {
+ this.milestones = data.slice().sort(sortMilestonesByDueDate);
+ }
})
.catch(() => {
createFlash({ message: __('There was a problem fetching milestones.') });
diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue
deleted file mode 100644
index 9ab91e567e6..00000000000
--- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-<script>
-import Tribute from '@gitlab/tributejs';
-import {
- GfmAutocompleteType,
- tributeConfig,
-} from 'ee_else_ce/vue_shared/components/gfm_autocomplete/utils';
-import * as Emoji from '~/emoji';
-import createFlash from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import SidebarMediator from '~/sidebar/sidebar_mediator';
-
-export default {
- errorMessage: __(
- 'An error occurred while getting autocomplete data. Please refresh the page and try again.',
- ),
- props: {
- autocompleteTypes: {
- type: Array,
- required: false,
- default: () => Object.values(GfmAutocompleteType),
- },
- dataSources: {
- type: Object,
- required: false,
- default: () => gl.GfmAutoComplete?.dataSources || {},
- },
- },
- computed: {
- config() {
- return this.autocompleteTypes.map((type) => ({
- ...tributeConfig[type].config,
- loadingItemTemplate: `<span class="gl-spinner gl-vertical-align-text-bottom gl-ml-3 gl-mr-2"></span>${__(
- 'Loading',
- )}`,
- requireLeadingSpace: true,
- values: this.getValues(type),
- }));
- },
- },
- mounted() {
- this.cache = {};
- this.tribute = new Tribute({ collection: this.config });
-
- const input = this.$slots.default?.[0]?.elm;
- this.tribute.attach(input);
- },
- beforeDestroy() {
- const input = this.$slots.default?.[0]?.elm;
- this.tribute.detach(input);
- },
- methods: {
- cacheAssignees() {
- const isAssigneesLengthSame =
- this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length;
-
- if (!this.assignees || !isAssigneesLengthSame) {
- this.assignees =
- SidebarMediator.singleton?.store?.assignees?.map((assignee) => assignee.username) || [];
- }
- },
- filterValues(type) {
- // The assignees AJAX response can come after the user first invokes autocomplete
- // so we need to check more than once if we need to update the assignee cache
- this.cacheAssignees();
-
- return tributeConfig[type].filterValues
- ? tributeConfig[type].filterValues({
- assignees: this.assignees,
- collection: this.cache[type],
- fullText: this.$slots.default?.[0]?.elm?.value,
- selectionStart: this.$slots.default?.[0]?.elm?.selectionStart,
- })
- : this.cache[type];
- },
- getValues(type) {
- return (inputText, processValues) => {
- if (this.cache[type]) {
- processValues(this.filterValues(type));
- } else if (type === GfmAutocompleteType.Emojis) {
- Emoji.initEmojiMap()
- .then(() => {
- const emojis = Emoji.getValidEmojiNames();
- this.cache[type] = emojis;
- processValues(emojis);
- })
- .catch(() => createFlash({ message: this.$options.errorMessage }));
- } else if (this.dataSources[type]) {
- axios
- .get(this.dataSources[type])
- .then((response) => {
- this.cache[type] = response.data;
- processValues(this.filterValues(type));
- })
- .catch(() => createFlash({ message: this.$options.errorMessage }));
- } else {
- processValues([]);
- }
- };
- },
- },
- render(createElement) {
- return createElement('div', this.$slots.default);
- },
-};
-</script>
diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js
deleted file mode 100644
index 44c3fc34ba6..00000000000
--- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js
+++ /dev/null
@@ -1,195 +0,0 @@
-import { escape, last } from 'lodash';
-import * as Emoji from '~/emoji';
-import { spriteIcon } from '~/lib/utils/common_utils';
-
-const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
-
-// Number of users to show in the autocomplete menu to avoid doing a mass fetch of 100+ avatars
-const memberLimit = 10;
-
-const nonWordOrInteger = /\W|^\d+$/;
-
-export const menuItemLimit = 100;
-
-export const GfmAutocompleteType = {
- Emojis: 'emojis',
- Issues: 'issues',
- Labels: 'labels',
- Members: 'members',
- MergeRequests: 'mergeRequests',
- Milestones: 'milestones',
- QuickActions: 'commands',
- Snippets: 'snippets',
-};
-
-function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
- const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
- const currentLine = fullText.split('\n')[currentLineNumber - 1];
- return currentLine.startsWith(searchString);
-}
-
-export const tributeConfig = {
- [GfmAutocompleteType.Emojis]: {
- config: {
- trigger: ':',
- lookup: (value) => value,
- menuItemLimit,
- menuItemTemplate: ({ original }) => `${original} ${Emoji.glEmojiTag(original)}`,
- selectTemplate: ({ original }) => `:${original}:`,
- },
- },
-
- [GfmAutocompleteType.Issues]: {
- config: {
- trigger: '#',
- lookup: (value) => `${value.iid}${value.title}`,
- menuItemLimit,
- menuItemTemplate: ({ original }) =>
- `<small>${original.reference || original.iid}</small> ${escape(original.title)}`,
- selectTemplate: ({ original }) => original.reference || `#${original.iid}`,
- },
- },
-
- [GfmAutocompleteType.Labels]: {
- config: {
- trigger: '~',
- lookup: 'title',
- menuItemLimit,
- menuItemTemplate: ({ original }) => `
- <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span>
- ${escape(original.title)}`,
- selectTemplate: ({ original }) =>
- nonWordOrInteger.test(original.title)
- ? `~"${escape(original.title)}"`
- : `~${escape(original.title)}`,
- },
- filterValues({ collection, fullText, selectionStart }) {
- if (doesCurrentLineStartWith('/label', fullText, selectionStart)) {
- return collection.filter((label) => !label.set);
- }
-
- if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) {
- return collection.filter((label) => label.set);
- }
-
- return collection;
- },
- },
-
- [GfmAutocompleteType.Members]: {
- config: {
- trigger: '@',
- fillAttr: 'username',
- lookup: (value) =>
- value.type === groupType ? last(value.name.split(' / ')) : `${value.name}${value.username}`,
- menuItemLimit: memberLimit,
- menuItemTemplate: ({ original }) => {
- const commonClasses = 'gl-avatar gl-avatar-s32 gl-flex-shrink-0';
- const noAvatarClasses = `${commonClasses} gl-rounded-small
- gl-display-flex gl-align-items-center gl-justify-content-center`;
-
- const avatar = original.avatar_url
- ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />`
- : `<div class="${noAvatarClasses}" aria-hidden="true">
- ${original.username.charAt(0).toUpperCase()}</div>`;
-
- let displayName = original.name;
- let parentGroupOrUsername = `@${original.username}`;
-
- if (original.type === groupType) {
- const splitName = original.name.split(' / ');
- displayName = splitName.pop();
- parentGroupOrUsername = splitName.pop();
- }
-
- const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
-
- const disabledMentionsIcon = original.mentionsDisabled
- ? spriteIcon('notifications-off', 's16 gl-ml-3')
- : '';
-
- return `
- <div class="gl-display-flex gl-align-items-center">
- ${avatar}
- <div class="gl-line-height-normal gl-ml-4">
- <div>${escape(displayName)}${count}</div>
- <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div>
- </div>
- ${disabledMentionsIcon}
- </div>
- `;
- },
- },
- filterValues({ assignees, collection, fullText, selectionStart }) {
- if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) {
- return collection.filter((member) => !assignees.includes(member.username));
- }
-
- if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) {
- return collection.filter((member) => assignees.includes(member.username));
- }
-
- return collection;
- },
- },
-
- [GfmAutocompleteType.MergeRequests]: {
- config: {
- trigger: '!',
- lookup: (value) => `${value.iid}${value.title}`,
- menuItemLimit,
- menuItemTemplate: ({ original }) =>
- `<small>${original.reference || original.iid}</small> ${escape(original.title)}`,
- selectTemplate: ({ original }) => original.reference || `!${original.iid}`,
- },
- },
-
- [GfmAutocompleteType.Milestones]: {
- config: {
- trigger: '%',
- lookup: 'title',
- menuItemLimit,
- menuItemTemplate: ({ original }) => escape(original.title),
- selectTemplate: ({ original }) => `%"${escape(original.title)}"`,
- },
- },
-
- [GfmAutocompleteType.QuickActions]: {
- config: {
- trigger: '/',
- fillAttr: 'name',
- lookup: (value) => `${value.name}${value.aliases.join()}`,
- menuItemLimit,
- menuItemTemplate: ({ original }) => {
- const aliases = original.aliases.length
- ? `<small>(or /${original.aliases.join(', /')})</small>`
- : '';
-
- const params = original.params.length ? `<small>${original.params.join(' ')}</small>` : '';
-
- let description = '';
-
- if (original.warning) {
- const confidentialIcon =
- original.icon === 'confidential' ? spriteIcon('eye-slash', 's16 gl-mr-2') : '';
- description = `<small>${confidentialIcon}<em>${original.warning}</em></small>`;
- } else if (original.description) {
- description = `<small><em>${original.description}</em></small>`;
- }
-
- return `<div>/${original.name} ${aliases} ${params}</div>
- <div>${description}</div>`;
- },
- },
- },
-
- [GfmAutocompleteType.Snippets]: {
- config: {
- trigger: '$',
- fillAttr: 'id',
- lookup: (value) => `${value.id}${value.title}`,
- menuItemLimit,
- menuItemTemplate: ({ original }) => `<small>${original.id}</small> ${escape(original.title)}`,
- },
- },
-};
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index f36b9107a6e..f3b871c91b6 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -33,6 +33,9 @@ export default {
<template #default>
<div v-safe-html="options.content"></div>
</template>
+ <template v-for="slot in Object.keys($slots)" #[slot]>
+ <slot :name="slot"></slot>
+ </template>
</gl-popover>
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 5c86c928ce3..cbf38984e23 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -8,7 +8,6 @@ import GLForm from '~/gl_form';
import axios from '~/lib/utils/axios_utils';
import { stripHtml } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
-import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownHeader from './header.vue';
@@ -20,7 +19,6 @@ function cleanUpLine(content) {
export default {
components: {
- GfmAutocomplete,
MarkdownHeader,
MarkdownToolbar,
GlIcon,
@@ -212,15 +210,16 @@ export default {
return new GLForm(
$(this.$refs['gl-form']),
{
- emojis: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- epics: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ emojis: this.enableAutocomplete,
+ members: this.enableAutocomplete,
+ issues: this.enableAutocomplete,
+ mergeRequests: this.enableAutocomplete,
+ epics: this.enableAutocomplete,
+ milestones: this.enableAutocomplete,
+ labels: this.enableAutocomplete,
+ snippets: this.enableAutocomplete,
vulnerabilities: this.enableAutocomplete,
+ contacts: this.enableAutocomplete && this.glFeatures.contactsAutocomplete,
},
true,
);
@@ -311,10 +310,7 @@ export default {
/>
<div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop">
- <gfm-autocomplete v-if="glFeatures.tributeAutocomplete">
- <slot name="textarea"></slot>
- </gfm-autocomplete>
- <slot v-else name="textarea"></slot>
+ <slot name="textarea"></slot>
<a
class="zen-control zen-control-leave js-zen-leave gl-text-gray-500"
href="#"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 3ed9de6c133..e2b6579a841 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,9 +1,9 @@
<script>
-import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlPopover, GlButton, GlTooltipDirective, GlTabs, GlTab } from '@gitlab/ui';
import $ from 'jquery';
import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings';
import { getSelectedFragment } from '~/lib/utils/common_utils';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
import ToolbarButton from './toolbar_button.vue';
@@ -12,6 +12,8 @@ export default {
ToolbarButton,
GlPopover,
GlButton,
+ GlTabs,
+ GlTab,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -144,136 +146,143 @@ export default {
italic: keysFor(ITALIC_TEXT),
link: keysFor(LINK_TEXT),
},
+ i18n: {
+ writeTabTitle: __('Write'),
+ previewTabTitle: __('Preview'),
+ },
};
</script>
<template>
<div class="md-header">
- <ul class="nav-links clearfix">
- <li :class="{ active: !previewMarkdown }" class="md-header-tab">
- <button class="js-write-link" type="button" @click="writeMarkdownTab($event)">
- {{ __('Write') }}
- </button>
- </li>
- <li :class="{ active: previewMarkdown }" class="md-header-tab">
- <button
- class="js-preview-link js-md-preview-button"
- type="button"
- @click="previewMarkdownTab($event)"
- >
- {{ __('Preview') }}
- </button>
- </li>
- <li :class="{ active: !previewMarkdown }" class="md-header-toolbar">
- <toolbar-button
- tag="**"
- :button-title="
- sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey })
- "
- :shortcuts="$options.shortcuts.bold"
- icon="bold"
- />
- <toolbar-button
- tag="_"
- :button-title="
- sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey })
- "
- :shortcuts="$options.shortcuts.italic"
- icon="italic"
- />
- <toolbar-button
- :prepend="true"
- :tag="tag"
- :button-title="__('Insert a quote')"
- icon="quote"
- @click="handleQuote"
- />
- <template v-if="canSuggest">
+ <gl-tabs content-class="gl-display-none">
+ <gl-tab
+ title-link-class="gl-pt-3 gl-px-3 js-md-write-button"
+ :title="$options.i18n.writeTabTitle"
+ :active="!previewMarkdown"
+ data-testid="write-tab"
+ @click="writeMarkdownTab($event)"
+ />
+ <gl-tab
+ title-link-class="gl-pt-3 gl-px-3 js-md-preview-button"
+ :title="$options.i18n.previewTabTitle"
+ :active="previewMarkdown"
+ data-testid="preview-tab"
+ @click="previewMarkdownTab($event)"
+ />
+
+ <template v-if="!previewMarkdown" #tabs-end>
+ <div class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center">
+ <toolbar-button
+ tag="**"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey })
+ "
+ :shortcuts="$options.shortcuts.bold"
+ icon="bold"
+ />
+ <toolbar-button
+ tag="_"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey })
+ "
+ :shortcuts="$options.shortcuts.italic"
+ icon="italic"
+ />
<toolbar-button
- ref="suggestButton"
- :tag="mdSuggestion"
:prepend="true"
- :button-title="__('Insert suggestion')"
- :cursor-offset="4"
- :tag-content="lineContent"
- icon="doc-code"
- data-qa-selector="suggestion_button"
- class="js-suggestion-btn"
- @click="handleSuggestDismissed"
+ :tag="tag"
+ :button-title="__('Insert a quote')"
+ icon="quote"
+ @click="handleQuote"
/>
- <gl-popover
- v-if="suggestPopoverVisible"
- :target="$refs.suggestButton.$el"
- :css-classes="['diff-suggest-popover']"
- placement="bottom"
- :show="suggestPopoverVisible"
- >
- <strong>{{ __('New! Suggest changes directly') }}</strong>
- <p class="mb-2">
- {{
- __(
- 'Suggest code changes which can be immediately applied in one click. Try it out!',
- )
- }}
- </p>
- <gl-button
- variant="info"
- category="primary"
- size="small"
+ <template v-if="canSuggest">
+ <toolbar-button
+ ref="suggestButton"
+ :tag="mdSuggestion"
+ :prepend="true"
+ :button-title="__('Insert suggestion')"
+ :cursor-offset="4"
+ :tag-content="lineContent"
+ icon="doc-code"
+ data-qa-selector="suggestion_button"
+ class="js-suggestion-btn"
@click="handleSuggestDismissed"
+ />
+ <gl-popover
+ v-if="suggestPopoverVisible"
+ :target="$refs.suggestButton.$el"
+ :css-classes="['diff-suggest-popover']"
+ placement="bottom"
+ :show="suggestPopoverVisible"
>
- {{ __('Got it') }}
- </gl-button>
- </gl-popover>
- </template>
- <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
- <toolbar-button
- tag="[{text}](url)"
- tag-select="url"
- :button-title="
- sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey })
- "
- :shortcuts="$options.shortcuts.link"
- icon="link"
- />
- <toolbar-button
- :prepend="true"
- tag="- "
- :button-title="__('Add a bullet list')"
- icon="list-bulleted"
- />
- <toolbar-button
- :prepend="true"
- tag="1. "
- :button-title="__('Add a numbered list')"
- icon="list-numbered"
- />
- <toolbar-button
- :prepend="true"
- tag="- [ ] "
- :button-title="__('Add a task list')"
- icon="list-task"
- />
- <toolbar-button
- :tag="mdCollapsibleSection"
- :prepend="true"
- tag-select="Click to expand"
- :button-title="__('Add a collapsible section')"
- icon="details-block"
- />
- <toolbar-button
- :tag="mdTable"
- :prepend="true"
- :button-title="__('Add a table')"
- icon="table"
- />
- <toolbar-button
- class="js-zen-enter"
- :prepend="true"
- :button-title="__('Go full screen')"
- icon="maximize"
- />
- </li>
- </ul>
+ <strong>{{ __('New! Suggest changes directly') }}</strong>
+ <p class="mb-2">
+ {{
+ __(
+ 'Suggest code changes which can be immediately applied in one click. Try it out!',
+ )
+ }}
+ </p>
+ <gl-button
+ variant="info"
+ category="primary"
+ size="small"
+ @click="handleSuggestDismissed"
+ >
+ {{ __('Got it') }}
+ </gl-button>
+ </gl-popover>
+ </template>
+ <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
+ <toolbar-button
+ tag="[{text}](url)"
+ tag-select="url"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey })
+ "
+ :shortcuts="$options.shortcuts.link"
+ icon="link"
+ />
+ <toolbar-button
+ :prepend="true"
+ tag="- "
+ :button-title="__('Add a bullet list')"
+ icon="list-bulleted"
+ />
+ <toolbar-button
+ :prepend="true"
+ tag="1. "
+ :button-title="__('Add a numbered list')"
+ icon="list-numbered"
+ />
+ <toolbar-button
+ :prepend="true"
+ tag="- [ ] "
+ :button-title="__('Add a task list')"
+ icon="list-task"
+ />
+ <toolbar-button
+ :tag="mdCollapsibleSection"
+ :prepend="true"
+ tag-select="Click to expand"
+ :button-title="__('Add a collapsible section')"
+ icon="details-block"
+ />
+ <toolbar-button
+ :tag="mdTable"
+ :prepend="true"
+ :button-title="__('Add a table')"
+ icon="table"
+ />
+ <toolbar-button
+ class="js-zen-enter"
+ :prepend="true"
+ :button-title="__('Go full screen')"
+ icon="maximize"
+ />
+ </div>
+ </template>
+ </gl-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
index 7d2af7983d1..521b1a1075a 100644
--- a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
+++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
@@ -1,34 +1,74 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
import { __ } from '~/locale';
+export const EMPTY_NAMESPACE_ID = -1;
export const i18n = {
DEFAULT_TEXT: __('Select a new namespace'),
+ DEFAULT_EMPTY_NAMESPACE_TEXT: __('No namespace'),
GROUPS: __('Groups'),
USERS: __('Users'),
};
-const filterByName = (data, searchTerm = '') =>
- data.filter((d) => d.humanName.toLowerCase().includes(searchTerm));
+const filterByName = (data, searchTerm = '') => {
+ if (!searchTerm) {
+ return data;
+ }
+
+ return data.filter((d) => d.humanName.toLowerCase().includes(searchTerm.toLowerCase()));
+};
export default {
name: 'NamespaceSelect',
components: {
GlDropdown,
+ GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
},
props: {
- data: {
- type: Object,
- required: true,
+ groupNamespaces: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ userNamespaces: {
+ type: Array,
+ required: false,
+ default: () => [],
},
fullWidth: {
type: Boolean,
required: false,
default: false,
},
+ defaultText: {
+ type: String,
+ required: false,
+ default: i18n.DEFAULT_TEXT,
+ },
+ includeHeaders: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ emptyNamespaceTitle: {
+ type: String,
+ required: false,
+ default: i18n.DEFAULT_EMPTY_NAMESPACE_TEXT,
+ },
+ includeEmptyNamespace: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -38,21 +78,33 @@ export default {
},
computed: {
hasUserNamespaces() {
- return this.data.user?.length;
+ return this.userNamespaces.length;
},
hasGroupNamespaces() {
- return this.data.group?.length;
+ return this.groupNamespaces.length;
},
filteredGroupNamespaces() {
if (!this.hasGroupNamespaces) return [];
- return filterByName(this.data.group, this.searchTerm);
+ return filterByName(this.groupNamespaces, this.searchTerm);
},
filteredUserNamespaces() {
if (!this.hasUserNamespaces) return [];
- return filterByName(this.data.user, this.searchTerm);
+ return filterByName(this.userNamespaces, this.searchTerm);
},
selectedNamespaceText() {
- return this.selectedNamespace?.humanName || this.$options.i18n.DEFAULT_TEXT;
+ return this.selectedNamespace?.humanName || this.defaultText;
+ },
+ filteredEmptyNamespaceTitle() {
+ const { includeEmptyNamespace, emptyNamespaceTitle, searchTerm } = this;
+
+ if (!includeEmptyNamespace) {
+ return '';
+ }
+ if (!searchTerm) {
+ return emptyNamespaceTitle;
+ }
+
+ return emptyNamespaceTitle.toLowerCase().includes(searchTerm.toLowerCase());
},
},
methods: {
@@ -60,31 +112,47 @@ export default {
this.selectedNamespace = item;
this.$emit('select', item);
},
+ handleSelectEmptyNamespace() {
+ this.handleSelect({ id: EMPTY_NAMESPACE_ID, humanName: this.emptyNamespaceTitle });
+ },
},
i18n,
};
</script>
<template>
- <gl-dropdown :text="selectedNamespaceText" :block="fullWidth">
+ <gl-dropdown :text="selectedNamespaceText" :block="fullWidth" data-qa-selector="namespaces_list">
<template #header>
<gl-search-box-by-type v-model.trim="searchTerm" />
</template>
- <div v-if="hasGroupNamespaces" class="qa-namespaces-list-groups">
- <gl-dropdown-section-header>{{ $options.i18n.GROUPS }}</gl-dropdown-section-header>
+ <div v-if="filteredEmptyNamespaceTitle">
+ <gl-dropdown-item
+ data-qa-selector="namespaces_list_item"
+ @click="handleSelectEmptyNamespace()"
+ >
+ {{ emptyNamespaceTitle }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </div>
+ <div v-if="hasGroupNamespaces" data-qa-selector="namespaces_list_groups">
+ <gl-dropdown-section-header v-if="includeHeaders">{{
+ $options.i18n.GROUPS
+ }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="item in filteredGroupNamespaces"
:key="item.id"
- class="qa-namespaces-list-item"
+ data-qa-selector="namespaces_list_item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
</div>
- <div v-if="hasUserNamespaces" class="qa-namespaces-list-users">
- <gl-dropdown-section-header>{{ $options.i18n.USERS }}</gl-dropdown-section-header>
+ <div v-if="hasUserNamespaces" data-qa-selector="namespaces_list_users">
+ <gl-dropdown-section-header v-if="includeHeaders">{{
+ $options.i18n.USERS
+ }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="item in filteredUserNamespaces"
:key="item.id"
- class="qa-namespaces-list-item"
+ data-qa-selector="namespaces_list_item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue
deleted file mode 100644
index 3c0ac32e512..00000000000
--- a/app/assets/javascripts/vue_shared/components/pikaday.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-import { GlDatepicker } from '@gitlab/ui';
-import { pikadayToString } from '~/lib/utils/datetime_utility';
-
-export default {
- name: 'DatePicker',
- components: {
- GlDatepicker,
- },
- props: {
- selectedDate: {
- type: Date,
- required: false,
- default: null,
- },
- minDate: {
- type: Date,
- required: false,
- default: null,
- },
- maxDate: {
- type: Date,
- required: false,
- default: null,
- },
- },
- methods: {
- selected(date) {
- this.$emit('newDateSelected', pikadayToString(date));
- },
- toggled() {
- this.$emit('hidePicker');
- },
- },
-};
-</script>
-
-<template>
- <gl-datepicker
- :value="selectedDate"
- :min-date="minDate"
- :max-date="maxDate"
- start-opened
- @close="toggled"
- @click="toggled"
- @input="selected"
- />
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
index d886a67fff7..5d144c0d699 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
@@ -13,7 +13,7 @@ export default {
},
modalId: 'runner-instructions-modal',
i18n: {
- buttonText: s__('Runners|Show Runner installation instructions'),
+ buttonText: s__('Runners|Show runner installation instructions'),
},
data() {
return {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
deleted file mode 100644
index 460a10e08ed..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-
-export default {
- name: 'CollapsedCalendarIcon',
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- components: {
- GlIcon,
- },
- props: {
- containerClass: {
- type: String,
- required: false,
- default: '',
- },
- text: {
- type: String,
- required: false,
- default: '',
- },
- showIcon: {
- type: Boolean,
- required: false,
- default: true,
- },
- tooltipText: {
- type: String,
- required: false,
- default: '',
- },
- },
- methods: {
- click() {
- this.$emit('click');
- },
- },
-};
-</script>
-
-<template>
- <div v-gl-tooltip.left.viewport="tooltipText" :class="containerClass" @click="click">
- <gl-icon v-if="showIcon" name="calendar" />
- <slot>
- <span> {{ text }} </span>
- </slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
deleted file mode 100644
index 4531fafbf72..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
+++ /dev/null
@@ -1,148 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { dateInWords } from '../../../lib/utils/datetime_utility';
-import datePicker from '../pikaday.vue';
-import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
-import toggleSidebar from './toggle_sidebar.vue';
-
-export default {
- name: 'SidebarDatePicker',
- components: {
- datePicker,
- toggleSidebar,
- collapsedCalendarIcon,
- GlLoadingIcon,
- },
- props: {
- blockClass: {
- type: String,
- required: false,
- default: '',
- },
- collapsed: {
- type: Boolean,
- required: false,
- default: true,
- },
- showToggleSidebar: {
- type: Boolean,
- required: false,
- default: false,
- },
- isLoading: {
- type: Boolean,
- required: false,
- default: false,
- },
- editable: {
- type: Boolean,
- required: false,
- default: false,
- },
- label: {
- type: String,
- required: false,
- default: __('Date picker'),
- },
- selectedDate: {
- type: Date,
- required: false,
- default: null,
- },
- minDate: {
- type: Date,
- required: false,
- default: null,
- },
- maxDate: {
- type: Date,
- required: false,
- default: null,
- },
- },
- data() {
- return {
- editing: false,
- };
- },
- computed: {
- selectedAndEditable() {
- return this.selectedDate && this.editable;
- },
- selectedDateWords() {
- return dateInWords(this.selectedDate, true);
- },
- collapsedText() {
- return this.selectedDateWords ? this.selectedDateWords : __('None');
- },
- },
- methods: {
- stopEditing() {
- this.editing = false;
- },
- toggleDatePicker() {
- this.editing = !this.editing;
- },
- newDateSelected(date = null) {
- this.date = date;
- this.editing = false;
- this.$emit('saveDate', date);
- },
- toggleSidebar() {
- this.$emit('toggleCollapse');
- },
- },
-};
-</script>
-
-<template>
- <div :class="blockClass" class="block">
- <div class="issuable-sidebar-header">
- <toggle-sidebar :collapsed="collapsed" @toggle="toggleSidebar" />
- </div>
- <collapsed-calendar-icon :text="collapsedText" class="sidebar-collapsed-icon" />
- <div class="title">
- {{ label }}
- <gl-loading-icon v-if="isLoading" size="sm" :inline="true" />
- <div class="float-right">
- <button
- v-if="editable && !editing"
- type="button"
- class="btn-blank btn-link btn-primary-hover-link btn-sidebar-action"
- @click="toggleDatePicker"
- >
- {{ __('Edit') }}
- </button>
- <toggle-sidebar v-if="showToggleSidebar" :collapsed="collapsed" @toggle="toggleSidebar" />
- </div>
- </div>
- <div class="value">
- <date-picker
- v-if="editing"
- :selected-date="selectedDate"
- :min-date="minDate"
- :max-date="maxDate"
- :label="label"
- @newDateSelected="newDateSelected"
- @hidePicker="stopEditing"
- />
- <span v-else class="value-content">
- <template v-if="selectedDate">
- <strong>{{ selectedDateWords }}</strong>
- <span v-if="selectedAndEditable" class="no-value">
- -
- <button
- type="button"
- class="btn-blank btn-link btn-secondary-hover-link"
- @click="newDateSelected(null)"
- >
- {{ __('remove') }}
- </button>
- </span>
- </template>
- <span v-else class="no-value">{{ __('None') }}</span>
- </span>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
index b99083713a8..88977652556 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -117,7 +117,11 @@ export default {
labelCreate: { label },
},
},
- ) => this.updateLabelsInCache(store, label),
+ ) => {
+ if (label) {
+ this.updateLabelsInCache(store, label);
+ }
+ },
});
if (labelCreate.errors.length) {
[this.error] = labelCreate.errors;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
deleted file mode 100644
index 17904f20341..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
+++ /dev/null
@@ -1,37 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownForm, GlDropdownDivider } from '@gitlab/ui';
-
-export default {
- components: {
- GlDropdownForm,
- GlDropdown,
- GlDropdownDivider,
- },
- props: {
- headerText: {
- type: String,
- required: true,
- },
- text: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <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>
- <template #footer>
- <slot name="footer"></slot>
- </template>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
new file mode 100644
index 00000000000..9efe0147c37
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -0,0 +1,111 @@
+// Language map from Rouge::Lexer to highlight.js
+// Rouge::Lexer - We use it on the BE to determine the language of a source file (https://github.com/rouge-ruby/rouge/blob/master/docs/Languages.md).
+// Highlight.js - We use it on the FE to highlight the syntax of a source file (https://github.com/highlightjs/highlight.js/tree/main/src/languages).
+export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
+ bsl: '1c',
+ actionscript: 'actionscript',
+ ada: 'ada',
+ apache: 'apache',
+ applescript: 'applescript',
+ armasm: 'armasm',
+ awk: 'awk',
+ c: 'c',
+ ceylon: 'ceylon',
+ clean: 'clean',
+ clojure: 'clojure',
+ cmake: 'cmake',
+ coffeescript: 'coffeescript',
+ coq: 'coq',
+ cpp: 'cpp',
+ crystal: 'crystal',
+ csharp: 'csharp',
+ css: 'css',
+ d: 'd',
+ dart: 'dart',
+ pascal: 'delphi',
+ diff: 'diff',
+ jinja: 'django',
+ docker: 'dockerfile',
+ batchfile: 'dos',
+ elixir: 'elixir',
+ elm: 'elm',
+ erb: 'erb',
+ erlang: 'erlang',
+ fortran: 'fortran',
+ fsharp: 'fsharp',
+ gherkin: 'gherkin',
+ glsl: 'glsl',
+ go: 'go',
+ gradle: 'gradle',
+ groovy: 'groovy',
+ haml: 'haml',
+ handlebars: 'handlebars',
+ haskell: 'haskell',
+ haxe: 'haxe',
+ http: 'http',
+ hylang: 'hy',
+ ini: 'ini',
+ isbl: 'isbl',
+ java: 'java',
+ javascript: 'javascript',
+ json: 'json',
+ julia: 'julia',
+ kotlin: 'kotlin',
+ lasso: 'lasso',
+ tex: 'latex',
+ common_lisp: 'lisp',
+ livescript: 'livescript',
+ llvm: 'llvm',
+ hlsl: 'lsl',
+ lua: 'lua',
+ make: 'makefile',
+ markdown: 'markdown',
+ mathematica: 'mathematica',
+ matlab: 'matlab',
+ moonscript: 'moonscript',
+ nginx: 'nginx',
+ nim: 'nim',
+ nix: 'nix',
+ objective_c: 'objectivec',
+ ocaml: 'ocaml',
+ perl: 'perl',
+ php: 'php',
+ plaintext: 'plaintext',
+ pony: 'pony',
+ powershell: 'powershell',
+ prolog: 'prolog',
+ properties: 'properties',
+ protobuf: 'protobuf',
+ puppet: 'puppet',
+ python: 'python',
+ q: 'q',
+ qml: 'qml',
+ r: 'r',
+ reasonml: 'reasonml',
+ ruby: 'ruby',
+ rust: 'rust',
+ sas: 'sas',
+ scala: 'scala',
+ scheme: 'scheme',
+ scss: 'scss',
+ shell: 'shell',
+ smalltalk: 'smalltalk',
+ sml: 'sml',
+ sqf: 'sqf',
+ sql: 'sql',
+ stan: 'stan',
+ stata: 'stata',
+ swift: 'swift',
+ tap: 'tap',
+ tcl: 'tcl',
+ twig: 'twig',
+ typescript: 'typescript',
+ vala: 'vala',
+ vb: 'vbnet',
+ verilog: 'verilog',
+ vhdl: 'vhdl',
+ viml: 'vim',
+ xml: 'xml',
+ xquery: 'xquery',
+ yaml: 'yaml',
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index 99895926653..5aae1812de3 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -1,36 +1,31 @@
<script>
-import { GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui';
import LineNumbers from '~/vue_shared/components/line_numbers.vue';
import { sanitize } from '~/lib/dompurify';
+import { ROUGE_TO_HLJS_LANGUAGE_MAP } from './constants';
+import { wrapLines } from './utils';
const LINE_SELECT_CLASS_NAME = 'hll';
export default {
components: {
LineNumbers,
+ GlLoadingIcon,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
props: {
- content: {
- type: String,
+ blob: {
+ type: Object,
required: true,
},
- language: {
- type: String,
- required: false,
- default: 'plaintext',
- },
- autoDetect: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
return {
languageDefinition: null,
+ content: this.blob.rawTextBlob,
+ language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language],
hljs: null,
};
},
@@ -42,14 +37,14 @@ export default {
let highlightedContent;
if (this.hljs) {
- if (this.autoDetect) {
+ if (!this.language) {
highlightedContent = this.hljs.highlightAuto(this.content).value;
} else if (this.languageDefinition) {
highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
}
}
- return this.wrapLines(highlightedContent);
+ return wrapLines(highlightedContent);
},
},
watch: {
@@ -63,14 +58,14 @@ export default {
async mounted() {
this.hljs = await this.loadHighlightJS();
- if (!this.autoDetect) {
+ if (this.language) {
this.languageDefinition = await this.loadLanguage();
}
},
methods: {
loadHighlightJS() {
- // With auto-detect enabled we load all common languages else we load only the core (smallest footprint)
- return this.autoDetect ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
+ // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
+ return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
},
async loadLanguage() {
let languageDefinition;
@@ -84,15 +79,6 @@ export default {
return languageDefinition;
},
- wrapLines(content) {
- return (
- content &&
- content
- .split('\n')
- .map((line, i) => `<span id="LC${i + 1}" class="line">${line}</span>`)
- .join('\r\n')
- );
- },
selectLine() {
const hash = sanitize(this.$route.hash);
const lineToSelect = hash && this.$el.querySelector(hash);
@@ -115,9 +101,16 @@ export default {
};
</script>
<template>
- <div class="file-content code js-syntax-highlight" :class="$options.userColorScheme">
+ <gl-loading-icon v-if="!highlightedContent" size="sm" class="gl-my-5" />
+ <div
+ v-else
+ class="file-content code js-syntax-highlight blob-content gl-display-flex"
+ :class="$options.userColorScheme"
+ data-type="simple"
+ data-qa-selector="blob_viewer_file_content"
+ >
<line-numbers :lines="lineNumbers" />
- <pre class="code"><code v-safe-html="highlightedContent"></code>
+ <pre class="code gl-pb-0!"><code v-safe-html="highlightedContent"></code>
</pre>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
new file mode 100644
index 00000000000..e64e564bf61
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
@@ -0,0 +1,26 @@
+export const wrapLines = (content) => {
+ return (
+ content &&
+ content
+ .split('\n')
+ .map((line, i) => {
+ let formattedLine;
+ const idAttribute = `id="LC${i + 1}"`;
+
+ if (line.includes('<span class="hljs') && !line.includes('</span>')) {
+ /**
+ * In some cases highlight.js will wrap multiple lines in a span, in these cases we want to append the line number to the existing span
+ *
+ * example (before): <span class="hljs-code">```bash
+ * example (after): <span id="LC67" class="hljs-code">```bash
+ */
+ formattedLine = line.replace(/(?=class="hljs)/, `${idAttribute} `);
+ } else {
+ formattedLine = `<span ${idAttribute} class="line">${line}</span>`;
+ }
+
+ return formattedLine;
+ })
+ .join('\n')
+ );
+};
diff --git a/app/assets/javascripts/vue_shared/components/svg_gradient.vue b/app/assets/javascripts/vue_shared/components/svg_gradient.vue
deleted file mode 100644
index 5ce45d492f9..00000000000
--- a/app/assets/javascripts/vue_shared/components/svg_gradient.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-<script>
-export default {
- props: {
- colors: {
- type: Array,
- required: true,
- validator(value) {
- return value.length === 2;
- },
- },
- opacity: {
- type: Array,
- required: true,
- validator(value) {
- return value.length === 2;
- },
- },
- identifierName: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-<template>
- <svg height="0" width="0">
- <defs>
- <linearGradient :id="identifierName">
- <stop :stop-color="colors[0]" :stop-opacity="opacity[0]" offset="0%" />
- <stop :stop-color="colors[1]" :stop-opacity="opacity[1]" offset="100%" />
- </linearGradient>
- </defs>
- </svg>
-</template>
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 0a7a22ed3a8..62de76e46b5 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
@@ -41,6 +41,16 @@ export default {
required: false,
default: false,
},
+ inputFieldName: {
+ type: String,
+ required: false,
+ default: 'upload_file',
+ },
+ shouldUpdateInputOnFileDrop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -84,6 +94,30 @@ export default {
return;
}
+ // NOTE: This is a temporary solution to integrate dropzone into a Rails
+ // form. On file drop if `shouldUpdateInputOnFileDrop` is true, the file
+ // input value is updated. So that when the form is submitted — the file
+ // value would be send together with the form data. This solution should
+ // be removed when License file upload page is fully migrated:
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/352501
+ // NOTE: as per https://caniuse.com/mdn-api_htmlinputelement_files, IE11
+ // is not able to set input.files property, thought the user would still
+ // be able to use the file picker dialogue option, by clicking the
+ // "openFileUpload" button
+ if (this.shouldUpdateInputOnFileDrop) {
+ // Since FileList cannot be easily manipulated, to match requirement of
+ // singleFileSelection, we're throwing an error if multiple files were
+ // dropped on the dropzone
+ // NOTE: we can drop this logic together with
+ // `shouldUpdateInputOnFileDrop` flag
+ if (this.singleFileSelection && files.length > 1) {
+ this.$emit('error');
+ return;
+ }
+
+ this.$refs.fileUpload.files = files;
+ }
+
this.$emit('change', this.singleFileSelection ? files[0] : files);
},
ondragenter(e) {
@@ -116,6 +150,7 @@ export default {
<slot>
<button
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"
+ type="button"
@click="openFileUpload"
>
<div
@@ -147,7 +182,7 @@ export default {
<input
ref="fileUpload"
type="file"
- name="upload_file"
+ :name="inputFieldName"
:accept="validFileMimetypes"
class="hide"
:multiple="!singleFileSelection"
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index f02cd5c4e2e..82022d1f4d6 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -1,9 +1,9 @@
<script>
-import $ from 'jquery';
import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue';
const KEY_EDIT = 'edit';
const KEY_WEB_IDE = 'webide';
@@ -16,6 +16,7 @@ export default {
GlModal,
GlSprintf,
GlLink,
+ ConfirmForkModal,
},
i18n: {
modal: {
@@ -103,11 +104,22 @@ export default {
required: false,
default: false,
},
+ forkPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ forkModalId: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
selection: KEY_WEB_IDE,
showEnableGitpodModal: false,
+ showForkModal: false,
};
},
computed: {
@@ -128,7 +140,7 @@ export default {
return;
}
- this.showJQueryModal('#modal-confirm-fork-edit');
+ this.showModal('showForkModal');
},
}
: { href: this.editUrl };
@@ -171,7 +183,7 @@ export default {
return;
}
- this.showJQueryModal('#modal-confirm-fork-webide');
+ this.showModal('showForkModal');
},
}
: { href: this.webIdeUrl };
@@ -247,9 +259,6 @@ export default {
select(key) {
this.selection = key;
},
- showJQueryModal(id) {
- $(id).modal('show');
- },
showModal(dataKey) {
this[dataKey] = true;
},
@@ -282,5 +291,11 @@ export default {
</template>
</gl-sprintf>
</gl-modal>
+ <confirm-fork-modal
+ v-if="showWebIdeButton || showEditButton"
+ v-model="showForkModal"
+ :modal-id="forkModalId"
+ :fork-path="forkPath"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
index af0235bfc69..8008b85bbdb 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
@@ -31,10 +31,6 @@ export default {
type: Object,
required: true,
},
- enableLabelPermalinks: {
- type: Boolean,
- required: true,
- },
labelFilterParam: {
type: String,
required: false,
@@ -121,7 +117,10 @@ export default {
},
showIssuableMeta() {
return Boolean(
- this.hasSlotContents('status') || this.showDiscussions || this.issuable.assignees,
+ this.hasSlotContents('status') ||
+ this.hasSlotContents('statistics') ||
+ this.showDiscussions ||
+ this.issuable.assignees,
);
},
issuableNotesLink() {
@@ -139,11 +138,8 @@ export default {
return label.title || label.name;
},
labelTarget(label) {
- if (this.enableLabelPermalinks) {
- const value = encodeURIComponent(this.labelTitle(label));
- return `?${this.labelFilterParam}[]=${value}`;
- }
- return '#';
+ const value = encodeURIComponent(this.labelTitle(label));
+ return `?${this.labelFilterParam}[]=${value}`;
},
/**
* This is needed as an independent method since
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
index 2f8401b45f0..028d48e7e8a 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -15,6 +15,7 @@ const VueDraggable = () => import('vuedraggable');
export default {
vueDraggableAttributes: {
animation: 200,
+ forceFallback: true,
ghostClass: 'gl-visibility-hidden',
tag: 'ul',
},
@@ -78,6 +79,11 @@ export default {
required: false,
default: null,
},
+ truncateCounts: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
currentTab: {
type: String,
required: true,
@@ -127,11 +133,6 @@ export default {
required: false,
default: 2,
},
- enableLabelPermalinks: {
- type: Boolean,
- required: false,
- default: true,
- },
labelFilterParam: {
type: String,
required: false,
@@ -261,6 +262,7 @@ export default {
:tabs="tabs"
:tab-counts="tabCounts"
:current-tab="currentTab"
+ :truncate-counts="truncateCounts"
@click="$emit('click-tab', $event)"
>
<template #nav-actions>
@@ -314,7 +316,6 @@ export default {
:data-qa-issuable-title="issuable.title"
:issuable-symbol="issuableSymbol"
:issuable="issuable"
- :enable-label-permalinks="enableLabelPermalinks"
:label-filter-param="labelFilterParam"
:show-checkbox="showBulkEditSidebar"
:checked="issuableChecked(issuable)"
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
index 9bf54e98cc4..0691bc02b5c 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
@@ -1,5 +1,6 @@
<script>
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
+import { numberToMetricPrefix } from '~/lib/utils/number_utils';
import { formatNumber } from '~/locale';
export default {
@@ -22,6 +23,11 @@ export default {
type: String,
required: true,
},
+ truncateCounts: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
methods: {
isTabActive(tabName) {
@@ -31,7 +37,7 @@ export default {
return Number.isInteger(this.tabCounts[tab.name]);
},
formatNumber(count) {
- return formatNumber(count);
+ return this.truncateCounts ? numberToMetricPrefix(count) : formatNumber(count);
},
},
};
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
index d7da533d055..ee7e113af72 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
@@ -102,7 +102,7 @@ export default {
</div>
</div>
<span>
- {{ __('Opened') }}
+ {{ __('Created') }}
<time-ago-tooltip data-testid="startTimeItem" :time="createdAt" />
{{ __('by') }}
</span>
diff --git a/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
index 99dcccd12ed..774267639fc 100644
--- a/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
@@ -1,8 +1,8 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import Cookies from 'js-cookie';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
+
import { USER_COLLAPSED_GUTTER_COOKIE } from '../constants';
export default {
@@ -10,7 +10,7 @@ export default {
GlIcon,
},
data() {
- const userExpanded = !parseBoolean(Cookies.get(USER_COLLAPSED_GUTTER_COOKIE));
+ const userExpanded = !parseBoolean(getCookie(USER_COLLAPSED_GUTTER_COOKIE));
// We're deliberately keeping two different props for sidebar status;
// 1. userExpanded reflects value based on cookie `collapsed_gutter`.
@@ -46,7 +46,7 @@ export default {
this.isExpanded = !this.isExpanded;
this.userExpanded = this.isExpanded;
- Cookies.set(USER_COLLAPSED_GUTTER_COOKIE, !this.userExpanded);
+ setCookie(USER_COLLAPSED_GUTTER_COOKIE, !this.userExpanded);
this.updatePageContainerClass();
},
},
diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
index f67e590e2ce..1f3cc663848 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
@@ -11,7 +11,7 @@ export default {
WelcomePage,
LegacyContainer,
CreditCardVerification: () =>
- import('ee_component/pages/groups/new/components/credit_card_verification.vue'),
+ import('ee_component/namespaces/verification/components/credit_card_verification.vue'),
},
directives: {
SafeHtml,
diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
index d1630c9ac13..3afd1f9410b 100644
--- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -14,7 +14,7 @@ export default {
components: {
GlButton,
},
- inject: ['projectPath'],
+ inject: ['projectFullPath'],
props: {
feature: {
type: Object,
@@ -47,7 +47,7 @@ export default {
try {
const { mutationSettings } = this;
const { data } = await this.$apollo.mutate(
- mutationSettings.getMutationPayload(this.projectPath),
+ mutationSettings.getMutationPayload(this.projectFullPath),
);
const { errors, successPath } = data[mutationSettings.mutationId];
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
index 12f2bc71505..f6d85599dba 100644
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -102,8 +102,8 @@ export default {
error(error) {
this.showError(error);
},
- result({ loading }) {
- if (loading) {
+ result({ loading, data }) {
+ if (loading || !data) {
return;
}
diff --git a/app/assets/javascripts/work_items/graphql/fragmentTypes.json b/app/assets/javascripts/work_items/graphql/fragmentTypes.json
deleted file mode 100644
index 3b837e84ee9..00000000000
--- a/app/assets/javascripts/work_items/graphql/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"INTERFACE","name":"LocalWorkItemWidget","possibleTypes":[{"name":"LocalTitleWidget"}]}]}}
diff --git a/app/assets/javascripts/work_items/graphql/project_work_item_types.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_item_types.query.graphql
new file mode 100644
index 00000000000..e7e3ce8c1ae
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/project_work_item_types.query.graphql
@@ -0,0 +1,11 @@
+query projectWorkItemTypes($fullPath: ID!) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ workItemTypes {
+ nodes {
+ id
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index fb536a425c0..676fffb12d8 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -1,23 +1,14 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
import workItemQuery from './work_item.query.graphql';
-import introspectionQueryResultData from './fragmentTypes.json';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
export function createApolloProvider() {
Vue.use(VueApollo);
const defaultClient = createDefaultClient(resolvers, {
- cacheConfig: {
- fragmentMatcher,
- },
typeDefs,
});
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 7cc8a23b7b1..10fae9b9cc0 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -5,11 +5,15 @@ import { createApolloProvider } from './graphql/provider';
export const initWorkItemsRoot = () => {
const el = document.querySelector('#js-work-items');
+ const { fullPath } = el.dataset;
return new Vue({
el,
router: createRouter(el.dataset.fullPath),
apolloProvider: createApolloProvider(),
+ provide: {
+ fullPath,
+ },
render(createElement) {
return createElement(App);
},
diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue
index 12bad5606d4..6c3bcf8f6a8 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -1,6 +1,8 @@
<script>
-import { GlButton, GlAlert } from '@gitlab/ui';
+import { GlButton, GlAlert, GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { s__ } from '~/locale';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
+import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
import ItemTitle from '../components/item_title.vue';
@@ -8,14 +10,55 @@ export default {
components: {
GlButton,
GlAlert,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownItem,
ItemTitle,
},
+ inject: ['fullPath'],
+ props: {
+ isModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ initialTitle: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
data() {
return {
- title: '',
- error: false,
+ title: this.initialTitle,
+ error: null,
+ workItemTypes: [],
+ selectedWorkItemType: null,
};
},
+ apollo: {
+ workItemTypes: {
+ query: projectWorkItemTypesQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.workItemTypes?.nodes;
+ },
+ error() {
+ this.error = s__(
+ 'WorkItem|Something went wrong when fetching work item types. Please try again',
+ );
+ },
+ },
+ },
+ computed: {
+ dropdownButtonText() {
+ return this.selectedWorkItemType?.name || s__('WorkItem|Type');
+ },
+ },
methods: {
async createWorkItem() {
try {
@@ -35,35 +78,82 @@ export default {
},
},
} = response;
- this.$router.push({ name: 'workItem', params: { id } });
+ if (!this.isModal) {
+ this.$router.push({ name: 'workItem', params: { id } });
+ } else {
+ this.$emit('onCreate', this.title);
+ }
} catch {
- this.error = true;
+ this.error = s__(
+ 'WorkItem|Something went wrong when creating a work item. Please try again',
+ );
}
},
handleTitleInput(title) {
this.title = title;
},
+ handleCancelClick() {
+ if (!this.isModal) {
+ this.$router.go(-1);
+ return;
+ }
+ this.$emit('closeModal');
+ },
+ selectWorkItemType(type) {
+ this.selectedWorkItemType = type;
+ },
},
};
</script>
<template>
<form @submit.prevent="createWorkItem">
- <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
- __('Something went wrong when creating a work item. Please try again')
- }}</gl-alert>
- <item-title data-testid="title-input" @title-input="handleTitleInput" />
- <div class="gl-bg-gray-10 gl-py-5 gl-px-6">
+ <gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert>
+ <div :class="{ 'gl-px-5': isModal }" data-testid="content">
+ <item-title
+ :initial-title="title"
+ data-testid="title-input"
+ @title-input="handleTitleInput"
+ />
+ <div>
+ <gl-dropdown :text="dropdownButtonText">
+ <gl-loading-icon
+ v-if="$apollo.queries.workItemTypes.loading"
+ size="md"
+ data-testid="loading-types"
+ />
+ <template v-else>
+ <gl-dropdown-item
+ v-for="type in workItemTypes"
+ :key="type.id"
+ @click="selectWorkItemType(type)"
+ >
+ {{ type.name }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </div>
+ </div>
+ <div
+ class="gl-bg-gray-10 gl-py-5 gl-px-6 gl-mt-4"
+ :class="{ 'gl-display-flex gl-justify-content-end': isModal }"
+ >
<gl-button
variant="confirm"
:disabled="title.length === 0"
- class="gl-mr-3"
+ :class="{ 'gl-mr-3': !isModal }"
data-testid="create-button"
type="submit"
>
- {{ __('Create') }}
+ {{ s__('WorkItem|Create work item') }}
</gl-button>
- <gl-button type="button" data-testid="cancel-button" @click="$router.go(-1)">
+ <gl-button
+ type="button"
+ data-testid="cancel-button"
+ class="gl-order-n1"
+ :class="{ 'gl-mr-3': isModal }"
+ @click="handleCancelClick"
+ >
{{ __('Cancel') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/work_items_hierarchy/components/app.vue b/app/assets/javascripts/work_items_hierarchy/components/app.vue
new file mode 100644
index 00000000000..621cfe5bace
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/components/app.vue
@@ -0,0 +1,101 @@
+<script>
+import { GlBanner } from '@gitlab/ui';
+import Cookies from 'js-cookie';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import RESPONSE from '../static_response';
+import { WORK_ITEMS_SURVEY_COOKIE_NAME, workItemTypes } from '../constants';
+import Hierarchy from './hierarchy.vue';
+
+export default {
+ components: {
+ GlBanner,
+ Hierarchy,
+ },
+ inject: ['illustrationPath', 'licensePlan'],
+ data() {
+ return {
+ bannerVisible: !parseBoolean(Cookies.get(WORK_ITEMS_SURVEY_COOKIE_NAME)),
+ workItemHierarchy: RESPONSE[this.licensePlan],
+ };
+ },
+ computed: {
+ hasUnavailableStructure() {
+ return this.workItemTypes.unavailable.length > 0;
+ },
+ workItemTypes() {
+ return this.workItemHierarchy.reduce(
+ (itemTypes, item) => {
+ const skipItem = workItemTypes[item.type].isWorkItem && !window.gon?.features?.workItems;
+
+ if (skipItem) {
+ return itemTypes;
+ }
+ const key = item.available ? 'available' : 'unavailable';
+ const nestedTypes = item.nestedTypes?.map((type) => workItemTypes[type]);
+
+ itemTypes[key].push({
+ ...item,
+ ...workItemTypes[item.type],
+ nestedTypes,
+ });
+
+ return itemTypes;
+ },
+ { available: [], unavailable: [] },
+ );
+ },
+ },
+ methods: {
+ handleClose() {
+ Cookies.set(WORK_ITEMS_SURVEY_COOKIE_NAME, 'true', { expires: 365 * 10 });
+ this.bannerVisible = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-banner
+ v-if="bannerVisible"
+ class="gl-mt-4 gl-px-5!"
+ :title="s__('Hierarchy|Help us improve work items in GitLab!')"
+ :button-text="s__('Hierarchy|Take the work items survey')"
+ button-link="https://forms.gle/u1BmRp8rTbwj52iq5"
+ :svg-path="illustrationPath"
+ @close="handleClose"
+ >
+ <p>
+ {{
+ s__(
+ 'Hierarchy|Is there a framework or type of work item you wish you had access to in GitLab? Give us your feedback and help us build the experiences valuable to you.',
+ )
+ }}
+ </p>
+ </gl-banner>
+ <h3 class="gl-mt-5!">{{ s__('Hierarchy|Planning hierarchy') }}</h3>
+ <p>
+ {{
+ s__(
+ 'Hierarchy|Deliver value more efficiently by breaking down necessary work into a hierarchical structure. This structure helps teams understand scope, priorities, and how work cascades up toward larger goals.',
+ )
+ }}
+ </p>
+
+ <div class="gl-font-weight-bold gl-mb-2">{{ s__('Hierarchy|Current structure') }}</div>
+ <p class="gl-mb-3!">{{ s__('Hierarchy|You can start using these items now.') }}</p>
+ <hierarchy :work-item-types="workItemTypes.available" />
+
+ <div
+ v-if="hasUnavailableStructure"
+ data-testid="unavailable-structure"
+ class="gl-font-weight-bold gl-mt-5 gl-mb-2"
+ >
+ {{ s__('Hierarchy|Unavailable structure') }}
+ </div>
+ <p v-if="hasUnavailableStructure" class="gl-mb-3!">
+ {{ s__('Hierarchy|These items are unavailable in the current structure.') }}
+ </p>
+ <hierarchy :work-item-types="workItemTypes.unavailable" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue
new file mode 100644
index 00000000000..9b81218b6e4
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlIcon, GlBadge } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ GlBadge,
+ },
+ props: {
+ workItemTypes: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ isLastItem(index, workItem) {
+ const hasMoreThanOneItem = workItem.nestedTypes.length > 1;
+ const isLastItemInArray = index === workItem.nestedTypes.length - 1;
+
+ return isLastItemInArray && hasMoreThanOneItem;
+ },
+ nestedWorkItemTypeMargin(index, workItem) {
+ const isLastItemInArray = index === workItem.nestedTypes.length - 1;
+ const hasMoreThanOneItem = workItem.nestedTypes.length > 1;
+
+ if (isLastItemInArray && hasMoreThanOneItem) {
+ return 'gl-ml-0';
+ }
+
+ return 'gl-ml-6';
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div
+ v-for="workItem in workItemTypes"
+ :key="workItem.id"
+ class="gl-mb-3"
+ :class="{ flex: !workItem.available }"
+ >
+ <span
+ class="gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base gl-pl-2 gl-pt-2 gl-pb-2 gl-pr-3 gl-display-inline-flex gl-align-items-center gl-justify-content-center gl-line-height-normal"
+ data-testid="work-item-wrapper"
+ >
+ <span
+ :style="{
+ backgroundColor: workItem.backgroundColor,
+ color: workItem.color,
+ }"
+ class="gl-rounded-base gl-mr-2 gl-display-inline-flex justify-content-center align-items-center hierarchy-icon-wrapper"
+ >
+ <gl-icon :size="workItem.iconSize || 12" :name="workItem.icon" />
+ </span>
+
+ {{ workItem.title }}
+ </span>
+
+ <gl-badge
+ v-if="!workItem.available"
+ variant="info"
+ icon="license"
+ size="sm"
+ class="gl-ml-3 gl-align-self-center"
+ >{{ workItem.license }}</gl-badge
+ >
+
+ <div v-if="workItem.nestedTypes" :class="{ 'gl-relative': workItem.nestedTypes.length > 1 }">
+ <svg
+ v-if="workItem.nestedTypes.length > 1"
+ class="hierarchy-rounded-arrow-tail gl-text-gray-400"
+ data-testid="hierarchy-rounded-arrow-tail"
+ width="2"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <line
+ x1="0.75"
+ y1="1"
+ x2="0.75"
+ y2="100%"
+ stroke="currentColor"
+ stroke-width="1.5"
+ stroke-linecap="round"
+ />
+ </svg>
+ <template v-for="(nestedWorkItem, index) in workItem.nestedTypes">
+ <div :key="nestedWorkItem.id" class="gl-display-block gl-mt-2 gl-ml-6">
+ <gl-icon name="arrow-down" class="gl-text-gray-400" />
+ </div>
+ <gl-icon
+ v-if="isLastItem(index, workItem)"
+ :key="nestedWorkItem.id"
+ name="level-up"
+ class="gl-text-gray-400 gl-ml-2 hierarchy-rounded-arrow"
+ />
+ <span
+ :key="nestedWorkItem.id"
+ class="gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base gl-pl-2 gl-pt-2 gl-pb-2 gl-pr-3 gl-display-inline-flex gl-align-items-center gl-justify-content-center gl-mt-2 gl-line-height-normal"
+ :class="nestedWorkItemTypeMargin(index, workItem)"
+ >
+ <span
+ :style="{
+ backgroundColor: nestedWorkItem.backgroundColor,
+ color: nestedWorkItem.color,
+ }"
+ class="gl-rounded-base gl-mr-2 gl-display-inline-flex justify-content-center align-items-center hierarchy-icon-wrapper"
+ >
+ <gl-icon :size="nestedWorkItem.iconSize || 12" :name="nestedWorkItem.icon" />
+ </span>
+
+ {{ nestedWorkItem.title }}
+ </span>
+ </template>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items_hierarchy/constants.js b/app/assets/javascripts/work_items_hierarchy/constants.js
new file mode 100644
index 00000000000..c14fe67af4d
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/constants.js
@@ -0,0 +1,62 @@
+import { __ } from '~/locale';
+
+export const WORK_ITEMS_SURVEY_COOKIE_NAME = 'hide_work_items_hierarchy_survey';
+
+/**
+ * Hard-coded strings since we're rendering hierarchy
+ * items from mock responses. Remove this when we
+ * have a real hierarchy endpoint.
+ */
+export const LICENSE_PLAN = {
+ FREE: 'free',
+ PREMIUM: 'premium',
+ ULTIMATE: 'ultimate',
+};
+
+export const workItemTypes = {
+ EPIC: {
+ title: __('Epic'),
+ icon: 'epic',
+ color: '#694CC0',
+ backgroundColor: '#E1D8F9',
+ },
+ ISSUE: {
+ title: __('Issue'),
+ icon: 'issues',
+ color: '#1068BF',
+ backgroundColor: '#CBE2F9',
+ },
+ TASK: {
+ title: __('Task'),
+ icon: 'task-done',
+ color: '#217645',
+ backgroundColor: '#C3E6CD',
+ isWorkItem: true,
+ },
+ INCIDENT: {
+ title: __('Incident'),
+ icon: 'issue-type-incident',
+ backgroundColor: '#db2a0f',
+ color: '#FDD4CD',
+ iconSize: 16,
+ },
+ SUB_EPIC: {
+ title: __('Child epic'),
+ icon: 'epic',
+ color: '#AB6100',
+ backgroundColor: '#F5D9A8',
+ },
+ REQUIREMENT: {
+ title: __('Requirement'),
+ icon: 'requirements',
+ color: '#0068c5',
+ backgroundColor: '#c5e3fb',
+ },
+ TEST_CASE: {
+ title: __('Test case'),
+ icon: 'issue-type-test-case',
+ backgroundColor: '#007a3f',
+ color: '#bae8cb',
+ iconSize: 16,
+ },
+};
diff --git a/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js b/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js
new file mode 100644
index 00000000000..61d93acdb91
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js
@@ -0,0 +1,10 @@
+import { LICENSE_PLAN } from './constants';
+
+export function inferLicensePlan({ hasSubEpics, hasEpics }) {
+ if (hasSubEpics) {
+ return LICENSE_PLAN.ULTIMATE;
+ } else if (hasEpics) {
+ return LICENSE_PLAN.PREMIUM;
+ }
+ return LICENSE_PLAN.FREE;
+}
diff --git a/app/assets/javascripts/work_items_hierarchy/static_response.js b/app/assets/javascripts/work_items_hierarchy/static_response.js
new file mode 100644
index 00000000000..d1e2e486082
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/static_response.js
@@ -0,0 +1,142 @@
+const FREE_TIER = 'free';
+const ULTIMATE_TIER = 'ultimate';
+const PREMIUM_TIER = 'premium';
+
+const RESPONSE = {
+ [FREE_TIER]: [
+ {
+ id: '1',
+ type: 'ISSUE',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '2',
+ type: 'TASK',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '3',
+ type: 'INCIDENT',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '4',
+ type: 'EPIC',
+ available: false,
+ license: 'Premium', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ {
+ id: '5',
+ type: 'SUB_EPIC',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ {
+ id: '6',
+ type: 'REQUIREMENT',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ {
+ id: '7',
+ type: 'TEST_CASE',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ ],
+
+ [PREMIUM_TIER]: [
+ {
+ id: '1',
+ type: 'EPIC',
+ available: true,
+ license: null,
+ nestedTypes: ['ISSUE'],
+ },
+ {
+ id: '2',
+ type: 'TASK',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '3',
+ type: 'INCIDENT',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '5',
+ type: 'SUB_EPIC',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ {
+ id: '6',
+ type: 'REQUIREMENT',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ {
+ id: '7',
+ type: 'TEST_CASE',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ ],
+
+ [ULTIMATE_TIER]: [
+ {
+ id: '1',
+ type: 'EPIC',
+ available: true,
+ license: null,
+ nestedTypes: ['SUB_EPIC', 'ISSUE'],
+ },
+ {
+ id: '2',
+ type: 'TASK',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '3',
+ type: 'INCIDENT',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '6',
+ type: 'REQUIREMENT',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '7',
+ type: 'TEST_CASE',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ ],
+};
+
+export default RESPONSE;
diff --git a/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js b/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js
new file mode 100644
index 00000000000..2258c725301
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import App from './components/app.vue';
+import { inferLicensePlan } from './hierarchy_util';
+
+export const initWorkItemsHierarchy = () => {
+ const el = document.querySelector('#js-work-items-hierarchy');
+
+ const { illustrationPath, hasEpics, hasSubEpics } = el.dataset;
+
+ const licensePlan = inferLicensePlan({
+ hasEpics: parseBoolean(hasEpics),
+ hasSubEpics: parseBoolean(hasSubEpics),
+ });
+
+ return new Vue({
+ el,
+ provide: {
+ illustrationPath,
+ licensePlan,
+ },
+ render(createElement) {
+ return createElement(App);
+ },
+ });
+};
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index ff2b82d1806..24549a170bd 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -31,3 +31,4 @@
@import './pages/storage_quota';
@import './pages/tree';
@import './pages/users';
+@import './pages/hierarchy';
diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss
index 377d5130571..a9be1d89495 100644
--- a/app/assets/stylesheets/components/design_management/design.scss
+++ b/app/assets/stylesheets/components/design_management/design.scss
@@ -1,4 +1,5 @@
$design-pin-diameter: 28px;
+$design-pin-diameter-sm: 24px;
$t-gray-a-16-design-pin: rgba($black, 0.16);
.layout-page.design-detail-layout {
@@ -12,24 +13,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
top: 35px;
}
- .design-note-pin {
- display: flex;
- height: $design-pin-diameter;
- width: $design-pin-diameter;
- box-sizing: content-box;
- background-color: $purple-500;
- color: $white;
- font-weight: $gl-font-weight-bold;
- border-radius: 50%;
- z-index: 1;
- padding: 0;
- border: 0;
-
- &.resolved {
- background-color: $gray-500;
- }
- }
-
.comment-indicator {
border-radius: 50%;
}
@@ -40,35 +23,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
cursor: grabbing;
}
}
-
- /**
- * Design pin that overlays the design
- */
- .frame .design-note-pin {
- box-shadow: 0 2px 4px $t-gray-a-08, 0 0 1px $t-gray-a-24;
- border: $white 2px solid;
- will-change: transform, box-shadow, opacity;
- // NOTE: verbose transition property required for Safari
- transition: transform $general-hover-transition-duration linear, box-shadow $general-hover-transition-duration linear, opacity $general-hover-transition-duration linear;
- transform-origin: 0 0;
- transform: translate(-50%, -50%);
-
- &:hover {
- transform: scale(1.2) translate(-50%, -50%);
- }
-
- &:active {
- box-shadow: 0 0 4px $t-gray-a-16-design-pin, 0 4px 12px $t-gray-a-16-design-pin;
- }
-
- &.inactive {
- @include gl-opacity-5;
-
- &:hover {
- @include gl-opacity-10;
- }
- }
- }
}
.design-scaler-wrapper {
@@ -177,3 +131,63 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
.design-card-header {
background: transparent;
}
+
+.design-note-pin {
+ display: flex;
+ height: $design-pin-diameter;
+ width: $design-pin-diameter;
+ box-sizing: content-box;
+ background-color: $purple-500;
+ color: $white;
+ font-weight: $gl-font-weight-bold;
+ border-radius: 50%;
+ z-index: 1;
+ padding: 0;
+ border: 0;
+
+ &.draft {
+ background-color: $orange-500;
+ }
+
+ &.resolved {
+ background-color: $gray-500;
+ }
+
+ &.on-image {
+ box-shadow: 0 2px 4px $t-gray-a-08, 0 0 1px $t-gray-a-24;
+ border: $white 2px solid;
+ will-change: transform, box-shadow, opacity;
+ // NOTE: verbose transition property required for Safari
+ transition: transform $general-hover-transition-duration linear, box-shadow $general-hover-transition-duration linear, opacity $general-hover-transition-duration linear;
+ transform-origin: 0 0;
+ transform: translate(-50%, -50%);
+
+ &:hover {
+ transform: scale(1.2) translate(-50%, -50%);
+ }
+
+ &:active {
+ box-shadow: 0 0 4px $t-gray-a-16-design-pin, 0 4px 12px $t-gray-a-16-design-pin;
+ }
+
+ &.inactive {
+ @include gl-opacity-5;
+
+ &:hover {
+ @include gl-opacity-10;
+ }
+ }
+ }
+
+ &.small {
+ position: absolute;
+ border: 1px solid $white;
+ height: $design-pin-diameter-sm;
+ width: $design-pin-diameter-sm;
+ }
+
+ &.user-avatar {
+ top: 25px;
+ right: 8px;
+ }
+}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index c1c8bfffff7..8e43a9b1b0d 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -46,7 +46,6 @@
@import 'framework/toggle';
@import 'framework/typography';
@import 'framework/zen';
-@import 'framework/blank';
@import 'framework/wells';
@import 'framework/page_header';
@import 'framework/page_title';
diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss
deleted file mode 100644
index 7dd7ab339dd..00000000000
--- a/app/assets/stylesheets/framework/blank.scss
+++ /dev/null
@@ -1,118 +0,0 @@
-.blank-state-parent-container {
- .section-container {
- padding: 10px;
- }
-
- .section-body {
- width: 100%;
- height: 100%;
- padding-bottom: 25px;
- border-radius: $border-radius-default;
- }
-}
-
-.blank-state-row {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
-}
-
-.blank-state-welcome {
- text-align: center;
- padding: $gl-padding 0 ($gl-padding * 2);
-
- .blank-state-welcome-title {
- font-size: 24px;
- }
-
- .blank-state-text {
- margin-bottom: 0;
- }
-}
-
-.blank-state-link {
- color: $gl-text-color;
- margin-bottom: 15px;
-
- &:hover {
- background-color: $gray-light;
- text-decoration: none;
- color: $gl-text-color;
- }
-}
-
-.blank-state-center {
- padding-top: 20px;
- padding-bottom: 20px;
- text-align: center;
-}
-
-.blank-state {
- display: flex;
- align-items: center;
- padding: 20px 50px;
- border: 1px solid $border-color;
- border-radius: $border-radius-default;
- min-height: 240px;
- margin-bottom: $gl-padding;
- width: calc(50% - #{$gl-padding-8});
-
- @include media-breakpoint-down(sm) {
- width: 100%;
- flex-direction: column;
- justify-content: center;
- padding: 50px 20px;
-
- .column-small & {
- width: 100%;
- }
-
- }
-}
-
-.blank-state,
-.blank-state-center {
- .blank-state-icon {
- svg {
- display: block;
- margin: auto;
- }
- }
-
- .blank-state-title {
- margin-top: 0;
- font-size: 18px;
- }
-
- .blank-state-body {
- @include media-breakpoint-down(sm) {
- text-align: center;
- margin-top: 20px;
- }
-
- @include media-breakpoint-up(sm) {
- padding-left: 20px;
- }
- }
-}
-
-@include media-breakpoint-up(lg) {
- .column-large {
- flex: 2;
- }
-
- .column-small {
- flex: 1;
- margin-bottom: 15px;
-
- .blank-state {
- max-width: 400px;
- flex-wrap: wrap;
- margin-left: 15px;
- }
-
- .blank-state-icon {
- margin-bottom: 30px;
- }
- }
-}
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index e0e9043ae24..9cebd4f49a4 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -158,12 +158,6 @@
line-height: $gl-btn-small-line-height;
}
- &.btn-xs {
- padding: 2px $gl-btn-padding;
- font-size: $gl-btn-xs-font-size;
- line-height: $gl-btn-xs-line-height;
- }
-
&.btn-success {
@include btn-green;
}
@@ -372,29 +366,6 @@
background-color: transparent;
border-color: transparent;
}
-
- &.btn-secondary-hover-link,
- &.btn-default-hover-link {
- color: $gl-text-color-secondary;
-
- &:hover,
- &:active,
- &:focus {
- color: $blue-600;
- text-decoration: none;
- }
- }
-
- &.btn-primary-hover-link {
- color: inherit;
-
- &:hover,
- &:active,
- &:focus {
- color: $blue-600;
- text-decoration: none;
- }
- }
}
// The .btn-svg class is available for legacy icon buttons to
@@ -438,10 +409,6 @@ fieldset[disabled] .btn,
cursor: default;
}
-.btn-no-padding {
- padding: 0;
-}
-
// This class helps convert `.gl-button` children so that they consistently
// match the style of `.btn` elements which might be around them. Ideally we
// wouldn't need this class.
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 2a3ed29258a..7b4f68e7a44 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -267,6 +267,8 @@
.nav-item-name {
flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
> a,
@@ -336,7 +338,8 @@
.nav-sidebar-inner-scroll {
@include gl-h-full;
@include gl-w-full;
- @include gl-overflow-auto;
+ @include gl-overflow-x-hidden;
+ @include gl-overflow-y-auto;
> div.context-header {
@include gl-mt-2;
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index ffacac07517..f0495fdc94e 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -582,6 +582,25 @@ table.code {
}
}
+.diff-expansion-cell {
+ flex: 1 1;
+ min-width: max-content;
+}
+
+.diff-expansion-cell-middle {
+ flex: 0 1 max-content;
+}
+
+@media only screen and (min-width: $breakpoint-xl) {
+ .diff-expansion-cell-start {
+ text-align: right;
+ }
+
+ .diff-expansion-cell-end {
+ text-align: left;
+ }
+}
+
// Merge request diff grid layout
.diff-grid {
.diff-td {
@@ -603,6 +622,14 @@ table.code {
grid-template-columns: 50px 8px 0 1fr;
}
+ .diff-grid-3-col {
+ grid-template-columns: 50px 1fr !important;
+ }
+
+ &.inline-diff-view .diff-grid-3-col {
+ grid-template-columns: 50px 50px 1fr !important;
+ }
+
.diff-grid-comments {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -832,6 +859,8 @@ table.code {
}
.diff-files-changed {
+ background-color: $body-bg;
+
.inline-parallel-buttons {
@include gl-relative;
z-index: 1;
@@ -840,7 +869,6 @@ table.code {
@include media-breakpoint-up(sm) {
@include gl-sticky;
top: calc(#{$header-height} + #{$mr-tabs-height});
- @include gl-bg-white;
z-index: 200;
.with-performance-bar & {
@@ -1064,24 +1092,6 @@ table.code {
}
}
-.frame .badge.badge-pill,
-.image-diff-avatar-link .badge.badge-pill,
-.user-avatar-link .badge.badge-pill,
-.notes > .badge.badge-pill {
- position: absolute;
- background-color: $blue-400;
- color: $white;
- border: $white 1px solid;
- min-height: $gl-padding;
- padding: 5px 8px;
- border-radius: 12px;
-
- &:focus {
- outline: none;
- }
-}
-
-.frame .badge.badge-pill,
.frame .image-comment-badge,
.frame .comment-indicator {
// Center align badges on the frame
@@ -1113,11 +1123,6 @@ table.code {
}
}
-.notes > .badge.badge-pill {
- display: none;
- left: -13px;
-}
-
.discussion-notes {
min-height: 35px;
@@ -1126,18 +1131,22 @@ table.code {
min-height: 25px;
}
+ .diff-notes-expand {
+ display: none;
+ }
+
&.collapsed {
background-color: $white;
+ .diff-notes-expand {
+ display: initial;
+ }
+
.diff-notes-collapse,
.note,
.discussion-reply-holder {
display: none;
}
-
- .notes > .badge.badge-pill {
- display: block;
- }
}
}
@@ -1183,7 +1192,7 @@ table.code {
}
}
-@media (max-width: map-get($grid-breakpoints, md)-1) {
+@media (max-width: map-get($grid-breakpoints, lg)-1) {
.diffs .files {
@include fixed-width-container;
flex-direction: column;
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 9209a0c2173..9387500e66f 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -411,11 +411,6 @@ span.idiff {
margin-right: 1.5em;
}
-.label-lfs {
- color: $common-gray-light;
- border: 1px solid $common-gray-light;
-}
-
.preview-container {
overflow: auto;
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index 2a46e50f0da..4d0d64ae723 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -150,6 +150,10 @@ label {
margin-bottom: 0;
margin-top: #{$grid-size / 2};
font-size: $gl-font-size;
+
+ .invisible {
+ visibility: hidden;
+ }
}
.gl-field-error,
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 68535badd78..1004383cfd3 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -150,7 +150,7 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important
}
li {
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge) {
+ .badge.badge-pill:not(.gl-badge) {
box-shadow: none;
font-weight: $gl-font-weight-bold;
}
@@ -415,49 +415,6 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important
}
}
-.title-container,
-.navbar-nav {
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge) {
- position: inherit;
- font-weight: $gl-font-weight-normal;
- margin-left: -6px;
- font-size: 11px;
- color: var(--gray-950, $white);
- padding: 0 5px;
- line-height: 12px;
- border-radius: 7px;
- box-shadow: 0 1px 0 rgba($gl-header-color, 0.2);
-
- &.green-badge {
- background-color: var(--green-400, $green-400);
- }
-
- &.merge-requests-count {
- background-color: var(--orange-400, $orange-400);
- }
-
- &.todos-count {
- background-color: var(--blue-400, $blue-400);
- }
- }
-
- .canary-badge {
- .badge {
- font-size: $gl-font-size-small;
- line-height: $gl-line-height;
- padding: 0 $grid-size;
- }
-
- &:hover {
- text-decoration: none;
-
- .badge {
- text-decoration: none;
- }
- }
- }
-}
-
@include media-breakpoint-down(xs) {
.navbar-gitlab .container-fluid {
font-size: 18px;
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index c6e52c13e83..7731ec751c9 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -67,6 +67,27 @@
}
}
}
+
+ .gl-tabs-nav {
+ @include media-breakpoint-down(xs) {
+ .nav-item {
+ flex: 1;
+ border-bottom: 1px solid $border-color;
+ }
+
+ .gl-tab-nav-item {
+ padding-top: $gl-padding-4;
+ padding-bottom: $gl-padding-8;
+ }
+
+ .md-header-toolbar {
+ width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: $gl-padding-8;
+ }
+ }
+ }
}
.md-header-tab {
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 563075b911c..8cad55f414a 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -18,7 +18,6 @@
line-height: 28px;
color: $gl-text-color-secondary;
border: 0;
- border-bottom: 2px solid transparent;
white-space: nowrap;
&:hover,
@@ -26,7 +25,7 @@
&:focus {
text-decoration: none;
color: $black;
- border-bottom: 2px solid $gray-darkest;
+ box-shadow: inset 0 -2px 0 0 $gray-darkest;
}
}
@@ -40,7 +39,7 @@
a.active {
color: $black;
font-weight: $gl-font-weight-bold;
- border-bottom: 2px solid var(--gl-theme-accent, $theme-indigo-500);
+ box-shadow: inset 0 -2px 0 0 var(--gl-theme-accent, $theme-indigo-500);
.badge.badge-pill {
color: $black;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 51c41c46f61..feedc40b487 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -47,6 +47,10 @@
margin-bottom: $gl-spacing-scale-2;
}
+ img {
+ max-width: 100%;
+ }
+
img:not(.emoji) {
margin: 0 0 8px;
}
@@ -62,15 +66,6 @@
min-width: inherit;
min-height: inherit;
background-color: inherit;
- max-width: 100%;
- }
-
- &:not(.md) img:not(.emoji) {
- border: 1px solid $white-normal;
- padding: 5px;
- margin: 5px 0;
- // Ensure that image does not exceed viewport
- max-height: calc(100vh - 100px);
}
details {
@@ -375,7 +370,8 @@
// Loose lists need bottom margin added back
p ~ ol,
p ~ ul {
- margin-bottom: 16px; }
+ margin-bottom: 16px;
+ }
}
ul:dir(rtl),
@@ -521,32 +517,26 @@
-moz-osx-font-smoothing: grayscale;
}
- .fa-2x,
.admonitionblock td.icon [class^='fa icon-'] {
font-size: 2em;
}
- .fa-exclamation-triangle::before,
.admonitionblock td.icon .icon-warning::before {
content: '⚠';
}
- .fa-exclamation-circle::before,
.admonitionblock td.icon .icon-important::before {
content: '❗';
}
- .fa-lightbulb-o::before,
.admonitionblock td.icon .icon-tip::before {
content: '💡';
}
- .fa-thumb-tack::before,
.admonitionblock td.icon .icon-note::before {
content: '📌';
}
- .fa-fire::before,
.admonitionblock td.icon .icon-caution::before {
content: '🔥';
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 21add43ad3f..31ef5ae0646 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -52,6 +52,11 @@ $spacing-scale: (
5: #{4 * $grid-size}
);
+/* Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 */
+$gl-spacing-scale-48: 48 * $grid-size;
+$gl-spacing-scale-75: 75 * $grid-size;
+/* End gitlab-ui#1709 */
+
/*
* Why another sizing scale???
* Great question, friend!
@@ -589,8 +594,6 @@ $gl-btn-vert-padding: 8px;
$gl-btn-horz-padding: 12px;
$gl-btn-small-font-size: 13px;
$gl-btn-small-line-height: 18px;
-$gl-btn-xs-font-size: 13px;
-$gl-btn-xs-line-height: 13px;
/*
* Badges
@@ -722,7 +725,7 @@ $calendar-activity-colors: (
#7fa8c9,
#527ba0,
#254e77,
-);
+) !default;
/*
* Commit Page
@@ -931,8 +934,6 @@ Merge requests
*/
$mr-tabs-height: 48px;
$mr-version-controls-height: 56px;
-$mr-widget-margin-left: 40px;
-$mr-review-bar-height: calc(2rem + 13px);
/*
Compare Branches
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
index 97dd7edef13..bd327082e20 100644
--- a/app/assets/stylesheets/highlight/common.scss
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -28,8 +28,24 @@
border-bottom: 1px solid $border;
}
- a {
+ button {
color: $link;
+ border: 0;
+ background: transparent;
+
+ &[disabled] {
+ color: desaturate($link, 100%);
+ opacity: 0.5;
+ cursor: default;
+ }
+
+ &:hover:not([disabled]) {
+ text-decoration: underline;
+ }
+
+ &:not(:focus-visible) {
+ outline: 0;
+ }
}
}
@@ -37,11 +53,11 @@
transition: border-left 0.1s ease-out;
&.coverage {
- border-left: 4px solid $coverage;
+ border-left: 2px solid $coverage;
}
&.no-coverage {
- border-left: 2px solid $no-coverage;
+ border-left: 4px solid $no-coverage;
}
}
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 0b696f1be60..28878280d24 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -36,6 +36,7 @@ $dark-cm: #969896;
$dark-cp: #969896;
$dark-c1: #969896;
$dark-cs: #969896;
+$dark-cd: #969896;
$dark-gd: #c66;
$dark-gh: #8abeb7;
$dark-gi: #b5bd68;
@@ -168,8 +169,8 @@ $dark-il: #de935f;
}
}
- .diff-grid-left:hover,
- .diff-grid-right:hover,
+ &:not(.match) .diff-grid-left:hover,
+ &:not(.match) .diff-grid-right:hover,
&.code-search-line:hover {
.diff-line-num:not(.empty-cell) {
@include line-number-hover;
@@ -236,6 +237,7 @@ $dark-il: #de935f;
.cp { color: $dark-cp; } /* Comment.Preproc */
.c1 { color: $dark-c1; } /* Comment.Single */
.cs { color: $dark-cs; } /* Comment.Special */
+ .cd { color: $dark-cd; } /* Comment.Doc */
.gd { color: $dark-gd; } /* Generic.Deleted */
.ge { font-style: italic; } /* Generic.Emph */
.gh { /* Generic.Heading */
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index ae72c0b6bf4..6faf1cffdef 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -38,6 +38,7 @@ $monokai-cm: #75715e;
$monokai-cp: #75715e;
$monokai-c1: #75715e;
$monokai-cs: #75715e;
+$monokai-cd: #75715e;
$monokai-kc: #66d9ef;
$monokai-kd: #66d9ef;
$monokai-kn: #f92672;
@@ -169,8 +170,8 @@ $monokai-gh: #75715e;
}
}
- .diff-grid-left:hover,
- .diff-grid-right:hover,
+ &:not(.match) .diff-grid-left:hover,
+ &:not(.match) .diff-grid-right:hover,
&.code-search-line:hover {
.diff-line-num:not(.empty-cell) {
@include line-number-hover;
@@ -240,6 +241,7 @@ $monokai-gh: #75715e;
.cp { color: $monokai-cp; } /* Comment.Preproc */
.c1 { color: $monokai-c1; } /* Comment.Single */
.cs { color: $monokai-cs; } /* Comment.Special */
+ .cd { color: $monokai-cd; } /* Comment.Doc */
.ge { font-style: italic; } /* Generic.Emph */
.gs { font-weight: $gl-font-weight-bold; } /* Generic.Strong */
.kc { color: $monokai-kc; } /* Keyword.Constant */
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index 913b289d808..9c28d9463dc 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -66,9 +66,9 @@
}
}
- .diff-grid-left:hover,
- .diff-grid-right:hover,
- &.code-search-line:hover {
+ &:not(.match) .diff-grid-left:hover,
+ &:not(.match) .diff-grid-right:hover,
+ &:not(.match) &.code-search-line:hover {
.diff-line-num:not(.empty-cell) {
@include line-number-hover;
}
@@ -204,6 +204,7 @@
.cp { color: $gl-text-color; } /* Comment.Preproc */
.c1 { color: $gl-text-color; } /* Comment.Single */
.cs { color: $gl-text-color; } /* Comment.Special */
+ .cd { color: $gl-text-color; } /* Comment.Doc */
.ge { color: $gl-text-color; } /* Generic.Emph */
.gr { color: $gl-text-color; } /* Generic.Error */
.gh { color: $gl-text-color; } /* Generic.Heading */
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index eee699ca4c2..c9f889c79fc 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -35,6 +35,7 @@ $solarized-dark-cm: #586e75;
$solarized-dark-cp: #859900;
$solarized-dark-c1: #586e75;
$solarized-dark-cs: #859900;
+$solarized-dark-cd: #586e75;
$solarized-dark-gd: #2aa198;
$solarized-dark-ge: #93a1a1;
$solarized-dark-gr: #dc322f;
@@ -148,8 +149,8 @@ $solarized-dark-il: #2aa198;
@include line-coverage-border-color($solarized-dark-coverage, $solarized-dark-no-coverage);
}
- .diff-grid-left:hover,
- .diff-grid-right:hover,
+ &:not(.match) .diff-grid-left:hover,
+ &:not(.match) .diff-grid-right:hover,
&.code-search-line:hover {
.diff-line-num:not(.empty-cell) {
@include line-number-hover;
@@ -258,6 +259,7 @@ $solarized-dark-il: #2aa198;
.cp { color: $solarized-dark-cp; } /* Comment.Preproc */
.c1 { color: $solarized-dark-c1; } /* Comment.Single */
.cs { color: $solarized-dark-cs; } /* Comment.Special */
+ .cd { color: $solarized-dark-cd; } /* Comment.Doc */
.gd { color: $solarized-dark-gd; } /* Generic.Deleted */
.ge { /* Generic.Emph */
color: $solarized-dark-ge;
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index 8c5e1f7318b..0108d7e496f 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -37,6 +37,7 @@ $solarized-light-cm: #93a1a1;
$solarized-light-cp: #859900;
$solarized-light-c1: #93a1a1;
$solarized-light-cs: #859900;
+$solarized-light-cd: #93a1a1;
$solarized-light-gd: #2aa198;
$solarized-light-ge: #586e75;
$solarized-light-gr: #dc322f;
@@ -168,8 +169,8 @@ $solarized-light-il: #2aa198;
}
}
- .diff-grid-left:hover,
- .diff-grid-right:hover,
+ &:not(.match) .diff-grid-left:hover,
+ &:not(.match) .diff-grid-right:hover,
&.code-search-line:hover {
.diff-line-num:not(.empty-cell) {
@include line-number-hover;
@@ -266,6 +267,7 @@ $solarized-light-il: #2aa198;
.cp { color: $solarized-light-cp; } /* Comment.Preproc */
.c1 { color: $solarized-light-c1; } /* Comment.Single */
.cs { color: $solarized-light-cs; } /* Comment.Special */
+ .cd { color: $solarized-light-cd; } /* Comment.Doc */
.gd { color: $solarized-light-gd; } /* Generic.Deleted */
.ge { /* Generic.Emph */
color: $solarized-light-ge;
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 80052f4a4d5..91d8f4a1ba5 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -18,6 +18,7 @@ $white-cm: #998;
$white-cp: #999;
$white-c1: #998;
$white-cs: #999;
+$white-cd: #998;
$white-gd: $black;
$white-gd-bg: #fdd;
$white-gd-x: $black;
@@ -118,6 +119,15 @@ pre.code,
.line_expansion {
@include diff-expansion($gray-light, $border-color, $blue-600);
+
+ &.diff-tr:last-child {
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+
+ .diff-td {
+ border-bottom: 0;
+ }
+ }
}
// Diff line
@@ -128,8 +138,8 @@ pre.code,
@include match-line;
}
- .diff-grid-left:hover,
- .diff-grid-right:hover,
+ &:not(.match) .diff-grid-left:hover,
+ &:not(.match) .diff-grid-right:hover,
&.code-search-line:hover {
.diff-line-num:not(.empty-cell):not(.conflict_marker_their):not(.conflict_marker_our) {
@include line-number-hover;
@@ -281,6 +291,9 @@ span.highlight_word {
font-weight: $gl-font-weight-bold;
font-style: italic; }
+.cd { color: $white-cd;
+ font-style: italic; }
+
.gd {
color: $white-gd;
background-color: $white-gd-bg;
diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
index 75c2428c1d4..fd212d14e30 100644
--- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss
+++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
@@ -22,6 +22,7 @@ $highlighted-cm: #998;
$highlighted-cp: #999;
$highlighted-c1: #998;
$highlighted-cs: #999;
+$highlighted-cd: #998;
$highlighted-gd: #000;
$highlighted-gd-bg: #fdd;
$highlighted-gd-x: #000;
@@ -173,6 +174,9 @@ span.highlight_word {
font-weight: $gl-font-weight-bold;
font-style: italic; }
+.cd { color: $highlighted-cd;
+ font-style: italic; }
+
.gd {
color: $highlighted-gd;
background-color: $highlighted-gd-bg;
diff --git a/app/assets/stylesheets/page_bundles/dashboard_projects.scss b/app/assets/stylesheets/page_bundles/dashboard_projects.scss
new file mode 100644
index 00000000000..eb0e1701b7f
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/dashboard_projects.scss
@@ -0,0 +1,35 @@
+@import 'mixins_and_variables_and_functions';
+
+.blank-state {
+ padding: 20px 50px;
+ min-height: 240px;
+ width: calc(50% - #{$gl-padding-8});
+
+ @include media-breakpoint-down(sm) {
+ width: 100%;
+ flex-direction: column;
+ justify-content: center;
+ padding: 50px 20px;
+ }
+}
+
+.blank-state-link {
+ &:hover {
+ background-color: $gray-light;
+ text-decoration: none;
+ color: $gl-text-color;
+ }
+}
+
+.blank-state-icon {
+ svg {
+ display: block;
+ }
+}
+
+.blank-state-body {
+ @include media-breakpoint-down(sm) {
+ text-align: center;
+ margin-top: 20px;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index d37171bc75e..6c270852e53 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -630,7 +630,6 @@ $ide-commit-header-height: 48px;
width: 1px;
background: var(--ide-highlight-background, $white);
}
-
}
&.is-right {
@@ -642,17 +641,6 @@ $ide-commit-header-height: 48px;
left: -1px;
}
}
-
- .ide-commit-badge {
- background-color: var(--ide-highlight-accent, $almost-black) !important;
- color: var(--ide-highlight-background, $white) !important;
- position: absolute;
- left: 38px;
- top: $gl-padding-8;
- font-size: $gl-font-size-12;
- padding: 2px $gl-padding-4;
- font-weight: $gl-font-weight-bold !important;
- }
}
.ide-activity-bar {
diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss
index 9fe0490571e..1c8fd7e2590 100644
--- a/app/assets/stylesheets/page_bundles/jira_connect.scss
+++ b/app/assets/stylesheets/page_bundles/jira_connect.scss
@@ -40,10 +40,6 @@ $header-height: 40px;
max-width: 1000px;
}
-.jira-connect-app-body {
- max-width: 768px;
-}
-
// needed for external_link
svg.s16 {
width: 16px;
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 37ab2e2be2b..63e951be698 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -1,5 +1,10 @@
@import 'mixins_and_variables_and_functions';
+$mr-review-bar-height: calc(2rem + 13px);
+$mr-widget-margin-left: 40px;
+$mr-widget-min-height: 69px;
+$tabs-holder-z-index: 250;
+
.compare-versions-container {
min-width: 0;
}
@@ -45,11 +50,9 @@
top: calc(#{$top-pos} + var(--system-header-height, 0px) + var(--performance-bar-height, 0px));
// stylelint-disable-next-line length-zero-no-unit
max-height: calc(100vh - #{$top-pos} - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
- z-index: 205;
.drag-handle {
bottom: 16px;
- transform: translateX(10px);
}
}
@@ -94,7 +97,7 @@
line-height: 0;
}
-@media (max-width: map-get($grid-breakpoints, md)-1) {
+@media (max-width: map-get($grid-breakpoints, lg)-1) {
.diffs .files {
.diff-tree-list {
position: relative;
@@ -110,6 +113,638 @@
}
}
+.ci-widget-container {
+ justify-content: space-between;
+ flex: 1;
+ flex-direction: row;
+
+ @include media-breakpoint-down(sm) {
+ flex-direction: column;
+
+ .stage-cell .stage-container {
+ margin-top: 16px;
+ }
+
+ .dropdown .mini-pipeline-graph-dropdown-menu.dropdown-menu {
+ transform: initial;
+ }
+ }
+
+ .coverage {
+ font-size: 12px;
+ color: var(--gray-500, $gray-500);
+ line-height: initial;
+ }
+}
+
+.deploy-body {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+
+ @include media-breakpoint-up(xs) {
+ flex-wrap: nowrap;
+ white-space: nowrap;
+ }
+
+ @include media-breakpoint-down(md) {
+ flex-direction: column;
+ align-items: flex-start;
+
+ .deployment-info {
+ margin-bottom: $gl-padding;
+ }
+ }
+
+ > *:not(:last-child) {
+ margin-right: 0.3em;
+ }
+
+ svg {
+ vertical-align: text-top;
+ }
+
+ .deployment-info {
+ flex: 1;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ min-width: 100px;
+
+ @include media-breakpoint-up(xs) {
+ min-width: 0;
+ max-width: 100%;
+ }
+ }
+
+ .dropdown-menu {
+ width: 400px;
+ }
+}
+
+.deploy-heading,
+.merge-train-position-indicator {
+ @include media-breakpoint-up(md) {
+ padding: $gl-padding-8 $gl-padding;
+ }
+
+ .media-body {
+ min-width: 0;
+ font-size: 12px;
+ margin-left: 32px;
+ }
+
+ &:not(:last-child) {
+ border-bottom: 1px solid var(--border-color, $border-color);
+ }
+}
+
+.diff-file-row.is-active {
+ background-color: var(--gray-50, $gray-50);
+}
+
+.mr-conflict-loader {
+ max-width: 334px;
+
+ > svg {
+ vertical-align: middle;
+ }
+}
+
+.mr-info-list {
+ clear: left;
+ position: relative;
+ padding-top: 4px;
+
+ p {
+ margin: 0;
+ position: relative;
+ padding: 4px 0;
+
+ &:last-child {
+ padding-bottom: 0;
+ }
+ }
+
+ &.mr-memory-usage {
+ p {
+ float: left;
+ }
+
+ .memory-graph-container {
+ float: left;
+ margin-left: 5px;
+ }
+ }
+}
+
+.mr-memory-usage {
+ width: 100%;
+
+ p.usage-info-loading .usage-info-load-spinner {
+ margin-right: 10px;
+ font-size: 16px;
+ }
+}
+
+.mr-ready-to-merge-loader {
+ max-width: 418px;
+
+ > svg {
+ vertical-align: middle;
+ }
+}
+
+.mr-section-container {
+ border: 1px solid var(--border-color, $border-color);
+ border-radius: $border-radius-default;
+ background: var(--white, $white);
+
+ > .mr-widget-border-top:first-of-type {
+ border-top: 0;
+ }
+}
+
+.mr-source-target {
+ flex-wrap: wrap;
+ padding: $gl-padding;
+ background: var(--white, $white);
+ min-height: $mr-widget-min-height;
+
+ @include media-breakpoint-up(md) {
+ align-items: center;
+ }
+
+ .git-merge-container {
+ justify-content: space-between;
+ flex: 1;
+ flex-direction: row;
+ align-items: center;
+
+ @include media-breakpoint-down(md) {
+ flex-direction: column;
+ align-items: stretch;
+
+ .branch-actions {
+ margin-top: 16px;
+ }
+ }
+
+ @include media-breakpoint-up(lg) {
+ .branch-actions {
+ align-self: center;
+ margin-left: $gl-padding;
+ white-space: nowrap;
+ }
+ }
+ }
+
+ .diverged-commits-count {
+ color: var(--gray-500, $gl-text-color-secondary);
+ }
+}
+
+.mr-state-widget {
+ color: var(--gl-text-color, $gl-text-color);
+
+ .commit-message-edit {
+ border-radius: $border-radius-default;
+ }
+
+ .mr-widget-section:not(:first-child) {
+ border-top: solid 1px var(--border-color, $border-color);
+ }
+
+ .mr-widget-alert-container + .mr-widget-section {
+ border-top: 0;
+ }
+
+ .mr-fast-forward-message {
+ padding-left: $gl-padding-50;
+ padding-bottom: $gl-padding;
+ }
+
+ .commits-list {
+ > li {
+ padding: $gl-padding;
+
+ @include media-breakpoint-up(md) {
+ margin-left: $gl-spacing-scale-7;
+ }
+ }
+ }
+
+ .mr-commit-dropdown {
+ .dropdown-menu {
+ @include media-breakpoint-up(md) {
+ width: 150%;
+ }
+ }
+ }
+
+ .mr-report {
+ padding: 0;
+
+ > .media {
+ padding: $gl-padding;
+ }
+ }
+
+ form {
+ margin-bottom: 0;
+
+ .clearfix {
+ margin-bottom: 0;
+ }
+ }
+
+ label {
+ margin-bottom: 0;
+ }
+
+ .btn {
+ font-size: $gl-font-size;
+ }
+
+ .accept-merge-holder {
+ .accept-action {
+ display: inline-block;
+ float: left;
+ }
+ }
+
+ .ci-widget {
+ color: var(--gl-text-color, $gl-text-color);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ @include media-breakpoint-down(xs) {
+ flex-wrap: wrap;
+ }
+
+ .ci-widget-content {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ }
+ }
+
+ .mr-widget-icon {
+ font-size: 22px;
+ }
+
+ .mr-loading-icon {
+ margin: 3px 0;
+ }
+
+ .ci-status-icon svg {
+ margin: 3px 0;
+ position: relative;
+ overflow: visible;
+ display: block;
+ }
+
+ .mr-widget-pipeline-graph {
+ .dropdown-menu {
+ z-index: $zindex-dropdown-menu;
+ }
+ }
+
+ .normal {
+ flex: 1;
+ flex-basis: auto;
+ }
+
+ .capitalize {
+ text-transform: capitalize;
+ }
+
+ .label-branch {
+ @include gl-font-monospace;
+ font-size: 95%;
+ color: var(--gl-text-color, $gl-text-color);
+ font-weight: normal;
+ overflow: hidden;
+ word-break: break-all;
+ }
+
+ .deploy-link,
+ .label-branch {
+ &.label-truncate {
+ // NOTE: This selector targets its children because some of the HTML comes from
+ // 'source_branch_link'. Once this external HTML is no longer used, we could
+ // simplify this.
+ > a,
+ > span {
+ display: inline-block;
+ max-width: 12.5em;
+ margin-bottom: -6px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+ }
+
+ .mr-widget-body {
+ &:not(.mr-widget-body-line-height-1) {
+ line-height: 28px;
+ }
+
+ @include clearfix;
+
+ .approve-btn {
+ margin-right: 5px;
+ }
+
+ h4 {
+ float: left;
+ font-weight: $gl-font-weight-bold;
+ font-size: 14px;
+ line-height: inherit;
+ margin-top: 0;
+ margin-bottom: 0;
+
+ time {
+ font-weight: $gl-font-weight-normal;
+ }
+ }
+
+ .btn-grouped {
+ margin-left: 0;
+ margin-right: 7px;
+ }
+
+ label {
+ font-weight: $gl-font-weight-normal;
+ }
+
+ .spacing {
+ margin: 0 0 0 10px;
+ }
+
+ .bold,
+ .gl-font-weight-bold {
+ font-weight: $gl-font-weight-bold;
+ color: var(--gray-600, $gray-600);
+ margin-left: 10px;
+ }
+
+ .state-label {
+ font-weight: $gl-font-weight-bold;
+ padding-right: 10px;
+ }
+
+ .danger {
+ color: var(--red-500, $red-500);
+ }
+
+ .spacing,
+ .bold,
+ .gl-font-weight-bold {
+ vertical-align: middle;
+ }
+
+ .dropdown-menu {
+ li a {
+ padding: 5px;
+ }
+
+ .merge-opt-icon {
+ line-height: 1.5;
+ }
+
+ .merge-opt-title {
+ margin-left: 8px;
+ }
+ }
+
+ .has-custom-error {
+ display: inline-block;
+ }
+
+ @include media-breakpoint-down(xs) {
+ p {
+ font-size: 13px;
+ }
+
+ .btn-grouped {
+ float: none;
+ margin-right: 0;
+ }
+
+ .accept-action {
+ width: 100%;
+ text-align: center;
+ }
+ }
+
+ .commit-message-editor {
+ label {
+ padding: 0;
+ }
+ }
+
+ &.mr-widget-empty-state {
+ line-height: 20px;
+ padding: $gl-padding;
+
+ .artwork {
+
+ @include media-breakpoint-down(md) {
+ margin-bottom: $gl-padding;
+ }
+ }
+
+ .text {
+ p {
+ margin-top: $gl-padding;
+ }
+
+ .highlight {
+ margin: 0 0 $gl-padding;
+ font-weight: $gl-font-weight-bold;
+ }
+ }
+ }
+
+ &.mr-pipeline-suggest {
+ border-radius: $border-radius-default;
+ line-height: 20px;
+ border: 1px solid var(--border-color, $border-color);
+
+ .circle-icon-container {
+ color: var(--gray-100, $gl-text-color-quaternary);
+ }
+ }
+ }
+
+ .ci-coverage {
+ float: right;
+ }
+
+ .stop-env-container {
+ color: var(--gl-text-color, $gl-text-color);
+ float: right;
+
+ a {
+ color: var(--gl-text-color, $gl-text-color);
+ }
+ }
+}
+
+.mr-widget-alert-container {
+ $radius: $border-radius-default - 1px;
+
+ border-radius: $radius $radius 0 0;
+
+ .gl-alert:not(:last-child) {
+ margin-bottom: 1px;
+ }
+}
+
+.mr-widget-body,
+.mr-widget-content {
+ padding: $gl-padding;
+}
+
+.mr-widget-border-top {
+ border-top: 1px solid var(--border-color, $border-color);
+}
+
+.mr-widget-extension {
+ border-top: 1px solid var(--border-color, $border-color);
+ background-color: var(--gray-50, $gray-50);
+
+ &.clickable:hover {
+ background-color: var(--gray-100, $gray-100);
+ cursor: pointer;
+ }
+}
+
+.mr-widget-extension-icon::before {
+ @include gl-content-empty;
+ @include gl-absolute;
+ @include gl-left-0;
+ @include gl-top-0;
+ @include gl-opacity-3;
+ @include gl-border-solid;
+ @include gl-border-4;
+ @include gl-rounded-full;
+
+ width: 24px;
+ height: 24px;
+}
+
+.mr-widget-heading {
+ position: relative;
+ border: 1px solid var(--border-color, $border-color);
+ border-radius: $border-radius-default;
+ background: var(--white, $white);
+
+ .gl-skeleton-loader {
+ display: block;
+ }
+}
+
+.mr-widget-info {
+ padding-left: $gl-padding;
+ padding-right: $gl-padding;
+}
+
+.mr-widget-margin-left {
+ margin-left: $mr-widget-margin-left;
+}
+
+.mr-widget-section {
+ .code-text {
+ flex: 1;
+ }
+}
+
+.mr-widget-workflow {
+ margin-top: $gl-padding;
+ position: relative;
+
+ &::before {
+ content: '';
+ border-left: 1px solid var(--gray-100, $gray-100);
+ position: absolute;
+ left: 28px;
+ top: -17px;
+ height: 16px;
+ }
+}
+
+.mr-version-controls {
+ position: relative;
+ z-index: $tabs-holder-z-index + 10;
+ background: var(--white, $white);
+ color: var(--gl-text-color, $gl-text-color);
+ margin-top: -1px;
+
+ .mr-version-menus-container {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ padding: 16px;
+ z-index: 199;
+ white-space: nowrap;
+
+ .gl-dropdown-toggle {
+ width: auto;
+ max-width: 170px;
+
+ svg {
+ top: 10px;
+ right: 8px;
+ }
+ }
+ }
+
+ .content-block {
+ padding: $gl-padding;
+ border-bottom: 0;
+ }
+
+ .mr-version-dropdown,
+ .mr-version-compare-dropdown {
+ margin: 0 0.5rem;
+ }
+
+ .dropdown-title {
+ color: var(--gl-text-color, $gl-text-color);
+ }
+
+ // Shortening button height by 1px to make compare-versions
+ // header 56px and fit into our 8px design grid
+ .btn {
+ height: 34px;
+ }
+
+ @include media-breakpoint-up(md) {
+ position: -webkit-sticky;
+ position: sticky;
+ top: calc(#{$header-height} + #{$mr-tabs-height});
+
+ .with-system-header & {
+ top: calc(#{$header-height} + #{$mr-tabs-height} + #{$system-header-height});
+ }
+
+ .with-system-header.with-performance-bar & {
+ top: calc(#{$header-height} + #{$mr-tabs-height} + #{$system-header-height} + #{$performance-bar-height});
+ }
+
+ .mr-version-menus-container {
+ flex-wrap: nowrap;
+ }
+
+ .with-performance-bar & {
+ top: calc(#{$header-height} + #{$performance-bar-height} + #{$mr-tabs-height});
+ }
+ }
+}
+
// TODO: Move to GitLab UI
.mr-extenson-scrim {
background: linear-gradient(to bottom, rgba($gray-light, 0), rgba($gray-light, 1));
diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss
index 08d9d24d246..989219552a6 100644
--- a/app/assets/stylesheets/page_bundles/milestone.scss
+++ b/app/assets/stylesheets/page_bundles/milestone.scss
@@ -42,12 +42,6 @@ $status-box-line-height: 26px;
}
.milestone-content {
- .issues-count {
- margin-right: 17px;
- float: right;
- width: 105px;
- }
-
.issuable-row {
span {
a {
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index a9d353a0444..cbb6d68bf35 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -139,7 +139,7 @@
}
.gl-downstream-pipeline-job-width {
- width: 240px;
+ width: 170px;
}
.gl-linked-pipeline-padding {
diff --git a/app/assets/stylesheets/page_bundles/project.scss b/app/assets/stylesheets/page_bundles/project.scss
index 7f044f081d4..0bc3cc6678c 100644
--- a/app/assets/stylesheets/page_bundles/project.scss
+++ b/app/assets/stylesheets/page_bundles/project.scss
@@ -49,7 +49,7 @@
.project-repo-buttons {
.btn {
svg {
- fill: $gray-500;
+ fill: var(--gray-500, $gray-500);
}
}
@@ -80,3 +80,134 @@
margin-top: $gl-padding-8;
}
}
+
+.project-stats,
+.project-buttons {
+ .scrolling-tabs-container {
+ .scrolling-tabs {
+ margin-top: $gl-padding-8;
+ margin-bottom: $gl-padding-8 - $browser-scrollbar-size;
+ padding-bottom: $browser-scrollbar-size;
+ flex-wrap: wrap;
+ border-bottom: 0;
+ }
+
+ .fade-left,
+ .fade-right {
+ top: 0;
+ height: calc(100% - #{$browser-scrollbar-size});
+
+ svg {
+ top: 50%;
+ margin-top: -$gl-padding-8;
+ }
+ }
+
+ .nav {
+ flex-basis: 100%;
+
+ + .nav {
+ margin: $gl-padding-8 0;
+ }
+ }
+
+ @include media-breakpoint-down(md) {
+ flex-direction: column;
+
+ .nav {
+ flex-wrap: nowrap;
+ }
+
+ .nav:first-child {
+ margin-right: $gl-padding-8;
+ }
+ }
+ }
+
+ .nav {
+ > li {
+ display: inline-block;
+
+ &:not(:last-child) {
+ margin-right: $gl-padding;
+ }
+
+ &.right {
+ vertical-align: top;
+ margin-top: 0;
+
+ @include media-breakpoint-up(lg) {
+ float: right;
+ }
+ }
+ }
+
+ .stat-text,
+ .stat-link {
+ padding: $gl-btn-vert-padding 0;
+ background-color: transparent;
+ font-size: $gl-font-size;
+ line-height: $gl-btn-line-height;
+ color: var(--gray-500, $gl-text-color-secondary);
+ white-space: pre-wrap;
+ }
+
+ .stat-link {
+ border-bottom: 0;
+ color: var(--black, $black);
+
+ &:hover,
+ &:focus {
+ text-decoration: underline;
+ border-bottom: 0;
+ }
+
+ .project-stat-value {
+ color: var(--gl-text-color, $gl-text-color);
+ }
+
+ .icon {
+ color: var(--gray-500, $gl-text-color-secondary);
+ }
+
+ .add-license-link {
+ &,
+ .icon {
+ color: var(--blue-600, $blue-600);
+ }
+ }
+ }
+
+ .btn {
+ margin-bottom: $gl-padding-8;
+ padding: $gl-btn-vert-padding $gl-btn-padding;
+ line-height: $gl-btn-line-height;
+
+ .icon {
+ top: 0;
+ }
+ }
+ }
+}
+
+.project-buttons {
+ .nav > li:not(:last-child) {
+ margin-right: $gl-padding-8;
+ }
+}
+
+.git-empty {
+ margin-bottom: 7px;
+
+ h5 {
+ color: var(--gl-text-color, $gl-text-color);
+ }
+
+ .light-well {
+ border-radius: 2px;
+
+ color: var(--gray-600, $well-light-text-color);
+ font-size: 13px;
+ line-height: 1.6em;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/projects_edit.scss b/app/assets/stylesheets/page_bundles/projects_edit.scss
new file mode 100644
index 00000000000..9a8b4ffcdd7
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/projects_edit.scss
@@ -0,0 +1,25 @@
+@import 'page_bundles/mixins_and_variables_and_functions';
+
+.project-repo-select {
+ transition: background 2s ease-out;
+
+ &:disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ .highlight-changes & {
+ background: var(--green-50, $highlight-changes-color);
+ transition: none;
+ }
+}
+
+.project-feature-controls {
+ max-width: 432px;
+}
+
+.project-feature-setting-group {
+ .project-feature-controls {
+ max-width: 400px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 5a091c14e53..d9ad82d4e4b 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -7,13 +7,6 @@
}
}
- .gl-card-body {
- @include media-breakpoint-up(sm) {
- @include gl-pt-2;
- min-height: 372px;
- }
- }
-
@include media-breakpoint-down(xs) {
.nav-controls {
@include gl-w-full;
@@ -27,6 +20,13 @@
}
}
+.cluster-card-item {
+ @include media-breakpoint-up(sm) {
+ @include gl-pt-2;
+ min-height: 372px;
+ }
+}
+
.agent-activity-list {
.system-note .timeline-entry-inner {
.timeline-icon {
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index ca6c9b9a073..7ac3ef2221f 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -112,19 +112,6 @@ table.pipeline-project-metrics tr td {
font-weight: $gl-font-weight-normal;
}
-.js-groups-dropdown {
- width: 100%;
-}
-
-.dropdown-group-transfer {
- bottom: 100%;
- top: initial;
-
- .dropdown-content {
- overflow-y: unset;
- }
-}
-
.groups-list-tree-container {
.has-no-search-results {
text-align: center;
diff --git a/app/assets/stylesheets/pages/hierarchy.scss b/app/assets/stylesheets/pages/hierarchy.scss
new file mode 100644
index 00000000000..0812e4cc41e
--- /dev/null
+++ b/app/assets/stylesheets/pages/hierarchy.scss
@@ -0,0 +1,15 @@
+.hierarchy-rounded-arrow-tail {
+ position: absolute;
+ top: 4px;
+ left: 5px;
+ height: calc(100% - 20px);
+}
+
+.hierarchy-icon-wrapper {
+ height: $default-icon-size;
+ width: $default-icon-size;
+}
+
+.hierarchy-rounded-arrow {
+ transform: scale(1, -1) rotate(90deg);
+}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index cdef843c9b4..fa07d29b536 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -148,12 +148,7 @@
}
.gl-label .gl-label-link:hover {
- text-decoration: none;
color: inherit;
-
- .gl-label-text:last-of-type {
- text-decoration: underline;
- }
}
.btn-link {
@@ -274,16 +269,10 @@
font-weight: $gl-font-weight-normal;
}
- .no-value,
- .btn-default-hover-link,
- .btn-secondary-hover-link {
+ .no-value {
color: $gl-text-color-secondary;
}
- .btn-secondary-hover-link:hover {
- color: $blue-600;
- }
-
.sidebar-collapsed-icon {
display: none;
}
@@ -753,6 +742,26 @@
}
}
+.sidebar-help-wrap {
+ .sidebar-help-state {
+ margin: 16px -20px -20px;
+ padding: 16px 20px;
+ }
+
+ .help-state-toggle-enter-active {
+ transition: all 0.8s ease;
+ }
+
+ .help-state-toggle-leave-active {
+ transition: all 0.5s ease;
+ }
+
+ .help-state-toggle-enter,
+ .help-state-toggle-leave-active {
+ opacity: 0;
+ }
+}
+
.time-tracker {
.sidebar-collapsed-icon {
> .stopwatch-svg {
@@ -770,11 +779,6 @@
}
}
- .help-button,
- .close-help-button {
- cursor: pointer;
- }
-
.compare-meter {
&.over_estimate {
.time-remaining,
@@ -787,31 +791,6 @@
.compare-display-container {
font-size: 13px;
}
-
- .time-tracking-help-state {
- background: $white;
- margin: 16px -20px -20px;
- padding: 16px 20px;
- border-top: 1px solid $border-gray-light;
- border-bottom: 1px solid $border-gray-light;
-
- a:hover {
- color: $btn-white-active;
- }
- }
-
- .help-state-toggle-enter-active {
- transition: all 0.8s ease;
- }
-
- .help-state-toggle-leave-active {
- transition: all 0.5s ease;
- }
-
- .help-state-toggle-enter,
- .help-state-toggle-leave-active {
- opacity: 0;
- }
}
.issuable-todo-btn {
@@ -890,3 +869,13 @@
}
}
}
+
+.icon-overlap-and-shadow {
+ filter:
+ drop-shadow(0 1px 0.5px #fff)
+ drop-shadow(1px 0 0.5px #fff)
+ drop-shadow(0 -1px 0.5px #fff)
+ drop-shadow(-1px 0 0.5px #fff);
+ margin-right: -7px;
+ z-index: 1;
+}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index d77c8a40a79..9bb4c5357e7 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -249,7 +249,9 @@ ul.related-merge-requests > li gl-emoji {
@include media-breakpoint-up(sm) {
width: calc(100% - #{$gutter-collapsed-width});
}
+}
+.limit-container-width {
.issue-sticky-header-text {
max-width: $limited-layout-width;
}
@@ -305,3 +307,32 @@ ul.related-merge-requests > li gl-emoji {
.issuable-header-slide-leave-to {
transform: translateY(-100%);
}
+
+.description.work-items-enabled {
+ ul.task-list {
+ > li.task-list-item {
+ padding-inline-start: 2.25rem;
+
+ .js-add-task {
+ svg {
+ visibility: hidden;
+ }
+
+ &:focus svg {
+ visibility: visible;
+ }
+ }
+
+ > input.task-list-item-checkbox {
+ left: 0.875rem;
+ }
+
+ &:hover,
+ &:focus-within {
+ .js-add-task svg {
+ visibility: visible;
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index fffea301a4f..4a3ec5992a5 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -242,10 +242,14 @@
}
.navless-container {
- padding: 65px 15px; // height of footer + bottom padding of email confirmation link
+ padding: 0 15px 65px; // height of footer + bottom padding of email confirmation link
+ }
+
+ .flash-container {
+ padding-bottom: 65px;
@include media-breakpoint-down(xs) {
- padding: 0 15px 65px;
+ padding-bottom: 0;
}
}
}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 10026e290e8..f95cff012d0 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -2,8 +2,6 @@
* MR -> show: Automerge widget
*
*/
-
-$mr-widget-min-height: 69px;
$tabs-holder-z-index: 250;
.space-children {
@@ -18,12 +16,6 @@ $tabs-holder-z-index: 250;
}
}
-.mr-widget-border-top {
- border-top: 1px solid $border-color;
-}
-
-.mr-widget-margin-left { margin-left: $mr-widget-margin-left; }
-
.media-section {
@include media-breakpoint-down(md) {
align-items: flex-start;
@@ -42,140 +34,9 @@ $tabs-holder-z-index: 250;
}
}
-.mr-widget-section {
- .code-text {
- flex: 1;
- }
-}
-
-.mr-widget-heading {
- position: relative;
- border: 1px solid $border-color;
- border-radius: $border-radius-default;
- background: var(--white, $white);
-
- .gl-skeleton-loader {
- display: block;
- }
-}
-
-.mr-widget-extension {
- border-top: 1px solid $border-color;
- background-color: $gray-50;
-
- &.clickable:hover {
- background-color: $gray-100;
- cursor: pointer;
- }
-}
-
-.mr-widget-workflow {
- margin-top: $gl-padding;
- position: relative;
-
- &::before {
- content: '';
- border-left: 1px solid $gray-100;
- position: absolute;
- left: 28px;
- top: -17px;
- height: 16px;
- }
-}
-
-.mr-section-container {
- border: 1px solid $border-color;
- border-radius: $border-radius-default;
- background: var(--white, $white);
-
- > .mr-widget-border-top:first-of-type {
- border-top: 0;
- }
-}
-
-.mr-widget-body,
-.mr-widget-content,
-.mr-widget-footer {
- padding: $gl-padding;
-}
-
-.mr-widget-info {
- padding-left: $gl-padding;
- padding-right: $gl-padding;
-}
-
.mr-state-widget {
- color: $gl-text-color;
-
- .commit-message-edit {
- border-radius: $border-radius-default;
- }
-
- .mr-widget-section:not(:first-child),
- .mr-widget-footer {
- border-top: solid 1px $border-color;
- }
-
- .mr-widget-alert-container + .mr-widget-section {
- border-top: 0;
- }
-
- .mr-fast-forward-message {
- padding-left: $gl-padding-50;
- padding-bottom: $gl-padding;
- }
-
- .commits-list {
- > li {
- padding: $gl-padding;
-
- @include media-breakpoint-up(md) {
- margin-left: $gl-spacing-scale-7;
- }
- }
- }
-
- .mr-commit-dropdown {
- .dropdown-menu {
- @include media-breakpoint-up(md) {
- width: 150%;
- }
- }
- }
-
- .mr-widget-footer {
- padding: 0;
- }
-
- .mr-report {
- padding: 0;
-
- > .media {
- padding: $gl-padding;
- }
- }
-
- form {
- margin-bottom: 0;
-
- .clearfix {
- margin-bottom: 0;
- }
- }
-
- label {
- margin-bottom: 0;
- }
-
- .btn {
- font-size: $gl-font-size;
- }
-
.accept-merge-holder {
.accept-action {
- display: inline-block;
- float: left;
-
.accept-merge-request {
&.ci-preparing,
&.ci-pending,
@@ -192,227 +53,6 @@ $tabs-holder-z-index: 250;
}
}
}
-
- .ci-widget {
- color: $gl-text-color;
- display: flex;
- align-items: center;
- justify-content: space-between;
-
- @include media-breakpoint-down(xs) {
- flex-wrap: wrap;
- }
-
- .ci-widget-content {
- display: flex;
- align-items: center;
- flex: 1;
- }
- }
-
- .mr-widget-icon {
- font-size: 22px;
- }
-
- .mr-loading-icon {
- margin: 3px 0;
- }
-
- .ci-status-icon svg {
- margin: 3px 0;
- position: relative;
- overflow: visible;
- display: block;
- }
-
- .mr-widget-pipeline-graph {
- .dropdown-menu {
- z-index: $zindex-dropdown-menu;
- }
- }
-
- .normal {
- flex: 1;
- flex-basis: auto;
- }
-
- .capitalize {
- text-transform: capitalize;
- }
-
- .label-branch {
- @include gl-font-monospace;
- font-size: 95%;
- color: $gl-text-color;
- font-weight: normal;
- overflow: hidden;
- word-break: break-all;
- }
-
- .deploy-link,
- .label-branch {
- &.label-truncate {
- // NOTE: This selector targets its children because some of the HTML comes from
- // 'source_branch_link'. Once this external HTML is no longer used, we could
- // simplify this.
- > a,
- > span {
- display: inline-block;
- max-width: 12.5em;
- margin-bottom: -3px;
- white-space: nowrap;
- text-overflow: ellipsis;
- line-height: 14px;
- overflow: hidden;
- }
- }
- }
-
- .mr-widget-body {
- &:not(.mr-widget-body-line-height-1) {
- line-height: 28px;
- }
-
- @include clearfix;
-
- .approve-btn {
- margin-right: 5px;
- }
-
- h4 {
- float: left;
- font-weight: $gl-font-weight-bold;
- font-size: 14px;
- line-height: inherit;
- margin-top: 0;
- margin-bottom: 0;
-
- time {
- font-weight: $gl-font-weight-normal;
- }
- }
-
- .btn-grouped {
- margin-left: 0;
- margin-right: 7px;
- }
-
- label {
- font-weight: $gl-font-weight-normal;
- }
-
- .spacing {
- margin: 0 0 0 10px;
- }
-
- .bold,
- .gl-font-weight-bold {
- font-weight: $gl-font-weight-bold;
- color: $gray-600;
- margin-left: 10px;
- }
-
- .state-label {
- font-weight: $gl-font-weight-bold;
- padding-right: 10px;
- }
-
- .danger {
- color: $red-500;
- }
-
- .spacing,
- .bold,
- .gl-font-weight-bold {
- vertical-align: middle;
- }
-
- .dropdown-menu {
- li a {
- padding: 5px;
- }
-
- .merge-opt-icon {
- line-height: 1.5;
- }
-
- .merge-opt-title {
- margin-left: 8px;
- }
- }
-
- .has-custom-error {
- display: inline-block;
- }
-
- @include media-breakpoint-down(xs) {
- p {
- font-size: 13px;
- }
-
- .btn-grouped {
- float: none;
- margin-right: 0;
- }
-
- .accept-action {
- width: 100%;
- text-align: center;
- }
- }
-
- .commit-message-editor {
- label {
- padding: 0;
- }
- }
-
- &.mr-widget-empty-state {
- line-height: 20px;
- padding: $gl-padding;
-
- .artwork {
-
- @include media-breakpoint-down(md) {
- margin-bottom: $gl-padding;
- }
- }
-
- .text {
- p {
- margin-top: $gl-padding;
- }
-
- .highlight {
- margin: 0 0 $gl-padding;
- font-weight: $gl-font-weight-bold;
- }
- }
- }
-
- &.mr-pipeline-suggest {
- border-radius: $border-radius-default;
- line-height: 20px;
- border: 1px solid $border-color;
-
- .circle-icon-container {
- color: $gl-text-color-quaternary;
- }
- }
- }
-
- .ci-coverage {
- float: right;
- }
-
- .stop-env-container {
- color: $gl-text-color;
- float: right;
-
- a {
- color: $gl-text-color;
- }
- }
}
.mr_source_commit,
@@ -478,72 +118,6 @@ $tabs-holder-z-index: 250;
}
}
-.mr-info-list {
- clear: left;
- position: relative;
- padding-top: 4px;
-
- p {
- margin: 0;
- position: relative;
- padding: 4px 0;
-
- &:last-child {
- padding-bottom: 0;
- }
- }
-
- &.mr-memory-usage {
- p {
- float: left;
- }
-
- .memory-graph-container {
- float: left;
- margin-left: 5px;
- }
- }
-}
-
-.mr-source-target {
- flex-wrap: wrap;
- padding: $gl-padding;
- background: var(--white, $white);
- min-height: $mr-widget-min-height;
-
- @include media-breakpoint-up(md) {
- align-items: center;
- }
-
- .git-merge-container {
- justify-content: space-between;
- flex: 1;
- flex-direction: row;
- align-items: center;
-
- @include media-breakpoint-down(md) {
- flex-direction: column;
- align-items: stretch;
-
- .branch-actions {
- margin-top: 16px;
- }
- }
-
- @include media-breakpoint-up(lg) {
- .branch-actions {
- align-self: center;
- margin-left: $gl-padding;
- white-space: nowrap;
- }
- }
- }
-
- .diverged-commits-count {
- color: $gl-text-color-secondary;
- }
-}
-
.card-new-merge-request {
.card-header {
padding: 5px 10px;
@@ -640,79 +214,14 @@ $tabs-holder-z-index: 250;
}
}
-.mr-version-controls {
- position: relative;
- z-index: $tabs-holder-z-index + 10;
- background: $white;
- color: $gl-text-color;
- margin-top: -1px;
-
- .mr-version-menus-container {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- padding: 16px;
- z-index: 199;
- white-space: nowrap;
-
- .gl-dropdown-toggle {
- width: auto;
- max-width: 170px;
-
- svg {
- top: 10px;
- right: 8px;
- }
- }
- }
-
- .content-block {
- padding: $gl-padding;
- border-bottom: 0;
- }
-
- .mr-version-dropdown,
- .mr-version-compare-dropdown {
- margin: 0 0.5rem;
- }
-
- .dropdown-title {
- color: $gl-text-color;
- }
-
- // Shortening button height by 1px to make compare-versions
- // header 56px and fit into our 8px design grid
- .btn {
- height: 34px;
- }
-
- @include media-breakpoint-up(md) {
- position: -webkit-sticky;
- position: sticky;
- top: calc(#{$header-height} + #{$mr-tabs-height});
-
- .with-system-header & {
- top: calc(#{$header-height} + #{$mr-tabs-height} + #{$system-header-height});
- }
-
- .with-system-header.with-performance-bar & {
- top: calc(#{$header-height} + #{$mr-tabs-height} + #{$system-header-height} + #{$performance-bar-height});
- }
-
- .mr-version-menus-container {
- flex-wrap: nowrap;
- }
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height} + #{$mr-tabs-height});
- }
- }
-}
-
.merge-request-tabs-holder,
.epic-tabs-holder {
top: $header-height;
z-index: $tabs-holder-z-index;
+ margin-left: -$gl-padding;
+ margin-right: -$gl-padding;
+ padding-left: $gl-padding;
+ padding-right: $gl-padding;
background-color: $body-bg;
border-bottom: 1px solid $border-color;
@@ -834,80 +343,10 @@ $tabs-holder-z-index: 250;
}
}
-.mr-memory-usage {
- width: 100%;
-
- p.usage-info-loading .usage-info-load-spinner {
- margin-right: 10px;
- font-size: 16px;
- }
-}
-
.fork-sprite {
margin-right: -5px;
}
-.deploy-heading,
-.merge-train-position-indicator {
- @include media-breakpoint-up(md) {
- padding: $gl-padding-8 $gl-padding;
- }
-
- .media-body {
- min-width: 0;
- font-size: 12px;
- margin-left: 32px;
- }
-
- &:not(:last-child) {
- border-bottom: 1px solid $border-color;
- }
-}
-
-.deploy-body {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
-
- @include media-breakpoint-up(xs) {
- flex-wrap: nowrap;
- white-space: nowrap;
- }
-
- @include media-breakpoint-down(md) {
- flex-direction: column;
- align-items: flex-start;
-
- .deployment-info {
- margin-bottom: $gl-padding;
- }
- }
-
- > *:not(:last-child) {
- margin-right: 0.3em;
- }
-
- svg {
- vertical-align: text-top;
- }
-
- .deployment-info {
- flex: 1;
- white-space: nowrap;
- text-overflow: ellipsis;
- min-width: 100px;
-
- @include media-breakpoint-up(xs) {
- min-width: 0;
- max-width: 100%;
- }
- }
-
- .dropdown-menu {
- width: 400px;
- }
-}
-
// Hack alert: we've rewritten `btn` class in a way that
// we've broken it and it is not possible to use with `btn-link`
// which causes a blank button when it's disabled and hovering
@@ -925,30 +364,6 @@ $tabs-holder-z-index: 250;
}
}
-.ci-widget-container {
- justify-content: space-between;
- flex: 1;
- flex-direction: row;
-
- @include media-breakpoint-down(sm) {
- flex-direction: column;
-
- .stage-cell .stage-container {
- margin-top: 16px;
- }
-
- .dropdown .mini-pipeline-graph-dropdown-menu.dropdown-menu {
- transform: initial;
- }
- }
-
- .coverage {
- font-size: 12px;
- color: $gray-500;
- line-height: initial;
- }
-}
-
.merge-request-details .file-finder-overlay.diff-file-finder {
position: fixed;
z-index: 99999;
@@ -964,47 +379,3 @@ $tabs-holder-z-index: 250;
}
}
}
-
-.diff-file-row.is-active {
- background-color: $gray-50;
-}
-
-.mr-conflict-loader {
- max-width: 334px;
-
- > svg {
- vertical-align: middle;
- }
-}
-
-.mr-ready-to-merge-loader {
- max-width: 418px;
-
- > svg {
- vertical-align: middle;
- }
-}
-
-.mr-widget-alert-container {
- $radius: $border-radius-default - 1px;
-
- border-radius: $radius $radius 0 0;
-
- .gl-alert:not(:last-child) {
- margin-bottom: 1px;
- }
-}
-
-.mr-widget-extension-icon::before {
- @include gl-content-empty;
- @include gl-absolute;
- @include gl-left-0;
- @include gl-top-0;
- @include gl-opacity-3;
- @include gl-border-solid;
- @include gl-border-4;
- @include gl-rounded-full;
-
- width: 24px;
- height: 24px;
-}
diff --git a/app/assets/stylesheets/pages/pages.scss b/app/assets/stylesheets/pages/pages.scss
index ebaf875ad8f..2de33f20595 100644
--- a/app/assets/stylesheets/pages/pages.scss
+++ b/app/assets/stylesheets/pages/pages.scss
@@ -42,8 +42,6 @@
}
:first-child {
- border-bottom-right-radius: 0;
- border-top-right-radius: 0;
line-height: $gl-line-height;
}
@@ -52,7 +50,6 @@
}
:last-child {
- border-bottom-right-radius: $border-radius-default;
- border-top-right-radius: $border-radius-default;
+ border-radius: $border-radius-default;
}
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 6b4d7c2520c..ac3d4dad585 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -52,37 +52,6 @@
}
}
-// INFO Scoped to project_feature_setting and settings_panel components in app/assets/javascripts/pages/projects/shared/permissions/components
-.project-repo-select {
- transition: background 2s ease-out;
-
- &:disabled {
- opacity: 0.5;
- pointer-events: none;
- }
-
- .highlight-changes & {
- background: $highlight-changes-color;
- transition: none;
- }
-}
-
-// INFO Scoped to project_feature_setting and settings_panel components in app/assets/javascripts/pages/projects/shared/permissions/components
-.project-feature-controls {
- max-width: 432px;
-}
-
-// INFO Scoped to settings_panel component in app/assets/javascripts/pages/projects/shared/permissions/components
-.project-feature-setting-group {
- .project-feature-controls {
- max-width: 400px;
- }
-}
-
-.nav > .project-buttons {
- margin-top: 0;
-}
-
.save-project-loader {
margin-top: 50px;
margin-bottom: 50px;
@@ -317,121 +286,6 @@
}
}
-.project-stats,
-.project-buttons {
- .scrolling-tabs-container {
- .scrolling-tabs {
- margin-top: $gl-padding-8;
- margin-bottom: $gl-padding-8 - $browser-scrollbar-size;
- padding-bottom: $browser-scrollbar-size;
- flex-wrap: wrap;
- border-bottom: 0;
- }
-
- .fade-left,
- .fade-right {
- top: 0;
- height: calc(100% - #{$browser-scrollbar-size});
-
- svg {
- top: 50%;
- margin-top: -$gl-padding-8;
- }
- }
-
- .nav {
- flex-basis: 100%;
-
- + .nav {
- margin: $gl-padding-8 0;
- }
- }
-
- @include media-breakpoint-down(md) {
- flex-direction: column;
-
- .nav {
- flex-wrap: nowrap;
- }
-
- .nav:first-child {
- margin-right: $gl-padding-8;
- }
- }
- }
-
- .nav {
- > li {
- display: inline-block;
-
- &:not(:last-child) {
- margin-right: $gl-padding;
- }
-
- &.right {
- vertical-align: top;
- margin-top: 0;
-
- @include media-breakpoint-up(lg) {
- float: right;
- }
- }
- }
-
- .stat-text,
- .stat-link {
- padding: $gl-btn-vert-padding 0;
- background-color: transparent;
- font-size: $gl-font-size;
- line-height: $gl-btn-line-height;
- color: $gl-text-color-secondary;
- white-space: pre-wrap;
- }
-
- .stat-link {
- border-bottom: 0;
- color: $black;
-
- &:hover,
- &:focus {
- text-decoration: underline;
- border-bottom: 0;
- }
-
- .project-stat-value {
- color: $gl-text-color;
- }
-
- .icon {
- color: $gl-text-color-secondary;
- }
-
- .add-license-link {
- &,
- .icon {
- color: $blue-600;
- }
- }
- }
-
- .btn {
- margin-bottom: $gl-padding-8;
- padding: $gl-btn-vert-padding $gl-btn-padding;
- line-height: $gl-btn-line-height;
-
- .icon {
- top: 0;
- }
- }
- }
-}
-
-.project-buttons {
- .nav > li:not(:last-child) {
- margin-right: $gl-padding-8;
- }
-}
-
.repository-languages-bar {
height: 8px;
margin-bottom: $gl-padding-8;
@@ -460,22 +314,6 @@ pre.light-well {
border-color: $well-light-border;
}
-.git-empty {
- margin-bottom: 7px;
-
- h5 {
- color: $gl-text-color;
- }
-
- .light-well {
- border-radius: 2px;
-
- color: $well-light-text-color;
- font-size: 13px;
- line-height: 1.6em;
- }
-}
-
/*
* Projects list rendered on dashboard and user page
*/
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 633051918a4..5956368a977 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -130,10 +130,6 @@
border-radius: $border-radius-base;
}
-.empty-variables {
- padding: 20px 0;
-}
-
.warning-title {
color: $gray-900;
}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index c72de0e6f29..1397590cc31 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -7,12 +7,8 @@ body.gl-dark {
--gray-100: #404040;
--gray-600: #bfbfbf;
--gray-900: #fafafa;
- --gray-950: #fff;
--green-100: #0d532a;
- --green-400: #108548;
--green-700: #91d4a8;
- --blue-400: #1f75cb;
- --orange-400: #ab6100;
--indigo-900-alpha-008: rgba(235, 235, 250, 0.08);
--gl-text-color: #fafafa;
--border-color: #4f4f4f;
@@ -314,10 +310,18 @@ h1 {
padding-left: 0.6em;
border-radius: 10rem;
}
+.badge-success {
+ color: #fff;
+ background-color: #2da160;
+}
.badge-info {
color: #fff;
background-color: #428fdc;
}
+.badge-warning {
+ color: #fff;
+ background-color: #c17d10;
+}
.bg-transparent {
background-color: transparent !important;
}
@@ -394,6 +398,34 @@ a.gl-badge.badge-info:active {
0 0 0 1px rgba(51, 51, 51, 0.4), 0 0 0 4px rgba(66, 143, 220, 0.48);
outline: none;
}
+.gl-badge.badge-success {
+ background-color: #0d532a;
+ color: #91d4a8;
+}
+a.gl-badge.badge-success.active,
+a.gl-badge.badge-success:active {
+ color: #ecf4ee;
+ background-color: #24663b;
+}
+a.gl-badge.badge-success:active {
+ box-shadow: inset 0 0 0 1px rgba(51, 51, 51, 0.8),
+ 0 0 0 1px rgba(51, 51, 51, 0.4), 0 0 0 4px rgba(66, 143, 220, 0.48);
+ outline: none;
+}
+.gl-badge.badge-warning {
+ background-color: #703800;
+ color: #e9be74;
+}
+a.gl-badge.badge-warning.active,
+a.gl-badge.badge-warning:active {
+ color: #fdf1dd;
+ background-color: #8f4700;
+}
+a.gl-badge.badge-warning:active {
+ box-shadow: inset 0 0 0 1px rgba(51, 51, 51, 0.8),
+ 0 0 0 1px rgba(51, 51, 51, 0.4), 0 0 0 4px rgba(66, 143, 220, 0.48);
+ outline: none;
+}
.gl-button .gl-badge {
top: 0;
}
@@ -837,7 +869,7 @@ input {
.container-fluid
.navbar-nav
li
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge) {
+ .badge.badge-pill:not(.gl-badge) {
box-shadow: none;
font-weight: 600;
}
@@ -920,44 +952,6 @@ input {
line-height: 18px;
margin: 4px 0 4px 2px;
}
-.title-container
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge),
-.navbar-nav
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge) {
- position: inherit;
- font-weight: 400;
- margin-left: -6px;
- font-size: 11px;
- color: var(--gray-950, #333);
- padding: 0 5px;
- line-height: 12px;
- border-radius: 7px;
- box-shadow: 0 1px 0 rgba(76, 78, 84, 0.2);
-}
-.title-container
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).green-badge,
-.navbar-nav
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).green-badge {
- background-color: var(--green-400, #108548);
-}
-.title-container
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).merge-requests-count,
-.navbar-nav
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).merge-requests-count {
- background-color: var(--orange-400, #ab6100);
-}
-.title-container
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).todos-count,
-.navbar-nav
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).todos-count {
- background-color: var(--blue-400, #1f75cb);
-}
-.title-container .canary-badge .badge,
-.navbar-nav .canary-badge .badge {
- font-size: 12px;
- line-height: 16px;
- padding: 0 0.5rem;
-}
@media (max-width: 575.98px) {
.navbar-gitlab .container-fluid {
font-size: 18px;
@@ -1103,6 +1097,8 @@ input {
}
.nav-sidebar li .nav-item-name {
flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.nav-sidebar li > a,
.nav-sidebar li > .fly-out-top-item-container {
@@ -1335,7 +1331,8 @@ input {
.nav-sidebar-inner-scroll {
height: 100%;
width: 100%;
- overflow: auto;
+ overflow-x: hidden;
+ overflow-y: auto;
}
.nav-sidebar-inner-scroll > div.context-header {
margin-top: 0.25rem;
@@ -1780,10 +1777,16 @@ body.gl-dark {
--purple-800: #cbbbf2;
--purple-900: #e1d8f9;
--purple-950: #f4f0ff;
+ --dark-icon-color-purple-1: #524a68;
+ --dark-icon-color-purple-2: #715bae;
+ --dark-icon-color-purple-3: #9a79f7;
+ --dark-icon-color-orange-1: #665349;
+ --dark-icon-color-orange-2: #b37a5d;
--gl-text-color: #fafafa;
--border-color: #4f4f4f;
--white: #333;
--black: #fff;
+ --black-normal: #fafafa;
--svg-status-bg: #333;
}
.nav-sidebar li a {
@@ -2005,10 +2008,16 @@ body.gl-dark {
--purple-800: #cbbbf2;
--purple-900: #e1d8f9;
--purple-950: #f4f0ff;
+ --dark-icon-color-purple-1: #524a68;
+ --dark-icon-color-purple-2: #715bae;
+ --dark-icon-color-purple-3: #9a79f7;
+ --dark-icon-color-orange-1: #665349;
+ --dark-icon-color-orange-2: #b37a5d;
--gl-text-color: #fafafa;
--border-color: #4f4f4f;
--white: #333;
--black: #fff;
+ --black-normal: #fafafa;
--svg-status-bg: #333;
}
.tab-width-8 {
@@ -2026,18 +2035,9 @@ body.gl-dark {
white-space: nowrap;
width: 1px;
}
-.gl-bg-green-500 {
- background-color: #2da160;
-}
.gl-border-none\! {
border-style: none !important;
}
-.gl-rounded-pill {
- border-radius: 0.75rem;
-}
-.gl-text-white {
- color: #333;
-}
.gl-display-none {
display: none;
}
@@ -2059,9 +2059,8 @@ body.gl-dark {
.gl-pr-2 {
padding-right: 0.25rem;
}
-.gl-py-1 {
- padding-top: 0.125rem;
- padding-bottom: 0.125rem;
+.gl-ml-n2 {
+ margin-left: -0.25rem;
}
.gl-ml-3 {
margin-left: 0.5rem;
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 2f79c86cdc6..0d35c400676 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -295,10 +295,18 @@ h1 {
padding-left: 0.6em;
border-radius: 10rem;
}
+.badge-success {
+ color: #fff;
+ background-color: #108548;
+}
.badge-info {
color: #fff;
background-color: #1f75cb;
}
+.badge-warning {
+ color: #fff;
+ background-color: #ab6100;
+}
.bg-transparent {
background-color: transparent !important;
}
@@ -375,6 +383,34 @@ a.gl-badge.badge-info:active {
0 0 0 1px rgba(255, 255, 255, 0.4), 0 0 0 4px rgba(31, 117, 203, 0.48);
outline: none;
}
+.gl-badge.badge-success {
+ background-color: #c3e6cd;
+ color: #24663b;
+}
+a.gl-badge.badge-success.active,
+a.gl-badge.badge-success:active {
+ color: #0a4020;
+ background-color: #91d4a8;
+}
+a.gl-badge.badge-success:active {
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.8),
+ 0 0 0 1px rgba(255, 255, 255, 0.4), 0 0 0 4px rgba(31, 117, 203, 0.48);
+ outline: none;
+}
+.gl-badge.badge-warning {
+ background-color: #f5d9a8;
+ color: #8f4700;
+}
+a.gl-badge.badge-warning.active,
+a.gl-badge.badge-warning:active {
+ color: #5c2900;
+ background-color: #e9be74;
+}
+a.gl-badge.badge-warning:active {
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.8),
+ 0 0 0 1px rgba(255, 255, 255, 0.4), 0 0 0 4px rgba(31, 117, 203, 0.48);
+ outline: none;
+}
.gl-button .gl-badge {
top: 0;
}
@@ -818,7 +854,7 @@ input {
.container-fluid
.navbar-nav
li
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge) {
+ .badge.badge-pill:not(.gl-badge) {
box-shadow: none;
font-weight: 600;
}
@@ -901,44 +937,6 @@ input {
line-height: 18px;
margin: 4px 0 4px 2px;
}
-.title-container
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge),
-.navbar-nav
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge) {
- position: inherit;
- font-weight: 400;
- margin-left: -6px;
- font-size: 11px;
- color: var(--gray-950, #fff);
- padding: 0 5px;
- line-height: 12px;
- border-radius: 7px;
- box-shadow: 0 1px 0 rgba(76, 78, 84, 0.2);
-}
-.title-container
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).green-badge,
-.navbar-nav
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).green-badge {
- background-color: var(--green-400, #2da160);
-}
-.title-container
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).merge-requests-count,
-.navbar-nav
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).merge-requests-count {
- background-color: var(--orange-400, #c17d10);
-}
-.title-container
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).todos-count,
-.navbar-nav
- .badge.badge-pill:not(.merge-request-badge):not(.version-check-badge).todos-count {
- background-color: var(--blue-400, #428fdc);
-}
-.title-container .canary-badge .badge,
-.navbar-nav .canary-badge .badge {
- font-size: 12px;
- line-height: 16px;
- padding: 0 0.5rem;
-}
@media (max-width: 575.98px) {
.navbar-gitlab .container-fluid {
font-size: 18px;
@@ -1084,6 +1082,8 @@ input {
}
.nav-sidebar li .nav-item-name {
flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.nav-sidebar li > a,
.nav-sidebar li > .fly-out-top-item-container {
@@ -1316,7 +1316,8 @@ input {
.nav-sidebar-inner-scroll {
height: 100%;
width: 100%;
- overflow: auto;
+ overflow-x: hidden;
+ overflow-y: auto;
}
.nav-sidebar-inner-scroll > div.context-header {
margin-top: 0.25rem;
@@ -1697,18 +1698,9 @@ svg.s16 {
white-space: nowrap;
width: 1px;
}
-.gl-bg-green-500 {
- background-color: #108548;
-}
.gl-border-none\! {
border-style: none !important;
}
-.gl-rounded-pill {
- border-radius: 0.75rem;
-}
-.gl-text-white {
- color: #fff;
-}
.gl-display-none {
display: none;
}
@@ -1730,9 +1722,8 @@ svg.s16 {
.gl-pr-2 {
padding-right: 0.25rem;
}
-.gl-py-1 {
- padding-top: 0.125rem;
- padding-bottom: 0.125rem;
+.gl-ml-n2 {
+ margin-left: -0.25rem;
}
.gl-ml-3 {
margin-left: 0.5rem;
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 3ed257caf60..c5cbe58ec27 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -138,10 +138,10 @@ hr {
margin-right: -15px;
margin-left: -15px;
}
-.col,
-.col-sm-5,
+.col-sm-12,
.col-sm-7,
-.col-sm-12 {
+.col-sm-5,
+.col {
position: relative;
width: 100%;
padding-right: 15px;
@@ -160,12 +160,12 @@ hr {
}
@media (min-width: 576px) {
.col-sm-5 {
- flex: 0 0 41.66667%;
- max-width: 41.66667%;
+ flex: 0 0 41.6666666667%;
+ max-width: 41.6666666667%;
}
.col-sm-7 {
- flex: 0 0 58.33333%;
- max-width: 58.33333%;
+ flex: 0 0 58.3333333333%;
+ max-width: 58.3333333333%;
}
.col-sm-12 {
flex: 0 0 100%;
@@ -725,11 +725,14 @@ svg {
margin-top: 40px;
}
.devise-layout-html body .navless-container {
- padding: 65px 15px;
+ padding: 0 15px 65px;
+}
+.devise-layout-html body .flash-container {
+ padding-bottom: 65px;
}
@media (max-width: 575.98px) {
- .devise-layout-html body .navless-container {
- padding: 0 15px 65px;
+ .devise-layout-html body .flash-container {
+ padding-bottom: 0;
}
}
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index c79816e3579..9db134ffa65 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -93,6 +93,7 @@ $gray-darker: #4f4f4f;
$gray-darkest: #c4c4c4;
$black: #fff;
+$black-normal: $gray-900;
$white: #333;
$white-light: #2b2b2b;
$white-normal: #333;
@@ -187,11 +188,18 @@ body.gl-dark {
--purple-900: #{$purple-900};
--purple-950: #{$purple-950};
+ --dark-icon-color-purple-1: #524a68;
+ --dark-icon-color-purple-2: #715bae;
+ --dark-icon-color-purple-3: #9a79f7;
+ --dark-icon-color-orange-1: #665349;
+ --dark-icon-color-orange-2: #b37a5d;
+
--gl-text-color: #{$gray-900};
--border-color: #{$border-color};
--white: #{$white};
--black: #{$black};
+ --black-normal: #{$black-normal};
--svg-status-bg: #{$white};
@@ -257,3 +265,11 @@ $line-removed-dark: $red-200;
$well-expand-item: $gray-200;
$well-inner-border: $gray-200;
+
+$calendar-activity-colors: (
+ #303030,
+ #333861,
+ #4a5593,
+ #6172c5,
+ #788ff7
+);
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 0e7e52129b4..8a4f9c32f9f 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -200,6 +200,12 @@
}
}
+.gl-xl-ml-3 {
+ @include media-breakpoint-up(lg) {
+ margin-left: $gl-spacing-scale-3;
+ }
+}
+
.gl-mb-n3 {
margin-bottom: -$gl-spacing-scale-3;
}
@@ -247,30 +253,6 @@ $gl-line-height-42: px-to-rem(42px);
max-width: 50%;
}
-// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1490
-.gl-w-grid-size-28 {
- width: $grid-size * 28;
-}
-
-// Will be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2347 is merged
-.gl-min-w-8 {
- min-width: $gl-spacing-scale-8;
-}
-
-// Will be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2347 is merged
-.gl-min-w-10 {
- min-width: $gl-spacing-scale-10;
-}
-
-// Will both be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1526
-.gl-opacity-6 {
- opacity: 0.6;
-}
-
-.gl-opacity-7 {
- opacity: 0.7;
-}
-
/**
Note: ::-webkit-scrollbar is a non-standard rule only
supported by webkit browsers.
@@ -298,14 +280,66 @@ $gl-line-height-42: px-to-rem(42px);
@include gl-focus($gl-border-size-1, $gray-900, true);
}
-// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1637
-.gl-lg-w-25p {
- @include gl-media-breakpoint-up(lg) {
- width: 25%;
- }
-}
-
// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2600
.gl-pr-10 {
padding-right: $gl-spacing-scale-10;
}
+
+/*
+All of the following (up until the "End gitlab-ui#1709" comment) will be moved
+to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
+*/
+.gl-sm-grid-template-columns-2 {
+ @include media-breakpoint-up(sm) {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+.gl-md-grid-template-columns-2 {
+ @include media-breakpoint-up(md) {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+.gl-md-grid-template-columns-3 {
+ @include media-breakpoint-up(md) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+.gl-lg-grid-template-columns-4 {
+ @include media-breakpoint-up(lg) {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+
+.gl-gap-6 {
+ gap: $gl-spacing-scale-6;
+}
+
+.gl-max-w-48 {
+ max-width: $gl-spacing-scale-48;
+}
+
+.gl-max-w-75 {
+ max-width: $gl-spacing-scale-75;
+}
+
+.gl-md-pt-11 {
+ @include media-breakpoint-up(md) {
+ padding-top: $gl-spacing-scale-11 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence
+ }
+}
+
+.gl-md-mb-6 {
+ @include media-breakpoint-up(md) {
+ margin-bottom: $gl-spacing-scale-6 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence
+ }
+}
+
+.gl-md-mb-12 {
+ @include media-breakpoint-up(md) {
+ margin-bottom: $gl-spacing-scale-12 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence
+ }
+}
+/* End gitlab-ui#1709 */
diff --git a/app/assets/stylesheets/vendors/tribute.scss b/app/assets/stylesheets/vendors/tribute.scss
deleted file mode 100644
index 65f3d1b6199..00000000000
--- a/app/assets/stylesheets/vendors/tribute.scss
+++ /dev/null
@@ -1,41 +0,0 @@
-.tribute-container {
- background: $white;
- border: 1px solid $gray-100;
- border-radius: $border-radius-base;
- box-shadow: 0 0 5px $issue-boards-card-shadow;
- color: $black;
- margin-top: $gl-padding-12;
- max-height: 200px;
- min-width: 120px;
- overflow-y: auto;
- z-index: 11110 !important;
-
- ul {
- list-style: none;
- margin-bottom: 0;
- padding: $gl-padding-8 1px;
- }
-
- li {
- cursor: pointer;
- padding: $gl-padding-8 $gl-padding;
- white-space: nowrap;
-
- small {
- color: $gray-500;
- }
-
- &.highlight {
- background-color: $gray-darker;
-
- .avatar {
- @include disable-all-animation;
- border: 1px solid $white;
- }
-
- small {
- color: inherit;
- }
- }
- }
-}
diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb
index 9d1c68eea89..206a5b11e4b 100644
--- a/app/controllers/abuse_reports_controller.rb
+++ b/app/controllers/abuse_reports_controller.rb
@@ -20,8 +20,10 @@ class AbuseReportsController < ApplicationController
message = _("Thank you for your report. A GitLab administrator will look into it shortly.")
redirect_to root_path, notice: message
- else
+ elsif report_params[:user_id].present?
render :new
+ else
+ redirect_to root_path, alert: _("Cannot create the abuse report. The reported user was invalid. Please try again or contact support.")
end
end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 8644d95b96c..1d0930ba73c 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -27,7 +27,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
feature_category :source_code_management, [:repository, :clear_repository_check_states]
feature_category :continuous_integration, [:ci_cd, :reset_registration_token]
- feature_category :service_ping, [:usage_data]
+ feature_category :service_ping, [:usage_data, :service_usage_data]
feature_category :integrations, [:integrations]
feature_category :pages, [:lets_encrypt_terms_of_service]
@@ -52,6 +52,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
@integrations = Integration.find_or_initialize_all_non_project_specific(Integration.for_instance).sort_by(&:title)
end
+ def service_usage_data
+ end
+
def update
perform_update
end
@@ -59,11 +62,11 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
def usage_data
respond_to do |format|
format.html do
- usage_data_json = Gitlab::Json.pretty_generate(Gitlab::UsageData.data)
+ usage_data_json = Gitlab::Json.pretty_generate(Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values, cached: true))
render html: Gitlab::Highlight.highlight('payload.json', usage_data_json, language: 'json')
end
- format.json { render json: Gitlab::UsageData.to_json }
+ format.json { render json: Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values, cached: true).to_json }
end
end
diff --git a/app/controllers/admin/instance_review_controller.rb b/app/controllers/admin/instance_review_controller.rb
index 5567ffbdc84..1ce6e66c6de 100644
--- a/app/controllers/admin/instance_review_controller.rb
+++ b/app/controllers/admin/instance_review_controller.rb
@@ -16,7 +16,7 @@ class Admin::InstanceReviewController < Admin::ApplicationController
}
if Gitlab::CurrentSettings.usage_ping_enabled?
- data = ::Gitlab::UsageData.data
+ data = Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values, cached: true)
counts = data[:counts]
result[:instance_review].merge!(
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 16657612050..f7f78ab3229 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -34,7 +34,7 @@ class Admin::RunnersController < Admin::ApplicationController
end
def destroy
- @runner.destroy
+ Ci::UnregisterRunnerService.new(@runner).execute
redirect_to admin_runners_path, status: :found
end
@@ -85,7 +85,11 @@ class Admin::RunnersController < Admin::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def assign_builds_and_projects
- @builds = runner.builds.order('id DESC').preload_project_and_pipeline_project.first(30)
+ @builds = runner
+ .builds
+ .order_id_desc
+ .preload_project_and_pipeline_project.first(30)
+
@projects =
if params[:search].present?
::Project.search(params[:search])
@@ -93,12 +97,10 @@ class Admin::RunnersController < Admin::ApplicationController
Project.all
end
- ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do
- @projects = @projects.where.not(id: runner.projects.select(:id)) if runner.projects.any?
- @projects = @projects.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659')
- @projects = @projects.inc_routes
- @projects = @projects.page(params[:page]).per(30).without_count
- end
+ runner_projects_ids = runner.runner_projects.pluck(:project_id)
+ @projects = @projects.where.not(id: runner_projects_ids) if runner_projects_ids.any?
+ @projects = @projects.inc_routes
+ @projects = @projects.page(params[:page]).per(30).without_count
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index b40e2affcee..c1fa104ffda 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -16,7 +16,7 @@ class Admin::UsersController < Admin::ApplicationController
return redirect_to admin_cohorts_path if params[:tab] == 'cohorts'
@users = User.filter_items(params[:filter]).order_name_asc
- @users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present?
+ @users = @users.search(params[:search_query], with_private_emails: true) if params[:search_query].present?
@users = users_with_included_associations(@users)
@users = @users.sort_by_attribute(@sort = params[:sort])
@users = @users.page(params[:page])
@@ -370,7 +370,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def check_ban_user_feature_flag
- access_denied! unless Feature.enabled?(:ban_user_feature_flag)
+ access_denied! unless Feature.enabled?(:ban_user_feature_flag, default_enabled: :yaml)
end
def log_impersonation_event
diff --git a/app/controllers/clusters/base_controller.rb b/app/controllers/clusters/base_controller.rb
index b1ffdf00b87..f88d381b3bf 100644
--- a/app/controllers/clusters/base_controller.rb
+++ b/app/controllers/clusters/base_controller.rb
@@ -4,7 +4,7 @@ class Clusters::BaseController < ApplicationController
include RoutableActions
skip_before_action :authenticate_user!
- before_action :authorize_read_cluster!
+ before_action :authorize_admin_cluster!, except: [:show, :index, :new, :authorize_aws_role, :update]
helper_method :clusterable
@@ -18,11 +18,11 @@ class Clusters::BaseController < ApplicationController
end
def authorize_update_cluster!
- access_denied! unless can?(current_user, :update_cluster, cluster)
+ access_denied! unless can?(current_user, :update_cluster, clusterable)
end
def authorize_admin_cluster!
- access_denied! unless can?(current_user, :admin_cluster, cluster)
+ access_denied! unless can?(current_user, :admin_cluster, clusterable)
end
def authorize_read_cluster!
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 15a261f572a..c12ceca9c3b 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -10,9 +10,9 @@ class Clusters::ClustersController < Clusters::BaseController
before_action :validate_gcp_token, only: [:new]
before_action :gcp_cluster, only: [:new]
before_action :user_cluster, only: [:new]
+ before_action :authorize_read_cluster!, only: [:show, :index]
before_action :authorize_create_cluster!, only: [:new, :authorize_aws_role]
before_action :authorize_update_cluster!, only: [:update]
- before_action :authorize_admin_cluster!, only: [:destroy, :clear_cache]
before_action :update_applications_status, only: [:cluster_status]
helper_method :token_in_session
diff --git a/app/controllers/concerns/bizible_csp.rb b/app/controllers/concerns/bizible_csp.rb
new file mode 100644
index 00000000000..521f3127759
--- /dev/null
+++ b/app/controllers/concerns/bizible_csp.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module BizibleCSP
+ extend ActiveSupport::Concern
+
+ included do
+ content_security_policy do |policy|
+ next unless helpers.bizible_enabled? || policy.directives.present?
+
+ default_script_src = policy.directives['script-src'] || policy.directives['default-src']
+ script_src_values = Array.wrap(default_script_src) | ["'unsafe-eval'", 'https://cdn.bizible.com/scripts/bizible.js']
+ policy.script_src(*script_src_values)
+ end
+ end
+end
diff --git a/app/controllers/concerns/integrations/actions.rb b/app/controllers/concerns/integrations/actions.rb
index f6e98c25b72..1f788860c8f 100644
--- a/app/controllers/concerns/integrations/actions.rb
+++ b/app/controllers/concerns/integrations/actions.rb
@@ -8,9 +8,6 @@ module Integrations::Actions
include IntegrationsHelper
before_action :integration, only: [:edit, :update, :overrides, :test]
- before_action do
- push_frontend_feature_flag(:vue_integration_form, current_user, default_enabled: :yaml)
- end
urgency :low, [:test]
end
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index 945540d1f8c..80acb369cb2 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -30,6 +30,7 @@ module Integrations
:datadog_site,
:datadog_env,
:datadog_service,
+ :datadog_tags,
:default_irc_uri,
:device,
:disable_diffs,
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index bac9732018c..eae087bca4d 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -17,7 +17,10 @@ module IssuableActions
def show
respond_to do |format|
format.html do
- @show_crm_contacts = issuable.is_a?(Issue) && can?(current_user, :read_crm_contact, issuable.project.group) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ @show_crm_contacts = issuable.is_a?(Issue) && # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ can?(current_user, :read_crm_contact, issuable.project.group) &&
+ CustomerRelations::Contact.exists_for_group?(issuable.project.group)
+
@issuable_sidebar = serializer.represent(issuable, serializer: 'sidebar') # rubocop:disable Gitlab/ModuleWithInstanceVariables
render 'show'
end
diff --git a/app/controllers/concerns/multiple_boards_actions.rb b/app/controllers/concerns/multiple_boards_actions.rb
index 85bb73463db..685c93fc2a2 100644
--- a/app/controllers/concerns/multiple_boards_actions.rb
+++ b/app/controllers/concerns/multiple_boards_actions.rb
@@ -14,7 +14,7 @@ module MultipleBoardsActions
end
def recent
- recent_visits = ::Boards::VisitsFinder.new(parent, current_user).latest(4)
+ recent_visits = ::Boards::VisitsFinder.new(parent, current_user).latest(Board::RECENT_BOARDS_SIZE)
recent_boards = recent_visits.map(&:board)
render json: serialize_as_json(recent_boards)
diff --git a/app/controllers/concerns/planning_hierarchy.rb b/app/controllers/concerns/planning_hierarchy.rb
new file mode 100644
index 00000000000..5df838bc183
--- /dev/null
+++ b/app/controllers/concerns/planning_hierarchy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module PlanningHierarchy
+ extend ActiveSupport::Concern
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def planning_hierarchy
+ return access_denied! unless can?(current_user, :read_planning_hierarchy, @project)
+
+ render 'shared/planning_hierarchy'
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+end
+
+PlanningHierarchy.prepend_mod_with('PlanningHierarchy')
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index f489de42864..e1bfe92f61b 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -142,6 +142,14 @@ module UploadsActions
uploader && uploader.exists? && uploader.embeddable?
end
+ def bypass_auth_checks_on_uploads?
+ if ::Feature.enabled?(:enforce_auth_checks_on_uploads, default_enabled: :yaml)
+ false
+ else
+ action_name == 'show' && embeddable?
+ end
+ end
+
def find_model
nil
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index d861ef646f8..0074bcac360 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -35,7 +35,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def starred
- @projects = load_projects(params.merge(starred: true))
+ @projects = load_projects(params.merge(starred: true, not_aimed_for_deletion: true))
.includes(:forked_from_project, :topics)
@groups = []
@@ -54,7 +54,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
private
def projects
- @projects ||= load_projects(params.merge(non_public: true))
+ @projects ||= load_projects(params.merge(non_public: true, not_aimed_for_deletion: true))
end
def render_projects
@@ -65,8 +65,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
def load_projects(finder_params)
- @total_user_projects_count = ProjectsFinder.new(params: { non_public: true, without_deleted: true }, current_user: current_user).execute
- @total_starred_projects_count = ProjectsFinder.new(params: { starred: true, without_deleted: true }, current_user: current_user).execute
+ @total_user_projects_count = ProjectsFinder.new(params: { non_public: true, without_deleted: true, not_aimed_for_deletion: true }, current_user: current_user).execute
+ @total_starred_projects_count = ProjectsFinder.new(params: { starred: true, without_deleted: true, not_aimed_for_deletion: true }, current_user: current_user).execute
finder_params[:use_cte] = true if use_cte_for_finder?
finder_params[:without_deleted] = true
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 2ecd17db487..f94da77609f 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -20,6 +20,10 @@ class DashboardController < Dashboard::ApplicationController
urgency :low, [:merge_requests]
+ before_action only: [:merge_requests] do
+ push_frontend_feature_flag(:mr_attention_requests, default_enabled: :yaml)
+ end
+
def activity
respond_to do |format|
format.html
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index 14dd2ae5691..f8a6d9f808e 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -92,7 +92,12 @@ class Explore::ProjectsController < Explore::ApplicationController
def load_projects
load_project_counts
- projects = ProjectsFinder.new(current_user: current_user, params: params.merge(minimum_search_length: MIN_SEARCH_LENGTH)).execute
+ finder_params = {
+ minimum_search_length: MIN_SEARCH_LENGTH,
+ not_aimed_for_deletion: true
+ }
+
+ projects = ProjectsFinder.new(current_user: current_user, params: params.merge(finder_params)).execute
projects = preload_associations(projects)
projects = projects.page(params[:page]).without_count
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 689ca32f6d9..ef229a2abec 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -31,6 +31,7 @@ class GraphqlController < ApplicationController
before_action :authorize_access_api!
before_action :set_user_last_activity
before_action :track_vs_code_usage
+ before_action :track_jetbrains_usage
before_action :disable_query_limiting
before_action :limit_query_size
@@ -137,6 +138,11 @@ class GraphqlController < ApplicationController
.track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
end
+ def track_jetbrains_usage
+ Gitlab::UsageDataCounters::JetBrainsPluginActivityUniqueCounter
+ .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
+ end
+
def execute_multiplex
GitlabSchema.multiplex(multiplex_queries, context: context)
end
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 6de77450a46..6fac6fcf426 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -11,8 +11,8 @@ class Groups::BoardsController < Groups::ApplicationController
push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
- e.use { }
- e.try { }
+ e.control { }
+ e.candidate { }
end.run
end
diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
index 00839583ecc..8513979c53b 100644
--- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb
+++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
@@ -120,7 +120,7 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy
end
def manifest_file_name
- @manifest_file_name ||= "#{image}:#{tag}.json"
+ @manifest_file_name ||= Gitlab::Utils.check_path_traversal!("#{image}:#{tag}.json")
end
def group
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index f602d02a165..b194aeff80d 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -9,10 +9,8 @@ class Groups::RunnersController < Groups::ApplicationController
feature_category :runner
def index
- ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') do
- finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group })
- @group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000)
- end
+ finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group })
+ @group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000)
end
def runner_list_group_view_vue_ui_enabled
@@ -37,7 +35,7 @@ class Groups::RunnersController < Groups::ApplicationController
if @runner.belongs_to_more_than_one_project?
redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found, alert: _('Runner was not deleted because it is assigned to multiple projects.')
else
- @runner.destroy
+ Ci::UnregisterRunnerService.new(@runner).execute
redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found
end
@@ -62,11 +60,9 @@ class Groups::RunnersController < Groups::ApplicationController
private
def runner
- ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') do
- @runner ||= Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }).execute
- .except(:limit, :offset)
- .find(params[:id])
- end
+ @runner ||= Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }).execute
+ .except(:limit, :offset)
+ .find(params[:id])
end
def runner_params
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index e125385f841..a290ef9b5e7 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -23,11 +23,6 @@ module Groups
@group_runners = runners_finder.execute.page(params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
@sort = runners_finder.sort_key
-
- # Allow sql generated by the two relations above, @all_group_runners and @group_runners
- ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') do
- render
- end
end
def update
diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb
index 49249f87d31..387f7be56cd 100644
--- a/app/controllers/groups/uploads_controller.rb
+++ b/app/controllers/groups/uploads_controller.rb
@@ -4,7 +4,7 @@ class Groups::UploadsController < Groups::ApplicationController
include UploadsActions
include WorkhorseRequest
- skip_before_action :group, if: -> { action_name == 'show' && embeddable? }
+ skip_before_action :group, if: -> { bypass_auth_checks_on_uploads? }
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 4acbb0482f3..12af76efe0d 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -38,6 +38,8 @@ class GroupsController < Groups::ApplicationController
before_action :check_export_rate_limit!, only: [:export, :download_export]
+ before_action :track_experiment_event, only: [:new]
+
helper_method :captcha_required?
skip_cross_project_access_check :index, :new, :create, :edit, :update,
@@ -207,6 +209,20 @@ class GroupsController < Groups::ApplicationController
end
end
+ def issues
+ return super if !html_request? || Feature.disabled?(:vue_issues_list, group, default_enabled: :yaml)
+
+ @has_issues = IssuesFinder.new(current_user, group_id: group.id).execute
+ .non_archived
+ .exists?
+
+ @has_projects = group_projects.exists?
+
+ respond_to do |format|
+ format.html
+ end
+ end
+
protected
def render_show_html
@@ -378,6 +394,12 @@ class GroupsController < Groups::ApplicationController
def captcha_required?
captcha_enabled? && !params[:parent_id]
end
+
+ def track_experiment_event
+ return if params[:parent_id]
+
+ experiment(:require_verification_for_namespace_creation, user: current_user).track(:start_create_group)
+ end
end
GroupsController.prepend_mod_with('GroupsController')
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 8de270e9d25..9b8c480e529 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -38,7 +38,7 @@ class Import::GitlabProjectsController < Import::BaseController
def project_params
params.permit(
- :path, :namespace_id, :file
+ :name, :path, :namespace_id, :file
)
end
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
index d3dea2ce159..a0c307a0a03 100644
--- a/app/controllers/metrics_controller.rb
+++ b/app/controllers/metrics_controller.rb
@@ -7,7 +7,6 @@ class MetricsController < ActionController::Base
def index
response = if Gitlab::Metrics.prometheus_metrics_enabled?
- Gitlab::Metrics::RailsSlis.initialize_request_slis_if_needed!
metrics_service.metrics_text
else
help_page = help_page_url('administration/monitoring/prometheus/gitlab_metrics',
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index ddf70c1892a..d1c409d071e 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -3,6 +3,7 @@
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
include Gitlab::Experimentation::ControllerConcern
include InitializesCurrentUserMode
+ include Gitlab::Utils::StrongMemoize
before_action :verify_confirmed_email!, :verify_confidential_application!
@@ -27,6 +28,56 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
private
+ def pre_auth_params
+ # Cannot be achieved with a before_action hook, due to the execution order.
+ downgrade_scopes! if action_name == 'new'
+
+ super
+ end
+
+ # limit scopes when signing in with GitLab
+ def downgrade_scopes!
+ return unless Feature.enabled?(:omniauth_login_minimal_scopes, current_user,
+ default_enabled: :yaml)
+
+ auth_type = params.delete('gl_auth_type')
+ return unless auth_type == 'login'
+
+ ensure_read_user_scope!
+
+ params['scope'] = Gitlab::Auth::READ_USER_SCOPE.to_s if application_has_read_user_scope?
+ end
+
+ # Configure the application to support read_user scope, if it already
+ # supports scopes with greater levels of privileges.
+ def ensure_read_user_scope!
+ return if application_has_read_user_scope?
+ return unless application_has_api_scope?
+
+ add_read_user_scope!
+ end
+
+ def add_read_user_scope!
+ return unless doorkeeper_application
+
+ scopes = doorkeeper_application.scopes
+ scopes.add(Gitlab::Auth::READ_USER_SCOPE)
+ doorkeeper_application.scopes = scopes
+ doorkeeper_application.save!
+ end
+
+ def doorkeeper_application
+ strong_memoize(:doorkeeper_application) { ::Doorkeeper::OAuth::Client.find(params['client_id'])&.application }
+ end
+
+ def application_has_read_user_scope?
+ doorkeeper_application&.includes_scope?(Gitlab::Auth::READ_USER_SCOPE)
+ end
+
+ def application_has_api_scope?
+ doorkeeper_application&.includes_scope?(*::Gitlab::Auth::API_SCOPES)
+ end
+
# Confidential apps require the client_secret to be sent with the request.
# Doorkeeper allows implicit grant flow requests (response_type=token) to
# work without client_secret regardless of the confidential setting.
diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb
index cf432cfb429..f678e19d05d 100644
--- a/app/controllers/projects/autocomplete_sources_controller.rb
+++ b/app/controllers/projects/autocomplete_sources_controller.rb
@@ -2,8 +2,9 @@
class Projects::AutocompleteSourcesController < Projects::ApplicationController
before_action :authorize_read_milestone!, only: :milestones
+ before_action :authorize_read_crm_contact!, only: :contacts
- feature_category :team_planning, [:issues, :labels, :milestones, :commands]
+ feature_category :team_planning, [:issues, :labels, :milestones, :commands, :contacts]
feature_category :code_review, [:merge_requests]
feature_category :users, [:members]
feature_category :snippets, [:snippets]
@@ -38,6 +39,10 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
render json: autocomplete_service.snippets
end
+ def contacts
+ render json: autocomplete_service.contacts
+ end
+
private
def autocomplete_service
@@ -49,6 +54,10 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
.new(project, current_user)
.execute(params[:type], params[:type_id])
end
+
+ def authorize_read_crm_contact!
+ render_404 unless can?(current_user, :read_crm_contact, project.root_ancestor)
+ end
end
Projects::AutocompleteSourcesController.prepend_mod_with('Projects::AutocompleteSourcesController')
diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb
index 8023e51b552..42bd87e1c01 100644
--- a/app/controllers/projects/badges_controller.rb
+++ b/app/controllers/projects/badges_controller.rb
@@ -8,6 +8,7 @@ class Projects::BadgesController < Projects::ApplicationController
feature_category :continuous_integration, [:index, :pipeline]
feature_category :code_testing, [:coverage]
+ feature_category :release_orchestration, [:release]
def pipeline
pipeline_status = Gitlab::Ci::Badge::Pipeline::Status
@@ -34,6 +35,17 @@ class Projects::BadgesController < Projects::ApplicationController
render_badge coverage_report
end
+ def release
+ latest_release = Gitlab::Ci::Badge::Release::LatestRelease
+ .new(project, current_user, opts: {
+ key_text: params[:key_text],
+ key_width: params[:key_width],
+ order_by: params[:order_by]
+ })
+
+ render_badge latest_release
+ end
+
private
def badge_layout
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index adaa47b48cb..0170cff6160 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -11,8 +11,8 @@ class Projects::BoardsController < Projects::ApplicationController
push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
- e.use { }
- e.try { }
+ e.control { }
+ e.candidate { }
end.run
end
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 63ac5f97420..dad73c37fea 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -131,11 +131,28 @@ class Projects::BranchesController < Projects::ApplicationController
private
def sort_value_for_mode
- return params[:sort] if params[:sort].present?
+ custom_sort || default_sort
+ end
+
+ def custom_sort
+ sort = params[:sort].presence
+
+ unless sort.in?(supported_sort_options)
+ flash.now[:alert] = _("Unsupported sort value.")
+ sort = nil
+ end
+ sort
+ end
+
+ def default_sort
'stale' == @mode ? sort_value_oldest_updated : sort_value_recently_updated
end
+ def supported_sort_options
+ [nil, sort_value_name, sort_value_oldest_updated, sort_value_recently_updated]
+ end
+
# It can be expensive to calculate the diverging counts for each
# branch. Normally the frontend should be specifying a set of branch
# names, but prior to
diff --git a/app/controllers/projects/cluster_agents_controller.rb b/app/controllers/projects/cluster_agents_controller.rb
index 404d3907128..84bb01ee266 100644
--- a/app/controllers/projects/cluster_agents_controller.rb
+++ b/app/controllers/projects/cluster_agents_controller.rb
@@ -16,7 +16,7 @@ class Projects::ClusterAgentsController < Projects::ApplicationController
private
def authorize_can_read_cluster_agent!
- return if can?(current_user, :admin_cluster, project)
+ return if can?(current_user, :read_cluster, project)
access_denied!
end
diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb
index 8f45fa1cb9f..440375bf3c9 100644
--- a/app/controllers/projects/clusters_controller.rb
+++ b/app/controllers/projects/clusters_controller.rb
@@ -6,6 +6,7 @@ class Projects::ClustersController < Clusters::ClustersController
before_action do
push_frontend_feature_flag(:prometheus_computed_alerts)
+ push_frontend_feature_flag(:show_gitlab_agent_feedback, type: :ops, default_enabled: :yaml)
end
layout 'project'
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 62935e133c5..0ce0b8b8895 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -92,6 +92,8 @@ class Projects::CommitController < Projects::ApplicationController
end
def branches
+ return git_not_found! unless commit
+
# branch_names_contains/tag_names_contains can take a long time when there are thousands of
# branches/tags - each `git branch --contains xxx` request can consume a cpu core.
# so only do the query when there are a manageable number of branches/tags
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 1ca35903703..82a13b60b13 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -67,11 +67,11 @@ class Projects::CommitsController < Projects::ApplicationController
def set_commits
render_404 unless @path.empty? || request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present?
- limit = params[:limit].to_i
+ limit = permitted_params[:limit].to_i
@limit = limit > 0 ? limit : COMMITS_DEFAULT_LIMIT # limit can only ever be a positive number
- @offset = (params[:offset] || 0).to_i
- search = params[:search]
- author = params[:author]
+ @offset = (permitted_params[:offset] || 0).to_i
+ search = permitted_params[:search]
+ author = permitted_params[:author]
@commits =
if search.present?
@@ -87,4 +87,8 @@ class Projects::CommitsController < Projects::ApplicationController
@commits = @commits.with_latest_pipeline(@ref)
@commits = set_commits_for_rendering(@commits)
end
+
+ def permitted_params
+ params.permit(:limit, :offset, :search, :author)
+ end
end
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 07f7c1cf7de..243cc7a346c 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -28,6 +28,7 @@ class Projects::CompareController < Projects::ApplicationController
COMMIT_DIFFS_PER_PAGE = 20
def index
+ compare_params
end
def show
@@ -44,9 +45,9 @@ class Projects::CompareController < Projects::ApplicationController
def create
from_to_vars = {
- from: params[:from].presence,
- to: params[:to].presence,
- from_project_id: params[:from_project_id].presence
+ from: compare_params[:from].presence,
+ to: compare_params[:to].presence,
+ from_project_id: compare_params[:from_project_id].presence
}
if from_to_vars[:from].blank? || from_to_vars[:to].blank?
@@ -87,10 +88,10 @@ class Projects::CompareController < Projects::ApplicationController
# target == start_ref == from
def target_project
strong_memoize(:target_project) do
- next source_project unless params.key?(:from_project_id)
- next source_project if params[:from_project_id].to_i == source_project.id
+ next source_project unless compare_params.key?(:from_project_id)
+ next source_project if compare_params[:from_project_id].to_i == source_project.id
- target_project = target_projects(source_project).find_by_id(params[:from_project_id])
+ target_project = target_projects(source_project).find_by_id(compare_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)
@@ -111,13 +112,13 @@ class Projects::CompareController < Projects::ApplicationController
end
def start_ref
- @start_ref ||= Addressable::URI.unescape(params[:from])
+ @start_ref ||= Addressable::URI.unescape(compare_params[:from])
end
def head_ref
return @ref if defined?(@ref)
- @ref = @head_ref = Addressable::URI.unescape(params[:to])
+ @ref = @head_ref = Addressable::URI.unescape(compare_params[:to])
end
def define_commits
@@ -146,4 +147,8 @@ class Projects::CompareController < Projects::ApplicationController
.find_by(source_project: source_project, source_branch: head_ref, target_branch: start_ref)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def compare_params
+ @compare_params ||= params.permit(:from, :to, :from_project_id)
+ end
end
diff --git a/app/controllers/projects/design_management/designs_controller.rb b/app/controllers/projects/design_management/designs_controller.rb
index 550d8578396..2aa48249c0e 100644
--- a/app/controllers/projects/design_management/designs_controller.rb
+++ b/app/controllers/projects/design_management/designs_controller.rb
@@ -4,6 +4,7 @@ class Projects::DesignManagement::DesignsController < Projects::ApplicationContr
before_action :authorize_read_design!
feature_category :design_management
+ urgency :low
private
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index ba83f8dad35..475c41eec9c 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -22,14 +22,14 @@ class Projects::ForksController < Projects::ApplicationController
end
def index
- @sort = params[:sort]
+ @sort = forks_params[:sort]
@total_forks_count = project.forks.size
@public_forks_count = project.forks.public_only.size
@private_forks_count = @total_forks_count - project.forks.public_and_internal_only.size
@internal_forks_count = @total_forks_count - @public_forks_count - @private_forks_count
- @forks = load_forks.page(params[:page])
+ @forks = load_forks.page(forks_params[:page])
prepare_projects_for_rendering(@forks)
@@ -98,7 +98,7 @@ class Projects::ForksController < Projects::ApplicationController
def load_forks
forks = ForkProjectsFinder.new(
project,
- params: params.merge(search: params[:filter_projects]),
+ params: forks_params.merge(search: forks_params[:filter_projects]),
current_user: current_user
).execute
@@ -117,6 +117,10 @@ class Projects::ForksController < Projects::ApplicationController
end
end
+ def forks_params
+ params.permit(:filter_projects, :sort, :page)
+ end
+
def fork_params
params.permit(:path, :name, :description, :visibility).tap do |param|
param[:namespace] = fork_namespace
diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb
index 4e7fd73e378..1941eb8a5f9 100644
--- a/app/controllers/projects/google_cloud/deployments_controller.rb
+++ b/app/controllers/projects/google_cloud/deployments_controller.rb
@@ -4,10 +4,63 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base
before_action :validate_gcp_token!
def cloud_run
- render json: "Placeholder"
+ params = { token_in_session: token_in_session }
+ enable_cloud_run_response = GoogleCloud::EnableCloudRunService
+ .new(project, current_user, params).execute
+
+ if enable_cloud_run_response[:status] == :error
+ flash[:error] = enable_cloud_run_response[:message]
+ redirect_to project_google_cloud_index_path(project)
+ else
+ params = { action: GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_RUN }
+ generate_pipeline_response = GoogleCloud::GeneratePipelineService
+ .new(project, current_user, params).execute
+
+ if generate_pipeline_response[:status] == :error
+ flash[:error] = 'Failed to generate pipeline'
+ redirect_to project_google_cloud_index_path(project)
+ else
+ cloud_run_mr_params = cloud_run_mr_params(generate_pipeline_response[:branch_name])
+ redirect_to project_new_merge_request_path(project, merge_request: cloud_run_mr_params)
+ end
+ end
+ rescue Google::Apis::ClientError => error
+ handle_gcp_error(error, project)
end
def cloud_storage
render json: "Placeholder"
end
+
+ private
+
+ def cloud_run_mr_params(branch_name)
+ {
+ title: cloud_run_mr_title,
+ description: cloud_run_mr_description(branch_name),
+ source_project_id: project.id,
+ target_project_id: project.id,
+ source_branch: branch_name,
+ target_branch: project.default_branch
+ }
+ end
+
+ def cloud_run_mr_title
+ 'Enable deployments to Cloud Run'
+ end
+
+ def cloud_run_mr_description(branch_name)
+ <<-TEXT
+This merge request includes a Cloud Run deployment job in the pipeline definition (.gitlab-ci.yml).
+
+The `deploy-to-cloud-run` job:
+* Requires the following environment variables
+ * `GCP_PROJECT_ID`
+ * `GCP_SERVICE_ACCOUNT_KEY`
+* Job definition can be found at: https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library
+
+This pipeline definition has been committed to the branch `#{branch_name}`.
+You may modify the pipeline definition further or accept the changes as-is if suitable.
+ TEXT
+ end
end
diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb
index 1fa8ae60376..206a8c7e391 100644
--- a/app/controllers/projects/google_cloud_controller.rb
+++ b/app/controllers/projects/google_cloud_controller.rb
@@ -6,6 +6,8 @@ class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController
screen: 'home',
serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project,
createServiceAccountUrl: project_google_cloud_service_accounts_path(project),
+ enableCloudRunUrl: project_google_cloud_deployments_cloud_run_path(project),
+ enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project),
emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg')
}.to_json
end
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 27893fe510d..6bc81381d92 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -28,7 +28,7 @@ class Projects::GroupLinksController < Projects::ApplicationController
if group_link.expires?
render json: {
- expires_in: helpers.distance_of_time_in_words_to_now(group_link.expires_at),
+ expires_in: helpers.time_ago_with_tooltip(group_link.expires_at),
expires_soon: group_link.expires_soon?
}
else
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 785fbdaa611..1b98810b09b 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -10,7 +10,7 @@ class Projects::IssuesController < Projects::ApplicationController
include RecordUserLastActivity
ISSUES_EXCEPT_ACTIONS = %i[index calendar new create bulk_update import_csv export_csv service_desk].freeze
- SET_ISSUABLES_INDEX_ONLY_ACTIONS = %i[index calendar service_desk].freeze
+ SET_ISSUABLES_INDEX_ONLY_ACTIONS = %i[calendar service_desk].freeze
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) }
@@ -42,18 +42,20 @@ class Projects::IssuesController < Projects::ApplicationController
if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) }
before_action do
- push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
+ push_frontend_feature_flag(:contacts_autocomplete, project&.group, default_enabled: :yaml)
+ push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml)
end
before_action only: :show do
- push_frontend_feature_flag(:real_time_issue_sidebar, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:real_time_issue_sidebar, project, default_enabled: :yaml)
push_frontend_feature_flag(:confidential_notes, project&.group, default_enabled: :yaml)
- push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:paginated_issue_discussions, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:fix_comment_scroll, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:issue_assignees_widget, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:paginated_issue_discussions, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:fix_comment_scroll, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:work_items, project, default_enabled: :yaml)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
@@ -71,11 +73,14 @@ class Projects::IssuesController < Projects::ApplicationController
]
feature_category :service_desk, [:service_desk]
+ urgency :low, [:service_desk]
feature_category :importers, [:import_csv, :export_csv]
attr_accessor :vulnerability_id
def index
+ set_issuables_index if !html_request? || Feature.disabled?(:vue_issues_list, project&.group, default_enabled: :yaml)
+
@issues = @issuables
respond_to do |format|
@@ -317,7 +322,7 @@ class Projects::IssuesController < Projects::ApplicationController
end
def reorder_params
- params.permit(:move_before_id, :move_after_id, :group_full_path)
+ params.permit(:move_before_id, :move_after_id)
end
def store_uri
diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb
index 645720a0889..686d2c1dc1f 100644
--- a/app/controllers/projects/merge_requests/drafts_controller.rb
+++ b/app/controllers/projects/merge_requests/drafts_controller.rb
@@ -92,7 +92,8 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
:commit_id,
:note,
:position,
- :resolve_discussion
+ :resolve_discussion,
+ :line_code
).tap do |h|
# Old FE version will still be sending `draft_note[commit_id]` as 'undefined'.
# That can result to having a note linked to a commit with 'undefined' ID
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index f936aeb0084..6445f920db5 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -30,24 +30,31 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
+ before_action only: [:index, :show] do
+ push_frontend_feature_flag(:mr_attention_requests, project, default_enabled: :yaml)
+ end
+
before_action only: [:show] do
push_frontend_feature_flag(:file_identifier_hash)
- push_frontend_feature_flag(:merge_request_widget_graphql, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:default_merge_ref_for_diffs, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:core_security_mr_widget_counts, @project)
- push_frontend_feature_flag(:paginated_notes, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:merge_request_widget_graphql, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:default_merge_ref_for_diffs, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:core_security_mr_widget_counts, project)
+ push_frontend_feature_flag(:paginated_notes, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:confidential_notes, project, default_enabled: :yaml)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
- push_frontend_feature_flag(:diffs_virtual_scrolling, project, default_enabled: :yaml)
push_frontend_feature_flag(:restructured_mr_widget, project, default_enabled: :yaml)
- push_frontend_feature_flag(:mr_changes_fluid_layout, project, default_enabled: :yaml)
- push_frontend_feature_flag(:mr_attention_requests, project, default_enabled: :yaml)
- push_frontend_feature_flag(:refactor_mr_widgets_extensions, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:rebase_without_ci_ui, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:refactor_mr_widgets_extensions, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:rebase_without_ci_ui, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:rearrange_pipelines_table, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml)
# Usage data feature flags
- push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml)
push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml)
- push_frontend_feature_flag(:diff_searching_usage_data, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:usage_data_diff_searches, project, default_enabled: :yaml)
+ end
+
+ before_action do
+ push_frontend_feature_flag(:permit_all_shared_groups_for_approval, @project, default_enabled: :yaml)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
@@ -100,10 +107,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
# rubocop:disable Metrics/AbcSize
def show
close_merge_request_if_no_source_project
-
- if Feature.disabled?(:check_mergeability_async_in_widget, @project, default_enabled: :yaml)
- @merge_request.check_mergeability(async: true)
- end
+ @merge_request.check_mergeability(async: true)
respond_to do |format|
format.html do
@@ -504,6 +508,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
.can_push_to_branch?(@merge_request.source_branch)
access_denied! unless access_check
+
+ access_denied! unless merge_request.permits_force_push?
end
def merge_access_check
diff --git a/app/controllers/projects/packages/infrastructure_registry_controller.rb b/app/controllers/projects/packages/infrastructure_registry_controller.rb
index c02a0a56e03..2fe353b7acb 100644
--- a/app/controllers/projects/packages/infrastructure_registry_controller.rb
+++ b/app/controllers/projects/packages/infrastructure_registry_controller.rb
@@ -9,11 +9,7 @@ module Projects
def show
@package = project.packages.find(params[:id])
- @package_files = if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- @package.installable_package_files.recent
- else
- @package.package_files.recent
- end
+ @package_files = @package.installable_package_files.recent
end
end
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 71dc67bb6dc..7f680bbf121 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -13,6 +13,9 @@ 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 :ensure_pipeline, only: [:show, :downloadable_artifacts]
+ before_action do
+ push_frontend_feature_flag(:rearrange_pipelines_table, project, default_enabled: :yaml)
+ end
before_action do
push_frontend_feature_flag(:jobs_tab_vue, @project, default_enabled: :yaml)
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index e8074f7d793..dc0614c6bdd 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -13,8 +13,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController
def index
@sort = params[:sort].presence || sort_value_name
- @skip_groups = @project.invited_group_ids
- @skip_groups += @project.group.self_and_ancestors_ids if @project.group
+ @skip_groups = @project.related_group_ids
@group_links = @project.project_group_links
@group_links = @group_links.search(params[:search_groups]) if params[:search_groups].present?
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index 73eb6bb2bf2..b070f9419fc 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -18,7 +18,7 @@ class Projects::RefsController < Projects::ApplicationController
respond_to do |format|
format.html do
new_path =
- case params[:destination]
+ case permitted_params[:destination]
when "tree"
project_tree_path(@project, @id)
when "blob"
@@ -45,7 +45,7 @@ class Projects::RefsController < Projects::ApplicationController
def logs_tree
tree_summary = ::Gitlab::TreeSummary.new(
@commit, @project, current_user,
- path: @path, offset: params[:offset], limit: 25)
+ path: @path, offset: permitted_params[:offset], limit: 25)
respond_to do |format|
format.html { render_404 }
@@ -62,6 +62,10 @@ class Projects::RefsController < Projects::ApplicationController
private
def validate_ref_id
- return not_found! if params[:id].present? && params[:id] !~ Gitlab::PathRegex.git_reference_regex
+ return not_found if permitted_params[:id].present? && permitted_params[:id] !~ Gitlab::PathRegex.git_reference_regex
+ end
+
+ def permitted_params
+ params.permit(:id, :offset, :destination)
end
end
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index 77826a2f789..9fc75fff807 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -23,7 +23,7 @@ class Projects::RepositoriesController < Projects::ApplicationController
feature_category :source_code_management
def create
- @project.create_repository
+ @project.create_repository unless @project.repository_exists?
redirect_to project_path(@project)
end
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 62a9f8a4625..192a29730d9 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -23,7 +23,7 @@ class Projects::RunnersController < Projects::ApplicationController
def destroy
if @runner.only_for?(project)
- @runner.destroy
+ Ci::UnregisterRunnerService.new(@runner).execute
end
redirect_to project_runners_path(@project), status: :found
diff --git a/app/controllers/projects/security/configuration_controller.rb b/app/controllers/projects/security/configuration_controller.rb
index 14f765814e6..7b799cc0aa6 100644
--- a/app/controllers/projects/security/configuration_controller.rb
+++ b/app/controllers/projects/security/configuration_controller.rb
@@ -10,6 +10,8 @@ module Projects
def show
render_403 unless can?(current_user, :read_security_configuration, project)
+ @configuration ||= configuration_presenter
+
respond_to do |format|
format.html
format.json do
diff --git a/app/controllers/projects/service_desk_controller.rb b/app/controllers/projects/service_desk_controller.rb
index 1fb07c3a903..aa0e70121df 100644
--- a/app/controllers/projects/service_desk_controller.rb
+++ b/app/controllers/projects/service_desk_controller.rb
@@ -4,6 +4,7 @@ class Projects::ServiceDeskController < Projects::ApplicationController
before_action :authorize_admin_project!
feature_category :service_desk
+ urgency :low
def show
json_response
diff --git a/app/controllers/projects/service_ping_controller.rb b/app/controllers/projects/service_ping_controller.rb
index 00530c09be8..368da8d1ef2 100644
--- a/app/controllers/projects/service_ping_controller.rb
+++ b/app/controllers/projects/service_ping_controller.rb
@@ -13,6 +13,14 @@ class Projects::ServicePingController < Projects::ApplicationController
head(200)
end
+ def web_ide_clientside_preview_success
+ return render_404 unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?
+
+ Gitlab::UsageDataCounters::WebIdeCounter.increment_previews_success_count
+
+ head(200)
+ end
+
def web_ide_pipelines_count
Gitlab::UsageDataCounters::WebIdeCounter.increment_pipelines_count
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 9896f75c099..1321111faaf 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -12,9 +12,6 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :web_hook_logs, only: [:edit, :update]
before_action :set_deprecation_notice_for_prometheus_integration, only: [:edit, :update]
before_action :redirect_deprecated_prometheus_integration, only: [:update]
- before_action do
- push_frontend_feature_flag(:vue_integration_form, current_user, default_enabled: :yaml)
- end
respond_to :html
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index c71134e0547..dd2fb57f7ac 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -160,6 +160,8 @@ module Projects
@badges.map! do |badge|
badge.new(@project, @ref).metadata
end
+
+ @badges.append(Gitlab::Ci::Badge::Release::LatestRelease.new(@project, current_user).metadata)
end
def define_auto_devops_variables
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index d750bd201e2..a28c08e87cb 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -25,7 +25,7 @@ module Projects
if result[:status] == :success
flash[:notice] = _('Repository cleanup has started. You will receive an email once the cleanup operation is complete.')
else
- flash[:alert] = status.fetch(:message, _('Failed to upload object map file'))
+ flash[:alert] = result.fetch(:message, _('Failed to upload object map file'))
end
redirect_to project_settings_repository_path(project)
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index de0faaca9c0..6472d3c3454 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -16,13 +16,16 @@ class Projects::TagsController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def index
begin
- params[:sort] = params[:sort].presence || sort_value_recently_updated
+ tags_params = params
+ .permit(:search, :sort, :per_page, :page_token, :page)
+ .with_defaults(sort: sort_value_recently_updated)
- @sort = params[:sort]
+ @sort = tags_params[:sort]
+ @search = tags_params[:search]
- @tags = TagsFinder.new(@repository, params).execute
+ @tags = TagsFinder.new(@repository, tags_params).execute
- @tags = Kaminari.paginate_array(@tags).page(params[:page])
+ @tags = Kaminari.paginate_array(@tags).page(tags_params[:page])
tag_names = @tags.map(&:name)
@tags_pipelines = @project.ci_pipelines.latest_successful_for_refs(tag_names)
@@ -31,6 +34,7 @@ class Projects::TagsController < Projects::ApplicationController
rescue Gitlab::Git::CommandError => e
@tags = []
+ @releases = []
@tags_loading_error = e
end
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index c15768e7bbb..ed5bd73d6d1 100644
--- a/app/controllers/projects/uploads_controller.rb
+++ b/app/controllers/projects/uploads_controller.rb
@@ -6,7 +6,7 @@ class Projects::UploadsController < Projects::ApplicationController
# These will kick you out if you don't have access.
skip_before_action :project, :repository,
- if: -> { action_name == 'show' && embeddable? }
+ if: -> { bypass_auth_checks_on_uploads? }
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 64abcd7cc33..519d9cd0d52 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -10,6 +10,7 @@ class ProjectsController < Projects::ApplicationController
include ImportUrlParams
include FiltersEvents
include SourcegraphDecorator
+ include PlanningHierarchy
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
@@ -41,6 +42,7 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:work_items, @project, default_enabled: :yaml)
end
layout :determine_layout
@@ -54,6 +56,7 @@ class ProjectsController < Projects::ApplicationController
feature_category :team_planning, [:preview_markdown, :new_issuable_address]
feature_category :importers, [:export, :remove_export, :generate_new_export, :download_export]
feature_category :code_review, [:unfoldered_environment_names]
+ feature_category :portfolio_management, [:planning_hierarchy]
urgency :low, [:refs]
urgency :high, [:unfoldered_environment_names]
@@ -283,7 +286,7 @@ class ProjectsController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def refs
- find_refs = params['find']
+ find_refs = refs_params['find']
find_branches = true
find_tags = true
@@ -298,13 +301,13 @@ class ProjectsController < Projects::ApplicationController
options = {}
if find_branches
- branches = BranchesFinder.new(@repository, params).execute.take(100).map(&:name)
+ branches = BranchesFinder.new(@repository, refs_params).execute.take(100).map(&:name)
options['Branches'] = branches
end
if find_tags && @repository.tag_count.nonzero?
tags = begin
- TagsFinder.new(@repository, params).execute
+ TagsFinder.new(@repository, refs_params).execute
rescue Gitlab::Git::CommandError
[]
end
@@ -313,7 +316,7 @@ class ProjectsController < Projects::ApplicationController
end
# If reference is commit id - we should add it to branch/tag selectbox
- ref = Addressable::URI.unescape(params[:ref])
+ ref = Addressable::URI.unescape(refs_params[:ref])
if find_commits && ref && options.flatten(2).exclude?(ref) && ref =~ /\A[0-9a-zA-Z]{6,52}\z/
options['Commits'] = [ref]
end
@@ -342,6 +345,14 @@ class ProjectsController < Projects::ApplicationController
private
+ def refs_params
+ if Feature.enabled?(:strong_parameters_for_project_controller, @project, default_enabled: :yaml)
+ params.permit(:search, :sort, :ref, find: [])
+ else
+ params
+ end
+ end
+
# Render project landing depending of which features are available
# So if page is not available in the list it renders the next page
#
@@ -461,8 +472,8 @@ class ProjectsController < Projects::ApplicationController
:suggestion_commit_message,
:packages_enabled,
:service_desk_enabled,
- :merge_commit_template,
- :squash_commit_template,
+ :merge_commit_template_or_default,
+ :squash_commit_template_or_default,
project_setting_attributes: project_setting_attributes
] + [project_feature_attributes: project_feature_attributes]
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index c1765d367d1..057c451ace2 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -6,6 +6,7 @@ class RegistrationsController < Devise::RegistrationsController
include RecaptchaHelper
include InvisibleCaptchaOnSignup
include OneTrustCSP
+ include BizibleCSP
layout 'devise'
@@ -35,6 +36,7 @@ class RegistrationsController < Devise::RegistrationsController
persist_accepted_terms_if_required(new_user)
set_role_required(new_user)
+ track_experiment_event(new_user)
if pending_approval?
NotificationService.new.new_instance_access_request(new_user)
@@ -223,6 +225,14 @@ class RegistrationsController < Devise::RegistrationsController
def context_user
current_user
end
+
+ def track_experiment_event(new_user)
+ # Track signed up event to relate it with click "Sign up" button events from
+ # the experimental logged out header with marketing links. This allows us to
+ # have a funnel of visitors clicking on the header and those visitors
+ # signing up and becoming users
+ experiment(:logged_out_marketing_header, actor: new_user).track(:signed_up) if new_user.persisted?
+ end
end
RegistrationsController.prepend_mod_with('RegistrationsController')
diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb
index c002c9b83f9..24520a187e3 100644
--- a/app/controllers/repositories/git_http_client_controller.rb
+++ b/app/controllers/repositories/git_http_client_controller.rb
@@ -17,6 +17,9 @@ module Repositories
prepend_before_action :authenticate_user, :parse_repo_path
+ skip_around_action :sessionless_bypass_admin_mode!
+ around_action :bypass_admin_mode!, if: :authenticated_user
+
feature_category :source_code_management
def authenticated_user
@@ -136,6 +139,12 @@ module Repositories
container &&
Guest.can?(repo_type.guest_read_ability, container)
end
+
+ def bypass_admin_mode!(&block)
+ return yield unless Gitlab::CurrentSettings.admin_mode
+
+ Gitlab::Auth::CurrentUserMode.bypass_session!(authenticated_user.id, &block)
+ end
end
end
diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb
index 2b0aa67326e..7deda473b9d 100644
--- a/app/controllers/repositories/lfs_api_controller.rb
+++ b/app/controllers/repositories/lfs_api_controller.rb
@@ -155,7 +155,6 @@ module Repositories
end
def should_auto_link?
- return false unless Feature.enabled?(:lfs_auto_link_fork_source, project, default_enabled: :yaml)
return false unless project.forked?
# Sanity check in case for some reason the user doesn't have access to the parent
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index d58ed252a36..e38eeaed367 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -144,6 +144,7 @@ class SearchController < ApplicationController
payload[:metadata]['meta.search.filters.state'] = params[:state]
payload[:metadata]['meta.search.force_search_results'] = params[:force_search_results]
payload[:metadata]['meta.search.project_ids'] = params[:project_ids]
+ payload[:metadata]['meta.search.search_level'] = params[:search_level]
if search_service.abuse_detected?
payload[:metadata]['abuse.confidence'] = Gitlab::Abuse.confidence(:certain)
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 7e8e3ea8789..e907e291eeb 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -10,6 +10,7 @@ class SessionsController < Devise::SessionsController
include KnownSignIn
include Gitlab::Utils::StrongMemoize
include OneTrustCSP
+ include BizibleCSP
skip_before_action :check_two_factor_requirement, only: [:destroy]
skip_before_action :check_password_expiration, only: [:destroy]
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 8710eebf210..f6cef7e133c 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -148,7 +148,11 @@ class UsersController < ApplicationController
end
def exists
- render json: { exists: !!Namespace.find_by_path_or_name(params[:username]) }
+ if Gitlab::CurrentSettings.signup_enabled? || current_user
+ render json: { exists: !!Namespace.find_by_path_or_name(params[:username]) }
+ else
+ render json: { error: _('You must be authenticated to access this path.') }, status: :unauthorized
+ end
end
def follow
@@ -182,7 +186,7 @@ class UsersController < ApplicationController
end
def starred_projects
- StarredProjectsFinder.new(user, current_user: current_user).execute
+ StarredProjectsFinder.new(user, params: finder_params, current_user: current_user).execute
end
def contributions_calendar
@@ -248,6 +252,15 @@ class UsersController < ApplicationController
end
end
end
+
+ def finder_params
+ {
+ # don't display projects pending deletion
+ without_deleted: true,
+ # don't display projects marked for deletion
+ not_aimed_for_deletion: true
+ }
+ end
end
UsersController.prepend_mod_with('UsersController')
diff --git a/app/events/ci/job_artifacts_deleted_event.rb b/app/events/ci/job_artifacts_deleted_event.rb
new file mode 100644
index 00000000000..2972342cae6
--- /dev/null
+++ b/app/events/ci/job_artifacts_deleted_event.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Ci
+ class JobArtifactsDeletedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'required' => ['job_ids'],
+ 'properties' => {
+ 'job_ids' => {
+ 'type' => 'array',
+ 'properties' => {
+ 'job_id' => { 'type' => 'integer' }
+ }
+ }
+ }
+ }
+ end
+ end
+end
diff --git a/app/events/members/members_added_event.rb b/app/events/members/members_added_event.rb
new file mode 100644
index 00000000000..c174ffebab6
--- /dev/null
+++ b/app/events/members/members_added_event.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Members
+ class MembersAddedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'required' => %w[source_id source_type],
+ 'properties' => {
+ 'source_id' => { 'type' => 'integer' },
+ 'source_type' => { 'type' => 'string' }
+ }
+ }
+ end
+ end
+end
diff --git a/app/events/projects/project_deleted_event.rb b/app/events/projects/project_deleted_event.rb
new file mode 100644
index 00000000000..ac58c5c6755
--- /dev/null
+++ b/app/events/projects/project_deleted_event.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Projects
+ class ProjectDeletedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'project_id' => { 'type' => 'integer' },
+ 'namespace_id' => { 'type' => 'integer' }
+ },
+ 'required' => %w[project_id namespace_id]
+ }
+ end
+ end
+end
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
index 859716b4739..f6af7ca15bb 100644
--- a/app/experiments/application_experiment.rb
+++ b/app/experiments/application_experiment.rb
@@ -1,14 +1,6 @@
# frozen_string_literal: true
-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 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
-
+class ApplicationExperiment < Gitlab::Experiment
def publish(_result = nil)
super
@@ -41,10 +33,6 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
# define a default nil control behavior so we can omit it when not needed
end
- def track(action, **event_args)
- super(action, **tracking_context.merge(event_args))
- end
-
# TODO: remove
# This is deprecated logic as of v0.6.0 and should eventually be removed, but
# needs to stay intact for actively running experiments. The new strategy
@@ -64,24 +52,16 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
private
- def tracking_context
+ def tracking_context(event_args)
{
namespace: context.try(:namespace) || context.try(:group),
project: context.try(:project),
user: user_or_actor
- }.compact || {}
+ }.merge(event_args)
end
def user_or_actor
actor = context.try(:actor)
actor.respond_to?(:id) ? actor : context.try(:user)
end
-
- def feature_flag_name
- name.tr('/', '_')
- end
-
- def experiment_group?
- Feature.enabled?(feature_flag_name, self, type: :experiment, default_enabled: :yaml)
- end
end
diff --git a/app/experiments/combined_registration_experiment.rb b/app/experiments/combined_registration_experiment.rb
index 08c015838db..576e10815aa 100644
--- a/app/experiments/combined_registration_experiment.rb
+++ b/app/experiments/combined_registration_experiment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class CombinedRegistrationExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+class CombinedRegistrationExperiment < ApplicationExperiment
include Rails.application.routes.url_helpers
def key_for(source, _ = nil)
diff --git a/app/experiments/empty_repo_upload_experiment.rb b/app/experiments/empty_repo_upload_experiment.rb
index d0d79a5fb45..c8c75f32d69 100644
--- a/app/experiments/empty_repo_upload_experiment.rb
+++ b/app/experiments/empty_repo_upload_experiment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class EmptyRepoUploadExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+class EmptyRepoUploadExperiment < ApplicationExperiment
include ProjectCommitCount
TRACKING_START_DATE = DateTime.parse('2021/4/20')
diff --git a/app/experiments/force_company_trial_experiment.rb b/app/experiments/force_company_trial_experiment.rb
index 00bdd5d693d..e7b98bb18ad 100644
--- a/app/experiments/force_company_trial_experiment.rb
+++ b/app/experiments/force_company_trial_experiment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ForceCompanyTrialExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+class ForceCompanyTrialExperiment < ApplicationExperiment
exclude :setup_for_personal
private
diff --git a/app/experiments/in_product_guidance_environments_webide_experiment.rb b/app/experiments/in_product_guidance_environments_webide_experiment.rb
index d77063a9834..6567ec0b3f1 100644
--- a/app/experiments/in_product_guidance_environments_webide_experiment.rb
+++ b/app/experiments/in_product_guidance_environments_webide_experiment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment
exclude :has_environments?
def control_behavior
diff --git a/app/experiments/new_project_readme_content_experiment.rb b/app/experiments/new_project_readme_content_experiment.rb
deleted file mode 100644
index 1de7632268d..00000000000
--- a/app/experiments/new_project_readme_content_experiment.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-class NewProjectReadmeContentExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
- TEMPLATE_PATH = Rails.root.join('app', 'experiments', 'templates', 'new_project_readme_content')
- include Rails.application.routes.url_helpers
-
- def run_with(project, variant: nil)
- @project = project
- publish_to_database
- run(variant)
- end
-
- def control_behavior
- template('readme_basic.md')
- end
-
- def advanced_behavior
- template('readme_advanced.md')
- end
-
- def redirect(to_url)
- experiment_redirect_url(self, url: to_url)
- end
-
- private
-
- def template(name)
- ERB.new(File.read(TEMPLATE_PATH.join("#{name}.tt")), trim_mode: '<>').result(binding)
- end
-end
diff --git a/app/experiments/new_project_sast_enabled_experiment.rb b/app/experiments/new_project_sast_enabled_experiment.rb
index a779b8ec633..ee9d0dc1700 100644
--- a/app/experiments/new_project_sast_enabled_experiment.rb
+++ b/app/experiments/new_project_sast_enabled_experiment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class NewProjectSastEnabledExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+class NewProjectSastEnabledExperiment < ApplicationExperiment
def publish(_result = nil)
super
diff --git a/app/experiments/require_verification_for_namespace_creation_experiment.rb b/app/experiments/require_verification_for_namespace_creation_experiment.rb
index 1cadac7e7d4..0c47f5d183c 100644
--- a/app/experiments/require_verification_for_namespace_creation_experiment.rb
+++ b/app/experiments/require_verification_for_namespace_creation_experiment.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
-class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment
+ exclude :existing_user
+
+ EXPERIMENT_START_DATE = Date.new(2022, 1, 31)
+
def control_behavior
false
end
@@ -24,4 +28,10 @@ class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment
def subject
context.value[:user]
end
+
+ def existing_user
+ return false unless user_or_actor
+
+ user_or_actor.created_at < EXPERIMENT_START_DATE
+ end
end
diff --git a/app/experiments/security_reports_mr_widget_prompt_experiment.rb b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
index fa0ba8e24d4..bcb9d64fcb7 100644
--- a/app/experiments/security_reports_mr_widget_prompt_experiment.rb
+++ b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment
def publish(_result = nil)
super
diff --git a/app/experiments/templates/new_project_readme_content/readme_basic.md.tt b/app/experiments/templates/new_project_readme_content/readme_basic.md.tt
deleted file mode 100644
index 1e68eaf2f05..00000000000
--- a/app/experiments/templates/new_project_readme_content/readme_basic.md.tt
+++ /dev/null
@@ -1,3 +0,0 @@
-# <%= @project.name %>
-
-<%= @project.description %>
diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb
index a9fffd3f411..33d9a8a3dbc 100644
--- a/app/finders/autocomplete/users_finder.rb
+++ b/app/finders/autocomplete/users_finder.rb
@@ -62,7 +62,7 @@ module Autocomplete
find_users
.active
.reorder_by_name
- .optionally_search(search)
+ .optionally_search(search, use_minimum_char_limit: use_minimum_char_limit)
.where_not_in(skip_users)
.limit_to_todo_authors(
user: current_user,
@@ -99,6 +99,12 @@ module Autocomplete
ActiveRecord::Associations::Preloader.new.preload(items, :status)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def use_minimum_char_limit
+ return if project.blank? && group.blank? # We return nil so that we use the default defined in the User model
+
+ false
+ end
end
end
diff --git a/app/finders/ci/jobs_finder.rb b/app/finders/ci/jobs_finder.rb
index 4408c9cdb6d..5fc9c0e1778 100644
--- a/app/finders/ci/jobs_finder.rb
+++ b/app/finders/ci/jobs_finder.rb
@@ -4,10 +4,11 @@ module Ci
class JobsFinder
include Gitlab::Allowable
- def initialize(current_user:, pipeline: nil, project: nil, params: {}, type: ::Ci::Build)
+ def initialize(current_user:, pipeline: nil, project: nil, runner: nil, params: {}, type: ::Ci::Build)
@pipeline = pipeline
@current_user = current_user
@project = project
+ @runner = runner
@params = params
@type = type
raise ArgumentError 'type must be a subclass of Ci::Processable' unless type < ::Ci::Processable
@@ -22,10 +23,10 @@ module Ci
private
- attr_reader :current_user, :pipeline, :project, :params, :type
+ attr_reader :current_user, :pipeline, :project, :runner, :params, :type
def init_collection
- pipeline_jobs || project_jobs || all_jobs
+ pipeline_jobs || project_jobs || runner_jobs || all_jobs
end
def all_jobs
@@ -34,6 +35,13 @@ module Ci
type.all
end
+ def runner_jobs
+ return unless runner
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :read_builds, runner)
+
+ jobs_by_type(runner, type).relevant
+ end
+
def project_jobs
return unless project
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :read_build, project)
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
index 3ebf6bd1562..356915722fe 100644
--- a/app/finders/ci/runners_finder.rb
+++ b/app/finders/ci/runners_finder.rb
@@ -4,7 +4,7 @@ module Ci
class RunnersFinder < UnionFinder
include Gitlab::Allowable
- ALLOWED_SORTS = %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date].freeze
+ ALLOWED_SORTS = %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date token_expires_at_asc token_expires_at_desc].freeze
DEFAULT_SORT = 'created_at_desc'
def initialize(current_user:, params:)
@@ -53,13 +53,7 @@ module Ci
when :direct
Ci::Runner.belonging_to_group(@group.id)
when :descendants, nil
- if ::Feature.enabled?(:ci_find_runners_by_ci_mirrors, @group, default_enabled: :yaml)
- Ci::Runner.belonging_to_group_or_project_descendants(@group.id)
- else
- # Getting all runners from the group itself and all its descendant groups/projects
- descendant_projects = Project.for_group_and_its_subgroups(@group)
- Ci::Runner.legacy_belonging_to_group_or_project(@group.self_and_descendants, descendant_projects)
- end
+ Ci::Runner.belonging_to_group_or_project_descendants(@group.id)
else
raise ArgumentError, 'Invalid membership filter'
end
diff --git a/app/finders/crm/contacts_finder.rb b/app/finders/crm/contacts_finder.rb
new file mode 100644
index 00000000000..c2d44bec27b
--- /dev/null
+++ b/app/finders/crm/contacts_finder.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# Finder for retrieving contacts scoped to a group
+#
+# Arguments:
+# current_user - user performing the action. Must have the correct permission level for the group.
+# params:
+# group: Group, required
+module Crm
+ class ContactsFinder
+ include Gitlab::Allowable
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :params, :current_user
+
+ def initialize(current_user, params = {})
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ return CustomerRelations::Contact.none unless root_group
+
+ root_group.contacts
+ end
+
+ private
+
+ def root_group
+ strong_memoize(:root_group) do
+ group = params[:group]&.root_ancestor
+
+ next unless can?(@current_user, :read_crm_contact, group)
+
+ group
+ end
+ end
+ end
+end
diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb
index bb9d204ab73..04b82ee04ec 100644
--- a/app/finders/deployments_finder.rb
+++ b/app/finders/deployments_finder.rb
@@ -49,6 +49,10 @@ class DeploymentsFinder
private
+ def raise_for_inefficient_updated_at_query?
+ params.fetch(:raise_for_inefficient_updated_at_query, Rails.env.development? || Rails.env.test?)
+ end
+
def validate!
if filter_by_updated_at? && filter_by_finished_at?
raise InefficientQueryError, 'Both `updated_at` filter and `finished_at` filter can not be specified'
@@ -57,9 +61,11 @@ class DeploymentsFinder
# Currently, the inefficient parameters are allowed in order to avoid breaking changes in Deployment API.
# We'll switch to a hard error in https://gitlab.com/gitlab-org/gitlab/-/issues/328500.
if (filter_by_updated_at? && !order_by_updated_at?) || (!filter_by_updated_at? && order_by_updated_at?)
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
- InefficientQueryError.new('`updated_at` filter and `updated_at` sorting must be paired')
- )
+ error = InefficientQueryError.new('`updated_at` filter and `updated_at` sorting must be paired')
+
+ Gitlab::ErrorTracking.log_exception(error)
+
+ raise error if raise_for_inefficient_updated_at_query?
end
if (filter_by_finished_at? && !order_by_finished_at?) || (!filter_by_finished_at? && order_by_finished_at?)
diff --git a/app/finders/environments/environments_by_deployments_finder.rb b/app/finders/environments/environments_by_deployments_finder.rb
index 1a0d5ff0d5e..bffd0c9a319 100644
--- a/app/finders/environments/environments_by_deployments_finder.rb
+++ b/app/finders/environments/environments_by_deployments_finder.rb
@@ -15,8 +15,8 @@ module Environments
deployments =
if ref
Deployment.where(ref: ref.to_s)
- elsif commit
- Deployment.where(sha: commit.sha)
+ elsif sha
+ Deployment.where(sha: sha)
else
Deployment.none
end
@@ -47,7 +47,7 @@ module Environments
return false unless Ability.allowed?(current_user, :read_environment, environment)
return false if ref && params[:recently_updated] && !environment.recently_updated_on_branch?(ref)
- return false if ref && commit && !environment.includes_commit?(commit)
+ return false if ref && sha && !environment.includes_commit?(sha)
true
end
@@ -56,8 +56,8 @@ module Environments
params[:ref].try(:to_s)
end
- def commit
- params[:commit]
+ def sha
+ params[:sha] || params[:commit]&.id
end
end
end
diff --git a/app/finders/git_refs_finder.rb b/app/finders/git_refs_finder.rb
index 11af659d37c..dbe0060d8ae 100644
--- a/app/finders/git_refs_finder.rb
+++ b/app/finders/git_refs_finder.rb
@@ -11,11 +11,11 @@ class GitRefsFinder
attr_reader :repository, :params
def search
- @params[:search].presence
+ @params[:search].to_s.presence
end
def sort
- @params[:sort].presence || 'name'
+ @params[:sort].to_s.presence || 'name'
end
def by_search(refs)
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
index 3d9b6e94cc6..40d6be03a17 100644
--- a/app/finders/group_descendants_finder.rb
+++ b/app/finders/group_descendants_finder.rb
@@ -25,7 +25,7 @@ class GroupDescendantsFinder
def initialize(current_user: nil, parent_group:, params: {})
@current_user = current_user
@parent_group = parent_group
- @params = params.reverse_merge(non_archived: params[:archived].blank?)
+ @params = params.reverse_merge(non_archived: params[:archived].blank?, not_aimed_for_deletion: true)
end
def execute
@@ -110,8 +110,13 @@ class GroupDescendantsFinder
# rubocop: disable CodeReuse/ActiveRecord
def ancestors_of_groups(base_for_ancestors)
group_ids = base_for_ancestors.except(:select, :sort).select(:id)
- Gitlab::ObjectHierarchy.new(Group.where(id: group_ids))
- .base_and_ancestors(upto: parent_group.id)
+ groups = Group.where(id: group_ids)
+
+ if Feature.enabled?(:linear_group_descendants_finder_upto, current_user, default_enabled: :yaml)
+ groups.self_and_ancestors(upto: parent_group.id)
+ else
+ Gitlab::ObjectHierarchy.new(groups).base_and_ancestors(upto: parent_group.id)
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index d3c26fd845c..00b700a101e 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -13,6 +13,7 @@
# only_shared: boolean
# limit: integer
# include_subgroups: boolean
+# include_ancestor_groups: boolean
# params:
# sort: string
# visibility_level: int
@@ -113,12 +114,19 @@ class GroupProjectsFinder < ProjectsFinder
options.fetch(:include_subgroups, false)
end
+ # ancestor groups are supported only for owned projects not for shared
+ def include_ancestor_groups?
+ options.fetch(:include_ancestor_groups, false)
+ end
+
def owned_projects
- if include_subgroups?
- Project.for_group_and_its_subgroups(group)
- else
- group.projects
- end
+ return group.projects unless include_subgroups? || include_ancestor_groups?
+
+ union_relations = []
+ union_relations << Project.for_group_and_its_subgroups(group) if include_subgroups?
+ union_relations << Project.for_group_and_its_ancestor_groups(group) if include_ancestor_groups?
+
+ Project.from_union(union_relations)
end
def shared_projects
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index de750b49c6a..a7eaaddd187 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -108,8 +108,14 @@ class IssuesFinder < IssuableFinder
if params.filter_by_no_due_date?
items.without_due_date
+ elsif params.filter_by_any_due_date?
+ items.with_due_date
elsif params.filter_by_overdue?
items.due_before(Date.today)
+ elsif params.filter_by_due_today?
+ items.due_today
+ elsif params.filter_by_due_tomorrow?
+ items.due_tomorrow
elsif params.filter_by_due_this_week?
items.due_between(Date.today.beginning_of_week, Date.today.end_of_week)
elsif params.filter_by_due_this_month?
diff --git a/app/finders/issues_finder/params.rb b/app/finders/issues_finder/params.rb
index 02b89f08f9e..57bfb35f1b8 100644
--- a/app/finders/issues_finder/params.rb
+++ b/app/finders/issues_finder/params.rb
@@ -10,6 +10,10 @@ class IssuesFinder
user_can_see_all_issues?
end
+ def filter_by_any_due_date?
+ due_date? && params[:due_date] == Issue::AnyDueDate.name
+ end
+
def filter_by_no_due_date?
due_date? && params[:due_date] == Issue::NoDueDate.name
end
@@ -18,6 +22,14 @@ class IssuesFinder
due_date? && params[:due_date] == Issue::Overdue.name
end
+ def filter_by_due_today?
+ due_date? && params[:due_date] == Issue::DueToday.name
+ end
+
+ def filter_by_due_tomorrow?
+ due_date? && params[:due_date] == Issue::DueTomorrow.name
+ end
+
def filter_by_due_this_week?
due_date? && params[:due_date] == Issue::DueThisWeek.name
end
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 81e4ab7014d..06feefb9059 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -44,7 +44,8 @@ class MergeRequestsFinder < IssuableFinder
:reviewer_id,
:reviewer_username,
:target_branch,
- :wip
+ :wip,
+ :attention
]
end
@@ -69,6 +70,7 @@ class MergeRequestsFinder < IssuableFinder
items = by_approvals(items)
items = by_deployments(items)
items = by_reviewer(items)
+ items = by_attention(items)
by_source_project_id(items)
end
@@ -218,6 +220,12 @@ class MergeRequestsFinder < IssuableFinder
end
end
+ def by_attention(items)
+ return items unless params.attention?
+
+ items.attention(params.attention)
+ end
+
def parse_datetime(input)
# To work around http://www.ruby-lang.org/en/news/2021/11/15/date-parsing-method-regexp-dos-cve-2021-41817/
DateTime.parse(input.byteslice(0, 128)) if input
diff --git a/app/finders/merge_requests_finder/params.rb b/app/finders/merge_requests_finder/params.rb
index e44e96054d3..1c6a425c8af 100644
--- a/app/finders/merge_requests_finder/params.rb
+++ b/app/finders/merge_requests_finder/params.rb
@@ -21,5 +21,11 @@ class MergeRequestsFinder
end
end
end
+
+ def attention
+ strong_memoize(:attention) do
+ User.find_by_username(params[:attention])
+ end
+ end
end
end
diff --git a/app/finders/packages/package_file_finder.rb b/app/finders/packages/package_file_finder.rb
index 55dc4be2001..786302d65b1 100644
--- a/app/finders/packages/package_file_finder.rb
+++ b/app/finders/packages/package_file_finder.rb
@@ -19,13 +19,7 @@ class Packages::PackageFileFinder
private
def package_files
- files = if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- package.installable_package_files
- else
- package.package_files
- end
-
- by_file_name(files)
+ by_file_name(package.installable_package_files)
end
def by_file_name(files)
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 7245bb36ac9..f6db150c5d8 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -27,6 +27,7 @@
# last_activity_before: datetime
# repository_storage: string
# without_deleted: boolean
+# not_aimed_for_deletion: boolean
#
class ProjectsFinder < UnionFinder
include CustomAttributesFilter
@@ -84,6 +85,7 @@ class ProjectsFinder < UnionFinder
collection = by_archived(collection)
collection = by_custom_attributes(collection)
collection = by_deleted_status(collection)
+ collection = by_not_aimed_for_deletion(collection)
collection = by_last_activity_after(collection)
collection = by_last_activity_before(collection)
by_repository_storage(collection)
@@ -203,6 +205,10 @@ class ProjectsFinder < UnionFinder
params[:without_deleted].present? ? items.without_deleted : items
end
+ def by_not_aimed_for_deletion(items)
+ params[:not_aimed_for_deletion].present? ? items.not_aimed_for_deletion : items
+ end
+
def by_last_activity_after(items)
if params[:last_activity_after].present?
items.where("last_activity_at > ?", params[:last_activity_after]) # rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb
index 0cfa4310ab7..0d72d6ffc6b 100644
--- a/app/finders/releases_finder.rb
+++ b/app/finders/releases_finder.rb
@@ -38,19 +38,17 @@ class ReleasesFinder
if parent.is_a?(Project)
Ability.allowed?(current_user, :read_release, parent) ? [parent] : []
elsif parent.is_a?(Group)
- accessible_projects
+ Ability.allowed?(current_user, :read_release, parent) ? accessible_projects : []
end
end
end
def accessible_projects
- projects = if include_subgroups?
- Project.for_group_and_its_subgroups(parent)
- else
- parent.projects
- end
-
- projects.select { |project| Ability.allowed?(current_user, :read_release, project) }
+ if include_subgroups?
+ Project.for_group_and_its_subgroups(parent)
+ else
+ parent.projects
+ end
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
index 2b4ce615090..b983882b272 100644
--- a/app/finders/users_finder.rb
+++ b/app/finders/users_finder.rb
@@ -32,7 +32,7 @@ class UsersFinder
end
def execute
- users = User.all.order_id_desc
+ users = base_scope
users = by_username(users)
users = by_id(users)
users = by_admins(users)
@@ -53,6 +53,10 @@ class UsersFinder
private
+ def base_scope
+ User.all.order_id_desc
+ end
+
def by_username(users)
return users unless params[:username]
diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb
index 290cd4d7146..ac1a4a6b9ef 100644
--- a/app/graphql/graphql_triggers.rb
+++ b/app/graphql/graphql_triggers.rb
@@ -8,4 +8,8 @@ module GraphqlTriggers
def self.issue_crm_contacts_updated(issue)
GitlabSchema.subscriptions.trigger('issueCrmContactsUpdated', { issuable_id: issue.to_gid }, issue)
end
+
+ def self.issuable_title_updated(issuable)
+ GitlabSchema.subscriptions.trigger('issuableTitleUpdated', { issuable_id: issuable.to_gid }, issuable)
+ end
end
diff --git a/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
index c4f91d0c15c..b1db355aa40 100644
--- a/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
+++ b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
@@ -8,7 +8,7 @@ module Mutations
ADMIN_MESSAGE = 'You must be an admin to use this mutation'
- ::Gitlab::ApplicationContext::KNOWN_KEYS.each do |key|
+ ::Gitlab::ApplicationContext.known_keys.each do |key|
argument key,
GraphQL::Types::String,
required: false,
diff --git a/app/graphql/mutations/alert_management/http_integration/create.rb b/app/graphql/mutations/alert_management/http_integration/create.rb
index 04840ac43bd..f8d1a383706 100644
--- a/app/graphql/mutations/alert_management/http_integration/create.rb
+++ b/app/graphql/mutations/alert_management/http_integration/create.rb
@@ -4,10 +4,10 @@ module Mutations
module AlertManagement
module HttpIntegration
class Create < HttpIntegrationBase
- include FindsProject
-
graphql_name 'HttpIntegrationCreate'
+ include FindsProject
+
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Project to create the integration in.'
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/create.rb b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
index 0153bd0e42a..9c3aefce033 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/create.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
@@ -4,10 +4,10 @@ module Mutations
module AlertManagement
module PrometheusIntegration
class Create < PrometheusIntegrationBase
- include FindsProject
-
graphql_name 'PrometheusIntegrationCreate'
+ include FindsProject
+
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Project to create the integration in.'
diff --git a/app/graphql/mutations/boards/create.rb b/app/graphql/mutations/boards/create.rb
index 080bf7c6e79..773ba08a291 100644
--- a/app/graphql/mutations/boards/create.rb
+++ b/app/graphql/mutations/boards/create.rb
@@ -3,10 +3,9 @@
module Mutations
module Boards
class Create < ::Mutations::BaseMutation
- include Mutations::ResolvesResourceParent
-
graphql_name 'CreateBoard'
+ include Mutations::ResolvesResourceParent
include Mutations::Boards::CommonMutationArguments
field :board,
diff --git a/app/graphql/mutations/branches/create.rb b/app/graphql/mutations/branches/create.rb
index 078c84bcdc0..b851622bfde 100644
--- a/app/graphql/mutations/branches/create.rb
+++ b/app/graphql/mutations/branches/create.rb
@@ -3,10 +3,10 @@
module Mutations
module Branches
class Create < BaseMutation
- include FindsProject
-
graphql_name 'CreateBranch'
+ include FindsProject
+
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Project full path the branch is associated with.'
diff --git a/app/graphql/mutations/ci/ci_cd_settings_update.rb b/app/graphql/mutations/ci/ci_cd_settings_update.rb
index 7bd38bc2998..dec90ced962 100644
--- a/app/graphql/mutations/ci/ci_cd_settings_update.rb
+++ b/app/graphql/mutations/ci/ci_cd_settings_update.rb
@@ -3,10 +3,10 @@
module Mutations
module Ci
class CiCdSettingsUpdate < BaseMutation
- include FindsProject
-
graphql_name 'CiCdSettingsUpdate'
+ include FindsProject
+
authorize :admin_project
argument :full_path, GraphQL::Types::ID,
diff --git a/app/graphql/mutations/ci/job_token_scope/add_project.rb b/app/graphql/mutations/ci/job_token_scope/add_project.rb
index 41adcae2c82..e16c08cb116 100644
--- a/app/graphql/mutations/ci/job_token_scope/add_project.rb
+++ b/app/graphql/mutations/ci/job_token_scope/add_project.rb
@@ -4,10 +4,10 @@ module Mutations
module Ci
module JobTokenScope
class AddProject < BaseMutation
- include FindsProject
-
graphql_name 'CiJobTokenScopeAddProject'
+ include FindsProject
+
authorize :admin_project
argument :project_path, GraphQL::Types::ID,
diff --git a/app/graphql/mutations/ci/job_token_scope/remove_project.rb b/app/graphql/mutations/ci/job_token_scope/remove_project.rb
index dd6b2358dd5..f503b4f2f7a 100644
--- a/app/graphql/mutations/ci/job_token_scope/remove_project.rb
+++ b/app/graphql/mutations/ci/job_token_scope/remove_project.rb
@@ -4,10 +4,10 @@ module Mutations
module Ci
module JobTokenScope
class RemoveProject < BaseMutation
- include FindsProject
-
graphql_name 'CiJobTokenScopeRemoveProject'
+ include FindsProject
+
authorize :admin_project
argument :project_path, GraphQL::Types::ID,
diff --git a/app/graphql/mutations/ci/runner/delete.rb b/app/graphql/mutations/ci/runner/delete.rb
index 88dc426398b..21c3d55881c 100644
--- a/app/graphql/mutations/ci/runner/delete.rb
+++ b/app/graphql/mutations/ci/runner/delete.rb
@@ -20,7 +20,7 @@ module Mutations
error = authenticate_delete_runner!(runner)
return { errors: [error] } if error
- runner.destroy!
+ ::Ci::UnregisterRunnerService.new(runner).execute
{ errors: runner.errors.full_messages }
end
diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb
index e37ab1081f9..e6123b4283a 100644
--- a/app/graphql/mutations/ci/runner/update.rb
+++ b/app/graphql/mutations/ci/runner/update.rb
@@ -28,7 +28,12 @@ module Mutations
argument :active, GraphQL::Types::Boolean,
required: false,
- description: 'Indicates the runner is allowed to receive jobs.'
+ description: 'Indicates the runner is allowed to receive jobs.',
+ deprecated: { reason: :renamed, replacement: 'paused', milestone: '14.8' }
+
+ argument :paused, GraphQL::Types::Boolean,
+ required: false,
+ description: 'Indicates the runner is not allowed to receive jobs.'
argument :locked, GraphQL::Types::Boolean, required: false,
description: 'Indicates the runner is locked.'
diff --git a/app/graphql/mutations/clusters/agents/create.rb b/app/graphql/mutations/clusters/agents/create.rb
index 0896cc7b203..deaa9c2d656 100644
--- a/app/graphql/mutations/clusters/agents/create.rb
+++ b/app/graphql/mutations/clusters/agents/create.rb
@@ -4,12 +4,12 @@ module Mutations
module Clusters
module Agents
class Create < BaseMutation
+ graphql_name 'CreateClusterAgent'
+
include FindsProject
authorize :create_cluster
- graphql_name 'CreateClusterAgent'
-
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Full path of the associated project for this cluster agent.'
diff --git a/app/graphql/mutations/commits/create.rb b/app/graphql/mutations/commits/create.rb
index 3eb1912dbc4..00ec64becc8 100644
--- a/app/graphql/mutations/commits/create.rb
+++ b/app/graphql/mutations/commits/create.rb
@@ -3,6 +3,8 @@
module Mutations
module Commits
class Create < BaseMutation
+ graphql_name 'CommitCreate'
+
include FindsProject
class UrlHelpers
@@ -10,8 +12,6 @@ module Mutations
include Gitlab::Routing
end
- graphql_name 'CommitCreate'
-
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Project full path the branch is associated with.'
diff --git a/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb b/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb
deleted file mode 100644
index f1ae54aa014..00000000000
--- a/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-# frozen_string_literal: true
-
-module Mutations
- # This concern is deprecated and will be deleted in 14.6
- #
- # Use the SpamProtection concern instead.
- module CanMutateSpammable
- extend ActiveSupport::Concern
-
- DEPRECATION_NOTICE = {
- reason: 'Use spam protection with HTTP headers instead',
- milestone: '13.11'
- }.freeze
-
- included do
- argument :captcha_response, GraphQL::Types::String,
- required: false,
- deprecated: DEPRECATION_NOTICE,
- description: 'Valid CAPTCHA response value obtained by using the provided captchaSiteKey with a CAPTCHA API to present a challenge to be solved on the client. Required to resubmit if the previous operation returned "NeedsCaptchaResponse: true".'
-
- argument :spam_log_id, GraphQL::Types::Int,
- required: false,
- deprecated: DEPRECATION_NOTICE,
- description: 'Spam log ID which must be passed along with a valid CAPTCHA response for the operation to be completed. Required to resubmit if the previous operation returned "NeedsCaptchaResponse: true".'
-
- field :spam,
- GraphQL::Types::Boolean,
- null: true,
- deprecated: DEPRECATION_NOTICE,
- description: 'Indicates whether the operation was detected as definite spam. There is no option to resubmit the request with a CAPTCHA response.'
-
- field :needs_captcha_response,
- GraphQL::Types::Boolean,
- null: true,
- deprecated: DEPRECATION_NOTICE,
- description: 'Indicates whether the operation was detected as possible spam and not completed. If CAPTCHA is enabled, the request must be resubmitted with a valid CAPTCHA response and spam_log_id included for the operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
-
- field :spam_log_id,
- GraphQL::Types::Int,
- null: true,
- deprecated: DEPRECATION_NOTICE,
- description: 'Spam log ID which must be passed along with a valid CAPTCHA response for an operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
-
- field :captcha_site_key,
- GraphQL::Types::String,
- null: true,
- deprecated: DEPRECATION_NOTICE,
- description: 'CAPTCHA site key which must be used to render a challenge for the user to solve to obtain a valid captchaResponse value. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
- end
- end
-end
diff --git a/app/graphql/mutations/container_expiration_policies/update.rb b/app/graphql/mutations/container_expiration_policies/update.rb
index db4acadfc38..762058acf3d 100644
--- a/app/graphql/mutations/container_expiration_policies/update.rb
+++ b/app/graphql/mutations/container_expiration_policies/update.rb
@@ -3,10 +3,10 @@
module Mutations
module ContainerExpirationPolicies
class Update < Mutations::BaseMutation
- include FindsProject
-
graphql_name 'UpdateContainerExpirationPolicy'
+ include FindsProject
+
authorize :destroy_container_image
argument :project_path,
diff --git a/app/graphql/mutations/container_repositories/destroy_tags.rb b/app/graphql/mutations/container_repositories/destroy_tags.rb
index c2737820d22..7777f903516 100644
--- a/app/graphql/mutations/container_repositories/destroy_tags.rb
+++ b/app/graphql/mutations/container_repositories/destroy_tags.rb
@@ -3,12 +3,11 @@
module Mutations
module ContainerRepositories
class DestroyTags < ::Mutations::ContainerRepositories::DestroyBase
- LIMIT = 20
+ graphql_name 'DestroyContainerRepositoryTags'
+ LIMIT = 20
TOO_MANY_TAGS_ERROR_MESSAGE = "Number of tags is greater than #{LIMIT}"
- graphql_name 'DestroyContainerRepositoryTags'
-
authorize :destroy_container_image
argument :id,
diff --git a/app/graphql/mutations/custom_emoji/create.rb b/app/graphql/mutations/custom_emoji/create.rb
index ad392d6c814..269ea6c9999 100644
--- a/app/graphql/mutations/custom_emoji/create.rb
+++ b/app/graphql/mutations/custom_emoji/create.rb
@@ -3,10 +3,10 @@
module Mutations
module CustomEmoji
class Create < BaseMutation
- include Mutations::ResolvesGroup
-
graphql_name 'CreateCustomEmoji'
+ include Mutations::ResolvesGroup
+
authorize :create_custom_emoji
field :custom_emoji,
diff --git a/app/graphql/mutations/customer_relations/contacts/create.rb b/app/graphql/mutations/customer_relations/contacts/create.rb
index 3495f30f227..96dc047c3db 100644
--- a/app/graphql/mutations/customer_relations/contacts/create.rb
+++ b/app/graphql/mutations/customer_relations/contacts/create.rb
@@ -4,11 +4,11 @@ module Mutations
module CustomerRelations
module Contacts
class Create < BaseMutation
+ graphql_name 'CustomerRelationsContactCreate'
+
include ResolvesIds
include Gitlab::Graphql::Authorize::AuthorizeResource
- graphql_name 'CustomerRelationsContactCreate'
-
field :contact,
Types::CustomerRelations::ContactType,
null: true,
diff --git a/app/graphql/mutations/customer_relations/contacts/update.rb b/app/graphql/mutations/customer_relations/contacts/update.rb
index e2f671058f0..a3abf37f21f 100644
--- a/app/graphql/mutations/customer_relations/contacts/update.rb
+++ b/app/graphql/mutations/customer_relations/contacts/update.rb
@@ -4,10 +4,10 @@ module Mutations
module CustomerRelations
module Contacts
class Update < Mutations::BaseMutation
- include ResolvesIds
-
graphql_name 'CustomerRelationsContactUpdate'
+ include ResolvesIds
+
authorize :admin_crm_contact
field :contact,
diff --git a/app/graphql/mutations/customer_relations/organizations/create.rb b/app/graphql/mutations/customer_relations/organizations/create.rb
index 17e0e9ad459..43c50a9fb30 100644
--- a/app/graphql/mutations/customer_relations/organizations/create.rb
+++ b/app/graphql/mutations/customer_relations/organizations/create.rb
@@ -4,11 +4,11 @@ module Mutations
module CustomerRelations
module Organizations
class Create < BaseMutation
+ graphql_name 'CustomerRelationsOrganizationCreate'
+
include ResolvesIds
include Gitlab::Graphql::Authorize::AuthorizeResource
- graphql_name 'CustomerRelationsOrganizationCreate'
-
field :organization,
Types::CustomerRelations::OrganizationType,
null: true,
diff --git a/app/graphql/mutations/customer_relations/organizations/update.rb b/app/graphql/mutations/customer_relations/organizations/update.rb
index 21fcf565239..0c05541dbd7 100644
--- a/app/graphql/mutations/customer_relations/organizations/update.rb
+++ b/app/graphql/mutations/customer_relations/organizations/update.rb
@@ -4,10 +4,10 @@ module Mutations
module CustomerRelations
module Organizations
class Update < Mutations::BaseMutation
- include ResolvesIds
-
graphql_name 'CustomerRelationsOrganizationUpdate'
+ include ResolvesIds
+
authorize :admin_crm_organization
field :organization,
diff --git a/app/graphql/mutations/dependency_proxy/group_settings/update.rb b/app/graphql/mutations/dependency_proxy/group_settings/update.rb
index d10e43cde29..65c919db3c3 100644
--- a/app/graphql/mutations/dependency_proxy/group_settings/update.rb
+++ b/app/graphql/mutations/dependency_proxy/group_settings/update.rb
@@ -4,10 +4,10 @@ module Mutations
module DependencyProxy
module GroupSettings
class Update < Mutations::BaseMutation
- include Mutations::ResolvesGroup
-
graphql_name 'UpdateDependencyProxySettings'
+ include Mutations::ResolvesGroup
+
authorize :admin_dependency_proxy
argument :group_path,
diff --git a/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb b/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb
index a5eb114b2da..79d7a93c4e2 100644
--- a/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb
+++ b/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb
@@ -4,10 +4,10 @@ module Mutations
module DependencyProxy
module ImageTtlGroupPolicy
class Update < Mutations::BaseMutation
- include Mutations::ResolvesGroup
-
graphql_name 'UpdateDependencyProxyImageTtlGroupPolicy'
+ include Mutations::ResolvesGroup
+
authorize :admin_dependency_proxy
argument :group_path,
diff --git a/app/graphql/mutations/design_management/delete.rb b/app/graphql/mutations/design_management/delete.rb
index 4e9f0aad934..9e643110628 100644
--- a/app/graphql/mutations/design_management/delete.rb
+++ b/app/graphql/mutations/design_management/delete.rb
@@ -3,10 +3,10 @@
module Mutations
module DesignManagement
class Delete < Base
- Errors = ::Gitlab::Graphql::Errors
-
graphql_name "DesignManagementDelete"
+ Errors = ::Gitlab::Graphql::Errors
+
argument :filenames, [GraphQL::Types::String],
required: true,
description: "Filenames of the designs to delete.",
diff --git a/app/graphql/mutations/groups/update.rb b/app/graphql/mutations/groups/update.rb
index 9c5628a57cd..be7a14d0b43 100644
--- a/app/graphql/mutations/groups/update.rb
+++ b/app/graphql/mutations/groups/update.rb
@@ -3,10 +3,10 @@
module Mutations
module Groups
class Update < Mutations::BaseMutation
- include Mutations::ResolvesGroup
-
graphql_name 'GroupUpdate'
+ include Mutations::ResolvesGroup
+
authorize :admin_group
field :group, Types::GroupType,
diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb
index 72b03cc27c2..6bf8caf82d7 100644
--- a/app/graphql/mutations/issues/create.rb
+++ b/app/graphql/mutations/issues/create.rb
@@ -3,12 +3,12 @@
module Mutations
module Issues
class Create < BaseMutation
+ graphql_name 'CreateIssue'
+
include Mutations::SpamProtection
include FindsProject
include CommonMutationArguments
- graphql_name 'CreateIssue'
-
authorize :create_issue
argument :project_path, GraphQL::Types::ID,
@@ -51,6 +51,14 @@ module Mutations
required: false,
description: 'Array of user IDs to assign to the issue.'
+ argument :move_before_id, ::Types::GlobalIDType[::Issue],
+ required: false,
+ description: 'Global ID of issue that should be placed before the current issue.'
+
+ argument :move_after_id, ::Types::GlobalIDType[::Issue],
+ required: false,
+ description: 'Global ID of issue that should be placed after the current issue.'
+
field :issue,
Types::IssueType,
null: true,
@@ -93,6 +101,13 @@ module Mutations
params[:assignee_ids] &&= params[:assignee_ids].map { |assignee_id| assignee_id&.model_id }
params[:label_ids] &&= params[:label_ids].map { |label_id| label_id&.model_id }
+ if params[:move_before_id].present? || params[:move_after_id].present?
+ params[:move_between_ids] = [
+ params.delete(:move_before_id)&.model_id,
+ params.delete(:move_after_id)&.model_id
+ ]
+ end
+
params
end
diff --git a/app/graphql/mutations/issues/set_confidential.rb b/app/graphql/mutations/issues/set_confidential.rb
index 35e629ddc90..abfd6fec0bd 100644
--- a/app/graphql/mutations/issues/set_confidential.rb
+++ b/app/graphql/mutations/issues/set_confidential.rb
@@ -3,10 +3,10 @@
module Mutations
module Issues
class SetConfidential < Base
- include Mutations::SpamProtection
-
graphql_name 'IssueSetConfidential'
+ include Mutations::SpamProtection
+
argument :confidential,
GraphQL::Types::Boolean,
required: true,
diff --git a/app/graphql/mutations/issues/set_escalation_status.rb b/app/graphql/mutations/issues/set_escalation_status.rb
index 6073b73277b..4f3fcb4886d 100644
--- a/app/graphql/mutations/issues/set_escalation_status.rb
+++ b/app/graphql/mutations/issues/set_escalation_status.rb
@@ -14,7 +14,7 @@ module Mutations
project = issue.project
authorize_escalation_status!(project)
- check_feature_availability!(project, issue)
+ check_feature_availability!(issue)
::Issues::UpdateService.new(
project: project,
@@ -36,8 +36,8 @@ module Mutations
raise_resource_not_available_error!
end
- def check_feature_availability!(project, issue)
- return if Feature.enabled?(:incident_escalations, project) && issue.supports_escalation?
+ def check_feature_availability!(issue)
+ return if issue.supports_escalation?
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature unavailable for provided issue'
end
diff --git a/app/graphql/mutations/jira_import/import_users.rb b/app/graphql/mutations/jira_import/import_users.rb
index 8d82a058dd0..b3874caee61 100644
--- a/app/graphql/mutations/jira_import/import_users.rb
+++ b/app/graphql/mutations/jira_import/import_users.rb
@@ -3,10 +3,10 @@
module Mutations
module JiraImport
class ImportUsers < BaseMutation
- include FindsProject
-
graphql_name 'JiraImportUsers'
+ include FindsProject
+
authorize :admin_project
field :jira_users,
diff --git a/app/graphql/mutations/jira_import/start.rb b/app/graphql/mutations/jira_import/start.rb
index 4929d6f394a..ea071c45bcf 100644
--- a/app/graphql/mutations/jira_import/start.rb
+++ b/app/graphql/mutations/jira_import/start.rb
@@ -3,10 +3,10 @@
module Mutations
module JiraImport
class Start < BaseMutation
- include FindsProject
-
graphql_name 'JiraImportStart'
+ include FindsProject
+
authorize :admin_project
field :jira_import,
diff --git a/app/graphql/mutations/labels/create.rb b/app/graphql/mutations/labels/create.rb
index cb3ba7939ae..3cd41dc01de 100644
--- a/app/graphql/mutations/labels/create.rb
+++ b/app/graphql/mutations/labels/create.rb
@@ -3,10 +3,10 @@
module Mutations
module Labels
class Create < BaseMutation
- include Mutations::ResolvesResourceParent
-
graphql_name 'LabelCreate'
+ include Mutations::ResolvesResourceParent
+
field :label,
Types::LabelType,
null: true,
diff --git a/app/graphql/mutations/merge_requests/accept.rb b/app/graphql/mutations/merge_requests/accept.rb
index 7ce850901af..ebd9e2b8fdd 100644
--- a/app/graphql/mutations/merge_requests/accept.rb
+++ b/app/graphql/mutations/merge_requests/accept.rb
@@ -3,12 +3,6 @@
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
@@ -17,6 +11,12 @@ module Mutations
immediately if possible, or using one of the automatic merge strategies.
DESC
+ 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'
+
argument :strategy,
::Types::MergeStrategyEnum,
required: false,
diff --git a/app/graphql/mutations/merge_requests/create.rb b/app/graphql/mutations/merge_requests/create.rb
index dc1d5a22bc9..2883c02a671 100644
--- a/app/graphql/mutations/merge_requests/create.rb
+++ b/app/graphql/mutations/merge_requests/create.rb
@@ -3,10 +3,10 @@
module Mutations
module MergeRequests
class Create < BaseMutation
- include FindsProject
-
graphql_name 'MergeRequestCreate'
+ include FindsProject
+
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Project full path the merge request is associated with.'
diff --git a/app/graphql/mutations/namespace/package_settings/update.rb b/app/graphql/mutations/namespace/package_settings/update.rb
index 400169d6b64..934b75193d7 100644
--- a/app/graphql/mutations/namespace/package_settings/update.rb
+++ b/app/graphql/mutations/namespace/package_settings/update.rb
@@ -4,10 +4,10 @@ module Mutations
module Namespace
module PackageSettings
class Update < Mutations::BaseMutation
- include Mutations::ResolvesNamespace
-
graphql_name 'UpdateNamespacePackageSettings'
+ include Mutations::ResolvesNamespace
+
authorize :create_package_settings
argument :namespace_path,
diff --git a/app/graphql/mutations/release_asset_links/create.rb b/app/graphql/mutations/release_asset_links/create.rb
index db486640507..f6445514ce9 100644
--- a/app/graphql/mutations/release_asset_links/create.rb
+++ b/app/graphql/mutations/release_asset_links/create.rb
@@ -3,14 +3,13 @@
module Mutations
module ReleaseAssetLinks
class Create < BaseMutation
- include FindsProject
-
graphql_name 'ReleaseAssetLinkCreate'
- authorize :create_release
-
+ include FindsProject
include Types::ReleaseAssetLinkSharedInputArguments
+ authorize :create_release
+
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Full path of the project the asset link is associated with.'
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index c01b0e4a01b..2921a77b86d 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -3,14 +3,13 @@
module Mutations
module Snippets
class Create < BaseMutation
+ graphql_name 'CreateSnippet'
+
include ServiceCompatibility
- include CanMutateSpammable
include Mutations::SpamProtection
authorize :create_snippet
- graphql_name 'CreateSnippet'
-
field :snippet,
Types::SnippetType,
null: true,
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index 9ecaa8d4bf2..2a2941c5328 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -3,12 +3,11 @@
module Mutations
module Snippets
class Update < Base
+ graphql_name 'UpdateSnippet'
+
include ServiceCompatibility
- include CanMutateSpammable
include Mutations::SpamProtection
- graphql_name 'UpdateSnippet'
-
argument :id, ::Types::GlobalIDType[::Snippet],
required: true,
description: 'Global ID of the snippet to update.'
diff --git a/app/graphql/mutations/user_preferences/update.rb b/app/graphql/mutations/user_preferences/update.rb
new file mode 100644
index 00000000000..c92c6d725b7
--- /dev/null
+++ b/app/graphql/mutations/user_preferences/update.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Mutations
+ module UserPreferences
+ class Update < BaseMutation
+ graphql_name 'UserPreferencesUpdate'
+
+ argument :issues_sort, Types::IssueSortEnum,
+ required: false,
+ description: 'Sort order for issue lists.'
+
+ field :user_preferences,
+ Types::UserPreferencesType,
+ null: true,
+ description: 'User preferences after mutation.'
+
+ def resolve(**attributes)
+ user_preferences = current_user.user_preference
+ user_preferences.update(attributes)
+
+ {
+ user_preferences: user_preferences.valid? ? user_preferences : nil,
+ errors: errors_on_object(user_preferences)
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb
index 88b8cefd8d2..81454db62b1 100644
--- a/app/graphql/mutations/work_items/create.rb
+++ b/app/graphql/mutations/work_items/create.rb
@@ -3,10 +3,13 @@
module Mutations
module WorkItems
class Create < BaseMutation
+ graphql_name 'WorkItemCreate'
+
include Mutations::SpamProtection
include FindsProject
- graphql_name 'WorkItemCreate'
+ description "Creates a work item." \
+ " Available only when feature flag `work_items` is enabled. The feature is experimental and is subject to change without notice."
authorize :create_work_item
@@ -29,16 +32,21 @@ module Mutations
def resolve(project_path:, **attributes)
project = authorized_find!(project_path)
+
+ unless Feature.enabled?(:work_items, project)
+ return { errors: ['`work_items` feature flag disabled for this project'] }
+ end
+
params = global_id_compatibility_params(attributes).merge(author_id: current_user.id)
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
- work_item = ::WorkItems::CreateService.new(project: project, current_user: current_user, params: params, spam_params: spam_params).execute
+ create_result = ::WorkItems::CreateService.new(project: project, current_user: current_user, params: params, spam_params: spam_params).execute
- check_spam_action_response!(work_item)
+ check_spam_action_response!(create_result[:work_item]) if create_result[:work_item]
{
- work_item: work_item.valid? ? work_item : nil,
- errors: errors_on_object(work_item)
+ work_item: create_result.success? ? create_result[:work_item] : nil,
+ errors: create_result.errors
}
end
diff --git a/app/graphql/mutations/work_items/delete.rb b/app/graphql/mutations/work_items/delete.rb
new file mode 100644
index 00000000000..71792a802c0
--- /dev/null
+++ b/app/graphql/mutations/work_items/delete.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Mutations
+ module WorkItems
+ class Delete < BaseMutation
+ graphql_name 'WorkItemDelete'
+ description "Deletes a work item." \
+ " Available only when feature flag `work_items` is enabled. The feature is experimental and is subject to change without notice."
+
+ authorize :delete_work_item
+
+ argument :id, ::Types::GlobalIDType[::WorkItem],
+ required: true,
+ description: 'Global ID of the work item.'
+
+ field :project, Types::ProjectType,
+ null: true,
+ description: 'Project the deleted work item belonged to.'
+
+ def resolve(id:)
+ work_item = authorized_find!(id: id)
+
+ unless Feature.enabled?(:work_items, work_item.project)
+ return { errors: ['`work_items` feature flag disabled for this project'] }
+ end
+
+ result = ::WorkItems::DeleteService.new(
+ project: work_item.project,
+ current_user: current_user
+ ).execute(work_item)
+
+ {
+ project: result.success? ? work_item.project : nil,
+ errors: result.errors
+ }
+ end
+
+ private
+
+ def find_object(id:)
+ # TODO: Remove coercion when working on https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
new file mode 100644
index 00000000000..3ab9ba2d502
--- /dev/null
+++ b/app/graphql/mutations/work_items/update.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Mutations
+ module WorkItems
+ class Update < BaseMutation
+ graphql_name 'WorkItemUpdate'
+ description "Updates a work item by Global ID." \
+ " Available only when feature flag `work_items` is enabled. The feature is experimental and is subject to change without notice."
+
+ include Mutations::SpamProtection
+
+ authorize :update_work_item
+
+ argument :id, ::Types::GlobalIDType[::WorkItem],
+ required: true,
+ description: 'Global ID of the work item.'
+ argument :state_event, Types::WorkItems::StateEventEnum,
+ description: 'Close or reopen a work item.',
+ required: false
+ argument :title, GraphQL::Types::String,
+ required: false,
+ description: copy_field_description(Types::WorkItemType, :title)
+
+ field :work_item, Types::WorkItemType,
+ null: true,
+ description: 'Updated work item.'
+
+ def resolve(id:, **attributes)
+ work_item = authorized_find!(id: id)
+
+ unless Feature.enabled?(:work_items, work_item.project)
+ return { errors: ['`work_items` feature flag disabled for this project'] }
+ end
+
+ spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
+
+ ::WorkItems::UpdateService.new(
+ project: work_item.project,
+ current_user: current_user,
+ params: attributes,
+ spam_params: spam_params
+ ).execute(work_item)
+
+ check_spam_action_response!(work_item)
+
+ {
+ work_item: work_item.valid? ? work_item : nil,
+ errors: errors_on_object(work_item)
+ }
+ end
+
+ private
+
+ def find_object(id:)
+ # TODO: Remove coercion when working on https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
index 5dece2f81cc..f4921706f7e 100644
--- a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
+++ b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
@@ -120,6 +120,7 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
hasDetails
detailsPath
group
+ label
action {
__typename
id
diff --git a/app/graphql/resolvers/ci/project_pipeline_counts_resolver.rb b/app/graphql/resolvers/ci/project_pipeline_counts_resolver.rb
new file mode 100644
index 00000000000..728bc9627c5
--- /dev/null
+++ b/app/graphql/resolvers/ci/project_pipeline_counts_resolver.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class ProjectPipelineCountsResolver < BaseResolver
+ type Types::Ci::PipelineCountsType, null: true
+
+ argument :ref,
+ GraphQL::Types::String,
+ required: false,
+ description: "Filter pipelines by the ref they are run for."
+
+ argument :sha,
+ GraphQL::Types::String,
+ required: false,
+ description: "Filter pipelines by the SHA of the commit they are run for."
+
+ argument :source,
+ GraphQL::Types::String,
+ required: false,
+ description: "Filter pipelines by their source."
+
+ def resolve(**args)
+ ::Gitlab::PipelineScopeCounts.new(context[:current_user], object, args)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/runner_jobs_resolver.rb b/app/graphql/resolvers/ci/runner_jobs_resolver.rb
new file mode 100644
index 00000000000..2f6ca09d031
--- /dev/null
+++ b/app/graphql/resolvers/ci/runner_jobs_resolver.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class RunnerJobsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+ include LooksAhead
+
+ type ::Types::Ci::JobType.connection_type, null: true
+ authorize :read_builds
+ authorizes_object!
+
+ argument :statuses, [::Types::Ci::JobStatusEnum],
+ required: false,
+ description: 'Filter jobs by status.'
+
+ alias_method :runner, :object
+
+ def ready?(**args)
+ context[self.class] ||= { executions: 0 }
+ context[self.class][:executions] += 1
+
+ raise GraphQL::ExecutionError, "Jobs can be requested for only one runner at a time" if context[self.class][:executions] > 1
+
+ super
+ end
+
+ def resolve_with_lookahead(statuses: nil)
+ jobs = ::Ci::JobsFinder.new(current_user: current_user, runner: runner, params: { scope: statuses }).execute
+
+ apply_lookahead(jobs)
+ end
+
+ private
+
+ def preloads
+ {
+ previous_stage_jobs_and_needs: [:needs, :pipeline],
+ artifacts: [:job_artifacts],
+ pipeline: [:user]
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb
index 9848b5a503f..e221dfea4d0 100644
--- a/app/graphql/resolvers/ci/runners_resolver.rb
+++ b/app/graphql/resolvers/ci/runners_resolver.rb
@@ -9,7 +9,12 @@ module Resolvers
argument :active, ::GraphQL::Types::Boolean,
required: false,
- description: 'Filter runners by active (true) or paused (false) status.'
+ description: 'Filter runners by `active` (true) or `paused` (false) status.',
+ deprecated: { reason: :renamed, replacement: 'paused', milestone: '14.8' }
+
+ argument :paused, ::GraphQL::Types::Boolean,
+ required: false,
+ description: 'Filter runners by `paused` (true) or `active` (false) status.'
argument :status, ::Types::Ci::RunnerStatusEnum,
required: false,
@@ -41,8 +46,11 @@ module Resolvers
protected
def runners_finder_params(params)
+ # Give preference to paused argument over the deprecated 'active' argument
+ paused = params.fetch(:paused, params[:active] ? !params[:active] : nil)
+
{
- active: params[:active],
+ active: paused.nil? ? nil : !paused,
status_status: params[:status]&.to_s,
type_type: params[:type],
tag_name: params[:tag_list],
diff --git a/app/graphql/resolvers/clusters/agent_tokens_resolver.rb b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb
index 8208fa56485..722fbab3bb7 100644
--- a/app/graphql/resolvers/clusters/agent_tokens_resolver.rb
+++ b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb
@@ -25,7 +25,7 @@ module Resolvers
private
def can_read_agent_tokens?
- current_user.can?(:admin_cluster, project)
+ current_user.can?(:read_cluster, project)
end
end
end
diff --git a/app/graphql/resolvers/kas/agent_configurations_resolver.rb b/app/graphql/resolvers/kas/agent_configurations_resolver.rb
index a1b1d3bfe4c..9db104287a6 100644
--- a/app/graphql/resolvers/kas/agent_configurations_resolver.rb
+++ b/app/graphql/resolvers/kas/agent_configurations_resolver.rb
@@ -21,7 +21,7 @@ module Resolvers
private
def can_read_agent_configuration?
- current_user.can?(:admin_cluster, project)
+ current_user.can?(:read_cluster, project)
end
def kas_client
diff --git a/app/graphql/resolvers/kas/agent_connections_resolver.rb b/app/graphql/resolvers/kas/agent_connections_resolver.rb
index 8b7c4003598..cf1a47aac75 100644
--- a/app/graphql/resolvers/kas/agent_connections_resolver.rb
+++ b/app/graphql/resolvers/kas/agent_connections_resolver.rb
@@ -29,7 +29,7 @@ module Resolvers
def get_connected_agents
kas_client.get_connected_agents(project: project)
- rescue GRPC::BadStatus => e
+ rescue GRPC::BadStatus, Gitlab::Kas::Client::ConfigurationError => e
raise Gitlab::Graphql::Errors::ResourceNotAvailable, e.class.name
end
diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb
index 6dbcbe0e04d..72372ae6b42 100644
--- a/app/graphql/resolvers/merge_requests_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -55,6 +55,19 @@ module Resolvers
required: false,
description: 'Limit result to draft merge requests.'
+ argument :created_after, Types::TimeType,
+ required: false,
+ description: 'Merge requests created after this timestamp.'
+ argument :created_before, Types::TimeType,
+ required: false,
+ description: 'Merge requests created before this timestamp.'
+ argument :updated_after, Types::TimeType,
+ required: false,
+ description: 'Merge requests updated after this timestamp.'
+ argument :updated_before, Types::TimeType,
+ required: false,
+ description: 'Merge requests updated before this timestamp.'
+
argument :labels, [GraphQL::Types::String],
required: false,
as: :label_name,
@@ -72,12 +85,6 @@ module Resolvers
description: 'Sort merge requests by this criteria.',
required: false,
default_value: :created_desc
- argument :created_after, Types::TimeType,
- required: false,
- description: 'Merge requests created after this timestamp.'
- argument :created_before, Types::TimeType,
- required: false,
- description: 'Merge requests created before this timestamp.'
negated do
argument :labels, [GraphQL::Types::String],
diff --git a/app/graphql/resolvers/paginated_tree_resolver.rb b/app/graphql/resolvers/paginated_tree_resolver.rb
index 6c0545d26de..d29d87ca204 100644
--- a/app/graphql/resolvers/paginated_tree_resolver.rb
+++ b/app/graphql/resolvers/paginated_tree_resolver.rb
@@ -11,14 +11,14 @@ module Resolvers
required: false,
default_value: '', # root of the repository
description: 'Path to get the tree for. Default value is the root of the repository.'
- argument :ref, GraphQL::Types::String,
- required: false,
- default_value: :head,
- description: 'Commit ref to get the tree for. Default value is HEAD.'
argument :recursive, GraphQL::Types::Boolean,
required: false,
default_value: false,
description: 'Used to get a recursive tree. Default is false.'
+ argument :ref, GraphQL::Types::String,
+ required: false,
+ default_value: :head,
+ description: 'Commit ref to get the tree for. Default value is HEAD.'
alias_method :repository, :object
diff --git a/app/graphql/resolvers/project_jobs_resolver.rb b/app/graphql/resolvers/project_jobs_resolver.rb
index 8a2693ee46b..b09158d475d 100644
--- a/app/graphql/resolvers/project_jobs_resolver.rb
+++ b/app/graphql/resolvers/project_jobs_resolver.rb
@@ -18,7 +18,8 @@ module Resolvers
def ready?(**args)
context[self.class] ||= { executions: 0 }
context[self.class][:executions] += 1
- raise GraphQL::ExecutionError, "Jobs can only be requested for one project at a time" if context[self.class][:executions] > 1
+
+ raise GraphQL::ExecutionError, "Jobs can be requested for only one project at a time" if context[self.class][:executions] > 1
super
end
diff --git a/app/graphql/resolvers/recent_boards_resolver.rb b/app/graphql/resolvers/recent_boards_resolver.rb
new file mode 100644
index 00000000000..4de5b8f072b
--- /dev/null
+++ b/app/graphql/resolvers/recent_boards_resolver.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class RecentBoardsResolver < BaseResolver
+ type Types::BoardType, null: true
+
+ def resolve
+ parent = object.respond_to?(:sync) ? object.sync : object
+ return Board.none unless parent
+
+ recent_visits =
+ ::Boards::VisitsFinder.new(parent, current_user).latest(Board::RECENT_BOARDS_SIZE)
+
+ recent_visits&.map(&:board) || []
+ end
+ end
+end
diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb
index 8d6ece0956e..f02eb226810 100644
--- a/app/graphql/resolvers/tree_resolver.rb
+++ b/app/graphql/resolvers/tree_resolver.rb
@@ -10,14 +10,14 @@ module Resolvers
required: false,
default_value: '',
description: 'Path to get the tree for. Default value is the root of the repository.'
- argument :ref, GraphQL::Types::String,
- required: false,
- default_value: :head,
- description: 'Commit ref to get the tree for. Default value is HEAD.'
argument :recursive, GraphQL::Types::Boolean,
required: false,
default_value: false,
description: 'Used to get a recursive tree. Default is false.'
+ argument :ref, GraphQL::Types::String,
+ required: false,
+ default_value: :head,
+ description: 'Commit ref to get the tree for. Default value is HEAD.'
alias_method :repository, :object
diff --git a/app/graphql/resolvers/users/groups_resolver.rb b/app/graphql/resolvers/users/groups_resolver.rb
index d8492a8fcf9..09c6b51cc3d 100644
--- a/app/graphql/resolvers/users/groups_resolver.rb
+++ b/app/graphql/resolvers/users/groups_resolver.rb
@@ -11,13 +11,13 @@ module Resolvers
authorize :read_user_groups
authorizes_object!
- argument :search, GraphQL::Types::String,
- required: false,
- description: 'Search by group name or path.'
argument :permission_scope,
::Types::PermissionTypes::GroupEnum,
required: false,
description: 'Filter by permissions the user has on groups.'
+ argument :search, GraphQL::Types::String,
+ required: false,
+ description: 'Search by group name or path.'
before_connection_authorization do |nodes, current_user|
Preloaders::GroupPolicyPreloader.new(nodes, current_user).execute
diff --git a/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb b/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb
index 8276549ddcc..1fc47303d67 100644
--- a/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb
+++ b/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb
@@ -5,10 +5,11 @@ module Types
module Analytics
module UsageTrends
class MeasurementType < BaseObject
- include Gitlab::Graphql::Authorize::AuthorizeResource
graphql_name 'UsageTrendsMeasurement'
description 'Represents a recorded measurement (object count) for the Admins'
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
authorize :read_usage_trends_measurement
field :recorded_at, Types::TimeType, null: true,
diff --git a/app/graphql/types/alert_management/prometheus_integration_type.rb b/app/graphql/types/alert_management/prometheus_integration_type.rb
index 27e4832d8f6..9a2ef78eca7 100644
--- a/app/graphql/types/alert_management/prometheus_integration_type.rb
+++ b/app/graphql/types/alert_management/prometheus_integration_type.rb
@@ -3,11 +3,11 @@
module Types
module AlertManagement
class PrometheusIntegrationType < ::Types::BaseObject
- include ::Gitlab::Routing
-
graphql_name 'AlertManagementPrometheusIntegration'
description 'An endpoint and credentials used to accept Prometheus alerts for a project'
+ include ::Gitlab::Routing
+
implements(Types::AlertManagement::IntegrationType)
authorize :admin_project
diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb
index 8c67803e39e..733006369ea 100644
--- a/app/graphql/types/board_list_type.rb
+++ b/app/graphql/types/board_list_type.rb
@@ -3,11 +3,11 @@
module Types
# rubocop: disable Graphql/AuthorizeTypes
class BoardListType < BaseObject
- include Gitlab::Utils::StrongMemoize
-
graphql_name 'BoardList'
description 'Represents a list for an issue board'
+ include Gitlab::Utils::StrongMemoize
+
alias_method :list, :object
field :id, GraphQL::Types::ID,
diff --git a/app/graphql/types/ci/pipeline_counts_type.rb b/app/graphql/types/ci/pipeline_counts_type.rb
new file mode 100644
index 00000000000..9c2b822091e
--- /dev/null
+++ b/app/graphql/types/ci/pipeline_counts_type.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class PipelineCountsType < BaseObject
+ graphql_name 'PipelineCounts'
+ description "Represents pipeline counts for the project"
+
+ authorize :read_pipeline
+
+ (::Types::Ci::PipelineScopeEnum.values.keys - %w[BRANCHES TAGS]).each do |scope|
+ field scope.downcase,
+ GraphQL::Types::Int,
+ null: true,
+ description: "Number of pipelines with scope #{scope} for the project"
+ end
+
+ field :all,
+ GraphQL::Types::Int,
+ null: true,
+ description: 'Total number of pipelines for the project.'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/runner_sort_enum.rb b/app/graphql/types/ci/runner_sort_enum.rb
index 95ec1867fea..8f2a13bd699 100644
--- a/app/graphql/types/ci/runner_sort_enum.rb
+++ b/app/graphql/types/ci/runner_sort_enum.rb
@@ -10,6 +10,8 @@ module Types
value 'CONTACTED_DESC', 'Ordered by contacted_at in descending order.', value: :contacted_desc
value 'CREATED_ASC', 'Ordered by created_at in ascending order.', value: :created_at_asc
value 'CREATED_DESC', 'Ordered by created_at in descending order.', value: :created_at_desc
+ value 'TOKEN_EXPIRES_AT_ASC', 'Ordered by token_expires_at in ascending order.', value: :token_expires_at_asc
+ value 'TOKEN_EXPIRES_AT_DESC', 'Ordered by token_expires_at in descending order.', value: :token_expires_at_desc
end
end
end
diff --git a/app/graphql/types/ci/runner_status_enum.rb b/app/graphql/types/ci/runner_status_enum.rb
index dd056191ceb..2e65e2d4e1e 100644
--- a/app/graphql/types/ci/runner_status_enum.rb
+++ b/app/graphql/types/ci/runner_status_enum.rb
@@ -7,12 +7,20 @@ module Types
value 'ACTIVE',
description: 'Runner that is not paused.',
- deprecated: { reason: 'Use CiRunnerType.active instead', milestone: '14.6' },
+ deprecated: {
+ reason: :renamed,
+ replacement: 'CiRunner.paused',
+ milestone: '14.6'
+ },
value: :active
value 'PAUSED',
description: 'Runner that is paused.',
- deprecated: { reason: 'Use CiRunnerType.active instead', milestone: '14.6' },
+ deprecated: {
+ reason: :renamed,
+ replacement: 'CiRunner.paused',
+ milestone: '14.6'
+ },
value: :paused
value 'ONLINE',
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index 4fe65734911..9094c6b96e4 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -3,12 +3,13 @@
module Types
module Ci
class RunnerType < BaseObject
+ graphql_name 'CiRunner'
+
edge_type_class(RunnerWebUrlEdge)
connection_type_class(Types::CountableConnectionType)
- graphql_name 'CiRunner'
+
authorize :read_runner
present_using ::Ci::RunnerPresenter
-
expose_permissions Types::PermissionTypes::Ci::Runner
JOB_COUNT_LIMIT = 1000
@@ -24,12 +25,18 @@ module Types
field :contacted_at, Types::TimeType, null: true,
description: 'Timestamp of last contact from this runner.',
method: :contacted_at
+ field :token_expires_at, Types::TimeType, null: true,
+ description: 'Runner token expiration time.',
+ method: :token_expires_at
field :maximum_timeout, GraphQL::Types::Int, null: true,
description: 'Maximum timeout (in seconds) for jobs processed by the runner.'
field :access_level, ::Types::Ci::RunnerAccessLevelEnum, null: false,
description: 'Access level of the runner.'
field :active, GraphQL::Types::Boolean, null: false,
- description: 'Indicates the runner is allowed to receive jobs.'
+ description: 'Indicates the runner is allowed to receive jobs.',
+ deprecated: { reason: 'Use paused', milestone: '14.8' }
+ field :paused, GraphQL::Types::Boolean, null: false,
+ description: 'Indicates the runner is paused and not available to run jobs.'
field :status,
Types::Ci::RunnerStatusEnum,
null: false,
@@ -63,6 +70,14 @@ module Types
description: 'Executor last advertised by the runner.',
method: :executor_name,
feature_flag: :graphql_ci_runner_executor
+ field :groups, ::Types::GroupType.connection_type, null: true,
+ description: 'Groups the runner is associated with. For group runners only.'
+ field :projects, ::Types::ProjectType.connection_type, null: true,
+ description: 'Projects the runner is associated with. For project runners only.'
+ field :jobs, ::Types::Ci::JobType.connection_type, null: true,
+ description: 'Jobs assigned to the runner.',
+ authorize: :read_builds,
+ resolver: ::Resolvers::Ci::RunnerJobsResolver
def job_count
# We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT
@@ -94,11 +109,40 @@ module Types
end
# rubocop: enable CodeReuse/ActiveRecord
+ def groups
+ return unless runner.group_type?
+
+ batched_owners(::Ci::RunnerNamespace, Group, :runner_groups, :namespace_id)
+ end
+
+ def projects
+ return unless runner.project_type?
+
+ batched_owners(::Ci::RunnerProject, Project, :runner_projects, :project_id)
+ end
+
private
def can_admin_runners?
context[:current_user]&.can_admin_all_resources?
end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def batched_owners(runner_assoc_type, assoc_type, key, column_name)
+ BatchLoader::GraphQL.for(runner.id).batch(key: key) do |runner_ids, loader, args|
+ runner_and_owner_ids = runner_assoc_type.where(runner_id: runner_ids).pluck(:runner_id, column_name)
+
+ owner_ids_by_runner_id = runner_and_owner_ids.group_by(&:first).transform_values { |v| v.pluck(1) }
+ owner_ids = runner_and_owner_ids.pluck(1).uniq
+
+ owners = assoc_type.where(id: owner_ids).index_by(&:id)
+
+ runner_ids.each do |runner_id|
+ loader.call(runner_id, owner_ids_by_runner_id[runner_id]&.map { |owner_id| owners[owner_id] } || [])
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
end
end
diff --git a/app/graphql/types/clusters/agent_activity_event_type.rb b/app/graphql/types/clusters/agent_activity_event_type.rb
index 79a9fd70505..3484acfe25e 100644
--- a/app/graphql/types/clusters/agent_activity_event_type.rb
+++ b/app/graphql/types/clusters/agent_activity_event_type.rb
@@ -5,7 +5,7 @@ module Types
class AgentActivityEventType < BaseObject
graphql_name 'ClusterAgentActivityEvent'
- authorize :admin_cluster
+ authorize :read_cluster
connection_type_class(Types::CountableConnectionType)
diff --git a/app/graphql/types/clusters/agent_token_type.rb b/app/graphql/types/clusters/agent_token_type.rb
index 96fdb5f05c8..24489707698 100644
--- a/app/graphql/types/clusters/agent_token_type.rb
+++ b/app/graphql/types/clusters/agent_token_type.rb
@@ -5,7 +5,7 @@ module Types
class AgentTokenType < BaseObject
graphql_name 'ClusterAgentToken'
- authorize :admin_cluster
+ authorize :read_cluster
connection_type_class(Types::CountableConnectionType)
diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb
index 89316ed4728..546252b2285 100644
--- a/app/graphql/types/clusters/agent_type.rb
+++ b/app/graphql/types/clusters/agent_type.rb
@@ -5,7 +5,7 @@ module Types
class AgentType < BaseObject
graphql_name 'ClusterAgent'
- authorize :admin_cluster
+ authorize :read_cluster
connection_type_class(Types::CountableConnectionType)
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
index 2584e15ff0b..8bc00359ccb 100644
--- a/app/graphql/types/commit_type.rb
+++ b/app/graphql/types/commit_type.rb
@@ -10,33 +10,37 @@ module Types
field :id, type: GraphQL::Types::ID, null: false,
description: 'ID (global ID) of the commit.'
+
field :sha, type: GraphQL::Types::String, null: false,
description: 'SHA1 ID of the commit.'
+
field :short_id, type: GraphQL::Types::String, null: false,
description: 'Short SHA1 ID of the commit.'
field :title, type: GraphQL::Types::String, null: true, calls_gitaly: true,
description: 'Title of the commit message.'
- markdown_field :title_html, null: true
field :full_title, type: GraphQL::Types::String, null: true, calls_gitaly: true,
description: 'Full title of the commit message.'
- markdown_field :full_title_html, null: true
field :description, type: GraphQL::Types::String, null: true,
description: 'Description of the commit message.'
- markdown_field :description_html, null: true
field :message, type: GraphQL::Types::String, null: true,
description: 'Raw commit message.'
+
field :authored_date, type: Types::TimeType, null: true,
description: 'Timestamp of when the commit was authored.'
+
field :web_url, type: GraphQL::Types::String, null: false,
description: 'Web URL of the commit.'
+
field :web_path, type: GraphQL::Types::String, null: false,
description: 'Web path of the commit.'
+
field :signature_html, type: GraphQL::Types::String, null: true, calls_gitaly: true,
description: 'Rendered HTML of the commit signature.'
+
field :author_name, type: GraphQL::Types::String, null: true,
description: 'Commit authors name.'
field :author_email, type: GraphQL::Types::String, null: true,
@@ -53,6 +57,10 @@ module Types
description: 'Pipelines of the commit ordered latest first.',
resolver: Resolvers::CommitPipelinesResolver
+ markdown_field :title_html, null: true
+ markdown_field :full_title_html, null: true
+ markdown_field :description_html, null: true
+
def author_gravatar
GravatarService.new.execute(object.author_email, 40)
end
diff --git a/app/graphql/types/group_invitation_type.rb b/app/graphql/types/group_invitation_type.rb
index 9410253553c..48281dcfd9f 100644
--- a/app/graphql/types/group_invitation_type.rb
+++ b/app/graphql/types/group_invitation_type.rb
@@ -2,14 +2,14 @@
module Types
class GroupInvitationType < BaseObject
+ graphql_name 'GroupInvitation'
+ description 'Represents a Group Invitation'
+
expose_permissions Types::PermissionTypes::Group
authorize :admin_group
implements InvitationInterface
- graphql_name 'GroupInvitation'
- description 'Represents a Group Invitation'
-
field :group, Types::GroupType, null: true,
description: 'Group that a User is invited to.'
diff --git a/app/graphql/types/group_member_type.rb b/app/graphql/types/group_member_type.rb
index 8b8e69d795d..d68abc11bba 100644
--- a/app/graphql/types/group_member_type.rb
+++ b/app/graphql/types/group_member_type.rb
@@ -2,14 +2,14 @@
module Types
class GroupMemberType < BaseObject
+ graphql_name 'GroupMember'
+ description 'Represents a Group Membership'
+
expose_permissions Types::PermissionTypes::Group
authorize :read_group
implements MemberInterface
- graphql_name 'GroupMember'
- description 'Represents a Group Membership'
-
field :group, Types::GroupType, null: true,
description: 'Group that a User is a member of.'
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index e02650fd285..5f63aa20953 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -94,6 +94,12 @@ module Types
max_page_size: 2000,
resolver: Resolvers::BoardsResolver
+ field :recent_issue_boards,
+ Types::BoardType.connection_type,
+ null: true,
+ description: 'List of recently visited boards of the group. Maximum size is 4.',
+ resolver: Resolvers::RecentBoardsResolver
+
field :board,
Types::BoardType,
null: true,
@@ -170,15 +176,6 @@ module Types
null: true,
description: 'Dependency proxy TTL policy for the group.'
- def label(title:)
- BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
- LabelsFinder
- .new(current_user, group: args[:key], title: titles)
- .execute
- .each { |label| loader.call(label.title, label) }
- end
- end
-
field :labels,
Types::LabelType.connection_type,
null: true,
@@ -215,6 +212,15 @@ module Types
description: 'Work item types available to the group.',
feature_flag: :work_items
+ def label(title:)
+ BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
+ LabelsFinder
+ .new(current_user, group: args[:key], title: titles)
+ .execute
+ .each { |label| loader.call(label.title, label) }
+ end
+ end
+
def avatar_url
object.avatar_url(only_path: false)
end
diff --git a/app/graphql/types/issuable_type.rb b/app/graphql/types/issuable_type.rb
index 6ca74087f8a..4a39b5ed6ec 100644
--- a/app/graphql/types/issuable_type.rb
+++ b/app/graphql/types/issuable_type.rb
@@ -5,10 +5,12 @@ module Types
graphql_name 'Issuable'
description 'Represents an issuable.'
- possible_types Types::IssueType, Types::MergeRequestType
+ possible_types Types::IssueType, Types::MergeRequestType, Types::WorkItemType
def self.resolve_type(object, context)
case object
+ when WorkItem
+ Types::WorkItemType
when Issue
Types::IssueType
when MergeRequest
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 46fe91feae4..ee57961ee4a 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -21,10 +21,8 @@ module Types
description: "Internal ID of the issue."
field :title, GraphQL::Types::String, null: false,
description: 'Title of the issue.'
- markdown_field :title_html, null: true
field :description, GraphQL::Types::String, null: true,
description: 'Description of the issue.'
- markdown_field :description_html, null: true
field :state, IssueStateEnum, null: false,
description: 'State of the issue.'
@@ -143,6 +141,9 @@ module Types
field :escalation_status, Types::IncidentManagement::EscalationStatusEnum, null: true,
description: 'Escalation status of the issue.'
+ markdown_field :title_html, null: true
+ markdown_field :description_html, null: true
+
def author
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
end
@@ -168,13 +169,11 @@ module Types
end
def hidden?
- object.hidden? if Feature.enabled?(:ban_user_feature_flag)
+ object.hidden? if Feature.enabled?(:ban_user_feature_flag, default_enabled: :yaml)
end
def escalation_status
- return unless Feature.enabled?(:incident_escalations, object.project) && object.supports_escalation?
-
- object.escalation_status&.status_name
+ object.supports_escalation? ? object.escalation_status&.status_name : nil
end
end
end
diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb
index bb2d561014e..5a10bcfee74 100644
--- a/app/graphql/types/label_type.rb
+++ b/app/graphql/types/label_type.rb
@@ -12,7 +12,6 @@ module Types
description: 'Label ID.'
field :description, GraphQL::Types::String, null: true,
description: 'Description of the label (Markdown rendered as HTML for caching).'
- markdown_field :description_html, null: true
field :title, GraphQL::Types::String, null: false,
description: 'Content of the label.'
field :color, GraphQL::Types::String, null: false,
@@ -23,5 +22,7 @@ module Types
description: 'When this label was created.'
field :updated_at, Types::TimeType, null: false,
description: 'When this label was last updated.'
+
+ markdown_field :description_html, null: true
end
end
diff --git a/app/graphql/types/member_interface.rb b/app/graphql/types/member_interface.rb
index c5623cd4710..67d0e18b522 100644
--- a/app/graphql/types/member_interface.rb
+++ b/app/graphql/types/member_interface.rb
@@ -25,6 +25,12 @@ module Types
field :user, Types::UserType, null: true,
description: 'User that is associated with the member object.'
+ field :merge_request_interaction, Types::UserMergeRequestInteractionType,
+ null: true,
+ description: 'Find a merge request.' do
+ argument :id, ::Types::GlobalIDType[::MergeRequest], required: true, description: 'Global ID of the merge request.'
+ end
+
definition_methods do
def resolve_type(object, context)
case object
@@ -37,5 +43,11 @@ module Types
end
end
end
+
+ def merge_request_interaction(id: nil)
+ Gitlab::Graphql::Lazy.with_value(GitlabSchema.object_from_id(id, expected_class: ::MergeRequest)) do |merge_request|
+ Users::MergeRequestInteraction.new(user: object.user, merge_request: merge_request) if merge_request
+ end
+ end
end
end
diff --git a/app/graphql/types/merge_request_sort_enum.rb b/app/graphql/types/merge_request_sort_enum.rb
index d75eae6abc4..a74c5a01769 100644
--- a/app/graphql/types/merge_request_sort_enum.rb
+++ b/app/graphql/types/merge_request_sort_enum.rb
@@ -9,5 +9,7 @@ module Types
value 'MERGED_AT_DESC', 'Merge time by descending order.', value: :merged_at_desc
value 'CLOSED_AT_ASC', 'Closed time by ascending order.', value: :closed_at_asc
value 'CLOSED_AT_DESC', 'Closed time by descending order.', value: :closed_at_desc
+ value 'TITLE_ASC', 'Title by ascending order.', value: :title_asc
+ value 'TITLE_DESC', 'Title by descending order.', value: :title_desc
end
end
diff --git a/app/graphql/types/merge_requests/assignee_type.rb b/app/graphql/types/merge_requests/assignee_type.rb
index 8448477370e..24321d057a3 100644
--- a/app/graphql/types/merge_requests/assignee_type.rb
+++ b/app/graphql/types/merge_requests/assignee_type.rb
@@ -3,11 +3,12 @@
module Types
module MergeRequests
class AssigneeType < ::Types::UserType
+ graphql_name 'MergeRequestAssignee'
+ description 'A user assigned to a merge request.'
+
include FindClosest
include ::Types::MergeRequests::InteractsWithMergeRequest
- graphql_name 'MergeRequestAssignee'
- description 'A user assigned to a merge request.'
authorize :read_user
end
end
diff --git a/app/graphql/types/merge_requests/interacts_with_merge_request.rb b/app/graphql/types/merge_requests/interacts_with_merge_request.rb
index d4a1f2faa8d..15621ef1472 100644
--- a/app/graphql/types/merge_requests/interacts_with_merge_request.rb
+++ b/app/graphql/types/merge_requests/interacts_with_merge_request.rb
@@ -5,6 +5,8 @@ module Types
module InteractsWithMergeRequest
extend ActiveSupport::Concern
+ include FindClosest
+
included do
field :merge_request_interaction,
type: ::Types::UserMergeRequestInteractionType,
@@ -13,8 +15,9 @@ module Types
description: "Details of this user's interactions with the merge request."
end
- def merge_request_interaction(parent:)
+ def merge_request_interaction(parent:, id: nil)
merge_request = closest_parent([::Types::MergeRequestType], parent)
+
return unless merge_request
Users::MergeRequestInteraction.new(user: object, merge_request: merge_request)
diff --git a/app/graphql/types/merge_requests/reviewer_type.rb b/app/graphql/types/merge_requests/reviewer_type.rb
index 1ced821c839..11f7ceaf461 100644
--- a/app/graphql/types/merge_requests/reviewer_type.rb
+++ b/app/graphql/types/merge_requests/reviewer_type.rb
@@ -3,11 +3,12 @@
module Types
module MergeRequests
class ReviewerType < ::Types::UserType
+ graphql_name 'MergeRequestReviewer'
+ description 'A user assigned to a merge request as a reviewer.'
+
include FindClosest
include ::Types::MergeRequests::InteractsWithMergeRequest
- graphql_name 'MergeRequestReviewer'
- description 'A user assigned to a merge request as a reviewer.'
authorize :read_user
end
end
diff --git a/app/graphql/types/metrics/dashboards/annotation_type.rb b/app/graphql/types/metrics/dashboards/annotation_type.rb
index fb35f2bd9a1..0c787476f54 100644
--- a/app/graphql/types/metrics/dashboards/annotation_type.rb
+++ b/app/graphql/types/metrics/dashboards/annotation_type.rb
@@ -4,8 +4,8 @@ module Types
module Metrics
module Dashboards
class AnnotationType < ::Types::BaseObject
- authorize :read_metrics_dashboard_annotation
graphql_name 'MetricsDashboardAnnotation'
+ authorize :read_metrics_dashboard_annotation
field :description, GraphQL::Types::String, null: true,
description: 'Description of the annotation.'
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index c350f4dd922..3c735231595 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -2,10 +2,10 @@
module Types
class MutationType < BaseObject
- include Gitlab::Graphql::MountMutation
-
graphql_name 'Mutation'
+ include Gitlab::Graphql::MountMutation
+
mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs
mount_mutation Mutations::AlertManagement::CreateAlertIssue
mount_mutation Mutations::AlertManagement::UpdateAlertStatus
@@ -121,10 +121,13 @@ module Types
mount_mutation Mutations::Namespace::PackageSettings::Update
mount_mutation Mutations::Groups::Update
mount_mutation Mutations::UserCallouts::Create
+ mount_mutation Mutations::UserPreferences::Update
mount_mutation Mutations::Packages::Destroy
mount_mutation Mutations::Packages::DestroyFile
mount_mutation Mutations::Echo
- mount_mutation Mutations::WorkItems::Create, feature_flag: :work_items
+ mount_mutation Mutations::WorkItems::Create
+ mount_mutation Mutations::WorkItems::Delete
+ mount_mutation Mutations::WorkItems::Update
end
end
diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb
index 56579c357a7..ffe61c9ff88 100644
--- a/app/graphql/types/notes/discussion_type.rb
+++ b/app/graphql/types/notes/discussion_type.rb
@@ -3,10 +3,10 @@
module Types
module Notes
class DiscussionType < BaseObject
- DiscussionID = ::Types::GlobalIDType[::Discussion]
-
graphql_name 'Discussion'
+ DiscussionID = ::Types::GlobalIDType[::Discussion]
+
authorize :read_note
implements(Types::ResolvableInterface)
diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb
index 1d2cf9649d8..444ecb5e792 100644
--- a/app/graphql/types/packages/package_details_type.rb
+++ b/app/graphql/types/packages/package_details_type.rb
@@ -3,16 +3,17 @@
module Types
module Packages
class PackageDetailsType < PackageType
- include ::PackagesHelper
-
graphql_name 'PackageDetailsType'
description 'Represents a package details in the Package Registry. Note that this type is in beta and susceptible to changes'
+
+ include ::PackagesHelper
+
authorize :read_package
field :versions, ::Types::Packages::PackageType.connection_type, null: true,
description: 'Other versions of the package.'
- field :package_files, Types::Packages::PackageFileType.connection_type, null: true, description: 'Package files.'
+ field :package_files, Types::Packages::PackageFileType.connection_type, null: true, method: :installable_package_files, description: 'Package files.'
field :dependency_links, Types::Packages::PackageDependencyLinkType.connection_type, null: true, description: 'Dependency link.'
@@ -36,14 +37,6 @@ module Types
object.versions
end
- def package_files
- if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- object.installable_package_files
- else
- object.package_files
- end
- end
-
def composer_config_repository_url
composer_config_repository_name(object.project.group&.id)
end
diff --git a/app/graphql/types/permission_types/issue.rb b/app/graphql/types/permission_types/issue.rb
index 94e1bffd685..b38971b64cd 100644
--- a/app/graphql/types/permission_types/issue.rb
+++ b/app/graphql/types/permission_types/issue.rb
@@ -3,8 +3,8 @@
module Types
module PermissionTypes
class Issue < BasePermissionType
- description 'Check permissions for the current user on a issue'
graphql_name 'IssuePermissions'
+ description 'Check permissions for the current user on a issue'
abilities :read_issue, :admin_issue, :update_issue, :reopen_issue,
:read_design, :create_design, :destroy_design,
diff --git a/app/graphql/types/permission_types/merge_request.rb b/app/graphql/types/permission_types/merge_request.rb
index 52c11fe5588..73a2f820f79 100644
--- a/app/graphql/types/permission_types/merge_request.rb
+++ b/app/graphql/types/permission_types/merge_request.rb
@@ -3,15 +3,16 @@
module Types
module PermissionTypes
class MergeRequest < BasePermissionType
+ graphql_name 'MergeRequestPermissions'
+ description 'Check permissions for the current user on a merge request'
+
+ present_using MergeRequestPresenter
+
PERMISSION_FIELDS = %i[push_to_source_branch
remove_source_branch
cherry_pick_on_current_merge_request
revert_on_current_merge_request].freeze
- present_using MergeRequestPresenter
- description 'Check permissions for the current user on a merge request'
- graphql_name 'MergeRequestPermissions'
-
abilities :read_merge_request, :admin_merge_request,
:update_merge_request, :create_note
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index d49244f6b65..dc428e7bdce 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -15,6 +15,8 @@ module Types
description: 'Full path of the project.'
field :path, GraphQL::Types::String, null: false,
description: 'Path of the project.'
+ field :ci_config_path_or_default, GraphQL::Types::String, null: false,
+ description: 'Path of the CI configuration file.'
field :sast_ci_configuration, Types::CiConfiguration::Sast::Type, null: true,
calls_gitaly: true,
@@ -195,6 +197,12 @@ module Types
extras: [:lookahead],
resolver: Resolvers::ProjectPipelineResolver
+ field :pipeline_counts,
+ Types::Ci::PipelineCountsType,
+ null: true,
+ description: 'Build pipeline counts of the project.',
+ resolver: Resolvers::Ci::ProjectPipelineCountsResolver
+
field :ci_cd_settings,
Types::Ci::CiCdSettingType,
null: true,
@@ -231,6 +239,12 @@ module Types
max_page_size: 2000,
resolver: Resolvers::BoardsResolver
+ field :recent_issue_boards,
+ Types::BoardType.connection_type,
+ null: true,
+ description: 'List of recently visited boards of the project. Maximum size is 4.',
+ resolver: Resolvers::RecentBoardsResolver
+
field :board,
Types::BoardType,
null: true,
diff --git a/app/graphql/types/query_complexity_type.rb b/app/graphql/types/query_complexity_type.rb
index 3f58a15aef7..13b618cf5ce 100644
--- a/app/graphql/types/query_complexity_type.rb
+++ b/app/graphql/types/query_complexity_type.rb
@@ -3,10 +3,10 @@
module Types
# rubocop: disable Graphql/AuthorizeTypes
class QueryComplexityType < ::Types::BaseObject
- ANALYZER = GraphQL::Analysis::QueryComplexity.new { |_query, complexity| complexity }
-
graphql_name 'QueryComplexity'
+ ANALYZER = GraphQL::Analysis::QueryComplexity.new { |_query, complexity| complexity }
+
alias_method :query, :object
field :limit, GraphQL::Types::Int,
diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb
index 28339093172..bfd59763a07 100644
--- a/app/graphql/types/repository/blob_type.rb
+++ b/app/graphql/types/repository/blob_type.rb
@@ -4,10 +4,10 @@ module Types
# rubocop: disable Graphql/AuthorizeTypes
# This is presented through `Repository` that has its own authorization
class BlobType < BaseObject
- present_using BlobPresenter
-
graphql_name 'RepositoryBlob'
+ present_using BlobPresenter
+
field :id, GraphQL::Types::ID, null: false,
description: 'ID of the blob.'
@@ -87,6 +87,14 @@ module Types
description: 'Web path to blob permalink.',
calls_gitaly: true
+ field :environment_formatted_external_url, GraphQL::Types::String, null: true,
+ description: 'Environment on which the blob is available.',
+ calls_gitaly: true
+
+ field :environment_external_url_for_route_map, GraphQL::Types::String, null: true,
+ description: 'Web path to blob on an environment.',
+ calls_gitaly: true
+
field :code_owners, [Types::UserType], null: true,
description: 'List of code owners for the blob.',
calls_gitaly: true
@@ -117,6 +125,12 @@ module Types
field :archived, GraphQL::Types::Boolean, null: true, method: :archived?,
description: 'Whether the current project is archived.'
+ field :language, GraphQL::Types::String,
+ description: 'Blob language.',
+ method: :blob_language,
+ null: true,
+ calls_gitaly: true
+
def raw_text_blob
object.data unless object.binary?
end
diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb
index 88dc6036bfd..4dcadf1274f 100644
--- a/app/graphql/types/root_storage_statistics_type.rb
+++ b/app/graphql/types/root_storage_statistics_type.rb
@@ -15,5 +15,6 @@ module Types
field :snippets_size, GraphQL::Types::Float, null: false, description: 'Snippets size in bytes.'
field :pipeline_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI pipeline artifacts size in bytes.'
field :uploads_size, GraphQL::Types::Float, null: false, description: 'Uploads size in bytes.'
+ field :dependency_proxy_size, GraphQL::Types::Float, null: false, description: 'Dependency Proxy sizes in bytes.'
end
end
diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb
index 3629edb5b33..db6a247179d 100644
--- a/app/graphql/types/subscription_type.rb
+++ b/app/graphql/types/subscription_type.rb
@@ -9,5 +9,8 @@ module Types
field :issue_crm_contacts_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the crm contacts of an issuable are updated.'
+
+ field :issuable_title_updated, subscription: Subscriptions::IssuableUpdated, null: true,
+ description: 'Triggered when the title of an issuable is updated.'
end
end
diff --git a/app/graphql/types/terraform/state_version_type.rb b/app/graphql/types/terraform/state_version_type.rb
index bf1af4565bc..59da550aa1b 100644
--- a/app/graphql/types/terraform/state_version_type.rb
+++ b/app/graphql/types/terraform/state_version_type.rb
@@ -3,10 +3,10 @@
module Types
module Terraform
class StateVersionType < BaseObject
- include ::API::Helpers::RelatedResourcesHelpers
-
graphql_name 'TerraformStateVersion'
+ include ::API::Helpers::RelatedResourcesHelpers
+
authorize :read_terraform_state
field :id, GraphQL::Types::ID,
diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb
index bb15d91a62f..bcff65be652 100644
--- a/app/graphql/types/tree/blob_type.rb
+++ b/app/graphql/types/tree/blob_type.rb
@@ -4,12 +4,11 @@ module Types
# rubocop: disable Graphql/AuthorizeTypes
# This is presented through `Repository` that has its own authorization
class BlobType < BaseObject
- implements Types::Tree::EntryType
+ graphql_name 'Blob'
+ implements Types::Tree::EntryType
present_using BlobPresenter
- graphql_name 'Blob'
-
field :web_url, GraphQL::Types::String, null: true,
description: 'Web URL of the blob.'
field :web_path, GraphQL::Types::String, null: true,
diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb
index 05d8c1a951a..bc7828dbffa 100644
--- a/app/graphql/types/tree/submodule_type.rb
+++ b/app/graphql/types/tree/submodule_type.rb
@@ -4,10 +4,10 @@ module Types
# rubocop: disable Graphql/AuthorizeTypes
# This is presented through `Repository` that has its own authorization
class SubmoduleType < BaseObject
- implements Types::Tree::EntryType
-
graphql_name 'Submodule'
+ implements Types::Tree::EntryType
+
field :web_url, type: GraphQL::Types::String, null: true,
description: 'Web URL for the sub-module.'
field :tree_url, type: GraphQL::Types::String, null: true,
diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb
index 998b3617574..cdc84c8e318 100644
--- a/app/graphql/types/tree/tree_entry_type.rb
+++ b/app/graphql/types/tree/tree_entry_type.rb
@@ -4,13 +4,12 @@ module Types
# rubocop: disable Graphql/AuthorizeTypes
# This is presented through `Repository` that has its own authorization
class TreeEntryType < BaseObject
- implements Types::Tree::EntryType
-
- present_using TreeEntryPresenter
-
graphql_name 'TreeEntry'
description 'Represents a directory'
+ implements Types::Tree::EntryType
+ present_using TreeEntryPresenter
+
field :web_url, GraphQL::Types::String, null: true,
description: 'Web URL for the tree entry (directory).'
field :web_path, GraphQL::Types::String, null: true,
diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb
index 6bb4cb29cdd..24fca80d5a9 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -31,7 +31,7 @@ module Types
null: false,
resolver_method: :redacted_name,
description: 'Human-readable name of the user. ' \
- 'Will return `****` if the user is a project bot and the requester does not have permission to read resource access tokens.'
+ 'Returns `****` if the user is a project bot and the requester does not have permission to view the project.'
field :state,
type: Types::UserStateEnum,
@@ -127,7 +127,7 @@ module Types
def redacted_name
return object.name unless object.project_bot?
- return object.name if context[:current_user]&.can?(:read_resource_access_tokens, object.projects.first)
+ return object.name if context[:current_user]&.can?(:read_project, object.projects.first)
# If the requester does not have permission to read the project bot name,
# the API returns an arbitrary string. UI changes will be addressed in a follow up issue:
diff --git a/app/graphql/types/user_preferences_type.rb b/app/graphql/types/user_preferences_type.rb
new file mode 100644
index 00000000000..9a1ea4a2e4f
--- /dev/null
+++ b/app/graphql/types/user_preferences_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ # Only used to render the current user's own preferences
+ class UserPreferencesType < BaseObject
+ graphql_name 'UserPreferences'
+
+ field :issues_sort, Types::IssueSortEnum,
+ description: 'Sort order for issue lists.',
+ null: true
+
+ def issues_sort
+ object.issues_sort.to_sym
+ end
+ end
+end
diff --git a/app/graphql/types/work_item_state_enum.rb b/app/graphql/types/work_item_state_enum.rb
new file mode 100644
index 00000000000..8fe58f76d9a
--- /dev/null
+++ b/app/graphql/types/work_item_state_enum.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class WorkItemStateEnum < BaseEnum
+ graphql_name 'WorkItemState'
+ description 'State of a GitLab work item'
+
+ value 'OPEN', 'In open state.', value: 'opened'
+ value 'CLOSED', 'In closed state.', value: 'closed'
+ end
+end
diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb
index 486c1e52987..15a5557b489 100644
--- a/app/graphql/types/work_item_type.rb
+++ b/app/graphql/types/work_item_type.rb
@@ -12,6 +12,8 @@ module Types
description: 'Global ID of the work item.'
field :iid, GraphQL::Types::ID, null: false,
description: 'Internal ID of the work item.'
+ field :state, WorkItemStateEnum, null: false,
+ description: 'State of the work item.'
field :title, GraphQL::Types::String, null: false,
description: 'Title of the work item.'
field :work_item_type, Types::WorkItems::TypeType, null: false,
diff --git a/app/graphql/types/work_items/state_event_enum.rb b/app/graphql/types/work_items/state_event_enum.rb
new file mode 100644
index 00000000000..db54d494c12
--- /dev/null
+++ b/app/graphql/types/work_items/state_event_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ class StateEventEnum < BaseEnum
+ graphql_name 'WorkItemStateEvent'
+ description 'Values for work item state events'
+
+ value 'REOPEN', 'Reopens the work item.', value: 'reopen'
+ value 'CLOSE', 'Closes the work item.', value: 'close'
+ end
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index e88d1832480..e675c01bcbb 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -396,7 +396,8 @@ module ApplicationHelper
labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
milestones: milestones_project_autocomplete_sources_path(object),
commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
- snippets: snippets_project_autocomplete_sources_path(object)
+ snippets: snippets_project_autocomplete_sources_path(object),
+ contacts: contacts_project_autocomplete_sources_path(object)
}
end
end
@@ -428,7 +429,7 @@ module ApplicationHelper
experiment(:logged_out_marketing_header, actor: nil) do |e|
html_class = 'logged-out-marketing-header-candidate'
e.candidate { html_class }
- e.try(:trial_focused) { html_class }
+ e.variant(:trial_focused) { html_class }
e.control {}
e.run
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 7541247b19f..fa9b3bfc912 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -234,7 +234,9 @@ module ApplicationSettingsHelper
:outbound_local_requests_allowlist_raw,
:dsa_key_restriction,
:ecdsa_key_restriction,
+ :ecdsa_sk_key_restriction,
:ed25519_key_restriction,
+ :ed25519_sk_key_restriction,
:eks_integration_enabled,
:eks_account_id,
:eks_access_key_id,
@@ -421,7 +423,12 @@ module ApplicationSettingsHelper
:sidekiq_job_limiter_compression_threshold_bytes,
:sidekiq_job_limiter_limit_bytes,
:suggest_pipeline_enabled,
- :user_email_lookup_limit
+ :user_email_lookup_limit,
+ :users_get_by_id_limit,
+ :users_get_by_id_limit_allowlist_raw,
+ :runner_token_expiration_interval,
+ :group_runner_token_expiration_interval,
+ :project_runner_token_expiration_interval
].tap do |settings|
settings << :deactivate_dormant_users unless Gitlab.com?
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index dd852a68682..9dc93779b12 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module AvatarsHelper
+ DEFAULT_AVATAR_PATH = 'no_avatar.png'
+
def project_icon(project, options = {})
source_icon(project, options)
end
@@ -33,12 +35,12 @@ module AvatarsHelper
end
end
- def avatar_icon_for_user(user = nil, size = nil, scale = 2, only_path: true)
- if user
- user.avatar_url(size: size, only_path: only_path) || default_avatar
- else
- gravatar_icon(nil, size, scale)
- end
+ def avatar_icon_for_user(user = nil, size = nil, scale = 2, only_path: true, current_user: nil)
+ return gravatar_icon(nil, size, scale) unless user
+ return default_avatar if blocked_or_unconfirmed?(user) && !can_admin?(current_user)
+
+ user_avatar = user.avatar_url(size: size, only_path: only_path)
+ user_avatar || default_avatar
end
def gravatar_icon(user_email = '', size = nil, scale = 2)
@@ -47,7 +49,7 @@ module AvatarsHelper
end
def default_avatar
- ActionController::Base.helpers.image_path('no_avatar.png')
+ ActionController::Base.helpers.image_path(DEFAULT_AVATAR_PATH)
end
def author_avatar(commit_or_event, options = {})
@@ -103,8 +105,8 @@ module AvatarsHelper
end
def avatar_without_link(resource, options = {})
- if resource.is_a?(User)
- user_avatar_without_link(options.merge(user: resource))
+ if resource.is_a?(Namespaces::UserNamespace)
+ user_avatar_without_link(options.merge(user: resource.first_owner))
elsif resource.is_a?(Group)
group_icon(resource, options.merge(class: 'avatar'))
end
@@ -157,4 +159,14 @@ module AvatarsHelper
source.name[0, 1].upcase
end
end
+
+ def blocked_or_unconfirmed?(user)
+ user.blocked? || !user.confirmed?
+ end
+
+ def can_admin?(user)
+ return false unless user
+
+ user.can_admin_all_resources?
+ end
end
diff --git a/app/helpers/bizible_helper.rb b/app/helpers/bizible_helper.rb
new file mode 100644
index 00000000000..970cc6558da
--- /dev/null
+++ b/app/helpers/bizible_helper.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module BizibleHelper
+ def bizible_enabled?
+ Feature.enabled?(:ecomm_instrumentation, type: :ops) &&
+ Gitlab.config.extra.has_key?('bizible') &&
+ Gitlab.config.extra.bizible.present? &&
+ Gitlab.config.extra.bizible == true
+ end
+end
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index 57da04b38cc..28cd61e10d9 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -17,7 +17,6 @@ module BoardsHelper
can_update: can_update?.to_s,
can_admin_list: can_admin_list?.to_s,
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,
labels_filter_base_path: build_issue_link_base,
@@ -128,10 +127,6 @@ module BoardsHelper
}
end
- def recent_boards_path
- recent_project_boards_path(@project) if current_board_parent.is_a?(Project)
- end
-
def serializer
CurrentBoardSerializer.new
end
diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb
index bb7226da74e..3f0379b1baa 100644
--- a/app/helpers/ci/pipeline_editor_helper.rb
+++ b/app/helpers/ci/pipeline_editor_helper.rb
@@ -12,6 +12,8 @@ module Ci
initial_branch = params[:branch_name]
latest_commit = project.repository.commit(initial_branch) || project.commit
commit_sha = latest_commit ? latest_commit.sha : ''
+ total_branches = project.repository_exists? ? project.repository.branch_count : 0
+
{
"ci-config-path": project.ci_config_path_or_default,
"ci-examples-help-page-path" => help_page_path('ci/examples/index'),
@@ -29,7 +31,7 @@ module Ci
"project-full-path" => project.full_path,
"project-namespace" => project.namespace.full_path,
"runner-help-page-path" => help_page_path('ci/runners/index'),
- "total-branches" => project.repository.branches.length,
+ "total-branches" => total_branches,
"yml-help-page-path" => help_page_path('ci/yaml/index')
}
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 93b6b4e8fe2..1475a26ca09 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -28,7 +28,8 @@ module ClustersHelper
clusters_empty_state_image: image_path('illustrations/empty-state/empty-state-clusters.svg'),
empty_state_help_text: clusterable.empty_state_help_text,
new_cluster_path: clusterable.new_path(tab: 'create'),
- can_add_cluster: clusterable.can_add_cluster?.to_s
+ can_add_cluster: clusterable.can_add_cluster?.to_s,
+ can_admin_cluster: clusterable.can_admin_cluster?.to_s
}
end
@@ -38,7 +39,8 @@ module ClustersHelper
empty_state_image: image_path('illustrations/empty-state/empty-state-agents.svg'),
project_path: clusterable.full_path,
add_cluster_path: clusterable.new_path(tab: 'add'),
- kas_address: Gitlab::Kas.external_url
+ kas_address: Gitlab::Kas.external_url,
+ gitlab_version: Gitlab.version_info
}.merge(js_clusters_list_data(clusterable))
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 7296560a450..c58a365b884 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -139,7 +139,7 @@ module GroupsHelper
{}
end
- def require_verification_for_group_creation_enabled?
+ def require_verification_for_namespace_creation_enabled?
# overridden in EE
false
end
@@ -204,7 +204,7 @@ module GroupsHelper
end
def group_url_error_message
- s_('GroupSettings|Please choose a group URL with no special characters or spaces.')
+ s_('GroupSettings|Choose a group path that does not start with a dash or end with a period. It can also contain alphanumeric characters and underscores.')
end
# Maps `jobs_to_be_done` values to option texts
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 4d81aeca37a..bd1571f3956 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -34,7 +34,7 @@ module IdeHelper
def enable_environments_guidance?
experiment(:in_product_guidance_environments_webide, project: @project) do |e|
- e.try { !has_dismissed_ide_environments_callout? }
+ e.candidate { !has_dismissed_ide_environments_callout? }
e.run
end
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index 230f80e20a5..f5ba978e860 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -228,10 +228,6 @@ module IntegrationsHelper
name: integration.to_param
}
end
-
- def vue_integration_form_enabled?
- Feature.enabled?(:vue_integration_form, current_user, default_enabled: :yaml)
- end
end
IntegrationsHelper.prepend_mod_with('IntegrationsHelper')
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
index 8b26b646fdd..1f225e9c0e5 100644
--- a/app/helpers/invite_members_helper.rb
+++ b/app/helpers/invite_members_helper.rb
@@ -6,7 +6,7 @@ module InviteMembersHelper
def can_invite_members_for_project?(project)
# do not use the can_admin_project_member? helper here due to structure of the view and how membership_locked?
# is leveraged for inviting groups
- Feature.enabled?(:invite_members_group_modal, project.group) && can?(current_user, :admin_project_member, project)
+ Feature.enabled?(:invite_members_group_modal, project.group, default_enabled: :yaml) && can?(current_user, :admin_project_member, project)
end
def invite_accepted_notice(member)
@@ -21,6 +21,11 @@ module InviteMembersHelper
end
def group_select_data(group)
+ # This should only be used for groups to load the invite group modal.
+ # For instance the invite groups modal should not call this from a project scope
+ # this is only to be called in scope of a group context as noted in this thread
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79036#note_821465513
+ # the group sharing in projects disabling is explained there as well
if group.root_ancestor.namespace_settings.prevent_sharing_groups_outside_hierarchy
{ groups_filter: 'descendant_groups', parent_id: group.root_ancestor.id }
else
@@ -28,6 +33,18 @@ module InviteMembersHelper
end
end
+ def common_invite_group_modal_data(source, member_class, is_project)
+ {
+ id: source.id,
+ name: source.name,
+ default_access_level: Gitlab::Access::GUEST,
+ invalid_groups: source.related_group_ids,
+ help_link: help_page_url('user/permissions'),
+ is_project: is_project,
+ access_levels: member_class.access_level_roles.to_json
+ }
+ end
+
def common_invite_modal_dataset(source)
dataset = {
id: source.id,
@@ -69,3 +86,5 @@ module InviteMembersHelper
projects.map { |project| { id: project.id, title: project.title } }
end
end
+
+InviteMembersHelper.prepend_mod_with('InviteMembersHelper')
diff --git a/app/helpers/issuables_description_templates_helper.rb b/app/helpers/issuables_description_templates_helper.rb
index 6c23f888823..a82a5ac0fb0 100644
--- a/app/helpers/issuables_description_templates_helper.rb
+++ b/app/helpers/issuables_description_templates_helper.rb
@@ -38,7 +38,13 @@ module IssuablesDescriptionTemplatesHelper
# Only local templates will be listed if licenses for inherited templates are not present
all_templates = all_templates.values.flatten.map { |tpl| tpl[:name] }.compact.uniq
- all_templates.find { |tmpl_name| tmpl_name == params[:issuable_template] }
+ template = all_templates.find { |tmpl_name| tmpl_name == params[:issuable_template] }
+
+ unless issuable.description.present?
+ template ||= all_templates.find { |tmpl_name| tmpl_name.casecmp?('default') }
+ end
+
+ template
end
def available_service_desk_templates_for(project)
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 53a7487741e..ec1f8ca5f00 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -201,10 +201,7 @@ module IssuablesHelper
count = issuables_count_for_state(issuable_type, state)
if count != -1
- html << " " << content_tag(:span,
- format_count(issuable_type, count, Gitlab::IssuablesCountForState::THRESHOLD),
- class: 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex'
- )
+ html << " " << gl_badge_tag(format_count(issuable_type, count, Gitlab::IssuablesCountForState::THRESHOLD), { variant: :muted, size: :sm }, { class: "gl-tab-counter-badge gl-display-none gl-sm-display-inline-flex" })
end
html.html_safe
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 5aa2aca37f3..8e7f5060412 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -63,7 +63,7 @@ module IssuesHelper
end
def issue_hidden?(issue)
- Feature.enabled?(:ban_user_feature_flag) && issue.hidden?
+ Feature.enabled?(:ban_user_feature_flag, default_enabled: :yaml) && issue.hidden?
end
def hidden_issue_icon(issue)
@@ -199,6 +199,7 @@ module IssuesHelper
calendar_path: url_for(safe_params.merge(calendar_url_options)),
empty_state_svg_path: image_path('illustrations/issues.svg'),
full_path: namespace.full_path,
+ initial_sort: current_user&.user_preference&.issues_sort,
is_anonymous_search_disabled: Feature.enabled?(:disable_anonymous_search, type: :ops).to_s,
is_issue_repositioning_disabled: issue_repositioning_disabled?.to_s,
is_signed_in: current_user.present?.to_s,
@@ -231,10 +232,10 @@ module IssuesHelper
)
end
- def group_issues_list_data(group, current_user, issues, projects)
+ def group_issues_list_data(group, current_user)
common_issues_list_data(group, current_user).merge(
- has_any_issues: issues.to_a.any?.to_s,
- has_any_projects: any_projects?(projects).to_s
+ has_any_issues: @has_issues.to_s,
+ has_any_projects: @has_projects.to_s
)
end
diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb
index 6330b8fc829..7dfd9ed47e3 100644
--- a/app/helpers/learn_gitlab_helper.rb
+++ b/app/helpers/learn_gitlab_helper.rb
@@ -33,8 +33,8 @@ module LearnGitlabHelper
actor: current_user,
sticky_to: project.namespace
) do |e|
- e.use { urls_to_use = action_urls }
- e.try { urls_to_use = new_action_urls(project) }
+ e.control { urls_to_use = action_urls }
+ e.candidate { urls_to_use = new_action_urls(project) }
end
urls_to_use.to_h do |action, url|
diff --git a/app/helpers/listbox_helper.rb b/app/helpers/listbox_helper.rb
new file mode 100644
index 00000000000..d24680bc0b0
--- /dev/null
+++ b/app/helpers/listbox_helper.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module ListboxHelper
+ DROPDOWN_CONTAINER_CLASSES = %w[dropdown b-dropdown gl-new-dropdown btn-group js-redirect-listbox].freeze
+ DROPDOWN_BUTTON_CLASSES = %w[btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle].freeze
+ DROPDOWN_INNER_CLASS = 'gl-new-dropdown-button-text'
+ DROPDOWN_ICON_CLASS = 'gl-button-icon dropdown-chevron gl-icon'
+
+ # Creates a listbox component with redirect behavior.
+ #
+ # Use this for migrating existing deprecated dropdowns to become
+ # Pajamas-compliant. New features should use Vue components directly instead.
+ #
+ # The `items` parameter must be an array of hashes, each with `value`, `text`
+ # and `href` keys, where `value` is a unique identifier for the item (e.g.,
+ # the sort key), `text` is the user-facing string for the item, and `href` is
+ # the path to redirect to when that item is selected.
+ #
+ # The `selected` parameter is the currently selected `value`, and must
+ # correspond to one of the `items`, or be `nil`. When `selected.nil?`, the first item is selected.
+ #
+ # The final parameter `html_options` applies arbitrary attributes to the
+ # returned tag. Some of these are passed to the underlying Vue component as
+ # props, e.g., to right-align the menu of items, add `data: { right: true }`.
+ #
+ # Examples:
+ # # Create a listbox with two items, with the first item selected
+ # - items = [{ value: 'foo', text: 'Name, ascending', href: '/foo' },
+ # { value: 'bar', text: 'Name, descending', href: '/bar' }]
+ # = gl_redirect_listbox_tag items, 'foo'
+ #
+ # # Create the same listbox, right-align the menu and add margin styling
+ # = gl_redirect_listbox_tag items, 'foo', class: 'gl-ml-3', data: { right: true }
+ def gl_redirect_listbox_tag(items, selected, html_options = {})
+ # Add script tag for app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js
+ content_for :page_specific_javascripts do
+ webpack_bundle_tag 'redirect_listbox'
+ end
+
+ selected ||= items.first[:value]
+ selected_option = items.find { |opt| opt[:value] == selected }
+ raise ArgumentError, "cannot find #{selected} in #{items}" unless selected_option
+
+ button = button_tag(type: :button, class: DROPDOWN_BUTTON_CLASSES) do
+ content_tag(:span, selected_option[:text], class: DROPDOWN_INNER_CLASS) +
+ sprite_icon('chevron-down', css_class: DROPDOWN_ICON_CLASS)
+ end
+
+ classes = [*DROPDOWN_CONTAINER_CLASSES, *html_options[:class]]
+ data = html_options.fetch(:data, {}).merge(items: items, selected: selected)
+
+ content_tag(:div, button, html_options.merge({
+ class: classes,
+ data: data
+ }))
+ end
+end
diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb
index 24102a90a3b..9420c95c9ce 100644
--- a/app/helpers/nav/top_nav_helper.rb
+++ b/app/helpers/nav/top_nav_helper.rb
@@ -253,14 +253,18 @@ module Nav
end
def projects_submenu
- # These project links come from `app/views/layouts/nav/projects_dropdown/_show.html.haml`
builder = ::Gitlab::Nav::TopNavMenuBuilder.new
+ projects_submenu_items(builder: builder)
+ builder.build
+ end
+
+ def projects_submenu_items(builder:)
+ # These project links come from `app/views/layouts/nav/projects_dropdown/_show.html.haml`
builder.add_primary_menu_item(id: 'your', title: _('Your projects'), href: dashboard_projects_path)
builder.add_primary_menu_item(id: 'starred', title: _('Starred projects'), href: starred_dashboard_projects_path)
builder.add_primary_menu_item(id: 'explore', title: _('Explore projects'), href: explore_root_path)
builder.add_primary_menu_item(id: 'topics', title: _('Explore topics'), href: topics_explore_projects_path)
builder.add_secondary_menu_item(id: 'create', title: _('Create new project'), href: new_project_path)
- builder.build
end
def groups_submenu
diff --git a/app/helpers/projects/cluster_agents_helper.rb b/app/helpers/projects/cluster_agents_helper.rb
index e3027759d65..43d520d0eab 100644
--- a/app/helpers/projects/cluster_agents_helper.rb
+++ b/app/helpers/projects/cluster_agents_helper.rb
@@ -5,6 +5,7 @@ module Projects::ClusterAgentsHelper
{
activity_empty_state_image: image_path('illustrations/empty-state/empty-state-agents.svg'),
agent_name: agent_name,
+ can_admin_vulnerability: can?(current_user, :admin_vulnerability, project).to_s,
empty_state_svg_path: image_path('illustrations/operations-dashboard_empty.svg'),
project_path: project.full_path
}
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 084c962d34c..6098ef63ec3 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -54,31 +54,37 @@ module ProjectsHelper
default_opts = { avatar: true, name: true, title: ":name" }
opts = default_opts.merge(opts)
+ return "(deleted)" unless author
+
data_attrs = {
user_id: author.id,
username: author.username,
name: author.name
}
- return "(deleted)" unless author
+ inject_classes = ["author-link", opts[:extra_class]]
- author_html = []
+ if opts[:name]
+ inject_classes.concat(["js-user-link", opts[:mobile_classes]])
+ else
+ inject_classes.append( "has-tooltip" )
+ end
+
+ inject_classes = inject_classes.compact.join(" ")
+ author_html = []
# Build avatar image tag
author_html << link_to_member_avatar(author, opts) if opts[:avatar]
-
# Build name span tag
author_html << author_content_tag(author, opts) if opts[:name]
-
author_html << capture(&block) if block
-
author_html = author_html.join.html_safe
if opts[:name]
- link_to(author_html, user_path(author), class: "author-link js-user-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}", data: data_attrs).html_safe
+ link_to(author_html, user_path(author), class: inject_classes, data: data_attrs).html_safe
else
title = opts[:title].sub(":name", sanitize(author.name))
- link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body', qa_selector: 'assignee_link' }).html_safe
+ link_to(author_html, user_path(author), class: inject_classes, title: title, data: { container: 'body', qa_selector: 'assignee_link' }).html_safe
end
end
@@ -424,6 +430,18 @@ module ProjectsHelper
end
end
+ def import_from_bitbucket_message
+ link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("integration/bitbucket") }
+
+ str = if current_user.admin?
+ 'ImportProjects|To enable importing projects from Bitbucket, as administrator you need to configure %{link_start}OAuth integration%{link_end}'
+ else
+ 'ImportProjects|To enable importing projects from Bitbucket, ask your GitLab administrator to configure %{link_start}OAuth integration%{link_end}'
+ end
+
+ s_(str).html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ end
+
private
def tab_ability_map
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 6efede8d565..5b596c328d1 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -164,6 +164,23 @@ module SearchHelper
options
end
+ # search_context exposes a bit too much data to the frontend, this controls what data we share and when.
+ def header_search_context
+ {}.tap do |hash|
+ hash[:group] = { id: search_context.group.id, name: search_context.group.name } if search_context.for_group?
+ hash[:group_metadata] = search_context.group_metadata if search_context.for_group?
+
+ hash[:project] = { id: search_context.project.id, name: search_context.project.name } if search_context.for_project?
+ hash[:project_metadata] = search_context.project_metadata if search_context.for_project?
+
+ hash[:scope] = search_context.scope if search_context.for_project? || search_context.for_group?
+ hash[:code_search] = search_context.code_search? if search_context.for_project? || search_context.for_group?
+
+ hash[:ref] = search_context.ref if can?(current_user, :download_code, search_context.project)
+ hash[:for_snippets] = search_context.for_snippets?
+ end
+ end
+
private
# Autocomplete results for various settings pages
diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb
index a60143db739..34ba66db444 100644
--- a/app/helpers/storage_helper.rb
+++ b/app/helpers/storage_helper.rb
@@ -23,4 +23,42 @@ module StorageHelper
_("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters
end
+
+ def storage_enforcement_banner_info(namespace)
+ return if namespace.paid?
+ return unless namespace.storage_enforcement_date && namespace.storage_enforcement_date >= Date.today
+ return if user_dismissed_storage_enforcement_banner?(namespace)
+
+ {
+ text: html_escape_once(s_("UsageQuota|From %{storage_enforcement_date} storage limits will apply to this namespace. " \
+ "View and manage your usage in %{strong_start}Group Settings &gt; Usage quotas%{strong_end}.")).html_safe %
+ { storage_enforcement_date: namespace.storage_enforcement_date, strong_start: "<strong>".html_safe, strong_end: "</strong>".html_safe },
+ variant: 'warning',
+ callouts_path: group_callouts_path,
+ callouts_feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace),
+ learn_more_link: link_to(_('Learn more.'), help_page_path('/'), rel: 'noopener noreferrer', target: '_blank') # TBD: https://gitlab.com/gitlab-org/gitlab/-/issues/350632
+ }
+ end
+
+ private
+
+ def storage_enforcement_banner_user_callouts_feature_name(namespace)
+ "storage_enforcement_banner_#{storage_enforcement_banner_threshold(namespace)}_enforcement_threshold"
+ end
+
+ def storage_enforcement_banner_threshold(namespace)
+ days_to_enforcement_date = (namespace.storage_enforcement_date - Date.today)
+
+ return :first if days_to_enforcement_date > 30
+ return :second if days_to_enforcement_date > 15 && days_to_enforcement_date <= 30
+ return :third if days_to_enforcement_date > 7 && days_to_enforcement_date <= 15
+ return :fourth if days_to_enforcement_date > 0 && days_to_enforcement_date <= 7
+ end
+
+ def user_dismissed_storage_enforcement_banner?(namespace)
+ return false unless current_user
+
+ current_user.dismissed_callout_for_group?(feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace),
+ group: namespace)
+ end
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index f2e1d158c2d..b45ce10a2f6 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -42,7 +42,8 @@ module SystemNoteHelper
'cloned' => 'documents',
'issue_type' => 'pencil-square',
'attention_requested' => 'user',
- 'attention_request_removed' => 'user'
+ 'attention_request_removed' => 'user',
+ 'contact' => 'users'
}.freeze
def system_note_icon_name(note)
diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb
index 2efc3f27dc7..dbbe7069ca4 100644
--- a/app/helpers/tab_helper.rb
+++ b/app/helpers/tab_helper.rb
@@ -33,7 +33,7 @@ module TabHelper
#
def gl_tab_link_to(name = nil, options = {}, html_options = {}, &block)
link_classes = %w[nav-link gl-tab-nav-item]
- active_link_classes = %w[active gl-tab-nav-item-active gl-tab-nav-item-active-indigo]
+ active_link_classes = %w[active gl-tab-nav-item-active]
if block_given?
# Shift params to skip the omitted "name" param
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
index 86289ec8ed2..8216144c04c 100644
--- a/app/helpers/tags_helper.rb
+++ b/app/helpers/tags_helper.rb
@@ -6,12 +6,6 @@ module TagsHelper
end
def filter_tags_path(options = {})
- exist_opts = {
- search: params[:search],
- sort: params[:sort]
- }
-
- options = exist_opts.merge(options)
project_tags_path(@project, @id, options)
end
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 4437f309a9c..23a9601aed7 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -175,6 +175,21 @@ module TreeHelper
}
end
+ def fork_modal_options(project, ref, path, blob)
+ if show_edit_button?({ blob: blob })
+ fork_path = fork_and_edit_path(project, ref, path)
+ fork_modal_id = "modal-confirm-fork-edit"
+ elsif show_web_ide_button?
+ fork_path = ide_fork_and_edit_path(project, ref, path)
+ fork_modal_id = "modal-confirm-fork-webide"
+ end
+
+ {
+ fork_path: fork_path,
+ fork_modal_id: fork_modal_id
+ }
+ end
+
def web_ide_button_data(options = {})
{
project_path: project_to_use.full_path,
diff --git a/app/helpers/users/group_callouts_helper.rb b/app/helpers/users/group_callouts_helper.rb
index b66c7f9f821..0aa4eb89499 100644
--- a/app/helpers/users/group_callouts_helper.rb
+++ b/app/helpers/users/group_callouts_helper.rb
@@ -3,6 +3,7 @@
module Users
module GroupCalloutsHelper
INVITE_MEMBERS_BANNER = 'invite_members_banner'
+ APPROACHING_SEAT_COUNT_THRESHOLD = 'approaching_seat_count_threshold'
def show_invite_banner?(group)
Ability.allowed?(current_user, :admin_group, group) &&
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index c64c2ab35fb..1247f9ae260 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -101,6 +101,7 @@ module UsersHelper
badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin?
badges << { text: s_('AdminUsers|External'), variant: 'secondary' } if user.external?
badges << { text: s_("AdminUsers|It's you!"), variant: 'muted' } if current_user == user
+ badges << { text: s_("AdminUsers|Locked"), variant: 'warning' } if user.access_locked?
end
end
@@ -124,7 +125,7 @@ module UsersHelper
end
def ban_feature_available?
- Feature.enabled?(:ban_user_feature_flag)
+ Feature.enabled?(:ban_user_feature_flag, default_enabled: :yaml)
end
def confirm_user_data(user)
@@ -171,6 +172,10 @@ module UsersHelper
}
end
+ def display_public_email?(user)
+ user.public_email.present?
+ end
+
private
def admin_users_paths
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index b64e6c59817..06ff18ca409 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -4,6 +4,7 @@ class ApplicationRecord < ActiveRecord::Base
include DatabaseReflection
include Transactions
include LegacyBulkInsert
+ include CrossDatabaseModification
self.abstract_class = true
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 3c9f7c4dd7f..02fbf0f855e 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -563,6 +563,12 @@ class ApplicationSetting < ApplicationRecord
presence: true, length: { maximum: 255 },
if: :sentry_enabled?
+ validates :users_get_by_id_limit,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :users_get_by_id_limit_allowlist,
+ length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
+ allow_nil: false
+
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 25198178f69..415f0b35f3a 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -69,7 +69,9 @@ module ApplicationSettingImplementation
domain_allowlist: Settings.gitlab['domain_allowlist'],
dsa_key_restriction: 0,
ecdsa_key_restriction: 0,
+ ecdsa_sk_key_restriction: 0,
ed25519_key_restriction: 0,
+ ed25519_sk_key_restriction: 0,
eks_access_key_id: nil,
eks_account_id: nil,
eks_integration_enabled: false,
@@ -229,7 +231,9 @@ module ApplicationSettingImplementation
rate_limiting_response_text: nil,
whats_new_variant: 0,
user_deactivation_emails_enabled: true,
- user_email_lookup_limit: 60
+ user_email_lookup_limit: 60,
+ users_get_by_id_limit: 300,
+ users_get_by_id_limit_allowlist: []
}
end
@@ -332,6 +336,14 @@ module ApplicationSettingImplementation
self.notes_create_limit_allowlist = strings_to_array(values).map(&:downcase)
end
+ def users_get_by_id_limit_allowlist_raw
+ array_to_string(self.users_get_by_id_limit_allowlist)
+ end
+
+ def users_get_by_id_limit_allowlist_raw=(values)
+ self.users_get_by_id_limit_allowlist = strings_to_array(values).map(&:downcase)
+ end
+
def asset_proxy_whitelist=(values)
values = strings_to_array(values) if values.is_a?(String)
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 35c4e08730e..8e8e9389e2d 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -69,8 +69,7 @@ class AuditEvent < ApplicationRecord
end
def author
- lazy_author&.itself.presence ||
- ::Gitlab::Audit::NullAuthor.for(author_id, (self[:author_name] || details[:author_name]))
+ lazy_author&.itself.presence || default_author_value
end
def lazy_author
@@ -98,7 +97,7 @@ class AuditEvent < ApplicationRecord
end
def default_author_value
- ::Gitlab::Audit::NullAuthor.for(author_id, (self[:author_name] || details[:author_name]))
+ ::Gitlab::Audit::NullAuthor.for(author_id, self)
end
def parallel_persist
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 5731d38abe4..cc7758d9674 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -178,6 +178,10 @@ class Blob < SimpleDelegator
end
end
+ def symlink?
+ mode == MODE_SYMLINK
+ end
+
def extension
@extension ||= extname.downcase.delete('.')
end
diff --git a/app/models/board.rb b/app/models/board.rb
index 7938819b6e4..8a7330e7320 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Board < ApplicationRecord
+ RECENT_BOARDS_SIZE = 4
+
belongs_to :group
belongs_to :project
diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb
index ce3faf3546b..d5cbbb96134 100644
--- a/app/models/ci/namespace_mirror.rb
+++ b/app/models/ci/namespace_mirror.rb
@@ -6,7 +6,7 @@ module Ci
class NamespaceMirror < ApplicationRecord
belongs_to :namespace
- scope :contains_namespace, -> (id) do
+ scope :by_group_and_descendants, -> (id) do
where('traversal_ids @> ARRAY[?]::int[]', id)
end
@@ -32,7 +32,7 @@ module Ci
private
def sync_children_namespaces!(namespace_id, traversal_ids)
- contains_namespace(namespace_id)
+ by_group_and_descendants(namespace_id)
.where.not(namespace_id: namespace_id)
.update_all(
"traversal_ids = ARRAY[#{sanitize_sql(traversal_ids.join(','))}]::int[] || traversal_ids[array_position(traversal_ids, #{sanitize_sql(namespace_id)}) + 1:]"
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 00d331df4c3..a1311b8555f 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -40,6 +40,10 @@ module Ci
# https://gitlab.com/gitlab-org/gitlab/-/issues/259010
attr_accessor :merged_yaml
+ # This is used to retain access to the method defined by `Ci::HasRef`
+ # before being overridden in this class.
+ alias_method :jobs_git_ref, :git_ref
+
belongs_to :project, inverse_of: :all_pipelines
belongs_to :user
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
@@ -72,8 +76,6 @@ module Ci
has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent
has_many :variables, class_name: 'Ci::PipelineVariable'
- has_many :deployments, through: :builds
- has_many :environments, -> { distinct.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338658') }, through: :deployments
has_many :latest_builds, -> { latest.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build'
has_many :downloadable_artifacts, -> do
not_expired.or(where_exists(::Ci::Pipeline.artifacts_locked.where('ci_pipelines.id = ci_builds.commit_id'))).downloadable.with_job
@@ -352,7 +354,7 @@ module Ci
#
# ref - The name (or names) of the branch(es)/tag(s) to limit the list of
# pipelines to.
- # sha - The commit SHA (or mutliple SHAs) to limit the list of pipelines to.
+ # sha - The commit SHA (or multiple SHAs) to limit the list of pipelines to.
# limit - This limits a backlog search, default to 100.
def self.newest_first(ref: nil, sha: nil, limit: 100)
relation = order(id: :desc)
@@ -1163,7 +1165,11 @@ module Ci
end
def merge_request?
- merge_request_id.present?
+ if Feature.enabled?(:ci_pipeline_merge_request_presence_check, default_enabled: :yaml)
+ merge_request_id.present? && merge_request
+ else
+ merge_request_id.present?
+ end
end
def external_pull_request?
@@ -1284,18 +1290,6 @@ module Ci
end
end
- def create_deployment_in_separate_transaction?
- strong_memoize(:create_deployment_in_separate_transaction) do
- ::Feature.enabled?(:create_deployment_in_separate_transaction, project, default_enabled: :yaml)
- end
- end
-
- def use_variables_builder_definitions?
- strong_memoize(:use_variables_builder_definitions) do
- ::Feature.enabled?(:ci_use_variables_builder_definitions, project, default_enabled: :yaml)
- end
- end
-
private
def add_message(severity, content)
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 809c245d2b9..11150e839a3 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -13,7 +13,7 @@ module Ci
include TaggableQueries
include Presentable
- add_authentication_token_field :token, encrypted: :optional
+ add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration, expiration_enforced?: :token_expiration_enforced?
enum access_level: {
not_protected: 0,
@@ -67,7 +67,7 @@ module Ci
has_many :builds
has_many :runner_projects, inverse_of: :runner, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :projects, through: :runner_projects
+ has_many :projects, through: :runner_projects, disable_joins: true
has_many :runner_namespaces, inverse_of: :runner, autosave: true
has_many :groups, through: :runner_namespaces, disable_joins: true
@@ -101,7 +101,7 @@ module Ci
}
scope :belonging_to_group_or_project_descendants, -> (group_id) {
- group_ids = Ci::NamespaceMirror.contains_namespace(group_id).select(:namespace_id)
+ group_ids = Ci::NamespaceMirror.by_group_and_descendants(group_id).select(:namespace_id)
project_ids = Ci::ProjectMirror.by_namespace_id(group_ids).select(:project_id)
group_runners = joins(:runner_namespaces).where(ci_runner_namespaces: { namespace_id: group_ids })
@@ -119,30 +119,6 @@ module Ci
.where(ci_runner_namespaces: { namespace_id: group_self_and_ancestors_ids })
}
- # deprecated
- # split this into: belonging_to_group & belonging_to_group_and_ancestors
- scope :legacy_belonging_to_group, -> (group_id, include_ancestors: false) {
- groups = ::Group.where(id: group_id)
- groups = groups.self_and_ancestors if include_ancestors
-
- joins(:runner_namespaces)
- .where(ci_runner_namespaces: { namespace_id: groups })
- .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433')
- }
-
- # deprecated
- scope :legacy_belonging_to_group_or_project, -> (group_id, project_id) {
- groups = ::Group.where(id: group_id)
-
- group_runners = joins(:runner_namespaces).where(ci_runner_namespaces: { namespace_id: groups })
- project_runners = joins(:runner_projects).where(ci_runner_projects: { project_id: project_id })
-
- union_sql = ::Gitlab::SQL::Union.new([group_runners, project_runners]).to_sql
-
- from("(#{union_sql}) #{table_name}")
- .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433')
- }
-
scope :belonging_to_parent_group_of_project, -> (project_id) {
raise ArgumentError, "only 1 project_id allowed for performance reasons" unless project_id.is_a?(Integer)
@@ -152,11 +128,23 @@ module Ci
}
scope :owned_or_instance_wide, -> (project_id) do
+ project = project_id.respond_to?(:shared_runners) ? project_id : Project.find(project_id)
+
from_union(
[
belonging_to_project(project_id),
- belonging_to_parent_group_of_project(project_id),
- instance_type
+ project.group_runners_enabled? ? belonging_to_parent_group_of_project(project_id) : nil,
+ project.shared_runners
+ ].compact,
+ remove_duplicates: false
+ )
+ end
+
+ scope :group_or_instance_wide, -> (group) do
+ from_union(
+ [
+ belonging_to_group_and_ancestors(group.id),
+ group.shared_runners
],
remove_duplicates: false
)
@@ -179,6 +167,8 @@ module Ci
scope :order_contacted_at_desc, -> { order(contacted_at: :desc) }
scope :order_created_at_asc, -> { order(created_at: :asc) }
scope :order_created_at_desc, -> { order(created_at: :desc) }
+ scope :order_token_expires_at_asc, -> { order(token_expires_at: :asc) }
+ scope :order_token_expires_at_desc, -> { order(token_expires_at: :desc) }
scope :with_tags, -> { preload(:tags) }
validate :tag_constraints
@@ -210,7 +200,9 @@ module Ci
validates :config, json_schema: { filename: 'ci_runner_config' }
- validates :maintainer_note, length: { maximum: 255 }
+ validates :maintenance_note, length: { maximum: 255 }
+
+ alias_attribute :maintenance_note, :maintainer_note
# Searches for runners matching the given query.
#
@@ -247,6 +239,10 @@ module Ci
order_contacted_at_desc
when 'created_at_asc'
order_created_at_asc
+ when 'token_expires_at_asc'
+ order_token_expires_at_asc
+ when 'token_expires_at_desc'
+ order_token_expires_at_desc
else
order_created_at_desc
end
@@ -360,27 +356,12 @@ 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)
- 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
-
def match_build_if_online?(build)
- active? && online? && can_pick?(build)
+ active? && online? && matches_build?(build)
end
def only_for?(project)
- ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do
- projects == [project]
- end
+ !runner_projects.where.not(project_id: project.id).exists?
end
def short_sha
@@ -388,8 +369,6 @@ module Ci
end
def tag_list
- return super unless Feature.enabled?(:ci_preload_runner_tags, default_enabled: :yaml)
-
if tags.loaded?
tags.map(&:name)
else
@@ -455,6 +434,10 @@ module Ci
tick_runner_queue if matches_build?(build)
end
+ def matches_build?(build)
+ runner_matcher.matches?(build.build_matcher)
+ end
+
def uncached_contacted_at
read_attribute(:contacted_at)
end
@@ -465,6 +448,21 @@ module Ci
end
end
+ def compute_token_expiration
+ case runner_type
+ when 'instance_type'
+ compute_token_expiration_instance
+ when 'group_type'
+ compute_token_expiration_group
+ when 'project_type'
+ compute_token_expiration_project
+ end
+ end
+
+ def self.token_expiration_enforced?
+ Feature.enabled?(:enforce_runner_token_expires_at, default_enabled: :yaml)
+ end
+
private
EXECUTOR_NAME_TO_TYPES = {
@@ -484,6 +482,20 @@ module Ci
EXECUTOR_TYPE_TO_NAMES = EXECUTOR_NAME_TO_TYPES.invert.freeze
+ def compute_token_expiration_instance
+ return unless expiration_interval = Gitlab::CurrentSettings.runner_token_expiration_interval
+
+ expiration_interval.seconds.from_now
+ end
+
+ def compute_token_expiration_group
+ ::Group.where(id: runner_namespaces.map(&:namespace_id)).map(&:effective_runner_token_expiration_interval).compact.min&.from_now
+ end
+
+ def compute_token_expiration_project
+ Project.where(id: runner_projects.map(&:project_id)).map(&:effective_runner_token_expiration_interval).compact.min&.from_now
+ end
+
def cleanup_runner_queue
Gitlab::Redis::SharedState.with do |redis|
redis.del(runner_queue_key)
@@ -510,12 +522,6 @@ 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
-
def no_projects
if runner_projects.any?
errors.add(:runner, 'cannot have projects assigned')
@@ -539,10 +545,6 @@ module Ci
errors.add(:runner, 'needs to be assigned to exactly one group')
end
end
-
- def matches_build?(build)
- runner_matcher.matches?(build.build_matcher)
- end
end
end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 33cd5de3518..07eaca87fad 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.36.0'
+ VERSION = '0.37.1'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/commit.rb b/app/models/commit.rb
index f0c5f3c2d12..5293bfcf1ab 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -513,9 +513,7 @@ class Commit
# We don't want to do anything for `Commit` model, so this is empty.
end
- # WIP is deprecated in favor of Draft. Currently both options are supported
- # https://gitlab.com/gitlab-org/gitlab/-/issues/227426
- DRAFT_REGEX = /\A\s*#{Regexp.union(Gitlab::Regex.merge_request_wip, Gitlab::Regex.merge_request_draft)}|(fixup!|squash!)\s/.freeze
+ DRAFT_REGEX = /\A\s*#{Gitlab::Regex.merge_request_draft}|(fixup!|squash!)\s/.freeze
def work_in_progress?
!!(title =~ DRAFT_REGEX)
diff --git a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb
index 324e0fb57cb..7cc4bc569d3 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb
@@ -1,5 +1,4 @@
# frozen_string_literal: true
-
module Analytics
module CycleAnalytics
module StageEventModel
@@ -16,12 +15,39 @@ module Analytics
scope :authored, ->(user) { where(author_id: user) }
scope :with_milestone_id, ->(milestone_id) { where(milestone_id: milestone_id) }
scope :end_event_is_not_happened_yet, -> { where(end_event_timestamp: nil) }
+ scope :order_by_end_event, -> (direction) do
+ # ORDER BY end_event_timestamp, merge_request_id/issue_id, start_event_timestamp
+ # start_event_timestamp must be included in the ORDER BY clause for the duration
+ # calculation to work: SELECT end_event_timestamp - start_event_timestamp
+ keyset_order(
+ :end_event_timestamp => { order_expression: arel_order(arel_table[:end_event_timestamp], direction), distinct: false },
+ issuable_id_column => { order_expression: arel_order(arel_table[issuable_id_column], direction), distinct: true },
+ :start_event_timestamp => { order_expression: arel_order(arel_table[:start_event_timestamp], direction), distinct: false }
+ )
+ end
+ scope :order_by_duration, -> (direction) do
+ # ORDER BY EXTRACT('epoch', end_event_timestamp - start_event_timestamp)
+ duration = Arel::Nodes::Subtraction.new(
+ arel_table[:end_event_timestamp],
+ arel_table[:start_event_timestamp]
+ )
+ duration_in_seconds = Arel::Nodes::Extract.new(duration, :epoch)
+
+ keyset_order(
+ :total_time => { order_expression: arel_order(duration_in_seconds, direction), distinct: false, sql_type: 'double precision' },
+ issuable_id_column => { order_expression: arel_order(arel_table[issuable_id_column], direction), distinct: true }
+ )
+ end
end
def issuable_id
attributes[self.class.issuable_id_column.to_s]
end
+ def total_time
+ read_attribute(:total_time) || (end_event_timestamp - start_event_timestamp).to_f
+ end
+
class_methods do
def upsert_data(data)
upsert_values = data.map do |row|
@@ -68,6 +94,18 @@ module Analytics
result = connection.execute(query)
result.cmd_tuples
end
+
+ def keyset_order(column_definition_options)
+ built_definitions = column_definition_options.map do |attribute_name, column_options|
+ ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(attribute_name: attribute_name, **column_options)
+ end
+
+ order(Gitlab::Pagination::Keyset::Order.build(built_definitions))
+ end
+
+ def arel_order(arel_node, direction)
+ direction.to_sym == :desc ? arel_node.desc : arel_node.asc
+ end
end
end
end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index ed3b422251f..88b7bb89b89 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -11,26 +11,9 @@ module Ci
#
def scoped_variables(environment: expanded_environment_name, dependencies: true)
track_duration do
- variables = pipeline.variables_builder.scoped_variables(self, environment: environment, dependencies: dependencies)
-
- next variables if pipeline.use_variables_builder_definitions?
-
- variables.concat(project.predefined_variables)
- variables.concat(pipeline.predefined_variables)
- variables.concat(runner.predefined_variables) if runnable? && runner
- variables.concat(kubernetes_variables)
- variables.concat(deployment_variables(environment: environment))
- variables.concat(yaml_variables)
- variables.concat(user_variables)
- variables.concat(dependency_variables) if dependencies
- variables.concat(secret_instance_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)
- variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule
-
- variables
+ pipeline
+ .variables_builder
+ .scoped_variables(self, environment: environment, dependencies: dependencies)
end
end
@@ -60,29 +43,5 @@ module Ci
scoped_variables(environment: nil, dependencies: false)
end
end
-
- def user_variables
- pipeline.variables_builder.user_variables(user)
- end
-
- def kubernetes_variables
- pipeline.variables_builder.kubernetes_variables(self)
- end
-
- def deployment_variables(environment:)
- pipeline.variables_builder.deployment_variables(job: self, environment: environment)
- end
-
- def secret_instance_variables
- pipeline.variables_builder.secret_instance_variables(ref: git_ref)
- end
-
- def secret_group_variables(environment: expanded_environment_name)
- pipeline.variables_builder.secret_group_variables(environment: environment, ref: git_ref)
- end
-
- def secret_project_variables(environment: expanded_environment_name)
- pipeline.variables_builder.secret_project_variables(environment: environment, ref: git_ref)
- end
end
end
diff --git a/app/models/concerns/ci/has_variable.rb b/app/models/concerns/ci/has_variable.rb
index 7309469c77e..3b437fbba16 100644
--- a/app/models/concerns/ci/has_variable.rb
+++ b/app/models/concerns/ci/has_variable.rb
@@ -31,7 +31,24 @@ module Ci
end
def to_runner_variable
+ var_cache_key = to_runner_variable_cache_key
+
+ return uncached_runner_variable unless var_cache_key
+
+ ::Gitlab::SafeRequestStore.fetch(var_cache_key) { uncached_runner_variable }
+ end
+
+ private
+
+ def uncached_runner_variable
{ key: key, value: value, public: false, file: file? }
end
+
+ def to_runner_variable_cache_key
+ return unless persisted?
+
+ variable_id = read_attribute(self.class.primary_key)
+ "#{self.class}#to_runner_variable:#{variable_id}:#{key}"
+ end
end
end
diff --git a/app/models/concerns/cross_database_modification.rb b/app/models/concerns/cross_database_modification.rb
new file mode 100644
index 00000000000..85645e482f6
--- /dev/null
+++ b/app/models/concerns/cross_database_modification.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+module CrossDatabaseModification
+ extend ActiveSupport::Concern
+
+ class TransactionStackTrackRecord
+ DEBUG_STACK = Rails.env.test? && ENV['DEBUG_GITLAB_TRANSACTION_STACK']
+ LOG_FILENAME = Rails.root.join("log", "gitlab_transaction_stack.log")
+
+ EXCLUDE_DEBUG_TRACE = %w[
+ lib/gitlab/database/query_analyzer
+ app/models/concerns/cross_database_modification.rb
+ ].freeze
+
+ def self.logger
+ @logger ||= Logger.new(LOG_FILENAME, formatter: ->(_, _, _, msg) { Gitlab::Json.dump(msg) + "\n" })
+ end
+
+ def self.log_gitlab_transactions_stack(action: nil, example: nil)
+ return unless DEBUG_STACK
+
+ message = "gitlab_transactions_stack performing #{action}"
+ message += " in example #{example}" if example
+
+ cleaned_backtrace = Gitlab::BacktraceCleaner.clean_backtrace(caller)
+ .reject { |line| EXCLUDE_DEBUG_TRACE.any? { |exclusion| line.include?(exclusion) } }
+ .first(5)
+
+ logger.warn({
+ message: message,
+ action: action,
+ gitlab_transactions_stack: ::ApplicationRecord.gitlab_transactions_stack,
+ caller: cleaned_backtrace,
+ thread: Thread.current.object_id
+ })
+ end
+
+ def initialize(subject, gitlab_schema)
+ @subject = subject
+ @gitlab_schema = gitlab_schema
+ @subject.gitlab_transactions_stack.push(gitlab_schema)
+
+ self.class.log_gitlab_transactions_stack(action: :after_push)
+ end
+
+ def done!
+ unless @done
+ @done = true
+
+ self.class.log_gitlab_transactions_stack(action: :before_pop)
+ @subject.gitlab_transactions_stack.pop
+ end
+
+ true
+ end
+
+ def trigger_transactional_callbacks?
+ false
+ end
+
+ def before_committed!
+ end
+
+ def rolledback!(force_restore_state: false, should_run_callbacks: true)
+ done!
+ end
+
+ def committed!(should_run_callbacks: true)
+ done!
+ end
+ end
+
+ included do
+ private_class_method :gitlab_schema
+ end
+
+ class_methods do
+ def gitlab_transactions_stack
+ Thread.current[:gitlab_transactions_stack] ||= []
+ end
+
+ def transaction(**options, &block)
+ if track_gitlab_schema_in_current_transaction?
+ super(**options) do
+ # Hook into current transaction to ensure that once
+ # the `COMMIT` is executed the `gitlab_transactions_stack`
+ # will be allowing to execute `after_commit_queue`
+ record = TransactionStackTrackRecord.new(self, gitlab_schema)
+
+ begin
+ connection.current_transaction.add_record(record)
+
+ yield
+ ensure
+ record.done!
+ end
+ end
+ else
+ super(**options, &block)
+ end
+ end
+
+ def track_gitlab_schema_in_current_transaction?
+ return false unless Feature::FlipperFeature.table_exists?
+
+ Feature.enabled?(:track_gitlab_schema_in_current_transaction, default_enabled: :yaml)
+ rescue ActiveRecord::NoDatabaseError, PG::ConnectionBad
+ false
+ end
+
+ def gitlab_schema
+ case self.name
+ when 'ActiveRecord::Base', 'ApplicationRecord'
+ :gitlab_main
+ when 'Ci::ApplicationRecord'
+ :gitlab_ci
+ else
+ Gitlab::Database::GitlabSchema.table_schema(table_name) if table_name
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/has_environment_scope.rb b/app/models/concerns/has_environment_scope.rb
index 9553abe4dd3..c01996c0c4c 100644
--- a/app/models/concerns/has_environment_scope.rb
+++ b/app/models/concerns/has_environment_scope.rb
@@ -70,6 +70,14 @@ module HasEnvironmentScope
relation
end
+
+ scope :for_environment, ->(environment) do
+ if environment
+ on_environment(environment)
+ else
+ where(environment_scope: '*')
+ end
+ end
end
def environment_scope=(new_environment_scope)
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index dcd80201d3f..0138c0ad20f 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -194,6 +194,8 @@ module Issuable
end
def supports_escalation?
+ return false unless ::Feature.enabled?(:incident_escalations, project)
+
incident?
end
@@ -363,9 +365,10 @@ module Issuable
end
# Includes table keys in group by clause when sorting
- # preventing errors in postgres
+ # preventing errors in Postgres
+ #
+ # Returns an array of Arel columns
#
- # Returns an array of arel columns
def grouping_columns(sort)
sort = sort.to_s
grouping_columns = [arel_table[:id]]
@@ -384,9 +387,10 @@ module Issuable
end
# Includes all table keys in group by clause when sorting
- # preventing errors in postgres when using CTE search optimisation
+ # preventing errors in Postgres when using CTE search optimization
+ #
+ # Returns an array of Arel columns
#
- # Returns an array of arel columns
def issue_grouping_columns(use_cte: false)
if use_cte
attribute_names.map { |attr| arel_table[attr.to_sym] }
@@ -576,7 +580,7 @@ module Issuable
##
# Overridden in MergeRequest
#
- def wipless_title_changed(old_title)
+ def draftless_title_changed(old_title)
old_title != title
end
end
diff --git a/app/models/concerns/mirror_authentication.rb b/app/models/concerns/mirror_authentication.rb
index 4dbf4dcec77..14c8be93ce0 100644
--- a/app/models/concerns/mirror_authentication.rb
+++ b/app/models/concerns/mirror_authentication.rb
@@ -4,11 +4,6 @@
# implements support for persisting the necessary data in a `credentials`
# serialized attribute. It also needs an `url` method to be defined
module MirrorAuthentication
- SSH_PRIVATE_KEY_OPTS = {
- type: 'RSA',
- bits: 4096
- }.freeze
-
extend ActiveSupport::Concern
included do
@@ -84,10 +79,10 @@ module MirrorAuthentication
return if ssh_private_key.blank?
comment = "git@#{::Gitlab.config.gitlab.host}"
- ::SSHKey.new(ssh_private_key, comment: comment).ssh_public_key
+ SSHData::PrivateKey.parse(ssh_private_key).first.public_key.openssh(comment: comment)
end
def generate_ssh_private_key!
- self.ssh_private_key = ::SSHKey.generate(SSH_PRIVATE_KEY_OPTS).private_key
+ self.ssh_private_key = SSHData::PrivateKey::RSA.generate(4096).openssl.to_pem
end
end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index ea4fe5b27dc..c1aac235d33 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -176,7 +176,7 @@ module Noteable
Gitlab::Routing.url_helpers.project_noteable_notes_path(
project,
- target_type: self.class.name.underscore,
+ target_type: noteable_target_type_name,
target_id: id
)
end
@@ -201,6 +201,10 @@ module Noteable
project_email.sub('@', "-#{iid}@")
end
+ def noteable_target_type_name
+ model_name.singular
+ end
+
private
# Synthetic system notes don't have discussion IDs because these are generated dynamically
diff --git a/app/models/concerns/packages/debian/distribution.rb b/app/models/concerns/packages/debian/distribution.rb
index 2d46889ce6a..1520ec0828e 100644
--- a/app/models/concerns/packages/debian/distribution.rb
+++ b/app/models/concerns/packages/debian/distribution.rb
@@ -97,12 +97,8 @@ module Packages
end
def package_files
- if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- ::Packages::PackageFile.installable
- .for_package_ids(packages.select(:id))
- else
- ::Packages::PackageFile.for_package_ids(packages.select(:id))
- end
+ ::Packages::PackageFile.installable
+ .for_package_ids(packages.select(:id))
end
private
diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb
index aae338e9759..92a88d2f7c8 100644
--- a/app/models/concerns/resolvable_discussion.rb
+++ b/app/models/concerns/resolvable_discussion.rb
@@ -99,6 +99,12 @@ module ResolvableDiscussion
update { |notes| notes.unresolve! }
end
+ def clear_memoized_values
+ self.class.memoized_values.each do |name|
+ clear_memoization(name)
+ end
+ end
+
private
def update
@@ -110,8 +116,6 @@ module ResolvableDiscussion
# Set the notes array to the updated notes
@notes = notes_relation.fresh.to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables
- self.class.memoized_values.each do |name|
- clear_memoization(name)
- end
+ clear_memoized_values
end
end
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index 4d1c1d44af7..e41a0ca28f9 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -15,17 +15,16 @@ module Taskable
INCOMPLETE_PATTERN = /(\[\s\])/.freeze
ITEM_PATTERN = %r{
^
- (?:(?:>\s{0,4})*) # optional blockquote characters
- (?:\s*(?:[-+*]|(?:\d+\.)))+ # list prefix (one or more) required - task item has to be always in a list
- \s+ # whitespace prefix has to be always presented for a list item
- (\[\s\]|\[[xX]\]) # checkbox
- (\s.+) # followed by whitespace and some text.
+ (?:(?:>\s{0,4})*) # optional blockquote characters
+ ((?:\s*(?:[-+*]|(?:\d+\.)))+) # list prefix (one or more) required - task item has to be always in a list
+ \s+ # whitespace prefix has to be always presented for a list item
+ (\[\s\]|\[[xX]\]) # checkbox
+ (\s.+) # followed by whitespace and some text.
}x.freeze
def self.get_tasks(content)
- content.to_s.scan(ITEM_PATTERN).map do |checkbox, label|
- # ITEM_PATTERN strips out the hyphen, but Item requires it. Rabble rabble.
- TaskList::Item.new("- #{checkbox}", label.strip)
+ content.to_s.scan(ITEM_PATTERN).map do |prefix, checkbox, label|
+ TaskList::Item.new("#{prefix} #{checkbox}", label.strip)
end
end
diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb
index 3fe9d7f4d71..943ef3fa59f 100644
--- a/app/models/concerns/timebox.rb
+++ b/app/models/concerns/timebox.rb
@@ -51,7 +51,7 @@ module Timebox
validate :dates_within_4_digits
cache_markdown_field :title, pipeline: :single_line
- cache_markdown_field :description
+ cache_markdown_field :description, issuable_reference_expansion_enabled: true
belongs_to :project
belongs_to :group
@@ -125,17 +125,6 @@ module Timebox
fuzzy_search(query, [:title, :description])
end
- # Searches for timeboxes with a matching title.
- #
- # This method uses ILIKE on PostgreSQL
- #
- # query - The search query as a String
- #
- # Returns an ActiveRecord::Relation.
- def search_title(query)
- fuzzy_search(query, [:title])
- end
-
def filter_by_state(timeboxes, state)
case state
when 'closed' then timeboxes.closed
diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb
index 34c8630bb90..f44ad8ebe90 100644
--- a/app/models/concerns/token_authenticatable.rb
+++ b/app/models/concerns/token_authenticatable.rb
@@ -64,6 +64,18 @@ module TokenAuthenticatable
mod.define_method("format_#{token_field}") do |token|
token
end
+
+ mod.define_method("#{token_field}_expires_at") do
+ strategy.expires_at(self)
+ end
+
+ mod.define_method("#{token_field}_expired?") do
+ strategy.expired?(self)
+ end
+
+ mod.define_method("#{token_field}_with_expiration") do
+ strategy.token_with_expiration(self)
+ end
end
def token_authenticatable_module
diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb
index f72a41f06b1..2cec4ab460e 100644
--- a/app/models/concerns/token_authenticatable_strategies/base.rb
+++ b/app/models/concerns/token_authenticatable_strategies/base.rb
@@ -7,6 +7,7 @@ module TokenAuthenticatableStrategies
def initialize(klass, token_field, options)
@klass = klass
@token_field = token_field
+ @expires_at_field = "#{token_field}_expires_at"
@options = options
end
@@ -44,6 +45,25 @@ module TokenAuthenticatableStrategies
instance.save! if Gitlab::Database.read_write?
end
+ def expires_at(instance)
+ instance.read_attribute(@expires_at_field)
+ end
+
+ def expired?(instance)
+ return false unless expirable? && token_expiration_enforced?
+
+ exp = expires_at(instance)
+ !!exp && Time.current > exp
+ end
+
+ def expirable?
+ !!@options[:expires_at]
+ end
+
+ def token_with_expiration(instance)
+ API::Support::TokenWithExpiration.new(self, instance)
+ end
+
def self.fabricate(model, field, options)
if options[:digest] && options[:encrypted]
raise ArgumentError, _('Incompatible options set!')
@@ -64,6 +84,10 @@ module TokenAuthenticatableStrategies
new_token = generate_available_token
formatted_token = format_token(instance, new_token)
set_token(instance, formatted_token)
+
+ if expirable?
+ instance[@expires_at_field] = @options[:expires_at].to_proc.call(instance)
+ end
end
def unique
@@ -82,11 +106,21 @@ module TokenAuthenticatableStrategies
end
def relation(unscoped)
- unscoped ? @klass.unscoped : @klass
+ unscoped ? @klass.unscoped : @klass.where(not_expired)
end
def token_set?(instance)
raise NotImplementedError
end
+
+ def token_expiration_enforced?
+ return true unless @options[:expiration_enforced?]
+
+ @options[:expiration_enforced?].to_proc.call(@klass)
+ end
+
+ def not_expired
+ Arel.sql("#{@expires_at_field} IS NULL OR #{@expires_at_field} >= NOW()") if expirable? && token_expiration_enforced?
+ end
end
end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index b03d946fc47..1f123cb0244 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -5,15 +5,24 @@ class ContainerRepository < ApplicationRecord
include Gitlab::SQL::Pattern
include EachBatch
include Sortable
+ include AfterCommitQueue
WAITING_CLEANUP_STATUSES = %i[cleanup_scheduled cleanup_unfinished].freeze
REQUIRING_CLEANUP_STATUSES = %i[cleanup_unscheduled cleanup_scheduled].freeze
+ IDLE_MIGRATION_STATES = %w[default pre_import_done import_done import_aborted import_skipped].freeze
+ ACTIVE_MIGRATION_STATES = %w[pre_importing importing].freeze
+ ABORTABLE_MIGRATION_STATES = (ACTIVE_MIGRATION_STATES + %w[pre_import_done default]).freeze
+ MIGRATION_STATES = (IDLE_MIGRATION_STATES + ACTIVE_MIGRATION_STATES).freeze
+
+ TooManyImportsError = Class.new(StandardError)
+ NativeImportError = Class.new(StandardError)
belongs_to :project
validates :name, length: { minimum: 0, allow_nil: false }
validates :name, uniqueness: { scope: :project_id }
- validates :migration_state, presence: true
+ validates :migration_state, presence: true, inclusion: { in: MIGRATION_STATES }
+ validates :migration_aborted_in_state, inclusion: { in: ABORTABLE_MIGRATION_STATES }, allow_nil: true
validates :migration_retries_count, presence: true,
numericality: { greater_than_or_equal_to: 0 },
@@ -23,7 +32,7 @@ class ContainerRepository < ApplicationRecord
enum expiration_policy_cleanup_status: { cleanup_unscheduled: 0, cleanup_scheduled: 1, cleanup_unfinished: 2, cleanup_ongoing: 3 }
enum migration_skipped_reason: { not_in_plan: 0, too_many_retries: 1, too_many_tags: 2, root_namespace_in_deny_list: 3 }
- delegate :client, to: :registry
+ delegate :client, :gitlab_api_client, to: :registry
scope :ordered, -> { order(:name) }
scope :with_api_entity_associations, -> { preload(project: [:route, { namespace: :route }]) }
@@ -39,7 +48,152 @@ class ContainerRepository < ApplicationRecord
scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) }
scope :waiting_for_cleanup, -> { where(expiration_policy_cleanup_status: WAITING_CLEANUP_STATUSES) }
scope :expiration_policy_started_at_nil_or_before, ->(timestamp) { where('expiration_policy_started_at < ? OR expiration_policy_started_at IS NULL', timestamp) }
+ scope :with_migration_import_started_at_nil_or_before, ->(timestamp) { where("COALESCE(migration_import_started_at, '01-01-1970') < ?", timestamp) }
+ scope :with_migration_pre_import_started_at_nil_or_before, ->(timestamp) { where("COALESCE(migration_pre_import_started_at, '01-01-1970') < ?", timestamp) }
+ scope :with_migration_pre_import_done_at_nil_or_before, ->(timestamp) { where("COALESCE(migration_pre_import_done_at, '01-01-1970') < ?", timestamp) }
scope :with_stale_ongoing_cleanup, ->(threshold) { cleanup_ongoing.where('expiration_policy_started_at < ?', threshold) }
+ scope :import_in_process, -> { where(migration_state: %w[pre_importing pre_import_done importing]) }
+
+ scope :recently_done_migration_step, -> do
+ where(migration_state: %w[import_done pre_import_done import_aborted])
+ .order(Arel.sql('GREATEST(migration_pre_import_done_at, migration_import_done_at, migration_aborted_at) DESC'))
+ end
+
+ scope :ready_for_import, -> do
+ # There is no yaml file for the container_registry_phase_2_deny_list
+ # feature flag since it is only accessed in this query.
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/350543 tracks the rollout and
+ # removal of this feature flag.
+ joins(:project).where(
+ migration_state: [:default],
+ created_at: ...ContainerRegistry::Migration.created_before
+ ).with_target_import_tier
+ .where(
+ "NOT EXISTS (
+ SELECT 1
+ FROM feature_gates
+ WHERE feature_gates.feature_key = 'container_registry_phase_2_deny_list'
+ AND feature_gates.key = 'actors'
+ AND feature_gates.value = concat('Group:', projects.namespace_id)
+ )"
+ )
+ end
+
+ state_machine :migration_state, initial: :default, use_transactions: false do
+ state :pre_importing do
+ validates :migration_pre_import_started_at, presence: true
+ validates :migration_pre_import_done_at, presence: false
+ end
+
+ state :pre_import_done do
+ validates :migration_pre_import_done_at, presence: true
+ end
+
+ state :importing do
+ validates :migration_import_started_at, presence: true
+ validates :migration_import_done_at, presence: false
+ end
+
+ state :import_done
+
+ state :import_skipped do
+ validates :migration_skipped_reason,
+ :migration_skipped_at,
+ presence: true
+ end
+
+ state :import_aborted do
+ validates :migration_aborted_at, presence: true
+ validates :migration_retries_count, presence: true, numericality: { greater_than_or_equal_to: 1 }
+ end
+
+ event :start_pre_import do
+ transition default: :pre_importing
+ end
+
+ event :finish_pre_import do
+ transition %i[pre_importing import_aborted] => :pre_import_done
+ end
+
+ event :start_import do
+ transition pre_import_done: :importing
+ end
+
+ event :finish_import do
+ transition %i[importing import_aborted] => :import_done
+ end
+
+ event :already_migrated do
+ transition default: :import_done
+ end
+
+ event :abort_import do
+ transition ABORTABLE_MIGRATION_STATES.map(&:to_sym) => :import_aborted
+ end
+
+ event :skip_import do
+ transition ABORTABLE_MIGRATION_STATES.map(&:to_sym) => :import_skipped
+ end
+
+ event :retry_pre_import do
+ transition import_aborted: :pre_importing
+ end
+
+ event :retry_import do
+ transition import_aborted: :importing
+ end
+
+ before_transition any => :pre_importing do |container_repository|
+ container_repository.migration_pre_import_started_at = Time.zone.now
+ container_repository.migration_pre_import_done_at = nil
+ end
+
+ after_transition any => :pre_importing do |container_repository|
+ container_repository.try_import do
+ container_repository.migration_pre_import
+ end
+ end
+
+ before_transition %i[pre_importing import_aborted] => :pre_import_done do |container_repository|
+ container_repository.migration_pre_import_done_at = Time.zone.now
+ end
+
+ before_transition any => :importing do |container_repository|
+ container_repository.migration_import_started_at = Time.zone.now
+ container_repository.migration_import_done_at = nil
+ end
+
+ after_transition any => :importing do |container_repository|
+ container_repository.try_import do
+ container_repository.migration_import
+ end
+ end
+
+ before_transition %i[importing import_aborted] => :import_done do |container_repository|
+ container_repository.migration_import_done_at = Time.zone.now
+ end
+
+ before_transition any => :import_aborted do |container_repository|
+ container_repository.migration_aborted_in_state = container_repository.migration_state
+ container_repository.migration_aborted_at = Time.zone.now
+ container_repository.migration_retries_count += 1
+ end
+
+ before_transition import_aborted: any do |container_repository|
+ container_repository.migration_aborted_at = nil
+ container_repository.migration_aborted_in_state = nil
+ end
+
+ before_transition any => :import_skipped do |container_repository|
+ container_repository.migration_skipped_at = Time.zone.now
+ end
+
+ before_transition any => %i[import_done import_aborted] do |container_repository|
+ container_repository.run_after_commit do
+ ::ContainerRegistry::Migration::EnqueuerWorker.perform_async
+ end
+ end
+ end
def self.exists_by_path?(path)
where(
@@ -64,6 +218,114 @@ class ContainerRepository < ApplicationRecord
with_enabled_policy.cleanup_unfinished
end
+ def self.with_stale_migration(before_timestamp)
+ stale_pre_importing = with_migration_states(:pre_importing)
+ .with_migration_pre_import_started_at_nil_or_before(before_timestamp)
+ stale_pre_import_done = with_migration_states(:pre_import_done)
+ .with_migration_pre_import_done_at_nil_or_before(before_timestamp)
+ stale_importing = with_migration_states(:importing)
+ .with_migration_import_started_at_nil_or_before(before_timestamp)
+
+ union = ::Gitlab::SQL::Union.new([
+ stale_pre_importing,
+ stale_pre_import_done,
+ stale_importing
+ ])
+ from("(#{union.to_sql}) #{ContainerRepository.table_name}")
+ end
+
+ def self.with_target_import_tier
+ # overridden in ee
+ #
+ # Repositories are being migrated by tier on Saas, so we need to
+ # filter by plan/subscription which is not available in FOSS
+ all
+ end
+
+ def skip_import(reason:)
+ self.migration_skipped_reason = reason
+
+ super
+ end
+
+ def start_pre_import
+ return false unless ContainerRegistry::Migration.enabled?
+
+ super
+ end
+
+ def retry_pre_import
+ return false unless ContainerRegistry::Migration.enabled?
+
+ super
+ end
+
+ def retry_import
+ return false unless ContainerRegistry::Migration.enabled?
+
+ super
+ end
+
+ def finish_pre_import_and_start_import
+ # nothing to do between those two transitions for now.
+ finish_pre_import && start_import
+ end
+
+ def retry_aborted_migration
+ return unless migration_state == 'import_aborted'
+
+ case external_import_status
+ when 'native'
+ raise NativeImportError
+ when 'import_in_progress'
+ nil
+ when 'import_complete'
+ finish_import
+ when 'import_failed'
+ retry_import
+ when 'pre_import_in_progress'
+ nil
+ when 'pre_import_complete'
+ finish_pre_import_and_start_import
+ when 'pre_import_failed'
+ retry_pre_import
+ else
+ # If the import_status request fails, use the timestamp to guess current state
+ migration_pre_import_done_at ? retry_import : retry_pre_import
+ end
+ end
+
+ def try_import
+ raise ArgumentError, 'block not given' unless block_given?
+
+ try_count = 0
+ begin
+ try_count += 1
+ return true if yield == :ok
+
+ abort_import
+ false
+ rescue TooManyImportsError
+ if try_count <= ::ContainerRegistry::Migration.start_max_retries
+ sleep 0.1 * try_count
+ retry
+ else
+ abort_import
+ false
+ end
+ end
+ end
+
+ def last_import_step_done_at
+ [migration_pre_import_done_at, migration_import_done_at, migration_aborted_at].compact.max
+ end
+
+ def external_import_status
+ strong_memoize(:import_status) do
+ gitlab_api_client.import_status(self.path)
+ end
+ end
+
# rubocop: disable CodeReuse/ServiceClass
def registry
@registry ||= begin
@@ -146,6 +408,36 @@ class ContainerRepository < ApplicationRecord
update!(expiration_policy_started_at: Time.zone.now)
end
+ def migration_in_active_state?
+ migration_state.in?(ACTIVE_MIGRATION_STATES)
+ end
+
+ def migration_importing?
+ migration_state == 'importing'
+ end
+
+ def migration_pre_importing?
+ migration_state == 'pre_importing'
+ end
+
+ def migration_pre_import
+ return :error unless gitlab_api_client.supports_gitlab_api?
+
+ response = gitlab_api_client.pre_import_repository(self.path)
+ raise TooManyImportsError if response == :too_many_imports
+
+ response
+ end
+
+ def migration_import
+ return :error unless gitlab_api_client.supports_gitlab_api?
+
+ response = gitlab_api_client.import_repository(self.path)
+ raise TooManyImportsError if response == :too_many_imports
+
+ response
+ end
+
def self.build_from_path(path)
self.new(project: path.repository_project,
name: path.repository_name)
@@ -169,6 +461,11 @@ class ContainerRepository < ApplicationRecord
self.find_by!(project: path.repository_project,
name: path.repository_name)
end
+
+ def self.find_by_path(path)
+ self.find_by(project: path.repository_project,
+ name: path.repository_name)
+ end
end
ContainerRepository.prepend_mod_with('ContainerRepository')
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
index 168f1c48a6c..a981351f4a0 100644
--- a/app/models/customer_relations/contact.rb
+++ b/app/models/customer_relations/contact.rb
@@ -26,6 +26,18 @@ class CustomerRelations::Contact < ApplicationRecord
validate :validate_email_format
validate :unique_email_for_group_hierarchy
+ def self.reference_prefix
+ '[contact:'
+ end
+
+ def self.reference_prefix_quoted
+ '["contact:'
+ end
+
+ def self.reference_postfix
+ ']'
+ end
+
def self.find_ids_by_emails(group, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
@@ -33,6 +45,12 @@ class CustomerRelations::Contact < ApplicationRecord
.pluck(:id)
end
+ def self.exists_for_group?(group)
+ return false unless group
+
+ exists?(group_id: group.self_and_ancestor_ids)
+ end
+
private
def validate_email_format
diff --git a/app/models/customer_relations/issue_contact.rb b/app/models/customer_relations/issue_contact.rb
index 89dac6bad22..3e9d1e97c8c 100644
--- a/app/models/customer_relations/issue_contact.rb
+++ b/app/models/customer_relations/issue_contact.rb
@@ -16,6 +16,12 @@ class CustomerRelations::IssueContact < ApplicationRecord
.pluck(:contact_id)
end
+ def self.delete_for_project(project_id)
+ joins(:issue)
+ .where(issues: { project_id: project_id })
+ .delete_all
+ end
+
private
def contact_belongs_to_issue_group_or_ancestor
diff --git a/app/models/dependency_proxy/blob.rb b/app/models/dependency_proxy/blob.rb
index e4018ab4770..f7b08f1d077 100644
--- a/app/models/dependency_proxy/blob.rb
+++ b/app/models/dependency_proxy/blob.rb
@@ -14,6 +14,8 @@ class DependencyProxy::Blob < ApplicationRecord
validates :file, presence: true
validates :file_name, presence: true
+ scope :with_files_stored_locally, -> { where(file_store: ::DependencyProxy::FileUploader::Store::LOCAL) }
+
mount_file_store_uploader DependencyProxy::FileUploader
def self.total_size
@@ -24,3 +26,5 @@ class DependencyProxy::Blob < ApplicationRecord
find_or_initialize_by(file_name: file_name)
end
end
+
+DependencyProxy::Blob.prepend_mod_with('DependencyProxy::Blob')
diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb
index fe887c99e81..c2587ffac9d 100644
--- a/app/models/dependency_proxy/manifest.rb
+++ b/app/models/dependency_proxy/manifest.rb
@@ -17,6 +17,7 @@ class DependencyProxy::Manifest < ApplicationRecord
validates :digest, presence: true
scope :order_id_desc, -> { reorder(id: :desc) }
+ scope :with_files_stored_locally, -> { where(file_store: ::DependencyProxy::FileUploader::Store::LOCAL) }
mount_file_store_uploader DependencyProxy::FileUploader
@@ -24,3 +25,5 @@ class DependencyProxy::Manifest < ApplicationRecord
find_by(file_name: file_name) || find_by(digest: digest)
end
end
+
+DependencyProxy::Manifest.prepend_mod_with('DependencyProxy::Manifest')
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 2f04d99f9f6..46409465209 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -255,10 +255,10 @@ class Deployment < ApplicationRecord
end
end
- def includes_commit?(commit)
- return false unless commit
+ def includes_commit?(ancestor_sha)
+ return false unless sha
- project.repository.ancestor?(commit.id, sha)
+ project.repository.ancestor?(ancestor_sha, sha)
end
def update_merge_request_metrics!
@@ -294,10 +294,6 @@ class Deployment < ApplicationRecord
@stop_action ||= manual_actions.find { |action| action.name == self.on_stop }
end
- def finished_at
- read_attribute(:finished_at) || legacy_finished_at
- end
-
def deployed_at
return unless success?
@@ -405,10 +401,6 @@ class Deployment < ApplicationRecord
raise ArgumentError, "The status #{status.inspect} is invalid"
end
end
-
- def legacy_finished_at
- self.created_at if success? && !read_attribute(:finished_at)
- end
end
Deployment.prepend_mod_with('Deployment')
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index 203e14f1227..8a167034629 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -25,7 +25,7 @@ class Discussion
:to_ability_name,
:editable?,
:resolved_by_id,
- :system_note_with_references_visible_for?,
+ :system_note_visible_for?,
:resource_parent,
:save,
to: :first_note
diff --git a/app/models/draft_note.rb b/app/models/draft_note.rb
index febede9beba..9f7977fce68 100644
--- a/app/models/draft_note.rb
+++ b/app/models/draft_note.rb
@@ -25,6 +25,7 @@ class DraftNote < ApplicationRecord
validates :merge_request_id, presence: true
validates :author_id, presence: true, uniqueness: { scope: [:merge_request_id, :discussion_id] }, if: :discussion_id?
validates :discussion_id, allow_nil: true, format: { with: /\A\h{40}\z/ }
+ validates :line_code, length: { maximum: 255 }, allow_nil: true
scope :authored_by, ->(u) { where(author_id: u.id) }
@@ -89,7 +90,11 @@ class DraftNote < ApplicationRecord
end
def line_code
- @line_code ||= diff_file&.line_code_for_position(original_position)
+ super.presence || find_line_code
+ end
+
+ def find_line_code
+ write_attribute(:line_code, diff_file&.line_code_for_position(original_position))
end
def publish_params
diff --git a/app/models/environment.rb b/app/models/environment.rb
index a830c04f291..51a9024721b 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -235,10 +235,10 @@ class Environment < ApplicationRecord
self.environment_type = names.many? ? names.first : nil
end
- def includes_commit?(commit)
+ def includes_commit?(sha)
return false unless last_deployment
- last_deployment.includes_commit?(commit)
+ last_deployment.includes_commit?(sha)
end
def last_deployed_at
diff --git a/app/models/event.rb b/app/models/event.rb
index 409bc66c66c..a8cf2e2dfb0 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -354,7 +354,7 @@ class Event < ApplicationRecord
# hence we add the extra WHERE clause for last_activity_at.
Project.unscoped.where(id: project_id)
.where('last_activity_at <= ?', RESET_PROJECT_ACTIVITY_INTERVAL.ago)
- .update_all(last_activity_at: created_at)
+ .touch_all(:last_activity_at, time: created_at) # rubocop: disable Rails/SkipsModelValidations
end
def authored_by?(user)
@@ -430,7 +430,7 @@ class Event < ApplicationRecord
def set_last_repository_updated_at
Project.unscoped.where(id: project_id)
.where("last_repository_updated_at < ? OR last_repository_updated_at IS NULL", REPOSITORY_UPDATED_AT_INTERVAL.ago)
- .update_all(last_repository_updated_at: created_at)
+ .touch_all(:last_repository_updated_at, time: created_at) # rubocop: disable Rails/SkipsModelValidations
end
def design_action_names
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 3320c13e87b..7e538238cbd 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -50,8 +50,9 @@ class WebHook < ApplicationRecord
end
# rubocop: disable CodeReuse/ServiceClass
- def execute(data, hook_name)
- WebHookService.new(self, data, hook_name).execute if executable?
+ def execute(data, hook_name, force: false)
+ # hook.executable? is checked in WebHookService#execute
+ WebHookService.new(self, data, hook_name, force: force).execute
end
# rubocop: enable CodeReuse/ServiceClass
diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb
index dc025e576ed..2016024b2f4 100644
--- a/app/models/instance_configuration.rb
+++ b/app/models/instance_configuration.rb
@@ -118,7 +118,12 @@ class InstanceConfiguration
group_export_download: application_setting_limit_per_minute(:group_download_export_limit),
group_import: application_setting_limit_per_minute(:group_import_limit),
raw_blob: application_setting_limit_per_minute(:raw_blob_request_limit),
- user_email_lookup: application_setting_limit_per_minute(:user_email_lookup_limit)
+ user_email_lookup: application_setting_limit_per_minute(:user_email_lookup_limit),
+ users_get_by_id: {
+ enabled: application_settings[:users_get_by_id_limit] > 0,
+ requests_per_period: application_settings[:users_get_by_id_limit],
+ period_in_seconds: 10.minutes
+ }
}
end
@@ -147,7 +152,7 @@ class InstanceConfiguration
end
def ssh_algorithm_sha256(ssh_file_content)
- Gitlab::SSHPublicKey.new(ssh_file_content).fingerprint('SHA256')
+ Gitlab::SSHPublicKey.new(ssh_file_content).fingerprint_sha256
end
def application_settings
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 89b34932e20..e9cd90649ba 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -392,8 +392,7 @@ class Integration < ApplicationRecord
end
def api_field_names
- fields.map { |field| field[:name] }
- .reject { |field_name| field_name =~ /(password|token|key|title|description)/ }
+ fields.pluck(:name).grep_v(/password|token|key|title|description/)
end
def global_fields
diff --git a/app/models/integrations/chat_message/base_message.rb b/app/models/integrations/chat_message/base_message.rb
index ab213f4b43f..554b422c0fa 100644
--- a/app/models/integrations/chat_message/base_message.rb
+++ b/app/models/integrations/chat_message/base_message.rb
@@ -47,16 +47,21 @@ module Integrations
format(message)
end
+ # NOTE: Make sure to call `#strip_markup` on any untrusted user input that's added to the
+ # `title`, `subtitle`, `text`, `fallback`, or `author_name` fields.
def attachments
raise NotImplementedError
end
+ # NOTE: Make sure to call `#strip_markup` on any untrusted user input that's added to the
+ # `title`, `subtitle`, `text`, `fallback`, or `author_name` fields.
def activity
raise NotImplementedError
end
private
+ # NOTE: Make sure to call `#strip_markup` on any untrusted user input that's added to the string.
def message
raise NotImplementedError
end
diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb
index b86f0aaa7ef..bb0fb6b9079 100644
--- a/app/models/integrations/datadog.rb
+++ b/app/models/integrations/datadog.rb
@@ -13,7 +13,11 @@ module Integrations
pipeline job
].freeze
- prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env
+ TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze
+
+ prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env, :datadog_tags
+
+ before_validation :strip_properties
with_options if: :activated? do
validates :api_key, presence: true, format: { with: /\A\w+\z/ }
@@ -21,6 +25,7 @@ module Integrations
validates :api_url, public_url: { allow_blank: true }
validates :datadog_site, presence: true, unless: -> (obj) { obj.api_url.present? }
validates :api_url, presence: true, unless: -> (obj) { obj.datadog_site.present? }
+ validate :datadog_tags_are_valid
end
def initialize_properties
@@ -140,6 +145,20 @@ module Integrations
linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe,
linkClose: '</a>'.html_safe
}
+ },
+ {
+ type: 'textarea',
+ name: 'datadog_tags',
+ title: s_('DatadogIntegration|Tags'),
+ placeholder: "tag:value\nanother_tag:value",
+ help: ERB::Util.html_escape(
+ s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}')
+ ) % {
+ codeOpen: '<code>'.html_safe,
+ codeClose: '</code>'.html_safe,
+ linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe,
+ linkClose: '</a>'.html_safe
+ }
}
]
@@ -153,7 +172,8 @@ module Integrations
query = {
"dd-api-key" => api_key,
service: datadog_service.presence,
- env: datadog_env.presence
+ env: datadog_env.presence,
+ tags: datadog_tags_query_param.presence
}.compact
url.query = query.to_query
url.to_s
@@ -193,5 +213,35 @@ module Integrations
data
end
+
+ def strip_properties
+ datadog_service.strip! if datadog_service && !datadog_service.frozen?
+ datadog_env.strip! if datadog_env && !datadog_env.frozen?
+ datadog_tags.strip! if datadog_tags && !datadog_tags.frozen?
+ end
+
+ def datadog_tags_are_valid
+ return unless datadog_tags
+
+ unless datadog_tags.split("\n").select(&:present?).all? { _1 =~ TAG_KEY_VALUE_RE }
+ errors.add(:datadog_tags, s_("DatadogIntegration|have an invalid format"))
+ end
+ end
+
+ def datadog_tags_query_param
+ return unless datadog_tags
+
+ datadog_tags.split("\n").filter_map do |tag|
+ tag.strip!
+
+ next if tag.blank?
+
+ if tag.include?(',')
+ "\"#{tag}\""
+ else
+ tag
+ end
+ end.join(',')
+ end
end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 4f2773f4147..68ea6cb3abc 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -29,8 +29,10 @@ class Issue < ApplicationRecord
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
- AnyDueDate = DueDateStruct.new('Any Due Date', '').freeze
+ AnyDueDate = DueDateStruct.new('Any Due Date', 'any').freeze
Overdue = DueDateStruct.new('Overdue', 'overdue').freeze
+ DueToday = DueDateStruct.new('Due Today', 'today').freeze
+ DueTomorrow = DueDateStruct.new('Due Tomorrow', 'tomorrow').freeze
DueThisWeek = DueDateStruct.new('Due This Week', 'week').freeze
DueThisMonth = DueDateStruct.new('Due This Month', 'month').freeze
DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze
@@ -107,7 +109,9 @@ class Issue < ApplicationRecord
scope :without_due_date, -> { where(due_date: nil) }
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
+ scope :due_today, -> { where(due_date: Date.current) }
scope :due_tomorrow, -> { where(due_date: Date.tomorrow) }
+
scope :not_authored_by, ->(user) { where.not(author_id: user) }
scope :order_due_date_asc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'ASC')) }
@@ -121,7 +125,6 @@ class Issue < ApplicationRecord
scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) }
scope :with_web_entity_associations, -> { preload(:author, project: [:project_feature, :route, namespace: :route]) }
scope :preload_awardable, -> { preload(:award_emoji) }
- scope :with_label_attributes, ->(label_attributes) { joins(:labels).where(labels: label_attributes) }
scope :with_alert_management_alerts, -> { joins(:alert_management_alert) }
scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) }
scope :with_self_managed_prometheus_alert_events, -> { joins(:issues_self_managed_prometheus_alert_events) }
@@ -140,7 +143,7 @@ class Issue < ApplicationRecord
scope :confidential_only, -> { where(confidential: true) }
scope :without_hidden, -> {
- if Feature.enabled?(:ban_user_feature_flag)
+ if Feature.enabled?(:ban_user_feature_flag, default_enabled: :yaml)
where('NOT EXISTS (?)', Users::BannedUser.select(1).where('issues.author_id = banned_users.user_id'))
else
all
@@ -584,7 +587,7 @@ class Issue < ApplicationRecord
def readable_by?(user)
if user.can_read_all_resources?
true
- elsif project.owner == user
+ elsif project.personal? && project.team.owner?(user)
true
elsif confidential? && !assignee_or_author?(user)
project.team.member?(user, Gitlab::Access::REPORTER)
diff --git a/app/models/key.rb b/app/models/key.rb
index 933c939fdf5..4a4e792c074 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -130,7 +130,7 @@ class Key < ApplicationRecord
return unless public_key.valid?
self.fingerprint_md5 = public_key.fingerprint
- self.fingerprint_sha256 = public_key.fingerprint("SHA256").gsub("SHA256:", "")
+ self.fingerprint_sha256 = public_key.fingerprint_sha256.gsub("SHA256:", "")
end
def key_meets_restrictions
diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb
index db82d5bbf29..ebda5872f1c 100644
--- a/app/models/loose_foreign_keys/deleted_record.rb
+++ b/app/models/loose_foreign_keys/deleted_record.rb
@@ -46,17 +46,39 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel
.to_a
end
- def self.mark_records_processed(all_records)
- # Run a query for each partition to optimize the row lookup by primary key (partition, id)
+ def self.mark_records_processed(records)
+ update_by_partition(records) do |partitioned_scope|
+ partitioned_scope.update_all(status: :processed)
+ end
+ end
+
+ def self.reschedule(records, consume_after)
+ update_by_partition(records) do |partitioned_scope|
+ partitioned_scope.update_all(consume_after: consume_after, cleanup_attempts: 0)
+ end
+ end
+
+ def self.increment_attempts(records)
+ update_by_partition(records) do |partitioned_scope|
+ # Naive incrementing of the cleanup_attempts is good enough for us.
+ partitioned_scope.update_all('cleanup_attempts = cleanup_attempts + 1')
+ end
+ end
+
+ def self.update_by_partition(records)
update_count = 0
- all_records.group_by(&:partition_number).each do |partition, records_within_partition|
- update_count += status_pending
+ # Run a query for each partition to optimize the row lookup by primary key (partition, id)
+ records.group_by(&:partition_number).each do |partition, records_within_partition|
+ partitioned_scope = status_pending
.for_partition(partition)
.where(id: records_within_partition.pluck(:id))
- .update_all(status: :processed)
+
+ update_count += yield(partitioned_scope)
end
update_count
end
+
+ private_class_method :update_by_partition
end
diff --git a/app/models/member.rb b/app/models/member.rb
index 6c0503dca3f..528c6855d9c 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -108,6 +108,8 @@ class Member < ApplicationRecord
.reorder(nil)
end
+ scope :active_state, -> { where(state: STATE_ACTIVE) }
+
scope :connected_to_user, -> { where.not(user_id: nil) }
# This scope is exclusively used to get the members
@@ -115,6 +117,7 @@ class Member < ApplicationRecord
# to projects/groups.
scope :authorizable, -> do
connected_to_user
+ .active_state
.non_request
.non_minimal_access
end
@@ -128,7 +131,8 @@ class Member < ApplicationRecord
end
scope :without_invites_and_requests, -> do
- non_request
+ active_state
+ .non_request
.non_invite
.non_minimal_access
end
@@ -180,6 +184,7 @@ class Member < ApplicationRecord
scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) }
+ before_validation :set_member_namespace_id, on: :create
before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? && !member.invite_accepted_at? }
after_create :send_invite, if: :invite?, unless: :importing?
@@ -203,7 +208,7 @@ class Member < ApplicationRecord
class << self
def search(query)
- joins(:user).merge(User.search(query))
+ joins(:user).merge(User.search(query, use_minimum_char_limit: false))
end
def search_invite_email(query)
@@ -380,6 +385,12 @@ class Member < ApplicationRecord
private
+ # TODO: https://gitlab.com/groups/gitlab-org/-/epics/7054
+ # temporary until we can we properly remove the source columns
+ def set_member_namespace_id
+ self.member_namespace_id = self.source_id
+ end
+
def access_level_inclusion
return if access_level.in?(Gitlab::Access.all_values)
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 6fc665cb87a..3a449055bc1 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -118,6 +118,13 @@ class ProjectMember < Member
# rubocop:enable CodeReuse/ServiceClass
end
+ # TODO: https://gitlab.com/groups/gitlab-org/-/epics/7054
+ # temporary until we can we properly remove the source columns
+ override :set_member_namespace_id
+ def set_member_namespace_id
+ self.member_namespace_id = project&.project_namespace_id
+ end
+
def send_invite
run_after_commit_or_now { notification_service.invite_project_member(self, @raw_invite_token) }
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index cf36e72a565..29540cbde2f 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -406,6 +406,17 @@ class MergeRequest < ApplicationRecord
)
end
+ scope :attention, ->(user) do
+ # rubocop: disable Gitlab/Union
+ union = Gitlab::SQL::Union.new([
+ MergeRequestReviewer.select(:merge_request_id).where(user_id: user.id, state: MergeRequestReviewer.states[:attention_requested]),
+ MergeRequestAssignee.select(:merge_request_id).where(user_id: user.id, state: MergeRequestAssignee.states[:attention_requested])
+ ])
+ # rubocop: enable Gitlab/Union
+
+ with(Gitlab::SQL::CTE.new(:reviewers_and_assignees, union).to_arel).where('merge_requests.id in (select merge_request_id from reviewers_and_assignees)')
+ end
+
def self.total_time_to_merge
join_metrics
.merge(MergeRequest::Metrics.with_valid_time_to_merge)
@@ -471,6 +482,12 @@ class MergeRequest < ApplicationRecord
rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid)
end
+ def permits_force_push?
+ return true unless ProtectedBranch.protected?(source_project, source_branch)
+
+ ProtectedBranch.allow_force_push?(source_project, source_branch)
+ end
+
# Use this method whenever you need to make sure the head_pipeline is synced with the
# branch head commit, for example checking if a merge request can be merged.
# For more information check: https://gitlab.com/gitlab-org/gitlab-foss/issues/40004
@@ -561,20 +578,24 @@ class MergeRequest < ApplicationRecord
end
end
- # WIP is deprecated in favor of Draft. Currently both options are supported
- # https://gitlab.com/gitlab-org/gitlab/-/issues/227426
- DRAFT_REGEX = /\A*#{Regexp.union(Gitlab::Regex.merge_request_wip, Gitlab::Regex.merge_request_draft)}+\s*/i.freeze
+ DRAFT_REGEX = /\A*#{Gitlab::Regex.merge_request_draft}+\s*/i.freeze
- def self.work_in_progress?(title)
+ def self.draft?(title)
!!(title =~ DRAFT_REGEX)
end
- def self.wipless_title(title)
+ def self.draftless_title(title)
title.sub(DRAFT_REGEX, "")
end
- def self.wip_title(title)
- work_in_progress?(title) ? title : "Draft: #{title}"
+ def self.draft_title(title)
+ draft?(title) ? title : "Draft: #{title}"
+ end
+
+ class << self
+ alias_method :work_in_progress?, :draft?
+ alias_method :wipless_title, :draftless_title
+ alias_method :wip_title, :draft_title
end
def self.participant_includes
@@ -587,9 +608,10 @@ class MergeRequest < ApplicationRecord
# Verifies if title has changed not taking into account Draft prefix
# for merge requests.
- def wipless_title_changed(old_title)
- self.class.wipless_title(old_title) != self.wipless_title
+ def draftless_title_changed(old_title)
+ self.class.draftless_title(old_title) != self.draftless_title
end
+ alias_method :wipless_title_changed, :draftless_title_changed
def hook_attrs
Gitlab::HookData::MergeRequestBuilder.new(self).build
@@ -1088,18 +1110,20 @@ class MergeRequest < ApplicationRecord
@closed_event ||= target_project.events.where(target_id: self.id, target_type: "MergeRequest", action: :closed).last
end
- def work_in_progress?
- self.class.work_in_progress?(title)
+ def draft?
+ self.class.draft?(title)
end
- alias_method :draft?, :work_in_progress?
+ alias_method :work_in_progress?, :draft?
- def wipless_title
- self.class.wipless_title(self.title)
+ def draftless_title
+ self.class.draftless_title(self.title)
end
+ alias_method :wipless_title, :draftless_title
- def wip_title
- self.class.wip_title(self.title)
+ def draft_title
+ self.class.draft_title(self.title)
end
+ alias_method :wip_title, :draft_title
def mergeable?(skip_ci_check: false, skip_discussions_check: false)
return false unless mergeable_state?(skip_ci_check: skip_ci_check,
@@ -1754,6 +1778,8 @@ class MergeRequest < ApplicationRecord
paths = active_diff_discussions.flat_map { |n| n.diff_file.paths }.uniq
+ active_discussions_resolved = active_diff_discussions.all?(&:resolved?)
+
service = Discussions::UpdateDiffPositionService.new(
self.project,
current_user,
@@ -1764,9 +1790,15 @@ class MergeRequest < ApplicationRecord
active_diff_discussions.each do |discussion|
service.execute(discussion)
+ discussion.clear_memoized_values
end
- if project.resolve_outdated_diff_discussions?
+ # If they were all already resolved, this method will have already been called.
+ # If they all don't get resolved, we don't need to call the method
+ # If they go from unresolved -> resolved, then we call the method
+ if !active_discussions_resolved &&
+ active_diff_discussions.all?(&:resolved?) &&
+ project.resolve_outdated_diff_discussions?
MergeRequests::ResolvedDiscussionNotificationService
.new(project: project, current_user: current_user)
.execute(self)
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 868bee9961b..2c95cc2672c 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -52,6 +52,17 @@ class Milestone < ApplicationRecord
state :active
end
+ # Searches for timeboxes with a matching title.
+ #
+ # This method uses ILIKE on PostgreSQL
+ #
+ # query - The search query as a String
+ #
+ # Returns an ActiveRecord::Relation.
+ def self.search_title(query)
+ fuzzy_search(query, [:title])
+ end
+
def self.min_chars_for_partial_matching
2
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 0dc20e0016c..5c55f4d3def 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -43,6 +43,7 @@ class Namespace < ApplicationRecord
has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :project_statistics
has_one :namespace_settings, inverse_of: :namespace, class_name: 'NamespaceSetting', autosave: true
+ has_one :namespace_statistics
has_one :namespace_route, foreign_key: :namespace_id, autosave: false, inverse_of: :namespace, class_name: 'Route'
has_many :namespace_members, foreign_key: :member_namespace_id, inverse_of: :member_namespace, class_name: 'Member'
@@ -492,6 +493,10 @@ class Namespace < ApplicationRecord
end
end
+ def shared_runners
+ @shared_runners ||= shared_runners_enabled ? Ci::Runner.instance_type : Ci::Runner.none
+ end
+
def root?
!has_parent?
end
@@ -508,6 +513,12 @@ class Namespace < ApplicationRecord
Feature.enabled?(:create_project_namespace_on_project_create, self, default_enabled: :yaml)
end
+ def storage_enforcement_date
+ # should return something like Date.new(2022, 02, 03)
+ # TBD: https://gitlab.com/gitlab-org/gitlab/-/issues/350632
+ nil
+ end
+
private
def expire_child_caches
diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb
index 99e32537595..ee04ec39b1e 100644
--- a/app/models/namespace/root_storage_statistics.rb
+++ b/app/models/namespace/root_storage_statistics.rb
@@ -27,10 +27,17 @@ class Namespace::RootStorageStatistics < ApplicationRecord
update!(merged_attributes)
end
+ def self.namespace_statistics_attributes
+ %w(storage_size dependency_proxy_size)
+ end
+
private
def merged_attributes
- attributes_from_project_statistics.merge!(attributes_from_personal_snippets) { |key, v1, v2| v1 + v2 }
+ attributes_from_project_statistics.merge!(
+ attributes_from_personal_snippets,
+ attributes_from_namespace_statistics
+ ) { |key, v1, v2| v1 + v2 }
end
def attributes_from_project_statistics
@@ -68,6 +75,27 @@ class Namespace::RootStorageStatistics < ApplicationRecord
.where(author: namespace.owner_id)
.select("COALESCE(SUM(s.repository_size), 0) AS #{SNIPPETS_SIZE_STAT_NAME}")
end
+
+ def from_namespace_statistics
+ namespace
+ .self_and_descendants
+ .joins("INNER JOIN namespace_statistics ns ON ns.namespace_id = namespaces.id")
+ .select(
+ 'COALESCE(SUM(ns.storage_size), 0) AS storage_size',
+ 'COALESCE(SUM(ns.dependency_proxy_size), 0) AS dependency_proxy_size'
+ )
+ end
+
+ def attributes_from_namespace_statistics
+ # At the moment, only groups can have some storage data because of dependency proxy assets.
+ # Therefore, if the namespace is not a group one, there is no need to perform
+ # the query. If this changes in the future and we add some sort of resource to
+ # users that it's store in NamespaceStatistics, we will need to remove this
+ # guard clause.
+ return {} unless namespace.group_namespace?
+
+ from_namespace_statistics.take.slice(*self.class.namespace_statistics_attributes)
+ end
end
Namespace::RootStorageStatistics.prepend_mod_with('Namespace::RootStorageStatistics')
diff --git a/app/models/namespace_statistics.rb b/app/models/namespace_statistics.rb
new file mode 100644
index 00000000000..04ca05d85ff
--- /dev/null
+++ b/app/models/namespace_statistics.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+class NamespaceStatistics < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass
+ include AfterCommitQueue
+
+ belongs_to :namespace
+
+ validates :namespace, presence: true
+
+ scope :for_namespaces, -> (namespaces) { where(namespace: namespaces) }
+
+ before_save :update_storage_size
+ after_save :update_root_storage_statistics, if: :saved_change_to_storage_size?
+ after_destroy :update_root_storage_statistics
+
+ delegate :group_namespace?, to: :namespace
+
+ def refresh!(only: [])
+ return if Gitlab::Database.read_only?
+ return unless group_namespace?
+
+ self.class.columns_to_refresh.each do |column|
+ if only.empty? || only.include?(column)
+ public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ save!
+ end
+
+ def update_storage_size
+ # This prevents failures with older database schemas, such as those
+ # in migration specs.
+ return unless self.class.database.cached_column_exists?(:dependency_proxy_size)
+
+ self.storage_size = dependency_proxy_size
+ end
+
+ def update_dependency_proxy_size
+ return unless group_namespace?
+
+ self.dependency_proxy_size = namespace.dependency_proxy_manifests.sum(:size) + namespace.dependency_proxy_blobs.sum(:size)
+ end
+
+ def self.columns_to_refresh
+ [:dependency_proxy_size]
+ end
+
+ private
+
+ def update_root_storage_statistics
+ return unless group_namespace?
+
+ run_after_commit do
+ Namespaces::ScheduleAggregationWorker.perform_async(namespace.id)
+ end
+ end
+end
+
+NamespaceStatistics.prepend_mod_with('NamespaceStatistics')
diff --git a/app/models/namespaces/sync_event.rb b/app/models/namespaces/sync_event.rb
index 8534d8afb8c..fbe047f2c5a 100644
--- a/app/models/namespaces/sync_event.rb
+++ b/app/models/namespaces/sync_event.rb
@@ -13,4 +13,8 @@ class Namespaces::SyncEvent < ApplicationRecord
def self.enqueue_worker
::Namespaces::ProcessSyncEventsWorker.perform_async # rubocop:disable CodeReuse/Worker
end
+
+ def self.upper_bound_count
+ select('COALESCE(MAX(id) - MIN(id) + 1, 0) AS upper_bound_count').to_a.first.upper_bound_count
+ end
end
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index 757a0e40eb3..99a5b8cb063 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -43,14 +43,23 @@ module Namespaces
included do
before_update :lock_both_roots, if: -> { sync_traversal_ids? && parent_id_changed? }
- after_create :sync_traversal_ids, if: -> { sync_traversal_ids? }
after_update :sync_traversal_ids, if: -> { sync_traversal_ids? && saved_change_to_parent_id? }
+ # sync traversal_ids on namespace create, which can happen quite early within a transaction, thus keeping the lock on root namespace record
+ # for a relatively long time, e.g. creating the project namespace when a project is being created.
+ after_create :sync_traversal_ids, if: -> { sync_traversal_ids? && !sync_traversal_ids_before_commit? }
+ # This uses rails internal before_commit API to sync traversal_ids on namespace create, right before transaction is committed.
+ # This helps reduce the time during which the root namespace record is locked to ensure updated traversal_ids are valid
+ before_commit :sync_traversal_ids, on: [:create], if: -> { sync_traversal_ids? && sync_traversal_ids_before_commit? }
end
def sync_traversal_ids?
Feature.enabled?(:sync_traversal_ids, root_ancestor, default_enabled: :yaml)
end
+ def sync_traversal_ids_before_commit?
+ Feature.enabled?(:sync_traversal_ids_before_commit, root_ancestor, default_enabled: :yaml)
+ end
+
def use_traversal_ids?
return false unless Feature.enabled?(:use_traversal_ids, default_enabled: :yaml)
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
index 9f0f49e729c..09d69a5f77a 100644
--- a/app/models/namespaces/traversal/linear_scopes.rb
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -12,7 +12,7 @@ module Namespaces
def as_ids
return super unless use_traversal_ids?
- select('namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id')
+ select(Arel.sql('namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)]').as('id'))
end
def roots
@@ -53,7 +53,7 @@ module Namespaces
end
def self_and_descendants(include_self: true)
- return super unless use_traversal_ids?
+ return super unless use_traversal_ids_for_descendants_scopes?
if Feature.enabled?(:traversal_ids_btree, default_enabled: :yaml)
self_and_descendants_with_comparison_operators(include_self: include_self)
@@ -65,7 +65,7 @@ module Namespaces
end
def self_and_descendant_ids(include_self: true)
- return super unless use_traversal_ids?
+ return super unless use_traversal_ids_for_descendants_scopes?
if Feature.enabled?(:traversal_ids_btree, default_enabled: :yaml)
self_and_descendants_with_comparison_operators(include_self: include_self).as_ids
@@ -75,6 +75,12 @@ module Namespaces
end
end
+ def self_and_hierarchy
+ return super unless use_traversal_ids_for_self_and_hierarchy_scopes?
+
+ unscoped.from_union([all.self_and_ancestors, all.self_and_descendants(include_self: false)])
+ end
+
def order_by_depth(hierarchy_order)
return all unless hierarchy_order
@@ -109,6 +115,16 @@ module Namespaces
use_traversal_ids?
end
+ def use_traversal_ids_for_descendants_scopes?
+ Feature.enabled?(:use_traversal_ids_for_descendants_scopes, default_enabled: :yaml) &&
+ use_traversal_ids?
+ end
+
+ def use_traversal_ids_for_self_and_hierarchy_scopes?
+ Feature.enabled?(:use_traversal_ids_for_self_and_hierarchy_scopes, default_enabled: :yaml) &&
+ use_traversal_ids?
+ end
+
def self_and_descendants_with_comparison_operators(include_self: true)
base = all.select(
:traversal_ids,
diff --git a/app/models/namespaces/traversal/recursive_scopes.rb b/app/models/namespaces/traversal/recursive_scopes.rb
index 583c53f8221..c6f09a4d134 100644
--- a/app/models/namespaces/traversal/recursive_scopes.rb
+++ b/app/models/namespaces/traversal/recursive_scopes.rb
@@ -53,6 +53,11 @@ module Namespaces
self_and_descendants(include_self: include_self).as_ids
end
alias_method :recursive_self_and_descendant_ids, :self_and_descendant_ids
+
+ def self_and_hierarchy
+ Gitlab::ObjectHierarchy.new(all).all_objects
+ end
+ alias_method :recursive_self_and_hierarchy, :self_and_hierarchy
end
end
end
diff --git a/app/models/namespaces/user_namespace.rb b/app/models/namespaces/user_namespace.rb
index 14b867b2607..408acb6dcce 100644
--- a/app/models/namespaces/user_namespace.rb
+++ b/app/models/namespaces/user_namespace.rb
@@ -25,5 +25,9 @@ module Namespaces
def self.sti_name
'User'
end
+
+ def owners
+ Array.wrap(owner)
+ end
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index a143c21c0f9..3f3fa968393 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -27,10 +27,14 @@ class Note < ApplicationRecord
redact_field :note
- TYPES_RESTRICTED_BY_ABILITY = {
+ TYPES_RESTRICTED_BY_PROJECT_ABILITY = {
branch: :download_code
}.freeze
+ TYPES_RESTRICTED_BY_GROUP_ABILITY = {
+ contact: :read_crm_contact
+ }.freeze
+
# 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_by, :updated_by
@@ -119,7 +123,7 @@ class Note < ApplicationRecord
scope :inc_author, -> { includes(:author) }
scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) }
scope :inc_relations_for_view, -> do
- includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
+ includes({ project: :group }, { author: :status }, :updated_by, :resolved_by, :award_emoji,
{ system_note_metadata: :description_version }, :note_diff_file, :diff_note_positions, :suggestions)
end
@@ -565,10 +569,10 @@ class Note < ApplicationRecord
noteable.user_mentions.where(note: self)
end
- def system_note_with_references_visible_for?(user)
+ def system_note_visible_for?(user)
return true unless system?
- (!system_note_with_references? || all_referenced_mentionables_allowed?(user)) && system_note_viewable_by?(user)
+ system_note_viewable_by?(user) && all_referenced_mentionables_allowed?(user)
end
def parent_user
@@ -617,10 +621,17 @@ class Note < ApplicationRecord
def system_note_viewable_by?(user)
return true unless system_note_metadata
- restriction = TYPES_RESTRICTED_BY_ABILITY[system_note_metadata.action.to_sym]
- return Ability.allowed?(user, restriction, project) if restriction
+ system_note_viewable_by_project_ability?(user) && system_note_viewable_by_group_ability?(user)
+ end
- true
+ def system_note_viewable_by_project_ability?(user)
+ project_restriction = TYPES_RESTRICTED_BY_PROJECT_ABILITY[system_note_metadata.action.to_sym]
+ !project_restriction || Ability.allowed?(user, project_restriction, project)
+ end
+
+ def system_note_viewable_by_group_ability?(user)
+ group_restriction = TYPES_RESTRICTED_BY_GROUP_ABILITY[system_note_metadata.action.to_sym]
+ !group_restriction || Ability.allowed?(user, group_restriction, project&.group)
end
def keep_around_commit
@@ -646,6 +657,8 @@ class Note < ApplicationRecord
end
def all_referenced_mentionables_allowed?(user)
+ return true unless system_note_with_references?
+
if user_visible_reference_count.present? && total_reference_count.present?
# if they are not equal, then there are private/confidential references as well
user_visible_reference_count > 0 && user_visible_reference_count == total_reference_count
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index 4a97ae97ea0..c76473c9438 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -138,7 +138,7 @@ class Packages::Package < ApplicationRecord
scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) }
scope :has_version, -> { where.not(version: nil) }
- scope :preload_files, -> { Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml) ? preload(:installable_package_files) : preload(:package_files) }
+ scope :preload_files, -> { preload(:installable_package_files) }
scope :preload_pipelines, -> { preload(pipelines: :user) }
scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) }
scope :limit_recent, ->(limit) { order_created_desc.limit(limit) }
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index 190081c4e8e..fc7c348dfdb 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -55,12 +55,11 @@ class Packages::PackageFile < ApplicationRecord
end
scope :for_helm_with_channel, ->(project, channel) do
- result = joins(:package)
- .merge(project.packages.helm.installable)
- .joins(:helm_file_metadatum)
- .where(packages_helm_file_metadata: { channel: channel })
- result = result.installable if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- result
+ joins(:package)
+ .merge(project.packages.helm.installable)
+ .joins(:helm_file_metadatum)
+ .where(packages_helm_file_metadata: { channel: channel })
+ .installable
end
scope :with_conan_file_type, ->(file_type) do
@@ -110,13 +109,9 @@ class Packages::PackageFile < ApplicationRecord
cte_name = :packages_cte
cte = Gitlab::SQL::CTE.new(cte_name, packages.select(:id))
- package_files = if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- ::Packages::PackageFile.installable.limit_recent(1)
- .where(arel_table[:package_id].eq(Arel.sql("#{cte_name}.id")))
- else
- ::Packages::PackageFile.limit_recent(1)
- .where(arel_table[:package_id].eq(Arel.sql("#{cte_name}.id")))
- end
+ package_files = ::Packages::PackageFile.installable
+ .limit_recent(1)
+ .where(arel_table[:package_id].eq(Arel.sql("#{cte_name}.id")))
package_files = package_files.joins(extra_join) if extra_join
package_files = package_files.where(extra_where) if extra_where
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index c21027455b1..2804588be85 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -245,8 +245,8 @@ class PagesDomain < ApplicationRecord
def validate_pages_domain
return unless domain
- if domain.downcase.ends_with?(Settings.pages.host.downcase)
- self.errors.add(:domain, "*.#{Settings.pages.host} is restricted. Please compare our documentation at https://docs.gitlab.com/ee/administration/pages/#advanced-configuration against your configuration.")
+ if domain.downcase.ends_with?(".#{Settings.pages.host.downcase}") || domain.casecmp(Settings.pages.host) == 0
+ self.errors.add(:domain, "#{Settings.pages.host} and its subdomains cannot be used as custom pages domains. Please compare our documentation at https://docs.gitlab.com/ee/administration/pages/#advanced-configuration against your configuration.")
end
end
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 1778e927dd1..2f515f3443d 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -33,6 +33,7 @@ class PersonalAccessToken < ApplicationRecord
scope :preload_users, -> { preload(:user) }
scope :order_expires_at_asc, -> { reorder(expires_at: :asc) }
scope :order_expires_at_desc, -> { reorder(expires_at: :desc) }
+ scope :project_access_token, -> { includes(:user).where(user: { user_type: :project_bot }) }
validates :scopes, presence: true
validate :validate_scopes
@@ -93,6 +94,10 @@ class PersonalAccessToken < ApplicationRecord
"#{self.class.token_prefix}#{token}"
end
+ def project_access_token?
+ user&.project_bot?
+ end
+
protected
def validate_scopes
diff --git a/app/models/preloaders/single_hierarchy_project_group_plans_preloader.rb b/app/models/preloaders/single_hierarchy_project_group_plans_preloader.rb
new file mode 100644
index 00000000000..179214666ed
--- /dev/null
+++ b/app/models/preloaders/single_hierarchy_project_group_plans_preloader.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Preloaders
+ class SingleHierarchyProjectGroupPlansPreloader
+ attr_reader :projects
+
+ def initialize(projects_relation)
+ @projects = projects_relation
+ end
+
+ def execute
+ # no-op in FOSS
+ end
+ end
+end
+
+Preloaders::SingleHierarchyProjectGroupPlansPreloader.prepend_mod_with('Preloaders::SingleHierarchyProjectGroupPlansPreloader')
diff --git a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb b/app/models/preloaders/users_max_access_level_in_projects_preloader.rb
new file mode 100644
index 00000000000..b4ce61a869c
--- /dev/null
+++ b/app/models/preloaders/users_max_access_level_in_projects_preloader.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Preloaders
+ # This class preloads the max access level (role) for the users within the given projects and
+ # stores the values in requests store via the ProjectTeam class.
+ class UsersMaxAccessLevelInProjectsPreloader
+ def initialize(projects:, users:)
+ @projects = projects
+ @users = users
+ end
+
+ def execute
+ return unless @projects.present? && @users.present?
+
+ access_levels.each do |(project_id, user_id), access_level|
+ project = projects_by_id[project_id]
+
+ project.team.write_member_access_for_user_id(user_id, access_level)
+ end
+ end
+
+ private
+
+ def access_levels
+ ProjectAuthorization
+ .where(project_id: project_ids, user_id: user_ids)
+ .group(:project_id, :user_id)
+ .maximum(:access_level)
+ end
+
+ # Use reselect to override the existing select to prevent
+ # the error `subquery has too many columns`
+ # NotificationsController passes in an Array so we need to check the type
+ def project_ids
+ @projects.is_a?(ActiveRecord::Relation) ? @projects.reselect(:id) : @projects
+ end
+
+ def user_ids
+ @users.is_a?(ActiveRecord::Relation) ? @users.reselect(:id) : @users
+ end
+
+ def projects_by_id
+ @projects_by_id ||= @projects.index_by(&:id)
+ end
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 7f823b5ed6b..512c6ac1acb 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -74,6 +74,21 @@ class Project < ApplicationRecord
GL_REPOSITORY_TYPES = [Gitlab::GlRepository::PROJECT, Gitlab::GlRepository::WIKI, Gitlab::GlRepository::DESIGN].freeze
+ MAX_SUGGESTIONS_TEMPLATE_LENGTH = 255
+ MAX_COMMIT_TEMPLATE_LENGTH = 500
+
+ DEFAULT_MERGE_COMMIT_TEMPLATE = <<~MSG.rstrip.freeze
+ Merge branch '%{source_branch}' into '%{target_branch}'
+
+ %{title}
+
+ %{issues}
+
+ See merge request %{reference}
+ MSG
+
+ DEFAULT_SQUASH_COMMIT_TEMPLATE = '%{title}'
+
cache_markdown_field :description, pipeline: :description
default_value_for :packages_enabled, true
@@ -506,11 +521,12 @@ class Project < ApplicationRecord
validates :variables, nested_attributes_duplicates: { scope: :environment_scope }
validates :bfg_object_map, file_size: { maximum: :max_attachment_size }
validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
- validates :suggestion_commit_message, length: { maximum: 255 }
+ validates :suggestion_commit_message, length: { maximum: MAX_SUGGESTIONS_TEMPLATE_LENGTH }
# Scopes
scope :pending_delete, -> { where(pending_delete: true) }
scope :without_deleted, -> { where(pending_delete: false) }
+ scope :not_aimed_for_deletion, -> { where(marked_for_deletion_at: nil).without_deleted }
scope :with_storage_feature, ->(feature) do
where(arel_table[:storage_version].gteq(HASHED_STORAGE_FEATURES[feature]))
@@ -727,6 +743,7 @@ class Project < ApplicationRecord
scope :joins_import_state, -> { joins("INNER JOIN project_mirror_data import_state ON import_state.project_id = projects.id") }
scope :for_group, -> (group) { where(group: group) }
scope :for_group_and_its_subgroups, ->(group) { where(namespace_id: group.self_and_descendants.select(:id)) }
+ scope :for_group_and_its_ancestor_groups, ->(group) { where(namespace_id: group.self_and_ancestors.select(:id)) }
class << self
# Searches for a list of projects based on the query given in `query`.
@@ -987,7 +1004,7 @@ class Project < ApplicationRecord
end
def context_commits_enabled?
- Feature.enabled?(:context_commits, self, default_enabled: :yaml)
+ Feature.enabled?(:context_commits, self.group, default_enabled: :yaml)
end
# LFS and hashed repository storage are required for using Design Management.
@@ -1513,9 +1530,25 @@ class Project < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def owner
+ # This will be phased out and replaced with `owners` relationship
+ # backed by memberships with direct/inherited Owner access roles
+ # See https://gitlab.com/groups/gitlab-org/-/epics/7405
+ group || namespace.try(:owner)
+ end
+
+ def deprecated_owner
+ # Kept in order to maintain webhook structures until we remove owner_name and owner_email
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/350603
group || namespace.try(:owner)
end
+ def owners
+ # This will be phased out and replaced with `owners` relationship
+ # backed by memberships with direct/inherited Owner access roles
+ # See https://gitlab.com/groups/gitlab-org/-/epics/7405
+ team.owners
+ end
+
def first_owner
obj = owner
@@ -2168,14 +2201,6 @@ class Project < ApplicationRecord
end
end
- def ci_instance_variables_for(ref:)
- if protected_for?(ref)
- Ci::InstanceVariable.all_cached
- else
- Ci::InstanceVariable.unprotected_cached
- end
- end
-
def protected_for?(ref)
raise Repository::AmbiguousRefError if repository.ambiguous_ref?(ref)
@@ -2610,6 +2635,14 @@ class Project < ApplicationRecord
[project&.id, root_group&.id]
end
+ def related_group_ids
+ ids = invited_group_ids
+
+ ids += group.self_and_ancestors_ids if group
+
+ ids
+ end
+
def package_already_taken?(package_name, package_version, package_type:)
Packages::Package.with_name(package_name)
.with_version(package_version)
@@ -2746,6 +2779,32 @@ class Project < ApplicationRecord
].compact.min
end
+ def merge_commit_template_or_default
+ merge_commit_template.presence || DEFAULT_MERGE_COMMIT_TEMPLATE
+ end
+
+ def merge_commit_template_or_default=(value)
+ project_setting.merge_commit_template =
+ if value.blank? || value.delete("\r") == DEFAULT_MERGE_COMMIT_TEMPLATE
+ nil
+ else
+ value
+ end
+ end
+
+ def squash_commit_template_or_default
+ squash_commit_template.presence || DEFAULT_SQUASH_COMMIT_TEMPLATE
+ end
+
+ def squash_commit_template_or_default=(value)
+ project_setting.squash_commit_template =
+ if value.blank? || value.delete("\r") == DEFAULT_SQUASH_COMMIT_TEMPLATE
+ nil
+ else
+ value
+ end
+ end
+
private
# overridden in EE
@@ -2754,6 +2813,12 @@ class Project < ApplicationRecord
end
def save_topics
+ topic_ids_before = self.topic_ids
+ update_topics
+ Projects::Topic.update_non_private_projects_counter(topic_ids_before, self.topic_ids, visibility_level_previously_was, visibility_level)
+ end
+
+ def update_topics
return if @topic_list.nil?
@topic_list = @topic_list.split(',') if @topic_list.instance_of?(String)
diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb
index 633e669b5fc..0f04eb7d4af 100644
--- a/app/models/project_import_state.rb
+++ b/app/models/project_import_state.rb
@@ -57,6 +57,12 @@ class ProjectImportState < ApplicationRecord
end
end
+ after_transition any => :failed do |state, _|
+ if Feature.enabled?(:remove_import_data_on_failure, state.project, default_enabled: :yaml)
+ state.project.remove_import_data
+ end
+ end
+
after_transition started: :finished do |state, _|
project = state.project
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index 4e37174e604..ae3d7038a88 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class ProjectSetting < ApplicationRecord
+ include IgnorableColumns
+
+ ignore_column :show_diff_preview_in_email, remove_with: '14.10', remove_after: '2022-03-22'
+
belongs_to :project, inverse_of: :project_setting
enum squash_option: {
@@ -12,8 +16,12 @@ class ProjectSetting < ApplicationRecord
self.primary_key = :project_id
- validates :merge_commit_template, length: { maximum: 500 }
- validates :squash_commit_template, length: { maximum: 500 }
+ validates :merge_commit_template, length: { maximum: Project::MAX_COMMIT_TEMPLATE_LENGTH }
+ validates :squash_commit_template, length: { maximum: Project::MAX_COMMIT_TEMPLATE_LENGTH }
+
+ default_value_for(:legacy_open_source_license_available) do
+ Feature.enabled?(:legacy_open_source_license_available, default_enabled: :yaml, type: :ops)
+ end
def squash_enabled_by_default?
%w[always default_on].include?(squash_option)
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index 8061554006d..c3c7508df9f 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -107,6 +107,10 @@ class ProjectTeam
end
end
+ def owner?(user)
+ owners.include?(user)
+ end
+
def import(source_project, current_user = nil)
target_project = project
diff --git a/app/models/projects/sync_event.rb b/app/models/projects/sync_event.rb
index 5221b00c55f..7af863c0cf0 100644
--- a/app/models/projects/sync_event.rb
+++ b/app/models/projects/sync_event.rb
@@ -13,4 +13,8 @@ class Projects::SyncEvent < ApplicationRecord
def self.enqueue_worker
::Projects::ProcessSyncEventsWorker.perform_async # rubocop:disable CodeReuse/Worker
end
+
+ def self.upper_bound_count
+ select('COALESCE(MAX(id) - MIN(id) + 1, 0) AS upper_bound_count').to_a.first.upper_bound_count
+ end
end
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
index 8d6f8c3a9ca..78bc2df2e1e 100644
--- a/app/models/projects/topic.rb
+++ b/app/models/projects/topic.rb
@@ -25,6 +25,29 @@ module Projects
def search(query)
fuzzy_search(query, [:name])
end
+
+ def update_non_private_projects_counter(ids_before, ids_after, project_visibility_level_before, project_visibility_level_after)
+ project_visibility_level_before ||= project_visibility_level_after
+
+ topics_to_decrement = []
+ topics_to_increment = []
+ topic_ids_removed = ids_before - ids_after
+ topic_ids_retained = ids_before & ids_after
+ topic_ids_added = ids_after - ids_before
+
+ if project_visibility_level_before > Gitlab::VisibilityLevel::PRIVATE
+ topics_to_decrement += topic_ids_removed
+ topics_to_decrement += topic_ids_retained if project_visibility_level_after == Gitlab::VisibilityLevel::PRIVATE
+ end
+
+ if project_visibility_level_after > Gitlab::VisibilityLevel::PRIVATE
+ topics_to_increment += topic_ids_added
+ topics_to_increment += topic_ids_retained if project_visibility_level_before == Gitlab::VisibilityLevel::PRIVATE
+ end
+
+ where(id: topics_to_increment).update_counters(non_private_projects_count: 1) unless topics_to_increment.empty?
+ where(id: topics_to_decrement).where('non_private_projects_count > 0').update_counters(non_private_projects_count: -1) unless topics_to_decrement.empty?
+ end
end
end
end
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index 68f0ab06bea..0a59d9cef9b 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -54,7 +54,7 @@ class ResourceLabelEvent < ResourceEvent
end
def banzai_render_context(field)
- super.merge(pipeline: :label, only_path: true)
+ super.merge(pipeline: :label, only_path: true, label_url_method: label_url_method)
end
def refresh_invalid_reference
@@ -91,6 +91,10 @@ class ResourceLabelEvent < ResourceEvent
end
end
+ def label_url_method
+ issuable.is_a?(MergeRequest) ? :project_merge_requests_url : :project_issues_url
+ end
+
def expire_etag_cache
issuable.expire_note_etag_cache
end
diff --git a/app/models/state_note.rb b/app/models/state_note.rb
index 5e35f15aac4..93c025a9bf0 100644
--- a/app/models/state_note.rb
+++ b/app/models/state_note.rb
@@ -18,11 +18,11 @@ class StateNote < SyntheticNote
def note_text(html: false)
if event.state == 'closed'
if event.close_after_error_tracking_resolve
- return 'resolved the corresponding error and closed the issue.'
+ return 'resolved the corresponding error and closed the issue'
end
if event.close_auto_resolve_prometheus_alert
- return 'automatically closed this issue because the alert resolved.'
+ return 'automatically closed this incident because the alert resolved'
end
end
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index a3c9db90b5d..0be56d8b4a4 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -24,7 +24,7 @@ class SystemNoteMetadata < ApplicationRecord
opened closed merged duplicate locked unlocked outdated reviewer
tag due_date pinned_embed cherry_pick health_status approved unapproved
status alert_issue_added relate unrelate new_alert_added severity
- attention_requested attention_request_removed
+ attention_requested attention_request_removed contact
].freeze
validates :note, presence: true, unless: :importing?
diff --git a/app/models/user.rb b/app/models/user.rb
index 1d452fc2e50..74832bff9ac 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -149,6 +149,7 @@ class User < ApplicationRecord
has_many :members
has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, class_name: 'GroupMember'
has_many :groups, through: :group_members
+ has_many :groups_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :group_members, source: :group
has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group
has_many :maintainers_groups, -> { where(members: { access_level: Gitlab::Access::MAINTAINER }) }, through: :group_members, source: :group
has_many :developer_groups, -> { where(members: { access_level: ::Gitlab::Access::DEVELOPER }) }, through: :group_members, source: :group
@@ -170,6 +171,7 @@ class User < ApplicationRecord
has_many :project_members, -> { where(requested_at: nil) }
has_many :projects, through: :project_members
has_many :created_projects, foreign_key: :creator_id, class_name: 'Project'
+ has_many :projects_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :project_members, source: :project
has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :starred_projects, through: :users_star_projects, source: :project
has_many :project_authorizations, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
@@ -668,7 +670,8 @@ class User < ApplicationRecord
sanitized_order_sql = Arel.sql(sanitize_sql_array([order, query: query]))
- scope = options[:with_private_emails] ? search_with_secondary_emails(query) : search_with_public_emails(query)
+ scope = options[:with_private_emails] ? with_primary_or_secondary_email(query) : with_public_email(query)
+ scope = scope.or(search_by_name_or_username(query, use_minimum_char_limit: options[:use_minimum_char_limit]))
scope.reorder(sanitized_order_sql, :name)
end
@@ -685,50 +688,32 @@ class User < ApplicationRecord
reorder(:name)
end
- def search_with_public_emails(query)
- return none if query.blank?
-
- query = query.downcase
+ # searches user by given pattern
+ # it compares name and username fields with given pattern
+ # This method uses ILIKE on PostgreSQL.
+ def search_by_name_or_username(query, use_minimum_char_limit: nil)
+ use_minimum_char_limit = user_search_minimum_char_limit if use_minimum_char_limit.nil?
where(
- fuzzy_arel_match(:name, query, use_minimum_char_limit: user_search_minimum_char_limit)
- .or(fuzzy_arel_match(:username, query, use_minimum_char_limit: user_search_minimum_char_limit))
- .or(arel_table[:public_email].eq(query))
+ fuzzy_arel_match(:name, query, use_minimum_char_limit: use_minimum_char_limit)
+ .or(fuzzy_arel_match(:username, query, use_minimum_char_limit: use_minimum_char_limit))
)
end
- def search_without_secondary_emails(query)
- return none if query.blank?
-
- query = query.downcase
-
- where(
- fuzzy_arel_match(:name, query, lower_exact_match: true)
- .or(fuzzy_arel_match(:username, query, lower_exact_match: true))
- .or(arel_table[:email].eq(query))
- )
+ def with_public_email(email_address)
+ where(public_email: email_address)
end
- # searches user by given pattern
- # it compares name, email, username fields and user's secondary emails with given pattern
- # This method uses ILIKE on PostgreSQL.
-
- def search_with_secondary_emails(query)
- return none if query.blank?
-
- query = query.downcase
-
+ def with_primary_or_secondary_email(email_address)
email_table = Email.arel_table
matched_by_email_user_id = email_table
.project(email_table[:user_id])
- .where(email_table[:email].eq(query))
+ .where(email_table[:email].eq(email_address))
.take(1) # at most 1 record as there is a unique constraint
where(
- fuzzy_arel_match(:name, query, use_minimum_char_limit: user_search_minimum_char_limit)
- .or(fuzzy_arel_match(:username, query, use_minimum_char_limit: user_search_minimum_char_limit))
- .or(arel_table[:email].eq(query))
- .or(arel_table[:id].eq(matched_by_email_user_id))
+ arel_table[:email].eq(email_address)
+ .or(arel_table[:id].eq(matched_by_email_user_id))
)
end
@@ -1608,7 +1593,7 @@ class User < ApplicationRecord
.distinct
.reorder(nil)
- Project.where(id: events)
+ Project.where(id: events).not_aimed_for_deletion
end
def can_be_removed?
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 8394192c5ae..5c39e29a128 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -40,8 +40,9 @@ module Users
profile_personal_access_token_expiry: 37, # EE-only
terraform_notification_dismissed: 38,
security_newsletter_callout: 39,
- verification_reminder: 40, # EE-only
- ci_deprecation_warning_for_types_keyword: 41
+ verification_reminder: 40, # EE-only
+ ci_deprecation_warning_for_types_keyword: 41,
+ security_training_feature_promotion: 42 # EE-only
}
validates :feature_name,
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
index da9b95fd718..0dc449719ab 100644
--- a/app/models/users/group_callout.rb
+++ b/app/models/users/group_callout.rb
@@ -9,7 +9,12 @@ module Users
belongs_to :group
enum feature_name: {
- invite_members_banner: 1
+ invite_members_banner: 1,
+ approaching_seat_count_threshold: 2, # EE-only
+ storage_enforcement_banner_first_enforcement_threshold: 43,
+ storage_enforcement_banner_second_enforcement_threshold: 44,
+ storage_enforcement_banner_third_enforcement_threshold: 45,
+ storage_enforcement_banner_fourth_enforcement_threshold: 46
}
validates :group, presence: true
diff --git a/app/models/users_star_project.rb b/app/models/users_star_project.rb
index c633e2d8b3d..1549c099a64 100644
--- a/app/models/users_star_project.rb
+++ b/app/models/users_star_project.rb
@@ -32,7 +32,7 @@ class UsersStarProject < ApplicationRecord
end
def search(query)
- joins(:user).merge(User.search(query))
+ joins(:user).merge(User.search(query, use_minimum_char_limit: false))
end
end
end
diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb
index 4e1f48227d9..a5881e80e88 100644
--- a/app/models/vulnerability.rb
+++ b/app/models/vulnerability.rb
@@ -2,6 +2,7 @@
# Placeholder class for model that is implemented in EE
class Vulnerability < ApplicationRecord
+ include EachBatch
include IgnorableColumns
def self.link_reference_pattern
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index 02f52f04c85..99f05e4a181 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -3,4 +3,8 @@
class WorkItem < Issue
self.table_name = 'issues'
self.inheritance_column = :_type_disabled
+
+ def noteable_target_type_name
+ 'issue'
+ end
end
diff --git a/app/policies/ci/project_pipelines_policy.rb b/app/policies/ci/project_pipelines_policy.rb
new file mode 100644
index 00000000000..aab1208a8fe
--- /dev/null
+++ b/app/policies/ci/project_pipelines_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class ProjectPipelinesPolicy < BasePolicy
+ delegate { @subject.project }
+ end
+end
diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb
index 43478cf36c2..bdbe7021276 100644
--- a/app/policies/ci/runner_policy.rb
+++ b/app/policies/ci/runner_policy.rb
@@ -11,6 +11,10 @@ module Ci
rule { anonymous }.prevent_all
+ rule { admin }.policy do
+ enable :read_builds
+ end
+
rule { admin | owned_runner }.policy do
enable :assign_runner
enable :read_runner
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index fee47fe0ae9..76e5b3ece53 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -100,6 +100,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_group
enable :upload_file
enable :guest_access
+ enable :read_release
end
rule { admin }.policy do
@@ -144,6 +145,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :developer_access
enable :admin_crm_organization
enable :admin_crm_contact
+ enable :read_cluster
end
rule { reporter }.policy do
@@ -166,7 +168,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :create_projects
enable :admin_pipeline
enable :admin_build
- enable :read_cluster
enable :add_cluster
enable :create_cluster
enable :update_cluster
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index d9ea7c38f11..e85f18f2d37 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -16,7 +16,7 @@ class NotePolicy < BasePolicy
condition(:for_design) { @subject.for_design? }
- condition(:is_visible) { @subject.system_note_with_references_visible_for?(@user) }
+ condition(:is_visible) { @subject.system_note_visible_for?(@user) }
condition(:confidential, scope: :subject) { @subject.confidential? }
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 55f43cd9f7b..4cc5ed06d61 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -240,6 +240,7 @@ class ProjectPolicy < BasePolicy
enable :read_wiki
enable :read_issue
enable :read_label
+ enable :read_planning_hierarchy
enable :read_milestone
enable :read_snippet
enable :read_project_member
@@ -258,11 +259,13 @@ class ProjectPolicy < BasePolicy
rule { can?(:reporter_access) & can?(:create_issue) }.enable :create_incident
- rule { can?(:guest_access) & can?(:create_issue) }.policy do
+ rule { can?(:create_issue) }.policy do
enable :create_task
enable :create_work_item
end
+ rule { can?(:update_issue) }.enable :update_work_item
+
# These abilities are not allowed to admins that are not members of the project,
# that's why they are defined separately.
rule { guest & can?(:download_code) }.enable :build_download_code
@@ -385,6 +388,7 @@ class ProjectPolicy < BasePolicy
enable :destroy_environment
enable :create_deployment
enable :update_deployment
+ enable :read_cluster
enable :create_release
enable :update_release
enable :destroy_release
@@ -433,7 +437,6 @@ class ProjectPolicy < BasePolicy
enable :read_pages
enable :update_pages
enable :remove_pages
- enable :read_cluster
enable :add_cluster
enable :create_cluster
enable :update_cluster
@@ -572,6 +575,7 @@ class ProjectPolicy < BasePolicy
enable :read_issue_board_list
enable :read_wiki
enable :read_label
+ enable :read_planning_hierarchy
enable :read_milestone
enable :read_snippet
enable :read_project_member
diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb
new file mode 100644
index 00000000000..7ba5102a406
--- /dev/null
+++ b/app/policies/work_item_policy.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class WorkItemPolicy < BasePolicy
+ delegate { @subject.project }
+
+ desc 'User is author of the work item'
+ condition(:author) do
+ @user && @user == @subject.author
+ end
+
+ rule { can?(:owner_access) | author }.enable :delete_work_item
+end
diff --git a/app/presenters/README.md b/app/presenters/README.md
index dfd1818f97d..31e5c971a88 100644
--- a/app/presenters/README.md
+++ b/app/presenters/README.md
@@ -223,13 +223,9 @@ To add methods of a module to an allowlist, use `delegator_override_with`. For e
```ruby
module Ci
class PipelinePresenter < Gitlab::View::Presenter::Delegated
- include Gitlab::Utils::StrongMemoize
- include ActionView::Helpers::UrlHelper
+ include ActionView::Helpers::TagHelper
- delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
- delegator_override_with ActionView::Helpers::TagHelper # TODO: Remove `ActionView::Helpers::UrlHelper` inclusion as it overrides `Ci::Pipeline#tag`
+ delegator_override_with ActionView::Helpers::TagHelper # TODO: Remove `ActionView::Helpers::TagHelper` inclusion as it overrides `Ci::Pipeline#tag`
```
-Keep in mind that if you use `delegator_override_with`,
-there is a high chance that you're doing **something wrong**.
Read the [Validate Accidental Overrides](#validate-accidental-overrides) for more information.
diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb
index 86fe9859271..b692935d229 100644
--- a/app/presenters/alert_management/alert_presenter.rb
+++ b/app/presenters/alert_management/alert_presenter.rb
@@ -6,7 +6,7 @@ module AlertManagement
include ActionView::Helpers::UrlHelper
presents ::AlertManagement::Alert
- delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
+ delegator_override_with Gitlab::Utils::StrongMemoize # This module inclusion is expected. See https://gitlab.com/gitlab-org/gitlab/-/issues/352884.
MARKDOWN_LINE_BREAK = " \n"
HORIZONTAL_LINE = "\n\n---\n\n"
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index 2577fcaf303..47b72df32a2 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -32,7 +32,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
end
def blob_language
- @_blob_language ||= Gitlab::Diff::CustomDiff.transformed_blob_language(blob) || language
+ @_blob_language ||= Gitlab::Diff::CustomDiff.transformed_blob_language(blob) || gitattr_language || detect_language
end
def raw_plain_data
@@ -79,6 +79,18 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
url_helpers.project_blob_path(project, File.join(project.repository.commit.sha, blob.path))
end
+ def environment_formatted_external_url
+ return unless environment
+
+ environment.formatted_external_url
+ end
+
+ def environment_external_url_for_route_map
+ return unless environment
+
+ environment.external_url_for(blob.path, blob.commit_id)
+ end
+
# Will be overridden in EE
def code_owners
[]
@@ -113,7 +125,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
def external_storage_url
return unless static_objects_external_storage_enabled?
- external_storage_url_or_path(url_helpers.project_raw_url(project, ref_qualified_path))
+ external_storage_url_or_path(url_helpers.project_raw_url(project, ref_qualified_path), project)
end
private
@@ -122,6 +134,12 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
Gitlab::Routing.url_helpers
end
+ def environment
+ environment_params = project.repository.branch_exists?(blob.commit_id) ? { ref: blob.commit_id } : { sha: blob.commit_id }
+ environment_params[:find_latest] = true
+ ::Environments::EnvironmentsByDeploymentsFinder.new(project, current_user, environment_params).execute.last
+ end
+
def project
blob.repository.project
end
@@ -148,9 +166,15 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
@all_lines ||= blob.data.lines
end
- def language
+ def gitattr_language
blob.language_from_gitattributes
end
+
+ def detect_language
+ return if blob.binary?
+
+ Rouge::Lexer.guess(filename: blob.path, source: blob_data(nil)) { |lex| lex.min_by(&:tag) }.tag
+ end
end
BlobPresenter.prepend_mod_with('BlobPresenter')
diff --git a/app/presenters/blobs/unfold_presenter.rb b/app/presenters/blobs/unfold_presenter.rb
index b921b5bf670..90b471abf22 100644
--- a/app/presenters/blobs/unfold_presenter.rb
+++ b/app/presenters/blobs/unfold_presenter.rb
@@ -108,7 +108,7 @@ module Blobs
def limit(lines)
return lines if full?
- lines[since - 1..to - 1]
+ lines[since - 1..to - 1] || []
end
end
end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 7f5dffadcfb..2818e6da036 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -4,7 +4,7 @@ module Ci
class PipelinePresenter < Gitlab::View::Presenter::Delegated
include Gitlab::Utils::StrongMemoize
- delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
+ delegator_override_with Gitlab::Utils::StrongMemoize # This module inclusion is expected. See https://gitlab.com/gitlab-org/gitlab/-/issues/352884.
delegator_override_with ActionView::Helpers::TagHelper # TODO: Remove `ActionView::Helpers::UrlHelper` inclusion as it overrides `Ci::Pipeline#tag`
# We use a class method here instead of a constant, allowing EE to redefine
diff --git a/app/presenters/ci/runner_presenter.rb b/app/presenters/ci/runner_presenter.rb
index ffd826fab64..482534f27b9 100644
--- a/app/presenters/ci/runner_presenter.rb
+++ b/app/presenters/ci/runner_presenter.rb
@@ -15,5 +15,9 @@ module Ci
def executor_name
Ci::Runner::EXECUTOR_TYPE_TO_NAMES[executor_type&.to_sym]
end
+
+ def paused
+ !active
+ end
end
end
diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb
index 4b645510b51..cc466e0ff81 100644
--- a/app/presenters/clusterable_presenter.rb
+++ b/app/presenters/clusterable_presenter.rb
@@ -16,6 +16,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
can?(current_user, :add_cluster, clusterable)
end
+ def can_admin_cluster?
+ can?(current_user, :admin_cluster, clusterable)
+ end
+
def can_create_cluster?
can?(current_user, :create_cluster, clusterable)
end
diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb
index ce060476cfd..e2fc2b4b485 100644
--- a/app/presenters/clusters/cluster_presenter.rb
+++ b/app/presenters/clusters/cluster_presenter.rb
@@ -4,7 +4,7 @@ module Clusters
class ClusterPresenter < Gitlab::View::Presenter::Delegated
include ::Gitlab::Utils::StrongMemoize
- delegator_override_with ::Gitlab::Utils::StrongMemoize # TODO: Remove `::Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
+ delegator_override_with ::Gitlab::Utils::StrongMemoize # This module inclusion is expected. See https://gitlab.com/gitlab-org/gitlab/-/issues/352884.
presents ::Clusters::Cluster, as: :cluster
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index eeb94a8e657..8450679dd79 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -8,7 +8,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
include ChecksCollaboration
include Gitlab::Utils::StrongMemoize
- delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
+ delegator_override_with Gitlab::Utils::StrongMemoize # This module inclusion is expected. See https://gitlab.com/gitlab-org/gitlab/-/issues/352884.
APPROVALS_WIDGET_BASE_TYPE = 'base'
diff --git a/app/presenters/packages/conan/package_presenter.rb b/app/presenters/packages/conan/package_presenter.rb
index 57636922676..0c7a81038dd 100644
--- a/app/presenters/packages/conan/package_presenter.rb
+++ b/app/presenters/packages/conan/package_presenter.rb
@@ -81,11 +81,7 @@ module Packages
return unless @package
strong_memoize(:package_files) do
- if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- @package.installable_package_files.preload_conan_file_metadata
- else
- @package.package_files.preload_conan_file_metadata
- end
+ @package.installable_package_files.preload_conan_file_metadata
end
end
diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb
index c257edcadfb..b82b558f0cd 100644
--- a/app/presenters/packages/detail/package_presenter.rb
+++ b/app/presenters/packages/detail/package_presenter.rb
@@ -39,11 +39,7 @@ module Packages
private
def package_file_views
- package_files = if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- @package.installable_package_files
- else
- @package.package_files
- end
+ package_files = @package.installable_package_files
package_files.map { |pf| build_package_file_view(pf) }
end
diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb
index 1f94187204f..fabb0a36746 100644
--- a/app/presenters/packages/npm/package_presenter.rb
+++ b/app/presenters/packages/npm/package_presenter.rb
@@ -26,11 +26,7 @@ module Packages
.preload_npm_metadatum
batched_packages.each do |package|
- package_file = if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- package.installable_package_files.last
- else
- package.package_files.last
- end
+ package_file = package.installable_package_files.last
next unless package_file
diff --git a/app/presenters/packages/nuget/presenter_helpers.rb b/app/presenters/packages/nuget/presenter_helpers.rb
index cd3e123033c..5334e4aa6f8 100644
--- a/app/presenters/packages/nuget/presenter_helpers.rb
+++ b/app/presenters/packages/nuget/presenter_helpers.rb
@@ -27,13 +27,10 @@ module Packages
end
def archive_url_for(package)
- package_files = if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- package.installable_package_files
- else
- package.package_files
- end
-
- package_filename = package_files.with_format(NUGET_PACKAGE_FORMAT).last&.file_name
+ package_filename = package.installable_package_files
+ .with_format(NUGET_PACKAGE_FORMAT)
+ .last
+ &.file_name
path = api_v4_projects_packages_nuget_download_package_name_package_version_package_filename_path(
{
id: package.project_id,
diff --git a/app/presenters/packages/pypi/package_presenter.rb b/app/presenters/packages/pypi/package_presenter.rb
index 33854e4d2fc..a779ce41cf9 100644
--- a/app/presenters/packages/pypi/package_presenter.rb
+++ b/app/presenters/packages/pypi/package_presenter.rb
@@ -36,11 +36,7 @@ module Packages
refs = []
@packages.map do |package|
- package_files = if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- package.installable_package_files
- else
- package.package_files
- end
+ package_files = package.installable_package_files
package_files.each do |file|
url = build_pypi_package_path(file)
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 64cd54953e2..9e64d2d43a2 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -13,7 +13,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
include Gitlab::Experiment::Dsl
delegator_override_with GitlabRoutingHelper # TODO: Remove `GitlabRoutingHelper` inclusion as it's duplicate
- delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
+ delegator_override_with Gitlab::Utils::StrongMemoize # This module inclusion is expected. See https://gitlab.com/gitlab-org/gitlab/-/issues/352884.
presents ::Project, as: :project
diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb
index 7b2ffb6d755..53c547cde9e 100644
--- a/app/presenters/projects/import_export/project_export_presenter.rb
+++ b/app/presenters/projects/import_export/project_export_presenter.rb
@@ -3,11 +3,14 @@
module Projects
module ImportExport
class ProjectExportPresenter < Gitlab::View::Presenter::Delegated
+ # NOTE: This is needed because this presenter is serialized to JSON,
+ # and we need to make sure that `#as_json` is called in this class so
+ # it will use the overriden attributes below. Otherwise the call is
+ # delegated to the model and will use the original methods.
include ActiveModel::Serializers::JSON
presents ::Project, as: :project
- # TODO: Remove `ActiveModel::Serializers::JSON` inclusion as it's duplicate
delegator_override_with ActiveModel::Serializers::JSON
delegator_override_with ActiveModel::Naming
delegator_override :include_root_in_json, :include_root_in_json?
diff --git a/app/presenters/projects/security/configuration_presenter.rb b/app/presenters/projects/security/configuration_presenter.rb
index 89fca1a451a..1798d4b780f 100644
--- a/app/presenters/projects/security/configuration_presenter.rb
+++ b/app/presenters/projects/security/configuration_presenter.rb
@@ -80,7 +80,8 @@ module Projects
type: scan.type,
configured: scan.configured?,
configuration_path: scan.configuration_path,
- available: scan.available?
+ available: scan.available?,
+ can_enable_by_merge_request: scan.can_enable_by_merge_request?
}
end
diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb
index 026d442291c..51ce6ccea58 100644
--- a/app/presenters/snippet_blob_presenter.rb
+++ b/app/presenters/snippet_blob_presenter.rb
@@ -33,7 +33,7 @@ class SnippetBlobPresenter < BlobPresenter
blob.container
end
- def language
+ def gitattr_language
nil
end
diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb
index dc42d7f52ad..a92214d0efa 100644
--- a/app/serializers/analytics_summary_entity.rb
+++ b/app/serializers/analytics_summary_entity.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class AnalyticsSummaryEntity < Grape::Entity
+ expose :identifier
expose :value, safe: true
expose :title
expose :unit, if: { with_unit: true }
diff --git a/app/serializers/codequality_degradation_entity.rb b/app/serializers/codequality_degradation_entity.rb
index be561052507..6289260465b 100644
--- a/app/serializers/codequality_degradation_entity.rb
+++ b/app/serializers/codequality_degradation_entity.rb
@@ -2,7 +2,9 @@
class CodequalityDegradationEntity < Grape::Entity
expose :description
- expose :severity
+ expose :severity do |degradation|
+ degradation.dig(:severity)&.downcase
+ end
expose :file_path do |degradation|
degradation.dig(:location, :path)
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 11445f93609..8d9b73b2290 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -52,17 +52,13 @@ class EnvironmentSerializer < BaseSerializer
end
def batch_load(resource)
- if ::Feature.enabled?(:custom_preloader_for_deployments, default_enabled: :yaml)
- resource = resource.preload(environment_associations.except(:last_deployment, :upcoming_deployment))
+ resource = resource.preload(environment_associations.except(:last_deployment, :upcoming_deployment))
- Preloaders::Environments::DeploymentPreloader.new(resource)
- .execute_with_union(:last_deployment, deployment_associations)
+ Preloaders::Environments::DeploymentPreloader.new(resource)
+ .execute_with_union(:last_deployment, deployment_associations)
- Preloaders::Environments::DeploymentPreloader.new(resource)
- .execute_with_union(:upcoming_deployment, deployment_associations)
- else
- resource = resource.preload(environment_associations)
- end
+ Preloaders::Environments::DeploymentPreloader.new(resource)
+ .execute_with_union(:upcoming_deployment, deployment_associations)
resource.all.to_a.tap do |environments|
environments.each do |environment|
diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb
index c469dbdd6b8..08070c03bf8 100644
--- a/app/serializers/group_child_entity.rb
+++ b/app/serializers/group_child_entity.rb
@@ -58,6 +58,10 @@ class GroupChildEntity < Grape::Entity
end
end
+ expose :can_remove, unless: lambda { |_instance, _options| project? } do |group|
+ can?(request.current_user, :admin_group, group)
+ end
+
expose :number_users_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
number_with_delimiter(instance.member_count)
end
diff --git a/app/serializers/issuable_sidebar_basic_entity.rb b/app/serializers/issuable_sidebar_basic_entity.rb
index 14e416fb71a..b66aad6cc65 100644
--- a/app/serializers/issuable_sidebar_basic_entity.rb
+++ b/app/serializers/issuable_sidebar_basic_entity.rb
@@ -110,6 +110,7 @@ class IssuableSidebarBasicEntity < Grape::Entity
expose :supports_time_tracking?, as: :supports_time_tracking
expose :supports_milestone?, as: :supports_milestone
expose :supports_severity?, as: :supports_severity
+ expose :supports_escalation?, as: :supports_escalation
private
diff --git a/app/serializers/issue_sidebar_basic_entity.rb b/app/serializers/issue_sidebar_basic_entity.rb
index f93a42e5f98..9c6601afd5e 100644
--- a/app/serializers/issue_sidebar_basic_entity.rb
+++ b/app/serializers/issue_sidebar_basic_entity.rb
@@ -4,6 +4,12 @@ class IssueSidebarBasicEntity < IssuableSidebarBasicEntity
expose :due_date
expose :confidential
expose :severity
+
+ expose :current_user, merge: true do
+ expose :can_update_escalation_status, if: -> (issue, _) { issue.supports_escalation? } do |issue|
+ can?(current_user, :update_escalation_status, issue.project)
+ end
+ end
end
IssueSidebarBasicEntity.prepend_mod_with('IssueSidebarBasicEntity')
diff --git a/app/serializers/member_user_entity.rb b/app/serializers/member_user_entity.rb
index 01920fc95bb..fde3282ad25 100644
--- a/app/serializers/member_user_entity.rb
+++ b/app/serializers/member_user_entity.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class MemberUserEntity < UserEntity
- unexpose :show_status
unexpose :path
unexpose :state
unexpose :status_tooltip_html
diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb
index 5bf02c93c99..9d001d18aa6 100644
--- a/app/serializers/merge_request_poll_cached_widget_entity.rb
+++ b/app/serializers/merge_request_poll_cached_widget_entity.rb
@@ -8,7 +8,6 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :merged_commit_sha
expose :short_merged_commit_sha
expose :merge_error
- expose :public_merge_status, as: :merge_status
expose :merge_user_id
expose :source_branch
expose :source_project_id
@@ -26,6 +25,11 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :source_branch_exists?, as: :source_branch_exists
expose :branch_missing?, as: :branch_missing
+ expose :merge_status do |merge_request|
+ merge_request.check_mergeability(async: true)
+ merge_request.public_merge_status
+ end
+
expose :default_squash_commit_message do |merge_request|
merge_request.default_squash_commit_message(user: request.current_user)
end
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index f68477e82c9..12998d70a22 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -24,8 +24,6 @@ class MergeRequestPollWidgetEntity < Grape::Entity
end
expose :mergeable do |merge_request, options|
- next merge_request.mergeable? if Feature.disabled?(:check_mergeability_async_in_widget, merge_request.project, default_enabled: :yaml)
-
merge_request.mergeable?
end
diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb
index 0e64b843fd3..8a5fadf53a6 100644
--- a/app/serializers/test_case_entity.rb
+++ b/app/serializers/test_case_entity.rb
@@ -4,7 +4,7 @@ class TestCaseEntity < Grape::Entity
include API::Helpers::RelatedResourcesHelpers
expose :status
- expose :name
+ expose :name, default: "(No name)"
expose :classname
expose :file
expose :execution_time
diff --git a/app/services/alert_management/alerts/update_service.rb b/app/services/alert_management/alerts/update_service.rb
index 7a9bcf2a52d..0769adc862e 100644
--- a/app/services/alert_management/alerts/update_service.rb
+++ b/app/services/alert_management/alerts/update_service.rb
@@ -12,6 +12,7 @@ module AlertManagement
@alert = alert
@param_errors = []
@status = params.delete(:status)
+ @status_change_reason = params.delete(:status_change_reason)
super(project: alert.project, current_user: current_user, params: params)
end
@@ -36,7 +37,7 @@ module AlertManagement
private
- attr_reader :alert, :param_errors, :status
+ attr_reader :alert, :param_errors, :status, :status_change_reason
def allowed?
current_user&.can?(:update_alert_management_alert, alert)
@@ -133,7 +134,7 @@ module AlertManagement
end
def add_status_change_system_note
- SystemNoteService.change_alert_status(alert, current_user)
+ SystemNoteService.change_alert_status(alert, current_user, status_change_reason)
end
def resolve_todos
@@ -144,13 +145,17 @@ module AlertManagement
::Issues::UpdateService.new(
project: project,
current_user: current_user,
- params: { escalation_status: { status: status } }
+ params: {
+ escalation_status: {
+ status: status,
+ status_change_reason: " by changing the status of #{alert.to_reference(project)}"
+ }
+ }
).execute(alert.issue)
end
def should_sync_to_incident?
- Feature.enabled?(:incident_escalations, project) &&
- alert.issue &&
+ alert.issue &&
alert.issue.supports_escalation? &&
alert.issue.escalation_status &&
alert.issue.escalation_status.status != alert.status
diff --git a/app/services/alert_management/create_alert_issue_service.rb b/app/services/alert_management/create_alert_issue_service.rb
index a81c2380dad..ab8d1176b9e 100644
--- a/app/services/alert_management/create_alert_issue_service.rb
+++ b/app/services/alert_management/create_alert_issue_service.rb
@@ -22,8 +22,6 @@ module AlertManagement
return result unless result.success?
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)
@@ -47,14 +45,11 @@ module AlertManagement
user,
title: alert_presenter.title,
description: alert_presenter.issue_description,
- severity: alert.severity
+ severity: alert.severity,
+ alert: alert
).execute
end
- def associate_alert_with_issue(issue)
- alert.update(issue_id: issue.id)
- end
-
def update_title_for(issue)
return unless issue.title == DEFAULT_ALERT_TITLE
@@ -78,9 +73,5 @@ module AlertManagement
alert.present
end
end
-
- def object_errors(object)
- object.errors.full_messages.to_sentence
- end
end
end
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index f2b1d89161c..ad733c455a9 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -5,7 +5,7 @@ class AuditEventService
# Instantiates a new service
#
- # @param [User] author the user who authors the change
+ # @param [User, token String] author the entity who authors the change
# @param [User, Project, Group] entity the scope which audit event belongs to
# This param is also used to determine the visibility of the audit event.
# - Project: events are visible at Project and Instance level
@@ -44,7 +44,7 @@ class AuditEventService
# Writes event to a file and creates an event record in DB
#
- # @return [AuditEvent] persited if saves and non-persisted if fails
+ # @return [AuditEvent] persisted if saves and non-persisted if fails
def security_event
log_security_event_to_file
log_authentication_event_to_database
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index a92a2c8aef5..84518fd6b0e 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -14,6 +14,10 @@ module Auth
:build_destroy_container_image
].freeze
+ FORBIDDEN_IMPORTING_SCOPES = %w[push delete *].freeze
+
+ ActiveImportError = Class.new(StandardError)
+
def execute(authentication_abilities:)
@authentication_abilities = authentication_abilities
@@ -26,17 +30,27 @@ module Auth
end
{ token: authorized_token(*scopes).encoded }
+ rescue ActiveImportError
+ error(
+ 'DENIED',
+ status: 403,
+ message: 'Your repository is currently being migrated to a new platform and writes are temporarily disabled. Go to https://gitlab.com/groups/gitlab-org/-/epics/5523 to learn more.'
+ )
end
def self.full_access_token(*names)
access_token(%w(*), names)
end
+ def self.import_access_token
+ access_token(%w(*), ['import'], 'registry')
+ end
+
def self.pull_access_token(*names)
access_token(['pull'], names)
end
- def self.access_token(actions, names)
+ def self.access_token(actions, names, type = 'repository')
names = names.flatten
registry = Gitlab.config.registry
token = JSONWebToken::RSAToken.new(registry.key)
@@ -46,10 +60,10 @@ module Auth
token[:access] = names.map do |name|
{
- type: 'repository',
+ type: type,
name: name,
actions: actions,
- migration_eligible: migration_eligible(repository_path: name)
+ migration_eligible: type == 'repository' ? migration_eligible(repository_path: name) : nil
}.compact
end
@@ -104,6 +118,8 @@ module Auth
def process_repository_access(type, path, actions)
return unless path.valid?
+ raise ActiveImportError if actively_importing?(actions, path)
+
requested_project = path.repository_project
return unless requested_project
@@ -124,11 +140,19 @@ module Auth
type: type,
name: path.to_s,
actions: authorized_actions,
- migration_eligible: self.class.migration_eligible(project: requested_project),
- cdn_redirect: cdn_redirect
+ migration_eligible: self.class.migration_eligible(project: requested_project)
}.compact
end
+ def actively_importing?(actions, path)
+ return false if FORBIDDEN_IMPORTING_SCOPES.intersection(actions).empty?
+
+ container_repository = ContainerRepository.find_by_path(path)
+ return false unless container_repository
+
+ container_repository.migration_importing?
+ end
+
def self.migration_eligible(project: nil, repository_path: nil)
return unless Feature.enabled?(:container_registry_migration_phase1)
@@ -151,13 +175,6 @@ module Auth
false
end
- # This is used to determine whether blob download requests using a given JWT token should be redirected to Google
- # Cloud CDN or not. The intent is to enable a percentage of time rollout for this new feature on the Container
- # Registry side. See https://gitlab.com/gitlab-org/gitlab/-/issues/349417 for more details.
- def cdn_redirect
- Feature.enabled?(:container_registry_cdn_redirect) || nil
- end
-
##
# Because we do not have two way communication with registry yet,
# we create a container repository image resource when push to the
diff --git a/app/services/boards/base_item_move_service.rb b/app/services/boards/base_item_move_service.rb
index be16c595abb..9d711d83fd2 100644
--- a/app/services/boards/base_item_move_service.rb
+++ b/app/services/boards/base_item_move_service.rb
@@ -39,7 +39,7 @@ module Boards
end
def reposition_params(reposition_ids)
- reposition_parent.merge(move_between_ids: reposition_ids)
+ { move_between_ids: reposition_ids }
end
def move_single_issuable(issuable, issuable_modification_params)
@@ -91,7 +91,7 @@ module Boards
end
def move_between_ids(move_params)
- ids = [move_params[:move_after_id], move_params[:move_before_id]]
+ ids = [move_params[:move_before_id], move_params[:move_after_id]]
.map(&:to_i)
.map { |m| m > 0 ? m : nil }
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
index 959a7fa3ad2..90226b9d4e0 100644
--- a/app/services/boards/issues/move_service.rb
+++ b/app/services/boards/issues/move_service.rb
@@ -54,10 +54,6 @@ module Boards
def update(issue, issue_modification_params)
::Issues::UpdateService.new(project: issue.project, current_user: current_user, params: issue_modification_params).execute(issue)
end
-
- def reposition_parent
- { board_group_id: board.group&.id }
- end
end
end
end
diff --git a/app/services/branches/create_service.rb b/app/services/branches/create_service.rb
index b5faf2ec281..7300b31e3b3 100644
--- a/app/services/branches/create_service.rb
+++ b/app/services/branches/create_service.rb
@@ -21,7 +21,7 @@ module Branches
error("Failed to create branch '#{branch_name}': invalid reference name '#{ref}'")
end
rescue Gitlab::Git::PreReceiveError => e
- Gitlab::ErrorTracking.track_exception(e, pre_receive_message: e.raw_message, branch_name: branch_name, ref: ref)
+ Gitlab::ErrorTracking.log_exception(e, pre_receive_message: e.raw_message, branch_name: branch_name, ref: ref)
error(e.message)
end
diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb
index ee0ae6651ca..097b29cf143 100644
--- a/app/services/ci/after_requeue_job_service.rb
+++ b/app/services/ci/after_requeue_job_service.rb
@@ -22,13 +22,9 @@ module Ci
end
def dependent_jobs
- if ::Feature.enabled?(:ci_order_subsequent_jobs_by_stage, @processable.pipeline.project, default_enabled: :yaml)
- stage_dependent_jobs
- .or(needs_dependent_jobs.except(:preload))
- .ordered_by_stage
- else
- stage_dependent_jobs | needs_dependent_jobs
- end
+ stage_dependent_jobs
+ .or(needs_dependent_jobs.except(:preload))
+ .ordered_by_stage
end
def process(job)
diff --git a/app/services/ci/copy_cross_database_associations_service.rb b/app/services/ci/copy_cross_database_associations_service.rb
new file mode 100644
index 00000000000..c69e966dc04
--- /dev/null
+++ b/app/services/ci/copy_cross_database_associations_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Ci
+ class CopyCrossDatabaseAssociationsService
+ def execute(old_build, new_build)
+ ServiceResponse.success
+ end
+ end
+end
+
+Ci::CopyCrossDatabaseAssociationsService.prepend_mod_with('Ci::CopyCrossDatabaseAssociationsService')
diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb
index a65c22e273c..a2e53cbf9b8 100644
--- a/app/services/ci/create_downstream_pipeline_service.rb
+++ b/app/services/ci/create_downstream_pipeline_service.rb
@@ -125,7 +125,9 @@ module Ci
config_checksum(pipeline) unless pipeline.child?
end
- pipeline_checksums.uniq.length != pipeline_checksums.length
+ # To avoid false positives we allow 1 cycle in the ancestry and
+ # fail when 2 cycles are detected: A -> B -> A -> B -> A
+ pipeline_checksums.tally.any? { |_checksum, occurrences| occurrences > 2 }
end
end
@@ -137,7 +139,7 @@ module Ci
end
def config_checksum(pipeline)
- [pipeline.project_id, pipeline.ref].hash
+ [pipeline.project_id, pipeline.ref, pipeline.source].hash
end
end
end
diff --git a/app/services/ci/pipeline_schedule_service.rb b/app/services/ci/pipeline_schedule_service.rb
index 596c3b80bda..536eaa56f9b 100644
--- a/app/services/ci/pipeline_schedule_service.rb
+++ b/app/services/ci/pipeline_schedule_service.rb
@@ -3,6 +3,8 @@
module Ci
class PipelineScheduleService < BaseService
def execute(schedule)
+ return unless project.persisted?
+
# Ensure `next_run_at` is set properly before creating a pipeline.
# Otherwise, multiple pipelines could be created in a short interval.
schedule.schedule_next_run!
diff --git a/app/services/ci/process_sync_events_service.rb b/app/services/ci/process_sync_events_service.rb
index 11ce6e8eeaf..d90ee02b1c6 100644
--- a/app/services/ci/process_sync_events_service.rb
+++ b/app/services/ci/process_sync_events_service.rb
@@ -2,7 +2,6 @@
module Ci
class ProcessSyncEventsService
- include Gitlab::Utils::StrongMemoize
include ExclusiveLeaseGuard
BATCH_SIZE = 1000
@@ -10,22 +9,27 @@ module Ci
def initialize(sync_event_class, sync_class)
@sync_event_class = sync_event_class
@sync_class = sync_class
+ @results = {}
end
def execute
- return unless ::Feature.enabled?(:ci_namespace_project_mirrors, default_enabled: :yaml)
-
# preventing parallel processing over the same event table
try_obtain_lease { process_events }
enqueue_worker_if_there_still_event
+
+ @results
end
private
def process_events
+ add_result(estimated_total_events: @sync_event_class.upper_bound_count)
+
events = @sync_event_class.preload_synced_relation.first(BATCH_SIZE)
+ add_result(consumable_events: events.size)
+
return if events.empty?
processed_events = []
@@ -37,6 +41,7 @@ module Ci
processed_events << event
end
ensure
+ add_result(processed_events: processed_events.size)
@sync_event_class.id_in(processed_events).delete_all
end
end
@@ -52,5 +57,9 @@ module Ci
def lease_timeout
1.minute
end
+
+ def add_result(result)
+ @results.merge!(result)
+ end
end
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index e0f0f8f58b8..59c4c17a964 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -159,7 +159,7 @@ module Ci
return
end
- if runner.can_pick?(build)
+ if runner.matches_build?(build)
@metrics.increment_queue_operation(:build_can_pick)
else
@metrics.increment_queue_operation(:build_not_pick)
diff --git a/app/services/ci/register_runner_service.rb b/app/services/ci/register_runner_service.rb
index 0a2027e33ce..7c6cd82565d 100644
--- a/app/services/ci/register_runner_service.rb
+++ b/app/services/ci/register_runner_service.rb
@@ -3,7 +3,7 @@
module Ci
class RegisterRunnerService
def execute(registration_token, attributes)
- runner_type_attrs = check_token_and_extract_attrs(registration_token)
+ runner_type_attrs = extract_runner_type_attrs(registration_token)
return unless runner_type_attrs
@@ -12,16 +12,32 @@ module Ci
private
- def check_token_and_extract_attrs(registration_token)
+ def extract_runner_type_attrs(registration_token)
+ @attrs_from_token ||= check_token(registration_token)
+
+ return unless @attrs_from_token
+
+ attrs = @attrs_from_token.clone
+ case attrs[:runner_type]
+ when :project_type
+ attrs[:projects] = [attrs.delete(:scope)]
+ when :group_type
+ attrs[:groups] = [attrs.delete(:scope)]
+ end
+
+ attrs
+ end
+
+ def check_token(registration_token)
if runner_registration_token_valid?(registration_token)
# Create shared runner. Requires admin access
{ runner_type: :instance_type }
elsif runner_registrar_valid?('project') && project = ::Project.find_by_runners_token(registration_token)
# Create a specific runner for the project
- { runner_type: :project_type, projects: [project] }
+ { runner_type: :project_type, scope: project }
elsif runner_registrar_valid?('group') && group = ::Group.find_by_runners_token(registration_token)
# Create a specific runner for the group
- { runner_type: :group_type, groups: [group] }
+ { runner_type: :group_type, scope: group }
end
end
@@ -32,5 +48,11 @@ module Ci
def runner_registrar_valid?(type)
Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?(type)
end
+
+ def token_scope
+ @attrs_from_token[:scope]
+ end
end
end
+
+Ci::RegisterRunnerService.prepend_mod
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 7e5d5373648..73c5d0163da 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -40,17 +40,15 @@ module Ci
new_build = clone_build(build)
new_build.run_after_commit do
+ ::Ci::CopyCrossDatabaseAssociationsService.new.execute(build, new_build)
+
+ ::Deployments::CreateForBuildService.new.execute(new_build)
+
::MergeRequests::AddTodoWhenBuildFailsService
.new(project: project)
.close(new_build)
end
- if create_deployment_in_separate_transaction?
- new_build.run_after_commit do |new_build|
- ::Deployments::CreateForBuildService.new.execute(new_build)
- end
- end
-
::Ci::Pipelines::AddJobService.new(build.pipeline).execute!(new_build) do |job|
BulkInsertableAssociations.with_bulk_insert do
job.save!
@@ -74,11 +72,7 @@ module Ci
def check_assignable_runners!(build); end
def clone_build(build)
- project.builds.new(build_attributes(build)).tap do |new_build|
- unless create_deployment_in_separate_transaction?
- new_build.assign_attributes(deployment_attributes_for(new_build, build))
- end
- end
+ project.builds.new(build_attributes(build))
end
def build_attributes(build)
@@ -86,7 +80,7 @@ module Ci
[attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
end
- if create_deployment_in_separate_transaction? && build.persisted_environment.present?
+ if build.persisted_environment.present?
attributes[:metadata_attributes] ||= {}
attributes[:metadata_attributes][:expanded_environment_name] = build.expanded_environment_name
end
@@ -94,17 +88,6 @@ module Ci
attributes[:user] = current_user
attributes
end
-
- def deployment_attributes_for(new_build, old_build)
- ::Gitlab::Ci::Pipeline::Seed::Build
- .deployment_attributes_for(new_build, old_build.persisted_environment)
- end
-
- def create_deployment_in_separate_transaction?
- strong_memoize(:create_deployment_in_separate_transaction) do
- ::Feature.enabled?(:create_deployment_in_separate_transaction, project, default_enabled: :yaml)
- end
- end
end
end
diff --git a/app/services/ci/unregister_runner_service.rb b/app/services/ci/unregister_runner_service.rb
new file mode 100644
index 00000000000..97d9852b7ed
--- /dev/null
+++ b/app/services/ci/unregister_runner_service.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Ci
+ class UnregisterRunnerService
+ attr_reader :runner
+
+ # @param [Ci::Runner] runner the runner to unregister/destroy
+ def initialize(runner)
+ @runner = runner
+ end
+
+ def execute
+ @runner&.destroy
+ end
+ end
+end
diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb
index 2e38969c7a9..5a011a8cac6 100644
--- a/app/services/ci/update_build_queue_service.rb
+++ b/app/services/ci/update_build_queue_service.rb
@@ -100,7 +100,7 @@ module Ci
def tick_for(build, runners)
runners = runners.with_recent_runner_queue
- runners = runners.with_tags if Feature.enabled?(:ci_preload_runner_tags, default_enabled: :yaml)
+ runners = runners.with_tags
metrics.observe_active_runners(-> { runners.to_a.size })
diff --git a/app/services/ci/update_runner_service.rb b/app/services/ci/update_runner_service.rb
index e4117a51fe6..4a17e25c0cc 100644
--- a/app/services/ci/update_runner_service.rb
+++ b/app/services/ci/update_runner_service.rb
@@ -9,6 +9,8 @@ module Ci
end
def update(params)
+ params[:active] = !params.delete(:paused) if params.include?(:paused)
+
runner.update(params).tap do |updated|
runner.tick_runner_queue if updated
end
diff --git a/app/services/concerns/members/bulk_create_users.rb b/app/services/concerns/members/bulk_create_users.rb
index b98917f1396..9cfef96311e 100644
--- a/app/services/concerns/members/bulk_create_users.rb
+++ b/app/services/concerns/members/bulk_create_users.rb
@@ -48,6 +48,7 @@ module Members
end
if user_ids.present?
+ # we should handle the idea of existing members where users are passed as users - https://gitlab.com/gitlab-org/gitlab/-/issues/352617
# the below will automatically discard invalid user_ids
users.concat(User.id_in(user_ids))
# helps not have to perform another query per user id to see if the member exists later on when fetching
diff --git a/app/services/concerns/rate_limited_service.rb b/app/services/concerns/rate_limited_service.rb
index 87cba7814fe..c8dc60355cf 100644
--- a/app/services/concerns/rate_limited_service.rb
+++ b/app/services/concerns/rate_limited_service.rb
@@ -17,7 +17,7 @@ module RateLimitedService
end
def log_request(request, current_user)
- rate_limiter.class.log_request(request, "#{key}_request_limit".to_sym, current_user)
+ rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user)
end
private
@@ -26,20 +26,19 @@ module RateLimitedService
end
class RateLimiterScopedAndKeyed
- attr_reader :key, :opts, :rate_limiter_klass
+ attr_reader :key, :opts, :rate_limiter
- def initialize(key:, opts:, rate_limiter_klass:)
+ def initialize(key:, opts:, rate_limiter:)
@key = key
@opts = opts
- @rate_limiter_klass = rate_limiter_klass
+ @rate_limiter = rate_limiter
end
def rate_limit!(service)
evaluated_scope = evaluated_scope_for(service)
return if feature_flag_disabled?(evaluated_scope[:project])
- rate_limiter = new_rate_limiter(evaluated_scope)
- if rate_limiter.throttled?
+ if rate_limiter.throttled?(key, **opts.merge(scope: evaluated_scope.values, users_allowlist: users_allowlist))
raise RateLimitedError.new(key: key, rate_limiter: rate_limiter), _('This endpoint has been requested too many times. Try again later.')
end
end
@@ -59,20 +58,16 @@ module RateLimitedService
def feature_flag_disabled?(project)
Feature.disabled?("rate_limited_service_#{key}", project, default_enabled: :yaml)
end
-
- def new_rate_limiter(evaluated_scope)
- rate_limiter_klass.new(key, **opts.merge(scope: evaluated_scope.values, users_allowlist: users_allowlist))
- end
end
prepended do
attr_accessor :rate_limiter_bypassed
cattr_accessor :rate_limiter_scoped_and_keyed
- def self.rate_limit(key:, opts:, rate_limiter_klass: ::Gitlab::ApplicationRateLimiter)
+ def self.rate_limit(key:, opts:, rate_limiter: ::Gitlab::ApplicationRateLimiter)
self.rate_limiter_scoped_and_keyed = RateLimiterScopedAndKeyed.new(key: key,
opts: opts,
- rate_limiter_klass: rate_limiter_klass)
+ rate_limiter: rate_limiter)
end
end
diff --git a/app/services/google_cloud/create_service_accounts_service.rb b/app/services/google_cloud/create_service_accounts_service.rb
index fa025e8f672..e360b3a8e4e 100644
--- a/app/services/google_cloud/create_service_accounts_service.rb
+++ b/app/services/google_cloud/create_service_accounts_service.rb
@@ -5,6 +5,7 @@ module GoogleCloud
def execute
service_account = google_api_client.create_service_account(gcp_project_id, service_account_name, service_account_desc)
service_account_key = google_api_client.create_service_account_key(gcp_project_id, service_account.unique_id)
+ google_api_client.grant_service_account_roles(gcp_project_id, service_account.email)
service_accounts_service.add_for_project(
environment_name,
@@ -35,7 +36,7 @@ module GoogleCloud
end
def google_api_client
- GoogleApi::CloudPlatform::Client.new(google_oauth2_token, nil)
+ @google_api_client_instance ||= GoogleApi::CloudPlatform::Client.new(google_oauth2_token, nil)
end
def service_accounts_service
@@ -50,7 +51,7 @@ module GoogleCloud
"GitLab generated service account for project '#{project.name}' and environment '#{environment_name}'"
end
- # Overriden in EE
+ # Overridden in EE
def environment_protected?
false
end
diff --git a/app/services/google_cloud/enable_cloud_run_service.rb b/app/services/google_cloud/enable_cloud_run_service.rb
new file mode 100644
index 00000000000..643f2b2b6d2
--- /dev/null
+++ b/app/services/google_cloud/enable_cloud_run_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+ class EnableCloudRunService < :: BaseService
+ def execute
+ gcp_project_ids = unique_gcp_project_ids
+
+ if gcp_project_ids.empty?
+ error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.")
+ else
+ google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
+
+ gcp_project_ids.each do |gcp_project_id|
+ google_api_client.enable_cloud_run(gcp_project_id)
+ google_api_client.enable_artifacts_registry(gcp_project_id)
+ google_api_client.enable_cloud_build(gcp_project_id)
+ end
+
+ success({ gcp_project_ids: gcp_project_ids })
+ end
+ end
+
+ private
+
+ def unique_gcp_project_ids
+ all_gcp_project_ids = project.variables.filter { |var| var.key == 'GCP_PROJECT_ID' }.map { |var| var.value }
+ all_gcp_project_ids.uniq
+ end
+
+ def token_in_session
+ @params[:token_in_session]
+ end
+ end
+end
diff --git a/app/services/google_cloud/generate_pipeline_service.rb b/app/services/google_cloud/generate_pipeline_service.rb
new file mode 100644
index 00000000000..077f815e60c
--- /dev/null
+++ b/app/services/google_cloud/generate_pipeline_service.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+ class GeneratePipelineService < :: BaseService
+ ACTION_DEPLOY_TO_CLOUD_RUN = 'DEPLOY_TO_CLOUD_RUN'
+ ACTION_DEPLOY_TO_CLOUD_STORAGE = 'DEPLOY_TO_CLOUD_STORAGE'
+
+ def execute
+ commit_attributes = generate_commit_attributes
+ create_branch_response = ::Branches::CreateService.new(project, current_user)
+ .execute(commit_attributes[:branch_name], project.default_branch)
+
+ if create_branch_response[:status] == :error
+ return create_branch_response
+ end
+
+ branch = create_branch_response[:branch]
+
+ service = default_branch_gitlab_ci_yml.present? ? ::Files::UpdateService : ::Files::CreateService
+
+ commit_response = service.new(project, current_user, commit_attributes).execute
+
+ if commit_response[:status] == :error
+ return commit_response
+ end
+
+ success({ branch_name: branch.name, commit: commit_response })
+ end
+
+ private
+
+ def action
+ @params[:action]
+ end
+
+ def generate_commit_attributes
+ if action == ACTION_DEPLOY_TO_CLOUD_RUN
+ branch_name = "deploy-to-cloud-run-#{SecureRandom.hex(8)}"
+ {
+ commit_message: 'Enable Cloud Run deployments',
+ file_path: '.gitlab-ci.yml',
+ file_content: pipeline_content('gcp/cloud-run.gitlab-ci.yml'),
+ branch_name: branch_name,
+ start_branch: branch_name
+ }
+ elsif action == ACTION_DEPLOY_TO_CLOUD_STORAGE
+ branch_name = "deploy-to-cloud-storage-#{SecureRandom.hex(8)}"
+ {
+ commit_message: 'Enable Cloud Storage deployments',
+ file_path: '.gitlab-ci.yml',
+ file_content: pipeline_content('gcp/cloud-storage.gitlab-ci.yml'),
+ branch_name: branch_name,
+ start_branch: branch_name
+ }
+ end
+ end
+
+ def default_branch_gitlab_ci_yml
+ @default_branch_gitlab_ci_yml ||= project.repository.gitlab_ci_yml_for(project.default_branch)
+ end
+
+ def pipeline_content(include_path)
+ gitlab_ci_yml = Gitlab::Config::Loader::Yaml.new(default_branch_gitlab_ci_yml || '{}').load!
+ append_remote_include(gitlab_ci_yml, "https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/#{include_path}")
+ end
+
+ def append_remote_include(gitlab_ci_yml, include_url)
+ stages = gitlab_ci_yml['stages'] || []
+ gitlab_ci_yml['stages'] = (stages + %w[build test deploy]).uniq
+
+ includes = gitlab_ci_yml['include'] || []
+ includes = Array.wrap(includes)
+ includes << { 'remote' => include_url }
+ gitlab_ci_yml['include'] = includes.uniq
+
+ gitlab_ci_yml.to_yaml
+ end
+ end
+end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index da3cebc2e6d..67cbbaf84f6 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -61,6 +61,8 @@ module Groups
delay = Namespaces::InviteTeamEmailService::DELIVERY_DELAY_IN_MINUTES
Namespaces::InviteTeamEmailWorker.perform_in(delay, group.id, current_user.id)
end
+
+ track_experiment_event
end
def remove_unallowed_params
@@ -112,6 +114,15 @@ module Groups
@group.shared_runners_enabled = @group.parent.shared_runners_enabled
@group.allow_descendants_override_disabled_shared_runners = @group.parent.allow_descendants_override_disabled_shared_runners
end
+
+ def track_experiment_event
+ return unless group.persisted?
+
+ # Track namespace created events to relate them with signed up events for
+ # the same experiment. This will let us associate created namespaces to
+ # users that signed up from the experimental logged out header.
+ experiment(:logged_out_marketing_header, actor: current_user).track(:namespace_created, namespace: group)
+ end
end
end
diff --git a/app/services/groups/update_statistics_service.rb b/app/services/groups/update_statistics_service.rb
new file mode 100644
index 00000000000..9efce79ef42
--- /dev/null
+++ b/app/services/groups/update_statistics_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Groups
+ class UpdateStatisticsService
+ attr_reader :group, :statistics
+
+ def initialize(group, statistics: [])
+ @group = group
+ @statistics = statistics
+ end
+
+ def execute
+ unless group
+ return ServiceResponse.error(message: 'Invalid group', http_status: 400)
+ end
+
+ namespace_statistics.refresh!(only: statistics.map(&:to_sym))
+
+ ServiceResponse.success(message: 'Group statistics successfully updated.')
+ end
+
+ private
+
+ def namespace_statistics
+ @namespace_statistics ||= group.namespace_statistics || group.build_namespace_statistics
+ end
+ end
+end
diff --git a/app/services/incident_management/create_incident_label_service.rb b/app/services/incident_management/create_incident_label_service.rb
deleted file mode 100644
index 595f5df184f..00000000000
--- a/app/services/incident_management/create_incident_label_service.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-module IncidentManagement
- class CreateIncidentLabelService < BaseService
- LABEL_PROPERTIES = {
- title: 'incident',
- color: '#CC0033',
- description: <<~DESCRIPTION.chomp
- Denotes a disruption to IT services and \
- the associated issues require immediate attention
- DESCRIPTION
- }.freeze
-
- def execute
- label = Labels::FindOrCreateService
- .new(current_user, project, **LABEL_PROPERTIES)
- .execute(skip_authorization: true)
-
- ServiceResponse.success(payload: { label: label })
- end
- end
-end
diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb
index f8437290d9b..ef66325fdcc 100644
--- a/app/services/incident_management/incidents/create_service.rb
+++ b/app/services/incident_management/incidents/create_service.rb
@@ -2,15 +2,16 @@
module IncidentManagement
module Incidents
- class CreateService < BaseService
+ class CreateService < ::BaseProjectService
ISSUE_TYPE = 'incident'
- def initialize(project, current_user, title:, description:, severity: IssuableSeverity::DEFAULT)
- super(project, current_user)
+ def initialize(project, current_user, title:, description:, severity: IssuableSeverity::DEFAULT, alert: nil)
+ super(project: project, current_user: current_user)
@title = title
@description = description
@severity = severity
+ @alert = alert
end
def execute
@@ -21,11 +22,16 @@ module IncidentManagement
title: title,
description: description,
issue_type: ISSUE_TYPE,
- severity: severity
+ severity: severity,
+ alert_management_alert: alert
},
spam_params: nil
).execute
+ if alert
+ return error(alert.errors.full_messages.to_sentence, issue) unless alert.valid?
+ end
+
return error(issue.errors.full_messages.to_sentence, issue) unless issue.valid?
success(issue)
@@ -33,7 +39,7 @@ module IncidentManagement
private
- attr_reader :title, :description, :severity
+ attr_reader :title, :description, :severity, :alert
def success(issue)
ServiceResponse.success(payload: { issue: issue })
diff --git a/app/services/incident_management/issuable_escalation_statuses/after_update_service.rb b/app/services/incident_management/issuable_escalation_statuses/after_update_service.rb
index a7a99f88b32..b7f8b268f18 100644
--- a/app/services/incident_management/issuable_escalation_statuses/after_update_service.rb
+++ b/app/services/incident_management/issuable_escalation_statuses/after_update_service.rb
@@ -3,12 +3,12 @@
module IncidentManagement
module IssuableEscalationStatuses
class AfterUpdateService < ::BaseProjectService
- def initialize(issuable, current_user)
+ def initialize(issuable, current_user, **params)
@issuable = issuable
@escalation_status = issuable.escalation_status
@alert = issuable.alert_management_alert
- super(project: issuable.project, current_user: current_user)
+ super(project: issuable.project, current_user: current_user, params: params)
end
def execute
@@ -22,19 +22,27 @@ module IncidentManagement
attr_reader :issuable, :escalation_status, :alert
def after_update
- sync_to_alert
+ sync_status_to_alert
+ add_status_system_note
end
- def sync_to_alert
+ def sync_status_to_alert
return unless alert
return if alert.status == escalation_status.status
::AlertManagement::Alerts::UpdateService.new(
alert,
current_user,
- status: escalation_status.status_name
+ status: escalation_status.status_name,
+ status_change_reason: " by changing the incident status of #{issuable.to_reference(project)}"
).execute
end
+
+ def add_status_system_note
+ return unless escalation_status.status_previously_changed?
+
+ SystemNoteService.change_incident_status(issuable, current_user, params[:status_change_reason])
+ end
end
end
end
diff --git a/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb b/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb
index 1a660e1a163..8f591b375ee 100644
--- a/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb
+++ b/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb
@@ -2,18 +2,16 @@
module IncidentManagement
module IssuableEscalationStatuses
- class PrepareUpdateService
+ class PrepareUpdateService < ::BaseProjectService
include Gitlab::Utils::StrongMemoize
- SUPPORTED_PARAMS = %i[status].freeze
-
- InvalidParamError = Class.new(StandardError)
+ SUPPORTED_PARAMS = %i[status status_change_reason].freeze
def initialize(issuable, current_user, params)
@issuable = issuable
- @current_user = current_user
- @params = params.dup || {}
- @project = issuable.project
+ @param_errors = []
+
+ super(project: issuable.project, current_user: current_user, params: Hash(params))
end
def execute
@@ -23,19 +21,18 @@ module IncidentManagement
filter_attributes
filter_redundant_params
+ return invalid_param_error if param_errors.any?
+
ServiceResponse.success(payload: { escalation_status: params })
- rescue InvalidParamError
- invalid_param_error
end
private
- attr_reader :issuable, :current_user, :params, :project
+ attr_reader :issuable, :param_errors
def available?
- Feature.enabled?(:incident_escalations, project) &&
+ issuable.supports_escalation? &&
user_has_permissions? &&
- issuable.supports_escalation? &&
escalation_status.present?
end
@@ -66,7 +63,7 @@ module IncidentManagement
return unless status
status_event = escalation_status.status_event_for(status)
- raise InvalidParamError unless status_event
+ add_param_error(:status) && return unless status_event
params[:status_event] = status_event
end
@@ -85,12 +82,16 @@ module IncidentManagement
end
end
+ def add_param_error(param)
+ param_errors << param
+ end
+
def availability_error
ServiceResponse.error(message: 'Escalation status updates are not available for this issue, user, or project.')
end
def invalid_param_error
- ServiceResponse.error(message: 'Invalid value was provided for a parameter.')
+ ServiceResponse.error(message: "Invalid value was provided for parameters: #{param_errors.join(', ')}")
end
end
end
diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb
index 38050708fc5..9ee54c7ba0f 100644
--- a/app/services/issuable/common_system_notes_service.rb
+++ b/app/services/issuable/common_system_notes_service.rb
@@ -71,7 +71,7 @@ module Issuable
def create_title_change_note(old_title)
create_draft_note(old_title)
- if issuable.wipless_title_changed(old_title)
+ if issuable.draftless_title_changed(old_title)
SystemNoteService.change_title(issuable, issuable.project, current_user, old_title)
end
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index ecf10cf97a8..95093b88155 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -162,6 +162,8 @@ class IssuableBaseService < ::BaseProjectService
return unless result.success? && result.payload.present?
+ @escalation_status_change_reason = result[:escalation_status].delete(:status_change_reason)
+
params[:incident_management_issuable_escalation_status_attributes] = result[:escalation_status]
end
@@ -492,11 +494,12 @@ class IssuableBaseService < ::BaseProjectService
def handle_move_between_ids(issuable_position)
return unless params[:move_between_ids]
- after_id, before_id = params.delete(:move_between_ids)
- positioning_scope_id = params.delete(positioning_scope_key)
+ before_id, after_id = params.delete(:move_between_ids)
+
+ positioning_scope = issuable_position.class.relative_positioning_query_base(issuable_position)
- issuable_before = issuable_for_positioning(before_id, positioning_scope_id)
- issuable_after = issuable_for_positioning(after_id, positioning_scope_id)
+ issuable_before = issuable_for_positioning(before_id, positioning_scope)
+ issuable_after = issuable_for_positioning(after_id, positioning_scope)
raise ActiveRecord::RecordNotFound unless issuable_before || issuable_after
@@ -521,7 +524,7 @@ class IssuableBaseService < ::BaseProjectService
def invalidate_cache_counts(issuable, users: [])
users.each do |user|
- user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
+ user.public_send("invalidate_#{issuable.noteable_target_type_name}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
end
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 37d667d4be8..61a95e49228 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -2,6 +2,7 @@
module Issues
class BaseService < ::IssuableBaseService
+ extend ::Gitlab::Utils::Override
include IncidentManagement::UsageData
include IssueTypeHelpers
@@ -61,6 +62,21 @@ module Issues
issue.system_note_timestamp = params[:created_at] || params[:updated_at]
end
+ override :handle_move_between_ids
+ def handle_move_between_ids(issue)
+ issue.check_repositioning_allowed! if params[:move_between_ids]
+
+ super
+
+ rebalance_if_needed(issue)
+ end
+
+ def issuable_for_positioning(id, positioning_scope)
+ return unless id
+
+ positioning_scope.find(id)
+ end
+
def create_assignee_note(issue, old_assignees)
SystemNoteService.change_issuable_assignees(
issue, issue.project, current_user, old_assignees)
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index 65f143d0b21..ff45091c7e6 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -81,7 +81,7 @@ module Issues
return if alert.resolved?
if alert.resolve
- SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: current_user).closed_alert_issue(issue)
+ SystemNoteService.change_alert_status(alert, current_user, " by closing incident #{issue.to_reference(project)}")
else
Gitlab::AppLogger.warn(
message: 'Cannot resolve an associated Alert Management alert',
@@ -97,7 +97,7 @@ module Issues
status = issue.incident_management_issuable_escalation_status || issue.build_incident_management_issuable_escalation_status
- SystemNoteService.resolve_incident_status(issue, current_user) if status.resolve
+ SystemNoteService.change_incident_status(issue, current_user, ' by closing the incident') if status.resolve
end
def store_first_mentioned_in_commit_at(issue, merge_request, max_commit_lookup: 100)
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index e29bcf4a453..7fbf7c6af58 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -21,6 +21,8 @@ module Issues
def execute(skip_system_notes: false)
@issue = @build_service.execute
+ handle_move_between_ids(@issue)
+
filter_resolve_discussion_params
create(@issue, skip_system_notes: skip_system_notes)
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 4418b4eb2bf..e210e6a2362 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -2,6 +2,8 @@
module Issues
class MoveService < Issuable::Clone::BaseService
+ extend ::Gitlab::Utils::Override
+
MoveError = Class.new(StandardError)
def execute(issue, target_project)
@@ -47,6 +49,7 @@ module Issues
.sent_notifications.update_all(project_id: new_entity.project_id, noteable_id: new_entity.id)
end
+ override :update_old_entity
def update_old_entity
super
@@ -54,6 +57,13 @@ module Issues
mark_as_moved
end
+ override :update_new_entity
+ def update_new_entity
+ super
+
+ copy_contacts
+ end
+
def create_new_entity
new_params = {
id: nil,
@@ -99,6 +109,13 @@ module Issues
target_issue_links.update_all(target_id: new_entity.id)
end
+ def copy_contacts
+ return unless Feature.enabled?(:customer_relations, original_entity.project.root_ancestor)
+ return unless original_entity.project.root_ancestor == new_entity.project.root_ancestor
+
+ new_entity.customer_relations_contacts = original_entity.customer_relations_contacts
+ end
+
def notify_participants
notification_service.async.issue_moved(original_entity, new_entity, @current_user)
end
diff --git a/app/services/issues/reorder_service.rb b/app/services/issues/reorder_service.rb
index 9c5fbec7d8e..5443d41ac30 100644
--- a/app/services/issues/reorder_service.rb
+++ b/app/services/issues/reorder_service.rb
@@ -2,47 +2,31 @@
module Issues
class ReorderService < Issues::BaseService
+ include Gitlab::Utils::StrongMemoize
+
def execute(issue)
return false unless can?(current_user, :update_issue, issue)
- return false if group && !can?(current_user, :read_group, group)
-
- attrs = issue_params(group)
- return false if attrs.empty?
+ return false unless move_between_ids
- update(issue, attrs)
+ update(issue, { move_between_ids: move_between_ids })
end
private
- def group
- return unless params[:group_full_path]
-
- @group ||= Group.find_by_full_path(params[:group_full_path])
- end
-
def update(issue, attrs)
::Issues::UpdateService.new(project: project, current_user: current_user, params: attrs).execute(issue)
rescue ActiveRecord::RecordNotFound
false
end
- def issue_params(group)
- attrs = {}
-
- if move_between_ids
- attrs[:move_between_ids] = move_between_ids
- attrs[:board_group_id] = group&.id
- end
-
- attrs
- end
-
def move_between_ids
- ids = [params[:move_after_id], params[:move_before_id]]
- .map(&:to_i)
- .map { |m| m > 0 ? m : nil }
+ strong_memoize(:move_between_ids) do
+ ids = [params[:move_before_id], params[:move_after_id]]
+ .map(&:to_i)
+ .map { |m| m > 0 ? m : nil }
- ids.any? ? ids : nil
+ ids.any? ? ids : nil
+ end
end
end
end
diff --git a/app/services/issues/set_crm_contacts_service.rb b/app/services/issues/set_crm_contacts_service.rb
index 947d46f0809..2edc944435b 100644
--- a/app/services/issues/set_crm_contacts_service.rb
+++ b/app/services/issues/set_crm_contacts_service.rb
@@ -16,6 +16,9 @@ module Issues
determine_changes if params[:replace_ids].present?
return error_too_many if too_many?
+ @added_count = 0
+ @removed_count = 0
+
add if params[:add_ids].present?
remove if params[:remove_ids].present?
@@ -25,6 +28,7 @@ module Issues
if issue.valid?
GraphqlTriggers.issue_crm_contacts_updated(issue)
issue.touch
+ create_system_note
ServiceResponse.success(payload: issue)
else
# The default error isn't very helpful: "Issue customer relations contacts is invalid"
@@ -36,7 +40,7 @@ module Issues
private
- attr_accessor :issue, :errors, :existing_ids
+ attr_accessor :issue, :errors, :existing_ids, :added_count, :removed_count
def determine_changes
params[:add_ids] = params[:replace_ids] - existing_ids
@@ -48,16 +52,24 @@ module Issues
end
def add_by_email
- contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group, params[:add_emails])
+ contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group, emails(:add_emails))
add_by_id(contact_ids)
end
+ def emails(key)
+ params[key].map do |email|
+ extract_email_from_request_param(email)
+ end
+ end
+
def add_by_id(contact_ids)
contact_ids -= existing_ids
contact_ids.uniq.each do |contact_id|
issue_contact = issue.issue_customer_relations_contacts.create(contact_id: contact_id)
- unless issue_contact.persisted?
+ if issue_contact.persisted?
+ @added_count += 1
+ else
# The validation ensures that the id exists and the user has permission
errors << "#{contact_id}: The resource that you are attempting to access does not exist or you don't have permission to perform this action"
end
@@ -69,17 +81,24 @@ module Issues
end
def remove_by_email
- contact_ids = ::CustomerRelations::IssueContact.find_contact_ids_by_emails(issue.id, params[:remove_emails])
+ contact_ids = ::CustomerRelations::IssueContact.find_contact_ids_by_emails(issue.id, emails(:remove_emails))
remove_by_id(contact_ids)
end
def remove_by_id(contact_ids)
contact_ids &= existing_ids
- issue.issue_customer_relations_contacts
+ @removed_count += issue.issue_customer_relations_contacts
.where(contact_id: contact_ids) # rubocop: disable CodeReuse/ActiveRecord
.delete_all
end
+ def extract_email_from_request_param(email_param)
+ email_param.delete_prefix(::CustomerRelations::Contact.reference_prefix_quoted)
+ .delete_prefix(::CustomerRelations::Contact.reference_prefix)
+ .delete_suffix(::CustomerRelations::Contact.reference_postfix)
+ .tr('"', '')
+ end
+
def allowed?
current_user&.can?(:set_issue_crm_contacts, issue)
end
@@ -116,6 +135,11 @@ module Issues
params[:add_emails] && params[:add_emails].length > MAX_ADDITIONAL_CONTACTS
end
+ def create_system_note
+ SystemNoteService.change_issuable_contacts(
+ issue, issue.project, current_user, added_count, removed_count)
+ end
+
def error_no_permissions
ServiceResponse.error(message: _('You have insufficient permissions to set customer relations contacts for this issue'))
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index aecb22453b7..8372cd919e5 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -2,8 +2,6 @@
module Issues
class UpdateService < Issues::BaseService
- extend ::Gitlab::Utils::Override
-
# NOTE: For Issues::UpdateService, we default the spam_params to nil, because spam_checking is not
# necessary in many cases, and we don't want to require every caller to explicitly pass it as nil
# to disable spam checking.
@@ -92,18 +90,6 @@ module Issues
todo_service.update_issue(issuable, current_user)
end
- def handle_move_between_ids(issue)
- issue.check_repositioning_allowed! if params[:move_between_ids]
-
- super
-
- rebalance_if_needed(issue)
- end
-
- def positioning_scope_key
- :board_group_id
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def change_issue_duplicate(issue)
canonical_issue_id = params.delete(:canonical_issue_id)
@@ -214,23 +200,12 @@ module Issues
return unless old_escalation_status.present?
return if issue.escalation_status&.slice(:status, :policy_id) == old_escalation_status
- ::IncidentManagement::IssuableEscalationStatuses::AfterUpdateService.new(issue, current_user).execute
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def issuable_for_positioning(id, board_group_id = nil)
- return unless id
-
- issue =
- if board_group_id
- IssuesFinder.new(current_user, group_id: board_group_id, include_subgroups: true).find_by(id: id)
- else
- project.issues.find(id)
- end
-
- issue if can?(current_user, :update_issue, issue)
+ ::IncidentManagement::IssuableEscalationStatuses::AfterUpdateService.new(
+ issue,
+ current_user,
+ status_change_reason: @escalation_status_change_reason # Defined in IssuableBaseService before save
+ ).execute
end
- # rubocop: enable CodeReuse/ActiveRecord
def create_confidentiality_note(issue)
SystemNoteService.change_issue_confidentiality(issue, issue.project, current_user)
diff --git a/app/services/loose_foreign_keys/batch_cleaner_service.rb b/app/services/loose_foreign_keys/batch_cleaner_service.rb
index de52cbba576..f3db2037911 100644
--- a/app/services/loose_foreign_keys/batch_cleaner_service.rb
+++ b/app/services/loose_foreign_keys/batch_cleaner_service.rb
@@ -2,6 +2,9 @@
module LooseForeignKeys
class BatchCleanerService
+ CLEANUP_ATTEMPTS_BEFORE_RESCHEDULE = 3
+ CONSUME_AFTER_RESCHEDULE = 5.minutes
+
def initialize(parent_table:, loose_foreign_key_definitions:, deleted_parent_records:, modification_tracker: LooseForeignKeys::ModificationTracker.new)
@parent_table = parent_table
@loose_foreign_key_definitions = loose_foreign_key_definitions
@@ -11,15 +14,31 @@ module LooseForeignKeys
:loose_foreign_key_processed_deleted_records,
'The number of processed loose foreign key deleted records'
)
+ @deleted_records_rescheduled_count = Gitlab::Metrics.counter(
+ :loose_foreign_key_rescheduled_deleted_records,
+ 'The number of rescheduled loose foreign key deleted records'
+ )
+ @deleted_records_incremented_count = Gitlab::Metrics.counter(
+ :loose_foreign_key_incremented_deleted_records,
+ 'The number of loose foreign key deleted records with incremented cleanup_attempts'
+ )
end
def execute
loose_foreign_key_definitions.each do |loose_foreign_key_definition|
run_cleaner_service(loose_foreign_key_definition, with_skip_locked: true)
- break if modification_tracker.over_limit?
+
+ if modification_tracker.over_limit?
+ handle_over_limit
+ break
+ end
run_cleaner_service(loose_foreign_key_definition, with_skip_locked: false)
- break if modification_tracker.over_limit?
+
+ if modification_tracker.over_limit?
+ handle_over_limit
+ break
+ end
end
return if modification_tracker.over_limit?
@@ -27,12 +46,33 @@ module LooseForeignKeys
# At this point, all associations are cleaned up, we can update the status of the parent records
update_count = LooseForeignKeys::DeletedRecord.mark_records_processed(deleted_parent_records)
- deleted_records_counter.increment({ table: parent_table, db_config_name: LooseForeignKeys::DeletedRecord.connection.pool.db_config.name }, update_count)
+ deleted_records_counter.increment({ table: parent_table, db_config_name: db_config_name }, update_count)
end
private
- attr_reader :parent_table, :loose_foreign_key_definitions, :deleted_parent_records, :modification_tracker, :deleted_records_counter
+ attr_reader :parent_table, :loose_foreign_key_definitions, :deleted_parent_records, :modification_tracker, :deleted_records_counter, :deleted_records_rescheduled_count, :deleted_records_incremented_count
+
+ def handle_over_limit
+ return if Feature.disabled?(:lfk_fair_queueing)
+
+ records_to_reschedule = []
+ records_to_increment = []
+
+ deleted_parent_records.each do |deleted_record|
+ if deleted_record.cleanup_attempts >= CLEANUP_ATTEMPTS_BEFORE_RESCHEDULE
+ records_to_reschedule << deleted_record
+ else
+ records_to_increment << deleted_record
+ end
+ end
+
+ reschedule_count = LooseForeignKeys::DeletedRecord.reschedule(records_to_reschedule, CONSUME_AFTER_RESCHEDULE.from_now)
+ deleted_records_rescheduled_count.increment({ table: parent_table, db_config_name: db_config_name }, reschedule_count)
+
+ increment_count = LooseForeignKeys::DeletedRecord.increment_attempts(records_to_increment)
+ deleted_records_incremented_count.increment({ table: parent_table, db_config_name: db_config_name }, increment_count)
+ end
def record_result(cleaner, result)
if cleaner.async_delete?
@@ -60,5 +100,9 @@ module LooseForeignKeys
end
end
end
+
+ def db_config_name
+ LooseForeignKeys::DeletedRecord.connection.pool.db_config.name
+ end
end
end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index acd00d0d1ec..dc29bb2c6da 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -24,6 +24,9 @@ module Members
add_members
enqueue_onboarding_progress_action
+
+ publish_event!
+
result
rescue BlankInvitesError, TooManyInvitesError, MembershipLockedError => e
error(e.message)
@@ -144,6 +147,15 @@ module Members
def formatted_errors
errors.to_sentence
end
+
+ def publish_event!
+ Gitlab::EventStore.publish(
+ Members::MembersAddedEvent.new(data: {
+ source_id: source.id,
+ source_type: source.class.name
+ })
+ )
+ end
end
end
diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb
index e766a7e9044..fcce32ead94 100644
--- a/app/services/members/creator_service.rb
+++ b/app/services/members/creator_service.rb
@@ -67,6 +67,7 @@ module Members
def create_member_task
return unless member.persisted?
return if member_task_attributes.value?(nil)
+ return if member.member_task.present?
member.create_member_task(member_task_attributes)
end
diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb
index d2c83f82ff8..93a0d375b97 100644
--- a/app/services/merge_requests/after_create_service.rb
+++ b/app/services/merge_requests/after_create_service.rb
@@ -5,9 +5,8 @@ module MergeRequests
include Gitlab::Utils::StrongMemoize
def execute(merge_request)
- prepare_for_mergeability(merge_request) if early_prepare_for_mergeability?(merge_request)
+ prepare_for_mergeability(merge_request)
prepare_merge_request(merge_request)
- mark_as_unchecked(merge_request) unless early_prepare_for_mergeability?(merge_request)
end
private
@@ -15,7 +14,7 @@ module MergeRequests
def prepare_for_mergeability(merge_request)
create_pipeline_for(merge_request, current_user)
merge_request.update_head_pipeline
- mark_as_unchecked(merge_request)
+ check_mergeability(merge_request)
end
def prepare_merge_request(merge_request)
@@ -26,11 +25,6 @@ module MergeRequests
notification_service.new_merge_request(merge_request, current_user)
- unless early_prepare_for_mergeability?(merge_request)
- create_pipeline_for(merge_request, current_user)
- merge_request.update_head_pipeline
- end
-
merge_request.diffs(include_stats: false).write_cache
merge_request.create_cross_references!(current_user)
@@ -49,14 +43,13 @@ module MergeRequests
LinkLfsObjectsService.new(project: merge_request.target_project).execute(merge_request)
end
- def early_prepare_for_mergeability?(merge_request)
- strong_memoize("early_prepare_for_mergeability_#{merge_request.target_project_id}".to_sym) do
- Feature.enabled?(:early_prepare_for_mergeability, merge_request.target_project)
- end
- end
+ def check_mergeability(merge_request)
+ return unless merge_request.preparing?
- def mark_as_unchecked(merge_request)
- merge_request.mark_as_unchecked if merge_request.preparing?
+ # Need to set to unchecked to be able to check for mergeability or else
+ # it'll be a no-op.
+ merge_request.mark_as_unchecked
+ merge_request.check_mergeability(async: true)
end
end
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index b0d0c32abd1..3363fc90997 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -246,7 +246,9 @@ module MergeRequests
def remove_all_attention_requests(merge_request)
return unless merge_request.attention_requested_enabled?
- ::MergeRequests::BulkRemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request).execute
+ users = merge_request.reviewers + merge_request.assignees
+
+ ::MergeRequests::BulkRemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, users: users.uniq).execute
end
def remove_attention_requested(merge_request, user)
diff --git a/app/services/merge_requests/bulk_remove_attention_requested_service.rb b/app/services/merge_requests/bulk_remove_attention_requested_service.rb
index dd2ff741ba6..6573b623779 100644
--- a/app/services/merge_requests/bulk_remove_attention_requested_service.rb
+++ b/app/services/merge_requests/bulk_remove_attention_requested_service.rb
@@ -3,20 +3,24 @@
module MergeRequests
class BulkRemoveAttentionRequestedService < MergeRequests::BaseService
attr_accessor :merge_request
+ attr_accessor :users
- def initialize(project:, current_user:, merge_request:)
+ def initialize(project:, current_user:, merge_request:, users:)
super(project: project, current_user: current_user)
@merge_request = merge_request
+ @users = users
end
+ # rubocop: disable CodeReuse/ActiveRecord
def execute
return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request)
- merge_request.merge_request_assignees.update_all(state: :reviewed)
- merge_request.merge_request_reviewers.update_all(state: :reviewed)
+ merge_request.merge_request_assignees.where(user_id: users).update_all(state: :reviewed)
+ merge_request.merge_request_reviewers.where(user_id: users).update_all(state: :reviewed)
success
end
+ # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb
index 3e294aeaa07..30531fcc17b 100644
--- a/app/services/merge_requests/mergeability_check_service.rb
+++ b/app/services/merge_requests/mergeability_check_service.rb
@@ -14,8 +14,8 @@ module MergeRequests
def async_execute
return service_error if service_error
- return unless merge_request.mark_as_checking
+ merge_request.mark_as_checking
MergeRequestMergeabilityCheckWorker.perform_async(merge_request.id)
end
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
index d1f45b4b49c..1c4e1784b34 100644
--- a/app/services/merge_requests/rebase_service.rb
+++ b/app/services/merge_requests/rebase_service.rb
@@ -2,7 +2,7 @@
module MergeRequests
class RebaseService < MergeRequests::BaseService
- REBASE_ERROR = 'Rebase failed. Please rebase locally'
+ REBASE_ERROR = 'Rebase failed: Rebase locally, resolve all conflicts, then push the branch.'
attr_reader :merge_request, :rebase_error
@@ -35,7 +35,7 @@ module MergeRequests
def set_rebase_error(exception)
@rebase_error =
if exception.is_a?(Gitlab::Git::PreReceiveError)
- "Something went wrong during the rebase pre-receive hook: #{exception.message}."
+ "The rebase pre-receive hook failed: #{exception.message}."
else
REBASE_ERROR
end
diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb
index 69b9740c2a5..f04682bf08a 100644
--- a/app/services/merge_requests/squash_service.rb
+++ b/app/services/merge_requests/squash_service.rb
@@ -9,9 +9,9 @@ module MergeRequests
return success(squash_sha: merge_request.diff_head_sha)
end
- return error(s_('MergeRequests|This project does not allow squashing commits when merge requests are accepted.')) if squash_forbidden?
+ return error(s_("MergeRequests|Squashing not allowed: This project doesn't allow you to squash commits when merging.")) if squash_forbidden?
- squash! || error(s_('MergeRequests|Failed to squash. Should be done manually.'))
+ squash! || error(s_('MergeRequests|Squashing failed: Squash the commits locally, resolve any conflicts, then push the branch.'))
end
private
diff --git a/app/services/packages/maven/metadata/sync_service.rb b/app/services/packages/maven/metadata/sync_service.rb
index 4f35db36fb0..dacf6750412 100644
--- a/app/services/packages/maven/metadata/sync_service.rb
+++ b/app/services/packages/maven/metadata/sync_service.rb
@@ -93,11 +93,7 @@ module Packages
def metadata_package_file_for(package)
return unless package
- package_files = if Feature.enabled?(:packages_installable_package_files, default_enabled: :yaml)
- package.installable_package_files
- else
- package.package_files
- end
+ package_files = package.installable_package_files
package_files.with_file_name(Metadata.filename)
.recent
diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb
index 55f16aa3e3d..e6b1b33a82a 100644
--- a/app/services/projects/autocomplete_service.rb
+++ b/app/services/projects/autocomplete_service.rb
@@ -33,6 +33,11 @@ module Projects
SnippetsFinder.new(current_user, project: project).execute.select([:id, :title])
end
+ def contacts
+ Crm::ContactsFinder.new(current_user, group: project.group).execute
+ .select([:id, :email, :first_name, :last_name])
+ end
+
def labels_as_hash(target)
super(target, project_id: project.id, include_ancestor_groups: true)
end
diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb
index 410cf6c624e..b4a57c70111 100644
--- a/app/services/projects/container_repository/delete_tags_service.rb
+++ b/app/services/projects/container_repository/delete_tags_service.rb
@@ -14,6 +14,7 @@ module Projects
@tag_names = params[:tags]
return error('not tags specified') if @tag_names.blank?
+ return error('repository importing') if @container_repository.migration_importing?
delete_tags
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 1d187b140ef..c885369dfec 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -89,7 +89,7 @@ module Projects
end
def after_create_actions
- log_info("#{@project.owner.name} created a new project \"#{@project.full_name}\"")
+ log_info("#{current_user.name} created a new project \"#{@project.full_name}\"")
if @project.import?
experiment(:combined_registration, user: current_user).track(:import_project)
@@ -167,7 +167,7 @@ module Projects
end
def readme_content
- @readme_template.presence || experiment(:new_project_readme_content, namespace: @project.namespace).run_with(@project)
+ @readme_template.presence || ReadmeRendererService.new(@project, current_user).execute
end
def skip_wiki?
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 5c4a0e947de..95af5a6863f 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -37,6 +37,8 @@ module Projects
system_hook_service.execute_hooks_for(project, :destroy)
log_info("Project \"#{project.full_path}\" was deleted")
+ publish_project_deleted_event_for(project) if Feature.enabled?(:publish_project_deleted_event, default_enabled: :yaml)
+
current_user.invalidate_personal_projects_count
true
@@ -139,6 +141,7 @@ module Projects
destroy_web_hooks!
destroy_project_bots!
destroy_ci_records!
+ destroy_mr_diff_commits!
# Rails attempts to load all related records into memory before
# destroying: https://github.com/rails/rails/issues/22510
@@ -154,6 +157,33 @@ module Projects
log_info("Attempting to destroy #{project.full_path} (#{project.id})")
end
+ # Projects will have at least one merge_request_diff_commit for every commit
+ # contained in every MR, which deleting via `project.destroy!` and
+ # cascading deletes may exceed statement timeouts, causing failures.
+ # (see https://gitlab.com/gitlab-org/gitlab/-/issues/346166)
+ #
+ # rubocop: disable CodeReuse/ActiveRecord
+ def destroy_mr_diff_commits!
+ mr_batch_size = 100
+ delete_batch_size = 1000
+
+ project.merge_requests.each_batch(column: :iid, of: mr_batch_size) do |relation_ids|
+ loop do
+ inner_query = MergeRequestDiffCommit
+ .select(:merge_request_diff_id, :relative_order)
+ .where(merge_request_diff_id: MergeRequestDiff.where(merge_request_id: relation_ids).select(:id))
+ .limit(delete_batch_size)
+
+ deleted_rows = MergeRequestDiffCommit
+ .where('(merge_request_diff_commits.merge_request_diff_id, merge_request_diff_commits.relative_order) IN (?)', inner_query)
+ .delete_all
+
+ break if deleted_rows == 0
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
def destroy_ci_records!
project.all_pipelines.find_each(batch_size: BATCH_SIZE) do |pipeline| # rubocop: disable CodeReuse/ActiveRecord
# Destroy artifacts, then builds, then pipelines
@@ -232,6 +262,12 @@ module Projects
def flush_caches(project)
Projects::ForksCountService.new(project).delete_cache
end
+
+ def publish_project_deleted_event_for(project)
+ data = { project_id: project.id, namespace_id: project.namespace_id }
+ event = Projects::ProjectDeletedEvent.new(data: data)
+ Gitlab::EventStore.publish(event)
+ end
end
end
diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb
index b1a2182fbdc..b91b7f34d42 100644
--- a/app/services/projects/import_export/export_service.rb
+++ b/app/services/projects/import_export/export_service.rb
@@ -47,8 +47,7 @@ module Projects
end
def save_all!
- if save_exporters
- Gitlab::ImportExport::Saver.save(exportable: project, shared: shared)
+ if save_exporters && save_export_archive
notify_success
else
notify_error!
@@ -59,6 +58,10 @@ module Projects
exporters.all?(&:save)
end
+ def save_export_archive
+ Gitlab::ImportExport::Saver.save(exportable: project, shared: shared)
+ end
+
def exporters
[
version_saver, avatar_saver, project_tree_saver, uploads_saver,
diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb
index fe9dce26029..9da72d9300e 100644
--- a/app/services/projects/lfs_pointers/lfs_download_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_service.rb
@@ -34,7 +34,7 @@ module Projects
def wrap_download_errors(&block)
yield
rescue SizeError, OidError, ResponseError, StandardError => e
- error("LFS file with oid #{lfs_oid} could't be downloaded from #{lfs_sanitized_url}: #{e.message}")
+ error("LFS file with oid #{lfs_oid} couldn't be downloaded from #{lfs_sanitized_url}: #{e.message}")
end
def download_lfs_file!
@@ -104,7 +104,7 @@ module Projects
rescue StandardError => e
# If the lfs file is successfully downloaded it will be removed
# when it is added to the project's lfs files.
- # Nevertheless if any excetion raises the file would remain
+ # Nevertheless if any exception raises the file would remain
# in the file system. Here we ensure to remove it
File.unlink(file) if File.exist?(file)
diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb
index c58fba33b2a..eea8f867b45 100644
--- a/app/services/projects/overwrite_project_service.rb
+++ b/app/services/projects/overwrite_project_service.rb
@@ -6,30 +6,34 @@ module Projects
return unless source_project && source_project.namespace_id == @project.namespace_id
start_time = ::Gitlab::Metrics::System.monotonic_time
+ original_source_name = source_project.name
+ original_source_path = source_project.path
+ tmp_source_name, tmp_source_path = tmp_source_project_name(source_project)
- Project.transaction do
- move_before_destroy_relationships(source_project)
- # Reset is required in order to get the proper
- # uncached fork network method calls value.
- ::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/340256') do
- destroy_old_project(source_project.reset)
- end
- rename_project(source_project.name, source_project.path)
-
- @project
+ move_relationships_between(source_project, @project)
+
+ source_project_rename = rename_project(source_project, tmp_source_name, tmp_source_path)
+
+ if source_project_rename[:status] == :error
+ raise 'Source project rename failed during project overwrite'
end
- # Projects::DestroyService can raise Exceptions, but we don't want
- # to pass that kind of exception to the caller. Instead, we change it
- # for a StandardError exception
- rescue Exception => e # rubocop:disable Lint/RescueException
- attempt_restore_repositories(source_project)
-
- if e.instance_of?(Exception)
- raise StandardError, e.message
- else
- raise
+
+ new_project_rename = rename_project(@project, original_source_name, original_source_path)
+
+ if new_project_rename[:status] == :error
+ rename_project(source_project, original_source_name, original_source_path)
+
+ raise 'New project rename failed during project overwrite'
end
+ schedule_source_project_deletion(source_project)
+
+ @project
+ rescue StandardError => e
+ move_relationships_between(@project, source_project)
+ remove_source_project_from_fork_network(source_project)
+
+ raise e
ensure
track_service(start_time, source_project, e)
end
@@ -48,45 +52,63 @@ module Projects
error: exception.class.name)
end
- def move_before_destroy_relationships(source_project)
+ def move_relationships_between(source_project, target_project)
options = { remove_remaining_elements: false }
- ::Projects::MoveUsersStarProjectsService.new(@project, @current_user).execute(source_project, **options)
- ::Projects::MoveAccessService.new(@project, @current_user).execute(source_project, **options)
- ::Projects::MoveDeployKeysProjectsService.new(@project, @current_user).execute(source_project, **options)
- ::Projects::MoveNotificationSettingsService.new(@project, @current_user).execute(source_project, **options)
- ::Projects::MoveForksService.new(@project, @current_user).execute(source_project, **options)
- ::Projects::MoveLfsObjectsProjectsService.new(@project, @current_user).execute(source_project, **options)
- add_source_project_to_fork_network(source_project)
- end
-
- def destroy_old_project(source_project)
- # Delete previous project (synchronously) and unlink relations
- ::Projects::DestroyService.new(source_project, @current_user).execute
+ Project.transaction do
+ ::Projects::MoveUsersStarProjectsService.new(target_project, @current_user).execute(source_project, **options)
+ ::Projects::MoveAccessService.new(target_project, @current_user).execute(source_project, **options)
+ ::Projects::MoveDeployKeysProjectsService.new(target_project, @current_user).execute(source_project, **options)
+ ::Projects::MoveNotificationSettingsService.new(target_project, @current_user).execute(source_project, **options)
+ ::Projects::MoveForksService.new(target_project, @current_user).execute(source_project, **options)
+ ::Projects::MoveLfsObjectsProjectsService.new(target_project, @current_user).execute(source_project, **options)
+
+ add_source_project_to_fork_network(source_project)
+ end
end
- def rename_project(name, path)
- # Update de project's name and path to the original name/path
- ::Projects::UpdateService.new(@project,
- @current_user,
- { name: name, path: path })
- .execute
+ def schedule_source_project_deletion(source_project)
+ ::Projects::DestroyService.new(source_project, @current_user).async_execute
end
- def attempt_restore_repositories(project)
- ::Projects::DestroyRollbackService.new(project, @current_user).execute
+ def rename_project(target_project, name, path)
+ ::Projects::UpdateService.new(target_project, @current_user, { name: name, path: path }).execute
end
def add_source_project_to_fork_network(source_project)
- return unless @project.fork_network
+ return if source_project == @project
+ return unless fork_network
# Because they have moved all references in the fork network from the source_project
# we won't be able to query the database (only through its cached data),
# for its former relationships. That's why we're adding it to the network
# as a fork of the target project
- ForkNetworkMember.create!(fork_network: @project.fork_network,
+ ForkNetworkMember.create!(fork_network: fork_network,
project: source_project,
forked_from_project: @project)
end
+
+ def remove_source_project_from_fork_network(source_project)
+ return unless fork_network
+
+ fork_member = ForkNetworkMember.find_by( # rubocop: disable CodeReuse/ActiveRecord
+ fork_network: fork_network,
+ project: source_project,
+ forked_from_project: @project)
+
+ fork_member&.destroy
+ end
+
+ def tmp_source_project_name(source_project)
+ random_string = SecureRandom.hex
+ tmp_name = "#{source_project.name}-old-#{random_string}"
+ tmp_path = "#{source_project.path}-old-#{random_string}"
+
+ [tmp_name, tmp_path]
+ end
+
+ def fork_network
+ @project.fork_network_member&.fork_network
+ end
end
end
diff --git a/app/services/projects/readme_renderer_service.rb b/app/services/projects/readme_renderer_service.rb
new file mode 100644
index 00000000000..6871976aded
--- /dev/null
+++ b/app/services/projects/readme_renderer_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Projects
+ class ReadmeRendererService < BaseService
+ include Rails.application.routes.url_helpers
+
+ TEMPLATE_PATH = Rails.root.join('app', 'views', 'projects', 'readme_templates')
+
+ def execute
+ render(params[:template_name] || :default)
+ end
+
+ private
+
+ def render(template_name)
+ ERB.new(File.read(sanitized_filename(template_name)), trim_mode: '<>').result(binding)
+ end
+
+ def sanitized_filename(template_name)
+ path = Gitlab::Utils.check_path_traversal!("#{template_name}.md.tt")
+ path = TEMPLATE_PATH.join(path).to_s
+ Gitlab::Utils.check_allowed_absolute_path!(path, [TEMPLATE_PATH.to_s])
+
+ path
+ end
+ end
+end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 17da77fe950..51c0989ee55 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -2,11 +2,11 @@
# Projects::TransferService class
#
-# Used for transfer project to another namespace
+# Used to transfer a project to another namespace
#
# Ex.
-# # Move projects to namespace with ID 17 by user
-# Projects::TransferService.new(project, user, namespace_id: 17).execute
+# # Move project to namespace by user
+# Projects::TransferService.new(project, user).execute(namespace)
#
module Projects
class TransferService < BaseService
@@ -103,6 +103,8 @@ module Projects
update_repository_configuration(@new_path)
+ remove_issue_contacts
+
execute_system_hooks
end
@@ -254,6 +256,12 @@ module Projects
namespace_traversal_ids: new_namespace.traversal_ids
}
end
+
+ def remove_issue_contacts
+ return unless @old_group&.root_ancestor != @new_namespace&.root_ancestor
+
+ CustomerRelations::IssueContact.delete_for_project(project.id)
+ end
end
end
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index ab489ba49ca..906c4b98f56 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -61,28 +61,32 @@ module QuickActions
private
+ def failed_parse(message)
+ raise Gitlab::QuickActions::CommandDefinition::ParseError, message
+ end
+
def extractor
Gitlab::QuickActions::Extractor.new(self.class.command_definitions)
end
- # rubocop: disable CodeReuse/ActiveRecord
def extract_users(params)
- return [] if params.nil?
+ return [] if params.blank?
- users = extract_references(params, :user)
+ # We are using the a simple User.by_username query here rather than a ReferenceExtractor
+ # because the needs here are much simpler: we only deal in usernames, and
+ # want to also handle bare usernames. The ReferenceExtractor also has
+ # different behaviour, and will return all group members for groups named
+ # using a user-style reference, which is not in scope here.
+ args = params.split(/\s|,/).select(&:present?).uniq - ['and']
+ usernames = (args - ['me']).map { _1.delete_prefix('@') }
+ found = User.by_username(usernames).to_a.select { can?(:read_user_profile, _1) }
+ found_names = found.map(&:username).to_set
+ missing = args.reject { |arg| arg == 'me' || found_names.include?(arg.delete_prefix('@')) }.map { "'#{_1}'" }
- if users.empty?
- users =
- if params.strip == 'me'
- [current_user]
- else
- User.where(username: params.split(' ').map(&:strip))
- end
- end
+ failed_parse(format(_("Failed to find users for %{missing}"), missing: missing.to_sentence)) if missing.present?
- users
+ found + [current_user].select { args.include?('me') }
end
- # rubocop: enable CodeReuse/ActiveRecord
def find_milestones(project, params = {})
group_ids = project.group.self_and_ancestors.select(:id) if project.group
@@ -187,6 +191,10 @@ module QuickActions
user: current_user
)
end
+
+ def can?(ability, object)
+ Ability.allowed?(current_user, ability, object)
+ end
end
end
diff --git a/app/services/resource_events/change_state_service.rb b/app/services/resource_events/change_state_service.rb
index d68b86a1513..a396f7a1907 100644
--- a/app/services/resource_events/change_state_service.rb
+++ b/app/services/resource_events/change_state_service.rb
@@ -14,7 +14,7 @@ module ResourceEvents
ResourceStateEvent.create(
user: user,
- resource.class.underscore => resource,
+ resource.noteable_target_type_name => resource,
source_commit: commit_id_of(mentionable_source),
source_merge_request_id: merge_request_id_of(mentionable_source),
state: ResourceStateEvent.states[state],
diff --git a/app/services/security/ci_configuration/container_scanning_create_service.rb b/app/services/security/ci_configuration/container_scanning_create_service.rb
new file mode 100644
index 00000000000..788533575e6
--- /dev/null
+++ b/app/services/security/ci_configuration/container_scanning_create_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Security
+ module CiConfiguration
+ class ContainerScanningCreateService < ::Security::CiConfiguration::BaseCreateService
+ private
+
+ def action
+ Security::CiConfiguration::ContainerScanningBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate
+ end
+
+ def next_branch
+ 'set-container-scanning-config'
+ end
+
+ def message
+ _('Configure Container Scanning in `.gitlab-ci.yml`, creating this file if it does not already exist')
+ end
+
+ def description
+ _('Configure Container Scanning in `.gitlab-ci.yml` using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/container_scanning/#customizing-the-container-scanning-settings) to customize Container Scanning settings.')
+ end
+ end
+ end
+end
diff --git a/app/services/service_ping/build_payload_service.rb b/app/services/service_ping/build_payload_service.rb
index 2bef3d32103..f4ae939fd07 100644
--- a/app/services/service_ping/build_payload_service.rb
+++ b/app/services/service_ping/build_payload_service.rb
@@ -19,7 +19,7 @@ module ServicePing
end
def raw_payload
- @raw_payload ||= ::Gitlab::UsageData.data(force_refresh: true)
+ @raw_payload ||= ::Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values)
end
end
end
diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb
index d3d9dcecb2b..c8733bc2f11 100644
--- a/app/services/service_ping/submit_service.rb
+++ b/app/services/service_ping/submit_service.rb
@@ -5,6 +5,7 @@ module ServicePing
PRODUCTION_BASE_URL = 'https://version.gitlab.com'
STAGING_BASE_URL = 'https://gitlab-services-version-gitlab-com-staging.gs-staging.gitlab.org'
USAGE_DATA_PATH = 'usage_data'
+ ERROR_PATH = 'usage_ping_errors'
SubmissionError = Class.new(StandardError)
@@ -15,13 +16,24 @@ module ServicePing
def execute
return unless ServicePing::ServicePingSettings.product_intelligence_enabled?
+ start = Time.current
begin
usage_data = BuildPayloadService.new.execute
response = submit_usage_data_payload(usage_data)
- rescue StandardError
+ rescue StandardError => e
return unless Gitlab::CurrentSettings.usage_ping_enabled?
- usage_data = Gitlab::UsageData.data(force_refresh: true)
+ error_payload = {
+ time: Time.current,
+ uuid: Gitlab::UsageData.add_metric('UuidMetric'),
+ hostname: Gitlab::UsageData.add_metric('HostnameMetric'),
+ version: Gitlab::UsageData.alt_usage_data { Gitlab::VERSION },
+ message: e.message,
+ elapsed: (Time.current - start).round(1)
+ }
+ submit_payload({ error: error_payload }, url: error_url)
+
+ usage_data = Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values)
response = submit_usage_data_payload(usage_data)
end
@@ -42,12 +54,16 @@ module ServicePing
URI.join(base_url, USAGE_DATA_PATH)
end
+ def error_url
+ URI.join(base_url, ERROR_PATH)
+ end
+
private
- def submit_payload(usage_data)
+ def submit_payload(payload, url: self.url)
Gitlab::HTTP.post(
url,
- body: usage_data.to_json,
+ body: payload.to_json,
allow_local_requests: true,
headers: { 'Content-type' => 'application/json' }
)
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 0d13c73d49d..1f1edad7a69 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -45,6 +45,10 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_reviewers(old_reviewers)
end
+ def change_issuable_contacts(issuable, project, author, added_count, removed_count)
+ ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_contacts(added_count, removed_count)
+ end
+
def relate_issue(noteable, noteable_ref, user)
::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).relate_issue(noteable_ref)
end
@@ -319,8 +323,8 @@ module SystemNoteService
merge_requests_service(noteable, noteable.project, user).unapprove_mr
end
- def change_alert_status(alert, author)
- ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: author).change_alert_status(alert)
+ def change_alert_status(alert, author, reason = nil)
+ ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: author).change_alert_status(reason)
end
def new_alert_issue(alert, issue, author)
@@ -335,8 +339,8 @@ module SystemNoteService
::SystemNotes::IncidentService.new(noteable: incident, project: incident.project, author: author).change_incident_severity
end
- def resolve_incident_status(incident, author)
- ::SystemNotes::IncidentService.new(noteable: incident, project: incident.project, author: author).resolve_incident_status
+ def change_incident_status(incident, author, reason = nil)
+ ::SystemNotes::IncidentService.new(noteable: incident, project: incident.project, author: author).change_incident_status(reason)
end
def log_resolving_alert(alert, monitoring_tool)
diff --git a/app/services/system_notes/alert_management_service.rb b/app/services/system_notes/alert_management_service.rb
index 70cdd5c6434..994e3174668 100644
--- a/app/services/system_notes/alert_management_service.rb
+++ b/app/services/system_notes/alert_management_service.rb
@@ -24,11 +24,12 @@ module SystemNotes
# Example Note text:
#
# "changed the status to Acknowledged"
+ # "changed the status to Acknowledged by changing the incident status of #540"
#
# Returns the created Note object
- def change_alert_status(alert)
- status = alert.state.to_s.titleize
- body = "changed the status to **#{status}**"
+ def change_alert_status(reason)
+ status = noteable.state.to_s.titleize
+ body = "changed the status to **#{status}**#{reason}"
create_note(NoteSummary.new(noteable, project, author, body, action: 'status'))
end
@@ -39,30 +40,15 @@ module SystemNotes
#
# Example Note text:
#
- # "created issue #17 for this alert"
+ # "created incident #17 for this alert"
#
# Returns the created Note object
def new_alert_issue(issue)
- body = "created issue #{issue.to_reference(project)} for this alert"
+ body = "created incident #{issue.to_reference(project)} for this alert"
create_note(NoteSummary.new(noteable, project, author, body, action: 'alert_issue_added'))
end
- # Called when an AlertManagement::Alert is resolved due to the associated issue being closed
- #
- # issue - Issue object.
- #
- # Example Note text:
- #
- # "changed the status to Resolved by closing issue #17"
- #
- # Returns the created Note object
- def closed_alert_issue(issue)
- body = "changed the status to **Resolved** by closing issue #{issue.to_reference(project)}"
-
- 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.
diff --git a/app/services/system_notes/incident_service.rb b/app/services/system_notes/incident_service.rb
index 785291e0637..f3f9dfbec96 100644
--- a/app/services/system_notes/incident_service.rb
+++ b/app/services/system_notes/incident_service.rb
@@ -26,8 +26,19 @@ module SystemNotes
end
end
- def resolve_incident_status
- body = 'changed the status to **Resolved** by closing the incident'
+ # Called when the status of an IncidentManagement::IssuableEscalationStatus has changed
+ #
+ # reason - String.
+ #
+ # Example Note text:
+ #
+ # "changed the incident status to Acknowledged"
+ # "changed the incident status to Acknowledged by changing the status of ^alert#540"
+ #
+ # Returns the created Note object
+ def change_incident_status(reason)
+ status = noteable.escalation_status.status_name.to_s.titleize
+ body = "changed the incident status to **#{status}**#{reason}"
create_note(NoteSummary.new(noteable, project, author, body, action: 'status'))
end
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index d33dcd65589..09f36bb6501 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -111,6 +111,35 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'reviewer'))
end
+ # Called when the contacts of an issuable are changed or removed
+ # We intend to reference the contacts but for security we are just
+ # going to state how many were added/removed for now. See discussion:
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77816#note_806114273
+ #
+ # added_count - number of contacts added, or 0
+ # removed_count - number of contacts removed, or 0
+ #
+ # Example Note text:
+ #
+ # "added 2 contacts"
+ #
+ # "added 3 contacts and removed one contact"
+ #
+ # Returns the created Note object
+ def change_issuable_contacts(added_count, removed_count)
+ text_parts = []
+
+ Gitlab::I18n.with_default_locale do
+ text_parts << "added #{added_count} #{'contact'.pluralize(added_count)}" if added_count > 0
+ text_parts << "removed #{removed_count} #{'contact'.pluralize(removed_count)}" if removed_count > 0
+ end
+
+ return if text_parts.empty?
+
+ body = text_parts.join(' and ')
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'contact'))
+ end
+
# Called when the title of a Noteable is changed
#
# old_title - Previous String title
diff --git a/app/services/task_list_toggle_service.rb b/app/services/task_list_toggle_service.rb
index 32cfa198ce8..082fa1447fc 100644
--- a/app/services/task_list_toggle_service.rb
+++ b/app/services/task_list_toggle_service.rb
@@ -38,7 +38,7 @@ class TaskListToggleService
return unless markdown_task.chomp == line_source
return unless source_checkbox = Taskable::ITEM_PATTERN.match(markdown_task)
- currently_checked = TaskList::Item.new(source_checkbox[1]).complete?
+ currently_checked = TaskList::Item.new(source_checkbox[2]).complete?
# Check `toggle_as_checked` to make sure we don't accidentally replace
# any `[ ]` or `[x]` in the middle of the text
diff --git a/app/services/test_hooks/base_service.rb b/app/services/test_hooks/base_service.rb
index 0fda6fb1ed0..b41a9959c13 100644
--- a/app/services/test_hooks/base_service.rb
+++ b/app/services/test_hooks/base_service.rb
@@ -18,7 +18,7 @@ module TestHooks
return error('Testing not available for this hook') if trigger_key.nil? || data.blank?
return error(data[:error]) if data[:error].present?
- hook.execute(data, trigger_key)
+ hook.execute(data, trigger_key, force: true)
end
end
end
diff --git a/app/services/update_container_registry_info_service.rb b/app/services/update_container_registry_info_service.rb
index 531335839a9..7d79b257687 100644
--- a/app/services/update_container_registry_info_service.rb
+++ b/app/services/update_container_registry_info_service.rb
@@ -15,6 +15,12 @@ class UpdateContainerRegistryInfoService
client = ContainerRegistry::Client.new(registry_config.api_url, token: token)
info = client.registry_info
+ gitlab_api_client = ContainerRegistry::GitlabApiClient.new(registry_config.api_url, token: token)
+ if gitlab_api_client.supports_gitlab_api?
+ info[:features] ||= []
+ info[:features] << ContainerRegistry::GitlabApiClient::REGISTRY_GITLAB_V1_API_FEATURE
+ end
+
Gitlab::CurrentSettings.update!(
container_registry_vendor: info[:vendor] || '',
container_registry_version: info[:version] || '',
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 1634cc017ae..4ec875098fa 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -65,10 +65,7 @@ module Users
user.destroy_dependent_associations_in_batches(exclude: [:snippets])
# Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
- user_data = nil
- ::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/340260') do
- user_data = user.destroy
- end
+ user_data = user.destroy
namespace.destroy
user_data
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index 33e34ec41e2..b1d8872aa5e 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -34,11 +34,12 @@ class WebHookService
hook_name.to_s.singularize.titleize
end
- def initialize(hook, data, hook_name, uniqueness_token = nil)
+ def initialize(hook, data, hook_name, uniqueness_token = nil, force: false)
@hook = hook
@data = data
@hook_name = hook_name.to_s
@uniqueness_token = uniqueness_token
+ @force = force
@request_options = {
timeout: Gitlab.config.gitlab.webhook_timeout,
use_read_total_timeout: true,
@@ -46,10 +47,17 @@ class WebHookService
}
end
+ def disabled?
+ !@force && !hook.executable?
+ end
+
def execute
- return { status: :error, message: 'Hook disabled' } unless hook.executable?
+ return { status: :error, message: 'Hook disabled' } if disabled?
- log_recursion_limit if recursion_blocked?
+ if recursion_blocked?
+ log_recursion_blocked
+ return { status: :error, message: 'Recursive webhook blocked' }
+ end
Gitlab::WebHooks::RecursionDetection.register!(hook)
@@ -96,13 +104,14 @@ class WebHookService
def async_execute
Gitlab::ApplicationContext.with_context(hook.application_context) do
- break log_rate_limit if rate_limited?
+ break log_rate_limited if rate_limited?
+ break log_recursion_blocked if recursion_blocked?
- log_recursion_limit if recursion_blocked?
+ params = {
+ recursion_detection_request_uuid: Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid
+ }.compact
- data[:_gitlab_recursion_detection_request_uuid] = Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid
-
- WebHookWorker.perform_async(hook.id, data, hook_name)
+ WebHookWorker.perform_async(hook.id, data, hook_name, params)
end
end
@@ -144,8 +153,12 @@ class WebHookService
internal_error_message: error_message
}
- ::WebHooks::LogExecutionWorker
- .perform_async(hook.id, log_data, category, uniqueness_token)
+ if @force # executed as part of test - run log-execution inline.
+ ::WebHooks::LogExecutionService.new(hook: hook, log_data: log_data, response_category: category).execute
+ else
+ ::WebHooks::LogExecutionWorker
+ .perform_async(hook.id, log_data, category, uniqueness_token)
+ end
end
def response_category(response)
@@ -202,7 +215,7 @@ class WebHookService
@rate_limit ||= hook.rate_limit
end
- def log_rate_limit
+ def log_rate_limited
Gitlab::AuthLogger.error(
message: 'Webhook rate limit exceeded',
hook_id: hook.id,
@@ -212,9 +225,9 @@ class WebHookService
)
end
- def log_recursion_limit
+ def log_recursion_blocked
Gitlab::AuthLogger.error(
- message: 'Webhook recursion detected and will be blocked in future',
+ message: 'Recursive webhook blocked from executing',
hook_id: hook.id,
hook_type: hook.type,
hook_name: hook_name,
diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb
index 49f7b89158b..705735fe403 100644
--- a/app/services/work_items/create_service.rb
+++ b/app/services/work_items/create_service.rb
@@ -2,6 +2,8 @@
module WorkItems
class CreateService
+ include ::Services::ReturnServiceResponses
+
def initialize(project:, current_user: nil, params: {}, spam_params:)
@create_service = ::Issues::CreateService.new(
project: project,
@@ -10,10 +12,28 @@ module WorkItems
spam_params: spam_params,
build_service: ::WorkItems::BuildService.new(project: project, current_user: current_user, params: params)
)
+ @current_user = current_user
+ @project = project
end
def execute
- @create_service.execute
+ unless @current_user.can?(:create_work_item, @project)
+ return error(_('Operation not allowed'), :forbidden)
+ end
+
+ work_item = @create_service.execute
+
+ if work_item.valid?
+ success(payload(work_item))
+ else
+ error(work_item.errors.full_messages, :unprocessable_entity, pass_back: payload(work_item))
+ end
+ end
+
+ private
+
+ def payload(work_item)
+ { work_item: work_item }
end
end
end
diff --git a/app/services/work_items/delete_service.rb b/app/services/work_items/delete_service.rb
new file mode 100644
index 00000000000..1093a403a1c
--- /dev/null
+++ b/app/services/work_items/delete_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module WorkItems
+ class DeleteService < Issuable::DestroyService
+ def execute(work_item)
+ unless current_user.can?(:delete_work_item, work_item)
+ return ::ServiceResponse.error(message: 'User not authorized to delete work item')
+ end
+
+ if super
+ ::ServiceResponse.success
+ else
+ ::ServiceResponse.error(message: work_item.errors.full_messages)
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb
new file mode 100644
index 00000000000..5c45f4d90e5
--- /dev/null
+++ b/app/services/work_items/update_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module WorkItems
+ class UpdateService < ::Issues::UpdateService
+ private
+
+ def after_update(issuable)
+ super
+
+ GraphqlTriggers.issuable_title_updated(issuable) if issuable.previous_changes.key?(:title)
+ end
+ end
+end
diff --git a/app/uploaders/import_export_uploader.rb b/app/uploaders/import_export_uploader.rb
index 7b161d72efb..1dcd336d5d9 100644
--- a/app/uploaders/import_export_uploader.rb
+++ b/app/uploaders/import_export_uploader.rb
@@ -12,6 +12,10 @@ class ImportExportUploader < AttachmentUploader
end
def move_to_cache
+ # Exports create temporary files that we can safely move.
+ # Imports may be from project templates that we want to copy.
+ return super if mounted_as == :export_file
+
false
end
diff --git a/app/validators/x509_certificate_credentials_validator.rb b/app/validators/x509_certificate_credentials_validator.rb
index d2f18e956c3..11b53d59c7d 100644
--- a/app/validators/x509_certificate_credentials_validator.rb
+++ b/app/validators/x509_certificate_credentials_validator.rb
@@ -41,7 +41,7 @@ class X509CertificateCredentialsValidator < ActiveModel::Validator
return if private_key.nil? || certificate.nil?
- unless certificate.public_key.fingerprint == private_key.public_key.fingerprint
+ unless certificate.check_private_key(private_key)
record.errors.add(options[:pkey], _('private key does not match certificate.'))
end
end
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 e46a88b2217..189986b3dec 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -15,6 +15,7 @@
= f.label :max_attachment_size, _('Maximum attachment size (MB)'), class: 'label-bold'
= f.number_field :max_attachment_size, class: 'form-control gl-form-input', title: _('Maximum size of individual attachments in comments.'), data: { toggle: 'tooltip', container: 'body' }
+ = render 'admin/application_settings/repository_size_limit_setting_registration_features_cta', form: f
= render_if_exists 'admin/application_settings/repository_size_limit_setting', form: f
.form-group
diff --git a/app/views/admin/application_settings/_note_limits.html.haml b/app/views/admin/application_settings/_note_limits.html.haml
index eb6122f244a..38a5d6a1010 100644
--- a/app/views/admin/application_settings/_note_limits.html.haml
+++ b/app/views/admin/application_settings/_note_limits.html.haml
@@ -9,7 +9,7 @@
= f.label :notes_create_limit_allowlist, _('Users to exclude from the rate limit'), class: 'label-bold'
= f.text_area :notes_create_limit_allowlist_raw, placeholder: 'username1, username2', class: 'form-control gl-form-input', rows: 5
.form-text.text-muted
- = _('Comma-separated list of users allowed to exceed the rate limit.')
+ = _('List of users allowed to exceed the rate limit.')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index 6df1be9f6cb..ce81f81c125 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -16,7 +16,7 @@
= _("If you get a lot of false alarms from repository checks, you can clear all repository check information from the database.")
- clear_repository_checks_link = _('Clear all repository checks')
- clear_repository_checks_message = _('This clears repository check states for all projects in the database and cannot be undone. Are you sure?')
- = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "gl-button btn btn-sm btn-danger gl-mt-3"
+ = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message, confirm_btn_variant: 'danger' }, aria: { label: _('Clear repository checks') }, method: :put, class: "gl-button btn btn-sm btn-danger gl-mt-3"
.sub-section
%h4= _("Housekeeping")
diff --git a/app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml b/app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml
new file mode 100644
index 00000000000..8daa5aa8c73
--- /dev/null
+++ b/app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml
@@ -0,0 +1,8 @@
+- return unless registration_features_can_be_prompted?
+
+.form-group
+ = form.label :disabled_repository_size_limit, class: 'label-bold' do
+ = _('Size limit per repository (MB)')
+ = form.number_field :disabled_repository_size_limit, value: '', class: 'form-control gl-form-input', disabled: true
+ %span.form-text.text-muted
+ = render 'shared/registration_features_discovery_message'
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index 4fba1aee12d..326aae26d5e 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -28,8 +28,8 @@
%button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } }
.gl-spinner.js-spinner.gl-display-none.gl-mr-2
- .js-text.d-inline= _('Preview payload')
- %pre.service-data-payload-container.js-syntax-highlight.code.highlight.mt-2.d-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
+ .js-text.gl-display-inline= _('Preview payload')
+ %pre.service-data-payload-container.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
- else
= _('Service ping is disabled in your configuration file, and cannot be enabled through this form.')
- deactivating_service_ping_path = help_page_path('development/service_ping/index.md', anchor: 'disable-service-ping-using-the-configuration-file')
diff --git a/app/views/admin/application_settings/_users_api_limits.html.haml b/app/views/admin/application_settings/_users_api_limits.html.haml
new file mode 100644
index 00000000000..e9b657f8942
--- /dev/null
+++ b/app/views/admin/application_settings/_users_api_limits.html.haml
@@ -0,0 +1,14 @@
+= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-users-api-limits-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :users_get_by_id_limit, _('Maximum requests per 10 minutes per user'), class: 'label-bold'
+ = f.number_field :users_get_by_id_limit, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :users_get_by_id_limit_allowlist_raw, _('Users to exclude from the rate limit'), class: 'label-bold'
+ = f.text_area :users_get_by_id_limit_allowlist_raw, placeholder: 'username1, username2', class: 'form-control gl-form-input', rows: 5
+ .form-text.text-muted
+ = _('List of users allowed to exceed the rate limit.')
+
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index 242d0c364f4..90183b028f0 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -122,6 +122,18 @@
.settings-content
= render 'note_limits'
+%section.settings.as-users-api-limits.no-animate#js-users-api-limits-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Users API rate limit')
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Set the per-user rate limit for getting a user by ID via the API.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_users_api.md'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ = render 'users_api_limits'
+
%section.settings.as-import-export-limits.no-animate#js-import-export-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml
new file mode 100644
index 00000000000..d9825183d88
--- /dev/null
+++ b/app/views/admin/application_settings/service_usage_data.html.haml
@@ -0,0 +1,16 @@
+- name = _("Service usage data")
+
+- breadcrumb_title name
+- page_title name
+- @content_class = "limit-container-width" unless fluid_layout
+- payload_class = 'js-service-ping-payload'
+
+%h3= name
+
+%button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } }
+ .gl-spinner.js-spinner.gl-display-none.gl-mr-2
+ .js-text.gl-display-inline= _('Preview payload')
+%button.gl-button.btn.btn-default.js-payload-download-trigger{ type: 'button', data: { endpoint: usage_data_admin_application_settings_path(format: :json) } }
+ .gl-spinner.js-spinner.gl-display-none.gl-mr-2
+ .js-text.d-inline= _('Download payload')
+%pre.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
diff --git a/app/views/admin/background_migrations/index.html.haml b/app/views/admin/background_migrations/index.html.haml
index 13ac511f1b4..afceb6427e0 100644
--- a/app/views/admin/background_migrations/index.html.haml
+++ b/app/views/admin/background_migrations/index.html.haml
@@ -1,33 +1,26 @@
- page_title _('Background Migrations')
-.tabs.gl-tabs
- %div
- %ul.nav.gl-tabs-nav{ role: 'tablist' }
- - active_tab_classes = ['gl-tab-nav-item-active', 'gl-tab-nav-item-active-indigo']
+= gl_tabs_nav do
+ = gl_tab_link_to admin_background_migrations_path, item_active: @current_tab == 'queued' do
+ = _('Queued')
+ = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['queued'])
+ = gl_tab_link_to admin_background_migrations_path(tab: 'failed'), item_active: @current_tab == 'failed' do
+ = _('Failed')
+ = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['failed'])
+ = gl_tab_link_to admin_background_migrations_path(tab: 'finished'), item_active: @current_tab == 'finished' do
+ = _('Finished')
+ = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['finished'])
- %li.nav-item{ role: 'presentation' }
- %a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path, class: (active_tab_classes if @current_tab == 'queued'), role: 'tab' }
- = _('Queued')
- = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['queued'])
- %li.nav-item{ role: 'presentation' }
- %a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path(tab: 'failed'), class: (active_tab_classes if @current_tab == 'failed'), role: 'tab' }
- = _('Failed')
- = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['failed'])
- %li.nav-item{ role: 'presentation' }
- %a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path(tab: 'finished'), class: (active_tab_classes if @current_tab == 'finished'), role: 'tab' }
- = _('Finished')
- = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['finished'])
+.tab-content.gl-tab-content
+ .tab-pane.active{ role: 'tabpanel' }
+ %table.table.b-table.gl-table.b-table-stacked-md{ role: 'table' }
+ %thead{ role: 'rowgroup' }
+ %tr{ role: 'row' }
+ %th.table-th-transparent.border-bottom{ role: 'cell' }= _('Migration')
+ %th.table-th-transparent.border-bottom{ role: 'cell' }= _('Progress')
+ %th.table-th-transparent.border-bottom{ role: 'cell' }= _('Status')
+ %th.table-th-transparent.border-bottom{ role: 'cell' }
+ %tbody{ role: 'rowgroup' }
+ = render partial: 'migration', collection: @migrations
- .tab-content.gl-tab-content
- .tab-pane.active{ role: 'tabpanel' }
- %table.table.b-table.gl-table.b-table-stacked-md{ role: 'table' }
- %thead{ role: 'rowgroup' }
- %tr{ role: 'row' }
- %th.table-th-transparent.border-bottom{ role: 'cell' }= _('Migration')
- %th.table-th-transparent.border-bottom{ role: 'cell' }= _('Progress')
- %th.table-th-transparent.border-bottom{ role: 'cell' }= _('Status')
- %th.table-th-transparent.border-bottom{ role: 'cell' }
- %tbody{ role: 'rowgroup' }
- = render partial: 'migration', collection: @migrations
-
- = paginate_collection @migrations
+ = paginate_collection @migrations
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index 9dce33bf037..3f07bea7840 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -11,33 +11,33 @@
%br.clearfix
- if @broadcast_messages.any?
- %table.table.table-responsive
- %thead
- %tr
- %th= _('Status')
- %th= _('Preview')
- %th= _('Starts')
- %th= _('Ends')
- %th= _(' Target Path')
- %th= _(' Type')
- %th &nbsp;
- %tbody
- - @broadcast_messages.each do |message|
+ .table-responsive
+ %table.table.b-table.gl-table
+ %thead
%tr
- %td
- = broadcast_message_status(message)
- %td
- = broadcast_message(message, preview: true)
- %td
- = message.starts_at
- %td
- = message.ends_at
- %td
- = message.target_path
- %td
- = message.broadcast_type.capitalize
- %td.gl-white-space-nowrap.gl-display-flex
- = link_to sprite_icon('pencil-square', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: _('Edit'), class: 'btn btn-icon gl-button'
- = link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: _('Remove'), class: 'js-remove-tr btn btn-icon gl-button btn-danger ml-2'
-
- = paginate @broadcast_messages, theme: 'gitlab'
+ %th= _('Status')
+ %th= _('Preview')
+ %th= _('Starts')
+ %th= _('Ends')
+ %th= _(' Target Path')
+ %th= _(' Type')
+ %th &nbsp;
+ %tbody
+ - @broadcast_messages.each do |message|
+ %tr
+ %td
+ = broadcast_message_status(message)
+ %td
+ = broadcast_message(message, preview: true)
+ %td
+ = message.starts_at
+ %td
+ = message.ends_at
+ %td
+ = message.target_path
+ %td
+ = message.broadcast_type.capitalize
+ %td.gl-white-space-nowrap
+ = link_to sprite_icon('pencil-square', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: _('Edit'), class: 'btn btn-icon gl-button'
+ = link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: _('Remove'), class: 'js-remove-tr btn btn-icon gl-button btn-danger ml-2'
+ = paginate @broadcast_messages, theme: 'gitlab'
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 85b6ebfc63a..69033d274a2 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -143,6 +143,11 @@
= _('GitLab Pages')
%span.float-right
= Gitlab::Pages::VERSION
+ - if Gitlab::Kas.enabled?
+ %p
+ = _('GitLab KAS')
+ %span.gl-float-right
+ = Gitlab::Kas.version
= render_if_exists 'admin/dashboard/geo'
diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml
index 0c2280a2f63..c27ff348f59 100644
--- a/app/views/admin/groups/_group.html.haml
+++ b/app/views/admin/groups/_group.html.haml
@@ -33,4 +33,4 @@
.controls.gl-flex-shrink-0.gl-ml-5
= 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'
+ = link_to _('Delete'), [:admin, group], aria: { label: _('Remove') }, data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name }, confirm_btn_variant: 'danger' }, method: :delete, class: 'gl-button btn btn-danger'
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index 9c258e10008..566d8a99ac6 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -13,7 +13,7 @@
.form-actions
%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?') }
+ = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn gl-button btn-danger float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this webhook?'), confirm_btn_variant: 'danger' }
%hr
diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml
index 16661efce04..ae8fed8964f 100644
--- a/app/views/admin/labels/_label.html.haml
+++ b/app/views/admin/labels/_label.html.haml
@@ -3,5 +3,5 @@
.label-actions-list
= link_to edit_admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
= sprite_icon('pencil')
- = link_to admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary hover-red js-remove-label label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do
+ = link_to admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary hover-red js-remove-label label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: _('Are you sure you want to delete this label?'), confirm_btn_variant: 'danger' }, aria: { label: _('Delete label') }, method: :delete, remote: true do
= sprite_icon('remove')
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index ee2e63353f0..e8bcf479d70 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -39,15 +39,15 @@
= _('Namespace:')
%strong
- if @project.namespace
- = link_to @project.namespace.human_name, [:admin, @project.group || @project.owner]
+ = link_to @project.namespace.human_name, [:admin, @project.personal? ? @project.namespace.owner : @project.group]
- else
= s_('ProjectSettings|Global')
%li
%span.light
= _('Owned by:')
%strong
- - if @project.owner
- = link_to @project.owner_name, [:admin, @project.owner]
+ - if @project.owners.any?
+ = safe_join(@project.owners.map { |owner| link_to(owner.name, [:admin, owner]) }, ", ".html_safe)
- else
= _('(deleted)')
diff --git a/app/views/admin/runners/edit.html.haml b/app/views/admin/runners/edit.html.haml
index 0257983016c..b65fead49ab 100644
--- a/app/views/admin/runners/edit.html.haml
+++ b/app/views/admin/runners/edit.html.haml
@@ -1,7 +1,7 @@
- add_page_specific_style 'page_bundles/ci_status'
- runner_name = "##{@runner.id} (#{@runner.short_sha})"
-- if Feature.enabled?(:runner_read_only_admin_view)
+- if Feature.enabled?(:runner_read_only_admin_view, default_enabled: :yaml)
- breadcrumb_title _('Edit')
- page_title _('Edit'), runner_name
- add_to_breadcrumbs _('Runners'), admin_runners_path
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index 7b4390ae463..5c4a7026f50 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -5,4 +5,4 @@
- page_title title
- add_to_breadcrumbs _('Runners'), admin_runners_path
--# Empty view in development behind feature flag runner_read_only_admin_view
+#js-admin-runner-show{ data: {runner_id: @runner.id} }
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index 5977de7c84c..1e4c3f3bb62 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -26,10 +26,10 @@
= render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user
- - help_text = s_('AdminUsers|Administrators have access to all groups, projects and users and can manage all features in this installation.')
- - help_text += ' ' + s_('AdminUsers|You cannot remove your own admin rights.') if editing_current_user
+ - help_text = s_('AdminUsers|The user has unlimited access to all groups, projects, users, and features.')
+ - help_text += ' ' + s_('AdminUsers|You cannot remove your own administrator access.') if editing_current_user
= f.gitlab_ui_radio_component :access_level, :admin,
- s_('AdminUsers|Admin'),
+ s_('AdminUsers|Administrator'),
radio_options: { disabled: editing_current_user },
help_text: help_text
diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml
index 5edd5403d49..6fdf383d571 100644
--- a/app/views/admin/users/_users.html.haml
+++ b/app/views/admin/users/_users.html.haml
@@ -6,43 +6,44 @@
.gl-alert-body
= render 'shared/registration_features_discovery_message', feature_title: s_('RegistrationFeatures|send emails to users')
-.top-area.scrolling-tabs-container.inner-page-scroll-tabs
- .fade-left
- = sprite_icon('chevron-lg-left', size: 12)
- .fade-right
- = sprite_icon('chevron-lg-right', size: 12)
- = gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1' }) do
- = gl_tab_link_to admin_users_path, { item_active: active_when(params[:filter].nil?), class: 'gl-border-0!' } do
- = s_('AdminUsers|Active')
- = gl_tab_counter_badge(limited_counter_with_delimiter(User.active_without_ghosts))
- = gl_tab_link_to admin_users_path(filter: "admins"), { item_active: active_when(params[:filter] == 'admins'), class: 'gl-border-0!' } do
- = s_('AdminUsers|Admins')
- = gl_tab_counter_badge(limited_counter_with_delimiter(User.admins))
- = gl_tab_link_to admin_users_path(filter: 'two_factor_enabled'), { item_active: active_when(params[:filter] == 'two_factor_enabled'), class: 'filter-two-factor-enabled gl-border-0!' } do
- = s_('AdminUsers|2FA Enabled')
- = gl_tab_counter_badge(limited_counter_with_delimiter(User.with_two_factor))
- = gl_tab_link_to admin_users_path(filter: 'two_factor_disabled'), { item_active: active_when(params[:filter] == 'two_factor_disabled'), class: 'filter-two-factor-disabled gl-border-0!' } do
- = s_('AdminUsers|2FA Disabled')
- = gl_tab_counter_badge(limited_counter_with_delimiter(User.without_two_factor))
- = gl_tab_link_to admin_users_path(filter: 'external'), { item_active: active_when(params[:filter] == 'external'), class: 'gl-border-0!' } do
- = s_('AdminUsers|External')
- = gl_tab_counter_badge(limited_counter_with_delimiter(User.external))
- = gl_tab_link_to admin_users_path(filter: "blocked"), { item_active: active_when(params[:filter] == 'blocked'), class: 'gl-border-0!' } do
- = s_('AdminUsers|Blocked')
- = gl_tab_counter_badge(limited_counter_with_delimiter(User.blocked))
- - if ban_feature_available?
- = gl_tab_link_to admin_users_path(filter: "banned"), { item_active: active_when(params[:filter] == 'banned'), class: 'gl-border-0!' } do
- = s_('AdminUsers|Banned')
- = gl_tab_counter_badge(limited_counter_with_delimiter(User.banned))
- = gl_tab_link_to admin_users_path(filter: "blocked_pending_approval"), { item_active: active_when(params[:filter] == 'blocked_pending_approval'), class: 'filter-blocked-pending-approval gl-border-0!', data: { qa_selector: 'pending_approval_tab' } } do
- = s_('AdminUsers|Pending approval')
- = gl_tab_counter_badge(limited_counter_with_delimiter(User.blocked_pending_approval))
- = gl_tab_link_to admin_users_path(filter: "deactivated"), { item_active: active_when(params[:filter] == 'deactivated'), class: 'gl-border-0!' } do
- = s_('AdminUsers|Deactivated')
- = gl_tab_counter_badge(limited_counter_with_delimiter(User.deactivated))
- = gl_tab_link_to admin_users_path(filter: "wop"), { item_active: active_when(params[:filter] == 'wop'), class: 'gl-border-0!' } do
- = s_('AdminUsers|Without projects')
- = gl_tab_counter_badge(limited_counter_with_delimiter(User.without_projects))
+.top-area
+ .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full
+ .fade-left
+ = sprite_icon('chevron-lg-left', size: 12)
+ .fade-right
+ = sprite_icon('chevron-lg-right', size: 12)
+ = gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full' }) do
+ = gl_tab_link_to admin_users_path, { item_active: active_when(params[:filter].nil?), class: 'gl-border-0!' } do
+ = s_('AdminUsers|Active')
+ = gl_tab_counter_badge(limited_counter_with_delimiter(User.active_without_ghosts))
+ = gl_tab_link_to admin_users_path(filter: "admins"), { item_active: active_when(params[:filter] == 'admins'), class: 'gl-border-0!' } do
+ = s_('AdminUsers|Admins')
+ = gl_tab_counter_badge(limited_counter_with_delimiter(User.admins))
+ = gl_tab_link_to admin_users_path(filter: 'two_factor_enabled'), { item_active: active_when(params[:filter] == 'two_factor_enabled'), class: 'filter-two-factor-enabled gl-border-0!' } do
+ = s_('AdminUsers|2FA Enabled')
+ = gl_tab_counter_badge(limited_counter_with_delimiter(User.with_two_factor))
+ = gl_tab_link_to admin_users_path(filter: 'two_factor_disabled'), { item_active: active_when(params[:filter] == 'two_factor_disabled'), class: 'filter-two-factor-disabled gl-border-0!' } do
+ = s_('AdminUsers|2FA Disabled')
+ = gl_tab_counter_badge(limited_counter_with_delimiter(User.without_two_factor))
+ = gl_tab_link_to admin_users_path(filter: 'external'), { item_active: active_when(params[:filter] == 'external'), class: 'gl-border-0!' } do
+ = s_('AdminUsers|External')
+ = gl_tab_counter_badge(limited_counter_with_delimiter(User.external))
+ = gl_tab_link_to admin_users_path(filter: "blocked"), { item_active: active_when(params[:filter] == 'blocked'), class: 'gl-border-0!' } do
+ = s_('AdminUsers|Blocked')
+ = gl_tab_counter_badge(limited_counter_with_delimiter(User.blocked))
+ - if ban_feature_available?
+ = gl_tab_link_to admin_users_path(filter: "banned"), { item_active: active_when(params[:filter] == 'banned'), class: 'gl-border-0!' } do
+ = s_('AdminUsers|Banned')
+ = gl_tab_counter_badge(limited_counter_with_delimiter(User.banned))
+ = gl_tab_link_to admin_users_path(filter: "blocked_pending_approval"), { item_active: active_when(params[:filter] == 'blocked_pending_approval'), class: 'filter-blocked-pending-approval gl-border-0!', data: { qa_selector: 'pending_approval_tab' } } do
+ = s_('AdminUsers|Pending approval')
+ = gl_tab_counter_badge(limited_counter_with_delimiter(User.blocked_pending_approval))
+ = gl_tab_link_to admin_users_path(filter: "deactivated"), { item_active: active_when(params[:filter] == 'deactivated'), class: 'gl-border-0!' } do
+ = s_('AdminUsers|Deactivated')
+ = gl_tab_counter_badge(limited_counter_with_delimiter(User.deactivated))
+ = gl_tab_link_to admin_users_path(filter: "wop"), { item_active: active_when(params[:filter] == 'wop'), class: 'gl-border-0!' } do
+ = s_('AdminUsers|Without projects')
+ = gl_tab_counter_badge(limited_counter_with_delimiter(User.without_projects))
.nav-controls
= render_if_exists 'admin/users/admin_email_users'
= render_if_exists 'admin/users/admin_export_user_permissions'
diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml
index f51ac40df4f..580cfe9f956 100644
--- a/app/views/admin/users/projects.html.haml
+++ b/app/views/admin/users/projects.html.haml
@@ -16,7 +16,7 @@
.float-right
%span.light.vertical-align-middle= group_member.human_access
- unless group_member.owner?
- = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member), testid: 'remove-user' }, method: :delete, remote: true, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from group') do
+ = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member), confirm_btn_variant: 'danger', testid: 'remove-user' }, aria: { label: _('Remove') }, method: :delete, remote: true, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from group') do
= sprite_icon('remove', size: 16, css_class: 'gl-icon')
.row
@@ -46,7 +46,7 @@
%span.light.vertical-align-middle= member.human_access
- if member.respond_to? :project
- = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do
+ = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do
= sprite_icon('remove', size: 16, css_class: 'gl-icon')
= render partial: 'admin/users/modals'
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index bdc5bdabb21..94542af3b96 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -10,7 +10,7 @@
= @user.name
%ul.content-list
%li
- = image_tag avatar_icon_for_user(@user, 60), class: "avatar s60"
+ = image_tag avatar_icon_for_user(@user, 60, current_user: current_user), class: "avatar s60"
%li
%span.light= _('Profile page:')
%strong
diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml
index c872ee481ad..f0a9936112b 100644
--- a/app/views/ci/runner/_how_to_setup_runner.html.haml
+++ b/app/views/ci/runner/_how_to_setup_runner.html.haml
@@ -1,6 +1,6 @@
- link = link_to _("Install GitLab Runner and ensure it's running."), 'https://docs.gitlab.com/runner/install/', target: '_blank', rel: 'noopener noreferrer'
.gl-mb-3
- %h5= _("Set up a %{type} Runner for a project") % { type: type }
+ %h5= _("Set up a %{type} runner for a project") % { type: type }
%ol
%li
= link.html_safe
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index bf654999f2f..fb46b4e5064 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -22,8 +22,6 @@
.js-cluster-application-notice
.flash-container
- .js-serverless-survey-banner{ data: { user_name: current_user.name, user_email: current_user.email } }
-
%h4.gl-my-5.gl-display-flex.gl-align-items-center
= @cluster.name
= gl_badge_tag cluster_type_label(@cluster.cluster_type), { variant: :info }, { class: 'gl-ml-3' }
diff --git a/app/views/dashboard/_projects_nav.html.haml b/app/views/dashboard/_projects_nav.html.haml
index 64aa1e01d06..90b40f3c7b7 100644
--- a/app/views/dashboard/_projects_nav.html.haml
+++ b/app/views/dashboard/_projects_nav.html.haml
@@ -10,4 +10,4 @@
= gl_tab_counter_badge(limited_counter_with_delimiter(@total_starred_projects_count))
= gl_tab_link_to _("Explore projects"), explore_root_path, { item_active: is_explore_projects_path, data: { placement: 'right' } }
= gl_tab_link_to _("Explore topics"), topics_explore_projects_path, { data: { placement: 'right' } }
- = render_if_exists "dashboard/removed_projects_tab", removed_projects_count: @removed_projects_count
+ = render_if_exists "dashboard/removed_projects_tab"
diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
index 20bf7d232ce..eba5e7c6e9b 100644
--- a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
@@ -1,40 +1,42 @@
-.blank-state-row
+- link_classes = "blank-state blank-state-link gl-text-body gl-display-flex gl-align-items-center gl-border-1 gl-border-solid gl-border-gray-100 gl-rounded-base gl-mb-5"
+
+.gl-display-flex.gl-flex-wrap.gl-justify-content-space-between
- if has_start_trial?
= render_if_exists "dashboard/projects/blank_state_ee_trial"
- = link_to new_project_path, class: "blank-state blank-state-link" do
+ = link_to new_project_path, class: link_classes do
.blank-state-icon
= custom_icon("add_new_project", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Create a project
- %p.blank-state-text
- Projects are where you store your code, access issues, wiki and other features of GitLab.
+ .blank-state-body.gl-sm-pl-0.gl-pl-6
+ %h3.gl-font-size-h2.gl-mt-0
+ = _('Create a project')
+ %p
+ = _('Projects are where you store your code, access issues, wiki and other features of GitLab.')
- if current_user.can_create_group?
- = link_to new_group_path, class: "blank-state blank-state-link" do
+ = link_to new_group_path, class: link_classes do
.blank-state-icon
= custom_icon("add_new_group", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Create a group
- %p.blank-state-text
- Groups are a great way to organize projects and people.
+ .blank-state-body.gl-sm-pl-0.gl-pl-6
+ %h3.gl-font-size-h2.gl-mt-0
+ = _('Create a group')
+ %p
+ = _('Groups are a great way to organize projects and people.')
- = link_to new_admin_user_path, class: "blank-state blank-state-link" do
+ = link_to new_admin_user_path, class: link_classes do
.blank-state-icon
= custom_icon("add_new_user", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Add people
- %p.blank-state-text
- Add your team members and others to GitLab.
+ .blank-state-body.gl-sm-pl-0.gl-pl-6
+ %h3.gl-font-size-h2.gl-mt-0
+ = _('Add people')
+ %p
+ = _('Add your team members and others to GitLab.')
- = link_to admin_root_path, class: "blank-state blank-state-link" do
+ = link_to admin_root_path, class: link_classes do
.blank-state-icon
= custom_icon("configure_server", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Configure GitLab
- %p.blank-state-text
- Make adjustments to how your GitLab instance is set up.
+ .blank-state-body.gl-sm-pl-0.gl-pl-6
+ %h3.gl-font-size-h2.gl-mt-0
+ = _('Configure GitLab')
+ %p
+ = _('Make adjustments to how your GitLab instance is set up.')
diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml
index 003e6f18b33..e0b8850357e 100644
--- a/app/views/dashboard/projects/_blank_state_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml
@@ -1,48 +1,49 @@
-.blank-state-row
+- link_classes = "blank-state blank-state-link gl-text-body gl-display-flex gl-align-items-center gl-border-1 gl-border-solid gl-border-gray-100 gl-rounded-base gl-mb-5"
+
+.gl-display-flex.gl-flex-wrap.gl-justify-content-space-between
- if current_user.can_create_project?
- = link_to new_project_path, class: "blank-state blank-state-link" do
+ = link_to new_project_path, class: link_classes do
.blank-state-icon
= custom_icon("add_new_project", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Create a project
- %p.blank-state-text
- Projects are where you store your code, access issues, wiki and other features of GitLab.
+ .blank-state-body.gl-sm-pl-0.gl-pl-6
+ %h3.gl-font-size-h2.gl-mt-0
+ = _('Create a project')
+ %p
+ = _('Projects are where you store your code, access issues, wiki and other features of GitLab.')
- else
- .blank-state
+ .blank-state.gl-display-flex.gl-align-items-center.gl-border-1.gl-border-solid.gl-border-gray-100.gl-rounded-base.gl-mb-5
.blank-state-icon
= custom_icon("add_new_project", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Create a project
- %p.blank-state-text
- If you are added to a project, it will be displayed here.
+ .blank-state-body.gl-sm-pl-0.gl-pl-6
+ %h3.gl-font-size-h2.gl-mt-0
+ = _('Create a project')
+ %p
+ = _('If you are added to a project, it will be displayed here.')
- if current_user.can_create_group?
- = link_to new_group_path, class: "blank-state blank-state-link" do
+ = link_to new_group_path, class: link_classes do
.blank-state-icon
= custom_icon("add_new_group", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Create a group
- %p.blank-state-text
- Groups are the best way to manage projects and members.
+ .blank-state-body.gl-sm-pl-0.gl-pl-6
+ %h3.gl-font-size-h2.gl-mt-0
+ = _('Create a group')
+ %p
+ = _('Groups are the best way to manage projects and members.')
- = link_to trending_explore_projects_path, class: "blank-state blank-state-link" do
+ = link_to trending_explore_projects_path, class: link_classes do
.blank-state-icon
= custom_icon("globe", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Explore public projects
- %p.blank-state-text
- Public projects are an easy way to allow
- everyone to have read-only access.
+ .blank-state-body.gl-sm-pl-0.gl-pl-6
+ %h3.gl-font-size-h2.gl-mt-0
+ = _('Explore public projects')
+ %p
+ = _('Public projects are an easy way to allow everyone to have read-only access.')
- = link_to "https://docs.gitlab.com/", class: "blank-state blank-state-link" do
+ = link_to "https://docs.gitlab.com/", class: link_classes do
.blank-state-icon
= custom_icon("lightbulb", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Learn more about GitLab
- %p.blank-state-text
- Take a look at the documentation to discover all of GitLab's capabilities.
+ .blank-state-body.gl-sm-pl-0.gl-pl-6
+ %h3.gl-font-size-h2.gl-mt-0
+ = _('Learn more about GitLab')
+ %p
+ = _('Take a look at the documentation to discover all of GitLab’s capabilities.')
diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
index b5f5025b581..e72762f2ae5 100644
--- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml
+++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
@@ -1,13 +1,10 @@
-.blank-state-parent-container
- .section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" }
- .container.section-body
- .row
- .blank-state-welcome.w-100
- %h2.blank-state-welcome-title{ data: { qa_selector: 'welcome_title_content' } }
- = _('Welcome to GitLab')
- %p.blank-state-text
- = _('Faster releases. Better code. Less pain.')
- - if current_user.admin?
- = render "blank_state_admin_welcome"
- - else
- = render "blank_state_welcome"
+.container
+ .gl-text-center.gl-pt-6.gl-pb-7
+ %h2.gl-font-size-h1{ data: { qa_selector: 'welcome_title_content' } }
+ = _('Welcome to GitLab')
+ %p.gl-m-0
+ = _('Faster releases. Better code. Less pain.')
+ - if current_user.admin?
+ = render "blank_state_admin_welcome"
+ - else
+ = render "blank_state_welcome"
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index 4252b60514a..0d9257e659a 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -7,6 +7,7 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
+- add_page_specific_style 'page_bundles/dashboard_projects'
= render "projects/last_push"
- if show_projects?(@projects, params)
diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml
index 1d46a43e5bd..ef19ac33a15 100644
--- a/app/views/devise/confirmations/almost_there.haml
+++ b/app/views/devise/confirmations/almost_there.haml
@@ -4,6 +4,7 @@
- content_for :page_specific_javascripts do
= render "layouts/google_tag_manager_head"
= render "layouts/one_trust"
+ = render "layouts/bizible"
= render "layouts/google_tag_manager_body"
.well-confirmation.gl-text-center.gl-mb-6
diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml
index 87108c8ea78..60c3df718a1 100644
--- a/app/views/devise/registrations/new.html.haml
+++ b/app/views/devise/registrations/new.html.haml
@@ -3,6 +3,7 @@
- content_for :page_specific_javascripts do
= render "layouts/google_tag_manager_head"
= render "layouts/one_trust"
+ = render "layouts/bizible"
= render "layouts/google_tag_manager_body"
.signup-page
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index 175b45dbbfa..c669f3efec6 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -1,6 +1,7 @@
- page_title _("Sign in")
- content_for :page_specific_javascripts do
= render "layouts/one_trust"
+ = render "layouts/bizible"
#signin-container
- if any_form_based_providers_enabled?
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 982171b9e34..970e490dd72 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -1,7 +1,8 @@
- max_first_name_length = max_last_name_length = 127
- omniauth_providers_placement ||= :bottom
+- borderless ||= false
-.gl-mb-3.gl-p-4.gl-border-gray-100.gl-border-1.gl-border-solid.gl-rounded-base
+.gl-mb-3.gl-p-4{ class: (borderless ? '' : 'gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base') }
- if show_omniauth_providers && omniauth_providers_placement == :top
= render 'devise/shared/signup_omniauth_providers_top'
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index b34b6f09662..cd0c9a016a5 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -29,7 +29,7 @@
%td.line_content.js-success-lazy-load
.js-code-placeholder
%td.js-error-lazy-load-diff.hidden.diff-loading-error-block
- - button = button_tag(_("Try again"), class: "btn-link gl-button btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button")
+ - button = button_tag(_("Try again"), class: "btn-link gl-button btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button")
= _("Unable to load the diff. %{button_try_again}").html_safe % { button_try_again: button}
= render "discussions/diff_discussion", discussions: [discussion], expanded: true
- else
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index beac4946fd7..a35ba12dd52 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -9,9 +9,9 @@
-# to the first note position when we click on a badge diff discussion
%ul.notes{ id: "discussion_#{discussion.id}", data: { discussion_id: discussion.id, position: discussion.notes[0].position.to_json } }
- if discussion.try(:on_image?) && show_toggle
- %button.gl-button.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' }
+ %button.comment-indicator.gl-display-flex.gl-align-items-center.gl-justify-content-center.gl-font-sm.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' }
= sprite_icon('collapse', css_class: 'collapse-icon')
- %button.gl-button.btn-transparent.badge.badge-pill.js-diff-notes-toggle{ type: 'button' }
+ %button.gl-align-items-center.gl-justify-content-center.gl-font-sm.small.gl-translate-x-n50.design-note-pin.js-diff-notes-toggle.diff-notes-expand{ type: 'button' }
= badge_counter
= render partial: "shared/notes/note", collection: discussion.notes, as: :note, locals: { badge_counter: badge_counter, show_image_comment_badge: show_image_comment_badge }
diff --git a/app/views/doorkeeper/authorized_applications/_delete_form.html.haml b/app/views/doorkeeper/authorized_applications/_delete_form.html.haml
index d73d171798e..fcd52f33121 100644
--- a/app/views/doorkeeper/authorized_applications/_delete_form.html.haml
+++ b/app/views/doorkeeper/authorized_applications/_delete_form.html.haml
@@ -5,4 +5,4 @@
= form_tag path do
%input{ :name => "_method", :type => "hidden", :value => "delete" }/
- = submit_tag _('Revoke'), class: 'gl-button btn btn-danger btn-sm', data: { confirm: _('Are you sure?') }
+ = submit_tag _('Revoke'), class: 'gl-button btn btn-danger btn-sm', aria: { label: s_('AuthorizedApplication|Revoke application') }, data: { confirm: s_('AuthorizedApplication|Are you sure you want to revoke this application?'), confirm_btn_variant: 'danger' }
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index e5d67831c71..9b3a8c31d54 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -31,7 +31,7 @@
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('admin')
- 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' } }
+ .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', no_flip: 'true' } }
- 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-default gl-button gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_subgroup_button' }
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 0644910dd3e..ee0967f708a 100644
--- a/app/views/groups/_import_group_from_file_panel.html.haml
+++ b/app/views/groups/_import_group_from_file_panel.html.haml
@@ -11,10 +11,7 @@
.gl-alert-body
- docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') }
- link_end = '</a>'.html_safe
- = s_('GroupsNew|This feature is deprecated and replaced by %{docs_link_start}Group Migration%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end }
- - if Feature.enabled?(:bulk_import, default_enabled: :yaml)
- - enable_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md', anchor: 'enable-or-disable-gitlab-group-migration') }
- = s_('GroupsNew|Ask your administrator to %{enable_link_start}enable%{enable_link_end} Group Migration.').html_safe % { enable_link_start: enable_link_start, enable_link_end: link_end }
+ = s_('GroupsNew|This feature is deprecated and replaced by %{docs_link_start}group migration%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end }
.form-group.gl-display-flex.gl-flex-direction-column.gl-mt-5
= f.label :name, _('New group name'), for: 'import_group_name'
diff --git a/app/views/groups/_invite_groups_modal.html.haml b/app/views/groups/_invite_groups_modal.html.haml
new file mode 100644
index 00000000000..22ef319348a
--- /dev/null
+++ b/app/views/groups/_invite_groups_modal.html.haml
@@ -0,0 +1,3 @@
+- return unless can_admin_group_member?(group)
+
+.js-invite-groups-modal{ data: common_invite_group_modal_data(group, GroupMember, 'false').merge(group_select_data(group)) }
diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml
index 78f079df158..786034fd2e7 100644
--- a/app/views/groups/_invite_members_modal.html.haml
+++ b/app/views/groups/_invite_members_modal.html.haml
@@ -2,5 +2,4 @@
.js-invite-members-modal{ data: { is_project: 'false',
access_levels: GroupMember.access_level_roles.to_json,
- default_access_level: Gitlab::Access::GUEST,
- help_link: help_page_url('user/permissions') }.merge(group_select_data(group)).merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) }
+ help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) }
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 97867e312af..d1f56a50907 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -11,7 +11,7 @@
= _('Group members')
%p
= 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 Feature.enabled?(:invite_members_group_modal, @group)
+ - if Feature.enabled?(:invite_members_group_modal, @group, default_enabled: :yaml)
.gl-w-half.gl-xs-w-full
.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') } }
@@ -19,8 +19,9 @@
classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3',
trigger_source: 'group-members-page',
display_text: _('Invite members') } }
+ = render 'groups/invite_groups_modal', group: @group
= render 'groups/invite_members_modal', group: @group
- - if can_admin_group_member?(@group) && Feature.disabled?(:invite_members_group_modal, @group)
+ - if can_admin_group_member?(@group) && Feature.disabled?(:invite_members_group_modal, @group, default_enabled: :yaml)
%hr.gl-mt-4
%ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%li.nav-tab{ role: 'presentation' }
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index a9258a4e0d0..8afa6316c56 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -6,7 +6,7 @@
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
- if Feature.enabled?(:vue_issues_list, @group, default_enabled: :yaml)
- .js-issues-list{ data: group_issues_list_data(@group, current_user, @issues, @projects) }
+ .js-issues-list{ data: group_issues_list_data(@group, current_user) }
- if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
- else
diff --git a/app/views/groups/runners/index.html.haml b/app/views/groups/runners/index.html.haml
index f904b34d29e..a67a4f28c93 100644
--- a/app/views/groups/runners/index.html.haml
+++ b/app/views/groups/runners/index.html.haml
@@ -1,6 +1,3 @@
- page_title s_('Runners|Runners')
-%h2.page-title
- = s_('Runners|Group Runners')
-
#js-group-runners{ data: group_runners_data_attributes(@group).merge( { group_runners_limited_count: @group_runners_limited_count } ) }
diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml
index ff00ff1f6e8..81403fd88b2 100644
--- a/app/views/groups/settings/_export.html.haml
+++ b/app/views/groups/settings/_export.html.haml
@@ -10,7 +10,7 @@
.gl-alert-body
- docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') }
- docs_link_end = '</a>'.html_safe
- = s_('GroupsNew|This feature is deprecated and replaced by %{docs_link_start}Group Migration%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end }
+ = s_('GroupsNew|This feature is deprecated and replaced by %{docs_link_start}group migration%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end }
%p
- export_information = _('After the export is complete, download the data file from a notification email or from this page. You can then import the data file from the %{strong_text_start}Create new group%{strong_text_end} page of another GitLab instance.') % { strong_text_start: '<strong>'.html_safe, strong_text_end: '</strong>'.html_safe}
= export_information.html_safe
@@ -27,10 +27,10 @@
%li= _('Runner tokens')
%li= _('SAML discovery tokens')
- if group.export_file_exists?
- = link_to _('Regenerate export'), export_group_path(group),
- method: :post, class: 'btn gl-button btn-default', data: { qa_selector: 'regenerate_export_group_link' }
= link_to _('Download export'), download_export_group_path(group),
rel: 'nofollow', method: :get, class: 'btn gl-button btn-default', data: { qa_selector: 'download_export_link' }
+ = link_to _('Regenerate export'), export_group_path(group),
+ method: :post, class: 'btn gl-button btn-default', data: { qa_selector: 'regenerate_export_group_link' }
- else
= link_to _('Export group'), export_group_path(group),
method: :post, class: 'btn gl-button btn-default', data: { qa_selector: 'export_group_link' }
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index ed76a9fe253..ad0780e869c 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -5,29 +5,31 @@
%fieldset
.row
.form-group.col-md-5
- = f.label :name, _('Group name'), class: 'label-bold'
+ = f.label :name, s_('Groups|Group name'), class: 'label-bold'
= f.text_field :name, class: 'form-control', data: { qa_selector: 'group_name_field' }
+ .text-muted
+ = s_('Groups|Must start with letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.')
.form-group.col-md-7
- = f.label :id, _('Group ID'), class: 'label-bold'
+ = f.label :id, s_('Groups|Group ID'), class: 'label-bold'
= f.text_field :id, class: 'form-control w-auto', readonly: true
.row.gl-mt-3
.form-group.col-md-9
- = f.label :description, _('Group description'), class: 'label-bold'
+ = f.label :description, s_('Groups|Group description (optional)'), class: 'label-bold'
= f.text_area :description, class: 'form-control', rows: 3, maxlength: 250
- .form-text.text-muted= _('Optional.')
+ = render 'shared/repository_size_limit_setting_registration_features_cta', form: f
= render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group
.form-group.gl-mt-3.gl-mb-6
.avatar-container.rect-avatar.s90
= group_icon(@group, alt: '', class: 'avatar group-avatar s90')
- = f.label :avatar, _('Group avatar'), class: 'label-bold d-block'
+ = f.label :avatar, s_('Groups|Group avatar'), class: 'label-bold d-block'
= render 'shared/choose_avatar_button', f: f
- if @group.avatar?
%hr
- = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'gl-button btn btn-danger-secondary'
+ = link_to s_('Groups|Remove avatar'), group_avatar_path(@group.to_param), aria: { label: s_('Groups|Remove avatar') }, data: { confirm: s_('Groups|Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary'
= 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-confirm mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' }
+ = f.submit s_('Groups|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/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml
index 59d52e99dec..d52d9d59ab3 100644
--- a/app/views/groups/settings/_transfer.html.haml
+++ b/app/views/groups/settings/_transfer.html.haml
@@ -1,23 +1,20 @@
+- form_id = "transfer-group-form"
+- initial_data = { button_text: s_('GroupSettings|Transfer group'), group_name: @group.name, target_form_id: form_id, parent_groups: parent_group_options(group), is_paid_group: group.paid?.to_s }
+
.sub-section
%h4.warning-title= s_('GroupSettings|Transfer group')
%p= _('Transfer group to another parent group.')
- = form_for group, url: transfer_group_path(group), method: :put, html: { class: 'js-group-transfer-form' } do |f|
-
+ = form_for group, url: transfer_group_path(group), method: :put, html: { id: form_id, class: 'js-group-transfer-form' } do |f|
%ul
- learn_more_link_start = '<a href="https://docs.gitlab.com/ee/user/project/index.html#redirects-when-changing-repository-paths" target="_blank" rel="noopener noreferrer">'.html_safe
- warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended side effects. %{learn_more_link_start}Learn more.%{learn_more_link_end}") % { learn_more_link_start: learn_more_link_start, learn_more_link_end: '</a>'.html_safe }
%li= warning_text.html_safe
%li= s_('GroupSettings|You can only transfer the group to a group you manage.')
%li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
- %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
-
- .form-group
- = dropdown_tag(s_('GroupSettings|Select parent group'), options: { toggle_class: 'js-groups-dropdown', title: s_('GroupSettings|Parent Group'), filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: s_('GroupSettings|Search groups'), disabled: group.paid?, data: { data: parent_group_options(group), qa_selector: 'select_group_dropdown' } })
- = hidden_field_tag 'new_parent_group_id'
+ %li= s_("GroupSettings|If the parent group's visibility is lower than the group's current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
- if group.paid?
.gl-alert.gl-alert-info.gl-mb-5
= sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-body
= html_escape(_("This group can't be transfered because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
-
- = f.submit s_('GroupSettings|Transfer group'), class: 'btn gl-button btn-warning', data: { qa_selector: "transfer_group_button" }
+ .js-transfer-group-form{ data: initial_data }
diff --git a/app/views/jira_connect/branches/new.html.haml b/app/views/jira_connect/branches/new.html.haml
index 74d547e6bb8..482012b2848 100644
--- a/app/views/jira_connect/branches/new.html.haml
+++ b/app/views/jira_connect/branches/new.html.haml
@@ -1,6 +1,6 @@
- @hide_breadcrumbs = true
- @hide_top_links = true
- @content_class = 'limit-container-width'
-- page_title _('New branch')
+- page_title _('Create branch')
.js-jira-connect-create-branch{ data: @new_branch_data }
diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml
index d92c30c8840..3319137551b 100644
--- a/app/views/jira_connect/subscriptions/index.html.haml
+++ b/app/views/jira_connect/subscriptions/index.html.haml
@@ -4,14 +4,6 @@
%main.jira-connect-app.gl-px-5.gl-pt-7.gl-mx-auto
.js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) }
- %p.jira-connect-app-body.gl-px-5.gl-font-base.gl-text-center.gl-mx-auto
- %strong= s_('Integrations|Browser limitations')
- - browser_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'
- - firefox_link_start = browser_link_start.html_safe % { url: 'https://www.mozilla.org/en-US/firefox/' }
- - chrome_link_start = browser_link_start.html_safe % { url: 'https://www.google.com/chrome/' }
- = s_('Integrations|Adding a namespace works only in browsers that allow cross‑site cookies. Use %{firefox_link_start}Firefox%{link_end}, %{chrome_link_start}Google Chrome%{link_end}, or enable cross‑site cookies in your browser, when adding a namespace.').html_safe % { firefox_link_start: firefox_link_start, chrome_link_start: chrome_link_start, 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?
= webpack_bundle_tag 'jira_connect_app'
diff --git a/app/views/layouts/_bizible.html.haml b/app/views/layouts/_bizible.html.haml
new file mode 100644
index 00000000000..a2b28c138e5
--- /dev/null
+++ b/app/views/layouts/_bizible.html.haml
@@ -0,0 +1,14 @@
+- if bizible_enabled?
+ <!-- Bizible -->
+ = javascript_include_tag "https://cdn.bizible.com/scripts/bizible.js"
+ = javascript_tag nonce: content_security_policy_nonce do
+ :plain
+ const bizibleScript = document.createElement('script');
+ bizibleScript.src = 'https://cdn.bizible.com/scripts/bizible.js';
+ bizibleScript.nonce = '#{content_security_policy_nonce}'
+ bizibleScript.charset = 'UTF-8';
+ bizibleScript.defer = true;
+ document.head.appendChild(bizibleScript);
+
+ function OptanonWrapper() { }
+
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
index dded5ba76b0..21cccb86398 100644
--- a/app/views/layouts/_flash.html.haml
+++ b/app/views/layouts/_flash.html.haml
@@ -1,5 +1,6 @@
-# We currently only support `alert`, `notice`, `success`, 'toast', and 'raw'
- icons = {'alert' => 'error', 'notice' => 'information-o', 'success' => 'check-circle'}
+- type_to_variant = {'alert' => 'danger', 'notice' => 'info', 'success' => 'success'}
.flash-container.flash-container-page.sticky{ data: { qa_selector: 'flash_container' } }
- flash.each do |key, value|
- if key == 'toast' && value
@@ -9,7 +10,7 @@
- elsif value == I18n.t('devise.failure.unconfirmed')
= render 'shared/confirm_your_email_alert'
- elsif value
- %div{ class: "flash-#{key} mb-2" }
+ %div{ class: "flash-#{key} mb-2", data: { testid: "alert-#{type_to_variant[key]}" } }
= sprite_icon(icons[key], css_class: 'align-middle mr-1') unless icons[key].nil?
%span= value
- if %w(alert notice success).include?(key)
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 3e875a0eb24..b7299df1bc1 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -4,7 +4,6 @@
.content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" }
.mobile-overlay
= render_if_exists 'layouts/header/verification_reminder'
- = yield :group_invite_members_banner
.alert-wrapper.gl-force-block-formatting-context
= render 'shared/outdated_browser'
= render_if_exists "layouts/header/licensed_user_count_threshold"
@@ -17,10 +16,12 @@
= render "shared/service_ping_consent"
= render_two_factor_auth_recovery_settings_check
= render_if_exists "layouts/header/ee_subscribable_banner"
+ = render_if_exists "layouts/header/seats_count_alert"
= render_if_exists "shared/namespace_storage_limit_alert"
= render_if_exists "shared/namespace_user_cap_reached_alert"
= render_if_exists "shared/new_user_signups_cap_reached_alert"
= yield :page_level_alert
+ = yield :group_invite_members_banner
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index d0a06c7d5bf..871d1213c0e 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -16,7 +16,7 @@
= logo_text
- if Gitlab.com_and_canary?
= link_to Gitlab::Saas.canary_toggle_com_url, class: 'canary-badge bg-transparent', data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer' do
- %span.gl-badge.gl-bg-green-500.gl-text-white.gl-rounded-pill.gl-font-weight-bold.gl-py-1
+ = gl_badge_tag({ variant: :success, size: :sm }) do
= _('Next')
- if current_user
@@ -41,7 +41,7 @@
%li.nav-item.d-none.d-lg-block.m-auto
- unless current_controller?(:search)
- if Feature.enabled?(:new_header_search)
- #js-header-search.header-search{ data: { 'search-context' => search_context.to_json,
+ #js-header-search.header-search{ data: { 'search-context' => header_search_context.to_json,
'search-path' => search_path,
'issues-path' => issues_dashboard_path,
'mr-path' => merge_requests_dashboard_path,
@@ -64,7 +64,7 @@
container: 'body' } do
= sprite_icon('issues')
- issues_count = assigned_issuables_count(:issues)
- %span.badge.badge-pill.issues-count.green-badge{ class: ('hidden' if issues_count == 0) }
+ = gl_badge_tag({ size: :sm, variant: :success }, { class: "gl-ml-n2 #{(' gl-display-none' if issues_count == 0)}", "aria-label": n_("%d assigned issue", "%d assigned issues", issues_count) % issues_count }) do
= number_with_delimiter(issues_count)
- if header_link?(:merge_requests)
= nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter dropdown" }) do
@@ -77,7 +77,7 @@
track_property: 'navigation',
container: 'body' } do
= sprite_icon('git-merge')
- %span.badge.badge-pill.merge-requests-count.js-merge-requests-count{ class: ('hidden' if user_merge_requests_counts[:total] == 0) }
+ = gl_badge_tag({ size: :sm, variant: :warning }, { class: "js-merge-requests-count gl-ml-n2#{(' gl-display-none' if user_merge_requests_counts[:total] == 0)}", "aria-label": n_("%d merge request", "%d merge requests", user_merge_requests_counts[:total]) % user_merge_requests_counts[:total] }) do
= number_with_delimiter(user_merge_requests_counts[:total])
= sprite_icon('chevron-down', css_class: 'caret-down gl-mx-0!')
.dropdown-menu.dropdown-menu-right
@@ -87,12 +87,12 @@
%li
= link_to assigned_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do
= _('Assigned to you')
- %span.badge.gl-badge.badge-pill.badge-muted.merge-request-badge.gl-ml-auto.js-assigned-mr-count{ class: "" }
+ = gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-assigned-mr-count gl-ml-auto" }) do
= user_merge_requests_counts[:assigned]
%li
= link_to reviewer_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do
= _('Review requests for you')
- %span.badge.gl-badge.badge-pill.badge-muted.merge-request-badge.gl-ml-auto.js-reviewer-mr-count{ class: "" }
+ = gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-reviewer-mr-count gl-ml-auto" }) do
= user_merge_requests_counts[:review_requested]
- if header_link?(:todos)
= nav_link(controller: 'dashboard/todos', html_options: { class: "user-counter" }) do
@@ -103,9 +103,11 @@
track_property: 'navigation',
container: 'body' } do
= sprite_icon('todo-done')
- %span.badge.badge-pill.todos-count.js-todos-count{ class: ('hidden' if todos_pending_count == 0) }
+ -# The todos' counter badge's visibility is being toggled by adding or removing the .hidden class in Js.
+ -# We'll eventually migrate to .gl-display-none: https://gitlab.com/gitlab-org/gitlab/-/issues/351792.
+ = gl_badge_tag({ size: :sm, variant: :info }, { class: "js-todos-count gl-ml-n2#{(' hidden' if todos_pending_count == 0)}", "aria-label": _("Todos count") }) do
= todos_count_format(todos_pending_count)
- %li.nav-item.header-help.dropdown.d-none.d-md-block{ **tracking_attrs('main_navigation', 'click_question_mark_link', 'navigation') }
+ %li.nav-item.header-help.dropdown.d-none.d-md-block{ data: { track_action: 'click_question_mark_link', track_label: 'main_navigation', track_property: 'navigation', track_experiment: 'cross_stage_fdm' } }
= link_to help_path, class: 'header-help-dropdown-toggle gl-relative', data: { toggle: "dropdown" } do
%span.gl-sr-only
= s_('Nav|Help')
@@ -139,15 +141,15 @@
- experiment(:logged_out_marketing_header, actor: nil) do |e|
- e.candidate do
%li.nav-item.gl-display-none.gl-sm-display-block
- = link_to _('Sign up now'), new_user_registration_path, class: 'gl-button btn btn-default btn-sign-in'
+ = link_to _('Sign up now'), new_user_registration_path, class: 'gl-button btn btn-default btn-sign-in', data: { track_action: 'click_button', track_experiment: e.name, track_label: 'sign_up_now' }
%li.nav-item.gl-display-none.gl-sm-display-block
= link_to _('Login'), new_session_path(:user, redirect_to_referer: 'yes')
= render 'layouts/header/sign_in_register_button', class: 'gl-sm-display-none'
- e.try(:trial_focused) do
%li.nav-item.gl-display-none.gl-sm-display-block
- = link_to _('Get a free trial'), 'https://about.gitlab.com/free-trial/', class: 'gl-button btn btn-default btn-sign-in'
+ = link_to _('Get a free trial'), 'https://about.gitlab.com/free-trial/', class: 'gl-button btn btn-default btn-sign-in', data: { track_action: 'click_button', track_experiment: e.name, track_label: 'get_a_free_trial' }
%li.nav-item.gl-display-none.gl-sm-display-block
- = link_to _('Sign up'), new_user_registration_path
+ = link_to _('Sign up'), new_user_registration_path, data: { track_action: 'click_button', track_experiment: e.name, track_label: 'sign_up' }
%li.nav-item.gl-display-none.gl-sm-display-block
= link_to _('Login'), new_session_path(:user, redirect_to_referer: 'yes')
= render 'layouts/header/sign_in_register_button', class: 'gl-sm-display-none'
diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml
index 738bca2f2cc..3a8f9c1ae8d 100644
--- a/app/views/layouts/header/_help_dropdown.html.haml
+++ b/app/views/layouts/header/_help_dropdown.html.haml
@@ -1,6 +1,7 @@
%ul
- if current_user_menu?(:help)
= render 'layouts/header/gitlab_version'
+ = render_if_exists 'layouts/header/help_dropdown/cross_stage_fdm'
= render 'layouts/header/whats_new_dropdown_item'
%li
= link_to _("Help"), help_path
diff --git a/app/views/layouts/header/_whats_new_dropdown_item.html.haml b/app/views/layouts/header/_whats_new_dropdown_item.html.haml
index 377f0f3271d..6473d9c8dd4 100644
--- a/app/views/layouts/header/_whats_new_dropdown_item.html.haml
+++ b/app/views/layouts/header/_whats_new_dropdown_item.html.haml
@@ -2,5 +2,4 @@
%li
%button.gl-justify-content-space-between.gl-align-items-center.js-whats-new-trigger{ type: 'button', class: 'gl-display-flex!' }
= _("What's new")
- %span.js-whats-new-notification-count.gl-badge.badge.sm.badge-dark.badge-pill
- = whats_new_most_recent_release_items_count
+ = gl_badge_tag whats_new_most_recent_release_items_count, { size: :sm }, { class: 'js-whats-new-notification-count' }
diff --git a/app/views/layouts/nav/_classification_level_banner.html.haml b/app/views/layouts/nav/_classification_level_banner.html.haml
index d76fb50aa0b..b36111df99c 100644
--- a/app/views/layouts/nav/_classification_level_banner.html.haml
+++ b/app/views/layouts/nav/_classification_level_banner.html.haml
@@ -1,5 +1,3 @@
- if ::Gitlab::ExternalAuthorization.enabled? && @project
= content_for :header_content do
- %span.badge.color-label.gl-bg-red-500.has-tooltip{ title: s_('ExternalAuthorizationService|Classification label') }
- = sprite_icon('lock-open', size: 8, css_class: 'inline')
- = @project.external_authorization_classification_label
+ = gl_badge_tag(@project.external_authorization_classification_label, { variant: :danger, icon: 'lock-open' }, { class: 'has-tooltip', title: s_('ExternalAuthorizationService|Classification label') })
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index f820f911d61..52eea73ecd2 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -156,13 +156,13 @@
= sprite_icon('slight-frown')
%span.nav-item-name
= _('Abuse Reports')
- %span.badge.badge-pill.count= number_with_delimiter(AbuseReport.count(:all))
+ = gl_badge_tag number_with_delimiter(AbuseReport.count(:all)), variant: :info, size: :sm
%ul.sidebar-sub-level-items.is-fly-out-only
= nav_link(controller: :abuse_reports, html_options: { class: "fly-out-top-item" } ) do
= link_to admin_abuse_reports_path do
%strong.fly-out-top-item-name
= _('Abuse Reports')
- %span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(AbuseReport.count(:all))
+ = gl_badge_tag number_with_delimiter(AbuseReport.count(:all)), variant: :info, size: :sm
= render_if_exists 'layouts/nav/sidebar/licenses_link'
@@ -269,6 +269,11 @@
= link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), class: 'qa-admin-settings-metrics-and-profiling-item' do
%span
= _('Metrics and profiling')
+ - if Feature.enabled?(:admin_application_settings_service_usage_data_center, default_enabled: :yaml)
+ = nav_link(path: ['application_settings#service_usage_data']) do
+ = link_to service_usage_data_admin_application_settings_path, title: _('Service usage data') do
+ %span
+ = _('Service usage data')
= nav_link(path: 'application_settings#network') do
= link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_item' } do
%span
diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml
index 73a437a0702..6c6fa32f736 100644
--- a/app/views/profiles/accounts/_providers.html.haml
+++ b/app/views/profiles/accounts/_providers.html.haml
@@ -2,7 +2,7 @@
%label.label-bold
= s_('Profiles|Connected Accounts')
- %p= s_('Profiles|Click on icon to activate signin with one of the following services')
+ %p= s_('Profiles|Select a service to sign in with.')
- providers.each do |provider|
- unlink_allowed = unlink_provider_allowed?(provider)
- link_allowed = link_provider_allowed?(provider)
diff --git a/app/views/projects/_bitbucket_import_modal.html.haml b/app/views/projects/_bitbucket_import_modal.html.haml
deleted file mode 100644
index 1379a339feb..00000000000
--- a/app/views/projects/_bitbucket_import_modal.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-#bitbucket_import_modal.modal
- .modal-dialog
- .modal-content
- .modal-header
- %h3.modal-title Import projects from Bitbucket
- %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
- %span{ "aria-hidden": "true" } &times;
- .modal-body
- To enable importing projects from Bitbucket,
- - if current_user.admin?
- as administrator you need to configure
- - else
- ask your GitLab administrator to configure
- = link_to 'OAuth integration', help_page_path("integration/bitbucket")
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 7e8daea5651..8e6cc6da65d 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -37,7 +37,7 @@
= sprite_icon('admin')
.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 } }
+ .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, no_flip: 'true' } }
.count-buttons.gl-display-flex.gl-align-items-flex-start
= render 'projects/buttons/star'
@@ -71,11 +71,13 @@
= render_if_exists "projects/home_mirror"
- if @project.badges.present?
- .project-badges.mb-2
+ .project-badges.mb-2{ data: { qa_selector: 'project_badges_content' } }
- @project.badges.each do |badge|
- %a.gl-mr-3{ href: badge.rendered_link_url(@project),
+ - badge_link_url = badge.rendered_link_url(@project)
+ %a.gl-mr-3{ href: badge_link_url,
target: '_blank',
- rel: 'noopener noreferrer' }>
+ rel: 'noopener noreferrer',
+ data: { qa_selector: 'badge_image_link', qa_link_url: badge_link_url } }>
%img.project-badge{ src: badge.rendered_image_url(@project),
'aria-hidden': true,
alt: 'Project badge' }>
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 63f09a065df..aca7b73267b 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -22,13 +22,11 @@
- if bitbucket_import_enabled?
%div
- = link_to status_import_bitbucket_path, class: "gl-button btn-default btn import_bitbucket js-import-project-btn #{'how_to_import_link' unless bitbucket_import_configured?}",
- data: { platform: 'bitbucket_cloud', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_cloud') } do
+ = link_to status_import_bitbucket_path, class: "gl-button btn-default btn import_bitbucket js-import-project-btn #{'js-how-to-import-link' unless bitbucket_import_configured?}",
+ data: { modal_title: _("Import projects from Bitbucket"), modal_message: import_from_bitbucket_message, platform: 'bitbucket_cloud', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_cloud') } do
.gl-button-icon
= sprite_icon('bitbucket')
Bitbucket Cloud
- - unless bitbucket_import_configured?
- = render 'projects/bitbucket_import_modal'
- if bitbucket_server_import_enabled?
%div
= link_to status_import_bitbucket_server_path, class: "gl-button btn-default btn import_bitbucket js-import-project-btn", data: { platform: 'bitbucket_server', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_server') } do
@@ -82,7 +80,7 @@
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
- = form_for @project, html: { class: 'new_project gl-show-field-errors' } do |f|
+ = form_for @project, html: { class: 'new_project gl-show-field-errors js-project-import' } do |f|
%hr
= render "shared/import_form", f: f
= render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label
diff --git a/app/views/projects/_invite_groups_modal.html.haml b/app/views/projects/_invite_groups_modal.html.haml
new file mode 100644
index 00000000000..d16e87d1c26
--- /dev/null
+++ b/app/views/projects/_invite_groups_modal.html.haml
@@ -0,0 +1,3 @@
+- return unless can_admin_project_member?(project)
+
+.js-invite-groups-modal{ data: common_invite_group_modal_data(project, ProjectMember, 'true') }
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 6b25c5ddaef..4f9af40f711 100644
--- a/app/views/projects/_merge_request_merge_checks_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_checks_settings.html.haml
@@ -8,9 +8,7 @@
= form.label :only_allow_merge_if_pipeline_succeeds, class: 'form-check-label' do
= s_('ProjectSettings|Pipelines must succeed')
.text-secondary
- - configuring_pipelines_for_merge_requests_help_link_url = help_page_path('ci/pipelines/merge_request_pipelines.md', anchor: 'prerequisites')
- - 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 }
+ = s_("ProjectSettings|Merge requests can't be merged if the latest pipeline did not succeed or is still running.")
.form-check.mb-2
.gl-pl-6
= form.check_box :allow_merge_on_skipped_pipeline, class: 'form-check-input'
diff --git a/app/views/projects/_merge_request_merge_commit_template.html.haml b/app/views/projects/_merge_request_merge_commit_template.html.haml
index 1c023ae6ceb..502014b7279 100644
--- a/app/views/projects/_merge_request_merge_commit_template.html.haml
+++ b/app/views/projects/_merge_request_merge_commit_template.html.haml
@@ -5,10 +5,10 @@
%p.text-secondary
= s_('ProjectSettings|The commit message used when merging, if the merge method creates a merge commit.')
.mb-2
- - default_merge_commit_template = "Merge branch '%{source_branch}' into '%{target_branch}'\n\n%{title}\n\n%{issues}\n\nSee merge request %{reference}"
- = form.text_area :merge_commit_template, class: 'form-control gl-form-input', rows: 8, maxlength: 500, placeholder: default_merge_commit_template
+ = form.text_area :merge_commit_template_or_default, class: 'form-control gl-form-input', rows: 8, maxlength: Project::MAX_COMMIT_TEMPLATE_LENGTH, placeholder: s_('ProjectSettings|The default template will be applied on save.')
%p.form-text.text-muted
- = s_('ProjectSettings|Maximum 500 characters.')
+ = s_('ProjectSettings|Leave empty to use default template.')
+ = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Project::MAX_COMMIT_TEMPLATE_LENGTH })
- configure_the_merge_commit_message_help_link_url = help_page_path('user/project/merge_requests/commit_templates.md')
- configure_the_merge_commit_message_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_merge_commit_message_help_link_url }
= s_('ProjectSettings|%{link_start}What variables can I use?%{link_end}').html_safe % { link_start: configure_the_merge_commit_message_help_link_start, link_end: '</a>'.html_safe }
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 b0e3bda2b4f..778586a592e 100644
--- a/app/views/projects/_merge_request_merge_method_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_method_settings.html.haml
@@ -2,7 +2,9 @@
.form-group
%b= s_('ProjectSettings|Merge method')
- %p.text-secondary= s_('ProjectSettings|Determine what happens to 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.')
+ = link_to s_('ProjectSettings|Learn about commit history.'), help_page_path('user/project/merge_requests/commits.md'), target: '_blank', rel: 'noopener noreferrer'
.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
@@ -33,4 +35,4 @@
= s_('ProjectSettings|When there is a merge conflict, the user is given the option to rebase.')
%div
= s_('ProjectSettings|If merge trains are enabled, merging is only possible if the branch can be rebased without conflicts.')
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/merge_trains.md', anchor: 'enable-merge-trains'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to s_('ProjectSettings|What are merge trains?'), help_page_path('ci/pipelines/merge_trains.md', anchor: 'enable-merge-trains'), target: '_blank', rel: 'noopener noreferrer'
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 9ed21593203..eb2fc05686c 100644
--- a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml
@@ -7,6 +7,8 @@
.mb-2
= 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|Leave empty to use default template.')
+ = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Project::MAX_SUGGESTIONS_TEMPLATE_LENGTH })
- configure_the_commit_message_for_applied_suggestions_help_link_url = help_page_path('user/project/merge_requests/reviews/suggestions.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|%{link_start}What variables can I use?%{link_end}').html_safe % { link_start: configure_the_commit_message_for_applied_suggestions_help_link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/projects/_merge_request_squash_commit_template.html.haml b/app/views/projects/_merge_request_squash_commit_template.html.haml
index be1d78154c6..4d1b89bea83 100644
--- a/app/views/projects/_merge_request_squash_commit_template.html.haml
+++ b/app/views/projects/_merge_request_squash_commit_template.html.haml
@@ -5,9 +5,10 @@
%p.text-secondary
= s_('ProjectSettings|The commit message used when squashing commits.')
.mb-2
- = form.text_area :squash_commit_template, class: 'form-control gl-form-input', rows: 8, maxlength: 500, placeholder: '%{title}'
+ = form.text_area :squash_commit_template_or_default, class: 'form-control gl-form-input', rows: 8, maxlength: Project::MAX_COMMIT_TEMPLATE_LENGTH, placeholder: s_('ProjectSettings|The default template will be applied on save.')
%p.form-text.text-muted
- = s_('ProjectSettings|Maximum 500 characters.')
+ = s_('ProjectSettings|Leave empty to use default template.')
+ = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Project::MAX_COMMIT_TEMPLATE_LENGTH })
- configure_the_squash_commit_message_help_link_url = help_page_path('user/project/merge_requests/commit_templates.md')
- configure_the_squash_commit_message_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_squash_commit_message_help_link_url }
= s_('ProjectSettings|%{link_start}What variables can I use?%{link_end}').html_safe % { link_start: configure_the_squash_commit_message_help_link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 0b5da84e4e3..966587a9210 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -46,6 +46,8 @@
= s_('ProjectsNew|Project description %{tag_start}(optional)%{tag_end}').html_safe % { tag_start: '<span>'.html_safe, tag_end: '</span>'.html_safe }
= f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control gl-form-input", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_description", track_value: "" }
+.js-deployment-target-select
+
= f.label :visibility_level, class: 'label-bold' do
= s_('ProjectsNew|Visibility Level')
= link_to sprite_icon('question-o'), help_page_path('public_access/public_access'), aria: { label: 'Documentation for Visibility Level' }, target: '_blank', rel: 'noopener noreferrer'
@@ -63,44 +65,14 @@
= s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.')
- experiment(:new_project_sast_enabled, user: current_user) do |e|
- - e.try(:candidate) do
- .form-group
- .form-check.gl-mb-3
- = check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' }
- = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do
- = s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
- .form-text.text-muted
- = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
- = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name }
- - e.try(:unchecked_candidate) do
- .form-group
- .form-check.gl-mb-3
- = check_box_tag 'project[initialize_with_sast]', '1', false, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' }
- = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do
- = s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
- .form-text.text-muted
- = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
- = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name }
- - e.try(:free_indicator) do
- .form-group
- .form-check.gl-mb-3
- = check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' }
- = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do
- = s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
- = gl_badge_tag _('Free'), variant: :info, size: :sm
- .form-text.text-muted
- = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
- = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name }
- - e.try(:unchecked_free_indicator) do
- .form-group
- .form-check.gl-mb-3
- = check_box_tag 'project[initialize_with_sast]', '1', false, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' }
- = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do
- = s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
- = gl_badge_tag _('Free'), variant: :info, size: :sm
- .form-text.text-muted
- = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
- = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name }
+ - e.variant(:candidate) do
+ = render 'new_project_initialize_with_sast', experiment_name: e.name, track_label: track_label, checked: true, with_free_badge: false
+ - e.variant(:unchecked_candidate) do
+ = render 'new_project_initialize_with_sast', experiment_name: e.name, track_label: track_label, checked: false, with_free_badge: false
+ - e.variant(:free_indicator) do
+ = render 'new_project_initialize_with_sast', experiment_name: e.name, track_label: track_label, checked: true, with_free_badge: true
+ - e.variant(:unchecked_free_indicator) do
+ = render 'new_project_initialize_with_sast', experiment_name: e.name, track_label: track_label, checked: false, with_free_badge: true
= f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }
= link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" }
diff --git a/app/views/projects/_new_project_initialize_with_sast.html.haml b/app/views/projects/_new_project_initialize_with_sast.html.haml
new file mode 100644
index 00000000000..ec12abbf789
--- /dev/null
+++ b/app/views/projects/_new_project_initialize_with_sast.html.haml
@@ -0,0 +1,16 @@
+- experiment_name = local_assigns.fetch(:experiment_name)
+- track_label = local_assigns.fetch(:track_label)
+
+- with_free_badge = local_assigns.fetch(:with_free_badge, false)
+- checked = local_assigns.fetch(:checked, false)
+
+.form-group
+ .form-check.gl-mb-3
+ = check_box_tag 'project[initialize_with_sast]', '1', checked, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: experiment_name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' }
+ = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do
+ = s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
+ - if with_free_badge
+ = gl_badge_tag _('Free'), variant: :info, size: :sm
+ .form-text.text-muted
+ = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
+ = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: experiment_name }
diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml
index 815e76ebcb9..d0dfbb89ca7 100644
--- a/app/views/projects/_remove.html.haml
+++ b/app/views/projects/_remove.html.haml
@@ -1,6 +1,7 @@
- return unless can?(current_user, :remove_project, project)
- merge_requests_count = Projects::AllMergeRequestsCountService.new(project).count
- issues_count = Projects::AllIssuesCountService.new(project).count
+- forks_count = Projects::ForksCountService.new(project).count
.sub-section
%h4.danger-title= _('Delete project')
@@ -9,4 +10,4 @@
= link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer'
%p
%strong= _('Deleted projects cannot be restored!')
- #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: delete_confirm_phrase(project), is_fork: project.forked?.to_s, issues_count: number_with_delimiter(issues_count), merge_requests_count: number_with_delimiter(merge_requests_count), forks_count: number_with_delimiter(project.forks_count), stars_count: number_with_delimiter(project.star_count) } }
+ #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: delete_confirm_phrase(project), is_fork: project.forked?.to_s, issues_count: number_with_delimiter(issues_count), merge_requests_count: number_with_delimiter(merge_requests_count), forks_count: number_with_delimiter(forks_count), stars_count: number_with_delimiter(project.star_count) } }
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index 9fa65f27651..919cafe7ce8 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -1,6 +1,7 @@
= render "projects/blob/breadcrumb", blob: blob
- project = @project.present(current_user: current_user)
- ref = local_assigns[:ref] || @ref
+- expanded = params[:expanded].present?
.info-well.d-none.d-sm-block
.well-segment
@@ -13,7 +14,7 @@
#blob-content-holder.blob-content-holder
- if @code_navigation_path
#js-code-navigation{ data: { code_navigation_path: @code_navigation_path, blob_path: blob.path, definition_path_prefix: project_blob_path(@project, @ref) } }
- - if Feature.enabled?(:refactor_blob_viewer, @project, default_enabled: :yaml)
+ - if Feature.enabled?(:refactor_blob_viewer, @project, default_enabled: :yaml) && !expanded
-# Data info will be removed once we migrate this to use GraphQL
-# Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/330406
#js-view-blob-app{ data: { blob_path: blob.path,
diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml
index 95a5d63e07f..9cd2f583fdd 100644
--- a/app/views/projects/blob/_header_content.html.haml
+++ b/app/views/projects/blob/_header_content.html.haml
@@ -9,9 +9,9 @@
= copy_file_path_button(blob.path)
%small.mr-1
- - if blob.mode == Blob::MODE_SYMLINK
+ - if blob.symlink?
= _('Symbolic link') << ' ·'
= number_to_human_size(blob.raw_size)
- if blob.stored_externally? && blob.external_storage == :lfs
- %span.badge.label-lfs.gl-mr-2 LFS
+ = gl_badge_tag(_('LFS'), variant: :neutral)
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 2121d15643c..96acd863a4c 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -16,7 +16,9 @@
class: 'gl-button btn btn-danger btn-danger-secondary has-tooltip qa-delete-merged-branches',
title: s_("Branches|Delete all branches that are merged into '%{default_branch}'") % { default_branch: @project.repository.root_ref },
method: :delete,
+ aria: { label: s_('Branches|Delete merged branches') },
data: { confirm: s_('Branches|Deleting the merged branches cannot be undone. Are you sure?'),
+ confirm_btn_variant: 'danger',
container: 'body' } do
= s_('Branches|Delete merged branches')
= link_to new_project_branch_path(@project), class: 'gl-button btn btn-confirm' do
diff --git a/app/views/projects/ci/lints/show.html.haml b/app/views/projects/ci/lints/show.html.haml
index 4463220e951..b48e69c2c23 100644
--- a/app/views/projects/ci/lints/show.html.haml
+++ b/app/views/projects/ci/lints/show.html.haml
@@ -3,4 +3,4 @@
%h4.pt-3.pb-3= _("Validate your GitLab CI configuration")
-#js-ci-lint{ data: { endpoint: project_ci_lint_path(@project), pipeline_simulation_help_page_path: help_page_path('ci/lint', anchor: 'pipeline-simulation') , lint_help_page_path: help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax') } }
+#js-ci-lint{ data: { endpoint: project_ci_lint_path(@project), pipeline_simulation_help_page_path: help_page_path('ci/lint', anchor: 'simulate-a-pipeline') , lint_help_page_path: help_page_path('ci/lint', anchor: 'check-cicd-syntax') } }
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 62ed50f5a0c..4442f62b221 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -34,7 +34,7 @@
&middot;
= commit.short_id
- if commit.description? && collapsible
- %button.gl-button.btn.btn-default.button-ellipsis-horizontal.btn-sm.gl-ml-2.text-expander.js-toggle-button
+ %button.gl-button.btn.btn-default.button-ellipsis-horizontal.btn-sm.gl-ml-2.text-expander.js-toggle-button{ data: { toggle: 'tooltip', container: 'body' }, :title => _("Toggle commit description"), aria: { label: _("Toggle commit description") } }
= sprite_icon('ellipsis_h', size: 12)
.committer
diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml
index 8ca41941e07..12d3f28dc20 100644
--- a/app/views/projects/compare/index.html.haml
+++ b/app/views/projects/compare/index.html.haml
@@ -13,4 +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_selector_data(@project, @merge_request, params) }
+ #js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, @compare_params) }
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index aa9a3ea61f7..f32514141c5 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -1,5 +1,6 @@
- breadcrumb_title _("General Settings")
- page_title _("General")
+- add_page_specific_style 'page_bundles/projects_edit'
- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
- reduce_visibility_form_id = 'reduce-visibility-form'
@@ -40,7 +41,7 @@
= render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded
-%section.settings.no-animate{ class: ('expanded' if expanded) }
+%section.settings.no-animate{ class: ('expanded' if expanded), data: { qa_selector: 'badges_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= s_('ProjectSettings|Badges')
@@ -62,7 +63,7 @@
.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')
- %p= _('Housekeeping, export, path, transfer, remove, archive.')
+ %p= s_('ProjectSettings|Housekeeping, export, archive, change path, transfer, and delete.')
.settings-content
.sub-section
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index 72ccc8d830c..2b05ffe3eea 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -1,4 +1,5 @@
- page_title _("Environments")
+- add_page_specific_style 'page_bundles/environments'
- if Feature.enabled?(:new_environments_table)
#environments-table{ data: { endpoint: project_environments_path(@project, format: :json),
@@ -9,7 +10,6 @@
"project-path" => @project.full_path,
"default-branch-name" => @project.default_branch_or_main } }
- else
- - add_page_specific_style 'page_bundles/environments'
#environments-list-view{ data: { environments_data: environments_list_data,
"can-read-environment" => can?(current_user, :read_environment, @project).to_s,
"can-create-environment" => can?(current_user, :create_environment, @project).to_s,
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index b123b81b89c..6d60ef92d86 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -14,7 +14,7 @@
.text-content
%h4.state-title
= _("You don't have any deployments right now.")
- %p.blank-state-text
+ %p
= html_escape(_("Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
.text-center
= link_to _("Read more"), help_page_path("ci/environments/index.md"), class: "gl-button btn btn-confirm"
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index 7d696a988d4..4df109dbb61 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -14,7 +14,7 @@
= f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
- = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn gl-button btn-danger float-right', data: { confirm: _('Are you sure?') }
+ = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn gl-button btn-danger float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this project hook?'), confirm_btn_variant: 'danger' }
%hr
diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml
index 77c715aa376..b021087c394 100644
--- a/app/views/projects/imports/new.html.haml
+++ b/app/views/projects/imports/new.html.haml
@@ -12,8 +12,8 @@
:preserve
#{h(@project.import_state.last_error)}
-= form_for @project, url: project_import_path(@project), method: :post do |f|
+= form_for @project, url: project_import_path(@project), method: :post, html: { class: 'js-project-import' } do |f|
= render "shared/import_form", f: f
.form-actions
- = f.submit 'Start import', class: "gl-button btn btn-confirm"
+ = f.submit 'Start import', class: "gl-button btn btn-confirm", data: { disable_with: false }
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 1cf0551535b..8d6c0e29b6a 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -6,8 +6,8 @@
- create_mr_text = can_create_confidential_merge_request? ? _('Create confidential merge request') : _('Create merge request')
- can_create_path = can_create_branch_project_issue_path(@project, @issue)
- - create_mr_path = create_merge_request_project_issue_path(@project, @issue, branch_name: @issue.to_branch_name, ref: @project.default_branch)
- - create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid)
+ - create_mr_path = project_new_merge_request_path(@project, merge_request: { source_branch: @issue.to_branch_name, target_branch: @project.default_branch })
+ - create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid, format: :json)
- refs_path = refs_namespace_project_path(@project.namespace, @project, search: '')
.create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } }
diff --git a/app/views/projects/learn_gitlab/index.html.haml b/app/views/projects/learn_gitlab/index.html.haml
index 6bca145dc18..9924b172875 100644
--- a/app/views/projects/learn_gitlab/index.html.haml
+++ b/app/views/projects/learn_gitlab/index.html.haml
@@ -2,7 +2,6 @@
- page_title _("Learn GitLab")
- add_page_specific_style 'page_bundles/learn_gitlab'
- data = learn_gitlab_data(@project)
-- invite_members_open = session.delete(:confetti_post_signup)
= render 'projects/invite_members_modal', project: @project
@@ -10,4 +9,4 @@
- e.control do
#js-learn-gitlab-app{ data: data }
- e.candidate do
- #js-learn-gitlab-app{ data: data.merge(invite_members_open: invite_members_open) }
+ #js-learn-gitlab-app{ data: data.merge(invite_members: 'true') }
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 9d5d1de1005..f2a271da771 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -2,7 +2,7 @@
- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
- can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request)
- are_close_and_open_buttons_hidden = merge_request_button_hidden?(@merge_request, true) && merge_request_button_hidden?(@merge_request, false)
-- cache_key = [@project, @merge_request, can_update_merge_request, can_reopen_merge_request, are_close_and_open_buttons_hidden]
+- cache_key = [@project, @merge_request, can_update_merge_request, can_reopen_merge_request, are_close_and_open_buttons_hidden, current_user&.preferred_language]
= cache(cache_key, expires_in: 1.day) do
- if @merge_request.closed_or_merged_without_fork?
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index a0e78b7570a..a7667d03138 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -49,8 +49,6 @@
= render "projects/merge_requests/tabs/pane", id: "notes", class: "notes voting_notes" do
.row
%section.col-md-12
- -# haml-lint:disable InlineJavaScript
- %script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
.issuable-discussion.js-vue-notes-event
- if @merge_request.description.present?
.detail-page-description
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index c88ea313287..e3f46d601a3 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -23,4 +23,4 @@
- if can? current_user, :remove_project, @project
.prepend-top-20
- = link_to _('Delete project'), project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn gl-button btn-danger float-right"
+ = link_to _('Delete project'), project_path(@project), data: { confirm: remove_project_message(@project), confirm_btn_variant: 'danger' }, aria: { label: _('Delete project') }, method: :delete, class: "btn gl-button btn-danger float-right"
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index d11b61466e2..31c14aaad50 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -6,36 +6,6 @@
- elsif note.contributor?
%span{ class: 'note-role user-access-role has-tooltip', title: _("This user has previously committed to the %{name} project.") % { name: note.project_name } }= _("Contributor")
-- if note.resolvable?
- - can_resolve = can?(current_user, :resolve_note, note)
- %resolve-btn{ "project-path" => project_path(note.project),
- "discussion-id" => note.discussion_id(@noteable),
- ":note-id" => note.id,
- ":resolved" => note.resolved?,
- ":can-resolve" => can_resolve,
- ":author-name" => "'#{j(note.author.name)}'",
- "author-avatar" => note.author.avatar_url,
- ":note-truncated" => "'#{j(truncate(note.note, length: 17))}'",
- ":resolved-by" => "'#{j(note.resolved_by.try(:name))}'",
- "v-show" => "#{can_resolve || note.resolved?}",
- "inline-template" => true,
- "ref" => "note_#{note.id}" }
-
- .note-actions-item
- %button.note-action-button.line-resolve-btn{ type: "button",
- class: ("is-disabled" unless can_resolve),
- ":class" => "{ 'is-active': isResolved }",
- ":aria-label" => "buttonText",
- "@click" => "resolve",
- ":title" => "buttonText",
- ":ref" => "'button'" }
-
- %div
- %template{ 'v-if' => 'isResolved' }
- = render 'shared/icons/icon_status_success_solid.svg'
- %template{ 'v-else' => '' }
- = render 'shared/icons/icon_resolve_discussion.svg'
-
- if can?(current_user, :award_emoji, note)
- if note.emoji_awardable?
.note-actions-item
diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml
index c81a3683e90..5385c6a4cc6 100644
--- a/app/views/projects/notes/_more_actions_dropdown.html.haml
+++ b/app/views/projects/notes/_more_actions_dropdown.html.haml
@@ -13,6 +13,6 @@
= _('Report abuse to admin')
- if note_editable
%li
- = link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?', qa_selector: 'delete_comment_button' }, remote: true, class: 'js-note-delete' do
+ = link_to note_url(note), method: :delete, data: { confirm: _('Are you sure you want to delete this comment?'), confirm_btn_variant: 'danger', qa_selector: 'delete_comment_button' }, aria: { label: _('Delete comment') }, remote: true, class: 'js-note-delete' do
%span.text-danger
= _('Delete comment')
diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml
index 2db44528d51..15fb5755b61 100644
--- a/app/views/projects/pages/_pages_settings.html.haml
+++ b/app/views/projects/pages/_pages_settings.html.haml
@@ -1,11 +1,12 @@
+- can_edit_max_page_size=can?(current_user, :update_max_pages_size)
+- can_enforce_https_only=Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
+
+- return unless can_edit_max_page_size || can_enforce_https_only
= form_for @project, url: project_pages_path(@project), html: { class: 'inline', title: pages_https_only_title } do |f|
- - if can?(current_user, :update_max_pages_size)
+ - if can_edit_max_page_size
= render_if_exists 'shared/pages/max_pages_size_input', form: f
- .gl-mt-3
- = f.submit s_('GitLabPages|Save changes'), class: 'btn btn-confirm gl-button'
-
- - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
+ - if can_enforce_https_only
.form-group
.form-check
= f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled?
@@ -17,5 +18,5 @@
%p
= s_("GitLabPages|When enabled, all attempts to visit your website through HTTP are automatically redirected to HTTPS using a response with status code 301. Requires a valid certificate for all domains. %{docs_link_start}Learn more.%{link_end}").html_safe % { docs_link_start: docs_link_start, link_end: link_end }
- .gl-mt-3
- = f.submit s_('GitLabPages|Save changes'), class: 'btn btn-confirm gl-button'
+ .gl-mt-3
+ = f.submit s_('GitLabPages|Save changes'), class: 'btn btn-confirm gl-button'
diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml
index 2732463020e..6943469aaac 100644
--- a/app/views/projects/pages_domains/_dns.html.haml
+++ b/app/views/projects/pages_domains/_dns.html.haml
@@ -20,10 +20,9 @@
= _("Verification status")
.col-sm-10
.status-badge
- - text, status = domain_presenter.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success']
- .badge{ class: status }
- = text
- = link_to sprite_icon("redo"), verify_project_pages_domain_path(@project, domain_presenter), method: :post, class: "gl-button btn btn-default has-tooltip", title: _("Retry verification")
+ - text, status = domain_presenter.unverified? ? [_('Unverified'), :danger] : [_('Verified'), :success]
+ = gl_badge_tag text, variant: status
+ = link_to sprite_icon("redo"), verify_project_pages_domain_path(@project, domain_presenter), method: :post, class: "gl-ml-2 gl-button btn btn-default has-tooltip", title: _("Retry verification")
.input-group
= text_field_tag :domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true
.input-group-append
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index f6a0638ccd0..908de68f825 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -36,5 +36,5 @@
= 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 btn-icon', 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', aria: { label: _('Delete pipeline schedule') }, data: { confirm: _("Are you sure you want to delete this pipeline schedule?"), confirm_btn_variant: 'danger' } do
= sprite_icon('remove')
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 13a77dbf2fd..4e93d7a04e7 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -38,7 +38,7 @@
- popover_content_text = _('Learn more about Auto DevOps')
= gl_badge_tag s_('Pipelines|Auto DevOps'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-autodevops', href: "#", tabindex: "0", role: "button", data: { container: 'body', toggle: 'popover', placement: 'top', html: 'true', triggers: 'focus', title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>", content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>" } }
- if @pipeline.detached_merge_request_pipeline?
- = gl_badge_tag s_('Pipelines|detached'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-mergerequest has-tooltip', title: _('Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.') }
+ = gl_badge_tag s_('Pipelines|detached'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-mergerequest has-tooltip', title: _('Merge request pipelines are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for merge request pipelines.') }
- if @pipeline.stuck?
= gl_badge_tag s_('Pipelines|stuck'), { variant: :warning, size: :sm }, { class: 'js-pipeline-url-stuck has-tooltip' }
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index d654d0e04d7..70815dbe7a7 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -26,6 +26,7 @@
- lint_link_start = '<a href="%{url}" class="gl-text-blue-500!">'.html_safe % { url: lint_link_url }
= s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe }
+ #js-pipeline-notification{ data: { deprecated_keywords_doc_path: help_page_path('ci/yaml/index.md', anchor: 'deprecated-keywords'), full_path: @project.full_path, pipeline_iid: @pipeline.iid } }
= render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors
.js-pipeline-details-vue{ data: { 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/index.html.haml b/app/views/projects/project_members/index.html.haml
index 12b2b33e364..220e44679cd 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -21,6 +21,7 @@
.js-import-a-project-modal{ data: { project_id: @project.id, project_name: @project.name } }
- if @project.allowed_to_share_with_group?
.js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite a group') } }
+ = render 'projects/invite_groups_modal', project: @project
- if can_admin_project_member?(@project)
.js-invite-members-trigger{ data: { variant: 'success',
classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3',
@@ -38,7 +39,7 @@
%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 }
- - if Feature.disabled?(:invite_members_group_modal, @project.group) && can?(current_user, :admin_project_member, @project) && project_can_be_shared?
+ - if Feature.disabled?(:invite_members_group_modal, @project.group, default_enabled: :yaml) && can?(current_user, :admin_project_member, @project) && project_can_be_shared?
- if !membership_locked? && @project.allowed_to_share_with_group?
%ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%li.nav-tab{ role: 'presentation' }
@@ -61,7 +62,7 @@
default_access_level: ProjectGroupLink.default_access,
group_link_field: 'link_group_id',
group_access_field: 'link_group_access',
- groups_select_tag_data: { skip_groups: @skip_groups }
+ groups_select_tag_data: { min_access_level: Gitlab::Access::GUEST, skip_groups: @skip_groups }
- elsif !membership_locked?
.invite-member
= render 'shared/members/invite_member',
@@ -78,7 +79,7 @@
submit_url: project_group_links_path(@project),
group_link_field: 'link_group_id',
group_access_field: 'link_group_access',
- groups_select_tag_data: { skip_groups: @skip_groups }
+ groups_select_tag_data: { min_access_level: Gitlab::Access::GUEST, skip_groups: @skip_groups }
.js-project-members-list-app{ data: { members_data: project_members_app_data_json(@project,
members: @project_members,
group_links: @group_links,
diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
index f3bb2a66a4c..c9e964b2984 100644
--- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
@@ -20,4 +20,4 @@
- if can_admin_project
%td
- = link_to s_('ProtectedBranch|Unprotect'), [@project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?') }, method: :delete, class: "btn gl-button btn-warning"
+ = link_to s_('ProtectedBranch|Unprotect'), [@project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, class: "btn gl-button btn-warning"
diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml
index fe63f921780..8f5ce798dc7 100644
--- a/app/views/projects/protected_tags/shared/_index.html.haml
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -11,7 +11,7 @@
= link_to s_("ProtectedTag|What are protected tags?"), help_page_path("user/project/protected_tags")
.settings-content
%p
- = s_("ProtectedTag|By default, protected branches restrict who can modify the tag.")
+ = s_("ProtectedTag|By default, protected tags restrict who can modify the tag.")
= link_to s_("ProtectedTag|Learn more."), help_page_path("user/project/protected_tags", anchor: "who-can-modify-a-protected-tag")
- if can? current_user, :admin_project, @project
diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
index b312a09aadd..ed5b5b17942 100644
--- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
@@ -19,4 +19,4 @@
- if can? current_user, :admin_project, @project
%td
- = link_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'gl-button btn btn-danger-secondary'
+ = link_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], aria: { label: s_('ProtectedTags|Unprotect tag') }, data: { confirm: 'Tag will be writable for developers. Are you sure?', confirm_btn_variant: 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary'
diff --git a/app/views/projects/readme_templates/default.md.tt b/app/views/projects/readme_templates/default.md.tt
new file mode 100644
index 00000000000..d5fef29b290
--- /dev/null
+++ b/app/views/projects/readme_templates/default.md.tt
@@ -0,0 +1,93 @@
+# <%= project.name %>
+
+<%= project.description %>
+
+
+## Getting started
+
+To make it easy for you to get started with GitLab, here's a list of recommended next steps.
+
+Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
+
+## Add your files
+
+- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
+- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
+
+```
+cd existing_repo
+git remote add origin <%= project.http_url_to_repo %>
+git branch -M <%= project.default_branch_or_main %>
+git push -uf origin <%= project.default_branch_or_main %>
+```
+
+## Integrate with your tools
+
+- [ ] [Set up project integrations](<%= project_settings_integrations_url(project) %>)
+
+## Collaborate with your team
+
+- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
+- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
+- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
+- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
+- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
+
+## Test and Deploy
+
+Use the built-in continuous integration in GitLab.
+
+- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
+- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
+- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
+- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
+- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
+
+***
+
+# Editing this README
+
+When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template.
+
+## Suggestions for a good README
+Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
+
+## Name
+Choose a self-explaining name for your project.
+
+## Description
+Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
+
+## Badges
+On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
+
+## Visuals
+Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
+
+## Installation
+Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
+
+## Usage
+Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
+
+## Support
+Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
+
+## Roadmap
+If you have ideas for releases in the future, it is a good idea to list them in the README.
+
+## Contributing
+State if you are open to contributions and what your requirements are for accepting them.
+
+For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
+
+You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
+
+## Authors and acknowledgment
+Show your appreciation to those who have contributed to the project.
+
+## License
+For open source projects, say how it is licensed.
+
+## Project status
+If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
diff --git a/app/views/projects/security/configuration/show.html.haml b/app/views/projects/security/configuration/show.html.haml
index e8ac572df1d..df14bd09a4d 100644
--- a/app/views/projects/security/configuration/show.html.haml
+++ b/app/views/projects/security/configuration/show.html.haml
@@ -2,4 +2,6 @@
- page_title _("Security Configuration")
- @content_class = "limit-container-width" unless fluid_layout
-#js-security-configuration-static{ data: { project_path: @project.full_path, upgrade_path: security_upgrade_path } }
+#js-security-configuration{ data: { **@configuration.to_html_data_attribute,
+ upgrade_path: security_upgrade_path,
+ project_full_path: @project.full_path } }
diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml
index b17e6df1e8e..03a1b0ee7bb 100644
--- a/app/views/projects/serverless/functions/index.html.haml
+++ b/app/views/projects/serverless/functions/index.html.haml
@@ -10,8 +10,6 @@
help_path: help_page_path('user/project/clusters/serverless/index'),
empty_image_path: image_path('illustrations/empty-state/empty-serverless-lg.svg') } }
-.js-serverless-survey-banner{ data: { user_name: current_user.name, user_email: current_user.email } }
-
.js-serverless-functions-notice
.flash-container
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index 419dd827e49..65b93dc930a 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -6,13 +6,7 @@
- if integration.operating?
= sprite_icon('check', css_class: 'gl-text-green-500')
-- if vue_integration_form_enabled?
- = render 'shared/integration_settings', integration: integration
-- else
- = form_for(integration, as: :service, url: scoped_integration_path(integration, project: @project, group: @group), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => test_project_integration_path(@project, integration), testid: 'integration-form' } }) do |form|
- = render 'shared/integration_settings', form: form, integration: integration
- %input{ id: 'services_redirect_to', type: 'hidden', name: 'redirect_to', value: request.referer }
-
+= render 'shared/integration_settings', integration: integration
- if lookup_context.template_exists?('show', "projects/services/#{integration.to_param}", true)
%hr
= render "projects/services/#{integration.to_param}/show", integration: integration
diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml
index be9bd3dfc01..12cb1c3574a 100644
--- a/app/views/projects/settings/_archive.html.haml
+++ b/app/views/projects/settings/_archive.html.haml
@@ -10,11 +10,13 @@
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchiving-a-project') }
%p= _("Unarchiving the project will restore its members' ability to make changes to it. The repository can be committed to, and issues, comments, and other entities can be created. %{strong_start}Once active, this project shows up in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe }
= link_to _('Unarchive project'), unarchive_project_path(@project),
+ aria: { label: _('Unarchive project') },
data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' },
method: :post, class: "gl-button btn btn-confirm"
- else
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archiving-a-project') }
%p= _("Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe }
= link_to _('Archive project'), archive_project_path(@project),
- data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' },
+ aria: { label: _('Archive project') },
+ data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'warning' },
method: :post, class: "gl-button btn btn-warning"
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
index 0f4d5869cea..960b1d67610 100644
--- a/app/views/projects/settings/_general.html.haml
+++ b/app/views/projects/settings/_general.html.haml
@@ -27,6 +27,7 @@
.row= render_if_exists 'projects/classification_policy_settings', f: f
+ = render 'shared/repository_size_limit_setting_registration_features_cta', form: f
= render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project
.form-group.gl-mt-3.gl-mb-3
@@ -36,6 +37,6 @@
= render 'shared/choose_avatar_button', f: f
- if @project.avatar?
%hr
- = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'gl-button btn btn-danger-secondary'
+ = link_to _('Remove avatar'), project_avatar_path(@project), aria: { label: _('Remove avatar') }, data: { confirm: _('Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary'
= f.submit _('Save changes'), class: "gl-button btn btn-confirm gl-mt-6", data: { qa_selector: 'save_naming_topics_avatar_button' }
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index d3cdfc4f7c9..c70e153ae41 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -94,7 +94,7 @@
.input-group-text /
%p.form-text.text-muted
= 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: 'add-test-coverage-results-to-a-merge-request'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'add-test-coverage-results-to-a-merge-request-deprecated'), target: '_blank', rel: 'noopener noreferrer'
= f.submit _('Save changes'), class: "btn gl-button btn-confirm", data: { qa_selector: 'save_general_pipelines_changes_button' }
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 215448be3d6..4e94c96fdde 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -2,6 +2,21 @@
- page_title _('Monitor Settings')
- breadcrumb_title _('Monitor Settings')
+.gl-alert.gl-alert-danger.gl-mb-5
+ - removal_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/7188'
+ - removal_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: removal_epic_link_url }
+ - opstrace_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/6976'
+ - opstrace_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: opstrace_link_url }
+ - link_end = '</a>'.html_safe
+ .gl-alert-container
+ = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-content
+ .gl-alert-title
+ = s_('Deprecations|Feature deprecation and removal')
+ .gl-alert-body
+ %p
+ = html_escape(s_('Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0. For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {removal_link_start: removal_epic_link_start, opstrace_link_start: opstrace_link_start, link_end: link_end }
+
= render 'projects/settings/operations/metrics_dashboard'
= render 'projects/settings/operations/tracing'
= render 'projects/settings/operations/error_tracking'
diff --git a/app/views/projects/starrers/index.html.haml b/app/views/projects/starrers/index.html.haml
index 618c4c249a1..fe8a6508dd7 100644
--- a/app/views/projects/starrers/index.html.haml
+++ b/app/views/projects/starrers/index.html.haml
@@ -12,21 +12,13 @@
= search_field_tag :search, params[:search], { placeholder: _('Search'), class: 'form-control', spellcheck: false }
%button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") }
= sprite_icon('search')
- .dropdown.inline.gl-ml-3
- = dropdown_toggle(starrers_sort_options_hash[@sort], { toggle: 'dropdown' })
- %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
- %li.dropdown-header
- = _("Sort by")
- - starrers_sort_options_hash.each do |value, title|
- %li
- = link_to filter_starrer_path(sort: value), class: ("is-active" if @sort == value) do
- = title
+ - starrers_sort_options = starrers_sort_options_hash.map { |value, text| { value: value, text: text, href: filter_starrer_path(sort: value) } }
+ = gl_redirect_listbox_tag starrers_sort_options, @sort, class: 'gl-ml-3', data: { right: true }
- if @starrers.size > 0
.row.gl-mt-3
= render partial: 'starrer', collection: @starrers, as: :starrer
= paginate @starrers, theme: 'gitlab'
+- elsif params[:search].present?
+ .nothing-here-block= _('No starrers matched your search')
- else
- - if params[:search].present?
- .nothing-here-block= _('No starrers matched your search')
- - else
- .nothing-here-block= _('Nobody has starred this repository yet')
+ .nothing-here-block= _('Nobody has starred this repository yet')
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index d3cc409df1d..4a44ad2337f 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -1,4 +1,3 @@
-- @sort ||= sort_value_recently_updated
- page_title s_('TagsPage|Tags')
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_tags_url(@project, rss_url_options), title: "#{@project.name} tags")
@@ -9,7 +8,7 @@
= s_('TagsPage|Tags give the ability to mark specific points in history as being important')
.nav-controls
- #js-tags-sort-dropdown{ data: { filter_tags_path: filter_tags_path, sort_options: tags_sort_options_hash.to_json } }
+ #js-tags-sort-dropdown{ data: { filter_tags_path: filter_tags_path(search: @search, sort: @sort), sort_options: tags_sort_options_hash.to_json } }
= link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn gl-button btn-default btn-icon has-tooltip gl-ml-auto' do
= sprite_icon('rss', css_class: 'gl-icon qa-rss-icon')
- if can?(current_user, :admin_tag, @project)
diff --git a/app/views/projects/tracings/show.html.haml b/app/views/projects/tracings/show.html.haml
index 813908e5a57..a7a02ab917e 100644
--- a/app/views/projects/tracings/show.html.haml
+++ b/app/views/projects/tracings/show.html.haml
@@ -1,6 +1,21 @@
- @content_class = "limit-container-width" unless fluid_layout
- page_title _("Tracing")
+.gl-alert.gl-alert-danger.gl-mb-5
+ - removal_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/7188'
+ - removal_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: removal_epic_link_url }
+ - opstrace_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/6976'
+ - opstrace_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: opstrace_link_url }
+ - link_end = '</a>'.html_safe
+ .gl-alert-container
+ = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-content
+ .gl-alert-title
+ = s_('Deprecations|Feature deprecation and removal')
+ .gl-alert-body
+ %p
+ = html_escape(s_('Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0. For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {removal_link_start: removal_epic_link_start, opstrace_link_start: opstrace_link_start, link_end: link_end }
+
- if @project.tracing_external_url.present?
%h3.page-title= _('Tracing')
.gl-alert.gl-alert-info.gl-mb-5
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index 081afacdaa6..9d082436aa7 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -8,7 +8,7 @@
.label-container
- unless trigger.can_access_project?
- %span.badge.badge-danger.has-tooltip{ title: "Trigger user has insufficient permissions to project" } invalid
+ = gl_badge_tag s_('Trigger|invalid'), { variant: :danger }, { title: s_('Trigger|Trigger user has insufficient permissions to project'), data: { toggle: 'tooltip', container: 'body' } }
%td
- if trigger.description? && trigger.description.length > 15
diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml
index de1135cf928..5b4edc92d1d 100644
--- a/app/views/projects/usage_quotas/index.html.haml
+++ b/app/views/projects/usage_quotas/index.html.haml
@@ -9,11 +9,10 @@
%a{ href: help_page_path('user/usage_quotas.md'), target: '_blank', rel: 'noopener noreferrer' }
= s_('UsageQuota|Learn more about usage quotas') + '.'
-.top-area.scrolling-tabs-container.inner-page-scroll-tabs
- %ul.nav.nav-tabs.nav-links.scrolling-tabs.separator.js-usage-quota-tabs{ role: 'tablist' }
- %li.nav-item
- %a.nav-link#storage-quota{ data: { toggle: "tab", action: '#storage-quota-tab' }, href: '#storage-quota-tab', 'aria-controls': '#storage-quota-tab', 'aria-selected': 'true' }
- = s_('UsageQuota|Storage')
+= gl_tabs_nav do
+ = gl_tab_link_to '#storage-quota-tab', item_active: true do
+ = s_('UsageQuota|Storage')
+
.tab-content
- .tab-pane#storage-quota-tab
+ .tab-pane.active#storage-quota-tab
#js-project-storage-count-app{ data: { project_path: @project.full_path } }
diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml
index ca2f225a2d8..44dffdbf70a 100644
--- a/app/views/registrations/welcome/show.html.haml
+++ b/app/views/registrations/welcome/show.html.haml
@@ -5,6 +5,7 @@
- content_for :page_specific_javascripts do
= render "layouts/google_tag_manager_head"
= render "layouts/one_trust"
+ = render "layouts/bizible"
= render "layouts/google_tag_manager_body"
.row.gl-flex-grow-1
diff --git a/app/views/shared/_confirm_fork_modal.html.haml b/app/views/shared/_confirm_fork_modal.html.haml
deleted file mode 100644
index 96b128eb2ec..00000000000
--- a/app/views/shared/_confirm_fork_modal.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-.modal{ data: { qa_selector: 'confirm_fork_modal'}, id: "modal-confirm-fork-#{type}" }
- .modal-dialog
- .modal-content
- .modal-header
- %h3.page-title= _('Fork project?')
- %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
- %span{ "aria-hidden": "true" } &times;
- .modal-body.p-3
- %p= _("You can’t %{tag_start}edit%{tag_end} files directly in this project. Fork this project and submit a merge request with your changes.") % { tag_start: '', tag_end: ''}
- .modal-footer
- = link_to _('Cancel'), '#', class: "btn gl-button btn-default", "data-dismiss" => "modal"
- = link_to _('Fork project'), fork_path, class: 'btn gl-button btn-confirm', data: { qa_selector: 'fork_project_button' }, method: :post
diff --git a/app/views/shared/_gl_toggle.html.haml b/app/views/shared/_gl_toggle.html.haml
new file mode 100644
index 00000000000..afaa6b6df92
--- /dev/null
+++ b/app/views/shared/_gl_toggle.html.haml
@@ -0,0 +1,28 @@
+-# This partial renders a GlToggle root element.
+-# To actually initialize the component, make sure to call the initToggle helper from ~/toggles.
+
+- classes = local_assigns.fetch(:classes)
+- name = local_assigns.fetch(:name, nil)
+- is_checked = local_assigns.fetch(:is_checked, false).to_s
+- disabled = local_assigns.fetch(:disabled, false).to_s
+- is_loading = local_assigns.fetch(:is_loading, false).to_s
+- label = local_assigns.fetch(:label, nil)
+- help = local_assigns.fetch(:help, nil)
+- label_position = local_assigns.fetch(:label_position, nil)
+- data = local_assigns.fetch(:data, {})
+
+%span{ class: classes,
+ data: { name: name,
+ is_checked: is_checked,
+ disabled: disabled,
+ is_loading: is_loading,
+ label: label,
+ help: help,
+ label_position: label_position,
+ **data } }
+
+-# Leverage this block to render a rich help text. To render a plain text help text,
+-# prefer the `help` parameter.
+- if yield.present?
+ .gl-text-secondary.gl-mt-1
+ = yield
diff --git a/app/views/shared/_global_alert.html.haml b/app/views/shared/_global_alert.html.haml
index ea83f5c1656..1eaf21fc568 100644
--- a/app/views/shared/_global_alert.html.haml
+++ b/app/views/shared/_global_alert.html.haml
@@ -8,10 +8,9 @@
- close_button_class = local_assigns.fetch(:close_button_class, nil)
- close_button_data = local_assigns.fetch(:close_button_data, nil)
- icon = icons[variant]
-- alert_root_class = 'gl-alert-layout-limited' if fluid_layout
- alert_container_class = [container_class, @content_class] unless fluid_layout || local_assigns.fetch(:is_contained, false)
-%div{ role: 'alert', class: [alert_root_class, 'gl-alert-max-content', 'gl-alert', "gl-alert-#{variant}", alert_class], data: alert_data }
+%div{ role: 'alert', class: ['gl-alert', "gl-alert-#{variant}", alert_class], data: alert_data }
.gl-alert-container{ class: alert_container_class }
= sprite_icon(icon, size: 16, css_class: "gl-alert-icon#{' gl-alert-icon-no-title' if title.nil?}")
- if dismissible
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index 63468340992..ee8cfe3abb6 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -5,16 +5,18 @@
.row
.form-group.group-name-holder.col-sm-12
= f.label :name, class: 'label-bold' do
- = _("Group name")
- = f.text_field :name, placeholder: _('My Awesome Group'), class: 'js-autofill-group-name form-control input-lg', data: { qa_selector: 'group_name_field' },
+ = s_('Groups|Group name')
+ = f.text_field :name, placeholder: _('My awesome group'), class: 'js-autofill-group-name form-control input-lg', data: { qa_selector: 'group_name_field' },
required: true,
- title: _('Please fill in a descriptive name for your group.'),
+ title: s_('Groups|Enter a descriptive name for your group.'),
autofocus: true
+ .text-muted
+ = s_('Groups|Must start with letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.')
.row
.form-group.col-xs-12.col-sm-8
= f.label :path, class: 'label-bold' do
- = _("Group URL")
+ = s_('Groups|Group URL')
.input-group.gl-field-error-anchor
.group-root-path.input-group-prepend.has-tooltip{ title: group_path, :'data-placement' => 'bottom' }
.input-group-text
@@ -29,21 +31,21 @@
maxlength: ::Namespace::URL_MAX_LENGTH,
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
%p.validation-error.gl-field-error.field-validation.hide
- = _("Group path is already taken. We've suggested one that is available.")
- %p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.')
- %p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group URL availability...')
+ = s_('Groups|Group path is unavailable. Path has been replaced with a suggested available path.')
+ %p.validation-success.gl-field-success.field-validation.hide= s_('Groups|Group path is available.')
+ %p.validation-pending.gl-field-error-ignore.field-validation.hide= s_('Groups|Checking group URL availability...')
- if @group.persisted?
.gl-alert.gl-alert-warning.gl-mt-3.gl-mb-3
= sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-body
- = _('Changing group URL can have unintended side effects.')
+ = s_('Groups|Changing group URL can have unintended side effects.')
= succeed '.' do
- = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'change-a-groups-path'), target: '_blank', class: 'gl-link'
+ = link_to s_('Groups|Learn more'), help_page_path('user/group/index', anchor: 'change-a-groups-path'), target: '_blank', class: 'gl-link'
- if @group.persisted?
.row
.form-group.group-name-holder.col-sm-8
= f.label :id, class: 'label-bold' do
- = _("Group ID")
+ = s_('Groups|Group ID')
= f.text_field :id, class: 'form-control', readonly: true
diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml
index 4e3b1e02f16..42a146a4f65 100644
--- a/app/views/shared/_issues.html.haml
+++ b/app/views/shared/_issues.html.haml
@@ -1,7 +1,7 @@
= render 'shared/alerts/positioning_disabled' if @sort == 'relative_position'
- if @issues.to_a.any?
- %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class, data: { group_full_path: @group&.full_path } }
+ %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class }
= render partial: 'projects/issues/issue', collection: @issues
= paginate @issues, theme: "gitlab"
- else
diff --git a/app/views/shared/_registration_features_discovery_message.html.haml b/app/views/shared/_registration_features_discovery_message.html.haml
index 8a16d36b836..e5b1ad88a7f 100644
--- a/app/views/shared/_registration_features_discovery_message.html.haml
+++ b/app/views/shared/_registration_features_discovery_message.html.haml
@@ -1,9 +1,8 @@
- feature_title = local_assigns.fetch(:feature_title, s_('RegistrationFeatures|use this feature'))
- registration_features_docs_path = help_page_path('development/service_ping/index.md', anchor: 'registration-features-program')
-- service_ping_settings_path = metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings')
+- registration_features_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: registration_features_docs_path }
%div
%span= sprintf(s_('RegistrationFeatures|Want to %{feature_title} for free?'), { feature_title: feature_title })
- - if Gitlab.ee?
- = link_to s_('RegistrationFeatures|Enable Service Ping and register for this feature.'), service_ping_settings_path
- = sprintf(s_('RegistrationFeatures|Read more about the %{linkStart}%{label}%{linkEnd}.') , { linkStart: "<a href=\"#{registration_features_docs_path}\" target=\"_blank\">", label: s_('RegistrationFeatures|Registration Features Program'), linkEnd: "</a>" }).html_safe
+ = render_if_exists 'shared/registration_features_discovery_settings_link'
+ = html_escape(s_('RegistrationFeatures|Read more about the %{link_start}Registration Features Program%{link_end}.')) % { link_start: registration_features_link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml b/app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml
new file mode 100644
index 00000000000..9fe1e3087f6
--- /dev/null
+++ b/app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml
@@ -0,0 +1,9 @@
+- return unless registration_features_can_be_prompted?
+
+.row
+ .form-group.col-md-9
+ = form.label :disabled_repository_size_limit, class: 'label-bold' do
+ = _('Repository size limit (MB)')
+ = form.number_field :disabled_repository_size_limit, value: '', class: 'form-control', disabled: true
+ %span.form-text.text-muted
+ = render 'shared/registration_features_discovery_message'
diff --git a/app/views/shared/_web_ide_button.html.haml b/app/views/shared/_web_ide_button.html.haml
index 82af52cdd59..83646a3c92e 100644
--- a/app/views/shared/_web_ide_button.html.haml
+++ b/app/views/shared/_web_ide_button.html.haml
@@ -1,8 +1,5 @@
- type = blob ? 'blob' : 'tree'
+- button_data = web_ide_button_data({ blob: blob })
+- fork_options = fork_modal_options(@project, @ref, @path, blob)
-.d-inline-block{ data: { options: web_ide_button_data({ blob: blob }).to_json }, id: "js-#{type}-web-ide-link" }
-
-- if show_edit_button?({ blob: blob })
- = render 'shared/confirm_fork_modal', fork_path: fork_and_edit_path(@project, @ref, @path), type: 'edit'
-- if show_web_ide_button?
- = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path), type: 'webide'
+.gl-display-inline-block{ data: { options: button_data.merge(fork_options).to_json }, id: "js-#{type}-web-ide-link" }
diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml
index aa579b4a672..7f7dafbe5b0 100644
--- a/app/views/shared/access_tokens/_table.html.haml
+++ b/app/views/shared/access_tokens/_table.html.haml
@@ -56,7 +56,7 @@
%span.token-never-expires-label= _('Never')
- if resource
%td= resource.member(token.user).human_access
- %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: "gl-button btn btn-danger btn-sm float-right qa-revoke-button #{'btn-danger-secondary' unless token.expires?}", data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type } }
+ %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: "gl-button btn btn-danger btn-sm float-right qa-revoke-button #{'btn-danger-secondary' unless token.expires?}", aria: { label: _('Revoke') }, data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type }, 'confirm-btn-variant': 'danger' }
- else
.settings-message.text-center
= no_active_tokens_message
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index 902a0cad483..7289121d9eb 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -38,7 +38,7 @@
%fieldset.form-group.form-check
= f.check_box :write_registry, class: 'form-check-input', data: { qa_selector: 'deploy_token_write_registry_checkbox' }
= f.label :write_registry, 'write_registry', class: 'label-bold form-check-label'
- .text-secondary= s_('DeployTokens|Allows read and write access to registry images.')
+ .text-secondary= s_('DeployTokens|Allows write access to registry images.')
- if packages_registry_enabled?(group_or_project)
%fieldset.form-group.form-check
diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml
index d358340814c..95590d6e515 100644
--- a/app/views/shared/hook_logs/_content.html.haml
+++ b/app/views/shared/hook_logs/_content.html.haml
@@ -15,7 +15,7 @@
.gl-alert-container
= sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-content
- %h4.gl-alert-title= _('Internal error occured while delivering this webhook.')
+ %h4.gl-alert-title= _('Internal error occurred while delivering this webhook.')
.gl-alert-body
= _('Error: %{error}') % { error: hook_log.internal_error_message }
diff --git a/app/views/shared/icons/_icon_resolve_discussion.svg b/app/views/shared/icons/_icon_resolve_discussion.svg
deleted file mode 100644
index 845562e9320..00000000000
--- a/app/views/shared/icons/_icon_resolve_discussion.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill-rule="evenodd"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></svg>
diff --git a/app/views/shared/icons/_icon_status_success_solid.svg b/app/views/shared/icons/_icon_status_success_solid.svg
deleted file mode 100644
index 0aac6d933e1..00000000000
--- a/app/views/shared/icons/_icon_status_success_solid.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z" fill-rule="evenodd"/></svg>
diff --git a/app/views/shared/icons/_mr_widget_empty_state.svg b/app/views/shared/icons/_mr_widget_empty_state.svg
index 6a811893b2d..a75eee846c9 100644
--- a/app/views/shared/icons/_mr_widget_empty_state.svg
+++ b/app/views/shared/icons/_mr_widget_empty_state.svg
@@ -1 +1 @@
-<svg width="256" height="146" viewBox="0 0 256 146" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="178.714" height="115.389" rx="10"/><mask id="d" x="0" y="0" width="178.714" height="115.389" fill="#fff"><use xlink:href="#a"/></mask><path d="M8.796 31.515c.395.047.8.072 1.207.072h23.065c5.536 0 10.003-4.475 10.003-9.994v-11.6C43.07 4.476 38.594 0 33.07 0H10.003C4.467 0 0 4.475 0 9.994v11.6c0 1.248.23 2.444.65 3.547H0v7.414c0 4.094 2.394 5.113 5.342 2.28l3.454-3.32z" id="b"/><mask id="e" x="0" y="0" width="43.071" height="36.437" fill="#fff"><use xlink:href="#b"/></mask><path d="M8.796 31.515c.395.047.8.072 1.207.072h23.065c5.536 0 10.003-4.475 10.003-9.994v-11.6C43.07 4.476 38.594 0 33.07 0H10.003C4.467 0 0 4.475 0 9.994v11.6c0 1.248.23 2.444.65 3.547H0v7.414c0 4.094 2.394 5.113 5.342 2.28l3.454-3.32z" id="c"/><mask id="f" x="0" y="0" width="43.071" height="36.437" fill="#fff"><use xlink:href="#c"/></mask></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 3.868)" fill="#F9F9F9"><rect x="19.286" width="77.143" height="14.182" rx="7.091"/><rect y="28.364" width="84.857" height="14.182" rx="7.091"/><rect x="133.714" y="42.546" width="122.143" height="14.182" rx="7.091"/><rect x="82.929" y="126.992" width="101.571" height="14.182" rx="7.091"/><rect x="42.429" y="99.273" width="101.571" height="14.182" rx="7.091"/><rect x="19.929" y="70.909" width="225" height="14.182" rx="7.091"/><path d="M98.37 14.182H13.488h13.81a7.098 7.098 0 0 1 7.094 7.09 7.09 7.09 0 0 1-7.094 7.092h-13.81 84.88-23.452a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.096-7.092h23.452zm162 42.545h-75.238 23.452a7.098 7.098 0 0 1 7.095 7.09 7.09 7.09 0 0 1-7.096 7.092h-23.452 75.237-23.453a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.095-7.093h23.452zM103.512 85.09H28.275h23.452a7.098 7.098 0 0 1 7.095 7.092 7.09 7.09 0 0 1-7.095 7.09H28.275h75.237H80.06a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.095-7.09h23.452zm48.215 28.365H76.49 90.3a7.098 7.098 0 0 1 7.093 7.09 7.09 7.09 0 0 1-7.094 7.092H76.49h75.237-33.096a7.098 7.098 0 0 1-7.094-7.09 7.09 7.09 0 0 1 7.095-7.092h33.097z"/></g><g transform="translate(38.57 12.248)"><use stroke="#EEE" mask="url(#d)" stroke-width="8" fill="#FFF" xlink:href="#a"/><path fill="#EEE" d="M2.57 18.694h174.215v2.58H2.57z"/><g transform="translate(21.857 38.678)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(21.857 59.95)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#FC6D26" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(21.857 81.223)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(100.93 38.033)"><rect fill="#FDE5D8" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="14.826" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="21.917" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" y="21.273" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="37.286" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="25.071" y="35.455" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="18.643" y="28.364" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="30.857" y="21.273" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="35.455" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="21.857" y="21.273" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="30.857" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="39.857" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="49.5" y="14.182" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="29.008" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="36.099" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="43.19" width="3.857" height="1.289" rx=".645"/><rect fill="#6B4FBB" x="9.643" y="42.546" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="56.727" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="34.071" y="49.636" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="56.727" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="18.643" y="49.636" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="21.857" y="42.546" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" y="49.636" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="49.636" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="50.281" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="57.372" width="3.857" height="1.289" rx=".645"/></g></g><g transform="translate(196.07)"><use stroke="#FDE5D8" mask="url(#e)" stroke-width="8" fill="#FFF" xlink:href="#b"/><rect fill="#FDB692" x="9" y="9.025" width="18.643" height="1.934" rx=".967"/><rect fill="#FDB692" x="9" y="14.826" width="25.071" height="1.934" rx=".967"/><rect fill="#FDB692" x="9" y="20.628" width="18.643" height="1.934" rx=".967"/></g><g transform="translate(189 41.256)"><ellipse stroke="#FC6D26" stroke-width="3" fill="#FFF7F4" cx="10.286" cy="9.669" rx="9.643" ry="9.669"/><path d="M.023 9.002a8.352 8.352 0 0 0 7.94-4.308M9 .644c0-.21-.008-.416-.023-.62" stroke="#FC6D26" stroke-width="2"/><path d="M5.045 2.008A10.266 10.266 0 0 0 13.5 6.446c2.112 0 4.076-.638 5.71-1.733" stroke="#FC6D26" stroke-width="2"/><ellipse fill="#FC6D26" cx="6.75" cy="11.281" rx=".964" ry=".967"/><ellipse fill="#FC6D26" cx="13.821" cy="11.281" rx=".964" ry=".967"/></g><g transform="translate(46.93 96.05)"><ellipse stroke="#6B4FBB" stroke-width="3" fill="#F4F1FA" cx="9.643" cy="10.314" rx="9.643" ry="9.669"/><path d="M12.86 4.51h-.005L11.25 2.58 9.645 4.51H9.64L8.036 2.58 6.43 4.51h-.002L4.82 2.58 3.215 4.512h-1.75A9.646 9.646 0 0 1 9.642 0c3.447 0 6.47 1.8 8.176 4.508h-1.75l-1.605-1.93L12.86 4.51z" fill="#6B4FBB"/><ellipse fill="#6B4FBB" cx="6.107" cy="11.281" rx=".964" ry=".967"/><ellipse fill="#6B4FBB" cx="13.179" cy="11.281" rx=".964" ry=".967"/></g><g transform="matrix(-1 0 0 1 56.57 54.794)"><use stroke="#E2DCF2" mask="url(#f)" stroke-width="8" fill="#FFF" xlink:href="#c"/><rect fill="#6B4FBB" opacity=".5" x="15.429" y="9.025" width="18.643" height="1.934" rx=".967"/><rect fill="#6B4FBB" opacity=".5" x="21.857" y="14.826" width="12.214" height="1.934" rx=".967"/><rect fill="#6B4FBB" opacity=".5" x="21.857" y="20.628" width="12.214" height="1.934" rx=".967"/></g></g></svg>
+<svg width="256" height="146" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><mask id="d" x="0" y="0" width="178.7" height="115.4" fill="#FFF"><use xlink:href="#a"/></mask><mask id="e" x="0" y="0" width="43.1" height="36.4" fill="#FFF"><use xlink:href="#b"/></mask><mask id="f" x="0" y="0" width="43.1" height="36.4" fill="#FFF"><use xlink:href="#c"/></mask><path d="M8.8 31.5H33a10 10 0 0 0 10-10V10A10 10 0 0 0 33 0H10A10 10 0 0 0 0 10v11.6c0 1.2.2 2.4.7 3.5H0v7.5c0 4 2.4 5 5.3 2.2l3.5-3.3z" id="b"/><path d="M8.8 31.5H33a10 10 0 0 0 10-10V10A10 10 0 0 0 33 0H10A10 10 0 0 0 0 10v11.6c0 1.2.2 2.4.7 3.5H0v7.5c0 4 2.4 5 5.3 2.2l3.5-3.3z" id="c"/><rect id="a" width="178.7" height="115.4" rx="10"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 3.9)" fill="var(--gray-10, #f9f9f9)"><rect x="19.3" width="77.1" height="14.2" rx="7.1"/><rect y="28.4" width="84.9" height="14.2" rx="7.1"/><rect x="133.7" y="42.5" width="122.1" height="14.2" rx="7.1"/><rect x="82.9" y="127" width="101.6" height="14.2" rx="7.1"/><rect x="42.4" y="99.3" width="101.6" height="14.2" rx="7.1"/><rect x="19.9" y="70.9" width="225" height="14.2" rx="7.1"/><path d="M98.4 14.2h-85 13.9a7.1 7.1 0 0 1 7 7 7 7 0 0 1-7 7.2H13.5h84.9-23.5a7.1 7.1 0 0 1-7-7.1 7 7 0 0 1 7-7.1h23.5zm162 42.5H185h23.5a7.1 7.1 0 0 1 7 7.1 7 7 0 0 1-7 7.1H185h75.3-23.5a7.1 7.1 0 0 1-7-7 7 7 0 0 1 7-7.2h23.5zM103.5 85.1H28.3h23.4a7.1 7.1 0 0 1 7.1 7 7 7 0 0 1-7 7.2H28.2h75.2H80a7.1 7.1 0 0 1-7.1-7.1 7 7 0 0 1 7-7.1h23.5zm48.2 28.4H76.5h13.8a7.1 7.1 0 0 1 7 7 7 7 0 0 1-7 7.1H76.5h75.2-33a7.1 7.1 0 0 1-7.2-7 7 7 0 0 1 7.1-7.1h33.1z"/></g><g transform="translate(38.6 12.2)"><use stroke="var(--gray-200, #EEE)" mask="url(#d)" stroke-width="8" fill="var(--white, #fff)" xlink:href="#a"/><path fill="var(--gray-200, #EEE)" d="M2.6 18.7h174.2v2.6H2.6z"/><g fill="var(--gray-100, #EEE)"><g transform="translate(21.9 38.7)"><g fill="var(--dark-icon-color-purple-2, #B5A7DD)"><rect y=".6" width="3.9" height="1.3" rx=".6"/><rect y="7.7" width="3.9" height="1.3" rx=".6"/><rect y="14.8" width="3.9" height="1.3" rx=".6"/></g><rect x="9.6" width="9.6" height="2.6" rx="1.3"/><rect x="46.3" width="9.6" height="2.6" rx="1.3"/><rect x="25.1" y="14.2" width="9.6" height="2.6" rx="1.3"/><rect fill="#FC6D26" x="34.1" y="7.1" width="9.6" height="2.6" rx="1.3"/><rect fill="#FC6D26" opacity=".5" x="30.9" width="12.9" height="2.6" rx="1.3"/><rect x="9.6" y="14.2" width="12.9" height="2.6" rx="1.3"/><rect x="18.6" y="7.1" width="12.9" height="2.6" rx="1.3"/><rect fill="#FC6D26" x="21.9" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="7.1" width="6.4" height="2.6" rx="1.3"/></g><g transform="translate(21.9 60)"><g fill="var(--dark-icon-color-purple-2, #B5A7DD)"><rect y=".6" width="3.9" height="1.3" rx=".6"/><rect y="7.7" width="3.9" height="1.3" rx=".6"/><rect y="14.8" width="3.9" height="1.3" rx=".6"/></g><rect fill="#FC6D26" x="9.6" width="9.6" height="2.6" rx="1.3"/><rect x="46.3" width="9.6" height="2.6" rx="1.3"/><rect fill="#FC6D26" opacity=".5" x="25.1" y="14.2" width="9.6" height="2.6" rx="1.3"/><rect x="34.1" y="7.1" width="9.6" height="2.6" rx="1.3"/><rect fill="#FC6D26" x="30.9" width="12.9" height="2.6" rx="1.3"/><rect fill="#FC6D26" x="9.6" y="14.2" width="12.9" height="2.6" rx="1.3"/><rect x="18.6" y="7.1" width="12.9" height="2.6" rx="1.3"/><rect fill="#FC6D26" opacity=".5" x="21.9" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="7.1" width="6.4" height="2.6" rx="1.3"/></g><g transform="translate(21.9 81.2)"><g fill="var(--dark-icon-color-purple-2, #B5A7DD)"><rect y=".6" width="3.9" height="1.3" rx=".6"/><rect y="7.7" width="3.9" height="1.3" rx=".6"/><rect y="14.8" width="3.9" height="1.3" rx=".6"/></g><rect x="9.6" width="9.6" height="2.6" rx="1.3"/><rect x="46.3" width="9.6" height="2.6" rx="1.3"/><rect x="25.1" y="14.2" width="9.6" height="2.6" rx="1.3"/><rect x="34.1" y="7.1" width="9.6" height="2.6" rx="1.3"/><rect fill="#FC6D26" x="30.9" width="12.9" height="2.6" rx="1.3"/><rect x="9.6" y="14.2" width="12.9" height="2.6" rx="1.3"/><rect x="18.6" y="7.1" width="12.9" height="2.6" rx="1.3"/><rect fill="#FC6D26" opacity=".5" x="21.9" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="7.1" width="6.4" height="2.6" rx="1.3"/></g><g transform="translate(101 38)"><g fill="var(--dark-icon-color-purple-3, #6B4FBB)"><rect opacity=".5" x="25.1" y="14.2" width="9.6" height="2.6" rx="1.3"/><rect x="34.1" y="7.1" width="9.6" height="2.6" rx="1.3"/><rect opacity=".5" x="30.9" width="12.9" height="2.6" rx="1.3"/><rect x="9.6" y="14.2" width="12.9" height="2.6" rx="1.3"/><rect x="21.9" width="6.4" height="2.6" rx="1.3"/><rect opacity=".5" x="25.1" y="35.5" width="9.6" height="2.6" rx="1.3"/><rect x="18.6" y="28.4" width="9.6" height="2.6" rx="1.3"/><rect x="30.9" y="21.3" width="12.9" height="2.6" rx="1.3"/><rect x="9.6" y="42.5" width="9.6" height="2.6" rx="1.3"/><rect opacity=".5" x="34.1" y="49.6" width="9.6" height="2.6" rx="1.3"/><rect x="18.6" y="49.6" width="12.9" height="2.6" rx="1.3"/></g><g fill="var(--dark-icon-color-orange-1, #FDE5D8)"><rect y=".6" width="3.9" height="1.3" rx=".6"/><rect y="7.7" width="3.9" height="1.3" rx=".6"/><rect y="14.8" width="3.9" height="1.3" rx=".6"/><rect y="21.9" width="3.9" height="1.3" rx=".6"/><rect y="29" width="3.9" height="1.3" rx=".6"/><rect y="36.1" width="3.9" height="1.3" rx=".6"/><rect y="43.2" width="3.9" height="1.3" rx=".6"/><rect y="50.3" width="3.9" height="1.3" rx=".6"/><rect y="57.4" width="3.9" height="1.3" rx=".6"/></g><rect x="9.6" width="9.6" height="2.6" rx="1.3"/><rect x="46.3" width="9.6" height="2.6" rx="1.3"/><rect x="18.6" y="7.1" width="12.9" height="2.6" rx="1.3"/><rect x="9.6" y="7.1" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="21.3" width="9.6" height="2.6" rx="1.3"/><rect x="37.3" y="14.2" width="9.6" height="2.6" rx="1.3"/><rect x="9.6" y="35.5" width="12.9" height="2.6" rx="1.3"/><rect x="21.9" y="21.3" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="28.4" width="6.4" height="2.6" rx="1.3"/><rect x="30.9" y="28.4" width="6.4" height="2.6" rx="1.3"/><rect x="39.9" y="28.4" width="6.4" height="2.6" rx="1.3"/><rect x="49.5" y="14.2" width="6.4" height="2.6" rx="1.3"/><rect x="25.1" y="56.7" width="9.6" height="2.6" rx="1.3"/><rect x="9.6" y="56.7" width="12.9" height="2.6" rx="1.3"/><rect x="21.9" y="42.5" width="6.4" height="2.6" rx="1.3"/><rect x="46.3" y="49.6" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="49.6" width="6.4" height="2.6" rx="1.3"/></g></g></g><g transform="translate(196)"><use stroke="var(--dark-icon-color-orange-1, #FDE5D8)" mask="url(#e)" stroke-width="8" fill="var(--white, #fff)" xlink:href="#b"/><g fill="var(--dark-icon-color-orange-2, #FDB692)"><rect x="9" y="9" width="18.6" height="1.9" rx="1"/><rect x="9" y="14.8" width="25.1" height="1.9" rx="1"/><rect x="9" y="20.6" width="18.6" height="1.9" rx="1"/></g></g><g transform="translate(189 41.3)"><ellipse stroke="#FC6D26" stroke-width="3" fill="#fde5d8" cx="10.3" cy="9.7" rx="9.6" ry="9.7"/><path d="M0 9a8.4 8.4 0 0 0 8-4.3m1-4V0" stroke="#FC6D26" stroke-width="2"/><path d="M5 2a10.3 10.3 0 0 0 8.5 4.4c2.1 0 4-.6 5.7-1.7" stroke="#FC6D26" stroke-width="2"/><circle fill="#FC6D26" cx="6.8" cy="11.3" r="1"/><circle fill="#FC6D26" cx="13.8" cy="11.3" r="1"/></g><g transform="translate(47 96)"><ellipse stroke="var(--dark-icon-color-purple-3, #6B4FBB)" stroke-width="3" fill="#F4F1FA" cx="9.6" cy="10.3" rx="9.6" ry="9.7"/><path d="m12.9 4.5-1.7-2-1.6 2-1.6-2-1.6 2-1.6-2-1.6 2H1.5A9.6 9.6 0 0 1 9.6 0c3.5 0 6.5 1.8 8.2 4.5h-1.7l-1.6-2-1.6 2z" fill="var(--dark-icon-color-purple-3, #6B4FBB)"/><circle fill="var(--dark-icon-color-purple-3, #6B4FBB)" cx="6.1" cy="11.3" r="1"/><circle fill="var(--dark-icon-color-purple-3, #6B4FBB)" cx="13.2" cy="11.3" r="1"/></g><g transform="matrix(-1 0 0 1 56.6 54.8)" fill="var(--dark-icon-color-purple-2, #b5a8dd)"><use stroke="var(--dark-icon-color-purple-1, #E2DCF2)" mask="url(#f)" stroke-width="8" fill="var(--white, #fff)" xlink:href="#c"/><rect x="15.4" y="9" width="18.6" height="1.9" rx="1"/><rect x="21.9" y="14.8" width="12.2" height="1.9" rx="1"/><rect x="21.9" y="20.6" width="12.2" height="1.9" rx="1"/></g></g></svg>
diff --git a/app/views/shared/integrations/_form.html.haml b/app/views/shared/integrations/_form.html.haml
deleted file mode 100644
index e2457bc0632..00000000000
--- a/app/views/shared/integrations/_form.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- integration = local_assigns.fetch(:integration)
-
-= form_for integration, as: :service, url: scoped_integration_path(integration, group: @group), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => scoped_test_integration_path(integration, group: @group), testid: 'integration-form' } } do |form|
- = render 'shared/integration_settings', form: form, integration: integration
diff --git a/app/views/shared/integrations/edit.html.haml b/app/views/shared/integrations/edit.html.haml
index 4ceaedc2a69..f2a31400698 100644
--- a/app/views/shared/integrations/edit.html.haml
+++ b/app/views/shared/integrations/edit.html.haml
@@ -7,7 +7,4 @@
= @integration.title
= render 'shared/integrations/tabs', integration: @integration, active_tab: 'edit' do
- - if vue_integration_form_enabled?
- = render 'shared/integration_settings', integration: @integration
- - else
- = render 'shared/integrations/form', integration: @integration
+ = render 'shared/integration_settings', integration: @integration
diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml
index 196d0417fb8..e6d722cb08d 100644
--- a/app/views/shared/issuable/_assignees.html.haml
+++ b/app/views/shared/issuable/_assignees.html.haml
@@ -3,8 +3,11 @@
- render_count = assignees_rendering_overflow ? max_render - 1 : max_render
- more_assignees_count = issuable.assignees.size - render_count
-- issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord
- = link_to_member(@project, assignee, name: false, title: _("Assigned to %{name}") % { name: assignee.name})
+- if issuable.instance_of?(MergeRequest) && Feature.enabled?(:mr_attention_requests, default_enabled: :yaml)
+ = render 'shared/issuable/merge_request_assignees', issuable: issuable, count: render_count
+- else
+ - issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord
+ = link_to_member(@project, assignee, name: false, title: s_("MrList|Assigned to %{name}, go to their profile.") % { name: assignee.name})
- if more_assignees_count > 0
%span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', qa_selector: 'avatar_counter_content' }, title: _("+%{more_assignees_count} more assignees") % { more_assignees_count: more_assignees_count} }
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 16301789b65..ae896b7348d 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -75,8 +75,6 @@
- if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project)
= link_to 'Delete', polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { confirm: _('%{issuableType} will be removed! Are you sure?') % { issuableType: issuable.human_class_name } }, method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary gl-float-right'
-= render_if_exists 'shared/issuable/remove_approver'
-
- if issuable.respond_to?(:issue_type)
= form.hidden_field :issue_type
diff --git a/app/views/shared/issuable/_merge_request_assignees.html.haml b/app/views/shared/issuable/_merge_request_assignees.html.haml
new file mode 100644
index 00000000000..13dc6ae4abb
--- /dev/null
+++ b/app/views/shared/issuable/_merge_request_assignees.html.haml
@@ -0,0 +1,8 @@
+- issuable.merge_request_assignees.take(count).each do |merge_request_assignee| # rubocop: disable CodeReuse/ActiveRecord
+ - assignee = merge_request_assignee.assignee
+ - assignee_tooltip = ( merge_request_assignee.attention_requested? ? s_("MrList|Attention requested from assignee %{name}, go to their profile.") : s_("MrList|Assigned to %{name}, go to their profile.") ) % { name: assignee.name}
+
+ = link_to_member(@project, assignee, name: false, title: assignee_tooltip, extra_class: "gl-flex-direction-row-reverse") do
+ - if merge_request_assignee.attention_requested?
+ %span.gl-display-inline-flex
+ = sprite_icon('attention-solid-sm', css_class: 'gl-text-orange-500 icon-overlap-and-shadow')
diff --git a/app/views/shared/issuable/_merge_request_reviewers.html.haml b/app/views/shared/issuable/_merge_request_reviewers.html.haml
new file mode 100644
index 00000000000..df5c69e309f
--- /dev/null
+++ b/app/views/shared/issuable/_merge_request_reviewers.html.haml
@@ -0,0 +1,8 @@
+- issuable.merge_request_reviewers.take(count).each do |merge_request_reviewer| # rubocop: disable CodeReuse/ActiveRecord
+ - reviewer = merge_request_reviewer.reviewer
+ - reviewer_tooltip = ( merge_request_reviewer.attention_requested? ? s_("MrList|Attention requested from reviewer %{name}, go to their profile.") : s_("MrList|Review requested from %{name}, go to their profile.") ) % { name: reviewer.name}
+
+ = link_to_member(@project, reviewer, name: false, title: reviewer_tooltip, extra_class: "gl-flex-direction-row-reverse") do
+ - if merge_request_reviewer.attention_requested?
+ %span.gl-display-inline-flex
+ = sprite_icon('attention-solid-sm', css_class: 'gl-text-orange-500 icon-overlap-and-shadow')
diff --git a/app/views/shared/issuable/_reviewers.html.haml b/app/views/shared/issuable/_reviewers.html.haml
index 8e66135a20b..0bb0faa0bb8 100644
--- a/app/views/shared/issuable/_reviewers.html.haml
+++ b/app/views/shared/issuable/_reviewers.html.haml
@@ -3,8 +3,11 @@
- render_count = reviewers_rendering_overflow ? max_render - 1 : max_render
- more_reviewers_count = issuable.reviewers.size - render_count
-- issuable.reviewers.take(render_count).each do |reviewer| # rubocop: disable CodeReuse/ActiveRecord
- = link_to_member(@project, reviewer, name: false, title: _("Review requested from %{name}") % { name: reviewer.name})
+- if issuable.instance_of?(MergeRequest) && Feature.enabled?(:mr_attention_requests, default_enabled: :yaml)
+ = render 'shared/issuable/merge_request_reviewers', issuable: issuable, count: render_count
+- else
+ - issuable.reviewers.take(render_count).each do |reviewer| # rubocop: disable CodeReuse/ActiveRecord
+ = link_to_member(@project, reviewer, name: false, title: s_("MrList|Review requested from %{name}, go to their profile.") % { name: reviewer.name})
- if more_reviewers_count > 0
%span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old' }, title: _("+%{more_reviewers_count} more reviewers") % { more_reviewers_count: more_reviewers_count} }
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 3975748ba57..b02c6b65359 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -31,7 +31,7 @@
= check_box_tag checkbox_id, nil, false, class: "check-all-issues left"
- if is_epic_board
#js-board-filtered-search{ data: { full_path: @group&.full_path } }
- - elsif Feature.enabled?(:issue_boards_filtered_search, ff_resource) && board
+ - elsif Feature.enabled?(:issue_boards_filtered_search, ff_resource, default_enabled: :yaml) && board
#js-issue-board-filtered-search
- else
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
@@ -107,6 +107,16 @@
= render 'shared/issuable/user_dropdown_item',
user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' }
+ - if Feature.enabled?(:mr_attention_requests, default_enabled: :yaml)
+ #js-dropdown-attention-requested.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}}' }
= 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
@@ -206,7 +216,7 @@
%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
+ .filter-dropdown-container.gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row.gl-align-items-flex-start
- if type == :boards
#js-board-labels-toggle
- if current_user
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 9a703b9d355..7787e5dd660 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -25,6 +25,11 @@
.block.reviewer.qa-reviewer-block
= render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers, signed_in: signed_in
+ - if issuable_sidebar[:supports_escalation]
+ .block.escalation-status{ data: { testid: 'escalation_status_container' } }
+ #js-escalation-status{ data: { can_edit: issuable_sidebar.dig(:current_user, :can_update_escalation_status).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
+ = render_if_exists 'shared/issuable/sidebar_escalation_policy', issuable_sidebar: issuable_sidebar
+
- if @project.group.present?
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml
index 5742f22ce05..f6d7ed6764d 100644
--- a/app/views/shared/issuable/_sort_dropdown.html.haml
+++ b/app/views/shared/issuable/_sort_dropdown.html.haml
@@ -21,6 +21,6 @@
= sortable_item(sort_title_merged_date, page_filter_path(sort: sort_value_merged_date), sort_title) if viewing_merge_requests
= sortable_item(sort_title_closed_date, page_filter_path(sort: sort_value_closed_date), sort_title) if viewing_merge_requests
= sortable_item(sort_title_relative_position, page_filter_path(sort: sort_value_relative_position), sort_title) if viewing_issues
- = sortable_item(sort_title_title, page_filter_path(sort: sort_value_title), sort_title) if viewing_issues
+ = sortable_item(sort_title_title, page_filter_path(sort: sort_value_title), sort_title)
= render_if_exists('shared/ee/issuable/sort_dropdown', viewing_issues: viewing_issues, sort_title: sort_title)
= issuable_sort_direction_button(sort_value)
diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml
index c0a6322eb1b..257ad7a8518 100644
--- a/app/views/shared/issuable/form/_title.html.haml
+++ b/app/views/shared/issuable/form/_title.html.haml
@@ -14,13 +14,15 @@
- if issuable.respond_to?(:work_in_progress?)
.form-text.text-muted
- .js-wip-explanation
+ .js-wip-explanation{ style: "display: none;" }
= remove_wip_text
.js-no-wip-explanation
- if has_wip_commits
= _('It looks like you have some draft commits in this branch.')
%br
- = add_wip_text
+ .invisible
+ .js-unwrap-on-load
+ = add_wip_text
- if no_issuable_templates && can?(current_user, :push_code, issuable.project)
= render 'shared/issuable/form/default_templates'
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index 0bf002fbbc5..e5197acf06f 100644
--- a/app/views/shared/issue_type/_details_content.html.haml
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -3,7 +3,7 @@
.issue-details.issuable-details
.detail-page-description.content-block
- #js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json} }
+ #js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json, full_path: @project.full_path } }
.title-container
%h2.title= markdown_field(issuable, :title)
- if issuable.description.present?
diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml
index 8600db25e65..7af946377be 100644
--- a/app/views/shared/members/_access_request_links.html.haml
+++ b/app/views/shared/members/_access_request_links.html.haml
@@ -4,7 +4,8 @@
- link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project')
= link_to link_text, polymorphic_path([:leave, source, :members]),
method: :delete,
- data: { confirm: leave_confirmation_message(source), qa_selector: 'leave_group_link' },
+ aria: { label: link_text },
+ data: { confirm: leave_confirmation_message(source), confirm_btn_variant: 'danger', qa_selector: 'leave_group_link' },
class: 'js-leave-link'
- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord
= link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index 6549c86ab29..3ab8514aebf 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -25,7 +25,7 @@
- elsif note_counter == 0
- counter = badge_counter if local_assigns[:badge_counter]
- badge_class = "hidden" if @fresh_discussion || counter.nil?
- %span.badge.badge-pill{ class: badge_class }
+ %span.gl-display-flex.gl-align-items-center.gl-justify-content-center.gl-font-sm.design-note-pin.small.user-avatar{ class: badge_class }
= counter
.timeline-content
.note-header
diff --git a/app/views/shared/planning_hierarchy.html.haml b/app/views/shared/planning_hierarchy.html.haml
new file mode 100644
index 00000000000..7ab5347b33d
--- /dev/null
+++ b/app/views/shared/planning_hierarchy.html.haml
@@ -0,0 +1,5 @@
+- page_title _("Planning hierarchy")
+- has_sub_epics = @project&.licensed_feature_available?(:subepics)
+- has_epics = @project&.licensed_feature_available?(:epics)
+
+#js-work-items-hierarchy{ data: { has_sub_epics: has_sub_epics.to_s, has_epics: has_epics.to_s, illustration_path: image_path('illustrations/rocket-launch-md.svg') } }
diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml
index c5a03ef4dc1..529ef47a2cf 100644
--- a/app/views/shared/web_hooks/_hook.html.haml
+++ b/app/views/shared/web_hooks/_hook.html.haml
@@ -22,4 +22,4 @@
.col-md-4.col-lg-5.text-right-md.gl-mt-2
%span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm btn-default gl-mr-3'
%span>= link_to _('Edit'), edit_hook_path(hook), class: 'btn gl-button btn-default btn-sm gl-mr-3'
- = link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn gl-button btn-secondary btn-danger-secondary btn-sm'
+ = link_to _('Delete'), destroy_hook_path(hook), aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm_btn_variant: "danger", confirm: s_('Webhooks|Are you sure you want to delete this webhook?') }, method: :delete, class: 'btn gl-button btn-secondary btn-danger-secondary btn-sm'
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index 7e745efd069..c0a6ab44a26 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -8,7 +8,6 @@
= _('There was an error loading users activity calendar.')
%a.js-retry-load{ href: '#' }
= s_('UserProfile|Retry')
- .user-calendar-activities
- if @user.user_readme&.rich_viewer
.row.justify-content-center
.col-12.col-md-10.col-lg-8.gl-my-6
@@ -25,6 +24,8 @@
= link_to _('Edit'), edit_blob_path(@user.user_project, @user.user_project.default_branch, @user.user_readme.path)
= render 'projects/blob/viewer', viewer: @user.user_readme.rich_viewer, load_async: false
.row
+ .col-12.user-calendar-activities
+.row
%div{ class: activity_pane_class }
- if can?(current_user, :read_cross_project)
.activities-block
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index d5a1f3884c9..88eacaefcb0 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -50,8 +50,8 @@
.profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
.avatar-holder
- = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
- = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: '', itemprop: 'image'
+ = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do
+ = image_tag avatar_icon_for_user(@user, 90, current_user: current_user), class: "avatar s90", alt: '', itemprop: 'image'
- if @user.blocked? || !@user.confirmed?
.user-info
@@ -112,7 +112,7 @@
- if Feature.enabled?(:security_auto_fix) && @user.bot?
= sprite_icon('question', css_class: 'gl-text-blue-600')
= link_to @user.short_website_url, @user.full_website_url, target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url'
- - unless @user.public_email.blank?
+ - if display_public_email?(@user)
= render 'middle_dot_divider', stacking: true do
= link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email'
.gl-text-gray-900
diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml
index c461250fc9b..afe257c2fc2 100644
--- a/app/views/users/terms/index.html.haml
+++ b/app/views/users/terms/index.html.haml
@@ -1,6 +1,7 @@
- content_for :page_specific_javascripts do
= render "layouts/google_tag_manager_head"
= render "layouts/one_trust"
+ = render "layouts/bizible"
= render "layouts/google_tag_manager_body"
#js-terms-of-service{ data: { terms_data: terms_data(@term, @redirect) } }
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 239b66bdeb0..fb1fcb7937c 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -273,6 +273,33 @@
:weight: 1
:idempotent:
:tags: []
+- :name: cronjob:container_registry_migration_enqueuer
+ :worker_name: ContainerRegistry::Migration::EnqueuerWorker
+ :feature_category: :container_registry
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: cronjob:container_registry_migration_guard
+ :worker_name: ContainerRegistry::Migration::GuardWorker
+ :feature_category: :container_registry
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: cronjob:container_registry_migration_observer
+ :worker_name: ContainerRegistry::Migration::ObserverWorker
+ :feature_category: :container_registry
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:database_batched_background_migration
:worker_name: Database::BatchedBackgroundMigrationWorker
:feature_category: :database
@@ -1100,8 +1127,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :needs_own_queue
+ :tags: []
- :name: hashed_storage:hashed_storage_project_migrate
:worker_name: HashedStorage::ProjectMigrateWorker
:feature_category: :source_code_management
@@ -1110,8 +1136,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :needs_own_queue
+ :tags: []
- :name: hashed_storage:hashed_storage_project_rollback
:worker_name: HashedStorage::ProjectRollbackWorker
:feature_category: :source_code_management
@@ -1120,8 +1145,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :needs_own_queue
+ :tags: []
- :name: hashed_storage:hashed_storage_rollbacker
:worker_name: HashedStorage::RollbackerWorker
:feature_category: :source_code_management
@@ -1130,8 +1154,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :needs_own_queue
+ :tags: []
- :name: incident_management:incident_management_add_severity_system_note
:worker_name: IncidentManagement::AddSeveritySystemNoteWorker
:feature_category: :incident_management
@@ -1960,6 +1983,15 @@
:weight: 1
:idempotent:
:tags: []
+- :name: background_migration_ci_database
+ :worker_name: BackgroundMigration::CiDatabaseWorker
+ :feature_category: :database
+ :has_external_dependencies:
+ :urgency: :throttled
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: bulk_import
:worker_name: BulkImportWorker
:feature_category: :importers
@@ -2303,6 +2335,15 @@
:weight: 1
:idempotent:
:tags: []
+- :name: groups_update_statistics
+ :worker_name: Groups::UpdateStatisticsWorker
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: import_issues_csv
:worker_name: ImportIssuesCsvWorker
:feature_category: :team_planning
@@ -2564,6 +2605,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: namespaces_update_root_statistics
+ :worker_name: Namespaces::UpdateRootStatisticsWorker
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: new_issue
:worker_name: NewIssueWorker
:feature_category: :team_planning
@@ -2636,15 +2686,6 @@
:weight: 1
:idempotent:
:tags: []
-- :name: pages_update_configuration
- :worker_name: PagesUpdateConfigurationWorker
- :feature_category: :pages
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: true
- :tags: []
- :name: phabricator_import_import_tasks
:worker_name: Gitlab::PhabricatorImport::ImportTasksWorker
:feature_category: :importers
diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
index f5327449242..8452f2a7821 100644
--- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
@@ -20,7 +20,6 @@ module AuthorizedProjectUpdate
urgency :low
queue_namespace :authorized_project_update
- deduplicate :until_executing, including_scheduled: true
data_consistency :delayed
idempotent!
diff --git a/app/workers/auto_devops/disable_worker.rb b/app/workers/auto_devops/disable_worker.rb
index 9ec3e5490c2..03613db3f29 100644
--- a/app/workers/auto_devops/disable_worker.rb
+++ b/app/workers/auto_devops/disable_worker.rb
@@ -32,8 +32,12 @@ module AutoDevops
def email_receivers_for(pipeline, project)
recipients = [pipeline.user&.email]
- recipients << project.owner.email unless project.group
- recipients.uniq.compact
+
+ if project.personal?
+ recipients << project.owners.map(&:email)
+ end
+
+ recipients.flatten.uniq.compact
end
end
end
diff --git a/app/workers/background_migration/ci_database_worker.rb b/app/workers/background_migration/ci_database_worker.rb
new file mode 100644
index 00000000000..901d16681fd
--- /dev/null
+++ b/app/workers/background_migration/ci_database_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module BackgroundMigration
+ class CiDatabaseWorker # rubocop:disable Scalability/IdempotentWorker
+ include SingleDatabaseWorker
+
+ def self.tracking_database
+ @tracking_database ||= Gitlab::Database::CI_DATABASE_NAME
+ end
+ end
+end
diff --git a/app/workers/background_migration/single_database_worker.rb b/app/workers/background_migration/single_database_worker.rb
index b6661d4fd14..f3a2165c41e 100644
--- a/app/workers/background_migration/single_database_worker.rb
+++ b/app/workers/background_migration/single_database_worker.rb
@@ -32,10 +32,6 @@ module BackgroundMigration
def tracking_database
raise NotImplementedError, "#{self.name} does not implement #{__method__}"
end
-
- def unhealthy_metric_name
- raise NotImplementedError, "#{self.name} does not implement #{__method__}"
- end
end
# Performs the background migration.
@@ -55,8 +51,12 @@ module BackgroundMigration
private
+ def tracking_database
+ self.class.tracking_database
+ end
+
def job_coordinator
- @job_coordinator ||= Gitlab::BackgroundMigration.coordinator_for_database(self.class.tracking_database)
+ @job_coordinator ||= Gitlab::BackgroundMigration.coordinator_for_database(tracking_database)
end
def perform_with_connection(class_name, arguments, lease_attempts)
@@ -91,7 +91,7 @@ module BackgroundMigration
healthy_db = healthy_database?
perform = lease_obtained && healthy_db
- database_unhealthy_counter.increment if lease_obtained && !healthy_db
+ database_unhealthy_counter.increment(db_config_name: tracking_database) if lease_obtained && !healthy_db
# When the DB is unhealthy or the lease can't be obtained after several tries,
# then give up on the job and log a warning. Otherwise we could end up in
@@ -140,7 +140,7 @@ module BackgroundMigration
def database_unhealthy_counter
Gitlab::Metrics.counter(
- self.class.unhealthy_metric_name,
+ :background_migration_database_health_reschedules,
'The number of times a background migration is rescheduled because the database is unhealthy.'
)
end
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
index dea0d467eca..6145f34b693 100644
--- a/app/workers/background_migration_worker.rb
+++ b/app/workers/background_migration_worker.rb
@@ -6,8 +6,4 @@ class BackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker
def self.tracking_database
@tracking_database ||= Gitlab::BackgroundMigration::DEFAULT_TRACKING_DATABASE
end
-
- def self.unhealthy_metric_name
- @unhealthy_metric_name ||= :background_migration_database_health_reschedules
- end
end
diff --git a/app/workers/ci/delete_objects_worker.rb b/app/workers/ci/delete_objects_worker.rb
index cbcad3e8838..32c57750076 100644
--- a/app/workers/ci/delete_objects_worker.rb
+++ b/app/workers/ci/delete_objects_worker.rb
@@ -22,13 +22,7 @@ module Ci
end
def max_running_jobs
- if ::Feature.enabled?(:ci_delete_objects_medium_concurrency)
- 20
- elsif ::Feature.enabled?(:ci_delete_objects_high_concurrency)
- 50
- else
- 2
- end
+ 20
end
private
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index 83261d9e42e..c1fec4f0196 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -13,7 +13,6 @@ module ApplicationWorker
include Gitlab::SidekiqVersioning::Worker
LOGGING_EXTRA_KEY = 'extra'
- DEFAULT_DELAY_INTERVAL = 1
SAFE_PUSH_BULK_LIMIT = 1000
included do
@@ -92,18 +91,6 @@ module ApplicationWorker
validate_worker_attributes!
end
- def perform_async(*args)
- return super if Gitlab::Database::LoadBalancing.primary_only?
-
- # Worker execution for workers with data_consistency set to :delayed or :sticky
- # will be delayed to give replication enough time to complete
- if utilizes_load_balancing_capabilities? && Feature.disabled?(:skip_scheduling_workers_for_replicas, default_enabled: :yaml)
- perform_in(delay_interval, *args)
- else
- super
- end
- end
-
def set_queue
queue_name = ::Gitlab::SidekiqConfig::WorkerRouter.global.route(self)
sidekiq_options queue: queue_name # rubocop:disable Cop/SidekiqOptionsQueue
@@ -194,12 +181,6 @@ module ApplicationWorker
end
end
- protected
-
- def delay_interval
- DEFAULT_DELAY_INTERVAL.seconds
- end
-
private
def do_push_bulk(args_list)
diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb
index 5fcbd74ddad..16ac61976eb 100644
--- a/app/workers/container_expiration_policy_worker.rb
+++ b/app/workers/container_expiration_policy_worker.rb
@@ -57,7 +57,7 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo
def perform_unthrottled
with_runnable_policy(preloaded: true) do |policy|
with_context(project: policy.project,
- user: policy.project.owner) do |project:, user:|
+ user: nil) do |project:, user:|
ContainerExpirationPolicyService.new(project, user)
.execute(policy)
end
diff --git a/app/workers/container_registry/migration/enqueuer_worker.rb b/app/workers/container_registry/migration/enqueuer_worker.rb
new file mode 100644
index 00000000000..5feaba870e6
--- /dev/null
+++ b/app/workers/container_registry/migration/enqueuer_worker.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+module ContainerRegistry
+ module Migration
+ class EnqueuerWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+ include Gitlab::Utils::StrongMemoize
+
+ data_consistency :always
+ feature_category :container_registry
+ urgency :low
+ deduplicate :until_executing, including_scheduled: true
+ idempotent!
+
+ def perform
+ return unless migration.enabled?
+ return unless below_capacity?
+ return unless waiting_time_passed?
+
+ re_enqueue_if_capacity if handle_aborted_migration || handle_next_migration
+ rescue StandardError => e
+ Gitlab::ErrorTracking.log_exception(
+ e,
+ next_repository_id: next_repository&.id,
+ next_aborted_repository_id: next_aborted_repository&.id
+ )
+
+ next_repository&.abort_import
+ end
+
+ private
+
+ def handle_aborted_migration
+ return unless next_aborted_repository&.retry_aborted_migration
+
+ log_extra_metadata_on_done(:container_repository_id, next_aborted_repository.id)
+ log_extra_metadata_on_done(:import_type, 'retry')
+
+ true
+ end
+
+ def handle_next_migration
+ return unless next_repository
+ # We return true because the repository was successfully processed (migration_state is changed)
+ return true if tag_count_too_high?
+ return unless next_repository.start_pre_import
+
+ log_extra_metadata_on_done(:container_repository_id, next_repository.id)
+ log_extra_metadata_on_done(:import_type, 'next')
+
+ true
+ end
+
+ def tag_count_too_high?
+ return false unless next_repository.tags_count > migration.max_tags_count
+
+ next_repository.skip_import(reason: :too_many_tags)
+
+ true
+ end
+
+ def below_capacity?
+ current_capacity <= maximum_capacity
+ end
+
+ def waiting_time_passed?
+ delay = migration.enqueue_waiting_time
+ return true if delay == 0
+ return true unless last_step_completed_repository
+
+ last_step_completed_repository.last_import_step_done_at < Time.zone.now - delay
+ end
+
+ def current_capacity
+ strong_memoize(:current_capacity) do
+ ContainerRepository.with_migration_states(
+ %w[pre_importing pre_import_done importing]
+ ).count
+ end
+ end
+
+ def maximum_capacity
+ migration.capacity
+ end
+
+ def next_repository
+ strong_memoize(:next_repository) do
+ ContainerRepository.ready_for_import.take # rubocop:disable CodeReuse/ActiveRecord
+ end
+ end
+
+ def next_aborted_repository
+ strong_memoize(:next_aborted_repository) do
+ ContainerRepository.with_migration_state('import_aborted').take # rubocop:disable CodeReuse/ActiveRecord
+ end
+ end
+
+ def last_step_completed_repository
+ strong_memoize(:last_step_completed_repository) do
+ ContainerRepository.recently_done_migration_step.first
+ end
+ end
+
+ def migration
+ ::ContainerRegistry::Migration
+ end
+
+ def re_enqueue_if_capacity
+ return unless current_capacity < maximum_capacity
+
+ self.class.perform_async
+ end
+ end
+ end
+end
diff --git a/app/workers/container_registry/migration/guard_worker.rb b/app/workers/container_registry/migration/guard_worker.rb
new file mode 100644
index 00000000000..77ae111c1cb
--- /dev/null
+++ b/app/workers/container_registry/migration/guard_worker.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+module ContainerRegistry
+ module Migration
+ class GuardWorker
+ include ApplicationWorker
+ # This is a general worker with no context.
+ # It is not scoped to a project, user or group.
+ # We don't have a context.
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ data_consistency :always
+ feature_category :container_registry
+ urgency :low
+ worker_resource_boundary :unknown
+ deduplicate :until_executed
+ idempotent!
+
+ def perform
+ return unless Gitlab.com?
+
+ repositories = ::ContainerRepository.with_stale_migration(step_before_timestamp)
+ .limit(max_capacity)
+ aborts_count = 0
+ long_running_migration_ids = []
+
+ # the #to_a is safe as the amount of entries is limited.
+ # In addition, we're calling #each in the next line and we don't want two different SQL queries for these two lines
+ log_extra_metadata_on_done(:stale_migrations_count, repositories.to_a.size)
+
+ repositories.each do |repository|
+ if abortable?(repository)
+ repository.abort_import
+ aborts_count += 1
+ else
+ long_running_migration_ids << repository.id if long_running_migration?(repository)
+ end
+ end
+
+ log_extra_metadata_on_done(:aborted_stale_migrations_count, aborts_count)
+
+ if long_running_migration_ids.any?
+ log_extra_metadata_on_done(:long_running_stale_migration_container_repository_ids, long_running_migration_ids)
+ end
+ end
+
+ private
+
+ # This can ping the Container Registry API.
+ # We loop on a set of repositories to calls this function (see #perform)
+ # In the worst case scenario, we have a n+1 API calls situation here.
+ #
+ # This is reasonable because the maximum amount of repositories looped
+ # on is `25`. See ::ContainerRegistry::Migration.capacity.
+ #
+ # TODO We can remove this n+1 situation by having a Container Registry API
+ # endpoint that accepts multiple repository paths at once. This is issue
+ # https://gitlab.com/gitlab-org/container-registry/-/issues/582
+ def abortable?(repository)
+ # early return to save one Container Registry API request
+ return true unless repository.importing? || repository.pre_importing?
+ return true unless external_migration_in_progress?(repository)
+
+ false
+ end
+
+ def long_running_migration?(repository)
+ migration_start_timestamp(repository).before?(long_running_migration_threshold)
+ end
+
+ def external_migration_in_progress?(repository)
+ status = repository.external_import_status
+
+ (status == 'pre_import_in_progress' && repository.pre_importing?) ||
+ (status == 'import_in_progress' && repository.importing?)
+ end
+
+ def migration_start_timestamp(repository)
+ if repository.pre_importing?
+ repository.migration_pre_import_started_at
+ else
+ repository.migration_import_started_at
+ end
+ end
+
+ def step_before_timestamp
+ ::ContainerRegistry::Migration.max_step_duration.seconds.ago
+ end
+
+ def max_capacity
+ # doubling the actual capacity to prevent issues in case the capacity
+ # is not properly applied
+ ::ContainerRegistry::Migration.capacity * 2
+ end
+
+ def long_running_migration_threshold
+ @threshold ||= 30.minutes.ago
+ end
+ end
+ end
+end
diff --git a/app/workers/container_registry/migration/observer_worker.rb b/app/workers/container_registry/migration/observer_worker.rb
new file mode 100644
index 00000000000..757c4fd11a5
--- /dev/null
+++ b/app/workers/container_registry/migration/observer_worker.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module ContainerRegistry
+ module Migration
+ class ObserverWorker
+ include ApplicationWorker
+ # This worker does not perform work scoped to a context
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ COUNT_BATCH_SIZE = 50000
+
+ data_consistency :sticky
+ feature_category :container_registry
+ urgency :low
+ deduplicate :until_executed, including_scheduled: true
+ idempotent!
+
+ def perform
+ return unless ::ContainerRegistry::Migration.enabled?
+
+ use_replica_if_available do
+ ContainerRepository::MIGRATION_STATES.each do |state|
+ relation = ContainerRepository.with_migration_state(state)
+ count = ::Gitlab::Database::BatchCount.batch_count(
+ relation, batch_size: COUNT_BATCH_SIZE
+ )
+ name = "#{state}_count".to_sym
+ log_extra_metadata_on_done(name, count)
+ end
+ end
+ end
+
+ private
+
+ def use_replica_if_available(&block)
+ ::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries(&block)
+ end
+ end
+ end
+end
diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb
index 49f0222e9c9..eaa8810a78e 100644
--- a/app/workers/expire_job_cache_worker.rb
+++ b/app/workers/expire_job_cache_worker.rb
@@ -10,8 +10,6 @@ class ExpireJobCacheWorker # rubocop:disable Scalability/IdempotentWorker
queue_namespace :pipeline_cache
urgency :high
-
- deduplicate :until_executing, including_scheduled: true
idempotent!
def perform(job_id)
diff --git a/app/workers/groups/update_statistics_worker.rb b/app/workers/groups/update_statistics_worker.rb
new file mode 100644
index 00000000000..40b9e883dbb
--- /dev/null
+++ b/app/workers/groups/update_statistics_worker.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+# Worker for updating group statistics.
+module Groups
+ class UpdateStatisticsWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ sidekiq_options retry: 3
+
+ worker_resource_boundary :cpu
+
+ feature_category :source_code_management
+ idempotent!
+ loggable_arguments 0, 1
+
+ # group_id - The ID of the group for which to flush the cache.
+ # statistics - An Array containing columns from NamespaceStatistics to
+ # refresh, if empty all columns will be refreshed
+ def perform(group_id, statistics = [])
+ group = Group.find_by_id(group_id)
+
+ return unless group
+
+ Groups::UpdateStatisticsService.new(group, statistics: statistics).execute
+ end
+ end
+end
diff --git a/app/workers/hashed_storage/migrator_worker.rb b/app/workers/hashed_storage/migrator_worker.rb
index 03019ae3131..5f90b8f1009 100644
--- a/app/workers/hashed_storage/migrator_worker.rb
+++ b/app/workers/hashed_storage/migrator_worker.rb
@@ -11,9 +11,6 @@ module HashedStorage
queue_namespace :hashed_storage
feature_category :source_code_management
- # https://gitlab.com/gitlab-org/gitlab/-/issues/340629
- tags :needs_own_queue
-
# @param [Integer] start initial ID of the batch
# @param [Integer] finish last ID of the batch
def perform(start, finish)
diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb
index 460aac3f2f2..01e2d6307de 100644
--- a/app/workers/hashed_storage/project_migrate_worker.rb
+++ b/app/workers/hashed_storage/project_migrate_worker.rb
@@ -11,9 +11,6 @@ module HashedStorage
queue_namespace :hashed_storage
loggable_arguments 1
- # https://gitlab.com/gitlab-org/gitlab/-/issues/340629
- tags :needs_own_queue
-
attr_reader :project_id
def perform(project_id, old_disk_path = nil)
diff --git a/app/workers/hashed_storage/project_rollback_worker.rb b/app/workers/hashed_storage/project_rollback_worker.rb
index 91ea3dd9189..2ec323248ab 100644
--- a/app/workers/hashed_storage/project_rollback_worker.rb
+++ b/app/workers/hashed_storage/project_rollback_worker.rb
@@ -11,9 +11,6 @@ module HashedStorage
queue_namespace :hashed_storage
loggable_arguments 1
- # https://gitlab.com/gitlab-org/gitlab/-/issues/340629
- tags :needs_own_queue
-
attr_reader :project_id
def perform(project_id, old_disk_path = nil)
diff --git a/app/workers/hashed_storage/rollbacker_worker.rb b/app/workers/hashed_storage/rollbacker_worker.rb
index d6a16b4d083..c6c4990d799 100644
--- a/app/workers/hashed_storage/rollbacker_worker.rb
+++ b/app/workers/hashed_storage/rollbacker_worker.rb
@@ -11,9 +11,6 @@ module HashedStorage
queue_namespace :hashed_storage
feature_category :source_code_management
- # https://gitlab.com/gitlab-org/gitlab/-/issues/340629
- tags :needs_own_queue
-
# @param [Integer] start initial ID of the batch
# @param [Integer] finish last ID of the batch
def perform(start, finish)
diff --git a/app/workers/loose_foreign_keys/cleanup_worker.rb b/app/workers/loose_foreign_keys/cleanup_worker.rb
index c3492fed77b..ecece92ec1b 100644
--- a/app/workers/loose_foreign_keys/cleanup_worker.rb
+++ b/app/workers/loose_foreign_keys/cleanup_worker.rb
@@ -12,8 +12,6 @@ module LooseForeignKeys
idempotent!
def perform
- return if Feature.disabled?(:loose_foreign_key_cleanup, default_enabled: :yaml)
-
in_lock(self.class.name.underscore, ttl: ModificationTracker::MAX_RUNTIME, retries: 0) do
stats = {}
diff --git a/app/workers/merge_requests/update_head_pipeline_worker.rb b/app/workers/merge_requests/update_head_pipeline_worker.rb
index c8dc9d1f7c8..acebf5fc767 100644
--- a/app/workers/merge_requests/update_head_pipeline_worker.rb
+++ b/app/workers/merge_requests/update_head_pipeline_worker.rb
@@ -2,7 +2,6 @@
module MergeRequests
class UpdateHeadPipelineWorker
- include ApplicationWorker
include Gitlab::EventStore::Subscriber
feature_category :code_review
diff --git a/app/workers/namespaces/process_sync_events_worker.rb b/app/workers/namespaces/process_sync_events_worker.rb
index f3c4f5bebb1..269710dd804 100644
--- a/app/workers/namespaces/process_sync_events_worker.rb
+++ b/app/workers/namespaces/process_sync_events_worker.rb
@@ -16,7 +16,13 @@ module Namespaces
deduplicate :until_executing
def perform
- ::Ci::ProcessSyncEventsService.new(::Namespaces::SyncEvent, ::Ci::NamespaceMirror).execute
+ results = ::Ci::ProcessSyncEventsService.new(
+ ::Namespaces::SyncEvent, ::Ci::NamespaceMirror
+ ).execute
+
+ results.each do |key, value|
+ log_extra_metadata_on_done(key, value)
+ end
end
end
end
diff --git a/app/workers/namespaces/update_root_statistics_worker.rb b/app/workers/namespaces/update_root_statistics_worker.rb
new file mode 100644
index 00000000000..9fdf8e2506b
--- /dev/null
+++ b/app/workers/namespaces/update_root_statistics_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class UpdateRootStatisticsWorker
+ include Gitlab::EventStore::Subscriber
+
+ data_consistency :always
+
+ idempotent!
+
+ feature_category :source_code_management
+
+ def handle_event(event)
+ ScheduleAggregationWorker.perform_async(event.data[:namespace_id])
+ end
+ end
+end
diff --git a/app/workers/pages_update_configuration_worker.rb b/app/workers/pages_update_configuration_worker.rb
deleted file mode 100644
index 9c58b40e098..00000000000
--- a/app/workers/pages_update_configuration_worker.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-# TODO: remove this in 14.7 https://gitlab.com/gitlab-org/gitlab/-/issues/348582
-class PagesUpdateConfigurationWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 1
-
- idempotent!
- feature_category :pages
-
- def perform(_project_id)
- # Do nothing
- end
-end
diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb
index ebda30f57d8..5a53d53ccf9 100644
--- a/app/workers/pipeline_schedule_worker.rb
+++ b/app/workers/pipeline_schedule_worker.rb
@@ -13,6 +13,8 @@ class PipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker
def perform
Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules|
schedules.each do |schedule|
+ next unless schedule.project
+
with_context(project: schedule.project, user: schedule.owner) do
Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule)
end
diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb
index 4dd9a9c6fcb..e3f8c4bcd9d 100644
--- a/app/workers/project_export_worker.rb
+++ b/app/workers/project_export_worker.rb
@@ -24,8 +24,15 @@ class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker
::Projects::ImportExport::ExportService.new(project, current_user, params).execute(after_export)
export_job&.finish
- rescue ActiveRecord::RecordNotFound, Gitlab::ImportExport::AfterExportStrategyBuilder::StrategyNotFoundError => e
- logger.error("Failed to export project #{project_id}: #{e.message}")
+ rescue ActiveRecord::RecordNotFound => e
+ log_failure(project_id, e)
+ rescue Gitlab::ImportExport::AfterExportStrategyBuilder::StrategyNotFoundError => e
+ log_failure(project_id, e)
+ export_job&.finish
+ rescue StandardError => e
+ log_failure(project_id, e)
+ export_job&.fail_op
+ raise
end
private
@@ -35,4 +42,8 @@ class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker
Gitlab::ImportExport::AfterExportStrategyBuilder.build!(strategy_klass, after_export_strategy)
end
+
+ def log_failure(project_id, ex)
+ logger.error("Failed to export project #{project_id}: #{ex.message}")
+ end
end
diff --git a/app/workers/projects/git_garbage_collect_worker.rb b/app/workers/projects/git_garbage_collect_worker.rb
index cf236f8b660..d16583975fc 100644
--- a/app/workers/projects/git_garbage_collect_worker.rb
+++ b/app/workers/projects/git_garbage_collect_worker.rb
@@ -16,7 +16,15 @@ module Projects
def before_gitaly_call(task, resource)
return unless gc?(task)
- ::Projects::GitDeduplicationService.new(resource).execute
+ # Don't block garbage collection if we can't fetch into an object pool
+ # due to some gRPC error because we don't want to accumulate cruft.
+ # See https://gitlab.com/gitlab-org/gitaly/-/issues/4022.
+ begin
+ ::Projects::GitDeduplicationService.new(resource).execute
+ rescue Gitlab::Git::CommandTimedOut, GRPC::Internal => e
+ Gitlab::ErrorTracking.track_exception(e)
+ end
+
cleanup_orphan_lfs_file_references(resource)
end
diff --git a/app/workers/projects/process_sync_events_worker.rb b/app/workers/projects/process_sync_events_worker.rb
index b7c4b4de3d0..1330ae47a68 100644
--- a/app/workers/projects/process_sync_events_worker.rb
+++ b/app/workers/projects/process_sync_events_worker.rb
@@ -16,7 +16,13 @@ module Projects
deduplicate :until_executing
def perform
- ::Ci::ProcessSyncEventsService.new(::Projects::SyncEvent, ::Ci::ProjectMirror).execute
+ results = ::Ci::ProcessSyncEventsService.new(
+ ::Projects::SyncEvent, ::Ci::ProjectMirror
+ ).execute
+
+ results.each do |key, value|
+ log_extra_metadata_on_done(key, value)
+ end
end
end
end
diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb
index f08d8231e43..35e3e633c70 100644
--- a/app/workers/run_pipeline_schedule_worker.rb
+++ b/app/workers/run_pipeline_schedule_worker.rb
@@ -15,7 +15,7 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker
schedule = Ci::PipelineSchedule.find_by_id(schedule_id)
user = User.find_by_id(user_id)
- return unless schedule && user
+ return unless schedule && schedule.project && user
run_pipeline_schedule(schedule, user)
end
diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb
index 952ac94d5e6..fdcd22128a3 100644
--- a/app/workers/web_hook_worker.rb
+++ b/app/workers/web_hook_worker.rb
@@ -6,14 +6,14 @@ class WebHookWorker
include ApplicationWorker
feature_category :integrations
- loggable_arguments 2
+ loggable_arguments 2, 3
data_consistency :delayed
sidekiq_options retry: 4, dead: false
urgency :low
worker_has_external_dependencies!
- # Webhook recursion detection properties are passed through the `data` arg.
+ # Webhook recursion detection properties may be passed through the `data` arg.
# This will be migrated to the `params` arg over the next few releases.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/347389.
def perform(hook_id, data, hook_name, params = {})
@@ -21,12 +21,14 @@ class WebHookWorker
return unless hook
data = data.with_indifferent_access
+ params.symbolize_keys!
- # Before executing the hook, reapply any recursion detection UUID that was
- # initially present in the request header so the hook can pass this same header
- # value in its request.
- recursion_detection_uuid = data.delete(:_gitlab_recursion_detection_request_uuid)
- Gitlab::WebHooks::RecursionDetection.set_request_uuid(recursion_detection_uuid)
+ # TODO: Remove in 14.9 https://gitlab.com/gitlab-org/gitlab/-/issues/347389
+ params[:recursion_detection_request_uuid] ||= data.delete(:_gitlab_recursion_detection_request_uuid)
+
+ # Before executing the hook, reapply any recursion detection UUID that was initially
+ # present in the request header so the hook can pass this same header value in its request.
+ Gitlab::WebHooks::RecursionDetection.set_request_uuid(params[:recursion_detection_request_uuid])
WebHookService.new(hook, data, hook_name, jid).execute
end